Статьи

Создание строго типизированных массивов и коллекций в PHP

Этот пост впервые появился на Medium и был переиздан здесь с разрешения автора. Мы рекомендуем вам следить за Бертом на Medium и дать ему несколько лайков!


Одной из особенностей языка, анонсированной еще в PHP 5.6, было добавление токена ... для обозначения того, что функция или метод принимает переменную длину аргументов.

Что-то, что я редко вижу, упоминалось о том, что можно комбинировать эту функцию с подсказками типов для создания типизированных массивов.

Например, у нас может быть класс Movie с методом для установки массива эфирных дат, который принимает только объекты DateTimeImmutable :

 <?php class Movie { private $dates = []; public function setAirDates(\DateTimeImmutable ...$dates) { $this->dates = $dates; } public function getAirDates() { return $this->dates; } } 

Теперь мы можем передать переменное число отдельных объектов DateTimeImmutable в метод setAirDates() :

 <?php $movie = new Movie(); $movie->setAirDates( \DateTimeImmutable::createFromFormat('Ym-d', '2017-01-28'), \DateTimeImmutable::createFromFormat('Ym-d', '2017-02-22') ); 

Если мы передадим что-то еще, кроме DateTimeImmutable , например, строку, то возникнет фатальная ошибка:

Исправляемая фатальная ошибка: аргумент 1, передаваемый в Movie :: setAirDates (), должен быть экземпляром DateTimeImmutable с заданной строкой.

Если бы вместо этого у нас уже был массив объектов DateTimeImmutable которые мы хотели передать в setAirDates() , мы могли бы снова использовать токен ... , но на этот раз, чтобы распаковать их:

 <?php $dates = [ \DateTimeImmutable::createFromFormat('Ym-d', '2017-01-28'), \DateTimeImmutable::createFromFormat('Ym-d', '2017-02-22'), ]; $movie = new Movie(); $movie->setAirDates(...$dates); 

Если бы массив содержал значение, которое не относится к ожидаемому типу, мы все равно получили бы фатальную ошибку, упомянутую ранее.

Кроме того, мы можем использовать скалярные типы таким же образом, начиная с PHP 7. Например, мы можем добавить метод для установки списка оценок в виде чисел с плавающей точкой в ​​нашем классе Movie :

 <?php declare(strict_types=1); class Movie { private $dates = []; private $ratings = []; public function setAirDates(\DateTimeImmutable ...$dates) { /* ... */ } public function getAirDates() : array { /* ... */ } public function setRatings(float ...$ratings) { $this->ratings = $ratings; } public function getAverageRating() : float { if (empty($this->ratings)) { return 0; } $total = 0; foreach ($this->ratings as $rating) { $total += $rating; } return $total / count($this->ratings); } } 

Опять же, это гарантирует, что свойство оценок всегда будет содержать числа с плавающей запятой, и нам не придется перебирать все содержимое для их проверки. Так что теперь мы можем легко выполнять некоторые математические операции над ними в getAverageRating() , не беспокоясь о недопустимых типах.

Проблемы с этим типом типизированных массивов

Одним из недостатков использования этой функции в качестве типизированных массивов является то, что мы можем определить только один такой массив для каждого метода. Допустим, мы хотели иметь класс Movie, который ожидает список дат в эфире вместе со списком оценок в конструкторе, вместо того, чтобы устанавливать их позже с помощью необязательных методов. Это было бы невозможно с методом, использованным выше.

Другая проблема заключается в том, что при использовании PHP 7 возвращаемые типы наших методов get() все равно должны быть «массивом», который часто слишком универсален.

Решение: Коллекционные классы

Чтобы решить обе проблемы, мы можем просто внедрить наши типизированные массивы в так называемые классы «коллекции». Это также улучшает разделение интересов, поскольку теперь мы можем переместить метод расчета среднего рейтинга в соответствующий класс сбора:

 <?php declare(strict_types=1); class Ratings { private $ratings; public function __construct(float ...$ratings) { $this->ratings = $ratings; } public function getAverage() : float { if (empty($this->ratings)) { return 0; } $total = 0; foreach ($this->ratings as $rating) { $total += $rating; } return $total / count($this->ratings); } } 

