前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >PHP 中的操作符重载

PHP 中的操作符重载

作者头像
桶哥
发布2019-06-04 18:31:23
发布2019-06-04 18:31:23
1.5K00
代码可运行
举报
文章被收录于专栏:PHP饭米粒PHP饭米粒
运行总次数:0
代码可运行

1. 操作符重载

操作符重载是一种语法糖,它在 C++、Python、Kotlin 等编程语言中被广泛使用。这一特性有助于我们写出更加整洁、表述力更强的代码,尤其是当我们对某些对象进行数学操作时。

例如,当我们在 PHP 中使用一个 Complex 类,我们往往更希望这样写:

代码语言:javascript
代码运行次数:0
运行
复制
$a = new Complex(1.1, 2.2);
$b = new Complex(1.2, 2.3);

$c = $a * $b / ($a + $b);

而不是这样:

代码语言:javascript
代码运行次数:0
运行
复制
$c = $a->mul($b)->div($a->add($b));

尽管这个 RFC 提出了要在 PHP 中实现这一特性,然而截至目前,这一提议并未被实施。幸运的是,我们可以通过在 PHP 扩展中编写一些简单的逻辑来实现操作符重载,而无需修改 PHP 本身的源码。PECL operator 扩展做的就是这样一件事情(注意,该扩展的发布版本比较旧,想要 PHP7 支持需要看 git master 分支)。

本文中,我们将讨论在一个 PHP 扩展中实现操作符重载的相关细节。我们假定读者具备 C/C++ 的编程语言基础,并且对 PHP 的 Zend 实现有初步的了解。

2. PHP 的操作码

在一个 PHP 脚本可以在 Zend VM 中运行之前,它首先会被编译为一系列操作码。与机器码类似,一个 PHP 操作码包含指令、操作数等,其存储在结构体 zend_op 中。

代码语言:javascript
代码运行次数:0
运行
复制
struct _zend_op {
    const void *handler;      // 操作码处理函数的指针
    znode_op op1;             // 第一个操作数
    znode_op op2;             // 第二个操作数
    znode_op result;          // 执行结果
    uint32_t extended_value;  // 与该操作码相关的额外信息
    uint32_t lineno;          // 操作码所在行数
    zend_uchar opcode;        // 操作码指令
    zend_uchar op1_type;      // 第一个操作数的类型
    zend_uchar op2_type;      // 第二个操作数的类型
    zend_uchar result_type;   // 执行结果的类型
};

2.1 操作数

操作数之于操作码,如同参数之于函数。结构体 zend_op 的操作数成员存储了其所指向的对象的偏移量或指针,在 znode_op 中被定义。由于操作数有多种不同类型(我们后面会讨论),因此用一个联合体定义。

代码语言:javascript
代码运行次数:0
运行
复制
typedef union _znode_op {
    uint32_t      constant;
    uint32_t      var;
    uint32_t      num;
    uint32_t      opline_num;
#if ZEND_USE_ABS_JMP_ADDR
    zend_op      *jmp_addr;
#else
    uint32_t      jmp_offset;
#endif
#if ZEND_USE_ABS_CONST_ADDR
    zval         *zv;
#endif
} znode_op;

正如 zend_compile.h 中所述:

On 64-bit systems, less optimal but more compact VM code leads to better performance. So on 32-bit systems we use absolute addresses for jump targets and constants, but on 64-bit systems relative 32-bit offsets.

在 64 位系统中,宏 ZEND_USE_ABS_JMP_ADDRZEND_USE_ABS_CONST_ADDR 被定义为 0, 因此 znode_op 永远是 32 位大小。

2.2 操作指令

指令码用于指示 Zend VM 应该对操作数进行什么样的操作。在 zend_vm_opcodes.h 中可以看到所有的指令码定义。

PHP 源码中的操作符会被编译为对应的指令码。借助 phpdbg 或类似调试工具,我们可以分析编译后的操作码。如,PHP 代码 $c = $a + $b 会被编译为:

代码语言:javascript
代码运行次数:0
运行
复制
ADD    $a, $b, ~0  # "+" 操作符
ASSIGN $c, ~0      # "=" 操作符

可以看到,+ 操作符对应指令 ZEND_ADD$a$b 是操作码的两个操作数。操作结果被存储在临时变量 ~0 中,并在下一行的赋值指令中被赋值给 $c

