PHP中数组规范和自定义集合
这差不多是一个关于数组设计的风格指南,但是把它添加到对象设计风格指南感觉不太对,因为不是所有的面向对象语言都有动态数组。本文中的示例是用 PHP 编写的,因为 PHP 很像 Java(可能比较熟悉),但是使用的是动态数组而不是内置的集合类和接口。
使用数组作为列表
所有元素都应该具有相同的类型
当使用一个数组作为一个列表(一个具有特定顺序的值的集合)时,每个值应该是 z 类型:
- $goodList = [
- 'a',
- 'b'
- ];
- $badList = [
- 'a',
- 1
- ];
一个被普遍接受的注释列表类型的风格是:@var array<TypeOfElement>。 确保不添加索引的类型(它总是int)。
应该忽略每个元素的索引
PHP 将自动为列表中的每个元素(0、1、2等)创建新索引。然而,你不应该依赖这些索引,也不应该直接使用它们。客户端应该依赖的列表的唯一属性是可迭代的和可计数的。
因此,可以随意使用foreach和count(),但不要使用for循环遍历列表中的元素:
- // 好的循环:
- foreach ($list as $element) {
- }
- // 不好的循环 (公开每个元素的索引):
- foreach ($list as $index => $element) {
- }
- // 也是不好的循环 (不应该使用每个元素的索引):
- for ($i = 0; $i < count($list); $i++) {
- }
(在 PHP 中,for循环甚至可能不起作用,因为列表中可能缺少索引,而且索引可能比列表中的元素数量还要多。)
使用过滤器而不是删除元素
你可能希望通过索引从列表中删除元素(unset()),但是,你应该使用array_filter()来创建一个新列表(没有不需要的元素),而不是删除元素。
同样,你不应该依赖于元素的索引,因此,在使用array_filter()时,不应该使用flag 参数去根据索引过滤元素,甚至根据元素和索引过滤元素
- // 好的过滤:
- array_filter(
- $list,
- function (string $element): bool {
- return strlen($element) > 2;
- }
- );
- // 不好的过滤器(也使用索引来过滤元素)
- array_filter(
- $list,
- function (int $index): bool {
- return $index > 3;
- },
- ARRAY_FILTER_USE_KEY
- );
- // 不好的过滤器(同时使用索引和元素来过滤元素)
- array_filter(
- $list,
- function (string $element, int $index): bool {
- return $index > 3 || $element === 'Include';
- },
- ARRAY_FILTER_USE_BOTH
- );
使用数组作为映射
当键是相关的,而不是索引(0,1,2,等等)。你可以随意使用数组作为映射(可以通过其唯一的键从其中检索值)。
所有的键应该是相同的类型
使用数组作为映射的第一个规则是,数组中的所有键都应该具有相同的类型(最常见的是string类型的键)。
- $goodMap = [
- 'foo' => 'bar',
- 'bar' => 'baz'
- ];
- // 不好(使用不同类型的键)
- $badMap = [
- 'foo' => 'bar',
- 1 => 'baz'
- ];
所有的值都应该是相同的类型
映射中的值也是如此:它们应该具有相同的类型。
- $goodMap = [
- 'foo' => 'bar',
- 'bar' => 'baz'
- ];
- // 不好(使用不同类型的值)
- $badMap = [
- 'foo' => 'bar',
- 'bar' => 1
- ];
一种普遍接受的映射类型注释样式是: @var array<TypeOfKey, TypeOfValue>。
映射应该保持私有
列表可以安全地在对象之间传递,因为它们具有简单的特征。任何客户端都可以使用它来循环其元素,或计数其元素,即使列表是空的。映射则更难处理,因为客户端可能依赖于没有对应值的键。这意味着在一般情况下,它们应该对管理它们的对象保持私有。不允许客户端直接访问内部映射,而是提供 getter (可能还有 setter )来检索值。如果请求的键不存在值,则抛出异常。但是,如果您可以保持映射及其值完全私有,那么就这样做。
- // 公开一个列表是可以的
- /**
- * @return array<User>
- */
- public function allUsers(): array
- {
- // ...
- }
- // 公开地图可能很麻烦
- /**
- * @return array<string, User>
- */
- public function usersById(): array
- {
- // ...
- }
- // 相反,提供一种方法来根据其键检索值
- /**
- * @throws UserNotFound
- */
- public function userById(string $id): User
- {
- // ...
- }
对具有多个值类型的映射使用对象
当你想要在一个映射中存储不同类型的值时,请使用一个对象。定义一个类,并向其添加公共的类型化属性,或添加构造函数和 getter。像这样的对象的例子是配置对象,或者命令对象:
- final class SillyRegisterUserCommand
- {
- public string $username;
- public string $plainTextPassword;
- public bool $wantsToReceiveSpam;
- public int $answerToIAmNotARobotQuestion;
- }
这些规则的例外
有时,库或框架需要以更动态的方式使用数组。在这些情况下,不可能(也不希望)遵循前面的规则。例如数组数据,它将被存储在一个数据库表中,或者Symfony 表单配置。
自定义集合类
自定义集合类是一种非常酷的方法,最后可以和Iterator、ArrayAccess和其朋友一起使用,但是我发现大多数生成的代码令人很困惑。第一次查看代码的人必须在 PHP 手册中查找详细信息,即使他们是有经验的开发人员。另外,你需要编写更多的代码,你必须维护这些代码(测试、调试等)。所以在大多数情况下,我发现一个简单的数组,加上一些适当的类型注释,就足够了。到底什么是需要将数组封装到自定义集合对象中的强信号?
如果你发现与那个数组相关的逻辑被复制了。
如果你发现客户端必须处理太多关于数组内部内容的细节。
使用自定义集合类来防止重复逻辑
如果使用相同数组的多个客户端执行相同的任务(例如过滤、映射、减少、计数),则可以通过引入自定义集合类来消除重复。将重复的逻辑移到集合类的一个方法上,允许任何客户端使用对集合的简单方法调用来执行相同的任务:
- $names = [/* ... */];
- // 在几个地方发现:
- $shortNames = array_filter(
- $names,
- function (string $element): bool {
- return strlen($element) < 5;
- }
- );
- // 变成一个自定义集合类:
- use Assert\Assert;
- final class Names
- {
- /**
- * @var array<string>
- */
- private array $names;
- public function __construct(array $names)
- {
- Assert::that()->allIsString($names);
- $this->names = $names;
- }
- public function shortNames(): self
- {
- return new self(
- array_filter(
- $this->names,
- function (string $element): bool {
- return strlen($element) < 5;
- }
- )
- );
- }
- }
- $names = new Names([/* ... */]);
- $shortNames = $names->shortNames();
在集合的转换上使用方法的好处就是获得了一个名称。这使你能够向看起来相当复杂的array_filter()调用添加一个简短而有意义的标签。
使用自定义集合类来解耦客户端
如果一个客户端使用特定的数组并循环,从选定的元素中取出一段数据,并对该数据进行处理,那么该客户端就与所有涉及的类型紧密耦合: 数组本身、数组中元素的类型、它从所选元素中检索的值的类型、选择器方法的类型,等等。这种深度耦合的问题是,在不破坏依赖于它们的客户端的情况下,很难更改所涉及类型的任何内容。因此,在这种情况下,你也可以将数组包装在一个自定义 的集合类中,让它一次性给出正确的答案,在内部进行必要的计算,让客户端与集合更加松散地耦合。
- $lines = [];
- $sum = 0;
- foreach ($lines as $line) {
- if ($line->isComment()) {
- continue;
- }
- $sum += $line->quantity();
- }
- // Turned into a custom collection class:
- final class Lines
- {
- public function totalQuantity(): int
- {
- $sum = 0;
- foreach ($lines as $line) {
- if ($line->isComment()) {
- continue;
- }
- $sum += $line->quantity();
- }
- return $sum;
- }
- }
自定义集合类的一些规则
让我们看看在使用自定义集合类时应用的一些规则。
让它们不可变
对集合实例的现有引用在运行某种转换时不应受到影响。因此,任何执行转换的方法都应该返回类的一个新实例,就像我们在上面的例子中看到的那样:
- final class Names
- {
- /**
- * @var array<string>
- */
- private array $names;
- public function __construct(array $names)
- {
- Assert::that()->allIsString($names);
- $this->names = $names;
- }
- public function shortNames(): self
- {
- return new self(
- /* ... */
- );
- }
- }
当然,如果要映射内部数组,则可能要映射到另一种类型的集合或简单数组。与往常一样,请确保提供适当的返回类型。
只提供实际客户需要和使用的行为
你不必扩展泛型集合库类,也不必自己在每个自定义集合类上实现泛型筛选器、映射和缩减方法,只实现真正需要的。如果某个方法在某一时刻不被使用,那么就删除它。
使用 IteratorAggregate 和 ArrayIterator 来支持迭代
如果你使用 PHP,不用实现所有的Iterator接口的方法(并保持一个内部指针,等等),只是实现IteratorAggregate接口,让它返回一个ArrayIterator实例基于内部数组:
- final class Names implements IteratorAggregate
- {
- /**
- * @var array<string>
- */
- private array $names;
- public function __construct(array $names)
- {
- Assert::that()->allIsString($names);
- $this->names = $names;
- }
- public function getIterator(): Iterator
- {
- return new ArrayIterator($this->names);
- }
- }
- $names = new Names([/* ... */]);
- foreach ($names as $name) {
- // ...
- }
权衡考虑
为你的自定义集合类编写更多代码的好处是使客户端更容易地使用该集合(而不是仅仅使用一个数组)。如果客户端代码变得更容易理解,如果集合提供了有用的行为,那么维护自定义集合类的额外成本就是合理的。但是,因为使用动态数组非常容易(主要是因为你不必写出所涉及的类型),所以我不经常介绍自己的集合类。尽管如此,我知道有些人是它们的伟大支持者,所以我将确保继续寻找潜在的用例。