Обратите внимание, что мы все еще используем список типизированных аргументов с переменной длиной в нашем конструкторе, что избавляет нас от необходимости циклически повторять каждый рейтинг для проверки его типа.

Если бы мы хотели использовать этот класс коллекции в циклах foreach, нам просто нужно было бы реализовать интерфейс IteratorAggregate :

 <?php declare(strict_types=1); class Ratings implements IteratorAggregate { private $ratings; public function __construct(float ...$ratings) { $this->ratings = $ratings; } public function getAverage() : float { /* ... */ } public function getIterator() { return new ArrayIterator($this->ratings); } } 

Двигаясь дальше, мы также можем создать коллекцию для нашего списка эфирных дат:

 <?php class AirDates implements IteratorAggregate { private $dates; public function __construct(\DateTimeImmutable ...$dates) { $this->dates = $dates; } public function getIterator() { return new ArrayIterator($this->airdates); } } 

Собрав все кусочки головоломки вместе в классе Movie, теперь мы можем внедрить две отдельно напечатанные коллекции в нашем конструкторе. Кроме того, мы можем определить более конкретные возвращаемые типы, чем «массив» в наших методах get:

 <?php class Movie { private $dates; private $ratings; public function __construct(AirDates $airdates, Ratings $ratings) { $this->airdates = $airdates; $this->ratings = $ratings; } public function getAirDates() : AirDates { return $this->airdates; } public function getRatings() : Ratings { return $this->ratings; } } 

Использование объектов значений для пользовательской проверки

Если бы мы хотели добавить дополнительную проверку к нашим рейтингам, мы могли бы пойти еще дальше и определить объект значения рейтинга с некоторыми пользовательскими ограничениями. Например, рейтинг может быть ограничен от 0 до 5:

 <?php declare(strict_types=1); class Rating { private $value; public function __construct(float $value) { if ($value < 0 || $value > 5) { throw new \InvalidArgumentException('A rating should always be a number between 0 and 5!'); } $this->value = $value; } public function getValue() : float { return $this->value; } } 

Вернувшись в наш класс коллекции Ratings, мы должны были бы сделать лишь некоторые незначительные изменения, чтобы использовать эти объекты-значения вместо float:

 <?php class Ratings implements IteratorAggregate { private $ratings; public function __construct(Rating ...$ratings) { $this->ratings = $ratings; } public function getAverage() : Rating { if (empty($this->ratings)) { return new Rating(0); } $total = 0; foreach ($this->ratings as $rating) { $total += $rating->getValue(); } $average = $total / count($this->ratings); return new Rating($average); } public function getIterator() { /* ... */ } } 

Таким образом, мы получаем дополнительную проверку отдельных членов коллекции, без необходимости циклически повторять каждый внедренный объект .

преимущества

Выделение этих отдельных классов коллекции и объекта значения может показаться большой работой, но у них есть несколько преимуществ по сравнению с универсальными массивами и скалярными значениями:

  • Простая проверка типов в одном месте. Нам никогда не придется вручную зацикливаться на массиве для проверки типов членов нашей коллекции;

  • Где бы мы ни использовали эти коллекции и объекты значений в нашем приложении, мы знаем, что их значения всегда проверялись при создании. Например, любой рейтинг всегда будет между 0 и 5;

  • Мы можем легко добавить собственную логику для каждой коллекции и / или объекта значения. Например, метод getAverage() , который мы можем повторно использовать во всем приложении;

  • Мы получаем возможность вставлять несколько типизированных списков в одну функцию или метод, что мы не можем сделать с помощью токена ... без предварительного введения значений в классы коллекции;

  • Существенно уменьшены шансы смешивания аргументов в сигнатурах методов. Например, когда мы хотим внедрить как список рейтингов, так и список дат эфира, они могут легко перепутаться при создании при использовании универсальных массивов;

Как насчет правок?

К настоящему времени вам может быть интересно, как вы можете вносить изменения в значения ваших коллекций и объектов-значений после первоначального построения.

