Сообщений 23    Оценка 1226 [+1/-0]         Оценить  
Система Orphus

Нововведения в C# 2.0

Автор: Владислав Чистяков (VladD2)
The RSDN Group

Источник: RSDN Magazine #6-2003
Опубликовано: 24.06.2004
Исправлено: 07.10.2005
Версия текста: 1.0
Generics
Ограничения/уточнения
Generic-интерфейсы
Generic-делегаты
Типы, вложенные в generic-типы
default
Сравнение ссылок
Оператор is
Оператор as
Исключения и параметры типа
Оператор lock и параметры типа
Оператор typeof
Generic-методы
Наследование
Реализация методов generic-интерфейсов и generic-классов
Перегрузка методов в generic-классах
Тип System.Nullable<T>
Generic-и и стандартная библиотека (FCL)
Скоростные характеристики generic-ов
Результаты тестов
Сравнение generic-ов с шаблонами C++
Итераторы
Анонимные методы
Инициализация делегатов
Partial types
Вместо заключения

Минуло вот уже два года с выхода первой версии.NET Framework. Примерно через год свет увидело первое обновление – .NET Framework 1.1. Изменения в этой версии в основном носили косметический характер, так что ее скорее можно назвать сервис-паком. Ниже я расскажу об очередном обновлении .NET Framework, которое появится в 2004-ом году. В этот раз изменен не только сам Framework. В первую очередь изменения коснулись языков программирования. Это обновление содержит по крайней мере одно серьезное изменение в языках и CLR и несколько более мелких которые в основном касаются языков и их компиляторов. В этой статье речь пойдет о изменениях в C#. Новая версия .NET Framework будет включать радикально обновленную версию C# - C# 2.0.

Вот краткий список изменений языка, анонсированных Microsoft (перечислю их в порядке «весомости»):

  1. Generics – новая возможность языка, позволяющая создавать обобщенные алгоритмы (наподобие шаблонов в C++).
  2. Итераторы – механизм, упрощающий создание перечислителей (реализации интерфейса IEnumerable).
  3. Анонимные методы – возможность инициализировать делегаты телами методов, объявленными «по месту».
  4. Partial types – возможность разбивать код одного класса по нескольким файлам.
  5. Упрощенный синтаксис инициализации делегатов.

Generics

Объектно-ориентированный подход позволяет уменьшить объем работы программиста за счет повторного использования кода. Такие понятия, как наследование и полиморфизм, позволяют создавать специализированных наследников некоторого базового класса. Однако не всегда такой подход приемлем. Иногда решения, основанные на ОО-подходе, оказываются чересчур громоздкими и медленными.

Основные проблемы ОО-подхода заключаются в том, что универсальность в нем достигается за счет работы с множеством объектов как с экземплярами некоторого базового типа. Если планируется хранить заранее неизвестные типы, то в .NET придется использовать базовый тип object. Это приводит к двум проблемам. Во-первых, object является ссылочным типом, и при помещении в него value-типа (структуры, перечисления или базового типа вроде int) будет происходить boxing, что приводит к дополнительным (лишним) затратам времени. Во-вторых, при извлечении хранящегося значения требуется явное приведение типа. В результате происходит не только снижение скорости работы программы - компилятор уже не может проверять корректность этой операции, и подобная проверка типов переносится в runtime, что грозит потенциальным снижением надежности и безопасности программы.

Отличным примером, где ОО-подход оказывается не лучшим выбором, являются универсальные коллекции (контейнеры, предназначенные для хранения данных различных типов) и универсальные алгоритмы. Взгляните на следующий пример:

// Подразумевается, что в массиве ary должны храниться целые числа.
ArrayList ary = new ArrayList();
ary.Add(1);
ary.Add(2);
...
ary.Add("3");
...
ary[1]++; // Ошибка при компиляции! Требуется явное приведение типов.

// Корректно, но требуется приведение типов и происходит пара
// операций boxing/unboxing.
ary[1] = (int)ary[1] + 1;
int i2 = ary[2]; // Ошибка при компиляции! Требуется явное приведение типов.
int i3 = (int)ary[3]; // Runtime-ошибка! В массиве находится строка.

Кроме видимых проблем, в этом примере появляется еще одна, незаметная на первый взгляд. Дело в том, что все элементы массива хранятся в GC-хипе. А ведь элементов может быть довольно много. Это приводит к замедлению сборки мусора. Причем проявится это не сразу, а спустя какое-то время.

В общем, нет счастья на земле. Generic-и как раз и созданы, чтобы решить все эти проблемы. Идея generic-ов схожа с идеей шаблонов C++. При описании классу можно задать набор параметров, которые замещают типы, неизвестные на стадии разработки. С применением generic-ов приведенный выше пример будет выглядеть так:

// List – это типизированный аналог ArrayList (generic-тип). Так что
// подразумевать ничего не нужно.
List<int> ary = new List<int>();
ary.Add(1);
ary.Add(2);
...
ary.Add("3"); // Ошибка при компиляции (несовместимый тип).
...
ary[1]++; // Все прекрасно! Изменяется содержимое ячейки.
// Корректно, приведение типов не требуется, отсутствует boxing/unboxing,
// а стало быть, нет и лишних накладных расходов.
ary[1] = ary[1] + 1;
int i2 = ary[2]; // Все ОК! Приведение не требуется. ary[2] уже типа int.
int i3 = ary[3]; // Ошибка предотвращена на стадии компиляции!

С применением generic-ов код становится проще, безопаснее и быстрее.

Отличие generic-класса от обычного заключается в том, что при объявлении generic-классу задается список типов. Например, класс List может быть описан следующим образом:

class List<ItemType>
{
  ItemType[] _items;
  int _count;
  
  void Add(ItemType item) { ... }
  ItemType this[int index] { get { ... } set { ... } }
  ...
}

ItemType в этом примере – это параметр типа (по-английски это звучит как type parameter), или параметризующий тип. Параметр типа – это тип, который неизвестен до момента создания экземпляра или обращения к generic-типу. При создании экземпляра generic-типа программист должен явно указать типы, которые нужно использовать вместо параметризующих типов. Подставляемые типы называются аргументами типа (type argument).

Тип, образуемый вследствие подстановки аргументов типа к generic-типу, называется сконструированным типом (constructed type). В качестве параметра типа может быть подставлен как конкретный тип (например int, string или MySomeStruct), так и тип, сам по себе являющийся параметром generic-а (или сконструированный с их использованием). В первом случае получаемый тип является так называемым закрытым типом (closed type), а во втором открытым (open type). Иногда закрытый тип называют специализацией, так как при этом порождается специализированная версия generic-типа. В приведенном выше примере List<int> является сконструированным закрытым типом. Процесс создания сконструированного типа из generic-типа называется generic type instantiation. К сожалению, я не смог подобрать подходящего дословного перевода. Возможно, самый близкий по смыслу термин – специализация.

Сконструированный тип создается, когда компилятор в первый раз встречает его упоминание. Так, в следующем примере array1 и array2 имеют один и тот же тип:

List<int> array1 = new List<int>();
...
List<int> array2 = new List<int>();

Основной отличительной чертой generic-ов от шаблонов C++ является то, что даже если создание экземпляров одного generic-типа происходит в разных сборках, сконструированные типы будут идентичны. Причем совершенно неважно, в какой процесс или домен загружены эти сборки.

Так, если в одной сборке создать generic-объект и передать его в метод, определенный в другой сборке:

// Первая сборка
List<int> array1 = new List<int>();
OtherAssembly.Test(array1);

то тип переданного объекта будет идентичен типу такого же объекта, созданного внутри второй сборки:

// Вторая сборка
void Test(object obj)
{
  List<int> array2 = new List<int>();
  Console.WriteLine("obj.GetType() == array2.GetType() is {0}",
     obj.GetType() == array2.GetType()); // Выведет True
}

Если такой же эксперимент произвести с типом, созданным с помощью шаблона C++, то типы окажутся разными. Дело тут в том, что за конструирование специализаций отвечает не компилятор, а CLR.

Generic-типы, как и обычные, компилируются в MSIL. Но для generic-типов в метаданные помещается информация о параметрах типа. Формат MSIL был незначительно изменен, чтобы обеспечить возможность использования параметров типа.

Когда приложение в первый раз обращается к generic-типу, JIT-компилятор преобразует его MSIL и метаданные в машинный код. При этом происходит подстановка реальных типов вместо параметризующих. Все экземпляры одного сконструированного типа используют один и тот же машинный код и метаданные. Причем если имеется несколько конструируемых типов, получающих в качестве параметров ссылочные типы, то CLR создает для них единый машинный код. Если же в качестве параметра типа встречается хотя бы один value-тип, то для такого сконструированного типа создается отдельная версия машинного кода.

В отличие от шаблонов C++, generic-и не требуют наличия своего исходного кода для создания специализаций. По сути, generic-и полностью удовлетворяют требованиям компонентной архитектуры. Их можно создавать из других сборок, и даже динамически. Причем в качестве параметров типа можно использовать типы, еще не существовавшие на момент компиляции generic-типа.

Ограничения/уточнения

То, что generic-и перед использованием должны быть скомпилированы в MSIL, накладывает ряд ограничений. Дело в том, что при компиляции обычных (не managed-) языков программирования компилятор производит проверки типов. Например, когда компилятор C++ компилирует код, основанный на шаблонах, по сути, он просто генерирует специализированную версию класса и компилирует уже ее. Доступность аргументов типа во время компиляции специализированной версии класса позволяет компилятору C++ осуществить все необходимые проверки типов и гарантировать (при условии отсутствия явного (ручного) приведения типов) типобезопасность кода. Как говорилось раньше, generic-и лишены такой возможности. Когда компилятор обрабатывает generic-класс, он и в мыслях не может представить, какие конкретные типы будут использоваться в качестве параметров этого типа. Поэтому параметры типа «по умолчанию» рассматриваются как object. Таким образом, по умолчанию экземпляры объектов, тип которых задан списком параметров типа, можно копировать, у них можно вызвать методы, унаследованные от object (Equals, GetHashCode, GetType, ReferenceEquals, ToString), и можно создавать массивы этих типов. Как вы сами понимаете, выбор не богат. Проблема в том, что выбор был невелик и у самих создателей .NET. Они могли либо разрешить использовать любые операции и методы, отложив их проверку до момента JIT-компиляции, либо попытаться как-то ограничить (уточнить) функциональность типов, передаваемых в качестве параметра типа. Первый вариант плох по двум соображениям. Во-первых, подобные проверки отнимают относительно много времени, и перенесение их в runtime может замедлить и так не молниеносную загрузку. Во-вторых, перенесение проверок типов в runtime существенно снизило бы надежность приложения. Дело в том, что реальную проверку типов в таком случае можно было бы сделать только при JIT-компиляции, а это значит, что их можно будет отловить, только тестируя конкретные куски кода в runtime-е. Положение усугубляется тем, что CLR пытается до последнего отсрочить время загрузки сборок, и JIT-компиляция происходит непосредственно перед первым вызовом метода. А стало быть, велика вероятность, что ошибку первым увидит не программист, а пользователь. В общем, сами понимаете, такое положение дел совершенно неприемлемо.

