Совсем недавно товарищ 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, о которых мне стало лень писать. Надеюсь, было интересно ;)