Дженерики в PHP

Дженерики в PHP

Поговорим о дженериках в PHP, целесообразность и сложность их внедрения в существующее ядро языка

Дженерики в PHP. Многие разработчики желают иметь их в инструментарии PHP. С другой стороны, есть PHP-программисты, которые, возможно, не знают, что такое дженерики и почему их это должно волновать. Многие языки программирования имеют поддержку дженериков:  Java имеет дженерики, C# имеет свой аналог - обобщения, уже и в Golang 1.18 завезли дженерики. Почему ж PHP не поддерживает дженерики и возможно ли это в будущем?

Основы

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

Системы типов используются по целому ряду причин, наиболее очевидным из них является проверка типа. Давайте представим, что у нас есть функция, которая принимает два целых числа; и выполняет над ними какую-то математическую операцию:


function sum($a, $b) 
{
return $a + $b;
}

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


sum('1', '2');

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


sum([], true);  //Что произойдет?

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


function sum($a, $b) 
{
if (!is_int($a) || !is_int($b)) {
return null;
}

return $a + $b;
}

Или мы могли бы использовать встроенные в PHP подсказки типов (type hints) - встроенные сокращения того, что мы в противном случае сделали бы вручную:  


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

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

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

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

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

Ну и куда без статического анализа - программы, IDE или другие виды «статических анализаторов» могут посмотреть на наш код и, не запуская его, сказать нам, будет он работать или нет - по крайней мере, в какой-то степени. Если мы передаем нашей функции строку, которая принимает целое число, наша IDE сообщит нам, что мы делаем что-то неправильно - что-то, что может привести к сбою программы во время выполнения; но наша IDE может сообщить нам об этом без фактического запуска кода.

С другой стороны, системы типов имеют свои ограничения. Типичным примером является «список элементов»:


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

public function filter(Closure $fn): self
{ /* … */ }

public function map(Closure $fn): self
{ /* … */ }
}

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

Но давайте посмотрим на это с точки зрения стороннего наблюдателя. Что произойдет, если мы хотим быть уверены, что одна коллекция содержит только строки, а другая только User объекты. Самой коллекции все равно, как перебираются ее элементы, но нам это важно. Мы хотим знать, является ли этот элемент в цикле пользователем или строкой - в этом большая разница. Но без надлежащей информации о типе наша IDE работает в полном неведении.

Теперь мы могли бы создать отдельные реализации для каждой коллекции: одну, которая работает только со строками, и другую, которая работает только с User объектами:


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

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

Но что, если нам нужна третья реализация? Четвертая? Может десятая или двадцатая. Становится довольно мучительно управлять всем этим кодом.  

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

Вместо создания отдельной реализации для каждого возможного типа многие языки программирования позволяют разработчикам определять «общий» тип в классе коллекции:


class Collection<Type> extends ArrayObject
{
public function offsetGet(mixed $key): Type
{ /* … */ }

// …
}

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


$users = new Collection<User>();

$names = new Collection<string>();

Может показаться, что сделали совсем немного: добавили тип.

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

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

Дженерики в деталях

Коллекции, вероятно, самый простой способ объяснить, что такое дженерики, но также люди нередко думают, что «дженерики» и «коллекции с типом» — это одно и то же. Это определенно не так.

Во фреймворке Laravel есть функция app - эта функция принимает имя класса и резолвит экземпляр этого класса, используя контейнер зависимостей:


function app(string $className): mixed
{
return Container::get($className);
}

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

Так что, по сути, это функция дженерика, чей возвращаемый тип  будет зависеть от того, какое имя класса вы ему передали. И было бы круто, если бы наша IDE и другие статические анализаторы тоже понимали, что если я даю этой функции имя класса UserRepository, я ожидаю, что будет возвращен экземпляр UserRepository, и ничего больше:


function app(string $className): mixed
{ /* … */ }

app(UserRepository::class); // ?

И я думаю, сейчас самое время упомянуть, что ранее я говорил, что дженериков не существует в PHP; ну и это не совсем так. Все статические анализаторы - инструменты, которые читают ваш код, не запуская его, такие инструменты, как ваша IDE - согласились использовать  для дженериков docblock аннотацию:


/**
* @template Type
* @param class-string<Type> $className
* @return Type
*/
function app(string $className): mixed
{ /* … */ }

Может быть это не самый красивый синтаксис, но все статические анализаторы полагаются на простое соглашение, официальной спецификации нет; но тем не менее это работает. PhpStorm, Psalm и PhpStan - три крупнейших статических анализатора в мире PHP - в некоторой степени понимают этот синтаксис. 