Все это заставило разработчиков .NET ввести так называемые ограничения/уточнения (Constraints). Constraints, с одной стороны, указывают (уточняют) компилятору, чем является параметризующий тип, с другой – они накладывают ограничения на типы, используемые в качестве аргументов типа (подставляемые вместо параметризирующих).

ПРЕДУПРЕЖДЕНИЕ

К несчастью, в русском языке нет полного аналога слова Constraint. Мы четко разделяем понятия «уточнение» и «ограничение». Английское же слово Constraint в этом плане более гибко. Далее я буду использовать оба эти термина в одном и том же смысле. Какое из слов предпочесть, будет определяться контекстом. В общем, что больше подходит по смыслу, то и буду использовать. :)

Например, для реализации методов BinarySearch и Sort необходимо, чтобы хранимые в List<ItemType> объекты можно было сравнивать между собой. Вот приблизительная реализация метода Sort:

void Sort(int left, int right)
{
  int i = left;
  int j = right;
  ItemType center = _items[(left + right) / 2];
  while (i <= j)
  {
    while (center > _items[i])
      i++;
    while (_items[j] > center)
      j--;

    if (i <= j)
    {
      ItemType x = _items[i];
      _items[i] = _items[j];
      _items[j] = x;
      i++;
      j--;
    }
  }
  if (left < j)
    Sort(left, j);
  if (right > i)
    Sort(i, right);
}

Этот код прекрасно скомпилировался в случае с шаблонами C++, но не с generic-ами. Компилятор в .NET не знает реального типа ItemType, и, стало быть, не может гарантировать, что тип, подставляемый во время выполнения на место ItemType, будет поддерживать операции сравнения (выделенные красным). Проблему можно исправить, если переписать код так:

void Sort(int left, int right)
{
  int i = left;
  int j = right;
  ItemType center = _items[(left + right) / 2];
  while (i <= j)
  {
    while (((IComparable)center).CompareTo(_items[i]) > 0)
      i++;
    while (((IComparable)_items[j]).CompareTo(_center) > 0)
      j--;

    if (i <= j)
    {
      ItemType x = _items[i];
      _items[i] = _items[j];
      _items[j] = x;
      i++;
      j--;
    }
  }
  if (left < j)
    Sort(left, j);
  if (right > i)
    Sort(i, right);
}

Однако такое решение приведет к тому, что проверка типа будет перенесена в runtime. Проверка типов происходит при попытке приведения типа элемента к интерфейсу IComparable (выделено красным). А это возвращает нас к тем проблемам, которые и должны были решить generic-и. Уточнения как раз и призваны обойти эту проблему. Так, если указать в уточнении, что ItemType обязан реализовать интерфейс IComparable, то этот код можно будет записать как:

void Sort(int left, int right)
{
  int i = left;
  int j = right;
  ItemType center = _items[(left + right) / 2];
  while (i <= j)
  {
    while (center.CompareTo(_items[i]) > 0)
      i++;
    while (_items[j].CompareTo(_center) > 0)
      j--;

    if (i <= j)
    {
      ItemType x = _items[i];
      _items[i] = _items[j];
      _items[j] = x;
      i++;
      j--;
    }
  }
  if (left < j)
    Sort(left, j);
  if (right > i)
    Sort(_i, right);
}

При этом не только упрощается синтаксис. Главное, что компилятор может проверить правильность и типобезопасность кода во время компиляции в MSIL. При попытке скомпилировать специализацию для конкретного типа компилятор будет проверять, унаследован ли аргумент типа от необходимого типа или интерфейса.

Задать уточнение можно с помощью ключевого слова where сразу после списка параметров типа. Перечисление уточнений для одного параметра типа осуществляется через запятую. Каждое перечисление для каждого типа начинается с ключевого слова where, за которым должно следовать имя параметра типа, для которого задается список уточнений. Затем, после двоеточия, должен идти список уточнений. Например, ниже приведен код, задающий для нашего List<ItemType> ограничение, говорящее, что тип ItemType обязан реализовывать интерфейс IComparable:

