О дженериках в PHP и о том, зачем они нам нужны +35



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


Допустим, у вас есть набор блог-постов, скачанных из какого-то источника данных.


$posts = $blogModel->find();

Вам нужно циклически пройти по всем постам и что-то сделать с их данными. Например, с id.


foreach ($posts as $post) {
    $id = $post->getId();

    // Что-то делаем
}

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


Целостность структуры данных (Data integrity)


В PHP массив представляет собой коллекцию ... элементов.


$posts = [
    'foo',
    null,
    self::BAR,
    new Post('Lorem'),
];

Если циклически пройти по нашему набору постов, то в результате получим критическую ошибку.


PHP Fatal error:  Uncaught Error: Call to a member function getId() on string

Вызываем ->getId() применительно к строке 'foo'. Не прокатило. При циклическом проходе по массиву мы хотим быть уверены, что все значения принадлежат к определённому типу. Можно сделать так:


foreach ($posts as $post) {
    if (!$post instanceof Post) {
        continue;
    }

    $id = $post->getId();

    // Что-то делаем
}

Это будет работать, но если вы уже писали PHP-код для production, то знаете, что такие проверки иногда быстро разрастаются и загрязняют кодовую базу. В нашем примере можно проверять тип каждой записи в методе ->find() в $blogModel. Но это лишь переносит проблему из одного места в другое. Хотя ситуация чуть улучшилась.


С целостностью структуры данных есть ещё одна сложность. Допустим, у вас есть метод, которому нужен массив блог-постов:


function handlePosts(array $posts) {
    foreach ($posts as $post) {
        // ...
    }
}

Мы опять можем добавить в цикл дополнительные проверки, но это не гарантирует, что $posts содержит только коллекцию постов Posts.


Начиная с PHP 7.0 для решения этой проблемы вы можете использовать оператор ...:


function handlePosts(Post ...$posts) {
    foreach ($posts as $post) {
        // ...
    }
}

Но у этого подхода есть обратная сторона: вам придётся вызывать функцию применительно к распакованному массиву.


handlePosts(...$posts);

Производительность


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


Мы не можем прогнать на дженериках бенчмарки, потому что их пока нет, так что остаётся лишь гадать, как они повлияют на производительность. Но не будет безумием предположить, что оптимизированное поведение PHP, написанное на С, — это лучший способ решения проблемы по сравнению с созданием кучи кода для пользовательского пространства.


Автозавершение (Code completion)


Не знаю, как вы, а я при написании PHP-кода прибегаю к IDE. Автозавершение чрезвычайно повышает продуктивность, так что я хотел бы использовать его и здесь. При циклическом проходе по постам нам нужно, чтобы IDE считал каждый $post экземпляром Post. Давайте посмотрим на простую PHP-реализацию:


# BlogModel

public function find() : array {
    // возвращает ...
}

Начиная с PHP 7.0 появились типы возвращаемых значений, а в PHP 7.1 они были улучшены с помощью void и типов, допускающих значение null. Но мы никак не можем сообщить IDE, что содержится в массиве. Поэтому мы возвращаемся к PHPDoc.


/**
 * @return Post[]
 */
public function find() : array {
    // возвращает ...
}

При использовании реализации «дженерика», например класса моделей (model class), не всегда возможен подсказывающий метод ->find(). Так что в нашем коде придётся ограничиться подсказывающей переменной $posts.


/** @var Blog[] $posts */
$posts = $blogModel->find();

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


* * *


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


Важное замечание: дженериков пока что нет в PHP. RFC предназначен для PHP 7.1, о его будущем нет никакой дополнительной информации. Нижеприведённый код основан на интерфейсах Iterator и ArrayAccess, которые существуют с PHP 5.0. В конце мы разберём пример с дженериками, представляющий собой фиктивный код.


Для начала создадим класс Collection, который работает в PHP 5.0+. Этот класс реализует Iterator, чтобы можно было циклически проходить по его элементам, а также ArrayAccess, чтобы можно было использовать «массивоподобный» синтаксис для добавления элементов коллекции и обращения к ним.


class Collection implements Iterator, ArrayAccess
{
    private $position;

    private $array = [];

