Многие PHP разработчики хотели бы видеть в PHP поддержку дженериков, и я в том числе. RFC по их добавлению был создан ещё в 2016 году, но до сих пор не принял окончательный вид. Я рассмотрел несколько вариантов решений поддержки дженериков в синтаксисе PHP, но не нашёл рабочей версии, которой мог бы воспользоваться обычный разработчик.
В итоге я решил, что могу сам попробовать реализовать такое решение на PHP. Скриншот выше — реальный пример того, что у меня получилось.
Если хочется сразу попробовать, то вот библиотека mrsuh/php-generics и репо, в котором можно поиграться.
В качестве способа реализации дженериков я выбрал мономорфизацию.
Для тех, кто не слишком знаком, есть три основных способа реализации дженериков:
+ Type-erasure (стираемые): Дженерики просто удаляются и Foo<T> становится Foo. Во время выполнения дженерики ни на что не влияют, и предполагается, что проверки типов осуществляются на каком-то предварительном этапе компиляции/анализа (прим. Python, TypeScript).
+ Reification (реификация): Дженерики остаются в рантайме и могут быть на этом этапе использованы (и в случае PHP, могут быть проверены в рантайме).
+ Monomorphization (мономорфизация): С точки зрения пользователя, это очень похоже на реификацию, но подразумевает, что для каждой комбинации аргументов дженериков генерируется новый класс. То есть, Foo<T> не будет хранить информацию что, класс 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
<?php
namespace App;
class Box<T> {
private ?T $data = null;
public function set(T $data): void {
$this->data = $data;
}
public function get(): ?T {
return $this->data;
}
}
src/Usage.php
<?php
namespace App;
class Usage {
public function run(): void
{
$stringBox = new Box<string>();
$stringBox->set('cat');
var_dump($stringBox->get()); // string "cat"
$intBox = new Box<int>();
$intBox->set(1);
var_dump($intBox->get()); // integer 1
}
}
bin/test.php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use AppUsage;
$usage = new Usage();
$usage->run();
Сгенерировать конкретные классы из классов дженериков командой composer dump-generics
.
composer dump-generics -v
Generating concrete classes
- AppBoxForString
- AppBoxForInt
- AppUsage
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
<?php
namespace App;
class BoxForInt
{
private ?int $data = null;
public function set(int $data) : void
{
$this->data = $data;
}
public function get() : ?int
{
return $this->data;
}
}
cache/BoxForString.php
<?php
namespace App;
class BoxForString
{
private ?string $data = null;
public function set(string $data) : void
{
$this->data = $data;
}
public function get() : ?string
{
return $this->data;
}
}
cache/Usage.php
<?php
namespace App;
class Usage
{
public function run() : void
{
$stringBox = new AppBoxForString();
$stringBox->set('cat');
var_dump($stringBox->get());// string "cat"
$intBox = new AppBoxForInt();
$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 не определён конкретный синтаксис, поэтому я взял тот, который реализовывал Никита Попов.
Пример синтаксиса:
<?php
namespace App;
class Generic<in T: Iface = int, out V: Iface = string> {
public function test(T $var): V {
}
}
Проблемы с синтаксисом
Для парсинга кода пришлось допилить nikic/php-parser. Вот тут можно посмотреть изменения грамматики, которые пришлось внести для поддержки дженериков. Внутри парсера используется PHP реализация YACC. Реализация алгоритма YACC (LALR) и существующий синтаксис PHP не дают возможности использовать некоторые вещи, потому что они могут вызывать коллизии при генерации синтаксического анализатора.
Пример коллизии:
<?php
const FOO = 'FOO';
const BAR = 'BAR';
var_dump(new DateTime<FOO, BAR>('now')); // кажется, что здесь есть дженерик
var_dump( (new DateTime < FOO) , ( BAR > 'now') ); // на самом деле нет
Варианты решения можно почитать тут.
Поэтому на данный момент вложенные дженерики не поддерживаются.
<?php
namespace App;
class Usage {
public function run() {
$map = new Map<Key<int>, Value<string>>();//не поддерживается
}
}
Имена параметров не имеют каких-то специальных ограничений
<?php
namespace App;
class GenericClass<T, varType, myCoolLongParaterName> {
private T $var1;
private varType $var2;
private myCoolLongParaterName $var3;
}
Можно использовать несколько параметров в дженериках
<?php
namespace App;
class Map<keyType, valueType> {
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;
}
}
Можно использовать значения по умолчанию
<?php
namespace App;
class Map<keyType = string, valueType = int> {
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;
}
}
<?php
namespace App;
class Usage {
public function run() {
$map = new Map<>();//обязательно нужно добавить знаки "<>"
$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
Пример класса, который использует дженерики:
<?php
namespace App;
use AppEntityCat;
use AppEntityBird;
use AppEntityDog;
class Test extends GenericClass<Cat> implements GenericInterface<Bird> {
use GenericTrait<Dog>;
private GenericClass<int>|GenericClass<Dog> $var;
public function test(GenericInterface<int>|GenericInterface<Dog> $var): GenericClass<string>|GenericClass<Bird> {
var_dump($var instanceof GenericInterface<int>);
var_dump(new GenericClass<int>::class);
var_dump(new GenericClass<array>::CONSTANT);
return new GenericClass<float>();
}
}
В каком месте класса дженерика можно использовать параметры дженериков?
- extends
- implements
- trait use
- property type
- method argument type
- method return type
- instanceof
- new
- class constants
Пример класса дженерика:
<?php
namespace App;
class Test<T,V> extends GenericClass<T> implements GenericInterface<V> {
use GenericTrait<T>;
use T;
private T|GenericClass<V> $var;
public function test(T|GenericInterface<V> $var): T|GenericClass<V> {
var_dump($var instanceof GenericInterface<V>);
var_dump($var instanceof T);
var_dump(new GenericClass<T>::class);
var_dump(T::class);
var_dump(new GenericClass<T>::CONSTANT);
var_dump(T::CONSTANT);
$obj1 = new T();
$obj2 = new GenericClass<V>();
return $obj2;
}
}
Насколько быстро работает?
Все конкретные классы генерируются заранее, и их можно кешировать (не должно влиять на производительность).
Генерация множества конкретных классов должна негативно сказываться на производительности при:
- резолве конкретных классов;
- хранении конкретных классов в памяти;
- проверки типов для каждого конкретного класса.
Думаю, всё индивидуально, и нужно проверять на конкретном проекте.
Нельзя использовать без composer autoload
Магия с автозагрузкой сгенерированных конкретных классов будет работать только с composer autoload.
Если вы напрямую подключите класс с дженериком через require, то у вас ничего не будет работать из-за ошибки синтаксиса.
PhpUnit по своим соображениям подключает файлы тестов только через require.
Поэтому использовать классы дженериков внутри тестов PhpUnit не получится.
IDE
-
PhpStorm
Не поддерживает синтаксис дженериков, потому что даже RFC ещё не до конца сформирован.
Также PhpStorm не имеет работающего плагина для подключения LSP, чтобы иметь возможность поддерживаеть синтаксисы сторонних языков.
От поддержки Hack (который уже поддерживает дженерики) отказались. -
VSCode
Поддерживает синтаксис дженериков после установки плагина для Hack.
Нет автодополнения.
Reflection
PHP выполняет проверки типов в runtime. Значит, все аргументы дженериков должны быть доступны через reflection в runtime. А этого не может быть, потому что информация о аргументах дженериков после генерации конкретных классов стирается.
Что не реализовано по RFC
Дженерики функций, анонимных функций и методов
<?php
namespace App;
function foo<T,V>(T $arg): V {
}
Проверка типов параметров дженериков
T должен быть подклассом или имплементировать интерфейс TInterface.
<?php
namespace App;
class Generic<T: TInterface> {
}
Вариантность параметров
<?php
namespace App;
class Generic<in T, out V> {
}
Существующие решения на PHP
Psalm Template Annotations
Особенности:
- не меняет синтаксис языка;
- дженерики/шаблоны пишутся через аннотации;
- проверки типов проиcходят при статическом анализе Psalm или IDE.
<?php
/**
* @template T
*/
class MyContainer {
/** @var T */
private $value;
/** @param T $value */
public function __construct($value) {
$this->value = $value;
}
/** @return T */
public function getValue() {
return $this->value;
}
}
spatie/typed
Особенности:
- не меняет синтаксис языка;
- можно создать список со определённым типом, но его нельзя указать в качестве типа параметра функции или возвращаемого типа функции;
- проверки типов происходят во время runtime.
<?php
$list = new Collection(T::bool());
$list[] = new Post(); // TypeError
<?php
$point = new Tuple(T::float(), T::float());
$point[0] = 1.5;
$point[1] = 3;
$point[0] = 'a'; // TypeError
$point['a'] = 1; // TypeError
$point[10] = 1; // TypeError
TimeToogo/PHP-Generics
Особенности:
- не меняет синтаксис языка;
- все вхождения TYPE заменяются на реальные типы, и на основе этого генерируются конкретные классы и сохраняются в ФС;
- подмена классов происходит во время autoload и для этого нужно использовать встроенный autoloader;
- проверки типов происходят во время runtime.
<?php
class Maybe {
private $MaybeValue;
public function __construct(__TYPE__ $Value = null) {
$this->MaybeValue = $Value;
}
public function HasValue() {
return $this->MaybeValue !== null;
}
public function GetValue() {
return $this->MaybeValue;
}
public function SetValue(__TYPE__ $Value = null) {
$this->MaybeValue = $Value;
}
}
<?php
$Maybe = new MaybestdClass();
$Maybe->HasValue(); //false
$Maybe->SetValue(new stdClass());
$Maybe->HasValue(); //true
$Maybe->SetValue(new DateTime()); //ERROR
<?php
$Configuration = new GenericsConfiguration();
$Configuration->SetIsDevelopmentMode(true);
$Configuration->SetRootPath(__DIR__);
$Configuration->SetCachePath(__DIR__ . '/Cache');
//Register the generic auto loader
GenericsLoader::Register($Configuration);
ircmaxell/PhpGenerics
Особенности:
- добавлен новый синтаксис;
- все вхождения T заменяются на реальные типы, и на основе этого генерируются конкретные классы и выполняется их загрузка через eval();
- подмена классов происходит во время autoload, и для этого нужно использовать встроенный autoloader;
- проверки типов происходят во время runtime.
Test/Item.php
<?php
namespace test;
class Item<T> {
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
<?php
namespace Test;
class Test {
public function runTest()
{
$item = new Item<StdClass>;
var_dump($item instanceof Item); // true
$item->setItem(new StdClass); // works fine
// $item->setItem([]); // E_RECOVERABLE_ERROR
}
}
test.php
<?php
require "vendor/autoload.php";
$test = new TestTest;
$test->runTest();
Отличие от mrsuh/php-generics:
- конкретные классы генерируются во время autoload;
- конкретные классы подгружаются через eval();
- подменяется стандартный composer autoload;
- код написан давно, поэтому нет поддержки последних версий PHP.
Заключение
Думаю, у меня получилось то, чего я хотел: библиотека легко устанавливается и может использоваться на реальных проектах. Расстраивает то, что по понятным причинам популярные IDE не поддерживают в полной мере новый синтаксис дженериков, поэтому сейчас пользоваться им сложно.
Если у вас будут предложения или вопросы, можете оставлять их тут или в комментариях.
Автор: Anton Sukhachev