class List<ItemType>
  where ItemType: IComparable
{

Если нужно задать несколько ограничений для нескольких параметров типа, то это можно сделать так:

class SomeGeneric<T1, T2, T3>
  where T1: IComparable
  where T2: SomeGenericClass<T1, T2>, IComparable, new()
  where T3: SomeClass, IEnumerable, IComparable
{

В качестве уточнения может использоваться имя класса (при этом параметр типа должен быть унаследован от этого класса), имя интерфейса (который должен реализовать параметр типа) или new().

Имя класса в уточнении может быть задано только один раз и обязано быть первым в списке уточнений. Интерфейсы могут задаваться в любом количестве. Ограничение new() говорит о том, что параметр типа должен реализовывать публичный конструктор, не имеющий параметров, что, собственно, означает возможность создания экземпляра соответствующего типа с помощью оператора new. Ограничение new() должно быть последним в списке ограничений.

К сожалению, в качестве уточнений нельзя указать value-типы, конструкторы, обладающие параметрами, методы или операторы. Зато классы и интерфейсы, перечисляемые в уточнении, могут быть также generic-типами.

В спецификации C# 2.0 сказано, что если в качестве аргумента типа используется value-тип, то при вызове у его экземпляров методов интерфейсов, описанных в уточнении, не будет производиться boxing. Это несколько противоречит правилам, принятым в C# сейчас (сейчас при приведении value-типа к интерфейсу происходит boxing). Сделано это в целях повышения производительности. Ведь boxing и unboxing занимают относительно много времени. К тому же такой подход, возможно, позволит разработчикам JIT-компилятора избавиться от виртуального вызова, а это, в свою очередь, позволит делать подстановку кода методов для value-типов. К сожалению, текущая альфа-версия не реализует эту возможность. При обращении к методу интерфейса, реализуемому value-типом, происходит boxing. Это резко замедляет алгоритмы, производящие массовые операции над объектами параметризующих типов. Я надеюсь, что ближе к выходу release-версии .NET Framework 1.2 CLR будет изменен так, чтобы удовлетворять спецификации C# 2.0. А пока что можно избавиться от boxing-а, используя внешние объекты, осуществляющие необходимые операции. О них речь пойдет в следующем разделе. Чуть позже я также приведу данные о скоростных характеристиках generic-ов, и вы поймете, что такая оптимизация могла бы позволить получать обобщенный код, который не уступал бы по скорости специализированному коду (написанному для конкретного типа) и C++-коду, созданному с использованием шаблонов. А пока поговорим о других возможностях generic-ов.

Generic-интерфейсы

У generic-ов есть множество неприятных ограничений, связанных в основном с их runtime-природой. Но есть у них и довольно привлекательные стороны. Так, в .NET можно описывать не только generic-классы (реализации), но и generic-интерфейсы. Generic-интерфейс аналогичен обычному интерфейсу, за исключением того, что он содержит список параметров типа. К сожалению, generic-и, в отличие от шаблонов C++, не поддерживают ручной специализации (это опять же связано с их runtime-природой). Так что если нужно изменять поведение обобщенного алгоритма в зависимости от типа или некоторых других условий, единственным способом является передача этому алгоритму ссылки на объект или интерфейс, реализующий нужную функциональность. При этом в некоторых случаях можно будет передавать ссылку на универсальный объект, а в некоторых – на специализированный. Generic-интерфейсы идеальным образом подходят для описания интерфейсов таких объектов. Например, операция сравнения для строк может быть реализована по-разному в зависимости от того, нужно ли учитывать регистр строк при сравнении. Для абстрагирования от операции сравнения, ее можно описать в виде метода generic-интерфейса:

public interface IComparer<T>
{
  bool Great(T a, T b);
}

Generic-интерфейсы можно реализовывать в generic-классе, используя в качестве аргументов интерфейса параметры типа, реализующего интерфейс, или в обычном классе, подставляя в качестве параметров интерфейса конкретные типы. Например, можно создать универсальную реализацию интерфейса IComparer<T>, которую можно будет использовать по умолчанию:

sealed class GenericComparer<T> : IComparer<T>
{
  public bool Great(T a, T b)
  {
    // Если T ссылочный тип...
    // (о T.default и сравнении значений параметризующих
    // типов с null будет рассказано в разделах 
    // «default» и «Сравнение ссылок»)
    if (T.default == null)
    {
      // Пытаемся сравнить ссылки...
      if (object.ReferenceEquals(a, b))
        return false;

      // и проверить на null...
      if (a == null)
        return false;
      if (b == null)
        return true;
    }

    // Пытаемся получить generic-IComparable...
    System.Collections.Generic.IComparable<T> genCmp = 
      a as System.Collections.Generic.IComparable<T>;
    // и в случае успеха выполнить сравнение...
    if (genCmp != null)
      return genCmp.CompareTo(b) > 0;

    // Пытаемся получить полиморфный IComparable...
    IComparable polyCmp = a as IComparable;
    // и в случае успеха выполнить сравнение...
    if (polyCmp != null)
      return polyCmp.CompareTo(b) > 0;

    // Если ничего не удалось, возбуждаем исключение.
    throw new ArgumentException("Тип " + typeof(T).Name
      + " не поддерживает сравнения. Вам нужно использовать"
      + " для него специализированный объект сравнения.");
  }
}

Этот вспомогательный объект сравнения не обладает ни высокой производительностью (уж слишком много лишних действий совершается при каждом сравнении), ни излишней интеллектуальностью, но в 80% случаев его можно использовать.

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

sealed class CaseSensitiveStringComparer : IComparer<string>
{
  public bool Great(string a, string b)
  {
    return a > b;
  }
}

И без учета:

sealed class CaseInsensitiveStringComparer : IComparer<string>
{
  // Объект, используемый для независимого от регистра сравнения строк.
  static System.Globalization.CompareInfo _compareInfo = 
    System.Globalization.CultureInfo.CurrentCulture.CompareInfo;

  public bool Great(string a, string b)
  {
    return _compareInfo.Compare(a, b,
      System.Globalization.CompareOptions.IgnoreCase) > 0;
  }
}

Первый из них просто работает значительно быстрее, чем обобщенная версия, а второй обеспечивает нестандартную функциональность (сравнение строк без учета регистра).

Используя этот интерфейс и обобщенную реализацию, нетрудно переписать процедуру сортировки так, чтобы она использовала внешний объект сравнения. Это позволит при необходимости задавать свой объект сравнения. Необходимость хранить объект сравнения и рекурсивная природа алгоритма быстрой сортировки наводят на мысль, что сортировку лучше оформить в виде отдельного класса. Вот пример такой реализации:

sealed class GenericSort<T>
{
  GenericSort(){}

  // Ссылка на абстрактный объект сравнения элементов.
  IComparer<T> _comparer;
  T[] _item; // Сортируемый массив.

  // Статическая процедура сортировки, доступная извне.
  // Она копирует массив и ссылку на объект сравнения во внутренние 
  // переменные и вызывает внутреннюю процедуру сортировки.
  public static void Sort(IComparer<T> comparer, 
    T[] item, int left, int right)
  {
    // Создаем экземпляр класса сортировки.
    GenericSort<T> sorter = new GenericSort<T>();
    // Инициализируем его поля...
    sorter._comparer = comparer;
    sorter._item = item;
    sorter.QuickSort(left, right);
  }

  // Вторая версия публичной статической процедуры, использующая
  // универсальный объект сравнения – GenericComparer.
  public static void Sort(T[] item,
    int left, int right)
  {
    Sort(new GenericComparer<T>(), item, left, right);
  }

  // Основная процедура сортировки.
  // Массив и ссылка на объект сравнения хранятся в полях класса.
  private void QuickSort(int left, int right)
  {
    int i = left;
    int j = right;
    T center = _item[(left + right) / 2];
    while (i <= j)
    {
      while (_comparer.Great(center, _item[i]))
        i++;

      while (_comparer.Great(_item[j], center))
        j--;

      if (i <= j)
      {
        T x = _item[i];
        _item[i] = _item[j];
        _item[j] = x;
        i++;
        j--;
      }
    }

    if (left < j)
      QuickSort(left, j);

    if (right > i)
      QuickSort(i, right);
  }
};

Использование вспомогательного объекта сравнения выделено в коде красным цветом.

Заметьте, что использование generic-интерфейса позволило абстрагироваться от процедуры сравнения, сделав ее более гибкой и быстрой. Заметьте также, что в этой реализации даже не потребовалось описывать ограничения, а код получился полностью типобезопасным. Единственная возможная неприятность – в случае использования GenericComparer может сложиться так, что тип сравниваемых элементов не будет поддерживать интерфейс IComparable<T> или IComparable. Это приведет к runtime-сбою. Однако можно обойти и эту проблему, если заставить пользователя явно указывать вспомогательный объект сравнения.

Использование этого класса сортировки выглядит так:

string[] stringArray = ...;
int[]    intArray = ...;
...

// С использованием универсального объекта сравнения:
GenericSort<string>.Sort(stringArray, 0, stringArray.Length - 1);
GenericSort<int>.Sort(intArray, 0, intArray.Length - 1);

// С использованием специализированного объекта сравнения для строк.
GenericSort<string>.Sort(new CaseInsensitiveStringComparer(),
  stringArray, 0, stringArray.Length);

// А эта строка вызовет ошибку во время компиляции, так как
// тип объекта сравнения и тип элемента массива несовместимы.
GenericSort<int>.Sort(new CaseInsensitiveStringComparer(),
  intArray, 0, intArray.Length - 1);

Нужно учитывать, что универсальный GenericComparer работает значительно медленнее, чем специализированные версии. В алгоритме быстрой сортировки сравнения составляют значительную часть, и использование GenericComparer может значительно снизить скорость работы этого алгоритма. Так что если нужно сортировать большие объемы данных или производить множество операций сортировки, лучше создать специальные объекты сравнения.

Список параметров типа доступен не только самому generic-типу, но и вложенным типам (типам, объявленным внутри generic-типа). Это позволяет упростить код, поместив сопутствующие описания внутрь основного generic-типа. Интерфейс IComparer<T> в приведенных выше примерах можно было определить внутри класса GenericSort. При этом, описывая IComparer, не потребовалось бы описывать личный список параметров, так как можно было бы воспользоваться списком параметров внешнего класса.

Generic-интерфейсы позволяют красиво решать многие задачи, связанные с абстрагированием поведения алгоритмов и классов. Возможность создавать специализированные реализации отчасти заменяет отсутствие ручной специализации generic-классов. Но не только интерфейсы позволяют делать такое абстрагирование. В .NET 1.2 также поддерживаются generic-делегаты.

Generic-делегаты

Делегаты, как и классы, структуры и интерфейсы, тоже могут быть параметризованными. Это позволяет создавать обобщенные алгоритмы, логику которых можно изменять передаваемыми в качестве параметров делегатами.

Например, метод сравнения можно было бы описать с помощью следующего делегата:

delegate bool Great<T>(T a, T b);

При этом переменная данного типа выглядела бы так:

Great<int> great;

или так:

class SomeClass<T>
{
  Great<T> _great;
  ...

А создать экземпляр делегата и произвести вызов метода, ассоциированного с делегатом можно так:

void IntGreat(int a, int b)
{
  return a > b;
}

void Test()
{
  Great<int> great = new Great<int>(IntGreat);
  ...
  if (great(1, 2))
    ...
}

Как и все остальные generic-типы, generic-делегаты могут использовать список параметров внешнего типа. Таким образом, пример сортировки из предыдущего раздела можно переписать следующим образом:

sealed class GenericSortDelegate<T>
{
  // Generic-делегат, получающий параметр типа от внешнего класса.
  public delegate bool Great(T a, T b);

  GenericSortDelegate() { }

  // Ссылка на экземпляр делегата, через который производится сравнение
  // элементов массива.
  Great _great;
  T[] _item; // Сортируемый массив.

  // Статическая процедура сортировки, доступная извне.
  // Она копирует массив и делегат во внутренние переменные 
  // и вызывает внутреннюю процедуру сортировки.
  public static void Sort(Great gt, 
    T[] item, int left, int right)
  {
    // Создаем экземпляр класса сортировки.
    GenericSortDelegate<T> sorter = new GenericSortDelegate<T>();
    // Инициализируем его поля...
    sorter._great = gt;
    sorter._item = item;
    sorter.QuickSort(left, right);
  }

  // Основная процедура сортировки.
  // Массив и делегат хранятся в полях класса.
  private void QuickSort(int left, int right)
  {

    int i = left;
    int j = right;
    T center = _item[(left + right) / 2];
    while (i <= j)
    {
      // Использование делегата для сравнения элементов массива.
      while (_great(center, _item[i]))
        i++;

      while (_great(_item[j], center))
        j--;

      if (i <= j)
      {
        T x = _item[i];
        _item[i] = _item[j];
        _item[j] = x;
        i++;
        j--;
      }
    }

    if (left < j)
      QuickSort(left, j);

    if (right > i)
      QuickSort(i, right);
  }
};

Использование generic-делегата помечено красным цветом.

Разница между использованием делегатов и интерфейсов в данном случае невелика. Но вот скорость работы значительно различается. Вариант, использующий generic-интерфейс, приблизительно вдвое быстрее варианта, использующего generic-делегат. Это связано с не очень удачной реализацией делегатов в .NET в целом. Более подробно вопросы производительности generic-ов будут обсуждаться чуть позже, а пока давайте поговорим о том, что могут, и чего не могут generic-и.

Типы, вложенные в generic-типы

До .NET 1.2 типы, вложенные в другие типы, мало чем отличались от аналогичных, объявленных в пространствах имен. Вложенность влияла в основном на область видимости и права доступа (вложенные классы имеют полный доступ к закрытым членам внешнего). Таким образом, вложенные классы были вполне независимы. Появление generic-ов внесло свои коррективы. Типы, объявленные внутри generic-типов, являются, по сути, тоже параметризованными. Даже если во вложенном типе не используются параметры внешнего типа, компилятор все равно учитывает их наличие. Так, в следующем описании:

class OuterGenericClass<T>
{
  class Inner { }
}

класс Inner является параметризованным, хотя и не содержит явно заданного списка параметров. В приведенном ниже примере конструируемые типы будут различны, несмотря на то, что сам вложенный класс не имеет собственных параметров:

OuterGenericClass<int>.Inner
OuterGenericClass<float>.Inner

Если же объявить вложенный тип с собственным набором параметров, то он будет параметризован одновременно собственными параметрами и параметрами внешнего типа:

class Outer<T>
{
  class Inner<U> { }
}

Здесь реальный тип класса Inner будет Outer<T>.Inner<U>.

Если создать вложенный тип с параметрами, имена которых будут совпадать с именами параметров внешнего типа, то произойдет перекрытие области видимости. При этом внутри вложенного типа не будут доступны перекрытые параметры внешнего, а компилятор будет выдавать предупреждение. Так, следующее объявление:

class Outer<T>
{
  public class Inner<T> { }
}

порождает класс:

Outer<T1>.Inner<T2>

где T1 и T2 – совершенно независимые типы. Можно изменить описание следующим образом:

class Outer<T>
{
  T _outerField;
  public class Inner<T>
  {
      T _innerField;
  }
}

Тогда в следующем объявлении:

Outer<int>.Inner<string> variable;

поле _outerField будет иметь тип int, а поле _innerField – string.

В общем, можно сказать, что каждая специализация порождает отдельный тип. Это хорошо демонстрирует следующий пример:

using System;

class Outer<T>
{
  public class Inner
  {
    public static int _count = 0;
    public Inner() { _count++; }
  }
}

class App
{
  static void Main()
  {

    Outer<int>.Inner x1 = new Outer<int>.Inner();
    Console.WriteLine(Outer<int>.Inner._count);  // Выведет 1

    Outer<double>.Inner x2 = new Outer<double>.Inner();
    Console.WriteLine(Outer<int>.Inner._count);  // Выведет 1

    Outer<int>.Inner x3 = new Outer<int>.Inner();
    Console.WriteLine(Outer<int>.Inner._count);  // Выведет 2

    Console.WriteLine("---");

    Console.WriteLine(typeof(Outer<int>.Inner).FullName);
    Console.WriteLine();
    Console.WriteLine(typeof(Outer<double>.Inner).FullName);

    Console.ReadLine();
  }
}

Если выполнить его, то в консоль будет выведено:

1
1
2
---
Outer+Inner[[System.Int32, mscorlib, Version=1.2.3400.0, Culture=neutral, Public KeyToken=b77a5c561934e089]]

Outer+Inner[[System.Double, mscorlib, Version=1.2.3400.0, Culture=neutral, Publi cKeyToken=b77a5c561934e089]]

Обратите внимание, что для каждого конструируемого типа создан свой набор статических переменных. Причем это никак не связано с тем, что класс вложенный. Такое поведение характерно для параметризованных типов в целом.

default

Иногда появляется необходимость присвоить переменной или ячейке массива некое незначащее значение (значение по умолчанию). Например, при удалении элемента в динамическом массиве после сдвига элементов нужно «стереть» значение последнего элемента. Если этого не сделать, то он сможет ссылаться на некоторые другие объекты, удерживая их в памяти. Если тип элемента массива заранее известен, то известно и значение, которое можно использовать для инициализации. Однако если элемент массива неизвестен во время компиляции (определяется параметром типа), то нельзя и подобрать значение, пригодное для использования при инициализации. Именно для таких случаев и было придумано специальное значение – default. default – это статическая псевдопеременная. Каждый тип имеет отдельный экземпляр этой псевдопеременной (даже object). Но необходимость в нем возникает именно в generic-ах. Вот пример реализации метода RemoveAt для класса List<ItemType>:

public void RemoveAt(int index)
{
  if (index < 0 || index >= _count)
    throw new ArgumentOutOfRangeException("index");

  _count--;
  if (index < _count)
    Array.Copy(_array, index + 1, _array, index, _count - index);

  _array[_count] = ItemType.default;
}

После сдвижки элементов массива в ячейке, на которую указывает _count, остается копия последнего элемента, и ее нужно затереть. Выделенный красным код как раз и делает это.

По сути, default – это значение, применяемое для инициализации переменной по умолчанию. Именно этим значением инициализируются переменные, объявленные как члены класса (если их специально не проинициализировали другим значением).

Сравнение с null можно использовать для определения, является ли переданный аргумент типа ссылочным. Следующий пример демонстрирует, как запретить создание экземпляров generic-типа, если в качестве аргумента типа передан тип, не являющийся ссылочным:

class Gen<T>
{
  // Статический конструктор вызывается при первом обращении к типу.
  static Gen()
  {
    if (T.default != null)
      throw new Exception("Value-типы идут в лес! :)");
  }
}
class App
{
  static void Main()
  {
    Gen<string> genStr = new Gen<string>();
    Gen<int> genInt = new Gen<int>();
  }
}

Этот код использует особенности статических конструкторов. Статический конструктор вызывается CLR при первом обращении к типу. Если в нем происходит исключение, то создания экземпляров не происходит, и в переменную помещается «значение по умолчанию».

Сравнение ссылок

Иногда возникает необходимость точно определить внутри generic-класса, будет ли тип, подставленный вместо параметра, ссылочным. Зачастую переменные ссылочного типа требуют особой обработки. Например, следующий код будет всегда корректно работать, если в качестве GenericType подставляется value-тип, и не всегда – если ссылочный:

void Print(GenericType obj)
{
  Console.WriteLine(obj.ToSting());
}

В случае со ссылочным типом obj будет ссылкой, и она может быть установлена в null. Если имеется ограничение, говорящее, что GenericType должен быть наследником некоторого класса, то можно переписать код следующим образом:

void Print(GenericType obj)
{
  if (obj == null)
    Console.WriteLine("obj is null");
  else
    Console.WriteLine(obj.ToSting());
}

Но что же делать, если GenericType потенциально может быть value-типом? Да собственно, то же самое. В C# 2.0 допускается сравнение переменных параметризующих типов с null. Если аргумент типа является value-типом, сравнение с null всегда будет возвращать false. Соответственно проверка на неравенство (!=) всегда будет возвращать true. Причем JIT-компилятор, создавая специализацию для value-типа, попросту удалит неработающий код, что очень полезно с точки зрения производительности.

Переменные параметризующих типов можно сравнивать на идентичность ссылок с помощью метода object.ReferenceEquals. При этом поведение будет аналогично сравнению с null. Для value-типов значение, возвращаемое object.ReferenceEquals, всегда будет равно false.

Оператор is

Оператор is допустим для проверки как параметров типов, так и сконструированных типов. При этом для открытых типов проверка всегда делается во время исполнения, а для закрытых проверка может быть осуществлена во время компиляции с выдачей предупреждения. В остальном правила работы оператора is с generic-типами ничем не отличаются от правил для обычных типов.

Оператор as

Приводить тип переменной к параметризующему типу с помощью оператора as можно, если параметризующий тип имеет уточнение, говорящее, что тип является классом. Собственно, оно и понятно. Оператор as не имеет смысла для value-типов.

Исключения и параметры типа

Параметризующий тип можно использовать в операторе throw и в фильтре catch, если параметр помечен ограничением, говорящим, что параметр типа должен быть унаследован от System.Exception.

Оператор lock и параметры типа

Оператор lock можно использовать для выражений, тип которых определяется параметром типа. При этом если в качестве аргумента типа был передан value-тип, lock игнорируется, так как он не имеет смысла для value-типа.

Оператор typeof

Как и с любыми другими типами, с параметризующими типами можно использовать оператор typeof. Результатом будет объект типа System.Type, описывающий runtime-тип, ассоциированный с параметризирующим типом. Оператор typeof может быть также применен для сконструированного типа. Например, следующий код:

class X<T>
{
  public static void PrintTypes()
  {
    Console.WriteLine(typeof(T).FullName);
    Console.WriteLine(typeof(X<X<T>>).FullName);
  }
}
class App
{
  static void Main()
  {
    X<int>.PrintTypes();
  }
}

выведет на консоль следующие значения:

System.Int32

X[[X[[System.Int32, mscorlib, Version=1.2.3400.0, Culture=neutral, 
  PublicKeyToken=b77a5c561934e089]], ConsoleApplication2, 
  Version=1.0.1470.1574, Culture=neutral, PublicKeyToken=null]]

Generic-методы

Иногда нужно создать обобщенную реализацию не целого класса (структуры), а только одного метода. Или нужно еще больше обобщить некий метод generic-класса, добавив к нему еще один (или несколько) параметризующих типов. Например, можно оформить сортировку не классом, а отдельной функцией:

static void Sort<T>(IComparer<T> comparer, T[] items, int left,
  int right)
{
  int i = left;
  int j = right;
  T center = items[(left + right) / 2];

  while (i <= j)
  {
    while (comparer.Great(center, items[i]))
      i++;
    while (comparer.Great(items[j], center))
      j--;

    if (i <= j)
    {
      T x = items[i];
      items[i] = items[j];
      items[j] = x;
      i++;
      j--;
    }
  }
  
  if (left < j)
    Sort(comparer, items, left, j);
  if (right > i)
    Sort(comparer, items, i, right);
}

Вызов generic-метода можно производить как явно задавая список параметров:

Sort<int>(comparer, 0, array.Length - 1);

так и не явно:

Sort(comparer, intArray, 0, array.Length - 1);

При этом компилятор сам будет подбирать наиболее подходящий вариант.

Особую привлекательность generic-методам придает то, что они подчиняются стандартным правилам перегрузки методов. Это позволяет эмулировать ручную специализацию (как в C++). Например, в одном и том же классе с приведенным выше методом можно разместить специализированную версию метода Sort для int:

static void Sort(int[] items,  int left, int right, IComparer<int> comparer)
{ ... }

В ней можно производить сравнения с помощью оператора «>», а не относительно медленного виртуального вызова метода IComparer<T>.Great. Это позволяет чуть ли не вдвое повысить скорость работы данного алгоритма для таких простых типов как int. Причем в случае необходимости всегда можно будет вызвать generic-версию, явно задав список параметризующих аргументов.

Наследование

Generic-классы могут быть унаследованы от других, в том числе и generic-классов. Также они могут реализовывать любое количество интерфейсов, как обычных, так и generic-. Простые классы и интерфейсы также могут быть унаследованы от generic-классов, но при этом generic-классы и интерфейсы должны быть закрытыми типами.

К сожалению, generic-классы и интерфейсы не могут быть унаследованы от типизирующих параметров. Если бы это было возможно, то можно было бы обойти ограничение .NET на множественное наследование. Разработчики .NET говорят, что эта возможность может появиться в следующих версиях .NET. Пока же приходится утешаться тем, что generic-классы можно порождать от конструируемых типов. Вот примеры допустимого и не очень наследования:

// Нельзя использовать параметр типа в качестве базового типа!
class Extend<V> : V {}                  // Ошибка!

class C<U, V> {}
interface I1<V> {}

class D : C<string, int>, I1<string> {} // ОК. Базовые типы закрытые.
class E<T> : C<int, T>, I1<T> {}        // ОК. Базовые типы конструируемые.
class F : I1<T> {}                      // Ошибка! T не определено.
class G<T> : C<string, int> {}          // ОК.
class X<U, V>: I<U>, I<V> {}            // Ошибка! Конфликт между I<U> и I<V>

Реализация методов generic-интерфейсов и generic-классов

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

abstract class A<U, V> 
{
  public abstract void Method1(U x, List<V> y);
}

то его реализация для закрытого типа C<string, int> будет выглядеть следующим образом:

class B : A<string, int>
{
  public override void Method1(string x, List<int> y) { }
}

то есть параметризующие типы заменяются аргументами типа, подставленными вместо них.

Ниже приведены корректные примеры реализации интерфейса:

interface Interface1<T> 
{
  T Method1(T value);
}

class C : Interface1<double>
{
  public double Method1(double value) { return double.default; }
}

class D<T> : Interface1<T>
{
  public T Method1(T value) { return T.default; }
}

class E : Interface1<long>, Interface1<int>
{
  // Реализация метода интерфейса Interface1<long>
  long Interface1<long>.Method1(long value) { return 3; }

  // Реализация метода интерфейса Interface1<int>
  int Interface1<int>.Method1(int value) { return 2; }

  // Просто публичный метод с именем, совпадающим с именем метода интерфейса
  public long Method1(long value) { return -1; }
}

Вот так:

class E<T> : Interface1<long>, Interface1<T>
{
  long Interface1<long>.Method1(long value) { return 3; }

  T Interface1<T>.Method1(int value) { return T.default; }
}

делать нельзя, так как реализация одновременно обобщенной и специализированной версии интерфейса в C# запрещается. Это сделано потому, что в качестве параметра типа (в данном случае T) при конструировании типа может быть подставлен тип, совпадающий с типом специализации второго интерфейса. Другими словами, следующий код вызвал бы парадокс:

E<long> xxx;

Перегрузка методов в generic-классах

Методы, конструкторы, индексаторы и операторы в generic-классах и структурах могут быть перегруженными, если они не приводят к неопределенности при конструировании типов.

Две одноименные функции, объявленные в одном generic-классе, должны иметь типы или число параметров, позволяющие однозначно идентифицировать метод при любой комбинации аргументов типа. Это правило включает и закрытые конструируемые типы, о которых еще ничего не известно в момент компиляции generic-класса. Причем ограничения никак не влияют на это правило.

Следующие примеры демонстрируют верные и неверные варианты перегрузки методов:

interface I1<T> { }
interface I2<T> { }

class G1<U>
{
  long F1(U u);              // Неверная перегрузка! G1<int> будет иметь два
  int  F1(int i);            // метода с одинаковой сигнатурой.

  void F2(U u1, U u2);       // ОК. Один и тот же тип не может одновременно
  void F2(int i, string s);  // быть типа int и string.

  void F3(I1<U> a);          // ОК. Любые закрытые типы разных интерфейсов
  void F3(I2<U> a);          // будут всегда являться разными типами.

  void F4(U a);              // ОК. Массив с элементом некоторого типа
  void F4(U[] a);            // всегда имеет уникальный тип.
}

class G2<U, V>
{
  void F5(U u, V v);        // Неверная перегрузка! G2<int, int> будет иметь
  void F5(V v, U u);        // два метода с одинаковой сигнатурой.

  void F6(U u, I1<V> v);    // Неверная перегрузка! G2<I1<int>, int> будет
  void F6(I1<V> v, U u);    // иметь два метода с одинаковой сигнатурой.

  void F7(U u1, I1<V> v2);  // ОК. U не может быть V и I1<V>
  void F7(V v1, U u2);      // одновременно.

  void F8(ref U u);         // Неверная перегрузка! U и V могут иметь
  void F8(out V v);         // один и тот же тип (G2<int, int>
}

class C1 { }
class C2 { }

class G3<U, V> where U: C1 where V: C2 
{
  void F9(U u);          // Неверная перегрузка! ограничения для U и V
  void F9(V v);          // игнорируются при проверке перегрузки.
}

Тип System.Nullable<T>

System.Nullable<T> – это структура, определенная в FCL и представляющая значение типа T, которое может быть установлено в null. System.Nullable<T> прекрасно подходит для использования вместе с БД, таблицы которых могут иметь колонки, содержащие null, или при работе с необязательными атрибутами XML-элементов.

Работает эта структура очень просто. В ней реализованы операторы неявного преобразования в (и из) null. При присвоении null экземпляру закрытого типа Nullable он получает значение «по умолчанию». Другими словами, следующий код:

Nullable<int> x = null;
Nullable<string> y = null;

аналогичен этому:

Nullable<int> x = Nullable<int>.default;
Nullable<string> y = Nullable<string>.default;

В имеющейся у меня версии .NET Framework этого класса не было, но в спецификации он описан. Видимо, к выпуску release-версии он появится.

Generic-и и стандартная библиотека (FCL)

Резонно было бы ожидать, что с появлением generic-ов в FCL должны появиться как минимум generic-коллекции. Microsoft не обманул наших ожиданий, и в .NET Framework 1.2 будет включено пространство имен System.Collections.Generic. Классы, вошедшие в него – это практически полные аналоги обычных коллекций (классов из пространства имен System.Collections). Вкратце о generic-коллекциях можно прочесть в моей статье «Коллекции в .NET Framework Class Library», опубликованной в этом же номере журнала.

Единственное, что хочется сказать – в бета-версии (1.2.30703.27), которая была доступна мне на момент написания этих статей, реализация generic-коллекций не очень толкова. Есть и проблемы дизайна. Так, List<T> нельзя проинициализировать значениями массива аналогичного типа. У List<T> есть конструктор, принимающий интерфейс ICollection<T> (по аналогии с конструктором класса ArrayList, принимающим интерфейс ICollection), но встроенные массивы не реализуют этого интерфейса. Так же странно, что класс Array обзавелся статическими generic-методами вроде Sort<T>, но они объявлены скрытыми и не доступны простым смертным. Ну, и главное, реализация коллекций сделана «под копирку». Так, например, большинство методов класса List<T> не реализует необходимую функциональность самостоятельно, а перенаправляет вызовы аналогичным методам класса Array. Такой подход характерен для полиморфных методов, но странен для generic-ов. Он приводит к неоптимальной производительности (если не сказать больше, но об этом в следующем разделе).

Однако вряд ли стоит расстраиваться из-за невысокой скорости встроенных классов. Во-первых, к release-версии положение дел может измениться, а во-вторых, если стандартные библиотеки окажутся тормозными, сразу же появятся библиотеки от сторонних разработчиков. Если будет время, постараюсь и сам тряхнуть стариной в этой области. :)

Скоростные характеристики generic-ов

Как я говорил в самом начале, одним из стимулов к введению generic-ов в .NET было ускорение работы managed-приложений. В этом разделе я попытаюсь сравнить скорость выполнения тестов, созданных с использованием generic-ов, с аналогичными, но реализованными альтернативными методами. Ну и, наверно, стоит сравнить скорость работы generic-ов со скоростью работы аналогичного C++-кода.

В качестве подопытного кролика я взял алгоритм быстрой сортировки, неоднократно приводившийся в этой статье. За основу я взял тест QuickSort из статьи «Кто сегодня самый шустрый?» .В нем сортируется массив целых чисел размером 100 000 000 байтов (25 000 000 элементов). Так как разные языки и компиляторы не гарантируют генерации одинаковых последовательностей псевдослучайных чисел в качестве исходных данных, в тесте используется файл архива Quake 3 (собственно, содержимое архива не имеет значения, главное, чтобы в нем были слабо повторяющиеся данные, и чтобы архив имел высокую степень сжатия), тестирование производилось в release-версии при закрытых приложениях и в отсутствие задач, выполняемых в теневом режиме (вроде WinAmp-а :) ). Тестовая система: AMD Athlon(TM) XP 2100+ (1.73 GHz), 512 MB RAM типа DDR 266, работающей в одноканальном режиме. Замер производился функциями QueryPerformanceCounter/QueryPerformanceFrequency.

Для начала приведу полный список тестов и попытаюсь объяснить, что они собой представляют и зачем нужны.

Не-generic int (функция)

Это эталонный тест. Он написан, что называется, в лоб. То есть в нем производится сортировка массива целых числе (int[]), и он не может быть использован для сортировки массивов других типов без изменения кода. Это теоретически (а, забегая вперед, скажу, что и практически) самая быстрая реализация, которую можно создать на C# без применения unsafe-кода. Вот ее код:

static void QuickSortInt(int[] item, int left, int right)
{
  int i = left;
  int j = right;
  int center = item[(left + right) / 2];
  
  while (i <= j)
  {
    while (item[i] < center)
      i++;
    while (item[j] > center)
      j--;

    if (i <= j)
    {
      int x = item[i];
      item[i] = item[j];
      item[j] = x;
      i++;
      j--;
    }
  }

  if (left < j)
    QuickSortInt(item, left, j);
  if (right > i)
    QuickSortInt(item, i, right);
}

Контрольный тест на VC8

В качестве контрольного выстрела в голову... эээ... простите, теста, будет использоваться версия этого же алгоритма, созданная на C++ с использованием шаблонов:

template <class SType> void __fastcall QuickSort(SType *item, int left, int right)
{
  int i = left;
  int j = right;  
  SType center = item[(left + right) / 2];
  while(i <= j)
  {
    while (item[i] < center)
      i++;
    while (item[j] > center)
      j--;
    
    if (i<=j){
      SType x  = item[i];
      item[i] = item[j];
      item[j] = x;
      i++;
      j--;
    }
  } 
  if(left < j)
    QuickSort(item, left, j);
  if(right > i)
    QuickSort(item, i, right);
}

Как показали тесты из статьи, ссылку на которую я приводил выше, VC7 оказался самым прытким в этом тесте, а VC8 стал еще быстрее. Вот с ним и будем сравнивать.

Не-generic int (класс 1)

Этот тест аналогичен тесту «Не-generic int (функция)», за тем исключением, что вместо статического метода для сортировки массива используется класс. Сортируемый массив являются полем этого класса, а через параметры метода сортировки передаются только границы сортируемого диапазона. Этот класс называется IntSort. Вот его код:

sealed class IntSort
{
  IntSort() { }
  int[] _item;

  public static void QuickSort(int[] item,
                  int left, int right)
  {
    IntSort sorter = new IntSort();
    sorter._item = item;
    sorter.QuickSort(left, right);
  }

  void QuickSort(int left, int right)
  {
    int[] item = _item;
    int i = left;
    int j = right;
    int center = item[(left + right) / 2];

    while (i <= j)
    {
      while (item[i] < center)
        i++;

      while (center < item[j])
        j--;

      if (i <= j)
      {
        int x = item[i];
        item[i] = item[j];
        item[j] = x;
        i++;
        j--;
      }
    }

    if (left < j)
      QuickSort(left, j);
    if (right > i)
      QuickSort(i, right);
  }
};

Сравнение элементов производится, как и в «лобовом» тесте, с помощью операции «>».

Этот класс нужен, чтобы можно было сравнить его с аналогичной generic-реализацией (она тоже создана в двух видах, в виде статической процедуры и в виде класса). О том, зачем нужны два варианта generic-теста, см. раздел «Generic-класс».

Не-generic int (класс 2)

Этот тест аналогичен предыдущему, за тем исключением, что сравнение элементов осуществляется с помощью внешнего объекта сравнения. Он, как и сортируемый массив в предыдущем тесте, помещается в соответствующее поле класса, а через параметры метода сортировки все так же передаются только границы сортируемого диапазона. Этот класс называется IntItfSort. Вот его код:

class IntItfSort
{
  IntItfSort() { }

  IComparerInt _comparer;

  int[] _item;


  public static void QuickSort(IComparerInt comparer, int[] item,
    int left, int right)
  {
    IntItfSort sorter = new IntItfSort();

    sorter._comparer = comparer;
    sorter._item = item;
    sorter.QuickSort(left, right);
  }


  void QuickSort(int left, int right)
  {
    int i = left;
    int j = right;
    int center = _item[(left + right) / 2];

    while (i <= j)
    {
      while (_comparer.Great(center, _item[i]))
        i++;

      while (_comparer.Great(_item[j], center))
        j--;

      if (i <= j)
      {
        int x = _item[i];
        _item[i] = _item[j];
        _item[j] = x;
        i++;
        j--;
      }
    }

    if (left < j)
      QuickSort(left, j);
    if (right > i)
      QuickSort(i, right);
  }
};

Как видите, сравнения осуществляются через интерфейс IComparerInt. Вот его описание:

interface IComparerInt
{
  bool Great(int a, int b);
}

Его метод сравнения типизирован. Вот реализация этого интерфейса, которая реально используется для сравнения элементов:

sealed class ComparerInt : IComparer<int>, IComparerInt
{
  public bool Great(int a, int b)
  {
    return a > b;
  }
}

Этот тест – полный аналог generic-теста «Generic-класс». В обоих из них сортируемый массив и объект сравнения помещаются в поля класса сортировки, и оба эти класса используют полностью идентичный механизм сравнения. В общем, можно сказать, что они полностью аналогичны, за тем исключением, что тест «Generic-класс» не зависит от типа массива, а текущий тест позволяет сортировать исключительно массивы целых чисел. Существенная разница в результатах этих двух тестов будет свидетельствовать о том, что generic-и порождают принципиально менее качественный код. Предыдущие (не generic-) тесты могут выигрывать у generic-теста не за счет более качественно сгенерированного кода, а за счет того, что не используют вызовов методов интерфейсов при сравнении элементов массива.

Generic-метод

Код этого метода приведен в самом начале раздела «Generic-методы». В нем сортировка осуществляется внутри отдельного статического метода. В качестве параметров метод получает объект сравнения, массив и индексы сортируемого диапазона.

Generic-класс

Этот тест очень похож на предыдущий, но вместо статического метода для сортировки массива используется generic-класс. Сортируемый массив и объект сравнения являются полями этого класса, а через параметры метода сортировки передаются только границы сортируемого диапазона. Этот класс называется GenericSort<T>. Его код можно увидеть в разделе «Generic-интерфейсы» (ближе к его концу).

Смысл создания отдельного класса – в проверке гипотезы о том, что доступ к полям класса быстрее, чем передача лишних параметров. Ведь метод сортировки является рекурсивным, а массив и объект сравнения (IComparer<T>) не меняются между рекурсивными вызовами.

Generic-делегат

Это вариант сортировки аналогичен предыдущему, за тем исключением, что вместо generic-интерфейса (объекта) для сравнения элементов массива используется делегат. Код класса сортировки (GenericSortDelegate<T>) взят из раздела «Generic-делегаты».

List<T>

Сортировка методом Sort класса List<T>. Тест демонстрирует резвость стандартных реализаций. Из-за упомянутой проблемы с отсутствием возможности копирования содержимого обычного массива целых в List<int> пришлось производить поэлементное копирование. Чтобы это не повлияло на результаты, я вынес данную операцию за пределы участка кода, время которого измерялось, но, как вы увидите чуть позже, это ему не сильно помогло. :(

Array (CLR)

В этом тесте сортировка производится встроенным в CLR методом Array.Sort. Подробности его реализации можно найти в упомянутой выше статье из этого номера журнала. Здесь же только скажу, что для массивов встроенных типов, коим и является int[], эта функция вызывает встроенную в CLR функцию (реализованную на C++). Этот тест нужен для чисто статистического сравнения.

Полиморфная (Array.SetValue)

Пожалуй, самый интересный тест. Это, скажем так, чистейший аналог generic-теста (точнее, теста «Generic-класс»), но (вместо generic-подхода) использующий тот факт, что все массивы в CLR виртуально унаследованы от класса Array и реализуют полиморфный доступ к себе. Этот метод был доступен и в более ранних версиях .NET (до 1.2). Именно его обычно использовали в случаях, когда нужно было написать обобщенный алгоритм работы с массивами. Все различия с generic-версией заключаются в том, что доступ к массиву осуществляется не с помощью оператора [], а с помощью универсальных методов SetValue и GetValue класса Array. Эти методы принимают тип object. Даже в документации по .NET сказано, что эти методы медленны, но насколько? На этот вопрос и ответит данный тест. Думаю, многие будут поражены, «насколько глубока заячья нора». :)

Вот код метода сортировки:

class PolymorphSort
{
  PolymorphSort() { }

  IComparerPolymorph _comparer;
  Array _item;

  public static void QuickSort(IComparerPolymorph comparer,
    Array item, int left, int right)
  {
    PolymorphSort sorter = new PolymorphSort();

    sorter._comparer = comparer;
    sorter._item = item;
    sorter.QuickSort(left, right);
  }

  void QuickSort(int left, int right)
  {
    int i = left;
    int j = right;
    object center = _item.GetValue((left + right) / 2);

    while (i <= j)
    {
      while (_comparer.Great(center, _item.GetValue(i)))
        i++;
      while (_comparer.Great(_item.GetValue(j), center))
        j--;

      if (i <= j)
      {
        object x = _item.GetValue(i);
        _item.SetValue(_item.GetValue(j), i);
        _item.SetValue(x, j);
        i++;
        j--;
      }
    }

    if (left < j)
      QuickSort(left, j);
    if (right > i)
      QuickSort(i, right);
  }
};

Красным выделены методы, осуществляющие полиморфный доступ к элементам массива. Сравнение элементов производится с помощью интерфейса IComparerPolymorph. Вот его описание:

public interface IComparerPolymorph
{
  bool Great(object a, object b);
}

Как видите, он очень похож на описанный выше интерфейс IComparer<T>, но вместо параметров типа использует тип object. Естественно, это приводит к операциям boxing-а и unboxing-а, что отрицательно сказывается на скорости. Но в полиморфных алгоритмах по-другому просто не получится.

Используемая в тестах реализация этого интерфейса для целых чисел выглядит так:

class ComparerIntPolymorph : IComparerPolymorph
{
  public bool Great(object a, object b)
  {
    return (int)a > (int)b;
  }
};

Полиморфная (object[])

Зачем был нужен этот тест, если .NET обладает замечательными средствами полиморфной работы с массивами (методами Array.GetValue и Array.SetValue)? А вот не скажу. :) Пока...

