- PVSM.RU - https://www.pvsm.ru -

PHP Generics. Right here. Right now

PHP Generics. Right here. Right now - 1

Многие PHP разработчики хотели бы видеть в PHP поддержку дженериков [1], и я в том числе. RFC [2] по их добавлению был создан ещё в 2016 году, но до сих пор не принял окончательный вид. Я рассмотрел несколько вариантов решений поддержки дженериков в синтаксисе PHP, но не нашёл рабочей версии, которой мог бы воспользоваться обычный разработчик.

В итоге я решил, что могу сам попробовать реализовать такое решение на PHP. Скриншот выше — реальный пример того, что у меня получилось.

Если хочется сразу попробовать, то вот библиотека mrsuh/php-generics [3] и репо [4], в котором можно поиграться.

В качестве способа реализации дженериков я выбрал мономорфизацию.

Цитата отсюда [5]. Оригинал тут [6].

Для тех, кто не слишком знаком, есть три основных способа реализации дженериков:
+ 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".

Пример с кодом выше можно посмотреть тут [4].
Больше примеров можно посмотреть тут [7].

Особенности реализации

Какой синтаксис используется?

В RFC [2] не определён конкретный синтаксис, поэтому я взял тот [8], который реализовывал Никита Попов.

Пример синтаксиса:

<?php

namespace App;

class Generic<in T: Iface = int, out V: Iface = string> {

   public function test(T $var): V {

   }
}

Проблемы с синтаксисом

Для парсинга кода пришлось допилить nikic/php-parser [9]. Вот тут [10] можно посмотреть изменения грамматики, которые пришлось внести для поддержки дженериков. Внутри парсера используется PHP реализация [11] YACC [12]. Реализация алгоритма YACC (LALR) и существующий синтаксис PHP не дают возможности использовать некоторые вещи, потому что они могут вызывать коллизии при генерации синтаксического анализатора.

Пример коллизии:

<?php

const FOO = 'FOO';
const BAR = 'BAR';

var_dump(new DateTime<FOO, BAR>('now')); // кажется, что здесь есть дженерик
var_dump( (new DateTime < FOO) , ( BAR > 'now') ); // на самом деле нет

Варианты решения можно почитать тут [13].

Поэтому на данный момент вложенные дженерики не поддерживаются.

<?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 по своим соображениям [14] подключает файлы тестов только через require.
Поэтому использовать классы дженериков внутри тестов PhpUnit не получится.

IDE

  • PhpStorm
    Не поддерживает синтаксис дженериков, потому что даже RFC [2] ещё не до конца сформирован.
    Также PhpStorm не имеет работающего плагина [15] для подключения LSP [16], чтобы иметь возможность поддерживаеть синтаксисы сторонних языков.
    От поддержки Hack [17] (который уже поддерживает дженерики) отказались [18].

  • VSCode
    Поддерживает синтаксис дженериков после установки плагина для Hack [19].
    Нет автодополнения.

Reflection

PHP выполняет проверки типов в runtime [20]. Значит, все аргументы дженериков должны быть доступны [21] через 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 [22]

Особенности:

  • не меняет синтаксис языка;
  • дженерики/шаблоны пишутся через аннотации;
  • проверки типов проиcходят при статическом анализе Psalm [23] или 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 [24]

Особенности:

  • не меняет синтаксис языка;
  • можно создать список со определённым типом, но его нельзя указать в качестве типа параметра функции или возвращаемого типа функции;
  • проверки типов происходят во время 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 [25]

Особенности:

  • не меняет синтаксис языка;
  • все вхождения 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 [26]

Особенности:

  • добавлен новый синтаксис;
  • все вхождения 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 [3]:

  • конкретные классы генерируются во время autoload;
  • конкретные классы подгружаются через eval();
  • подменяется стандартный composer autoload;
  • код написан давно, поэтому нет поддержки последних версий PHP.

Заключение

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

Если у вас будут предложения или вопросы, можете оставлять их тут [27] или в комментариях.

Автор: Anton Sukhachev

Источник [28]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/programmirovanie/367833

Ссылки в тексте:

[1] дженериков: https://en.wikipedia.org/wiki/Generic_programming

[2] RFC: https://github.com/PHPGenerics/php-generics-rfc

[3] mrsuh/php-generics: https://github.com/mrsuh/php-generics

[4] репо: https://github.com/mrsuh/php-generics-example

[5] отсюда: https://habr.com/ru/company/skyeng/blog/543794

[6] тут: https://www.reddit.com/r/PHP/comments/j65968/ama_with_the_phpstorm_team_from_jetbrains_on/g83skiz/?context=3

[7] тут: https://github.com/mrsuh/php-generics/tests

[8] тот: https://github.com/PHPGenerics/php-generics-rfc/issues/45

[9] nikic/php-parser: https://github.com/nikic/PHP-Parser

[10] тут: https://github.com/mrsuh/PHP-Parser/pull/1/files#diff-14ec37995c001c0c9808ab73668d64db5d1acc1ab0f60a360dcb9c611ecd57ea

[11] PHP реализация: https://github.com/ircmaxell/PHP-Yacc

[12] YACC: https://ru.wikipedia.org/wiki/Yacc

[13] тут: https://github.com/PHPGenerics/php-generics-rfc/issues/35#issuecomment-571546650

[14] своим соображениям: https://github.com/sebastianbergmann/phpunit/issues/4039

[15] плагина: https://plugins.jetbrains.com/plugin/10209-lsp-support

[16] LSP: https://en.wikipedia.org/wiki/Language_Server_Protocol

[17] Hack: https://hacklang.org

[18] отказались: https://blog.jetbrains.com/phpstorm/2015/06/hack-language-support-in-phpstorm-postponed/

[19] плагина для Hack: https://marketplace.visualstudio.com/items?itemName=pranayagarwal.vscode-hack

[20] runtime: https://github.com/PHPGenerics/php-generics-rfc/issues/43

[21] должны быть доступны: https://github.com/PHPGenerics/php-generics-rfc/blob/cc7219792a5b35226129d09536789afe20eac029/generics.txt#L426-L430

[22] Psalm Template Annotations: https://psalm.dev/docs/annotating_code/templated_annotations/

[23] Psalm: https://psalm.dev

[24] spatie/typed: https://github.com/spatie/typed

[25] TimeToogo/PHP-Generics: https://github.com/TimeToogo/PHP-Generics

[26] ircmaxell/PhpGenerics: https://github.com/ircmaxell/PhpGenerics

[27] тут: https://github.com/mrsuh/php-generics/issues

[28] Источник: https://habr.com/ru/post/577750/?utm_source=habrahabr&utm_medium=rss&utm_campaign=577750