A camel in blue surgical scrubs, cap, mask and gloves, holding forceps over an operating table in a hospital theatre

Ex::Op: A drop-in replacement for Exporter

If you have written a Perl module of small helper functions, you have almost certainly exported them with Exporter, and the people using it have almost certainly called them in a loop. Both of those cost more than they need to.

Exporter is pure Perl, and it does its work over again on every use. A call to a sub pushes a call frame and sets up @_ as aliases to the arguments, and then the sub's first line, my ($x, $y) = @_, copies them straight back out. For a helper whose whole body is $x + $y, that is most of the time the call takes.

Ex::Op is written in XS and takes on both. It is a drop-in replacement for Exporter, and it gives your module an op keyword: declare a helper with op instead of sub, and calls to it skip the frame and @_ altogether.

package My::Util;
use strict;
use warnings;
use Ex::Op;

our @EXPORT_OK   = qw(add clamp name);
our %EXPORT_TAGS = (math => [qw(add clamp)]);

op add ($x, $y) { $x + $y }

op clamp ($v, $lo, $hi) {
    return $lo if $v < $lo;
    return $hi if $v > $hi;
    $v;
}

op name ($self) { $self->{name} }

1;

And the code that uses it does not know anything changed:

use My::Util qw(:math name);

say add(2, 3);                    # 5
say clamp(15, 0, 10);             # 10
say name({ name => 'camel' });    # camel

A drop-in Exporter

use Ex::Op gives the package an import, the same one use Exporter 'import' would. It reads the same @EXPORT, @EXPORT_OK and %EXPORT_TAGS, and it does everything Exporter does: sigils, tags, :DEFAULT, ! negation, /pattern/, version checks, @EXPORT_FAIL, export_to_level, export_tags, require_version. It even fails the same way:

"sub_that_does_not_exist" is not exported by the My::Util module
Can't continue after import errors at script.pl line 3.

"Drop-in" is a claim, so it is tested as one. Exporter's own test suite runs against it unchanged, and a generated sweep imports 111,151 different lists from the same package twice, once through Exporter and once through Ex::Op, and compares every glob that lands, every warning and every error. They match, down to Exporter quietly stripping a leading & from your @EXPORT in place. The only lists that differ are 2,544 where Exporter's own answer changes with the hash seed. Ex::Op answers those the same way every time.

It is also faster. Exporter builds a "My::Util::clamp" string for every name, twice, and perl splits each string again to find the glob. Ex::Op looks the two packages up once for the whole import and finds each name straight in their symbol tables. On perl 5.42:

import Ex::Op against Exporter
ten names 4.4x
@EXPORT 3.3x
a tag 2.5x
a pattern 1.5x

Moving over is one word: use Exporter 'import' becomes use Ex::Op 'import', or just use Ex::Op.

The op keyword

op add ($x, $y) { $x + $y } is the same sub as sub add { my ($x, $y) = @_; $x + $y }. You can take a reference to it, call it with &add(...), put it in @EXPORT_OK. The difference is in every call to it that perl compiles afterwards. That call stops being an entersub and becomes a custom op of its own:

d      <#> gv[*add] s
e      <1> exop_call[t2] sKS/2

That op does less than entersub would. There is no call frame and no @_. The arguments are copied straight into $x and $y, and the body starts at its second statement. What a frame would have put back afterwards is saved in one step, and a plain result is written into a value kept at the call site, so a call makes no new value to return. The body itself runs exactly as it would in a sub, so the saving is the call, and the smaller the body, the more of the time that is. On perl 5.42, against a plain sub with the same body:

op faster by
add 1.59x
is_even 1.40x
the accessor name($self) 1.39x
clamp, with its two returns 1.35x
a six-parameter sum 1.35x

And because the rewrite is keyed on the sub itself, not on its name, an op you export stays an op in every file that imports it, by name, by tag or by pattern.

What cannot be an op

Some bodies need the frame an op does not have: anything that reads @_ after the parameters, calls wantarray or caller, uses goto or a string eval, returns from inside a loop, or makes a closure over its own variables. Ex::Op checks each body when it is compiled, and one it cannot run stays an ordinary sub. It still works, it is just not faster, and why_not tells you why:

op ctx ($x) { wantarray ? 'list' : 'scalar' }
say Ex::Op::why_not(\&ctx);    # it calls wantarray

If you would rather be told at compile time, say use Ex::Op 'strict':

ctx cannot be an op: it calls wantarray at script.pl line 4.

Two things to know. An op has no frame, so a stack trace from inside one names the op where its caller would be. And a call to an op made outside every sub, at the top level of a script, is an ordinary call. The speed is for calls from inside subs, which is where the hot loops are anyway.

Older perls

The exporter and the faster calls work back to perl 5.10. The op keyword needs 5.14. Before that, Ex::Op::make makes the same op from a sub:

BEGIN { Ex::Op::make(add => sub { my ($x, $y) = @_; $x + $y }) }

Getting it

cpanm Ex::Op

All writing