01
灵活但模棱两可

数组不解释其内容

本机数组可以表示列表、映射或键和值的混合。变量名本身并不是契约。

一个 列表 使用从零开始的连续整数键。一个 地图 将键与值关联起来。 PHP 使用相同的 array 两者的类型,所以这个参数声明说得很少:

数组参数不明确PHP 8.1+
<?php

declare(strict_types=1);

final class User
{
    public function __construct(
        public readonly string $email,
    ) {}
}

function sendWelcomeEmails(array $users): void
{
    foreach ($users as $user) {
        echo $user->email, PHP_EOL;
    }
}

sendWelcomeEmails([new User('owner@example.com')]);

读者必须推断 $users 应该是一个列表 User 对象。 IDE 或静态分析器可以从附近的代码推断出一些值,但是当数组跨越方法、服务或外部边界时,该信息变得不太可靠。

02
避免不必要的抽象

首先选择最简单的工具

仅当类添加代码实际需要的契约或行为时才有用。

01·PHP文档

注释本地列表

使用 list<User>array<int, User> 对于一个短暂的数组。 IDE 和分析器无需新的运行时对象即可理解预期值。

02·静态分析

描述可重用的泛型

PHPStan 和 Psalm 了解模板注释,例如 @template T of object。这可以在分析过程中跨多种对象类型扩展一个集合实现。

03 · 收藏

强制执行域边界

创建 UserCollection 当值跨越边界时,错误的值必须在运行时失败,或者列表有其自己的有用行为。

PHP 8.1 不提供本机用户态泛型。模板注解是开发工具的契约; PHP 本身并不强制执行它们。下面的集合添加了运行时检查。

03
简约的现代设计

包裹数组并保持合约较小

使用标准接口,以便对象支持 foreach、索引访问,以及 count() 无需手动管理迭代器位置。

IteratorAggregate

返回内部列表的可遍历视图。没有需要维护的可变迭代器游标。

ArrayAccess

支持 $users[0], 替换, 附加为 $users[]和移除。

Countable

让我们 count($users) 返回存储的对象的数量。

一个内部 list<User>

该类验证每个插入并在删除后保留连续的整数键。

方法签名必须与 PHP 的接口匹配:偏移量和插入的值作为 mixed。实现在使用前检查它们。仅当以下情况时才会发生追加 $offset === null,所以索引 0 永远不会被误认为是空值。

04
熟悉的语法

像使用焦点列表一样使用集合

构造、追加、索引访问、迭代和计数仍然是熟悉的。

加载以下完整类后的使用PHP 8.1+
<?php

$users = new UserCollection([
    new User('Ada', 'ada@example.com'),
    new User('Linus', 'linus@example.com'),
]);

$users[] = new User('Grace', 'grace@example.com');
$users[0] = new User('Ada Lovelace', 'ada@example.com');

echo $users[0]->name, PHP_EOL;
echo count($users), PHP_EOL;

foreach ($users as $user) {
    echo $user->email, PHP_EOL;
}

因为 offsetGet() 回报 UsergetIterator() 被记录为 Traversable<int, User>,许多 IDE 可以提供成员完成功能 $users[0]->name$user->email。确切的行为取决于 IDE 及其分析设置。

05
可选的工具级泛型

当重用证明合理时添加静态分析模板

混凝土 UserCollection 最容易阅读。仅当多个集合共享相同的机制时,通用基础才变得有用。

PHPStan 和 Psalm 可以使用注释对基本集合进行建模,例如 @template T of object, @implements IteratorAggregate<int, T>, 和 @implements ArrayAccess<int, T>。然后绑定一个子类 TUser.

这些注释不会创建运行时泛型。如果在 PHP 执行时必须强制执行值,那么可重用的基础仍然需要可靠的运行时验证器(例如子类传递的类字符串)。对于不运行静态分析器的团队,首先保留具体实现。

06
使用精确的声明

类型安全有什么作用,没有什么作用

元素类型合约可以防止一类错误。它不是一般的安全边界。

它记录了意图

一种方法接受 UserCollection 表明它需要一组有序的用户。

它拒绝错误的对象类型

尝试插入另一个对象会立即失败 InvalidArgumentException.

它改善了工具反馈

返回和迭代器注释为 IDE 和静态分析器提供有关每个元素的更多信息。

它不会净化输入

有效的 User 对象仍然可以包含不安全的名称、电子邮件或其他值。

它不授权请求