然而,并非所有操作符都有对应的指令码。如代码 $c = $a > -$b 会被编译为:

代码语言:javascript
代码运行次数:0
运行
复制
MUL        $b, -1, ~0  # 转换为乘法操作,乘以 -1
IS_SMALLER ~0, $a, ~1  # 调换操作符位置,并转换为小于比较
ASSIGN     $c, ~1

在之后的章节,我们会对这种情况进行进一步说明。

2.3 操作数类型

结构体 zend_opop1_typeop2_typeresult_type 成员分别存储了第一个操作数、第二个操作数和执行结果的操作数类型。其可能的值如下:

代码语言:javascript
代码运行次数:0
运行
复制
#define IS_UNUSED   0
#define IS_CONST    (1<<0)
#define IS_TMP_VAR  (1<<1)
#define IS_VAR      (1<<2)
#define IS_CV       (1<<3) // Compiled variable
  • 如果操作数不被使用,则其类型为 IS_UNUSED.
  • 如果操作数是一个字面量 , 则其类型为 IS_CONST.
  • 如果操作数是一个由表达式返回的临时变量 , 则其类型为 IS_TMP_VAR.
  • 如果操作数是一个在编译期被确定的变量,则其类型为 IS_CV.
  • 如果操作数是一个由表达式返回的在编译期被确定的变量,则其类型为 IS_VAR.

通过使用调试工具,可以有助于我们理解操作数的类型。如以下 PHP 代码:

代码语言:javascript
代码运行次数:0
运行
复制
$a = 1;
$a + 1;
$b = $a + 1;
$a += 1;
$c = $b = $a += 1;

会被编译为:

代码语言:javascript
代码运行次数:0
运行
复制
                       # (op1     op2     result) type
ASSIGN     $a, 1       #  CV      CONST   UNUSED
ADD        $a, 1,  ~1  #  CV      CONST   TMP_VAR
FREE       ~1          #  TMP_VAR UNUSED  UNUSED
ADD        $a, 1,  ~2  #  CV      CONST   TMP_VAR
ASSIGN     $b, ~2      #  CV      TMP_VAR UNUSED
ASSIGN_ADD $a, 1       #  CV      CONST   UNUSED
ASSIGN_ADD $a, 1,  @5  #  CV      CONST   VAR
ASSIGN     $b, @5, @6  #  CV      VAR     VAR
ASSIGN     $c, @6      #  CV      VAR     UNUSED

可以看出,编译期确定的变量 $a$bIS_CV,字面量 1IS_CONST,表达式产生的临时变量 ~1~2TMP_VAR@5@6 虽然对应 $a$b,但它们是由表达式返回的,因此是 IS_VAR

同时,我们也发现,对于赋值指令,若其执行结果未被使用,则不会返回结果,而非赋值指令永远会返回结果,即使其未被使用。这是因为赋值指令的运算结果会被赋值给第一个操作数,当其未被使用时,不需要额外的指令去释放内存。在后面的章节我们会进一步讨论这一细节。

3. 操作码处理函数

操作码处理函数的职能是根据给定的指令和操作数执行对应的操作,就像 CPU 执行机器码一样。通过调用如下的 Zend API,我们可以用自定义的函数来替代 Zend VM 内置的操作码处理函数:

代码语言:javascript
代码运行次数:0
运行
复制
ZEND_API int zend_set_user_opcode_handler(
    zend_uchar            opcode,
    user_opcode_handler_t handler);

其中 handler 参数是自定义的操作码处理函数的指针,opcode 参数是我们想要替代的指令。想要取消设定自定义操作码处理函数,向 handler 参数传递 nullptr 即可。每当操作码被执行时,Zend VM 会调用与其指令码相对应的自定义函数(如果它存在)。

函数指针 user_opcode_handler_t 定义如下:

代码语言:javascript
代码运行次数:0
运行
复制
typedef int (*user_opcode_handler_t) (zend_execute_data *execute_data);

操作码处理函数接受 execute_data 指针作为参数,并返回一个整型,其值为下述之一,代表该函数执行完成后进行的下一步操作。

代码语言:javascript
代码运行次数:0
运行
复制
#define ZEND_USER_OPCODE_CONTINUE   0
#define ZEND_USER_OPCODE_RETURN     1
#define ZEND_USER_OPCODE_DISPATCH   2
#define ZEND_USER_OPCODE_ENTER      3
#define ZEND_USER_OPCODE_LEAVE      4

