关于Doug的方法查找补丁笔记
加速方法查找
上周,Doug MacEachern提供了一个有趣且极具创意的补丁来加速方法调用。他的基准测试显示,它完全减少了使用方法的开销,并且似乎表明方法调用实际上可能比子程序调用更快。让我们详细看看这个补丁,看看它是如何做到的。
Sarathy最初的思考,这是所有这一切的起点
If @ISA isn't modified at run time, Foo::->bar() could be resolved
at compile time to a subroutine call, as can this:
my Foo $obj = shift;
$obj->bar();
正如所有关于Perl面向对象编程的教程正确指出的那样,当你调用一个方法时,你是在调用一个子程序。然而,区别在于方法调用必须执行查找以确定其子程序所在的包。
在没有继承的正常情况下,Foo->bar()
将是包Foo
中的子程序bar
。然而,由于继承,仅通过查看Foo->bar()
,你无法知道bar
在哪里 - 例如,Foo
可能从Frob
继承了bar
。
为了解决这个问题,当你调用一个方法时,Perl会在包Foo
中查找子程序bar
。如果没有找到,它会检查Foo
的父包:所有列在Foo
的@ISA
数组中的包。然后它会检查祖父母,依此类推。这个查找必须在运行时执行,而不是编译时,因为@ISA
是一个普通变量 - 某人可能在你脚下改变继承树。
Sarathy的想法是,如果你在执行编译时确信@ISA
中会包含什么,你就可以将昂贵的方法调用转换为稍微便宜一点的子程序调用。这正是Doug的补丁所做的事情。
我们可以将补丁分为两部分:函数method_to_entersub
执行转换并告诉我们如何
做到这一点,以及调用method_to_entersub
的代码部分,告诉我们何时
做到这一点。我们首先看看何时。
不过,在那样做之前,我们先了解一下子程序调用是如何在内部工作的。
Perl以类似的方式为方法调用和普通子程序调用构建entersub
操作符。区别在于对于子程序调用,entersub
操作符之前有一个操作符用于获取存储子程序代码的GV,而对于方法调用,entersub
操作符之前有一个操作符用于在继承树中查找方法名。你可以在以下B::Terse
输出中看到这一点。
% perl -MO=Terse,exec -e 'bar()'
...
SVOP (0xa043b00) gv GV (0xa04ecf8) *bar <- cvop
UNOP (0xa0b6a60) entersub [1]
...
然而,对于方法调用,Perl还会附加一个表示类的操作符(让我们称它为o2
,因为这是任何好名字),以及svop
,而不是GV,它成为一个特殊的操作符,表示应在该类中调用方法。
% perl -MO=Terse,exec -e 'Foo->bar()'
...
SVOP (0xa043b00) const PV (0xa0411f0) "Foo" <- o2
SVOP (0xa043b40) method_named PVIV (0xa04ecf8) "bar" <- cvop
UNOP (0xa0d0500) entersub [1]
...
对于类方法,o
是一个常量,而对于对象方法,o2
获取对象,使我们能够在运行时确定类。
% perl -MO=Terse,exec -e '$foo->bar()'
...
SVOP (0xa043b00) gvsv GV (0xa04ecf8) *foo <- o2
SVOP (0xa0d5720) method_named PVIV (0xa0411f0) "bar" <- cvop
UNOP (0xa0d04d0) entersub [1]
...
method_named
是表示命名字符串操作符的操作符。你还可以像这样动态调用方法:
Foo->$methname()
在这种情况下,method
操作符之前有一个操作符用于从$methname
获取方法名。在这个例子中,它是一个简单的变量查找。
% perl -MO=Terse,exec -e 'Foo->$x()'
...
SVOP (0xa043b00) const PV (0xa0411f0) "Foo" <- o2
SVOP (0xa043b60) gvsv GV (0xa04ed70) *x <- child of cvop
UNOP (0xa043b40) method <- cvop
UNOP (0xa0d04d0) entersub [1]
...
我们想要捕捉这两种方法风格的 cvop
:method_named
和 method
,并查看我们是否能在编译时执行方法查找。一旦我们知道将调用哪个子程序,我们就可以更改操作树,以便它直接调用子程序,这样在运行时实际调用方法时,Perl 就不必再搜索继承树了。
--- ./op.c.orig Fri Jun 2 15:49:32 2000
+++ ./op.c Thu Jun 15 22:45:33 2000
@@ -6243,6 +6309,12 @@
}
}
else if ( cvop->op_type == OP_METHOD
|| cvop->op_type == OP_METHOD_NAMED) {
+ if (o2->op_type == OP_CONST || o2->op_type == OP_PADSV) {
+ OP *nop;
+ if ((nop = method_to_entersub(aTHX_ o2, cvop))) {
+ return nop;
+ }
+ }
if (o2->op_type == OP_CONST) {
o2->op_private &= ~OPpCONST_STRICT;
}
如果你不习惯阅读补丁,前三条线告诉你我们在哪里:补丁应用前 op.c 中的第 6243 行,应用补丁后变为第 6309 行的 13 行。以加号开头的行是要添加的。
第 6243 行位于函数 Perl_ck_subr
的中间,该函数是在 entersub
操作码被提交时调用的检查例程。检查例程接收一个操作码并产生一个“清理”版本,执行任何优化或消除冗余。Perl 将编译阶段构建的操作树中的每个操作码替换为这些优化版本。
如上所述,我们此时将处于三种情况之一:普通子程序,它将在我们的 entersub
操作码上附加一个 GV 操作码;命名方法,它附加有 method_named
;或无命名方法,它附加有 method
。我们关注当 cvop
是 method
或 method_named
操作码的情况。
else if ( cvop->op_type == OP_METHOD
|| cvop->op_type == OP_METHOD_NAMED) {
我们正在修复类方法,这意味着当 o2
是一个常量时,我们尝试进行转换。我们还想修复 my Dog $sam; $sam->bark;
,在这种情况下,o2
,即对象,是一个词法变量:(在内部术语中,是一个 pad sv。)
if (o2->op_type == OP_CONST || o2->op_type == OP_PADSV) {
在这些情况下,我们尝试将方法转换为子程序,如果成功则返回新的方法
OP *nop;
if ((nop = method_to_entersub(aTHX_ o2, cvop))) {
return nop;
}
最后的修饰是原始源代码的一部分:在 Foo->bar
中,Foo
明显是一个类名,在 strict
检查裸词时不应触发警告。因此,我们从类名常量中删除了严格的测试。
if (o2->op_type == OP_CONST) {
o2->op_private &= ~OPpCONST_STRICT;
}
那就是“何时”。现在让我们看看“如何”。
如何转换方法
在检查例程内部,变量 svop
保存了指定 Perl 如何定位实际调用的子程序的 method
或 method_named
节点;o
是在执行此节点之前执行的节点,它获取类名或包含将被调用方法的对象的值的 GV。例如
Foo->bar():
SVOP (0xa043b00) const PV (0xa0411f0) "Foo" <- o
SVOP (0xa043b40) method_named PVIV (0xa04ecf8) "bar" <- svop
UNOP (0xa0d0500) entersub [1]
...
$foo->bar():
SVOP (0xa043b00) gvsv GV (0xa04ecf8) *foo <- o
SVOP (0xa0d5720) method_named PVIV (0xa0411f0) "bar" <- svop
UNOP (0xa0d04d0) entersub [1]
还有一个 method
变量,它保存了包含在 o
中的 SV。我们的函数首先从中获取方法名称
if (svop->op_type == OP_METHOD_NAMED) {
methname = SvPV(method, methlen);
}
else {
return Nullop;
}
如果不是命名方法(即,如果我们有类似 $foo->$bar()
的东西),我们无法对其进行任何操作。我们返回假值 Nullop
,这是一个空指针被转换为操作码,向调用例程发出信号,表示我们无法修改程序。
if (cvop->op_type == OP_METHOD_NAMED &&
o2->op_type == OP_CONST || o2->op_type == OP_PADSV) {
现在,我们正在修复两件事:当 o
是常量时的类方法调用,以及 my Dog $sam
情况,其中 o
是一个 pad SV。
在这个函数内部,我们有三个任务
找到存储库
存储库是一个包符号表,例如 Perl 空间中的 %Foo::
。我们需要找到我们的方法所属的类,并从中提取符号表。
提取GV条目
我们正在尝试创建一个 entersub
操作码,例如调用 &Foo::bar
。我们已经有了 %Foo::
(如上所述):下一个任务是找到 *Foo::bar
。在此阶段,我们想要处理继承。
重写entersub
最后,我们调整操作树,将 entersub
的方法形式转换为仅调用我们刚刚找到的 GV 的子程序调用。
在这个过程中,我们还需要注意 Perl 缓存方法查找的事实。
查找stash
我们正在尝试找到GV *Class::Method
,以便将其传递给entersub
。因此,我们首先需要做的事情是找到该类的包符号表,或者说是“stash”。目前有两种可能的情况,这决定了我们如何找到stash:要么我们有一个常量类,要么对象是词法。
如果我们有一个常量类,找到stash很简单:隐藏在SvOP o
中的SV的PV告诉我们包名,而gv_stashpvn
根据PV返回stash。
if (o->op_type == OP_CONST) {
STRLEN len;
char *package = SvPV(((SVOP*)o)->op_sv, len);
stash = gv_stashpvn(package, len, FALSE);
}
不要害怕第3行中的可怕类型转换:我们只是在从const
op获取字符串值。
如果我们有一个词法(pad sv)对象,例如my Dog $sam
,生活就稍微有趣一些。
else if (o->op_type == OP_PADSV) {
我们从当前的pad中获取实际的SV - pad是Perl保持当前块中词法变量的地方,它只是一个普通的数组。
SV *sv = *av_fetch(PL_comppad_name, o->op_targ, FALSE);
我们希望这个SV存在,并且是对象。如果是对象,SvSTASH
将给我们stash,因为Perl在我们使用bless
创建对象时存储了stash的指针。
if (sv && SvOBJECT(sv)) {
stash = SvSTASH(sv);
}
否则,我们无法做太多。
else {
return Nullop;
}
}
这只有两种可能的情况 - 词法和常量 - 但计算机编程有时会把不可能变成可能。Doug坚定地对抗着逻辑的恶魔。
else {
return Nullop;
}
提取GV条目
如果我们有一个stash,我们现在可以尝试找到GV。幸运的是,在gv.c中有一个函数可以做到这一点,并且还会为我们处理继承。
if (!(stash && (gv = gv_fetchmeth(stash, methname, methlen, 0))
&& isGV(gv))) {
return Nullop;
}
如果gv_fetchmeth
失败,或者由于某种原因返回的不是gv,那么我们无法找到实际包含该方法的包,所以我们放弃并返回Nullop
。否则,gv
指向包含我们想要entersub
调用的子例程的GV。或者,它将在首次查找方法时这样做。然而,如果我们的方法是继承的并由另一个stash提供,gv_fetchmeth
将在对象的stash中提供一个指向真实GV的别名。
为了获取真实子例程的代码,我们必须从这个别名中获取原始GV;我们使用GvCV
宏来做到这一点。如果从GvCV
获取的GV与我们的方法属于不同的类,那么我们有一个别名。我们需要entersub
指向原始GV,而不是这个别名。
if (GvSTASH(CvGV(GvCV(gv))) != stash) {
gv = CvGV(GvCV(gv)); /* point to the real gv */
}
在注释中,Doug指出,只有当继承在运行时不改变时,这个缓存的查找才有效。目前还没有方法可以在运行时冻结@ISA
,(嘿,这是一个工作!),所以Doug建议作为替代方案,如果有一个缓存的查找,就简单地返回Nullop
来终止。
重写entersub
最后,我们有了足够的信息来重写entersub
op:我们有一个GV,它包含一个CV,我们现在可以直接调用它。让我们安排一下如何做到这一点。
首先,像以前一样从方法名中删除strict
测试。
o->op_private &= ~(OPpCONST_BARE|OPpCONST_STRICT);
现在找到op_method_named
op,并将其更改为创建子程序调用的GV。
for (mop = o; mop->op_sibling->op_sibling; mop = mop->op_sibling) ;
op_free(mop->op_sibling); /* loose OP_METHOD{,_NAMED} */
mop->op_sibling = scalar(newUNOP(OP_RV2CV, 0,
newGVOP(OP_GV, 0, gv)));
成功地将转换后的op返回。
nop = convert(OP_ENTERSUB, OPf_STACKED, o);
return nop;
结论
我们从这个补丁中获得了很多收益;我们看到了Perl如何查找和调用方法,并展示了Doug的补丁如何将方法转换为子例程。这几乎可以完全消除使用面向对象Perl的开销,但有两个问题。
练习
Doug的补丁需要一种在编译时验证继承树的方法;显然的方法是冻结
@ISA
。写一个pragma来做到这一点。作为加分项和本周神祇的地位,找到一种在编译时验证方法缓存的方法,同时保持
@ISA
变量。此功能仅适用于命名方法;找到一种方法使其适用于从变量或其他方式获取的方法。也就是说,使
Class->$thing()
可转换为子程序。
标签
反馈
这篇文章有什么问题吗?请在 GitHub 上打开一个问题或拉取请求以帮助我们。