Почему в PHP нет дженериков?

PHP Дженерики в PHP

8 апреля 2022 г.

Почему в PHP нет дженериков?
Перевод статьи «Why we can't have generics in PHP»

Почему в PHP нет дженериков?

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

Давайте начнём.

Дженерики не появятся в PHP. Таков был вывод Никиты в прошлом году. Это просто невозможно было реализовать.

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

Первый из них — Мономорфизированные дженерики (Monomorphized Generics). Давайте вернёмся к коллекции из первой статьи этой серии:

<?php

class StringCollection extends Collection
{
    public function offsetGet(mixed $key): string 
    { /* … */ }
}

class UserCollection extends Collection
{
    public function offsetGet(mixed $key): User 
    { /* … */ }
}

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

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

$users = new Collection<User>();
// Collection_User

$slugs = new Collection<string>();
// Collection_string

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

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

Следующий метод — Материализованные дженерики (Reified Generics). Реализация, в которой общий класс сохраняется как есть, а информация о типе оценивается на лету во время выполнения. Материализованные дженерики используются в C# и Kotlin и это наиболее близко к текущей системе типов PHP, потому что PHP выполняет все проверки типов во время выполнения.

Проблема в том, что для работы материализованных дженериков потребовался бы огромный объем рефакторинга кода ядра PHP, а также снизилась бы производительность, поскольку во время выполнения мы делали бы всё больше и больше проверок типов.

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

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

Но не спешите. Игнорирование общих типов во время выполнения — это, кстати, называется затиранием типов в Java и Python, создаёт некоторые проблемы в PHP.

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

<?php

function add(int $a, int $b): int 
{
    return $a + $b;
}

add('1', '2') // 3;

Если бы PHP проигнорировал общий тип этой «строковой» коллекции и мы случайно добавили бы к ней целое число, он не смог бы нас об этом предупредить, если бы общий тип был затёрт:

<?php

$slugs = new Collection<string>();

$slugs[] = 1; // 1 не будет приведено к '1'

Вторая и более важная проблема с затиранием типов, возможно, вы уже догадались, заключается в том, что типы исчезают. Зачем нам добавлять общие типы, если они затираются во время выполнения?

Есть смысл делать это в Java и Python, потому что все определения типов проверяются перед запуском кода с помощью статического анализатора. Java, например, запускает встроенный статический анализатор во время компиляции кода, то, чего PHP просто не делает: нет шага компиляции и уж точно нет встроенного статического анализатора типов.

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

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

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

Так нужны ли нам проверки типов во время выполнения? Потому что это основная причина, по которой дженерики не могут быть добавлены в PHP сегодня — это либо слишком сложно, либо слишком ресурсоёмко для PHP, чтобы проверять дженерики во время выполнения.

Об этом в следующий раз, в последней статье этой серии.