Хотя мы могли бы добавить методы для облегчения редактирования, это быстро стало бы громоздким, потому что нам пришлось бы дублировать большинство методов в каждой коллекции, чтобы сохранить преимущество подсказок типов. Например, метод add() в Ratings должен принимать только объект Rating , а метод add() в AirDates должен принимать только объект DateTimeImmutable . Это делает сопряжение и / или повторное использование этих методов очень сложным.

Вместо этого мы могли бы просто сохранять наши коллекции и объекты-значения неизменными и преобразовывать их в их примитивные типы, когда нам нужно внести изменения. После того, как мы закончили вносить изменения, мы можем просто пересоздать любые необходимые коллекции или объекты значений с обновленными значениями. После (повторного) построения все типы будут проверены снова, наряду с любой дополнительной проверкой, которую мы могли бы определить.

Например, мы могли бы добавить простой метод toArray() в наши коллекции и внести изменения следующим образом:

 <?php // Initial Ratings collection $ratings = new Ratings( new Rating(1.5), new Rating(3.5), new Rating(2.5) ); // Convert the collection to an array. $ratingsArray = $ratings->toArray(); // Remove the 2nd rating. unset($ratingsArray[1]); $ratingsArray = array_values($ratingsArray); // Back to a (new) Ratings collection $updatedRatings = new Ratings(...$ratingsArray); с <?php // Initial Ratings collection $ratings = new Ratings( new Rating(1.5), new Rating(3.5), new Rating(2.5) ); // Convert the collection to an array. $ratingsArray = $ratings->toArray(); // Remove the 2nd rating. unset($ratingsArray[1]); $ratingsArray = array_values($ratingsArray); // Back to a (new) Ratings collection $updatedRatings = new Ratings(...$ratingsArray); 

Таким образом, мы также можем повторно использовать существующие функции массива, такие как array_filter() .

Если бы нам действительно нужно было редактировать сами объекты коллекции, мы могли бы добавлять необходимые методы по мере необходимости, где бы они ни требовались. Но имейте в виду, что большинству из них также придется выполнять проверку типа заданного аргумента (ов), поэтому их сложно повторно использовать во всех различных классах коллекций.

Повторное использование общих методов

Как вы, возможно, заметили, мы все еще получаем некоторое дублирование кода в наших классах коллекции, реализуя как toArray() и getIterator() для всех них. К счастью, эти методы достаточно универсальны, чтобы перейти к универсальному родительскому классу , так как они оба просто возвращают внедренный массив:

 <?php abstract class GenericCollection implements IteratorAggregate { protected $values; public function toArray() : array { return $this->values; } public function getIterator() { return new ArrayIterator($this->values); } } 

Все, что нам осталось бы в нашем классе коллекции, это проверка типа в конструкторе и любая необязательная дополнительная логика, специфичная для этой коллекции, например:

 <?php class Ratings extends GenericCollection { public function __construct(Rating ...$ratings) { $this->values = $ratings; } public function getAverage() : Rating { /* ... */ } } 

При желании мы могли бы сделать нашу коллекцию окончательной , чтобы не допустить, чтобы любые дочерние классы связывались со свойством values ​​способами, которые могли бы отменить нашу проверку типов.

Вывод

Хотя все еще далеко от совершенства, с недавними выпусками PHP все проще становится работать с проверкой типов в коллекциях и объектах значений.

В идеале мы бы получили какую-то форму обобщений в будущей версии PHP, чтобы еще больше облегчить создание повторно используемых классов коллекций.

Функцией, которая значительно улучшит использование объектов-значений, будет возможность приводить объект к различным примитивным типам, в дополнение к строке. Это можно легко реализовать, добавив дополнительные магические методы, сравнимые с __toString() , такие как __toInt() , __toFloat() и т. Д.

К счастью, существуют некоторые RFC для реализации обеих функций в более поздних версиях, так что пальцы скрещены! 🤞


Если вы нашли этот урок полезным, пожалуйста, посетите оригинальный пост на Medium и дайте ему немного ❤️. Если у вас есть какие-либо отзывы, вопросы или комментарии, пожалуйста, оставьте их ниже или в качестве ответа на оригинальный пост.