# 数据类型

Source: https://codewiki.com/zh/php/data-types/

> - **what**: PHP 的类型属于值；变量可以在运行过程中改为保存另一种类型的值。类型声明则约束参数、返回值、属性和类常量等位置。
> - **trap**: 强制转换与宽松比较会丢失信息，例如缺失值、非法整数和合法的 `0` 可能被压成同一个结果。
> - **fix**: 在输入边界先验证再转换，业务代码使用精确声明与 `===`，并在接收 `mixed` 后立即缩小类型范围。

## 是什么，为什么存在

PHP 是动态类型语言：值有运行时类型，但变量名不会永久绑定某一种类型。`$value` 可以先保存整数，随后保存字符串；每次表达式求值时，当前值的类型决定允许的操作与转换规则。这种灵活性适合处理表单、JSON 和数据库结果，也会把一部分错误推迟到代码真正执行时。

PHP 还支持参数、返回值、属性和类常量的类型声明。声明把函数或对象边界允许的值集合写进语言，由运行时在相应位置检查。动态类型与类型声明并不矛盾：前者描述变量和值如何运行，后者为选定边界建立契约。

PHP 值常见的运行时类型如下。`get_debug_type()` 会报告当前值的实用类型名称，对对象通常返回类名，对资源还会给出资源种类。

| 类别 | 运行时类型 | 典型用途 |
| --- | --- | --- |
| 标量类型（scalar type） | `bool`、`int`、`float`、`string` | 单个布尔值、数字或文本 |
| 复合值 | `array`、`object` | 容器与带行为的对象 |
| 特殊值 | `null`、`resource` | 缺少值或外部句柄 |

`callable` 与 `iterable` 更像能力集合，而不是 `get_debug_type()` 会报告的独立值类型。数组和 `Traversable` 对象都能满足 `iterable`；闭包、函数名字符串和合法的方法数组都可能满足 `callable`。`mixed`、`void` 与 `never` 也主要用于声明：它们分别表示任何值、正常返回但不返回值，以及永不正常返回。

数据类型解决的是「这个位置允许什么值，以及这些值能做什么」。类型本身不会证明字符串来自可信来源，也不会证明整数在业务范围内。`int $quantity` 能排除数组，却不能排除 `-5`；边界验证仍然属于应用代码。

## 工作原理

### 值携带运行时类型

赋值把一个值绑定到变量名，重新赋值可以改变该名称当前关联值的类型。运算符会根据操作数类型选择行为：`.` 执行字符串拼接，算术运算需要数值，条件位置会把值解释为布尔值。

检查类型时，优先使用表达意图的函数，例如 `is_int()`、`is_string()`、`is_array()`、`is_object()`、`is_iterable()` 与 `is_callable()`。`get_debug_type()` 适合诊断和错误消息。`gettype()` 返回的部分传统名称与声明语法不同，例如整数会报告为 `integer`，所以不应把它的字符串结果当作类型系统接口。

同一种表面赋值对不同值有不同复制语义。标量和数组表现为值；修改副本不会修改原变量。对象赋值复制的是指向同一对象的句柄，因此两个变量仍能观察到同一对象的属性变化。显式引用运算符 `&` 还能让变量成为别名，这与对象句柄不是同一机制。

### 声明约束边界

原子声明可以是 `int`、`string`、`array`、类名或接口名等。联合类型（union type） `int|string` 接受任一成员；交集类型（intersection type） `Countable&Iterator` 要求对象同时满足所有成员。单一类型前的 `?` 是可空简写，所以 `?string` 等价于 `string|null`。

参数在函数进入时检查，返回值在返回时检查，带类型的属性在赋值时检查。PHP 8.3 还允许给类、接口、Trait 与枚举常量声明类型。声明失败通常抛出 `TypeError`，但具体是否先发生标量强制转换取决于调用位置的严格模式。

