PHP Generics. Right here. Right now
Многие PHP разработчики хотели бы видеть в PHP поддержку дженериков, и я в том числе.
RFC по их добавлению был создан ещё в 2016 году, но до сих пор не принял окончательный вид.
Я рассмотрел несколько вариантов решений поддержки дженериков в синтаксисе PHP, но не нашёл рабочей версии, которой мог бы воспользоваться обычный разработчик.
В итоге я решил, что могу сам попробовать реализовать такое решение на PHP.
Скриншот выше — реальный пример того, что у меня получилось.
Если хочется сразу попробовать, то вот библиотека mrsuh/php-generics и репо, в котором можно поиграться.
В качестве способа реализации дженериков я выбрал мономорфизацию.
Цитата отсюда. Оригинал тут.
Для тех, кто не слишком знаком, есть три основных способа реализации дженериков:
+ Type-erasure (стираемые): Дженерики просто удаляются и Foo становится Foo. Во время выполнения дженерики ни на что не влияют, и предполагается, что проверки типов осуществляются на каком-то предварительном этапе компиляции/анализа (прим. Python, TypeScript).
+ Reification (реификация): Дженерики остаются в рантайме и могут быть на этом этапе использованы (и в случае PHP, могут быть проверены в рантайме).
+ Monomorphization (мономорфизация): С точки зрения пользователя, это очень похоже на реификацию, но подразумевает, что для каждой комбинации аргументов дженериков генерируется новый класс. То есть, Foo не будет хранить информацию что, класс Foo инстанциирован с параметром T, а вместо этого будут созданы классы Foo_T1, Foo_T2, …, Foo_Tn специализированный для данного типа параметра.
Как работает?
Кратко:
- парсим классы дженериков;
- генерируем на их основе конкретные классы;
- указываем для composer autoload, что в первую очередь нужно загружать файлы из директории со сгенерированными классами, а уже потом — из основной.
Подробный алгоритм.
Нужно подключить библиотеку как зависимость composer (минимальная версия PHP 7.4).
composer require mrsuh/php-generics
Добавить ещё одну директорию («cache/») в composer autoload PSR-4 для сгенерированных классов.
Она обязательно должна идти перед основной директорией.
composer.json
{
"autoload": {
"psr-4": {
"App\\": ["cache/","src/"]
}
}
}
Для примера нужно добавить несколько PHP файлов:
- класс дженерик
Box
; - класс
Usage
, который его использует; - скрипт, который подключает composer
autoload
и использует классUsage
.
src/Box.php
{
private ?T $data = null;
public function set(T $data): void {
$this->data = $data;
}
public function get(): ?T {
return $this->data;
}
}
src/Usage.php
();
$stringBox->set('cat');
var_dump($stringBox->get()); // string "cat"
$intBox = new Box();
$intBox->set(1);
var_dump($intBox->get()); // integer 1
}
}
bin/test.php
run();
Сгенерировать конкретные классы из классов дженериков командой composer dump-generics
.
composer dump-generics -v
Generating concrete classes
- App\BoxForString
- App\BoxForInt
- App\Usage
Generated 3 concrete classes in 0.062 seconds, 16.000 MB memory used
Что делает скрипт composer dump-generics
:
- находит все использования дженериков (как в случае с файлом
src/Usage.php
); - генерирует для них уникальные (на основе имени класса и аргументов) конкретные классы из классов дженериков;
- заменяет в местах использования дженерики на конкретные имена классов.
В данном случае должны быть сгенерированы:
- 2 конкретных класса дженериков
BoxForInt
иBoxForString
; - 1 конкретный класс
Usage
, в котором все классы дженериков заменены на конкретные.
cache/BoxForInt.php
data = $data;
}
public function get() : ?int
{
return $this->data;
}
}
cache/BoxForString.php
data = $data;
}
public function get() : ?string
{
return $this->data;
}
}
cache/Usage.php
set('cat');
var_dump($stringBox->get());// string "cat"
$intBox = new \App\BoxForInt();
$intBox->set(1);
var_dump($intBox->get());// integer 1
}
}
Сгенерировать актуальный vendor/autoload.php файл командой composer dump-autoload
.
composer dump-autoload
Generating autoload files
Generated autoload files
Запустить скрипт.
php bin/test.php
Composer autoload сначала будет проверять, есть ли класс в директории «cache», а уже потом в директории «src».
Пример с кодом выше можно посмотреть тут.
Больше примеров можно посмотреть тут.
Особенности реализации
Какой синтаксис используется?
В RFC не определён конкретный синтаксис, поэтому я взял тот, который реализовывал Никита Попов.
Пример синтаксиса:
{
public function test(T $var): V {
}
}
Проблемы с синтаксисом
Для парсинга кода пришлось допилить nikic/php-parser.
Вот тут можно посмотреть изменения грамматики, которые пришлось внести для поддержки дженериков.
Внутри парсера используется PHP реализация YACC.
Реализация алгоритма YACC (LALR) и существующий синтаксис PHP не дают возможности использовать некоторые вещи, потому что они могут вызывать коллизии при генерации синтаксического анализатора.
Пример коллизии:
('now')); // кажется, что здесь есть дженерик
var_dump( (new \DateTime < FOO) , ( BAR > 'now') ); // на самом деле нет
Варианты решения можно почитать тут.
Поэтому на данный момент вложенные дженерики не поддерживаются.
, Value>();//не поддерживается
}
}
Имена параметров не имеют каких-то специальных ограничений
{
private T $var1;
private varType $var2;
private myCoolLongParaterName $var3;
}
Можно использовать несколько параметров в дженериках
{
private array $map;
public function set(keyType $key, valueType $value): void {
$this->map[$key] = $value;
}
public function get(keyType $key): ?valueType {
return $this->map[$key] ?? null;
}
}
Можно использовать значения по умолчанию
{
private array $map = [];
public function set(keyType $key, valueType $value): void {
$this->map[$key] = $value;
}
public function get(keyType $key): ?valueType {
return $this->map[$key] ?? null;
}
}
();//обязательно нужно добавить знаки "<>"
$map->set('key', 1);
var_dump($map->get('key'));
}
}
В каком месте класса можно использовать дженерики?
- extends
- implements
- trait use
- property type
- method argument type
- method return type
- instanceof
- new
- class constants
Пример класса, который использует дженерики:
implements GenericInterface {
use GenericTrait;
private GenericClass|GenericClass $var;
public function test(GenericInterface|GenericInterface $var): GenericClass|GenericClass {
var_dump($var instanceof GenericInterface);
var_dump(new GenericClass::class);
var_dump(new GenericClass::CONSTANT);
return new GenericClass();
}
}
В каком месте класса дженерика можно использовать параметры дженериков?
- extends
- implements
- trait use
- property type
- method argument type
- method return type
- instanceof
- new
- class constants
Пример класса дженерика:
extends GenericClass implements GenericInterface {
use GenericTrait;
use T;
private T|GenericClass $var;
public function test(T|GenericInterface $var): T|GenericClass {
var_dump($var instanceof GenericInterface);
var_dump($var instanceof T);
var_dump(new GenericClass::class);
var_dump(T::class);
var_dump(new GenericClass::CONSTANT);
var_dump(T::CONSTANT);
$obj1 = new T();
$obj2 = new GenericClass();
return $obj2;
}
}
Насколько быстро работает?
Все конкретные классы генерируются заранее, и их можно кешировать (не должно влиять на производительность).
Генерация множества конкретных классов должна негативно сказываться на производительности при:
- резолве конкретных классов;
- хранении конкретных классов в памяти;
- проверки типов для каждого конкретного класса.
Думаю, всё индивидуально, и нужно проверять на конкретном проекте.
Нельзя использовать без composer autoload
Магия с автозагрузкой сгенерированных конкретных классов будет работать только с composer autoload.
Если вы напрямую подключите класс с дженериком через require, то у вас ничего не будет работать из-за ошибки синтаксиса.
PhpUnit по своим соображениям подключает файлы тестов только через require.
Поэтому использовать классы дженериков внутри тестов PhpUnit не получится.
IDE
PhpStorm
Не поддерживает синтаксис дженериков, потому что даже RFC ещё не до конца сформирован.
Также PhpStorm не имеет работающего плагина для подключения LSP, чтобы иметь возможность поддерживаеть синтаксисы сторонних языков.
От поддержки Hack (который уже поддерживает дженерики) отказались.VSCode
Поддерживает синтаксис дженериков после установки плагина для Hack.
Нет автодополнения.
Reflection
PHP выполняет проверки типов в runtime.
Значит, все аргументы дженериков должны быть доступны через reflection в runtime.
А этого не может быть, потому что информация о аргументах дженериков после генерации конкретных классов стирается.
Что не реализовано по RFC
Дженерики функций, анонимных функций и методов
(T $arg): V {
}
Проверка типов параметров дженериков
T должен быть подклассом или имплементировать интерфейс TInterface.
{
}
Вариантность параметров
{
}
Существующие решения на PHP
Psalm Template Annotations
Особенности:
- не меняет синтаксис языка;
- дженерики/шаблоны пишутся через аннотации;
- проверки типов проиcходят при статическом анализе Psalm или IDE.
value = $value;
}
/** @return T */
public function getValue() {
return $this->value;
}
}
spatie/typed
Особенности:
- не меняет синтаксис языка;
- можно создать список со определённым типом, но его нельзя указать в качестве типа параметра функции или возвращаемого типа функции;
- проверки типов происходят во время runtime.
TimeToogo/PHP-Generics
Особенности:
- не меняет синтаксис языка;
- все вхождения TYPE заменяются на реальные типы, и на основе этого генерируются конкретные классы и сохраняются в ФС;
- подмена классов происходит во время autoload и для этого нужно использовать встроенный autoloader;
- проверки типов происходят во время runtime.
MaybeValue = $Value;
}
public function HasValue() {
return $this->MaybeValue !== null;
}
public function GetValue() {
return $this->MaybeValue;
}
public function SetValue(__TYPE__ $Value = null) {
$this->MaybeValue = $Value;
}
}
HasValue(); //false
$Maybe->SetValue(new stdClass());
$Maybe->HasValue(); //true
$Maybe->SetValue(new DateTime()); //ERROR
SetIsDevelopmentMode(true);
$Configuration->SetRootPath(__DIR__);
$Configuration->SetCachePath(__DIR__ . '/Cache');
//Register the generic auto loader
\Generics\Loader::Register($Configuration);
ircmaxell/PhpGenerics
Особенности:
- добавлен новый синтаксис;
- все вхождения T заменяются на реальные типы, и на основе этого генерируются конкретные классы и выполняется их загрузка через eval ();
- подмена классов происходит во время autoload, и для этого нужно использовать встроенный autoloader;
- проверки типов происходят во время runtime.
Test/Item.php
{
protected $item;
public function __construct(T $item = null)
{
$this->item = $item;
}
public function getItem()
{
return $item;
}
public function setItem(T $item)
{
$this->item = $item;
}
}
Test/Test.php
;
var_dump($item instanceof Item); // true
$item->setItem(new StdClass); // works fine
// $item->setItem([]); // E_RECOVERABLE_ERROR
}
}
test.php
runTest();
Отличие от mrsuh/php-generics:
- конкретные классы генерируются во время autoload;
- конкретные классы подгружаются через eval ();
- подменяется стандартный composer autoload;
- код написан давно, поэтому нет поддержки последних версий PHP.
Заключение
Думаю, у меня получилось то, чего я хотел: библиотека легко устанавливается и может использоваться на реальных проектах.
Расстраивает то, что по понятным причинам популярные IDE не поддерживают в полной мере новый синтаксис дженериков, поэтому сейчас пользоваться им сложно.
Если у вас будут предложения или вопросы, можете оставлять их тут или в комментариях.