PHP Profi

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

php array type strict collection value object

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

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

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

class Movie {
  private $airdates = [];

  public function setAirDates(\DateTimeImmutable ...$airdates) {
    $this->airdates = $airdates;
  }

  public function getAirDates() {
    return $this->airdates;
  }
}

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

$movie = new Movie();

$movie->setAirDates(
  \DateTimeImmutable::createFromFormat('Y-m-d', '2017-01-28'),
  \DateTimeImmutable::createFromFormat('Y-m-d', '2017-02-22')
);

Если бы мы передали что-то другое, а не DateTimeImmutable, строку, например, то будет выброшено исключение фатальной ошибки:Catchable fatal error: Argument 1 passed to Movie::setAirDates() must be an instance of DateTimeImmutable, string given.

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

$dates = [
  \DateTimeImmutable::createFromFormat('Y-m-d', '2017-01-28'),
  \DateTimeImmutable::createFromFormat('Y-m-d', '2017-02-22'),
];

$movie = new Movie();
$movie->setAirDates(...$dates);

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

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

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);
  }
}

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

Проблемы с такого рода Типизированными массивами

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

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

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

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

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:

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);
  }
}

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

class AirDates implements IteratorAggregate {
  private $dates;

  public function __construct(\DateTimeImmutable ...$dates) {
    $this->dates = $dates;
  }

  public function getIterator() {
     return new ArrayIterator($this->airdates);
  }
}

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

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;
  }
}

Использование Value Objects для кастомной валидации

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

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-ов:

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() { /* ... */ }
}

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

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

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

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

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

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

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

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

Что насчет изменений?

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

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

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

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

// Первоначальная коллекция Ratings
$ratings = new Ratings(
  new Rating(1.5),
  new Rating(3.5),
  new Rating(2.5)
);

// Конвертируем коллекцию в массив.
$ratingsArray = $ratings->toArray();

// Удаляем второй рейтинг.
unset($ratingsArray[1]);
$ratingsArray = array_values($ratingsArray);

// Возвращаемся к (новой) коллекции Ratings
$updatedRatings = new Ratings(...$ratingsArray);

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

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

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

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

abstract class GenericCollection implements IteratorAggregate
{
  protected $values;

  public function toArray() : array {
    return $this->values;
  }

  public function getIterator() {
    return new ArrayIterator($this->values);
  }
}

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

class Ratings extends GenericCollection
{
  public function __construct(Rating ...$ratings) {
    $this->values = $ratings;
  }

  public function getAverage() : Rating { /* ... */ }
}

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

Заключение

Несмотря на то, что еще далеко от идеала, всё же в последних версиях РНР становится легче работать с проверкой типов в коллекциях и объектах-значениях.

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

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

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

Если вам понравилась статья и вы находите её полезной, подписывайтесь на нас в Twitter, Facebook, ВКонтакте, Google+. и подарите нам немного ❤️ — лайкайте, репостите, делитесь с друзьями и коллегами.

2017-04-13 alek13 оригинал
General Food

Комментарии

авторизуйтесь, чтобы оставить комментарий