А пока что расскажу, что и как делает этот тест. Любой тип в .NET можно привести к типу object. Любой-то любой, но для коллекций от этого мало толку. Массивы для полиморфной работы нужно ведь приводить не к object, а к object[]. А это возможно не всегда. Это возможно для массивов ссылочных типов (подробности все в той же статье «Коллекции в .NET Framework Class Library»), но мы-то сортируем массив целых (т.е. value-типов). Ну да горе не беда, скопировать-то мы его можем? Можем, но какой от этого толк, ведь копирование еще сильнее замедлит и без того медленный полиморфный вариант? Об этом я тоже пока что не скажу. Всему свое время. Должна же быть какая-то интрига? :) Пока что довольствуйтесь исходным кодом функции сортировки данного теста:

class ObjectSort
{
  ObjectSort() { }

  IComparerPolymorph _comparer;
  object[] _item;

  public static void QuickSort(IComparerPolymorph comparer, 
    Array item, int left, int right)
  {
    ObjectSort sorter = new ObjectSort();
    // Создаем массив объектов...
    sorter._item = new object[item.Length];
    // и копируем в него данные из полученного массива.
    item.CopyTo(sorter._item, 0);

    // Помещаем ссылку на массив и объект сравнения 
    // во внутренние переменные...
    sorter._comparer = comparer;
    // И вызываем внутренний метод сортировки.
    sorter.QuickSort(left, right);

    // Копируем элеметы из массива объектов в обычный массив
    sorter._item.CopyTo(item, 0);
  }