| 声明位置 | 检查时机 |
| --- | --- |
| 函数参数 | 进入调用时 |
| 返回类型 | 函数返回时 |
| 对象或静态属性 | 给属性赋值时 |
| 类常量 | 类定义被处理时 |

这些检查约束的是声明位置，不会冻结变量或递归检查容器内容。把类型放在稳定的模块边界上，通常比给临时局部变量补充强制转换更有价值。

`array` 声明只证明值是数组，不证明其中有哪些键或元素类型。`callable` 也不能在原生声明中写出参数与返回签名，而且不能作为属性类型。需要更细的数组形状、泛型集合或可调用签名时，可以补充 PHPDoc 和静态分析，但运行时验证仍要单独设计。

### 转换由上下文触发

类型杂耍（type juggling）是 PHP 根据上下文自动转换值的行为。可转换的数字字符串参加算术时会成为数字，整数与字符串用 `.` 连接时会按字符串处理，条件表达式则执行布尔转换。显式 `(int)`、`(string)` 等转换使用相应的转换规则，但「成功得到目标类型」不等于「输入有效」。

布尔上下文尤其容易隐藏业务差别。`false`、整数 `0`、浮点 `0.0`、空字符串、字符串 `'0'`、空数组和 `null` 都会被视为假；字符串 `'00'` 与非空数组则为真。`empty()` 采用相近的宽泛规则，因此不适合验证必须保留零值的字段。

`==` 会按比较规则转换操作数，`===` 同时要求类型和值一致。宽松比较不是简单的「先全部转成字符串」或「先全部转成数字」，不同类型组合有各自规则。业务标识、状态码和验证结果通常应先规范化为目标类型，再使用 `===`。

### 严格模式只收紧标量调用

文件顶部的 `declare(strict_types=1);` 启用严格类型模式（strict types）。在该文件发起的用户定义函数调用中，标量参数通常必须与声明精确匹配，唯一的放宽是 `int` 可以传给 `float`。严格规则由调用方文件决定，不由函数定义所在文件替调用方决定。

严格模式也会收紧声明所在文件中的返回类型处理，但它不是输入验证器。JSON、查询参数与表单值仍然以字符串、数组、整数或 `null` 等形式进入程序；代码必须决定允许哪些表示、范围与缺失状态。严格模式也不会检查 `array` 的元素形状。

## 示例

下面四个示例依次观察运行时类型、类型声明、输入解析与比较规则。所有输出都由本地 PHP 8.3.33 CLI 实际生成。

### 观察当前值的类型

同一个变量先绑定整数，再绑定内容相同的字符串。数组与生成器的具体运行时类型不同，但都满足 `iterable`。

<!-- quick -->

```php
// file: runtime_types.php
<?php

$value = 17;
printf("%s: %s\n", get_debug_type($value), $value);

$value = '17';
printf("%s: %s\n", get_debug_type($value), $value);

$items = ['draft', 'review'];
$stream = (function (): Generator {
    yield 'publish';
})();

foreach ([$items, $stream] as $collection) {
    printf(
        "%s iterable=%s\n",
        get_debug_type($collection),
        is_iterable($collection) ? 'yes' : 'no',
    );
}
```

```text
int: 17
string: 17
array iterable=yes
Generator iterable=yes
```

<!-- /quick -->

第一、二行说明类型属于当前值，而不是变量名。字符串 `'17'` 看起来像数字，但在没有发生转换前仍是字符串。

`iterable` 描述可被 `foreach` 遍历的能力。检查得到的是 `array` 和 `Generator`，不会得到两个名为 `iterable` 的运行时值。

### 用联合类型与可空类型表达契约

订单编号允许数据库整数或外部文本编号，所以函数用联合类型接收两种形式。负责人确实可能不存在，因此另一个函数把 `null` 写进声明。

