在SimpleXML拓展的使用中,发现了一个奇怪的小细节,最简化的代码如下:
$xmlStr=simplexml_load_string('<class><teacher>张三</teacher></class>');
var_dump($xmlStr->student);//object(SimpleXMLElement)#2 (0) { }
$haveStudent=(empty($xmlStr->student));
var_dump($haveStudent);//true
初看这非常合理,但是回想一下php官方文档empty()的两段说明:
empty() 本质上与 !isset($var) || $var == false 等价
以下的东西被认为是empty的:
- (空字符串)
- 0 (作为整数的0)
- 0.0 (作为浮点数的0)
- "0" (作为字符串的0)
- NULL
- FALSE
- array() (一个空数组)
- $var; (一个声明了,但是没有值的变量)
咦?一般非null对象转bool结果都是true,这个情况也不在enpty指定的列表中,为何该SimpleXMLElement节点empty()结果为true?这难道是什么特殊的BUG?
Zend BUG report https://bugs.php.net/bug.php?id=30972&edit=1
PHP文档:Bool的转换 http://php.net/manual/zh/language.types.boolean.php
根据上面两个文档可以看出,其实我已经不是第一个有这种疑惑的人了,并且SimpleXml的处理已经被官方定位为feature而不是BUG了,甚至在bool的转换文档中成为了唯一的一个特例。
但是,这到底是如何导致的呢?出于疑惑,我翻了下empty()的相关源码。
如何找到empty的源码?
opline指令是ZendVM的执行指令,而opline的handler成员就是ZendVM在执行阶段真正的执行内容。
严谨的来说,因为empty结构可能对应不止一种opline,所以我们需要从语法和词法分析开始,追溯生成的对应ast和ast编译出的opline,再根据opline的opcode和2个操作数类型使用zend_opcode_handlers[]获取empty()可能的所有handler.
但我们的目的并不是学习empty()的完整实现,而是搞清楚这个现象的直接原因,所以这里使用了一种更加巧妙和快速的办法。
zend_execute()是php脚本执行的入口函数,其参数op_array是编译器最终输出的内容,op_array的opcodes成员即上文提到的opline的集合。所以利用gdb在zend_execute()处打断点即可得到当前PHP脚本的所有执行句柄。
示例代码实测执行句柄如下:
(int (*)(zend_execute_data *)) 0x89c39f <ZEND_INIT_FCALL_SPEC_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x89ecf3 <ZEND_SEND_VAL_SPEC_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x8974a3 <ZEND_DO_ICALL_SPEC_HANDLER>
(int (*)(zend_execute_data *)) 0x8e681d <ZEND_ASSIGN_SPEC_CV_VAR_HANDLER>
(int (*)(zend_execute_data *)) 0x89c39f <ZEND_INIT_FCALL_SPEC_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x8e14b4 <ZEND_FETCH_OBJ_R_SPEC_CV_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x8b875d <ZEND_SEND_VAR_SPEC_VAR_HANDLER>
(int (*)(zend_execute_data *)) 0x8974a3 <ZEND_DO_ICALL_SPEC_HANDLER>
(int (*)(zend_execute_data *)) 0x8e14b4 <ZEND_FETCH_OBJ_R_SPEC_CV_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x8e681d <ZEND_ASSIGN_SPEC_CV_VAR_HANDLER>
(int (*)(zend_execute_data *)) 0x8e499d <ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_CV_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x8e5b24 <ZEND_ASSIGN_SPEC_CV_TMP_HANDLER>
(int (*)(zend_execute_data *)) 0x89c39f <ZEND_INIT_FCALL_SPEC_CONST_HANDLER>
(int (*)(zend_execute_data *)) 0x8d9c85 <ZEND_SEND_VAR_SPEC_CV_HANDLER>
(int (*)(zend_execute_data *)) 0x8974a3 <ZEND_DO_ICALL_SPEC_HANDLER>
(int (*)(zend_execute_data *)) 0x89e9b8 <ZEND_RETURN_SPEC_CONST_HANDLER>
显而易见的,ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_CV_CONST_HANDLER就是我们想要的empty的handler。
笔者公司开发环境用的php版本为php7.013,此时源码如下:
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_CV_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS){
USE_OPLINE
zval *container;
int result;
zval *offset;
SAVE_OPLINE();
//操作数1,偏移转zval指针操作(zval在内核中对应php的一个变量,下文不再赘叙),container此处等同$xmlStr
container = _get_zval_ptr_cv_BP_VAR_IS(execute_data, opline->op1.var);
//下面前两个if分支是自动生成的,会被编译器被直接优化掉,忽略之
if (IS_CV == IS_UNUSED && UNEXPECTED(Z_OBJ_P(container) == NULL)) {
zend_throw_error(NULL, "Using $this when not in object context");
HANDLE_EXCEPTION();
}
offset = EX_CONSTANT(opline->op2);//操作数2,offset此处即'student'
if (IS_CV == IS_CONST ||
(IS_CV != IS_UNUSED && UNEXPECTED(Z_TYPE_P(container) != IS_OBJECT))) {
if ((IS_CV & (IS_VAR|IS_CV)) && Z_ISREF_P(container)) {
container = Z_REFVAL_P(container);
if (UNEXPECTED(Z_TYPE_P(container) != IS_OBJECT)) {
goto isset_no_object;
}
} else {
goto isset_no_object;
}
}
//
if (UNEXPECTED(!Z_OBJ_HT_P(container)->has_property)) {
//如果container持有的zend_object(内核中对应php的一个对象值)没有has_property函数句柄
//抛出一个E_NOTICE等级的错误,我们没报错,不可能是这条if分支
zend_error(E_NOTICE, "Trying to check property of non-object");
isset_no_object:
result = ((opline->extended_value & ZEND_ISSET) == 0);
} else {
//实际执行的分支
result =
//这个handler是isset和empty结构公用的,当ast为ZEND_AST_ISSET时
//(opline->extended_value & ZEND_ISSET)不为0(详情见zend_compile_isset_or_empty())
((opline->extended_value & ZEND_ISSET) == 0) ^
Z_OBJ_HT_P(container)->has_property(container, offset, (opline->extended_value & ZEND_ISSET) == 0, ((IS_CONST == IS_CONST) ? CACHE_ADDR(Z_CACHE_SLOT_P(offset)) : NULL));
}
ZEND_VM_SMART_BRANCH(result, 1);
ZVAL_BOOL(EX_VAR(opline->result.var), result);
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}
看到这里基本上原因已经猜个八九不离时,empty()的逻辑不是固定的,而是在此处调用了对象实际的has_property()去计算empty的返回值,只要SimpleXMLElement类改动了该方法,就可能会导致开头出现的情况。
static zend_object_handlers sxe_object_handlers = { /* {{{ */
ZEND_OBJECTS_STORE_HANDLERS,
sxe_property_read,
sxe_property_write,
sxe_dimension_read,
sxe_dimension_write,
sxe_property_get_adr,
sxe_get_value, /* get */
NULL,
sxe_property_exists,//对应has_property成员
sxe_property_delete,
sxe_dimension_exists,
sxe_dimension_delete,
sxe_get_properties,
NULL, /* zend_get_std_object_handlers()->get_method,*/
NULL, /* zend_get_std_object_handlers()->call_method,*/
NULL, /* zend_get_std_object_handlers()->get_constructor, */
NULL, /* zend_get_std_object_handlers()->get_class_name,*/
sxe_objects_compare,
sxe_object_cast,
sxe_count_elements,
sxe_get_debug_info,
NULL,
sxe_get_gc
};
回头瞄一下SimpleXML拓展的源码,果不其然。SimpleXMLElement正正替换了原生的zend_object_handlers以重写类的基本功能,其中就包括了has_property(),所以我们对empty()的一般认识才在此处失效了。
最后:
这种和文档描述不一致的现象,与其说它说明PHP文档的不严谨,不如说这正说明了PHP/Zend拓展是一类如何强大而危险的工具。回想一下opcode和xdebug之类的黑科技拓展,人家是连zend_compile_file指针都去替换的。
况且从设计的角度来看,ZendVM预留出种种诸如zend_object_handlers 这样的灵活结构去解耦和加强灵活性,正是为了让我们更进一步的去拓展。
仅仅有处理用户自定义函数和类的知识,真的不足以让我们驰骋PHP。