    public function __construct() {
        $this->position = 0;
    }

    public function current() {
        return $this->array[$this->position];
    }

    public function next() {
        ++$this->position;
    }

    public function key() {
        return $this->position;
    }

    public function valid() {
        return isset($this->array[$this->position]);
    }

    public function rewind() {
        $this->position = 0;
    }

    public function offsetExists($offset) {
        return isset($this->array[$offset]);
    }

    public function offsetGet($offset) {
        return isset($this->array[$offset]) ? $this->array[$offset] : null;
    }

    public function offsetSet($offset, $value) {
        if (is_null($offset)) {
            $this->array[] = $value;
        } else {
            $this->array[$offset] = $value;
        }
    }

    public function offsetUnset($offset) {
        unset($this->array[$offset]);
    }
}

Теперь можем воспользоваться подобным классом:


$collection = new Collection();
$collection[] = new Post(1);

foreach ($collection as $item) {
    echo "{$item->getId()}\n";
}

Обратите внимание: нет никакой гарантии, что $collection содержит только Posts. Если добавить, к примеру, строковое значение, то работать будет, но наш цикл сломается.


$collection[] = 'abc';

foreach ($collection as $item) {
    // This fails
    echo "{$item->getId()}\n";
}

При текущем уровне развития PHP мы можем решить эту проблему с помощь создания класса PostCollection. Обратите внимание: типы возвращаемых данных, допускающие использование null, доступны лишь с PHP 7.1.


class PostCollection extends Collection
{
    public function current() : ?Post {
        return parent::current();
    }

    public function offsetGet($offset) : ?Post {
        return parent::offsetGet($offset);
    }

    public function offsetSet($offset, $value) {
        if (!$value instanceof Post) {
            throw new InvalidArgumentException("value must be instance of Post.");
        }

        parent::offsetSet($offset, $value);
    }
}

Теперь в нашу коллекцию могут добавляться только Posts.


$collection = new PostCollection();
$collection[] = new Post(1);

// This would throw the InvalidArgumentException.
$collection[] = 'abc';

foreach ($collection as $item) {
    echo "{$item->getId()}\n";
}

Работает! Даже без дженериков! Есть только одна проблема: решение немасштабируемое. Вам нужны отдельные реализации для каждого типа коллекции, даже если классы будут различаться только типом.


Вероятно, создавать подклассы можно с бо?льшим удобством, «злоупотребив» поздним статическим связыванием и рефлексивным API PHP. Но вам в любом случае понадобится создавать классы для каждого доступного типа.


Великолепные дженерики


Учитывая всё это, давайте рассмотрим код, который мы могли бы написать, будь дженерики реализованы в PHP. Это может быть один класс, используемый для всех типов. Ради удобства я приведу лишь изменения по сравнению с предыдущим классом Collection, имейте это в виду.


class GenericCollection<T> implements Iterator, ArrayAccess
{
    public function current() : ?T {
        return $this->array[$this->position];
    }

    public function offsetGet($offset) : ?T {
        return isset($this->array[$offset]) ? $this->array[$offset] : null;
    }

    public function offsetSet($offset, $value) {
        if (!$value instanceof T) {
            throw new InvalidArgumentException("value must be instance of {T}.");
        }

        if (is_null($offset)) {
            $this->array[] = $value;
        } else {
            $this->array[$offset] = $value;
        }
    }

    // public function __construct() ...
    // public function next() ...
    // public function key() ...
    // public function valid() ...
    // public function rewind() ...
    // public function offsetExists($offset) ...
}
$collection = new GenericCollection<Post>();
$collection[] = new Post(1);

// This would throw the InvalidArgumentException.
$collection[] = 'abc';

foreach ($collection as $item) {
    echo "{$item->getId()}\n";
}

И всё! Мы используем <T> в качестве динамического типа, который можно проверять перед runtime. И опять же, класс GenericCollection можно было бы брать для любых типов.


Если вы так же впечатлены дженериками, как и я (а это лишь верхушка айсберга), то ведите просветительскую работу в сообществе и делитесь RFC: https://wiki.php.net/rfc/generics

-->


К сожалению, не доступен сервер mySQL