```php
// file: declared_types.php
<?php
declare(strict_types=1);

function normalizeOrderId(int|string $id): string
{
    return is_int($id)
        ? sprintf('ORD-%05d', $id)
        : strtoupper(trim($id));
}

function displayOwner(?string $owner): string
{
    return $owner ?? 'unassigned';
}

echo normalizeOrderId(27), PHP_EOL;
echo normalizeOrderId(' web-27 '), PHP_EOL;
echo displayOwner(null), PHP_EOL;

try {
    normalizeOrderId(2.5);
} catch (TypeError) {
    echo "TypeError\n";
}
```

```text
ORD-00027
WEB-27
unassigned
TypeError
```

`is_int()` 将联合类型缩小到单一分支，此后两个分支都明确返回字符串。`2.5` 既不是 `int` 也不是 `string`，严格调用会在函数体执行前抛出 `TypeError`。

联合类型应该表达真实的领域输入，不是为了避免设计边界而不断增加成员。若所有调用方都能先规范化为一种编号，接收单一值对象或字符串会更简单。

### 验证后再转换外部输入

查询参数常以字符串进入程序。这个解析函数接受 `mixed`，随后立即检查允许的输入类型、整数格式与非负范围。

```php
// file: parse_quantity.php
<?php
declare(strict_types=1);

function parseQuantity(mixed $raw): int
{
    if (is_int($raw)) {
        $quantity = $raw;
    } elseif (is_string($raw)) {
        $parsed = filter_var($raw, FILTER_VALIDATE_INT);
        if ($parsed === false) {
            throw new InvalidArgumentException('not an integer');
        }
        $quantity = $parsed;
    } else {
        throw new InvalidArgumentException('wrong input type');
    }

    if ($quantity < 0) {
        throw new InvalidArgumentException('negative quantity');
    }
    return $quantity;
}

foreach (['12', '0', '12 boxes', null] as $raw) {
    try {
        printf("%s => %d\n", get_debug_type($raw), parseQuantity($raw));
    } catch (InvalidArgumentException) {
        printf("%s => invalid\n", get_debug_type($raw));
    }
}
```

```text
string => 12
string => 0
string => invalid
null => invalid
```

合法的字符串 `'0'` 被保留为整数 `0`，而带尾随文本的字符串与 `null` 被拒绝。直接执行 `(int) ($raw ?? 0)` 会把不同错误状态压成同一个整数，调用方就无法再区分它们。

`mixed` 在这里表示尚未建立信任的边界值，不表示后续代码可以随意对它做运算。完成分支检查后，`$quantity` 才具有业务代码可以依赖的 `int` 含义。

### 分开真值与相等性

条件位置检查真值，比较运算符检查两个操作数。两者都会遇到转换规则，但回答的是不同问题。

```php
// file: comparisons.php
<?php

$values = ['', '0', '00', [], [0]];

foreach ($values as $value) {
    printf(
        "%s => %s\n",
        json_encode($value),
        $value ? 'true' : 'false',
    );
}

var_export('10' == 10);
echo PHP_EOL;
var_export('10' === 10);
echo PHP_EOL;
```

```text
"" => false
"0" => false
"00" => true
[] => false
[0] => true
true
false
```

字符串 `'0'` 是 PHP 中少见的非空假字符串，而 `'00'` 为真。用 `if ($code)` 判断业务代码是否存在，可能会误判合法的字符串 `'0'`；应按契约检查 `null`、空字符串或格式。

最后两行的值相等但类型不同。输入已经规范化时使用 `===`，可以避免比较语句再次引入隐式转换。

## 陷阱

### 把强制转换当作验证

> **陷阱:** `(int)` 保证产生整数，却不保证原值是完整、合法且处于业务范围内的整数。缺失值与一些非法输入可能因此变成看似有效的 `0`。

**修复：**先限定允许的输入类型与文本格式，再转换并检查范围。错误状态需要返回结果对象还是抛出领域异常，应由接口契约明确决定。

### 认为 `strict_types` 会清洗请求数据