在多数情况下,我们只会用到如下所描述的其中两个返回值:

  • ZEND_USER_OPCODE_CONTINUE 表示该操作码已经执行完成,应该继续执行下一行指令。
  • ZEND_USER_OPCODE_DISPATCH 表示该操作码并没有被执行,应先转为使用内置操作码处理函数去执行,再执行下一行指令。

3.1 实现操作码处理函数

我们用 C++ 定义一个普适性的操作码处理函数模版,如下所示。其中,handler 参数包含处理操作码的具体业务逻辑,它可以为一个函数指针、lambda 表达式或仿函数,接受三个 zval 指针作为参数,分别为两个操作数和执行结果。

代码语言:javascript
代码运行次数:0
运行
复制
template <typename F>
int op_handler(zend_execute_data *execute_data, F handler)
{
    // 在这里做一些初始化操作
    if (!handler(op1, op2, result)) {
        return ZEND_USER_OPCODE_DISPATCH;
    }
    // 在这里做一些后续操作
    return ZEND_USER_OPCODE_CONTINUE;
}

在函数的开始,我们先进行一些初始化操作。首先,从 execute_data 中获取到当前执行的操作码,并从操作码中获取到各个操作数所对应的 zval

代码语言:javascript
代码运行次数:0
运行
复制
const zend_op *opline = EX(opline);
zend_free_op free_op1, free_op2;
zval *op1 = zend_get_zval_ptr(opline, opline->op1_type, &opline->op1,
                              execute_data, &free_op1, 0);
zval *op2 = zend_get_zval_ptr(opline, opline->op2_type, &opline->op2,
                              execute_data, &free_op2, 0);
zval *result = opline->result_type ? EX_VAR(opline->result.var) : nullptr;

操作数可能是指向其他 zval 的引用,即 zend_reference。我们往往需要先对其解引用。

代码语言:javascript
代码运行次数:0
运行
复制
if (EXPECTED(op1)) {
    ZVAL_DEREF(op1);
}
if (op2) {
    ZVAL_DEREF(op2);
}

现在,我们可以像之前所描述的那样调用 handler

若操作数是临时变量,当操作码处理函数执行完成后,我们需要先释放它们。最后,将 execute_data->opline 指向下一行操作码。

代码语言:javascript
代码运行次数:0
运行
复制
if (free_op2) {
    zval_ptr_dtor_nogc(free_op2);
}
if (free_op1) {
    zval_ptr_dtor_nogc(free_op1);
}
EX(opline) = opline + 1;

现在,我们就可以根据需要,注册自定义的操作码处理函数。

代码语言:javascript
代码运行次数:0
运行
复制
int add_handler(zend_execute_data *execute_data)
{
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (/* 是否要在这里重载 "+" 操作符?*/) {
            // 重载的具体实现
            return true;
        }
        return false;
    });
}

PHP_MINIT_FUNCTION(my_extension)
{
    // 一般情况下,我们在扩展被载入时注册自定义操作码处理函数
    zend_set_user_opcode_handler(ZEND_ADD, add_handler);
}

4. 操作符重载的实现细节

我们现已知道,通过自定义的操作码处理函数,可以实现操作符重载。下面我们将讨论一些实现细节,从而帮助大家减少在开发过程中的踩坑。

4.1 二元操作符

语法

指令码

$a + $b

ZEND_ADD

$a - $b

ZEND_SUB

$a * $b

ZEND_MUL

$a / $b

ZEND_DIV

$a % $b

ZEND_MOD

$a ** $b

ZEND_POW

$a << $b

ZEND_SL

$a >> $b

ZEND_SR

$a . $b

ZEND_CONCAT

$a

$b

ZEND_BW_OR

$a & $b

ZEND_BW_AND

$a ^ $b

ZEND_BW_XOR

$a === $b

ZEND_IS_IDENTICAL

$a !== $b

ZEND_IS_NOT_IDENTICAL

$a == $b

ZEND_IS_EQUAL

$a != $b

ZEND_IS_NOT_EQUAL

$a < $b

ZEND_IS_SMALLER

$a <= $b

ZEND_IS_SMALLER_OR_EQUAL

$a xor $b

ZEND_BOOL_XOR

$a <=> $b