  void QuickSort(int left, int right)
  {
    int i = left;
    int j = right;
    object center = _item[(left + right) / 2];

    while (i <= j)
    {
      while (_comparer.Great(center, _item[i]))
        i++;
      while (_comparer.Great(_item[j], center))
        j--;

      if (i <= j)
      {
        object x = _item[i];
        _item[i] = _item[j];
        _item[j] = x;
        i++;
        j--;
      }
    }

    if (left < j)
      QuickSort(left, j);
    if (right > i)
      QuickSort(i, right);
  }
};

Этот код копирует элементы из массива неизвестного ему типа в массив объектов (object[]), сортирует его и после сортировки копирует элементы обратно. Копирование осуществляется с помощью полиморфной функции Array.CopyTo. Это позволяет не думать о типе элемента массива.

Сравнение элементов, как и в предыдущем тесте, производится с помощью интерфейса IComparerPolymorph, а точнее, его реализацией в классе ComparerIntPolymorph. Их код приведен в разделе выше.

Ну, что же настала пора перейти к результатам. Вы уже сделали ставки? :) Самое время.

Результаты тестов

НазваниеТипСпособ сравнения элементов/тип параметроврезультат при 100 000 000 байтрезультат при 10 000 000 байт
Контрольный тест на VC++ 8.0genericоператор «>»6.000.48
Не-generic int (функция)-оператор «>»6.540.54
Не-generic int (класс 1)-оператор «>»6.580.54
Не-generic int (класс 2)-объект/int11.391.01
Generic-методgenericобъект/T11.771.01
Generic-классgenericобъект/T11.381.05
Generic-делегатgenericобъект/T24.532.15
List<T>genericреализация clr295.6825.01
Array (CLR)встроенныйреализация clr10.450.86
Полиморфная (Array.SetValue)полиморфныйобъект/object-34.41
Полиморфная (object[])полиморфныйобъект/object215.037.69

