Многие 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 App\Usage;
$usage = new Usage();
$usage->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
<?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 \App\BoxForString();
$stringBox->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 не определён конкретный синтаксис, поэтому я взял тот, который реализовывал Никита Попов.
Пример синтаксиса:
<?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 App\Entity\Cat;
use App\Entity\Bird;
use App\Entity\Dog;
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(GenericClass<int>::class);
var_dump(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(GenericClass<T>::class);
var_dump(T::class);
var_dump(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 Maybe\stdClass();
$Maybe->HasValue(); //false
$Maybe->SetValue(new stdClass());
$Maybe->HasValue(); //true
$Maybe->SetValue(new DateTime()); //ERROR
<?php
$Configuration = new \Generics\Configuration();
$Configuration->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
<?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 Test\Test;
$test->runTest();
Отличие от mrsuh/php-generics:
- конкретные классы генерируются во время autoload;
- конкретные классы подгружаются через eval();
- подменяется стандартный composer autoload;
- код написан давно, поэтому нет поддержки последних версий PHP.
Заключение
Думаю, у меня получилось то, чего я хотел: библиотека легко устанавливается и может использоваться на реальных проектах. Расстраивает то, что по понятным причинам популярные IDE не поддерживают в полной мере новый синтаксис дженериков, поэтому сейчас пользоваться им сложно.
Если у вас будут предложения или вопросы, можете оставлять их тут или в комментариях.
Комментарии (17)
BoShurik
14.09.2021 11:36+2PHP выполняет проверки типов в runtime.Значит, все аргументы дженериков должны быть доступны через reflection в runtime.А этого не может быть, потому что информация о аргументах дженериков после генерации конкретных классов стирается.
Как вариант - добавить в сгенерированые классы атрибуты, в которых уже эти данные предоставить
olegl84
14.09.2021 14:51+2Разработчики указывали что с дженериками есть проблемы технического характера. Дженерики в виде PHPDOC уже существуют и из поддержка появилась в последнем PHPStorm
nick1612
14.09.2021 21:38+5Тоже поставил плюс за старания, но я сомневаюсь, что это реально кто-то будет использовать. Как говорится "лекарство хуже болезни". Если честно, то я вообще очень скептически отношусь к современным попыткам сделать из php подобие джавы. У php много минусов, но все же, если ты хорошо знаешь подводные камни, то на данный момент, он остается самым практичным языком для веб разработки.
rvazh
15.09.2021 10:10Статья хорошая, спасибо. Кстати, те же phpstan или psalm вполне позволяют использовать дженерики с помощью template (psalm-template) аннотаций.
Standfest
16.09.2021 10:29Статья интересная, спасибо!
Но
библиотека легко устанавливается и может использоваться на реальных проектах
упаси Господь.
Dier_Sergio_Great
26.09.2021 11:53Уважаемый автор. Я так же силножелающий Дженериков.
Я прежде участвовал в описании характеристик Generic PHP на Github в черновике. (Та ветка уже закрыта/удалена.)
Кроме желаемого от дженериков в PHP я так же узнал различные нюансы.
1.Полная поддержка дженериков может быть в статическая и динамическая.
2.Динамическая поддержка может быть Полная и игнорируемая.Мы должны придерживаться не только поддержки Дженериков но и сохранить скорость исполнения.
Если с каждым новым Дженериком будет генерироваться новый PHP файл, то разумеется это снизит производительность.
Попов на эту тему уже давал комментарии.
Прежде был предложен вариант, в котором Атрибуты в Дженериках будут просто игнорироваться PHP движком, как бы он их будет не замечать.
Таким образом PHP не сможет следить за атрибутами Дженериков в полном объеме.
Но так и не надо.
TypeScript так же не имеет типов после компиляции.
Главное чтобы за Generic типами следил IDE редактор: VSCode, NetBeans, Eqlipse, PHPStorm.
С этой точки зрения сохранится скорость работы самого PHP, и мы получим полную статическую поддержку Дженериков.
Могли Вы бы скомпилировать еще одну версию где атрибуты дженериков будут удалятся?
И так же прошу Вас написать новую статью на Хабре на разработке новой версии.
Спасибо Вам большое.Dier_Sergio_Great
26.09.2021 11:57Ой, еще маленькую задачку, предложу Вам.
Если у Вас получится собрать версию PHP где дженерики игнорируются. Может быть Вы сделаете плагин для любого из редакторов кода который будет поддерживать Дженерики, Быть может для VSCode, либо для того редактора, которым Вы сейчас пользуетесь.Я думаю что в этом случае комментарии будут уже другими, так как это будет уже по настоящему быстроработающая машина PHP без тормозов и с поддержкой Дженериков.
mrsuh Автор
27.09.2021 09:11Спасибо за комментарий!
Дженерики в PHP - это не тоолько про typehint. Это еще, например, вариантность типов, наследование типов.
Вот тут можно почитать дискуссию о стираемых типах в PHP.
Вот тут можно почитать про наследование типов атрибутов.
Но даже если мы отбросим все это и оставим typehint только на стороне статического анализатора, то простого стирания атрибутов все равно будет недостаточно для нормальной работы PHP.Вот пример со стиранием атрибутов, когда все будет работать отлично:
<?php class Generic<T> { public function add(T $val) { $this->val = $val; } }
Вот пример, когда мы не можем просто так взять и стереть атрибуты:
<?php class Generic<T> { public function add($val) { if(!$val instanceof T) { throw new \Exception(); } var_dump(T::class); return new T(); } }
Пример вымешленный, но идея должно быть понятна.
В этом случае для каждого такого уникального атрибуты нужно завести отдельный класс с уникальным названием. Что я и сделал, оставив все typehint для runtime.P.S. Вижу, что вы пишите про компиляцию PHP. Хочу пояснить, что библиотека полностью написана на PHP и не требует перекомпиляции самого движка PHP.
Dier_Sergio_Great
27.09.2021 12:44Я предлагаю чтобы дженерики целиком игнорировались.
Чтобы Выше описанный код дженерика воспринимался и обрабатывался так:
<?php
class Generic {
public function add($val) {
$this->val = $val;
}
}
Т.е. такое поведение PHP будет работать без генерации допклассов, с той скоростью как оригинальный.
А анализом типов дженериков будет заниматься IDE.
В принципе типизация нужна чтобы писали люди код правильно. Если проблему типизации решит IDE это то что нужно, по аналогии TS.
Вначале в PHP были просто переменные, потом добавили типизированные переменные, быть может попробуем сделать промежуточную остановку сделаем дженерики игнорируемые. А уж тогда потом будем делать дженерики на уровне PHP с полной поддержкой.
Моё предложение позволит разработчикам сразу войти в дженерики, даже в продакшине и на высоконагруженных серверах. А после можно переходить к Вашей реализации.
Допускаю что в Вашей реализации могут быть не очевидные тонкости приводящие к непредсказуемым результатам или ошибкам. А код уже будет у людей написан и его нужно переписывать. А при создании PHP с игнорированием дженериков, код будет работать всегда так как он будет аналогичен оригинальному.
Потому что пока люди спорят как надо, мы ни когда их не получим. А получив стираемые дженерики, мы можем сравнивать их в разных IDE
mrsuh Автор
27.09.2021 13:58В таком случае уже есть Psalm Template Annotations)
Они как раз не добавляют ничего нового в язык. Не нужна генерация кода. Весь код проверяется статическим анализатором (отдельным или в IDE)
Dier_Sergio_Great
27.09.2021 13:06А что если тип T не будет как тип , и его нельзя будет использовать.
Или Вы написали пример что нельзя просто так игнорировать Т. Но тогда вместо Т сделайте $Т или __T.
mrsuh Автор
27.09.2021 14:02Если предполагать, что типами в дженериках могут быть только "встроенные типы"(и то не все, а только самые простые вроде int/string), то это довольно сильно урезает функционал дженериков и тогда становится возможным полное удаление атрибутов из кода.
Но, как я отписался выше, для этого уже есть инструменты.
Dier_Sergio_Great
03.10.2021 13:39Здравствуйте.
Инструменты то есть, но такие инструменты сложны для эксплуатации, одно дело тип написать, а другое дело Аннотация писать. Приемущество полного удаления атрибутов кода все таки имеет удобвство. На эту тему можно спорить. Но если была бы такая реализация с поддержкой статических типов в IDE, было бы уже чем то рабочим и внедряемым в реальные проекты.Если даже не думать на эту тему.
Подскажите пожалуйста, быть может есть у Вас какая нибудь мысль как реализовать дженерики, но без генерации кода? Может что то удалять придется или в памяти держать массив типов.
Вы можете что нибудь подсказать в этой в таком контексте.
Dier_Sergio_Great
17.11.2021 01:14Извините меня, Вы правы я сам не понимал как оно работает. Вы объяснили, стало ясно что Ваш модуль может быть только такой и ни какой другой.
Подскажите, а Вы пробовали отправить Ваш модуль в OpenServer, WAMP, php разработчикам?.
У PHP есть вроде каталог модулей, может они сошласибюлисьбы добавить Ваш модуль в своей каталог.
varanio
Статью плюсанул, потому что это большой труд, и в целом поддерживаю, что дженерики нужны. Спасибо за хорошую попытку.
Однако, в то, что такой подход на кодогенерации хоть сколько-нибудь распространится - тут есть сомнения. Все такие решения: асинхронность через костыли, дженерики через кодогенерацию - обычно остаются в небольших группах фанатов.
Поэтому ждем, когда (если) добавят это в сам язык
mrsuh Автор
Вариант с кодогенерацией плох тем, что PHP проверяет все в runtime.
Даже если добавить какие-то проверки типов во время генерации, то это будет противоречить общему принципу работы PHP.
По поводу добавления дженериков в сам язык - после изучения RFC и его обсуждений пришел к выводу, что должно произойти что-то кардинальное, чтобы их добавили:
+ RFC не сформирован до конца. Даже синтаксис (за 7 лет);
+ у текущего синтаксиса есть проблемы с парсингом (https://github.com/PHPGenerics/php-generics-rfc/issues/35#issuecomment-571546650);
+ сообщестовом отвергается варианты, которые идут по простому пути или противоречат общему принципу работы PHP. Вариант моей библиотеки, реализованный в самом PHP, может сильно увеличить расход памяти, хотя он не очень сложен в реализации. (https://github.com/PHPGenerics/php-generics-rfc/issues/42).
Исходя из этого, я не вижу предпосылок для добавления дженериков в сам PHP (хотя в Hack они добавлены в каком-то виде).
Dier_Sergio_Great
Спасибо за труд и вклад.
В комментах пишут можно ли использовать или не использовать.
Но дело в другом, это эксперимент, который делает попытки сдвинуть то что не способно сдвинуться само.
Спасибо.
Возможно эта библиотека попадет в англоязычное сообщество, и люди начнут поддерживать. Или может в IDE появится поддержка.
Когда-то node.js был экспериментом. А сейчас это серьёзный инструмент.
TypeScriptи тоже генерирует код, и все довольны.
Пока видны некоторые проблемы в этой библиотеке. Давайте о опишем эти проблемы, поставим заплачу и план.
Может правка этих задач будет через несколько лет, но главное что начало есть, и может получится что-то великолепное для всех.