> **陷阱:** 严格模式只控制声明位置的类型检查，不会把查询参数解析成整数，也不会校验数组键、字符串格式或数值范围。

**修复：**在 HTTP、CLI、消息队列与数据库边界建立解析层。解析层输出窄类型后，内部函数的严格声明才有稳定含义。

### 依赖宽松比较与 `empty()`

> **陷阱:** `==` 和 `empty()` 会把多种表示放进同一分支。字符串 `'0'`、整数 `0`、`null` 与空字符串在业务上可能完全不同。

**修复：**先写出允许的表示和缺失政策，然后规范化一次，并使用 `===`、`is_*()` 或针对领域的校验函数。不要用一条宽泛条件代替数据契约。

### 以为 `array` 声明包含元素类型

> **陷阱:** `array $orders` 只检查外层值是数组。缺少键、错误元素类型与列表或映射形状都可能通过运行时声明。

**修复：**在边界验证形状，并为稳定领域数据使用具名对象。PHPDoc 的数组形状与泛型可以帮助静态分析，但不能替代运行时校验。

### 把对象赋值当成对象复制

> **陷阱:** `$copy = $original` 在对象上复制句柄，不会自动克隆实例。通过任一变量修改属性，另一变量会看到同一对象的新状态。

**修复：**需要新身份时显式 `clone`，并为内部可变对象定义克隆政策。若只需要共享对象，就用名称与接口清楚表达所有权，不要依赖读者猜测。

<!-- deep -->

## 运行时值与声明类型

运行时值类型和声明类型有重叠，但不能当作同一张枚举表。理解两者的差别，可以避免在反射、错误消息和静态分析之间来回套用错误名称。

### 原子声明与能力集合

`bool`、`int`、`float`、`string`、`array`、`object`、`null` 以及类或接口名都可以出现在合适的用户代码声明位置。`resource` 是例外：它是运行时类型，却不能作为用户代码类型声明。需要接收资源时，通常先接收 `mixed`，再用 `is_resource()` 与 `get_resource_type()` 检查。

`iterable` 等价于接收 `array` 或实现 `Traversable` 的对象，因此值的具体类型仍是数组或某个类。`callable` 接受当前作用域中可调用的值，但原生语法无法约束它的参数与返回签名。可调用性还与作用域和可见性有关，所以外部字符串不应仅凭形状就当作可信回调。

`callable` 不能用作属性类型，因为属性保存期间不能保证该值在所有作用域一直可调用。需要把闭包存入属性时可以声明 `Closure`；需要更一般的回调时，应在构造边界验证并选择明确的内部表示。

### 联合、交集与 DNF

联合类型用 `|` 表示「至少满足一个成员」。PHP 会在编译时拒绝部分明显冗余的组合，例如 `int|INT` 与 `bool|false`。`mixed` 已经包含所有值，因此也不能再与其他类型组成联合。

交集类型用 `&` 表示「同时满足所有成员」，成员必须是类或接口类型，不能写 `int&Countable`。它适合要求一个对象同时具有两项独立能力的 API，不适合表达数组元素约束。

PHP 8.2 起可以用析取范式类型组合交集与联合，例如 `(Countable&Iterator)|array`。交集部分必须带括号，类型表达式也应保持足够窄；如果签名已经难以口头说明，具名接口往往更容易维护。

### `mixed`、`void` 与 `never`

`mixed` 接受包括 `null` 在内的所有值。它适合尚未解析的边界或真正透明的传递层，但会把缩小类型的责任交给函数体。一个函数接收 `mixed` 后直接执行算术或成员访问，通常说明契约尚未完成。

`void` 只能用于返回类型，表示函数正常返回时不提供结果值。函数可以执行裸 `return;`，但不能返回一个表达式。调用 `void` 函数仍会得到 `null`，不过调用方不应把这个实现结果当作 API 数据。