Колонка «Тип» указывает способ работы с элементами массива:

Колонка «Способ сравнения элементов/тип параметров» указывает, какой способ сравнения элементов массива использует тест. Сначала следует тип, который может принимать следующие значения:

После «/» идет тип параметров объекта сравнения. Т означает, что тип определяется аргументом типа.

Займемся трактовкой результатов. Помните, я интриговал вас, не говоря, зачем нужен тест «Полиморфная (object[])»? Для ответа на этот вопрос нужно взглянуть на результаты теста «Полиморфная (Arraу.SetValue)». При 10 миллионах байтов этот вариант показал самое худшее время. Это время почти на два порядка больше, чем лучшее время, показанное VC. При попытке выполнить этот тест с массивом, содержащим 100 миллионов байтов, тест надолго задумался, после чего выдал сообщение «Null reference exception». Если вы читали статью «Коллекции в .NET Framework Class Library», то могли заметить, что я частенько поминаю методы Array.SetValue/Array.GetValue недобрыми словами. Теперь вы должны понять меня, потому, что этот, не побоюсь этого слова, уникальный результат достигнут исключительно благодаря им. Даже двукратное копирование элементов с четыремя операциями boxing/unboxing для каждого элемента массива оказалась значительно шустрее (я имею в виду тест «Полиморфная (object[])»). Более того, как бы это невероятно ни звучало, но встроенная в List<T> сортировка также оказалась хуже этого варварского способа сортировки. Правда, на 100 миллионах байтов разрыв не столь существенен, но это потому, что тесту «Полиморфная (object[])» стало банально не хватать памяти (он съел все 512 мегабайт, доступные на моей машине). Если бы памяти было больше, то отрыв был бы куда значительнее.

