Привет из 2001 года: Generic C# specification
Совсем недавно товарищ Don Syme выложил в своём блоге привет из далёкого прошлого Microsoft Research - драфт спецификации research-языка GC# (Generic C#) от 12 декабря 2001 года. Не сложно догадаться, что этот язык был дополнением к уже существующему тогда языку C# 1.0, добавляющим в поддержку параметрического полиморфизма.
Спецификация интересна тем, что достаточно существенно отличается от C# 2.0, в которым reified generics допилили до production-состояния и отгрузили разработчикам - можно задуматься над теми или иными design decisions, которые привели разработчиков C# к тому языку, какой мы имеем сейчас. Вот что я нашёл интересного:
- В спецификации GC# фигурировала фича, позволяющая
using
-алиасам иметь собственные типы-параметры:
using IntDict<T> = System.Collections.Generic.Dictionary<int, T>;
К сожалению, она не была реализована в GC# и не материализовалась в C# 2.0, но практически идентичный механизм существует сейчас в type
-алиасах F#. На мой взгляд это мелкая и полезная фича, но не особо нужная в виду некоторой общей ущербности using
-алиасов в C#.
- Generic-типы не могли быть “перегружены” по числу типов-параметров. Все generic-типы имели простой суффикс “
G
” в метаданных, поэтому такой код раньше не компилировался из-за конфликта имён:
class C { }
class C<T> { }
class C<T, U> { }
Однако GC# не предусматривал такого же ограничения в перегрузке generic-методов. В C# 2.0 такой код компилируется, так как классы получат в метаданных имена C
, C`1
и C`2
соответственно. Я всячески поддерживаю ослабление этого ограничения, так как в существовании одноимённых классов с разным количеством типов-параметров нет ничего криминального, более того - это иногда удобно разработчикам framework’ов.
-
Выражение получения значения по-умолчанию для типа
T
имело немного другой синтаксис:default<T>
. -
В GC# качестве типов-аргументов могли выступать другие constructed-типы или типы-параметры, а так же специальный тип-аргумент
void
. Однако это не было реализовано из-за ограничений GCLR (Generic CLR - так называлась версия CLR с экспериментальной поддержкой reified generics), связанных с особым трактованием типаvoid
на уровне MSIL-кода. Реализовать поддержку использованияvoid
в качестве типов-аргументов можно было бы путём использования специального пустого типа-значенияSystem.Empty
, на который компилятор прозрачно бы подменял типы-аргументыvoid
(похожим образом это сейчас реализовано в F#).
С помощью типа-аргумента void
можно было бы делать такой “финт ушами” при переопределении методов из базовых generic-классов:
class C<T> {
public virtual T M() { ... }
}
class D : C<void> {
public override void M() { ... }
}
Пример выше предлагалось транслировать в подобный код (это не совсем валидный C# из-за переопределения виртуального метода с отличающейся областью видимости):
class C<T> {
public virtual T M() { ... }
}
class D : C<System.Empty> {
private sealed override System.Empty M() {
this.M();
return new System.Empty();
}
public new virtual void M() { ... }
}
Подобную трансформацию предполагалось делать и в случаях приведения метод-групп с возвращаемым значением типа void
к constructed-типам generic-делегатов, когда в результате подстановки типа-аргумента тип возвращаемого значения делегата становился типом viod
- должны были неявно генерироваться обёртки, иногда и вовсе включающие в себя замыкания.
Насколько я понял, похожая история должна была происходить с параметрами, тип которых становился типом void
после подстановки типов-аргументов - void
-параметры должны были “исчезать” из списков параметров, а компилятор должен был позаботиться о неявной передаче пустой структуры System.Empty
в скрытые параметры.
Самый главный вопрос - зачём всё это безобразие может понадобиться? Существование unit
-типа, такого как void
, могло бы убрать необходимость в существовании отдельных групп делегатов Action<T>
и Func<T, TResult>
. Тип делегатов Action<T>
прекрасно бы выражался как Func<T, void>
, что сократило бы вдвое количество перегрузок в некоторых местах .NET Framework, активно пользующихся данными типами делегатов; не было бы отдельных типов Task
и Task<T>
и т.д. Однако, при создании таких делегатов с void
мы получали бы небольшой оверхэд из-за оборачивания делегатов.
Если бы CLR изначально представляла тип void
в виде такой пустой структуры, на которую действовали бы все правила, действующие на обычные значения на MSIL-стеке, то эта фича натурально бы выражалась и никаких обёрток/хаков не потребовалось бы. Не смотря на то, что язык MSIL при добавлении generics пришлось существенно изменять и дополнять, поведение void
оставили прежним и поддержку типа-аргумента void
реализовывать не стали, что немного грустно, но совсем немного.
- В GC# планировалось ввести ограничение на уникальность реализуемых интерфейсов, запрещающее реализацию одного интерфейса несколько раз с разными типами-параметрами, но оно так и не было реализовано и отсутствует в C# 2.0:
interface I<T> { }
class C : I<string>, I<object> { }
- В GC# статические поля не могли ссылаться на типы-параметры определяющего их класса, такой код не компилировался:
class C<T> {
static T Field;
static List<T> Xs;
}
Причина такого жёсткого ограничения проста - на тот момент реализация GCLR шарила статические переменные между всеми constructed-типами данного generic-класса (как в Java, но там из-за type erasure и отсутствия поддержки средой исполнения). К моей большой радости, к выходу C# 2.0 среду исполнения допилили и статика стала выделяться отдельно на каждое инстанцирование generic-класса конкретными типами-аргументами.
Я считаю это изменение очень удобным и логичным поведением, однако есть достаточно большая доля разработчиков, не знающих об этом эффекте и непреднамеренно инициализирующих одну и ту же статику во множестве constructed-типов идентичными значениями.
По-умолчанию ReSharper ругается на поля в generic-типах если в типе поля не встречаются типы-параметры, что мне не очень нравится, но и вычисление зависимости значения поля от типа-параметра в общем случае просто невозможно, так что я смирился.
- Практически аналогичная история со статическими методами (а так же статическими свойствами и статическими событиями), такой код был под запретом:
class C<T> {
public static M(C<T> c) { }
}
Причина этого ограничения мне особо не ясна, так как GCLR уже умел передавать типы-аргументы в статические generic-методы (которыми спецификация GC# и рекомендовала пользоваться для того, чтобы обойти случай, приведённый выше). Возможно, это связанно с тем же в шарингом статических данных между constructed-типами или в каком-нибудь заковыристом случае становилось неоткуда материализовать типы-аргументы. Ограничение не действовало на методы уровня экземпляра потому что они всегда имели доступ к reified набору типов-аргументов через vtbl
объекта.
Сейчас такой код в C# работает за счёт того, что система исполнения неявно передаёт типы-аргументы статическому методу через скрытый параметр (в случае, когда все типы-аргументы являются ссылочными типами) или вовсе компилирует специализации статического метода для конкретных типов-аргументов (в случае, когда в типах-аргументах присутствует хотя бы один тип-значение). Очень хорошо, что таких ограничений не осталось и нынешнее поведение C# 2.0 понятно и ожидаемо.
Помимо этого, данное ограничение GC# делало невозможным определение операторов для generic-типов, поэтому разработчики вынуждены были разрешить определять generic-операторы (при этом приходилось рассчитывать на вывод типов, так как указывать такие типы-параметры явно было вовсе невозможно), что ушло в прошлое вместе с GC#:
public C<T> {
public static C<T> operator+ <T> (C<T> lhs, C<T> rhs) { .. }
}
- Типы-параметры классов не становились неявно типами-параметрами nested-классов в GC#:
class C<T> {
class N { /* нельзя ссылаться на N */ }
class N<T> { /* но можно сделать так */ }
}
Сейчас в C# классы из примера выше компилируются в метаданные как C`1
, C+N`1
и C+N`2
- то есть nested-классы компилируются в обычные классы с типами-параметрами от всех внешних generic-классов. Это не более чем синтаксический сахар компилятора для того, чтобы избежать дублирований типов-параметров в nested-классах, однако он вводит понятие области видимости типов-параметров и вероятность перекрытия имён типов-параметров (как в примере выше). Однако в C# 2.0 стало невозможно создать не обобщённые nested-классы внутри generic-классов, что может иногда мешать. Лично я всё же уверен, что подход с неявными типами-параметрами всё же лучше, хоть и сложнее для поддержки IDE, например.
- Я не сразу это осознал, но в GC# у делегатов планировалось сделать два набора типов-параметров (но конечно не было реализовано):
delegate void Foo <T><U>(T t, U u);
Первые - delegate-create-type-parameters - это обычные типы-параметры, которые мы имеем сейчас в C#.
Вторые - delegate-invoke-type-parameters - это фактически типы-параметры метода Invoke
, типы-параметры отложенные до вызовов делегата! Фактически, определение делегата выше было эквивалентно классу:
class Printer<T> {
public abstract void Invoke<U>(T t, U u);
}
С помощью таких делегатов можно было бы очень легко изобразить rank-2 types, можно было бы разрешить анонимные generic-методы/лямбды, передавать в методы полиморфные делегаты печати на экран значений типа T
и там их вызывать с разными типами-параметрами (распространённый троллинг языков с классическими системами типов) и творить прочие замечательные безобразия! Делегаты планировалось сделать не менее мощными, чем абстрактный generic-метод в generic-классе, однако этого не произошло. Пример использования:
delegate void Printer<T><U>(T t, U u);
class Program {
public void PrintGeneric<U>(int padding, U u) { ... }
public void PrintValues(Printer<int> printer) {
printer(100, 123);
printer(100, "abc");
printer(100, true);
}
public void Run() {
PrintValues(PrintGeneric);
}
}
Я думаю, что такие делегаты не случились потому, что пришлось бы допиливать среду исполнения и потому что правила приведения методов к таким делегатам - просто ад, с трудом поддающийся пониманию.
- В GC# в ограничениях типов-параметров не было constraint’ов
struct
,class
иnew()
, а так же subtype-ограничений, в которых участвовали другие типы параметры (обратите внимание на синтаксис):
class C<T, U | T : U> { ... } // ошибка в GC#
Я считаю эти ограничения на типы-параметры натуральными костылями (ужасный new()
, работающий через рефлексию) и небольшими вынужденными уродствами системы типов C#/CLR, появившимся в следствии различных особенностей поведения reified generics. Мне кажется, что можно было бы как-нибудь подумать и вовсе обойтись без ограничений class
/struct
, а вот ограничение другим типом-параметром - редкая, но интересная возможность.
- В GC# планировали сделать вывод типов-параметров конструкторов и даже конструкторов делегатов (но не было реализовано) по типам параметров. Эту фичу уже много где обсуждали, можно было бы опускать явные типы-аргументы во многих случаях вызовов конструкторов generic-классов:
int F(int x) { ... }
int G(int x) { ... }
var f = new Func(x => F(x) + G(y)); // Func<int, int>
var xs = new List(someIntEnumerable); // List<int>
Я считаю это безусловно полезной и достаточно легко реализуемой фичей, однако в C# её до сих пор нет. Единственный недостаток, который я здесь вижу - в некоторых случаях необходимо будет производить overload resolution между группами конструкторов абсолютно разных классов (подумайте, что будет если в примере выше будут одновременно существовать классы List
и List<T>
, имеющие подходящие для вызова конструкторы).
На этом всё, в спецификации можно найти ещё небольшие интересные design choices, о которых мне стало лень писать. Надеюсь, было интересно ;)