ZEND_SPACESHIP

二元操作符接受两个操作数,永远有返回值,而且允许修改操作数(当然如果尝试修改字面量或临时变量,是毫无意义的)。

注意,正如我们在 2.2 中所述,>>= 操作符是没有对应的指令码的。尽管在绝大多数情况下 $a > $b$b < $a 是完全等价的,但也有例外,如 PECL operator 扩展,需要区分这两个操作符,并调用 __is_smaller()__is_greater() 这两个魔术方法之一。

PECL operator 扩展提出了一种方法,即利用 zend_opextended_value 成员区分 ><。但这个 hack 是在解析语法树时做的,没有提供 API 可供我们用自定义方法去替换,需要修改 PHP 的源码并重新编译 PHP。此外,这个做法很可能会影响其在未来 PHP 版本中的兼容性。

这种情况下,建议采用类似如下所示的解决方案:

代码语言:javascript
代码运行次数:0
运行
复制
int is_smaller_handler(zend_execute_data *execute_data) {
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (Z_TYPE_P(zv1) == IS_OBJECT) {
            if (__zobj_has_method(Z_OBJ_P(zv1), "__is_smaller")) {
                // 在这里调用 `$zv1->__is_smaller($zv2)`.
                return true;
            }
        } else if (Z_TYPE_P(zv2) == IS_OBJECT) {
            if (__zobj_has_method(Z_OBJ_P(zv2), "__is_greater")) {
                // 在这里调用 `$zv2->__is_greater($zv1)`.
                return true;
            }
        }
        return false;
    });
}

4.2 二元赋值操作符

语法

指令码

$a += $b

ZEND_ASSIGN_ADD

$a -= $b

ZEND_ASSIGN_SUB

$a *= $b

ZEND_ASSIGN_MUL

$a /= $b

ZEND_ASSIGN_DIV

$a %= $b

ZEND_ASSIGN_MOD

$a **= $b

ZEND_ASSIGN_POW

$a <<= $b

ZEND_ASSIGN_SL

$a >>= $b

ZEND_ASSIGN_SR

$a .= $b

ZEND_ASSIGN_CONCAT

$a

= $b

ZEND_ASSIGN_BW_OR

$a &= $b

ZEND_ASSIGN_BW_AND

$a ^= $b

ZEND_ASSIGN_BW_XOR

$a = $b

ZEND_ASSIGN

$a =& $b

ZEND_ASSIGN_REF

二元赋值操作符与一般的二元操作符类似,区别在于当返回值不被使用(opline->result_type == IS_UNUSED)的时候,不要在操作码处理函数中对其赋值,否则可能会引起错误。

一般来说,二元赋值操作符对应的操作码执行完成后,要将执行结果赋值给第一个操作数。但这并不是必须的,而且 Zend VM 不会帮我们做这件事。

代码示例:

代码语言:javascript
代码运行次数:0
运行
复制
int assign_add_handler(zend_execute_data *execute_data) {
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (Z_TYPE_P(zv1) == IS_OBJECT) {
            // 在这里处理 "+" 操作符
            __update_value(zv1, add_result);
            if (rv != nullptr) {
                ZVAL_COPY(rv, zv1);
            }
            return true;
        }
        return false;
    });
}

4.3 一元操作符

代码语言:javascript
代码运行次数:0
运行
复制
ZEND_BW_NOT

一元操作符仅接受一个操作数(opline->op1),永远有返回值,而且允许修改操作数。

正如我们在 2.2 所述,一元操作符 -$a+$a 没有对应的指令码,因为它们被编译为操作数与 -1 and 1的乘法。如果在我们想要实现的逻辑中,-$a$a * (-1) 不等价,则需要在 ZEND_MUL 的处理函数中加入一些额外的逻辑。

注意,在 PHP 7.3 和低于 7.3 的版本之间,存在如下的兼容性问题,即 $a * (-1)(-1) * $a 的区别:

PHP 版本

语法

指令码

操作数 1

操作数 2

7.3

-$a or +$a

ZEND_MUL

$a

-1 or 1

7.1, 7.2

-$a or +$a

ZEND_MUL

-1 or 1

$a

如下是在 ZEND_MUL 处理函数中同时实现重载 -$a$a * $b 两个操作符的例子:

