PHP 底层原理之 Zval 基本结构
基本结构
zval(“Zend value”的缩写)表示一个任意的 PHP 值。因此,它很可能是 PHP 中最重要的结构,你会经常用到它。本节将介绍 zval 背后的基本概念及其使用方法。
类型和值
除此之外,每个 zval(值存储区)都会存储某个值及其类型。这是必要的,因为 PHP 是一种动态类型语言,因此变量类型只有在运行时才可知,而在编译时则无法确定。此外,在 zval 的生命周期中,类型可能会发生变化,因此,如果 zval 之前存储的是整数,那么在稍后的某个时间点,它可能包含的是字符串。
该类型以整数标签的形式存储,可取多个值中的任意一个。其中一些值对应于 PHP 中可用的八种类型,其他值则仅用于内部引擎目的。这些值使用 IS_TYPE 形式的常量来表示。例如,IS_NULL 对应于 null 类型,IS_STRING 对应于字符串类型。
实际值存储在一个联合体中,该联合体的定义如下:
typedef union _zend_value {
zend_long lval; // For IS_LONG
double dval; // For IS_DOUBLE
zend_refcounted *counted;
zend_string *str; // For IS_STRING
zend_array *arr; // For IS_ARRAY
zend_object *obj; // For IS_OBJECT
zend_resource *res; // For IS_RESOURCE
zend_reference *ref; // For IS_REFERENCE
zend_ast_ref *ast; // For IS_CONSTANT_AST (special)
zval *zv; // For IS_INDIRECT (special)
void *ptr;
zend_class_entry *ce;
zend_function *func;
struct {
uint32_t w1;
uint32_t w2;
} ww;
} zend_value;
对于那些不熟悉联合体概念的人:联合体定义了不同类型的多个成员,但一次只能使用其中的一个。例如,如果设置了 value.lval 成员,那么你也需要使用 value.lval 来查找值,而不能使用其他成员(这样做会违反“严格别名”保证,并导致未定义行为)。原因是联合体将所有成员存储在同一个内存位置,并根据你访问的成员不同来解释该位置的值。联合体的大小是其最大成员的大小。
在处理 zval 时,类型标签用于确定当前使用的是联合体的哪个成员。在了解用于此操作的 API 之前,我们先来了解一下 PHP 支持的不同类型及其存储方式:
最简单的类型是 IS_NULL:它实际上不需要存储任何值,因为只有一个 null 值。
布尔值使用 IS_TRUE 或 IS_FALSE 类型,同样不需要存储值。出于效率考虑,PHP 内部将 true 和 false 表示为单独的类型,尽管从用户的角度来看,它们被视为值。此外,还存在一个 _IS_BOOL 类型,但它从未作为 zval 类型使用。它在内部用于指示向布尔值的类型转换以及类似目的。
为了存储数字,PHP 提供了 IS_LONG 和 IS_DOUBLE 两种类型,它们分别利用 zend_long lval 和 double dval 成员。前者用于存储整数,而后者用于存储浮点数。
关于 zend_long类型,有几点需要特别注意:首先,它是一种有符号整数类型,即可以存储正整数和负整数,但通常不适用于执行位操作。其次,zend_long 与long 不同,因为它抽象化了平台差异。在 32 位平台上,zend_long 始终为 4 字节大小,在 64 位平台上为 8 字节大小,即使 long 类型的大小可能不同。
因此,使用专为 zend_long 编写的宏(如 SIZEOF_ZEND_LONG 或 ZEND_LONG_MAX)非常重要。你可以在 Zend/zend_long.h 中找到更多相关的宏。
用于存储浮点数的 double 双精度类型是一个遵循 IEEE-754 规范的 8 字节值。这里不讨论这种格式的详细信息,但你至少应该知道,这种类型的精度有限,通常无法存储你想要的确切值。
其余四种类型在此仅作简略提及,将在其各自章节中详细讨论:
字符串(IS_STRING)存储在 zend_string 结构中,该结构将字符串长度和字符串内容合并到一个分配空间中。有关 zend_string 结构及其专用 API 的更多信息,请参阅字符串章节。
数组使用 IS_ARRAY 类型标记,并存储在 zend_array *arr 成员中。HashTable 结构的工作原理将在“HashTable” 章节中讨论。
对象(IS_OBJECT)使用 zend_object *obj 成员。PHP 的类和对象系统将在对象章节中介绍。
资源(IS_RESOURCE)使用 zend_resource *res 成员。资源将在资源章节中介绍。
总结如下,下表列出了所有可用的“正常”类型标签及其值的对应存储位置:
类型标签 | 存储位置 |
|---|---|
| none |
| none |
|
|
|
|
|
|
|
|
|
|
|
|
特殊类型
有许多其他类型没有直接对应的用户空间类型,仅在内部使用。在这些类型中,IS_UNDEF 和 IS_REFERENCE 是你在日常工作中唯一会遇到的类型。
IS_UNDEF 类型用于表示未初始化的 zval。此类型标记的值为零,因此使用 memset 将 zval 清零将得到一个 UNDEF zval。IS_UNDEF 的确切含义取决于上下文,例如,它可以表示未初始化/未设置的对象属性,或者未使用的哈希表桶。
IS_REFERENCE 类型与 zend_reference *ref 成员结合使用,用于表示 PHP 引用。虽然从用户的角度来看,引用并不是一个单独的类型,但在内部,引用被表示为围绕另一个 zval 的包装器,该 zval 可在多个地方共享。
zend_refcounted *counted 成员访问所有引用计数类型的公共头,包括字符串、数组、对象、资源和引用。其工作原理将在内存管理章节中讨论。
IS_CONSTANT_AST 类型和 zend_ast_ref *ast 成员用于存储未求值的常量表达式抽象语法树(AST)。它仅出现在特定位置,如属性默认值。AST 将在编译器章节中讨论。
IS_INDIRECT 类型和 zval *zv 成员用于存储指向另一个 zval 的直接指针。这主要用于符号类型和动态属性表,以便指向存储在其他位置的实际值。
IS_PTR 类型与 void *ptr 字段一起用于存储任意指针。在 C 语言中,任何指针类型都可以转换为 void *,反之亦然。这被用于在通常只接受 zval(值)的位置存储指针,例如哈希表的值。
zend_class_entry *ce 和 zend_function *func 成员只是指定了一个更精确的类型,但在其他方面与 ptr 的用途相同。
zval 结构体
现在,让我们来看看 zval 结构体的实际样子:
struct _zval_struct {
zend_value value;
union {
uint32_t type_info;
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type,
zend_uchar type_flags,
union {
uint16_t extra;
} u)
} v;
} u1;
union {
uint32_t next; /* hash collision chain */
uint32_t cache_slot; /* cache slot (for RECV_INIT) */
uint32_t opline_num; /* opline number (for FAST_CALL) */
uint32_t lineno; /* line number (for ast nodes) */
uint32_t num_args; /* arguments number for EX(This) */
uint32_t fe_pos; /* foreach position */
uint32_t fe_iter_idx; /* foreach iterator index */
uint32_t access_flags; /* class constant access flags */
uint32_t property_guard; /* single property guard */
uint32_t constant_flags; /* constant flags */
uint32_t extra; /* not further specified */
} u2;
};
这个结构看起来比实际要复杂一些。其实,它的核心是存储一个 8 字节的值和一个单字节的类型标签,这两者我们之前已经讨论过。
理论上,这将使 zval 的大小为 9 个字节。然而,为了实现高效访问,必须将结构大小调整为 8 字节边界,这样总大小就变成了 16 个字节。由于额外的空间无论如何都会被使用,PHP 会利用这些“浪费”的空间:
类型标签是更大的 type_info 结构的一部分,该结构还存储了 type_flags。截至 PHP 7.4 版本,只有两个类型标志:IS_TYPE_REFCOUNTED 表示该值是引用计数的,而 IS_TYPE_COLLECTABLE 表示它参与循环垃圾收集。我们将在后续内容中讨论这两个标志。
u2 成员是一个 32 位空间,用于存储任意数据,并根据上下文用于不同的目的。哈希表使用它来存储冲突解决链,但如上述注释所示,它还有许多其他用途。需要注意的是,标准的 zval 宏永远不会修改或复制 u2 字段。
u1.v.u.extra 字段是该类型的一部分,但很少用于存储额外信息。然而,只有在非常特殊的情况下才能使用此字段,因为 PHP 通常会假定其值为零。
访问宏
了解了 zval 结构后,你现在可以编写代码来使用它了:
zval *zv_ptr = /* ... get zval from somewhere */;
if (zv_ptr->u1.v.type == IS_LONG) {
php_printf("Zval is a long with value " ZEND_LONG_FMT "\n", zv_ptr->value.lval);
} else /* ... handle other types */
虽然上述代码可以运行,但这不是惯用的编写方式。它直接访问了 zval 成员,而不是为此使用一组特殊的访问宏:
zval *zv_ptr = /* ... */;
if (Z_TYPE_P(zv_ptr) == IS_LONG) {
php_printf("Zval is a long with value " ZEND_LONG_FMT "\n", Z_LVAL_P(zv_ptr));
} else /* ... */
上述代码使用 Z_TYPE_P() 宏来检索类型标签,并使用 Z_LVAL_P() 来获取长整数值(即整数值)。所有访问宏都有带 _P(表示“指针”)后缀或不带后缀的变体。使用哪一个取决于你是在处理 zval 还是 zval*
zval zv;
zval *zv_ptr;
Z_TYPE(zv); // Same as Z_TYPE_P(&zv).
Z_TYPE_P(zv_ptr); // Same as Z_TYPE(*zv_ptr).
与 Z_LVAL 类似,也有用于获取所有其他类型值的宏。为了演示这些宏的使用方法,我们将创建一个用于转储 zval 的简单函数:
PHP_FUNCTION(dump)
{
zval *zv_ptr;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "z", &zv_ptr) == FAILURE) {
return;
}
try_again:
switch (Z_TYPE_P(zv_ptr)) {
case IS_NULL:
php_printf("NULL: null\n");
break;
case IS_TRUE:
php_printf("BOOL: true\n");
break;
case IS_FALSE:
php_printf("BOOL: false\n");
break;
case IS_LONG:
php_printf("LONG: %ld\n", Z_LVAL_P(zv_ptr));
break;
case IS_DOUBLE:
php_printf("DOUBLE: %g\n", Z_DVAL_P(zv_ptr));
break;
case IS_STRING:
php_printf("STRING: value=\"");
PHPWRITE(Z_STRVAL_P(zv_ptr), Z_STRLEN_P(zv_ptr));
php_printf("\", length=%zd\n", Z_STRLEN_P(zv_ptr));
break;
case IS_RESOURCE:
php_printf("RESOURCE: id=%d\n", Z_RES_HANDLE_P(zv_ptr));
break;
case IS_ARRAY:
php_printf("ARRAY: hashtable=%p\n", Z_ARRVAL_P(zv_ptr));
break;
case IS_OBJECT:
php_printf("OBJECT: object=%p\n", Z_OBJ_P(zv_ptr));
break;
case IS_REFERENCE:
// For references, remove the reference wrapper and try again.
// Yes, you are allowed to use goto for this purpose!
php_printf("REFERENCE: ");
zv_ptr = Z_REFVAL_P(zv_ptr);
goto try_again;
EMPTY_SWITCH_DEFAULT_CASE() // Assert that all types are handled.
}
}
让我们来试试吧:
dump(null); // NULL: null
dump(true); // BOOL: true
dump(false); // BOOL: false
dump(42); // LONG: 42
dump(4.2); // DOUBLE: 4.2
dump("foo"); // STRING: value="foo", length=3
dump(fopen(__FILE__, "r")); // RESOURCE: id=???
dump(array(1, 2, 3)); // ARRAY: hashtable=0x???
dump(new stdClass); // OBJECT: object=0x???
下表总结了最常用的访问器宏,尽管实际上还有更多。
Macro | 返回类型 | 所需 zval 类型 | 描述 |
|---|---|---|---|
|
| Type of the zval. One of the | |
|
|
| Integer value. |
|
|
| Floating-point value. |
|
|
| Pointer to full |
|
|
| String contents of the |
|
|
| String length of the |
|
|
| Pointer to |
|
|
| Alias of |
|
|
| Pointer to |
|
|
| Class entry of the object. |
|
|
| Pointer to |
|
|
| Pointer to |
|
|
| Pointer to the zval the reference wraps. |
当你想访问 zval 的内容时,你应该始终通过这些宏来操作,而不是直接访问其成员。这样做可以保持一定的抽象层次,并在一定程度上使你免受实现细节变化的影响。