知道一个对象是一个 User 并不能证明当前请求者可以查看或更改它。

这不是性能捷径

包装数组会添加方法调用和验证。在做出性能声明之前对您的实际工作负载进行基准测试。

07
实用规则

当列表中有作业时使用集合

当数组是本地临时数组时,首选普通类型参数加上 PHPDoc。

选择一个集合

这些值跨越控制器、服务、存储库或 API 边界;运行时拒绝很重要;或者列表拥有诸如查找活跃用户或强制唯一性等行为。

选择一个记录的数组

该列表存在于一个小函数内,不需要行为,并且已经由配置的静态分析器检查。

避免让集合模仿每个数组函数。添加域方法,使调用代码更清晰。如果对象变成了排序、过滤、持久性和表示行为的杂货包,则拆分这些职责。

08
复制、运行和调整

完整的 PHP 8.1 示例

这个独立的脚本测试构造、附加、索引零处的替换、迭代、计数、无效类型拒绝和超出范围的访问。

完整的 PHP 8.1 UserCollection 示例自测试脚本
<?php

declare(strict_types=1);

final class User
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}
}

/**
 * @implements IteratorAggregate<int, User>
 * @implements ArrayAccess<int, User>
 */
final class UserCollection implements IteratorAggregate, ArrayAccess, Countable
{
    /** @var list<User> */
    private array $items = [];

    /** @param iterable<User> $users */
    public function __construct(iterable $users = [])
    {
        foreach ($users as $user) {
            $this[] = $user;
        }
    }

    /** @return Traversable<int, User> */
    public function getIterator(): Traversable
    {
        yield from $this->items;
    }

    public function count(): int
    {
        return count($this->items);
    }

    public function offsetExists(mixed $offset): bool
    {
        return is_int($offset)
            && array_key_exists($offset, $this->items);
    }

    public function offsetGet(mixed $offset): User
    {
        if (!$this->offsetExists($offset)) {
            throw new OutOfBoundsException('Unknown user index.');
        }

        return $this->items[$offset];
    }

    public function offsetSet(mixed $offset, mixed $value): void
    {
        if (!$value instanceof User) {
            throw new InvalidArgumentException(
                'UserCollection accepts only User objects.'
            );
        }

        if ($offset === null) {
            $this->items[] = $value;
            return;
        }

        if (!is_int($offset) || $offset < 0 || $offset > count($this->items)) {
            throw new OutOfBoundsException('Invalid user index.');
        }

        $this->items[$offset] = $value;
    }

    public function offsetUnset(mixed $offset): void
    {
        if (!$this->offsetExists($offset)) {
            throw new OutOfBoundsException('Unknown user index.');
        }

        array_splice($this->items, $offset, 1);
    }
}

$check = static function (bool $condition, string $message): void {
    if (!$condition) {
        throw new RuntimeException($message);
    }
};

$users = new UserCollection([
    new User('Ada', 'ada@example.com'),
    new User('Linus', 'linus@example.com'),
]);

$users[] = new User('Grace', 'grace@example.com');
$check(count($users) === 3, 'Append or count failed.');

$users[0] = new User('Ada Lovelace', 'ada@example.com');
$check($users[0]->name === 'Ada Lovelace', 'Index zero failed.');

$names = [];
foreach ($users as $user) {
    $names[] = $user->name;
}
$check($names === ['Ada Lovelace', 'Linus', 'Grace'], 'Iteration failed.');

$wrongTypeRejected = false;
try {
    $users[] = new stdClass();
} catch (InvalidArgumentException) {
    $wrongTypeRejected = true;
}
$check($wrongTypeRejected, 'Invalid object type was accepted.');

$outOfRangeRejected = false;
try {
    $users[99];
} catch (OutOfBoundsException) {
    $outOfRangeRejected = true;
}
$check($outOfRangeRejected, 'Out-of-range access was accepted.');

echo "All UserCollection checks passed.", PHP_EOL;

将解码后的块保存为 user-collection.php,然后运行 php user-collection.php。它应该打印“所有 UserCollection 检查已通过”。

参考文献

关于作者

科里·马什

Cory 拥有 20 多年的互联网安全经验,是 BitFire 项目的首席开发人员。

阅读更多 BitFire 研究 →
保持合同可见

当列表中有作业时使用集合。

从记录的数组开始。仅当值跨越边界或集合拥有有用的域行为时才添加运行时强制。

免费保护我的网站 →