Правила работы с динамическими массивами и пользовательскими классами коллекций
Здесь представлены правила, которых я придерживаюсь при работе с динамическими массивами. По сути, это руководство по проектированию массивов, но я не захотел помещать его в руководство по проектированию объектов, потому что не в каждом объектно-ориентированном языке есть динамические массивы. Примеры написаны на PHP, потому что он похож на Java (с которым вы, возможно, уже знакомы), однако с динамическими массивами вместо встроенных классов коллекций и интерфейсов.
Использование массивов в качестве списка
Все элементы должны быть одного типа
Если вы используете массив в качестве списка (коллекция значений, идущих в определённом порядке), то все значения должны быть одного типа:
$goodList = [
'a',
'b'
];
$badList = [
'a',
1
];
Общепринятый стиль аннотирования типа списка:
@var array<TypeOfElеment>
. Удостоверьтесь, что вы не добавили тип индекса, он всегда должен быть int
.Нужно игнорировать индекс каждого элемента
PHP автоматически создаст для каждого элемента списка новый индекс (0, 1, 2 и т. д.). Однако вам не следует ни полагаться на эти индексы, ни использовать их напрямую. Клиенты могут полагаться только на свойства
iterable
and countable
.Так что можете свободно использовать
foreach
и count()
, но не применяйте for
для циклической работы с элементами списка:// Good loop:
foreach ($list as $element) {
}
// Bad loop (exposes the index of each element):
foreach ($list as $index => $element) {
}
// Also bad loop (the index of each element should not be used):
for ($i = 0; $i < count($list); $i++) {
}
В PHP цикл
for
может вообще не работать, если в списке отсутствуют какие-то индексы, либо если индексов больше количества элементов.Используйте фильтр вместо удаления элементов
Вы можете захотеть удалить элементы по индексам (
unset()
), но вместо удаления лучше с помощью array_filter()
создать новый список без нежелательных элементов.Повторюсь, не следует полагаться на индексы элементов. Так что при использовании
array_filter()
не применяйте параметр flag для отфильтровывания элементов по индексу, или даже по элементу и индексу.// Good filter:
array_filter(
$list,
function (string $element): bool {
return strlen($element) > 2;
}
);
// Bad filter (uses the index to filter elements as well)
array_filter(
$list,
function (int $index): bool {
return $index > 3;
},
ARRAY_FILTER_USE_KEY
);
// Bad filter (uses both the index and the element to filter elements)
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'
];
// Bad (uses different types of keys)
$badMap = [
'foo' => 'bar',
1 => 'baz'
];
Все значения должны быть одного типа
То же самое относится к значениям: они должны быть одного типа.
$goodMap = [
'foo' => 'bar',
'bar' => 'baz'
];
// Bad (uses different types of values)
$badMap = [
'foo' => 'bar',
'bar' => 1
];
Общепринятый стиль аннотирования типа:
@var array<TypeOfKеy, TypeOfValue>
.Ассоциативные массивы должны оставаться приватными
Списки, благодаря простоте их характеристик, можно безопасно передавать от объекта к объекту. Любой клиент может циклически проходить по элементам или подсчитывать их, даже если список пуст. С map работать труднее, потому что клиенты могут полагаться на ключи, которым не соответствует ни одно значение. Это означает, что ассоциативные массивы обычно должны оставаться приватными по отношению к объектам, которые ими управляют. Вместо того, чтобы позволять клиентам напрямую обращаться ко внутренним мапам, пусть геттеры (и, возможно, сеттеры) извлекают значения. Кидайте исключения, если для запрошенного ключа нет значения. Однако, если вы можете сохранить map и ее значения полностью приватными, сделайте это.
// Exposing a list is fine
/**
* @return array<User>
*/
public function allUsers(): array
{
// ...
}
// Exposing a map may be troublesome
/**
* @return array<string, User>
*/
public function usersById(): array
{
// ...
}
// Instead, offer a method to retrieve a value by its key
/**
* @throws UserNotFound
*/
public function userById(string $id): User
{
// ...
}
Используйте объекты в качестве ассоциативных массивов со значениями нескольких типов
Если вы хотите использовать ассоциативный массив, но при этом хранить в ней значения разных типов, то используйте объекты. Определите класс, добавьте публичные свойства типов, или добавьте конструктор и геттеры. К подобным объектам относятся конфигурационные или командные объекты:
final class SillyRegisterUserCommand
{
public string $username;
public string $plainTextPassword;
public bool $wantsToReceiveSpam;
public int $answerToIAmNotARobotQuestion;
}
Исключения из правила
Библиотеки и фреймворки иногда требуют использовать массивы динамичнее. Тогда невозможно (и нежелательно) следовать предыдущим правилам. В качестве примеров можно привести массив данных, которые хранятся в таблице БД, и конфигурацию form в Symfony.
Пользовательские классы коллекций
Пользовательские классы коллекций могут быть отличным инструментом для работы с
Iterator
, ArrayAccess
и прочими сущностями, но я считаю, что код часто становится запутанным. Тому, кто будет смотреть на код впервые, придётся сверяться с руководством по PHP, даже если он опытный разработчик. Кроме того, вам придётся писать больше кода, который нужно сопровождать (тестировать, отлаживать и т. д.). Так что в большинстве случаев достаточно простого массива с правильными аннотациями типов. Что свидетельствует о том, что вам нужно обернуть массив в пользовательский объект коллекции?
- Дублирование логики, относящейся к массиву.
- Клиентам приходится работать со слишком большим количеством подробностей о содержимом массива.
Для предотвращения дублирования логики используйте пользовательский класс коллекции
Если несколько клиентов, работающих с одним массивом, выполняют одну и ту же задачу (например, фильтруют, сопоставляют, уменьшают, подсчитывают), то можно убрать дубли с помощью пользовательского класса коллекции. Перенос дублирующейся логики в метод класса коллекции позволяет любому клиенту выполнять одну и ту же задачу, просто вызывая метод этой коллекции:
$names = [/* ... */];
// Found in several places:
$shortNames = array_filter(
$names,
function (string $element): bool {
return strlen($element) < 5;
}
);
// Turned into a custom collection class:
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(
/* ... */
);
}
}
Конечно, если вы преобразуете внутренний массив, то можно преобразовать в другой тип коллекции или простой массив. Как обычно, удостоверьтесь, что возвращается правильный тип.
Обеспечивайте лишь то поведение, которое действительно нужно клиентам
Вместо того, чтобы расширять класс из библиотеки с универсальной коллекцией, или реализовывать универсальный фильтр или map, а также reduce для каждого пользовательского класса коллекции, реализуйте только то, что действительно нужно. Если в какой-то момент вы перестаёте использовать метод, то удаляйте его.
Для итерирования применяйте 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) {
// ...
}
Компромисс
Раз вы пишете больше кода для пользовательского класса коллекции, то клиентам должно быть проще работать с этой коллекцией (а не с одним лишь массивом). Если клиентский код становится понятнее, если коллекция обеспечивает полезное поведение, то это оправдывает дополнительные усилия по сопровождению пользовательского класса коллекции. Но поскольку работать с динамическими массивами так легко (в основном потому что не нужно прописывать используемые типы), я редко использую свои классы коллекций. Однако некоторые разработчики активно их применяют, так что я обязательно продолжу искать возможные варианты использования.