代码语言:javascript
代码运行次数:0
运行
复制
int mul_handler(zend_execute_data *execute_data) {
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (Z_TYPE_P(zv1) == IS_OBJECT) {
#if PHP_VERISON_ID >= 70300
            if (Z_TYPE_P(zv2) == IS_LONG && Z_LVAL_P(zv2) == -1) {
                // 在这里处理 `-$zv1`
                return true;
            }
#endif
            // 在这里处理 `$zv1 * $zv2`
            return true;
        } else if (Z_TYPE_P(zv2) == IS_OBJECT) {
#if PHP_VERISON_ID < 70300
            if (Z_TYPE_P(zv1) == IS_LONG && Z_LVAL_P(zv1) == -1) {
                // 在这里处理 `-$zv2`
                return true;
            }
#endif
            // 在这里处理 `$zv1 * $zv2`
            return true;
        }
        return false;
    });
}

4.4 一元赋值操作符

代码语言:javascript
代码运行次数:0
运行
复制
ZEND_PRE_INC

一元赋值操作符有两种。第一种是后缀自增 / 自减操作符,其行为与非赋值的一元操作符相同。第二种是前缀自增 / 自减操作符,它与二元赋值操作符的行为相同。

这不难理解,因为在常规的使用场景下,后缀自增 / 自减操作符需要将自己的初始值保存在一个临时变量中返回,而前缀自增 / 自减操作符先执行自增 / 自减操作再返回,无需释放临时变量。

例如,以下 PHP 代码:

代码语言:javascript
代码运行次数:0
运行
复制
$a = 0;
$a++;
++$a;
$b = ++$a;

会被编译为:

代码语言:javascript
代码运行次数:0
运行
复制
ASSIGN   $a, 0
POST_INC $a,   , ~1
FREE     ~1
PRE_INC  $a
PRE_INC  $a,   , @3
ASSIGN   $b, @3

4.5 无法重载操作符的情况

尝试编译以下代码:

代码语言:javascript
代码运行次数:0
运行
复制
$a = 2 + 3 * (7 + 9);
$b = 'foo' . 'bar';

我们会得到:

代码语言:javascript
代码运行次数:0
运行
复制
ASSIGN $a, 50
ASSIGN $b, "foobar"

可以看出,变量 $a$b 的值在编译期已被确定,运行时没有数学运算和字符串拼接操作。对于任何一个只包含字面量和操作符的表达式,这种情况都是成立的。编译器会识别出它,并调用 zend_compile.h 中定义的函数 zend_const_expr_to_zval() 对其进行求值。在这个函数中,操作码处理函数是通过 get_binary_op()get_unary_op() 等函数获取的。内置操作码处理函数的指针被硬编码在其中,因此,即使我们实现了自定义处理函数,它们也不会在这里被调用。

5. 补充

  • 如果读者需要一个完整可运行的例子,可以参考下面这个复数类的实现。它是我正在开发的一个 PHP 扩展的一部分。
    • complex.hh,包含了和复数类相关的操作码处理函数的具体实现。
    • complex.cc,复数类的实现。
    • operators.cc,包含操作符重载的实现。
    • 002-complex-operators.phpt,有关操作符重载的测试样例。
  • 可自定义的操作码处理函数是一个强大的功能,它的用途远远不限于操作符重载。因为我们可以 hook 几乎所有在 Zend VM 中执行的指令,包括函数调用等。
    • 假设我们想要实现一个 profiler,我们可能会考虑对 ZEND_INIT_FCALLZEND_RETURN 注册处理函数。
  • 事物均有两面性。由于额外的函数调用开销,使用自定义的操作码处理函数会降低 PHP 程序整体的执行性能。
    • 当一个处理函数中包含了大量分支判断,最后还很可能返回一个 ZEND_USER_OPCODE_DISPATCH 时,你可能需要考虑一下,这个函数是否有实现的必要。

----------伟大的分割线-----------

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-04-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 PHP饭米粒 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 操作符重载
  • 2. PHP 的操作码
    • 2.1 操作数
    • 2.2 操作指令
    • 2.3 操作数类型
  • 3. 操作码处理函数
    • 3.1 实现操作码处理函数
  • 4. 操作符重载的实现细节
    • 4.1 二元操作符
    • 4.2 二元赋值操作符
    • 4.3 一元操作符
    • 4.4 一元赋值操作符
    • 4.5 无法重载操作符的情况
  • 5. 补充
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档