А что же generic-тесты? Тут все зависит от исповедуемой философии. С одной стороны, самый лучший generic-тест оказался приблизительно вдвое медленнее, чем C++-вариант или тест «Не-generic int (функция)». Однако по сравнению с полиморфными такое отставание просто незаметно. Взгляните на график (рисунок 1).


Рисунок 1

Если сравнить тесты «Не-generic int (класс 2)» и «Generic-класс», то становится ясно, что основные непроизводительные затраты времени приходятся на виртуальный вызов объекта сравнения и передачу данных (сравниваемых элементов массива) его методу через параметры. Можно ли избежать этого? В той версии, что была в моем распоряжении, нет. Но, возможно, к release-у от виртуального вызова можно будет избавиться. По крайней мере, отрывки высказываний сотрудников Microsoft, прорвавшиеся в Internet, говорят о том, что они тоже озабочены этой проблемой и ищут достойный выход из сложившейся ситуации.

Однако и то, что есть сейчас уже очень недурственно. Все же двукратный разрыв между generic- и обычной версией алгоритма обусловлен тем, что алгоритм настолько прост, что банальный виртуальный вызов оказывается сравним со всеми вычислениями, осуществляемыми при каждой итерации. На практике эффект от виртуальных вызовов будет куда менее заметен.

Интересно, что встроенный в CLR вариант сортировки, оптимизированный для встроенных типов, оказался ненамного производительнее generic-вариантов. Что же – это прекрасное доказательство того, что скорость зависит не только от средства разработки, но и от применяемых алгоритмов, и количества лишних действий. Ведь встроенный вариант написан на C++ и, скорее всего, компилировался на VC версии не ниже 7.0 (а это очень мощный оптимизирующий компилятор).

В общем, выводы можно сформулировать так. Производительность очень неплоха, но есть пути для ее улучшения. C++ пока что «рулит», но совсем чуть-чуть, и не факт, что это скоро не закончится. Для большинства задач производительности более чем достаточно. На практике скорость работы приложения намного больше будет зависеть от правильности подбора алгоритма и его качества.

Сравнение generic-ов с шаблонами C++

Создавая generic-и, команда .NET придерживалась двух правил, которые радикальным образом повлияли на конечный результат. Первое правило гласит, что generic-и должны ничем не отличаться от обычных типов .NET, а именно: отвечать компонентной идеологии, быть доступными без исходного кода, иметь возможность создавать экземпляры в runtime-е (с помощью reflection), компилироваться в MSIL, обеспечивать единую и всеобъемлющую информацию о типе, удовлетворять системе контроля версий .NET. Второе правило гласит, что языки типа C# и VB.NET не должен усложниться от добавления в них generic-ов. Они должны быть так же просты в использовании, как и раньше.

Шаблоны C++, напротив, являются кладезем возможностей. Некоторые современные библиотеки используют даже не сами шаблоны, а побочные эффекты от таких их возможностей, как рекурсивные объявления и частичная специализация. Используя эти возможности совместно, можно превратить шаблоны из средства обобщенного программирования в сложный механизм метапрограммирования, переводя вычисления на стадию компиляции. Возможность работы со статическими функциями предоставляет такие механизмы, как задание политик (когда в качестве параметра передаются классы, особым образом реализующие статические методы, тем самым задавая политику поведения конечного класса). Все это недоступно в generic-ах. Трудно сказать, хорошо это или плохо. Навороченные шаблоны C++ могут многих поставить в тупик. К тому же многие возможности шаблонов реализуются с учетом того, что специализации (закрытые конструируемые типы) будут создаваться в едином цикле компиляции и не будут доступны, если не доступен исходный код, а это несовместимо с компонентной идеологией, пропагандируемой .NET.

Пожалуй, самым неудобным ограничением generic-ов является то, что у экземпляра параметризующего типа нельзя вызвать произвольный метод. Метод обязательно должен быть описан в интерфейсе или классе, указанном в качестве ограничения для параметра типа. Так, нельзя произвести банальное сравнение или арифметическую операцию. И, как следствие, для абстрагирования необходимо применять множество виртуальных вызовов.

В принципе, JIT-компилятор может устранять виртуальность вызовов за счет того, что ему известны очень многие подробности о переменных и классах, реализующих эти методы. Устранение же виртуальности открывает путь к дальнейшей оптимизации (inlining-у методов, вынесению проверок и вычислений за пределы циклов и т.п.). В общем, проблему производительности решить, скорее всего, удастся, да и не так уж она критична.

Что же касается наворотов, то их отсутствие, по всей видимости, является политикой Microsoft в отношении C# и VB. Точно известно, что в будущие версии MC++ должно войти расширение под кодовым названием CLI/C++. В качестве одного из расширений обещают возможность использовать полноценные шаблоны в качестве базовых классов managed-классов. Так что на MC++ вся мощь шаблонов C++ будет доступна разработчикам .NET-приложений, знакомым с C++. C# и VB будут оставаться простыми и удобными языками для быстрой разработки приложений.

В таблице 1 приведен список отличий generic-ов от шаблонов C++.

ВозможностьGeneric-и .NETШаблоны C++
Механизм ограниченийЯвно накладываемые ограничения. Накладываются в виде базовых типов (классов и интерфейсов), а также ограничения на создание объектов.Неявно задаваемые ограничения. Фактически соответствие типов проверяется только в момент создания компилятором специализаций.
Возможность определяемой программистом (явной) специализацииНетДа
Возможность частичной специализацииНетДа
Уникальность сконструированного типаГлобально уникальный.Уникальный в пределах сборки (модуля).
Создание закрытого сконструированного типа (специализированного типа)CLR во время первого обращения к закрытому типу.Компилятором во время компиляции специализации (на базе исходного кода).
Возможность создания типобезопасного кодаДаДа
Возможность межъязыкового использования типаДаНет
Поддерживаемые типы параметровКлассы или value-типы.Любые типы и переменные (например, int i).
Разрешение имен и контекста связыванияВсе параметры типа определяются во время компиляции generic-типа. Допускается использовать только методы классов и интерфейсов, указанных в ограничениях. Во время создания специализаций. Допускается использовать любые методы, функции и типы. Компилятор подбирает методы и типы по принципу наилучшего удовлетворения критериям.
Возможность наследования от параметров типаНетДа
Возможность использования в качестве параметров типов не типы (переменные)НетДа
Поддержка со стороны IDE (IntelliSense) внутри типаДаНет
Возможность использования статических методов у параметра типовНетДа
Таблица 1. Отличия generic-ов от шаблонов C++.

Итераторы

В .NET с самого начала было введено понятие перечислителя (enumerator). Перечислители позволяют перебирать элементы некоторой коллекции. Подробнее о них можно прочесть все в той же статье «Коллекции в .NET Framework Class Library». Для упрощения работы с ними, да и вообще со списками, в C# было введено специальное выражение foreach. Чтобы иметь возможность перебирать элементы, коллекция должна реализовывать метод GetEnumerator (обычно для этого реализуется интерфейс IEnumerable), который не содержит параметров и возвращает интерфейс IEnumerator. Собственно, можно обойтись и без GetEnumerator/IEnumerator, достаточно реализовать метод или свойство, возвращающее интерфейс IEnumerator. В версии 1.2 .NET Framework к этим интерфейсам прибавились их generic-аналоги (IEnumerator<T> и IEnumerable<T>). Они отличаются только тем, что возвращают типизированные значения, а стало быть, более эффективны, особенно при работе с коллекциями value-типов.

Все хорошо в этой концепции, но создать качественный перечислитель не так-то просто. Это выливается в создание относительно большого объема кода. Можно, конечно, опуститься до халтуры, просто скопировав коллекцию в массив или ArrayList, запросить у него требуемый интерфейс и возвратить его. Однако на больших объемах данных это неэффективно. Для каждого перечислителя нужно создавать отдельный класс... в общем, возни много, толку мало.

Посмотрели на это дело архитекторы .NET, огляделись вокруг, увидали разные research-языки и увидели в них забавную концепцию – итераторы. Поглядели, поглядели и решили сделать такую же возможность, как у других, но другую (ну, вы знаете почему...). Так в C# 2.0 появились итераторы.

Итераторы – это «синтаксический сахар», позволяющий значительно упростить реализацию перечислителей. Суть его заключается в том, что вместо создания класса и нудного превращения циклических алгоритмов просмотра коллекции в последовательный ее просмотр вам достаточно объявить одно свойство или метод, возвращающие интерфейсы IEnumerator, IEnumerable, IEnumerator<T> или IEnumerable<T>. Но это не совсем обычный метод. Дело в том, что он вызывается каждый раз, когда читающий коллекцию код вызывает метод IEnumerator.MoveNext. Если управление покидает этот метод ненасильственным путем, т.е. без принудительного yield return, то процесс перечисления заканчивается, и метод больше вызываться не будет. Если же вернуть управление с помощью оператора yield return, то возвращенное в нем значение станет текущим значением перечисления. Когда перечислителю потребуется следующий элемент (у него вызовут метод MoveNext), управление снова вернется в метод, причем с позиции, следующей непосредственно за той, что привела к выходу из метода в прошлый раз. Так продолжается до тех пор, пока управление не покинет метод без вызова yield return. Это может случиться, если вызвать yield break или просто дать управлению выйти из метода самостоятельно.

Ниже приведен пример класса, реализующего несколько итераторов.

using System;
using System.Collections;
using System.Collections.Generic;