`never` 也只能用于返回类型，表示函数不会正常返回，例如总是抛出异常或终止进程。声明为 `never` 的函数若运行到函数末尾或尝试正常返回，会违反声明。它表达控制流事实，不是一种可存入变量的值。

## 类型转换保留不了来源信息

转换规则只看当前值，不知道这个值来自可信配置还是用户请求。把字符串转成整数后，原始拼写、前导字符、缺失状态与被截断部分都可能消失，因此转换应该发生在已经决定接受该表示之后。

### 数字字符串与算术

格式正确的数字字符串可以参加算术，并按内容转成 `int` 或 `float`。非数字字符串在 PHP 8 的算术上下文中会触发 `TypeError`；带尾随非数字内容的字符串也会产生诊断。依赖这种运算时转换，会让验证政策由表达式副作用决定。

明确的解析函数可以把语法错误、范围错误与缺失值分别处理。金额通常还需要十进制位数与舍入政策，不能先转成二进制浮点数再假设精确。数据类型只能提供载体，领域规则必须另外写出。

### 布尔转换不是文本解析

布尔强制转换不会把自然语言单词解释为真假。任何不属于假值集合的非空字符串都为真，所以 `(bool) 'false'` 与 `(bool) 'no'` 都是 `true`。处理表单布尔值时，应枚举允许的文本或使用配置明确的验证函数。

同样，`empty()` 回答的是 PHP 的宽泛真值问题，不是「字段是否存在」或「文本是否为空白」。数组键存在性、可空值与空白字符串需要分别检查，否则合法零值很容易被当作缺失值。

### 比较前先规范化

宽松比较表随着操作数组合而变化，记住几个孤立示例不足以建立可靠契约。更稳妥的边界是把输入解析成一个确定类型，然后在内部使用严格比较。

如果接口确实允许多种表示，例如整数 `10` 和字符串 `'10'` 都表示同一编号，规范化函数应明确记录这个政策并返回一个类型。这样，转换只发生一次，后续授权、查找和分支不会各自采用不同规则。

## 严格模式的文件边界

`strict_types` 是文件级指令，而且参数规则看调用发生在哪个文件。一个定义在严格文件中的函数，若被非严格文件调用，标量参数仍可能在进入函数前被强制转换。反过来，严格文件调用定义在别处的用户函数时，会采用严格参数检查。

这项设计意味着库作者不能只靠自己文件里的声明，强迫所有调用方采用严格标量传参。库仍应声明准确类型，因为类型契约和 `TypeError` 很有价值；但真正的外部数据解析必须在库或应用的明确边界内完成。

内部函数调用还有自己的规则，不应从用户定义函数的调用位置规则外推。遇到边界行为时，用目标 PHP 版本运行一个最小测试，并以官方手册为准，不要让生成代码根据其他语言的严格模式猜测。

### 严格不等于静态

启用严格模式不会把 PHP 变成静态类型语言。变量仍可重新绑定不同类型，未声明位置仍可保存任意值，许多错误仍在执行到相应路径时才出现。

静态分析器可以利用原生声明、PHPDoc 与控制流提前发现更多问题，但它的结论取决于注解是否真实。把未经验证的 `mixed` 值用注释断言成窄类型，只会隐藏风险；运行时解析与静态契约需要描述同一个边界。

<!-- /deep -->

[检查点: php/data-types](https://codewiki.com/zh/php/data-types/#checkpoint)

## 延伸阅读

- [PHP 手册：类型](https://www.php.net/manual/en/language.types.php)
- [PHP 手册：类型声明](https://www.php.net/manual/en/language.types.declarations.php)
- [PHP 手册：类型杂耍](https://www.php.net/manual/en/language.types.type-juggling.php)
- [PHP 手册：类型比较表](https://www.php.net/manual/en/types.comparisons.php)
- [PHP 手册：可调用类型](https://www.php.net/manual/en/language.types.callable.php)
