PHP7内核剖析 学习笔记 第三章 数据类型

数据类型是高级语言抽象出来的一个概念,对于低级语言而言是没有这个概念的,比如机器语言。对内存、CPU而言并没有什么类型之分,内存中的数据是没有差别的,高级语言为内存中这些无差异的数据指定了特定的计算方式,比如读取一个32位整型,就是告诉CPU按照整型的规则连续读取4个字节的数据。

数据类型使得程序的编写更加规范、简洁、灵活。PHP中变量的数据类型并不是固定不变的,它可以根据不同的场景进行转化。

3.1 变量

变量由三个主要部分组成:变量名、变量值、变量类型,PHP中变量名与变量值可以简单地对应为:zval、zend_value。PHP中变量的内存是通过引用计数进行管理的,且PHP7中引用计数转移到了具体的value结构中而不再是zval中,变量之间的传递、赋值通常也针对zend_value。

PHP中通过$符号定义一个变量,在定义的同时可以进行初始化,在变量使用前不需要提前声明。普通变量的定义方式包含了两步:变量定义、变量初始化,只定义而不初始化变量也是可以的,比如:

php 复制代码
$a;
$b = 1;

这段代码在执行时会分配两个zval,也就是定义了两个变量,但$a没有值而已,相当于unset()了。

3.1.1 变量类型

PHP中的变量类型,也就是数据类型,宏观角度可分为以下8种:

1.标量类型:字符串、整型、浮点型、布尔型。

2.复合类型:数组、对象。

3.特殊类型:资源、NULL。

具体到内部实现上会细分出更多类型,比如布尔型在内部实际分为IS_TRUE、IS_FALSE两种,也有一些基于基础数据类型产生的特殊类型,比如引用。全部类型如下:

c 复制代码
// file:zend_types.h
/* regular data types */
#define IS_UNDEF        0
#define IS_NULL         1
#define IS_FALSE        2
#define IS_TRUE         3
#define IS_LONG         4
#define IS_DOUBLE       5
#define IS_STRING       6
#define IS_ARRAY        7
#define IS_OBJECT       8
#define IS_RESOURCE     9
#define IS_REFERENCE    10
/* constant expressions */
#define IS_CONSTANT     11
#define IS_CONSTANT_AST 12
/* fake types */
#define _IS_BOOL        13
#define IS_CALLABLE     14
/* internal types */
#define IS_INDIRECT     15
#define IS_PTR          17

3.1.2 内部实现

PHP中通过zval这个结构体表示一个变量,而不同类型的变量值则通过zval嵌入的一个联合体表示,即zend_value。通过zval、zend_value及其他不同类型的结构实现了PHP基础的数据类型。另外,zval不只是PHP变量会使用,它也是内核中的一个通用结构,用于统一函数的参数。很多类型是供内核自己使用的。

c 复制代码
// file:zend_types.h
typedef struct _zval_struct zval;
struct _zval_struct {
    // 变量值
    zend_value value;
    union {
        struct {
            // 下面这个宏是为了兼容大小字节序,小字节序就是下面的顺序,大字节序是其顺序的翻转
            ZEND_ENDIAN_LOHI_4(
                // 变量类型
                zend_uchar type,
                // 类型掩码,各类型会有不同的几种属性,内存管理会用到
                zend_uchar type_flags,
                zend_uchar const_flags,
                // 预留字段,zend执行过程中会用来记录call info
                zend_uchar reserved)
        } v;
        uint32_t type_info;
    } u1;
    
    union {
        uint32_t var_flags;
        uint32_t next;
        uint32_t cache_slot;
        uint32_t lineno;
        uint32_t num_args;
        uint32_t fe_pos;
        uint32_t fe_iter_idx;
    } u2; // 一些辅助值
};

zval除了嵌入了一个zend_value用来保存具体的变量值,还有两个特殊的union:

1.u1:这个结构联合了一个结构体v和一个32位无符号整型type_info,ZEND_ENDIAN_LOHI_4宏是用来解决字节序问题的,他会根据系统的字节序决定struct v中4个成员的顺序。v中定义了4个成员,其中type用于标识value类型,即上一小节列举的IS_XXX的类型,type_flags是类型掩码,用于变量的内存管理,剩下两个后面用到了再作说明。type_info实际是将v结构的4个成员组合到了一起,v中的成员各站一个字节,总共4个字节,type_info也是4个字节,每个字节对应v的一个成员,可直接通过type_info位移获取v成员的值。

2.u2:这个结构纯粹用于一些辅助功能,zval结构的value、u1占用的空间分别为8byte、4byte,但是加起来却不是12byte,因为系统会进行字节对齐,value、u1将占用16byte,多的4byte将浪费,所以zval定义了一个u2结构把这4byte利用了,最终zval结构的大小就是16byte。这个结构在一些特殊场景下会使用,如:next在散列表解决哈希冲突时会用到(链地址法),fe_pos在foreach遍历时会用到,cache_slot在运行时缓存中会用到。

zend_value的结构比较简单,它是一个联合体,各类型根据自己的类型选择使用不同的成员,其中整型、浮点型的值直接存储在zend_value中,其他的类型在zend_value中会保存一个指针,指向具体类型的结构:

c 复制代码
typedef union _zend_value {
    zend_long lval; // 整型
    double dval; // 浮点型
    zend_refcounted *counted; // 获取不同类型结构的gc头部
    zend_string *str; // string字符串
    zend_array *arr; // array数组
    zend_object *obj; // object对象
    zend_resource *res; // resource资源类型
    zend_reference *ref; // 引用类型,通过&$var_name定义的
    zend_ast_ref *ast; // 下面几个都是内核使用的value
    zval *zv; // 指向另一个zval
    void *ptr; // 指针,通用类型
    zend_class_entry *ce; // 类
    zend_function *func; // 函数
    struct {
        uint32_t w1;
        uint32_t w2;
    } ww;
} zend_value;

zend_value中定义了众多指针,这些指针不全指向不同类型变量的值,有些指针只给内核使用,如ast、ptr、zv等。

3.2 字符串

PHP中没有使用char来表示字符串,而是为字符串单独定义了一个结构:zend_string。在zend_value中通过str指向具体的结构,zend_string除了字符串内容,还存储了其他信息,具体结构如下:

c 复制代码
typedef struct _zend_string zend_string;
struct _zend_string {
    zend_refcounted_h gc;
    zend_ulong h; /* hash value */
    size_t len;
    char val[1];
};

该结构有4个成员:

1.gc:变量的引用计数信息,用于内存管理。

2.h:字符串通过Times 33算法计算得到的Hash Code。

3.len:字符串长度。

4.val:字符串内容。

zend_string中的val并没有使用char *类型,而是使用了一个可变数组,val[1]并不代表它只能存储一个字节,在字符串分配时实际是类似这样操作的:malloc(sizeof(zend_string) + 字符串长度),也就是会多分配一些内存,而多出的这块内存的起始位置就是val,这样就可以直接将字符串内容存储到val中,通过val进行读取。如果val是一个指针char *,则需要额外分配一次内存,变长结构体不仅可以省一次内存分配,而且有助于内存管理,free时直接释放zend_string即可。

val中多出来的一个字节(结构体中为val[1]而不是val[0]),用于存储字符串的最后一个字符\0,比如$a="abc",则对应的zend_string内存结构如图3-1所示:

3.3 数组

数组的底层实现为散列表(HashTable,也称作哈希表),除了我们熟悉的PHP用户空间中的array类型,内核中也大量使用到这个数据结构,比如内核中用于存储函数、类、常量等的符号表。

散列表是根据关键码值(Key value)而直接进行访问的数据结构,它的key-value之间存在一个映射函数,可根据key通过映射函数直接索引到对应的value值,它不以关键字的比较为基本操作,直接根据"内存起始位置+偏移值"进行寻址,即它直接通过key映射到内存地址上去,从而加快了查找速度。理想情况下,无需任何比较就可以找到待查关键字,查找的期望时间复杂度为O(1)。下面是散列表的结构:

c 复制代码
// zend_array、HashTable的含义是相同的
typedef struct _zend_array zend_array
typedef struct _zend_array HashTable

struct _zend_array {
    zend_refcounted_h gc;
    // 这个union可先忽略
    union {
        ...
    } u;
    // 用于散列函数映射的存储元素(即value)在arData数组(即Bucket数组)中的下标
    uint32_t nTableMask;
    // 存储元素数组,每个元素的结构统一为Bucket,arData指向第一个Bucket
    // 注意,一个Bucket中只有一个元素,如果地址冲突,会用链地址法解决冲突
    // 链地址法中,会使用zval.u2.next指向下一个元素的Bucket
    Bucket *arData;
    // 已用Bucket数
    uint32_t nNumUsed;
    // 数组实际存储的元素数
    uint32_t nNumOfElements;
    // 数组的总容量
    uint32_t nTableSize;
    uint32_t nInternalPointer;
    // 下一个可用的数值索引,如arr[] = 1; arr["a"] = 2; arr[] = 3;
    // 则nNextFreeElement = 2
    zend_long nNextFreeElement;
    dtor_func_t pDestructor;
};

散列表的结构中有很多成员,比较重要的几个成员含义如下:

1.arData:散列表中保存元素(即Bucket,元素存在Bucket中,一个元素一个Bucket)的数组,其内存是连续的,arData指向数组的起始位置。

2.nTableSize:数组的总容量,即可以容纳的元素数,arData的内存大小就是根据这个值确定的,它的大小是2的幂次方,最小为8,即散列表的大小依次按8、16、32、64······递增。

3.nTableMask:这个值在散列函数根据key的hash code映射元素的存储位置时会用到,它的值实际就是nTableSize的负数,即nTableMask = -nTableMask,用位运算表示的话则为nTableMask = ~nTableSize + 1

4.nNumUsed、nNumOfElements:nNumUsed指当前使用的Bucket数,但这些Bucket并不都是有效的,因为当我们删除一个数组元素时不会马上将其从数组中移除,只会将这个元素的类型标为IS_UNDEF,只有在数组容量超限,需要进行扩容时才会删除;nNumOfElements则是数组中有效元素的数量,所以nNumOfElements <= nNumUsed。如果数组没有扩容,那么nNumOfElements将一直是递增的,无论是否删除元素。