Так что на самом деле мы можем построить эту app функцию таким образом, чтобы наши инструменты больше не работали в "темноте". Конечно, сам PHP не гарантирует, что возвращаемый тип будет правильным - PHP не будет выполнять никаких проверок типа во время выполнения для этой функции; но если мы можем доверять нашим статическим анализаторам, шансов на то, что этот код сломается при запуске, очень мало или даже нет.

Итак, предположим что дженерики как бы есть в PHP и все основные статические анализаторы умеют с ними работать. Но… есть пара нюансов.

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

Второе: использование аннотации докблоков, возможно, не совсем оптимально. Они кажутся менее важной частью нашей кодовой базы. И само собой разумеется: общие аннотации предоставляют только статическую информацию и не имеют функциональности во время выполнения, но мы видели, насколько мощным может быть статический анализ даже без проверок типов во время выполнения. Я думаю, что несправедливо рассматривать информацию о типах как «комментарии документа», это не сообщает о важности этих типов в нашем коде. Вот почему мы получили атрибуты в PHP 8: вся функциональность, которую обеспечивают атрибуты, уже была возможна с аннотациями docblock, но этого было недостаточно. То же самое касается дженериков.

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

Так почему же в PHP до сих пор нет подходящих дженериков? Почему мы полагаемся на docblock-аннотации без четкой спецификации?

Почему не может быть дженериков в PHP

В прошлом году Никита Попов заявил что дженерики в PHP просто невыполнимы.

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

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


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. В PHP нет явного шага компиляции, как в Rust, чтобы разделить один общий класс на несколько конкретных реализаций; и, кроме того: мономорфные дженерики требуют довольно много памяти, потому что вы делаете несколько копий одного и того же класса с некоторыми отличиями. Это может быть не такой большой проблемой для скомпилированного двоичного файла Rust, но это серьезная проблема для PHP-кода, запускаемого на сервере и обслуживающего сотни или тысячи запросов в секунду.

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

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

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

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

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


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

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

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


$slugs = new Collection<string>();

$slugs[] = 1; // 1 won't be cast to '1'

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

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

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

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

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

В заключение о дженериках PHP

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

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

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

Итак, если мы хотим использовать дженерики в PHP, нам нужно изменить мышление:

  • Во-первых, разработчики должны использовать статический анализ . Ирония здесь в том, что разработчики, которым нужны дженерики и которые понимают их ценность, также понимают ценность статических средств проверки типов. Таким образом, если  PHP-разработчикам наплевать на статический анализ, они также могут не заботиться о дженериках. Потому что дженерики и статическая проверка типов просто не могут быть разделены.
  • Во-вторых, если внутренние разработчики PHP решат, что статически проверенные дженерики имеют место быть в PHP,  им следует задаться вопросом, следует ли оставить статический анализ на сообществе, создав спецификацию, которой должен следовать каждый статический анализатор, либо сделать свою собственную статическую проверку типов. Второй определенно был бы предпочтительнее, но вы можете себе представить, какое это был бы большой труд. Я не думаю, что полагаться на проверенные сторонние инструменты должно быть проблемой.
  • В-третьих, жонглирование типами было бы просто невозможно, по крайней мере, при использовании дженериков. Вам придется доверять вашей статической проверке типов. Это способ программирования, к которому PHP-разработчики на самом деле не привыкли, но многие другие языки делают именно это, и это отлично работает. Статическая проверка типов невероятно мощная и точная. Я могу себе представить, что разработчикам PHP трудно понять мощь языков со статической типизацией, не использовав их раньше. Стоит изучить такие языки, как Rust, Java или даже TypeScript, просто чтобы оценить мощь систем статических типов. Или вы можете начать использовать один из сторонних статических анализаторов PHP: Psalm или PHPStan.

Подводя итог: если нам нужны дженерики в PHP со всеми преимуществами, которые они приносят для статического анализа, нам нужно принять тот факт, что дженерики, стираемые во время выполнения, являются единственным жизнеспособным путем.

В заключение еще несколько замечаний, на которые я хотел бы обратить внимание.

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

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

Второе замечание заключается в том, что даже при стирании типов мы все еще можем предоставлять информацию об общем типе через Reflection API. Я не говорю, что информация о типе должна полностью исчезать во время выполнения, меня больше всего беспокоит то, что PHP не должен проверять общие типы во время выполнения. Я не уверен, какое влияние окажет на ядро ​​​​PHP доступность информации об общем типе, с помощью Reflection API, так что я просто скажу, что не против этой идеи.

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

Сергей Мухин

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

Есть вопросы?

Я почти всегда в режиме онлайн

Связаться со мной