PHP Generics. Right here. Right now

в 7:38, , рубрики: generics, open source, php, Программирование

PHP Generics. Right here. Right now - 1

Многие 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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js