public class Test<T> : IEnumerable<T> where T: new()
{
  public Test(T[] ary)
  {
    _items = new T[ary.Length];
    ary.CopyTo(_items, 0);
  }

  T[] _items;

  // Реализация интерфейса IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    return this.TopToBottom.GetEnumerator();
  }

  // Типизированный прямой итератор
  public IEnumerable<T> TopToBottom
  {
    get
    {
      for (int i = _items.Length - 1; i >= 0; --i)
        yield return _items[i];
    }
  }

  // Типизированный обратный итератор
  public IEnumerable<T> BottomToTop
  {
    get
    {
      for (int i = 0; i < _items.Length; i++)
        yield return _items[i];
    }
  }

  // Нетипизированный итератор
  public IEnumerable SomeEnumerator
  {
    get
    {
      // Так как итератор нетипизированный, можно возвращать
      // любую пургу :)
      yield return 123;
      yield return 654;
      yield return 999;
      yield break;
    }
  }
}

class App
{

  static void Main(string[] args)
  {
    Test<int> x1 = new Test<int>(new int[] { 2, 3, 5, 6 });

    Console.WriteLine("x1.TopToBottom");
    foreach (int i in x1.TopToBottom)
      Console.WriteLine(i);

    Console.WriteLine("x1.BottomToTop");
    foreach (int i in x1.BottomToTop)
      Console.WriteLine(i);

    Console.WriteLine("x1");
    foreach (int i in x1)
      Console.WriteLine(i);

    Console.WriteLine("x1.SomeEnumerator");
    foreach (int i in x1.SomeEnumerator)
      Console.WriteLine(i);

    Console.WriteLine("...");
    Console.ReadLine();
  }
}

Этот код выводит на консоль следующий текст:

x1.TopToBottom
6
5
3
2
x1.BottomToTop
2
3
5
6
x1
6
5
3
2
x1.SomeEnumerator
123
654
999
...

Как можно заметить, оператором return можно вернуть готовый перечислитель. При этом повторных вызовов не будет, так как все вызовы пойдут к возвращенной реализации.

А теперь небольшой сюрприз. Описанный мной синтаксис пока не работает. Нет, итераторы работают, но вместо yield return пока что нужно писать просто yield, а yield break вообще не работает. Но это ненадолго. Решение об изменении синтаксиса уже принято, и в стандарте C# 2.0 описан уже новый вариант. Это сделали, чтобы исключить конфликты, связанные с тем, что слово yield ранее не было ключевым. Сочетание yield с ключевыми словами return и break гарантирует отсутствие пересечений.

Из обнаруженных мной проблем можно выделить то, что мне не удалось создать итератор, производящий рекурсивный вызов. Это довольно серьезное ограничение, поскольку итераторы как нельзя лучше подошли бы для деревьев и других рекурсивных структур, для которых создавать перечислители особо сложно и неудобно. Будем надеяться, что к release-версии эту проблему устранят.

Анонимные методы

Анонимные методы – это еще один «синтаксический сахар», привнесенный в язык. Вы, наверное, не раз подключались вручную к событиям того или иного компонента. Делегаты радикально упростили эту задачу по сравнению с языками, в которых подобной возможности нет. Для подключения к событиям достаточно объявить метод с подходящим набором параметров, где-нибудь при инициализации создать экземпляр делегата и подключить метод к событию через него. Те из вас, кто программировал на VB, знают, что на этом языке подключаться к событиям несколько проще. Однако операция довольно проста и на C#. И все же создатели C# решили еще более упростить данное действие, устранив необходимость создания метода-обработчика. Да-да, именно самого метода. Теперь подключать к делегату можно не только отдельный метод, но и так называемый безымянный метод, а по сути, блок кода, размещаемый «по месту». Сравните старый способ подключения к событию:

using System;

class App
{
  delegate int AlgorithmDelegate(int i);

  static int ToMultiplyBy2(int i)
  {
    return i * 2;
  }

  static void Main()
  {
    AlgorithmDelegate test = new AlgorithmDelegate(ToMultiplyBy2);

    Console.WriteLine("test(5): {0}", test(5));
  }
}

И новый, использующий анонимные методы:

using System;

class App
{
  delegate int AlgorithmDelegate(int i);

  static void Main()
  {
    AlgorithmDelegate test = delegate(int i) { return i * 2; };

    Console.WriteLine("test(5): {0}", test(5));
  }
}

Собственно сам анонимный метод выделен красным.

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

using System;

class App
{
  delegate int AlgorithmDelegate(int i);

  static AlgorithmDelegate _test;

  static void Test1()
  {
    int multiplier = 4;
    Console.WriteLine("Множитель = {0}", multiplier);

    _test = delegate(int i) { return i * multiplier; };

    Console.WriteLine("_test(5): {0}", _test(5));

    multiplier = 10;
    Console.WriteLine("Множитель = {0}", multiplier);
  }

  static void Test2()
  {
    Console.WriteLine("_test(5): {0}", _test(5));
  }

  static void Main()
  {
    Test1();
    Test2();
  }
}

Обратите внимание на выделенный красным текст. Заметьте, что второй раз переменная изменяется после того, как анонимный метод присваивается делегату. Второй вызов же вообще осуществляется в момент, когда по правилам C# переменная multiplier должна была давно быть разрушенной. Как же такое возможно? Все очень просто, компилятор C#, видя, что локальная переменная используется анонимным методом, создает скрытый класс, в котором размещает анонимный метод и используемые им внешние переменные. Все обращения к совместно используемым переменным заменяются обращениями к переменным этого класса. Вот код, реально формируемый компилятором для приведенного выше примера (после декомпиляции причесан руками):

using System;

class App
{
  public delegate int AlgorithmDelegate(int i);

  private sealed class __LocalsDisplayClass$00000001
  {
    public int __AnonymousMethod$00000000(int i) { return i * multiplier; }

    public int multiplier;
  }


  private static AlgorithmDelegate _test;


  private static void Test1()
  {
    __LocalsDisplayClass$00000001 s$1 = new __LocalsDisplayClass$00000001();
    s$1.multiplier = 4;
    Console.WriteLine("Множитель = {0}", s$1.multiplier);

    App._test = new AlgorithmDelegate(s$1.__AnonymousMethod$00000000);

    Console.WriteLine("_test(5): {0}", _test(5));

    s$1.multiplier = 10;
    Console.WriteLine("Множитель = {0}", s$1.multiplier);
  }

  private static void Test2()
  {
    Console.WriteLine("_test(5): {0}", _test(5));
  }

  private static void Main()
  {
    Test1();
    Test2();
  }
}

Мое мнение об этом нововведении – ничего концептуально нового оно не приносит. Все то же самое можно было написать и раньше. Но, несомненно, это новшество облегчит нашу с вами жизнь. Чтобы оценить его полезность, достаточно сравнить объем и читабельность кода исходного примера и декомпилированной версии.

Инициализация делегатов

Совсем уж простенькое нововведение, однако, очень удачное – это упрощенный синтаксис инициализации делегатов. Кстати, я уже пользовался им в примере выше. Дело в том, что строчка:

_test = delegate(int i) { return i * multiplier; };

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

SomeDelegateType = new SomeDelegateType(SomeMethodName);

теперь можно писать:

SomeDelegateType = SomeMethodName;

Мелочь, но чертовски приятно и удобно. Побольше бы таких «синтаксических сахаринок». :)

Partial types

Это тоже довольно простая и не жизненно важная возможность, однако она уже не относится к синтаксическим изыскам. Partial types – это возможность разделять код класса на несколько частей. Это не отделение деклараций от реализаций (они, как и прежде в C#, объединены), а простая возможность разнести код на два или более дисковых файла.

Эта возможность может оказаться очень полезной, если какой-либо класс очень велик, если над одним классам параллельно должны работать несколько программистов или если часть класса генерируется некоторым генератором исходного кода (например, дизайнером форм/компонентов). Как я понимаю, в первую очередь эта возможность сделана для поддержки нового стандарта описания графического интерфейса пользователя – языка XAML. XAML – это основанный на XML язык разметки, позволяющий создавать графический интерфейс и отрисовывать графические композиции любой сложности. Он появится в составе новой версии Windows, которая сейчас имеет кодовое имя Longhorn. Чтобы описать возможности этого нового языка скажу только, что с его помощью сделано половина GUI Longhorn и только лишь его средствами отверстана настоящая книга (поставляется вместе с Longhorn SDK). Причем качество верстки очень высокое, сравнимое с тем, что можно добиться средствами Adobe Postscript. Однако, в отличие от Postscript, XAML прекрасно читается. Так вот, для создания GUI XAML парсится специальным приложением ac.exe (Avalon Compiler), после чего получается код на некотором языке программирования, поддерживаемом .NET (на сегодня реально поддерживается только C#, но обещано, что в дальнейшем будут поддерживаться VB и MC++). Программист может добавить свой код, а также указать внешний файл, содержащий код. Так вот, для объединения этого внешнего файла с кодом, сгенерированным ac.exe используются именно новые Partial types.

Вот пример использования Partial types:

// Первый файл (Customer1.cs)
public partial class Customer
{
  private int id;
  private string name;
  private string address;
  private List<Order> orders;
  public Customer()
  {
  ...
  }
}

// Второй файл (Customer2.cs)
public partial class Customer
{
  public void SubmitOrder(Order order)
  {
    orders.Add(order);
  }
  public bool HasOutstandingOrders()
  {
    return orders.Count > 0;
  }
}

Как видите, о том, что класс состоит из двух частей, говорит новое ключевое слово «partial».

Кстати, части класса не обязаны находиться в разных файлах. Их с успехом можно поместить и в один, но вот зачем это может понадобиться, мне никак не приходит в голову. Видимо, Microsoft проявляет заботу о раздолбаях, расширяя им возможности для творчества. :)

Вместо заключения

Итак, вот я и описал новые возможности, которые должны появиться в новой версии C# – 2.0. Однако .NET не ограничивается C#. В .NET есть еще и другие языки, а также библиотека и runtime. Да и VC.NET в какой-то мере можно отнести к .NET, ведь не даром же с каждой новой версией .NET выходит и новая версия VC.NET. Рассказать обо всех новшествах в .NET в целом просто непосильная задача. Я думаю, что в будущем наш журнал обязательно вернется к этой теме, и почему-то мне кажется, что это случится еще не раз. :)


Эта статья опубликована в журнале RSDN Magazine #6-2003. Информацию о журнале можно найти здесь
    Сообщений 23    Оценка 1226 [+1/-0]         Оценить