5.nNextFreeElement:用于自动确定数值索引,从0开始,比如$a []= 1,执行后nNextFreeElement的值就增加为1,下次再有$a[]操作时就使用1作为新元素的索引值。

6.pDestructor:当删除或覆盖数组中的某个元素时,如果提供了这个函数句柄,则在删除或覆盖后调用此函数,对旧元素进行清理。

7.u:这个结构主要用于一些辅助作用,比如flags用来设置散列表的一些属性------是否持久化、是否已经初始化等。

Bucket结构比较简单,此结构主要用来保存元素的key和value。除此之外还有一个整型的h用来保存hash code:如果元素是数值索引,那么它的值就是数值索引的值;如果是字符串,那么这个值就是根据字符串key通过Time33算法计算得到的散列值。h的值用来映射元素的存储位置。另外,存储的value也直接嵌入到了Bucket结构中:

c 复制代码
typedef struct _Bucket {
    zval        val; // 存储的具体value,这里嵌入了一个zval,而不是一个指针
    zend_ulong  h; // key根据times 33计算得到的哈希值,或者是数值索引
    zend_string *key; // 存储元素的key
} Bucket;

3.3.1 基本实现

散列表主要由两部分组成:存储元素的数组、散列函数。一个简单的散列函数可以采用取模的方式,比如散列表大小为8,那么在散列表初始化数组时就分配8个元素大小的空间,根据key的hash code与8取模得到的值作为该元素在数组中的下标,这样就可以通过key映射到存储数组中的具体位置,如图3-2所示:

这就是一个散列表的基本实现,但这种直接以散列函数的输出值作为该元素在元素数组中的下标的方式有一个问题:元素在数组中的位置具有随机性,它是无序的。PHP中的数组除了散列表具备的特点,还是有序的,数组中各元素的顺序与其插入顺序一致。

为了实现散列表的有序性,PHP将元素按插入顺序存放在元素数组里,然后在散列表的散列函数与元素数组间加了一层映射表,这个映射表也是一个数组,大小与存储元素的数组相同。映射表的key是散列函数得到的元素下标,散列表的值是元素在元素数组里的下标,如图3-3所示:

但我们在PHP数组的结构中没有发现中间这个映射表,事实上它与arData放在一起,在数组初始化时并不仅仅分配用于存储Bucket的内存,还会分配相同数量的uint32_t大小的空间,这两块空间是一起分配的,然后将arData偏移到元素数组的位置,而这个中间映射表可通过arData向前访问到,如图3-4所示:

3.3.2 散列函数

散列函数的作用是根据key映射出元素的存储位置,通常会以取模作为散列函数:key->h % nTableSize。但PHP中使用了另一种方式,前面介绍数组结构中有一个nTableMask,它的值是nTableSize的负数,PHP中采用以下方式计算散列值:

c 复制代码
nIndex = key->h | nTableSize; // 这里书上写错了,应该是nIndex = key->h | nTableMask;

因为散列表的大小为2的幂次方,所以通过与运算可以得到[nTableMask, -1]之间的散列值。比如nTableSize为16,二进制为0001 0000,那么nTableMask就是-16,二进制为1111 0000,nTableMask与哈希值会进行或运算,得到的结果中前4位1不会变,后4位0可能变为1,即结果的范围是1111 1111到1111 0000,用十进制表示范围即-16到-1。

3.3.3 数组的初始化

数组初始化的过程主要是对HashTable中的成员进行设置,初始化时并不会立即分配arData的内存,arData的内存在插入第1个元素时才会分配。初始化操作可通过zend_hash_init()宏完成,最后由_zend_hash_init()函数处理。

c 复制代码
ZEND_API void ZEND_FASTCALL _zend_hash_init(HashTable *ht, uint32_t nSize,
  dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC) {
    // 初始化gc信息
    GC_REFCOUNT(ht) = 1;
    GC_TYPE_INFO(ht) = IS_ARRAY;
    // 设置flags
    ht->u.flags = (persistent ? HASH_FLAG_PERSISTENT : 0) | HASH_FLAG_APPLY_PROTECTION |
      HASH_FLAG_STATIC_KEYS;
    // 这里会把数组大小重置为2的幂次方
    ht->nTableSize = zend_hash_check_size(nSize);
    // nTableMask的值也是临时的
    ht->nTableMask = HT_MIN_MASK;
    // 临时设置ht->arData
    HT_SET_DATA_ADDR(ht, &uninitialized_bucket);
    ht->nNumUsed = 0;
    ht->nNumOfElements = 0;
    ht->nInternalPointer = HT_INVALID_IDX;
    ht->nNextFreeElement = 0;
    ht->pDestructor = pDestructor;
}

此时的HashTable只是设置了散列表的大小及其他一些成员的初值,还无法用来存储元素。

3.3.4 插入

