В чём разница, Часть первая: Обобщения – не шаблоны

Поскольку я – гик, мне нравится узнавать о порой тонких различиях между вещами, которые легко спутать. Например:

  • У меня в голове всё еще нет четкого понимания того, чем отличаются хаб, роутер и свитч, и как это относится к гномам, которые живут в каждом из них.
  • Глыбы минералов, которые находят в природе, называются горной породой. Но как только вы поместите их в сад, или построите из них мост, они внезапно оказываются камнем.
  • Когда свинья достигает 120 фунтов (54.43кг), она становится кабаном.

Я подумал, что мог бы провести серию экскурсов в трудноразличимые концепции в дизайне языков программирования.

Вот вопрос, который мне задают довольно часто:

public class C
{
public static void DoIt<T>(T t)
{
ReallyDoIt(t);
}
private static void ReallyDoIt(string s)
{
System.Console.WriteLine("строка");
}
private static void ReallyDoIt<T>(T t)
{
System.Console.WriteLine("всё остальное");
}
}

Что происходит при вызове C.DoIt<string>? Многие люди ожидают, что выведется «строка» в то время, как на самом деле всегда печатается «всё остальное», независимо от того, какой T использовать.

Спецификация C# гласит, что когда у вас есть выбор между ReallyDoIt<string>(string) и ReallyDoIt(string) – то есть, когда выбор идет из двух методов с идентичными сигнатурами, но один из них получает эту сигнатуру после подстановки обобщённых параметров – тогда мы выбираем «натуральную» сигнатуру вместо «подстановленной». Почему в этом случае мы этого не делаем?

Потому, что это не тот выбор, который нам предоставлен.

Если бы вы сказали

ReallyDoIt("здравствуй, мир");

то мы бы выбрали «натуральную» версию. Но вы не передали чего-то, известного компилятору как строка. Вы передали что-то, известное как T, неограниченный тип-параметр, так что он может быть чем угодно. Стало быть, думает алгоритм разрешения перегрузок, есть ли у нас тут метод, который принимает всё, что угодно? Да, есть.

Это иллюстрирует то, что обобщения в C# не похожи на шаблоны в C++. Вы можете думать о шаблонах, как о продвинутом механизме поиска и замены. Когда вы говорите DoIt<string> в шаблоне, компилятор ищет все применения «T», заменяет их на «string», а потом компилирует получившийся исходный код. Разрешение перегрузок работает с подставленными типами-аргументами, и сгенерированный код отражает результаты этого разрешения.

Обобщения работают не так; обобщённые типы – они, ну, обобщённые. Мы выполняем разрешение перегрузок единожды и замораживаем результат. Мы не меняем его во время исполнения, когда кто-то, возможно в совсем другой сборке, использует строку как тип-аргумент для метода. Уже выбран метод, который будет вызваться в IL, сгенерированном нами для обобщённого типа. Компилятор JIT не говорит «ага, я тут случайно знаю, что, если бы мы попросили компилятор C# сейчас выполниться, имея эту дополнительную информацию, то он бы выбрал другой перегруженный метод. Дай-ка я перепишу код, исходно сгенерированный компилятором C#...». Компилятор JIT ничего не знает о правилах C#.

В сущности, пример выше не отличается от этого:

public class C
{
  public static void DoIt(object t)
  {
    ReallyDoIt(t);
  }
  private static void ReallyDoIt(string s)
  {
    System.Console.WriteLine("строка");
  }
  private static void ReallyDoIt(object t)
  {
    System.Console.WriteLine("всё остальное");
  }
}

Когда компилятор генерирует код вызова ReallyDoIt, он выбирает версию с object потому, что это лучшее, что он может сделать. Если кто-то вызовет DoIt со строкой, то он всё равно пойдет в версию с object.

Теперь, если вы хотите, чтобы разрешение перегрузок было выполнено повторно во время исполнения, основываясь на реальных типах аргументов, то мы можем это для вас сделать; это как раз то, что делает новая функциональность «dynamic» в C# 4.0. Просто замените «object» на «dynamic», и когда вы сделаете вызов с участием этого объекта, мы запустим во время исполнения алгоритм разрешения перегрузок и динамически сгенерируем код для вызова того метода, который бы выбрал компилятор, знай он фактические типы во время компиляции.