插入时首先会检查数组是否已经分配存储空间,因为初始化时没有实际分配arData的内存,在第一次插入时才会根据nTableSize的大小分配,分配完后会把HashTabl->u.flags打上HASH_FLAG_INITIALIZED掩码,这样下次插入时发现已经分配了就不会再重复操作。

c 复制代码
#define CHECK_INIT(ht, packed) \
    zend_hash_check_init(ht, packed)

// _zend_hash_add_or_update_i
// 如果数组还没有分配arData内存
if (UNEXPECTED(!(ht->u.flags & HASH_FLAG_INITIALIZED))) {
    CHECK_INIT(ht, 0);
    goto add_to_hash;
}

如果arData没有分配,则最终由zend_hash_real_init_ex()完成内存的分配。分配的内存包括中间映射表及元素数组:`nTableSize * (sizeof(Bucket) + sizeof(uint32_t)),分配完后将HashTable->arData指向第1个Bucket的位置。

c 复制代码
static void zend_always_inline zend_hash_real_init_ex(HashTable *ht, int packed) {
    ...
    // 设置nTableMask
    (ht)->nTableMask = -(ht)->nTableSize;
    // 分配Bucket数组及映射数组
    HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT));
    (ht)->u.flags |= HASH_FLAG_INITIALIZED;
    ...
    // 初始化映射数组的value为-1
    HT_HASH_RESET(ht);
}

完成Bucket数组的分配后就可以进行插入操作了,插入时首先将元素按顺序插入arData,然后将该元素信息填入中间映射表中,该元素在中间映射表中的下标是key的hash code(即key->h)与nTableMask计算的结果,该元素在中间映射表中的值是该元素在arData数组中的下标:

c 复制代码
// _zend_hash_add_or_update_i
add_to_hash:
    HANDLE_BLOCK_INTERRUPTIONS();
    // idx为Bucket在arData中的存储位置
    idx = ht->nNumUsed++;
    ht->nNumOfElements++;
    ...
    // 找到存储Bucket,设置key、value
    p = ht->arData + idx;
    p->key = key;
    ...
    p->h = h = ZSTR_H(key);
    // 直接把zval拷贝到Bucket里
    ZVAL_COPY_VALUE(&p->val, pData);
    // 计算当前插入元素在中间映射表中的位置(散列值),idx将保存在映射数组的nIndex位置
    nIndex = h | ht->nTableMask;
    // 将映射表中原来的值保存到新Bucket中,哈希冲突时会用到
    Z_NEXT(p->val) = HT_HASH(ht, nIndex);
    // 保存idx:((uint32_t *)(ht->arData))[nIndex] = idx
    HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);
    
    return &p->val;

上面的过程只是最基本的插入,当然插入时还有一些其他逻辑,比如插入的元素已经存在的处理,此时需要根据插入的策略处理,如果想覆盖以前的值,则除了将元素的value更新为新的值外,还会调用pDestructor设定的函数对旧元素进行清理,如果不想覆盖则会直接返回,插入失败。

3.3.5 哈希冲突

散列表中不同元素的key可能计算得到相同的哈希值,这些具有相同哈希值的元素在插入散列表时会发生冲突,因为映射表只能存储一个元素。常见的一种解决方法是把冲突的Bucket串成链表,这样中间映射表映射出的就不再是一个Bucket,而是一个Bucket链表,查找时需要遍历这个链表,逐个比较key,从而找到目标元素,PHP实现的散列表也是采用该方法解决的哈希冲突。

Bucket会记录与它冲突的元素在arData数组中的存储位置,本质上这也是一个链表。如果发现中间映射表中要设置的位置已经被之前插入的元素占用了(值不等于初始化的-1),那么会把已经存在的值保存到新插入的Bucket中(实际是将Bucket的zval成员的u2.next指针指向已经存在的Bucket),然后将中间映射表中的值更新为新Bucket的存储位置,即每次都会把冲突的元素插到开头。

c 复制代码
// 先把旧的值保存到新插入的元素中
Z_NEXT(p->val) = HT_HASH(ht, nIndex);
// 再把新元素数组在arData中的存储位置更新到中间映射表中
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);

例如,一个数组有3个元素,按照a、b、c的顺序插入:

php 复制代码
$arr = [];
$arr['a'] = 11;
$arr['b'] = 22;
$arr['c'] = 33;

假如a、c两个key冲突了,则HashTable的结构如图3-5所示:

3.3.6 查找

查找过程:首先根据key计算出的hash code(即zend_string->h)与nTableMask计算得到散列值nIndex,然后根据散列值从中间映射表中得到存储元素在有序存储数组中的位置idx,接着根据idx从有序存储数组(即HashTable->arData)中取出Bucket,最后从取出的Bucket开始遍历,判断Bucket的key是否是要查找的key,如果是则终止遍历,否则继续根据zval.u2.next遍历比较。

c 复制代码
// zend_hash_find_bucket
// 根据zend_string *key查找
zend_ulong h;
uint32_t nIndex;
uint32_t idx;
Bucket *p, *arData;

h = zend_string_hash_val(key);
arData = ht->arData;
// 计算散列值
nIndex = h | ht->nTableMask;
// 获取Bucket存储位置
idx = HT_HASH_EX(arData, nIndex);
// 遍历
while (EXPECTED(idx != HT_INVALID_IDX)) {
    p = HT_HASH_TO_BUCKET_EX(arData, idx);
    // 如果两个C风格字符串的地址相同
    if (EXPECTED(p->key == key)) { /* check for the same interned string */
        return p;
    } else if (EXPECTED(p->h == h) && // 先比较hash code
      EXPECTED(p->key) &&
      // 再比较key的长度
      EXPECTED(ZSTR_LEN(p->key) == ZSTR_LEN(key)) &&
      // 再比较各字符是否相同
      EXPECTED(memcmp(ZSTR_VAL(p->key), ZSTR_VAL(key), ZSTR_LEN(key) == 0) {
        return p;
    }
    // 不匹配则继续遍历
    idx = Z_NEXT(p->val);
}

3.3.7 扩容

数组的容量是有限的,最多可存储nTableSize个元素,当数组空间已满还要继续插入时,PHP数组会自动扩容:在插入前首先会检查是否有空闲空间,当发现空间已满没有位置容纳新元素时就会触发扩容逻辑,扩容后再执行插入。

c 复制代码
#define ZEND_HASH_IF_FULL_DO_RESIZE(ht)        \
    if ((ht)->nNumUsed >= (ht)->nTableSize) {  \
        zend_hash_do_resize(ht);               \
    }

static zend_always_inline *_zend_hash_add_or_update_i(HashTable *ht, zend_string *key,
  zend *pData, uint32_t flag ZEND_FILE_LINE_DC) {
    ...
    // 检查是否需要扩容
    ZEND_HASH_IF_FULL_DO_RESIZE(ht);
add_to_hash: // 插入
    ...
}

扩容过程:首先检查数组中已经删除的元素所占比例(即那些已经删除但未从存储数组中移除的元素),如果比例达到阈值则触发重建索引的操作,这个过程会把删除的Bucket移除,然后把后面的Bucket往前移补上空缺的Bucket;如果还没有达到阈值,则会分配一个原数组大小2倍的新数组,然后把原数组的元素复制到新数组上,最后重建索引。这个阈值不是一个固定值,它是根据下式判断的:

c 复制代码
ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)

具体的处理过程:

c 复制代码
static void ZEND_FASTCALL zend_hash_do_resize(HashTable *ht) {
    // 无需扩容
    if (ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)) {
        // 只有到一定阈值才进行rehash操作
        zend_hash_rehash(ht); // 重建索引数组
    } else if (ht->nTableSize < HT_MAX_SIZE) { // 扩容
        void *new_data, *old_data = HT_GET_DATA_ADDR(ht);
        // 扩大为2倍,加法要比乘法快
        uint32_t nSize = ht->nTableSize + ht->nTableSize;
        Bucket *old_buckets = ht->arData;
        // 新分配arData空间,大小为(sizeof(Bucket) + sizeof(uint32_t)) * nSize
        new_data = pemalloc(HT_SIZE_EX(nSize, -nSize), ...);
        ht->nTableSize = nSize;
        ht->nTableMask = -ht->nTableSize;
        // 将arData指针偏移到Bucket数组起始位置
        HT_SET_DATA_ADDR(ht, new_data);
        // 将旧的Bucket数组复制到新空间
        memcpy(ht->arData, old_buckets, sizeof(Bucket) * ht->nNumUsed);
        // 释放旧空间
        pefree(old_data, ht->u.flags & HASH_FLAG_PERSISTENT);
        
        // 重建索引数组:中间映射表
        zend_hash_rehash(ht);
        ...
    }
    ...
}

将旧的数组复制到扩容后的数组时只复制存储的元素,即HashTable->arData,不会复制中间映射表,因为扩容后旧的映射表已经无法使用了(因为与key的hash code进行与运算的nTableMask变了),key-value的映射关系需要重新计算,这一步骤为重建索引。如果不考虑将已删除的Bucket移除,那么重建索引的过程实际上就是将所有元素重新插入了一遍:

c 复制代码
// 遍历数组,重新设置中间映射表(索引表)
do {
    nIndex = p->h | ht->nTableMask;
    Z_NEXT(p->val) = HT_HASH(ht, nIndex);
    HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
    p++;
} while (++i < ht->nNumUsed);

重建索引的过程还会将已删除的Bucket移除,移除后会把这个Bucket之后的元素全部向前移动一个位置,所以在重建索引后,存储数组中的元素会全部紧密排列在一起。

除了上面介绍的操作,数组还有大量其他操作,如数组的复制、合并、销毁、重置等,具体的操作定义在zend_hash.c中,这里不再一一说明。

3.4 引用

引用并不是一种独立的类型,而是一种指向其他数据类型的结构,类似C语言中指针的概念。当修改引用类型的变量时,其修改将反映到实际引用的变量上。在PHP中通过&操作符生成一个引用变量,比如$a = &$b,执行时首先分配一个zend_reference结构,这个结构就是引用类型的结构体,然后将$a$b的zval都指向新创建的zend_reference结构,而新创建的zend_reference内嵌了一个zval,此zval的value指向原来zval的value。也就是说,&生成的引用结构指向原来的value,而$a$b都指向新生成的引用结构。

c 复制代码
struct _zend_reference {
    zend_refcounted gc;
    zval val; // 指向原来的value
};

例如:

php 复制代码
$a = date("Y-m-d");
$b = &$a;

$a在data()函数返回后被赋值为字符串,然后通过&$a将其转为引用类型并赋值给了另外一个变量$b,转换后的$a的类型已不再是IS_STRING,而变为IS_REFERENCE,$a的value也转变为zend_reference结构,这个结构的val.value指向了原来的字符串。最终的结果:$a$b指向zend_reference,其引用计数为2,然后zend_reference指向原zend_string,其引用计数为1,也就是$a$b间接地指向了实际的value值,如图3-6所示:

引用只能通过&产生,无法通过赋值传递,如上例中,如果后面再把$b赋值给其他变量,那么传递给新变量的value将是实际引用的值,而不是引用本身,如图3-7所示:

这表示PHP中的引用只有一级,不会出现一个引用指向另一个引用的情况,即没有C语言中多级指针的概念。

php 复制代码
$a = date("Y-m-d");
$b = &$a;
$c = $b; // 如果想让$c也以引用指向$a和$b引用的值,则:$c = &$b或$c = &$a

执行完以上代码后:

上例可通过gdb在opcode(opcode是什么?)执行的位置设置断点,然后每执行一条opcode看一下zval结构的变化:

上图中,先用break命令在execute_ex函数处设置断点;再用r命令执行php,参数为test.php,程序会在execute_ex函数入口暂停;然后用n命令在execute_ex函数内部逐行执行,如果遇到函数调用,则不会进入被调用的函数内部,即不会进入子函数;之后用p命令打印zval结构的value成员(类型为zend_value)的状态。

execute_ex()是opcode执行的handler,可以在这个函数中捕获所有opcode的执行,局部变量的zval分配在zend_execute_data结构上。

3.5 类型转换

PHP是弱类型语言,使用时不需要明确定义变量的类型,Zend虚拟机在执行PHP代码时会根据具体的应用场景进行类型转换,变量会按照类型转换规则将不合格的变量转为合格的变量,然后进行操作。比如加法操作:$a = "100" + 200,执行时Zend发现相加的其中一个值为字符串,就会试图将字符串"100"转为数值类型(整型或浮点型),然后与200相加,转换时不会改变原来的值,而是会生成一个新的变量进行处理。

除了自动类型转换,PHP还提供了强制的转换方式:

1.(int)/(integer):转换为整型integer。

2.(bool)/(boolean):转换为布尔类型boolean。

3.(float)/(double)/(real):转换为浮点型float。

4.(string):转换为字符串string。

5.(array):转换为数组array。

6.(object):转换为对象object。

7.(unset):转换为NULL。

无论是自动转换还是强制转换,有些类型之间是无法转换的,比如资源类型,无法将其他类型转为资源类型。下面看一下不同类型之间的转换规则,这些转换方法定义在zend_operators.c中,其中一类是直接对原value进行转换,另一类是不改变原来的值。

3.5.1 转换为NULL

任意类型都可以转换为NULL,转换时直接将新的zval类型设置为IS_NULL:

c 复制代码
ZEND_API void ZEND_FASTCALL convert_to_null(zval *op) {
    if (Z_TYPE_P(op) == IS_OBJECT) {
        if (Z_OBJ_HT_P(op)->cast_object) {
            // 如果转化的是一个对象,且它定义了cast_object的handler,则调用
            ...
        }
    }
    // 销毁zval,将类型设置为NULL
    zval_ptr_dtor(op);
    ZVAL_NULL(op);
}

3.5.2 转换为布尔型

当转换为布尔型时,根据原值的true、false决定转换后的结果,以下值被认为是false:

1.布尔值false本身。

2.整型值0。

3.浮点型值0.0。

4.空字符串,以及字符串"0"。

5.空数组。

6.NULL。

除了以上情况,其他值通常被认为是true,比如资源、对象(默认情况下,因为可以通过扩展改变这个规则)。

i_zend_is_true()方法用于判断不同类型的zval是否为TRUE,在需要布尔型的使用场景中会调用该方法进行判断,比如if ($var) {}。在扩展中可通过convert_to_boolean()函数直接将原zval转为bool型,转换时的判断逻辑与i_zend_is_true()一致。

c 复制代码
static zend_always_inline int i_zend_is_true(zval *op) {
    int result = 0;

again:
    switch (Z_TYPE_P(op)) {
        case IS_TRUE:
            result = 1;
            break;
        case IS_LONG:
            // 非0即真
            if (Z_LVAL_P(op)) {
                result = 1;
            }
            break;
        case IS_DOUBLE:
            if (Z_DVAL_P(op)) {
                result = 1;
            }
            break;
        case IS_STRING:
            // 除非空字符串、"0"外都为true
            if (Z_STRLEN_P(op) > 1 || (Z_STRLEN_P(op) && Z_STRVAL_P(op)[0] != '0') {
                result = 1;
            }
            break;
        case IS_ARRAY:
            // 非空数组为true
            if (zend_hash_num_elements(Z_ARRVAL_P(op))) {
                result = 1;
            }
            break;
        case IS_OBJECT:
            // 默认始终返回true
            result = zend_object_is_true(op);
            break;
        case IS_RESOURCE:
            // 合法资源就是true
            if (EXPECTED(Z_RES_HANDLE_P(op))) {
                result = 1;
            }
            break;
        case IS_REFERENCE:
            // 引用类型判断的是实际引用的值
            op = Z_REFVAL_P(op);
            goto again;
            break;
        default:
            break;
    }
    return result;
}

3.5.3 转换为整型

从其他类型转为整型的规则:

1.NULL:转为0。

2.布尔型:false转为0,true转为1。

3.浮点型:向下取整,比如(int)2.8 => 2

4.字符串:就是C语言strtoll()的规则,如果字符串以合法的数值开始,则使用该数值,否则其值为0,合法数值由可选的正负号,后面跟着一个或多个数字(可能有小数点),再跟着可选的指数部分组成。

5.数组:很多操作不支持将一个数组自动转为整型处理,比如array() + 2,将报error错误,但可以强制把数组转为整型,非空数组转为1,空数组转为0。

6.对象:与数组类型,很多操作也不支持将对象自动转为整型,但有些操作只会抛出一个warning警告,还是会把对象转为1。

7.资源:转为分配给这个资源的唯一编号。

_zval_get_long_func()根据以上规则返回不同类型zval转为整型后的值,convert_to_long()直接将原zval转为整型,其判断逻辑是相同的。

c 复制代码
ZEND_API zend_long ZEND_FASTCALL _zval_get_long_func(zval *op) {
try_again:
    switch (Z_TYPE_P(op)) {
        case IS_NULL:
        case IS_FALSE:
            return 0;
        case IS_TRUE:
            return 1;
        case IS_RESOURCE:
            // 将资源转为zend_resource->handler
            return Z_RES_HANDLE_P(op);
        case IS_LONG:
            return Z_LVAL_P(op);
        case IS_DOUBLE:
            return zend_dval_to_lval(Z_DVAL_P(op));
        case IS_STRING:
            // 字符串的转换调用C语言的strtoll()处理
            return ZEND_STRTOL(Z_STRVAL_P(op), NULL, 10);
        case IS_ARRAY:
            // 根据数组是否为空转为0或1
            return zend_hash_num_elements(Z_ARRVAL_P(op)) ? 1 : 0;
        case IS_OBJECT:
            {
                zval dst;
                convert_object_to_type(op, &dst, IS_LONG, convert_to_long);
                if (Z_TYPE(dst) == IS_LONG) {
                    return Z_LVAL(dst);
                } else {
                    // 默认情况是1
                    return 1;
                }
            }
        case IS_REFERENCE:
            op = Z_REFVAL_P(op);
            goto try_again;
            EMPTY_SWITCH_DEFAULT_CASE()
    }
    return 0;
}

3.5.4 转换为浮点型

除字符串外,其他类型转换规则与整型基本一致,只是在整型转换结果上加了小数位,字符串转为浮点数由zend_strtod()完成,该函数定义在zend_strtod.c中。

3.5.5 转换为字符串

一个值可通过在其前面加上(string)或用strval()函数来转变成字符串。在一个需要字符串的表达式中,会自动转换为string,比如在使用函数echo或print时,或在一个非string类型变量和一个string类型变量进行比较时。从其他类型转为字符串的规则:

1.NULL/FALSE:转为空字符串。

2.TRUE:转为"1"。

3.整型:原样转为字符串,转换时将各位依次除10取余。

4.浮点型:原样转为字符串。

5.资源:转为"Resource id #xxx"。

6.数组:转为"Array",但是报Notice。

7.对象:不能转换,将报错。

c 复制代码
ZEND_API zend_string* ZEND_FASTCALL _zval_get_string_func(zval *op) {
try_again:
    switch (Z_TYPE_P(op)) {
        case IS_UNDEF:
        case IS_NULL:
        case IS_FALSE:
            // 转为空字符串""
            return ZSTR_EMPTY_ALLOC();
        case IS_TRUE:
            // 转为"1"
            ...
            return zend_string_init("1", 1, 0);
        case IS_RESOURCE: {
            // 转为"Resource id #xxx"
            ...
            len = snprintf(buf, sizeof(buf), "Resource id #" ZEND_LONG_FMT,
              (zend_long)Z_RES_HANDLE_P(op));
            return zend_string_init(buf, len, 0);
        }
        case IS_LONG:
            return zend_long_to_str(Z_LVAL_P(op));
        case IS_DOUBLE:
            // 此处的格式中,.*表示后面的参数会指定精度,精度为(int)EG(precision)
            // G表示自动选择使用指数方式(%E)还是浮点数(%F)方式表示
            return zend_strprintf(0, "%.*G", (int)EG(precision), Z_DVAL_P(op));
        case IS_ARRAY:
            // 转为"Array",但是报Notice
            zend_error(E_NOTICE, "Array to string conversion");
            return zend_string_init("Array", sizeof("Array")-1, 0);
        case IS_OBJECT: {
            // 报error错误
            zval tmp;
            ...
            return ZSTR_EMPTY_ALLOC();
        }
        case IS_REFERENCE:
            op = Z_REFVAL_P(op);
            goto try_again;
        case IS_STRING:
            return zend_string_copy(Z_STR_P(op));
        EMPTY_SWITCH_DEFAULT_CASE()  
    }
    return NULL;
}

3.5.6 转换为数组

如果将一个null、integer、float、string、boolean、resource类型的值转换为数组,则将得到仅有一个元素的数组,其下标为0,该元素为此标量的值。换句话说,(array)$scalarValuearray($scalarValue)完全一样。

如果一个object类型转换为array,则结果为一个数组,数组元素为该对象的全部属性,包括public、private、protected,其中private属性转换后的key加上了类名前缀,protected属性的key加上了"*"作为前缀,但这些前缀并不是转为数组时单独加上的,而是类编译生成属性zend_property_info时就已经加上了。例如:

php 复制代码
class test {
    private $a = 123;
    public $b = "bbb";
    protected $c = "ccc";
}
$obj = new test;
print_r((array)$obj);

上例将输出:

具体的转换逻辑:

c 复制代码
ZEND_API void ZEND_FASTCALL convert_to_array(zval *op) {
try_again:
    switch (Z_TYPE_P(op)) {
        case IS_ARRAY:
            break;
        case IS_OBJECT:
            ...
            if (Z_OBJ_HT_P(op)->get_properties) {
                // 获取所有属性数组
                HashTable *obj_ht = Z_OBJ_HT_P(op)->get_properties(op);
                // 将数组内容复制到新数组
                ...
            }
        case IS_NULL:
            ZVAL_NEW_ARR(op);
            // 转为空数组
            zend_hash_init(Z_ARRVAL_P(op), 8, NULL, ZVAL_PTR_DTOR, 0);
            break;
        case IS_REFERENCE:
            zend_unwrap_reference(op);
            goto try_again;
        default:
            convert_scalar_to_array(op);
            break;
    }
}

// 其他标量类型转array
static void convert_scalar_to_array(zval *op) {
    zval entry;
    ZVAL_COPY_VALUE(&entry, op);
    // 新分配一个数组,将原值插入数组
    ZVAL_NEW_ARR(op);
    zend_hash_init(Z_ARRVAL_P(op), 8, NULL, ZVAL_PTR_DTOR, 0);
    zend_hash_index_add_new(Z_ARRVAL_P(op), 0, &entry);
}

3.5.7 转换为对象

如果其他任何类型的值被转换成对象,将会创建一个内置类stdClass的实例:如果该值为NULL,则新实例为空,对象中没有任何属性;array转换成object将以键名作为属性名,值作为属性值,数值索引也将转为属性,但无法通过"->"访问,只能遍历获取;对于其他值,会以"scalar"作为属性名。

c 复制代码
ZEND_API void ZEND_FASTCALL convert_to_object(zval *op) {
try_again:
    switch (Z_TYPE_P(op)) {
        case IS_ARRAY: {
            HashTable *ht = Z_ARR_P(op);
            ...
            // 以key为属性名,将数组元素复制到对象属性
            object_and_properties_init(op, zend_standard_class_def, ht);
            break;
        }
        case IS_OBJECT:
            break;
        case IS_NULL:
            object_init(op);
            break;
        case IS_REFERENCE:
            zend_unwrap_reference(op);
            goto try_again;
        default: {
            zval tmp;
            ZVAL_COPY_VALUE(&tmp, op);
            object_init(op);
            // 以scalar作为属性名
            zend_hash_str_add_new(Z_OBJPROP_P(op), "scalar", sizeof("scalar")-1, &tmp);
            break;
        }
    }
}
相关推荐
五味香1 小时前
Java学习,查找List最大最小值
android·java·开发语言·python·学习·golang·kotlin
小爬菜1 小时前
Django学习笔记(启动项目)-03
前端·笔记·python·学习·django
小爬菜1 小时前
Django学习笔记(bootstrap的运用)-04
笔记·学习·django
叫我龙翔2 小时前
【博客之星】2024年度创作成长总结 - 面朝大海 ,春暖花开!
学习
dal118网工任子仪2 小时前
69,【1】BUUCTF WEB ssrf [De1CTF 2019]SSRF Me
笔记·学习
嵌入式DZC3 小时前
优秀代码段案例__笔记
笔记·算法
猿类崛起@3 小时前
百度千帆大模型实战:AI大模型开发的调用指南
人工智能·学习·百度·大模型·产品经理·大模型学习·大模型教程
Pandaconda3 小时前
【Golang 面试题】每日 3 题(三十九)
开发语言·经验分享·笔记·后端·面试·golang·go
viperrrrrrrrrr73 小时前
大数据学习(40)- Flink执行流
大数据·学习·flink
l1x1n03 小时前
No.35 笔记 | Python学习之旅:基础语法与实践作业总结
笔记·python·学习