Обзор C# 4.0

Недавно была выпущена .NET Framework 4.0 CTP и теперь нам стоит изучить новые возможности C# 4.0. В данной статье мы расскажем о следующих возможностях: динамический просмотр (поиск методов), ковариация и контрвариация, поименованные и необязательные параметры.

Динамический просмотр

Если при наличии  var в C# версии 3.0 вывод типов для локальных переменных сохраняет разработчику несколько движений, то динамический поиск методов прибавляет гораздо больше динамики языку C#. Когда вы объявляете переменную как тип dynamic, все ее вызовы методов или членство в доступах будут разрешены во время выполнения. Давайте, к примеру,  изучим следующий код:

public static void Main() {
    dynamic obj = “I’m statically System.String”;
    obj.NotExistingMethod(“param”);
}

В данном блоке мы создаем строковый объект и вместо того, чтобы назначать переменной тип string, мы назначаем переменной тип dynamic. Это диктует компилятору то, чтобы он не пытался разрешить вызов методов или доступ со стороны членов к этой переменной. Вместо этого все эти действия будут происходить во время выполнения. В таком случае вызванный следующей строкой несуществующий  метод с произвольным параметром  будет  скомпилирован  в  CIL. Во время выполнения при резолюции вызова метода и  в случае,если такой метод не будет найден в типах выполнения (которым является System.String), будет создано исключение. При этом если мы используем валидный метод, то исключение не будет вызвано и код будет скомпилирован до самого конца. К примеру:

private static void PrintID(dynamic obj) {
    Console.WriteLine(obj.ID);
}

public static void Main() {
    var person = new {ID = 111, Name = "Buu"};
    PrintID(person);

    var account = new {ID = 101, Bank = "Some Bank"};
    PrintID(account);
}

Мы практически создаем два анонимных типа, для удобства у обоих свойство названо ID, и затем создаем экземпляры данных объектов данных типов. Затем мы передаем каждый из этих объектов в метод PrintID, который принимает динамический объект и отображает свойство ID. Код выведет “111” и “101” соответственно. И, конечно, это утиная типизация в действии - в C#.

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

Мы не просто ограничены преобразованием статически типизированных объектов .NET в динамические, мы можем использовать данную функцию динамического просмотра для взаимодействия с реальными динамическими объектами, доступными посредством Dynamic Language Runtime (DLR), включенным в .NET Framework 4.0. Более того, мы можем реализовать такие динамические объекты в C# путем реализации интерфейса System.Scripting.Actions.IDynamicObject, что также является частью DLR. Независимо от реального приемника динамического размещения, использование динамического просмотра является таким с вызывающей стороны.

Кое-кто может спросить о коде, который создает компилятор и есть ли какие-либо изменения в CLR для поддержки данного динамического поиска. Для ответа на данный вопрос мы рассмотрим CIL , сгенерированный из самого первого фрагмента кода данной статьи. (Щелкните по изображению чтобы увидеть его в оригинальном размере.)

Сгенерированный CIL довольно многословен, но пробежавшись по нему, мы поймем несколько важных вещей. Во-первых, наша “динамическая переменная” оказывается простым экземпляром CLR System.Object. А во-вторых не существует новой директивы CIL или машинного кода для поддержки динамического просмотра. Вместо этого динамическое разрешение и вызов полностью обрабатывается кодом структуры. В частности, вышеуказанный CIL эквивалентен следующему C# коду, но теперь бех ключевого слова dynamic.

object obj = "I'm statically System.String";
var payload = new CSharpCallPayload(
    RuntimeBinder.GetInstance(),
    false, false, "NotExistingMethod",
    typeof(object), null);
var callSite = CallSite<Action<CallSite, object, string>>.Create(payLoad);
Action<CallSite, object, string> action = callSite.Target;
action.Invoke(callSite, obj, "param");

(Кстати, код был упрощен, тем самым  вместив в себя один метод. Реальный CIL имеет проверку в IL_000c, который просматривает статическое поле callSite вложенного класса, что генерируется автоматически компилятором для того, чтобы проверить если оно null или нет до того, как инициализировать его. Другими словами, callSite кэшируется для последующих вызовов того же метода.)

Итак, нет никаких фокусов у ключевого слова dynamic. На самом деле компилятор создает некоторый полезный груз, содержащий информацию о вызове, поэтому он может быть сделан во время выполнения. Если что-то и было бы фокусом, то это статический метод CallSite.Create() , который использует Reflection для вызова NotExistingMethod к данному объекту если он не является экземпляром IDynamicObject.

Теперь мы знаем, что “динамический объект” на самом деле является простым объектом – он объясняет, почему метод может принимать динамические параметры. Это не открытие, что динамический просмотр также может быть применен к возвращаемому значению экземпляра/статического метода или экземпляра/статического поля. В конце концов, в отличие от ключевого слова var , которое требует от компилятора определять конкретный тип во время компиляции, компилятор в случае с динамическим просмотром может просто выбрать System.Object в качестве типа.

Ковариация и контрвариация обобщённых переменных

В предыдущих версиях C#, обощеные типы были инвариантными (неизменяемыми). То есть для любых двух типов GenericType<T> и GenericType<K>, в котором T является подклассом K или K является подклассом T, GenericType<T> и GenericType<K> не имеет никаких наследственных связей между ними. С другой стороны, если T является подклассом K и если C# поддерживает ковариацию, GenericType<T> также является подклассом GenericType<K>, или если C# поддерживает ковариацию, GenericType<T> является суперклассом GenericType<K>.

Чтобы понять, почему версии C# до 4.0 не поддерживают ковариацию и контрвариацию, давайте изучим некоторый код:

var strList = new List<string>(); 
List<object> objList = strList; // ошибка компилятора, с преобразование или без

Код во второй строке может быть ошибочным. Вот что мы можем написать после второй строки, если допустить ее верность:

// мы добавляем  случайный AnyObject к тому, что во время выполнения является списком строк 
objList.add(new AnyObject());

С другой стороны, если C# поддерживает контрвариацию, то мы могли бы написать следующий проблематичный код:

var objList = new List<object>; 
objList.add(3);
objList.add(new AnyObject());
List<string> strList = objList;
// случайный объект теперь считает, что элемент строкового типа string
element = strList.get(0);

Благодаря данному запрету по отношению к инвариантности, пусть даже раде доброго помысла, мы можем с легкостью повторно использовать переменные и методы для того, чтобы соответственно быть назначенными или принять различные обобщенные типы. В какой-то степени это неуместно, поскольку нам стоит понять, что ковариация полезна пока GenericType<T> не имеет метода или члена, принимающего аргументы типа T (то есть, если мы не можем добавить несколько объектов в objList, что на самом деле является List<string>, все будет хорошо). Кроме того, контрвариация столь  же безопасна в случае, если T не появится ни в одном значении, возвращенном от члена или метода (то есть, если мы не можем получить строку из strList, что на самом деле является экземпляром List<object>, то все будет хорошо).

К счастью, C# 4.0 предоставляет нам следующую возможность - если обобщенный интерфейс или обобщенный делегат имеет ссылочный тип T в качестве его параметра типа и не обладает методом или членом, который принимает параметр типа T, мы можем объявить его в качестве ковариации над T. С другой стороны, если этот интерфейс или делегат не имеет ни одного метода или члена, который бы возвращал T, мы можем объявить его в качестве контрвариантного по отношению к T. (Акцент делается  на то, что только интерфейсы и делегаты поддерживают ковариацию и контрвариацию, и параметр типа должен быть ссылочным типом. С другой стороны, массивы C# поддерживали ковариацию с самого начала.)

Давайте изучим пример. У нас есть делегат Generator , который возвращает случайные объекты определенного типа (то есть, string). Его объявление будет следующим:

delegate T Generator<out T>();

Так как данный делегат не принимает ни одного T в качестве аргумента мы можем спокойно сделать его ковариационным над T. И вправду, компилятор позволит нам сделать это при помощи добавления модификатора out до параметра типа. Тем не менее, если данный делегат объявлен в качестве delegate T Generator<out T>(T seed), к примеру, то компилятор будет возникать, потому что это уже не настолько безопасно для ковариации. Давайте изучим использование:

Generator<string> strGen = new Generator<string>(StringGenerator);

// Следующая строка компилируется, потому что Generator<string> является
// подклассом Generator<object> под правилом ковариации
Generator<object> objGen = strGen;

// Преобразование типа также разрешается по той же причине
strGen = (Generator<String>)objGen;

...

private string StringGenerator()
{
    return "I'm a random string";
}

В случае с контрвариацией вам необходимо использовать ключевое слово in . Давайте рассмотрим пример, который использует как ковариацию, так контрвариацию:

delegate K Converter<in T, out K>(T param);

Этот преобразователь получает объект типа T и преобразует его в объект типа K (то есть, преобразует String в Object). Поскольку он не получает K, то он может быть спокойно объявлен в качестве ковариации над K. Аналогично, если он не обладает T, то он может быть объявлен контрвариационным над T. Вот его использование:

var converter = new Converter<object, string>( ConvertImpl);
Converter<string, object> string2ObjectConverter = converter;
object result = string2ObjectConverter("A");

...

private string ConvertImpl(object o) {
    return o.ToString();
}

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

До того, как мы закончим тему ковариации и контрвариации, давайте рассмотрим код, генерируемый компилятором. Ведь мы знаем, что компилятор должен шифровать что-то в CIL чтобы отметить ковариантые и/или контрвариантные обобщенные типы, тем самым они могут быть верно употреблены клиентским кодом. А вот как выглядят объявления наших Generator и Converter делегатов, если просмотреть их посредством ILDASM.

Обратите внимание, что есть знаки минуса и плюса, используемые для обозначения контрвариации и ковариации соответственно. Интересно, что CLR поддерживал такой CIL со времен введения Generics в .NET 2.0. Поэтому вы можете написать CIL используя ковариацию и контрвариацию в .NET 2.0+. Только сейчас вы можете сделать то же самое и в C#.

Последняя мысль о данной возможности заключается в том, что хотя это хорошее улучшение языка, реализация ковариации/контрвариации, возможных посредством групповых символов (wildcard), в Java выглядят лучше, а также более гибко. В любом случае, можно порадоваться тому, что у нас есть такая возможность.

Необязательные и поименованные параметры

Последние две функциональности языка C# 4.0 заключаются в поименованных и опциoнальных параметрах. Эти возможности присутствовали в VB.NET всю жизнь и теперь мы можем порадоваться их реализации в C#.

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

public Cart(int id, String name = “default cart”, double amount = 0d) {…}

Теперь вы можете вызвать данный конструктор любым из следующих вызовов:

new Cart(1); 
new Cart(1, “my cart”);
new Cart(1, “my cart”, 105.5);

Как же C# реализует данную функциональность? Если мы изучим CIL, сгенерированный для данного кода, то мы не найдем никакого секрета. В общем, стандартные значения будут внесены в вызов , таким образом вызов метода происходит нормальным способом. Вот код CIL , сгенерированный компилятором:

Кое-кто может подумать, что компилятор получает стандартные значения в исходном коде для инъекции в код вызывающего. Тем не менее, это не сработает если метод с опциональными параметрами будет доступен как библиотека. В этом случае не существует способа, в котором компилятор получает стандартные значения для инъекции их в код клиента библиотеки. Поэтому компилятор на самом деле шифрует параметры прямо в метод. Вот код CIL для конструктора Cart:

Отметим директиву .param , которая говорит компилятору про стандартные значения опциональных параметров. Эти параметры также отмечены атрибутом [opt].

Хотя это очень хорошая функциональность, существует некоторое предупреждение к использованию необязательных параметров: поскольку компилятор встраивает стандартные значения на стороне вызывающего, изменения стандартных значений на стороне библиотеки не будет иметь эффекта, пока «вызыватель» не будет повторно скомпилирован. Другими словами, вам стоит учесть стандартные значения в качестве части опубликованного интерфейса (API) метода или же вам стоит задать их с самого начала.

Теперь, допустим мы хотим вызывать конструктор Cart с ID и указанным amount , но не с name - как же нам это сделать ?

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

new Cart(1, 15.5d); // ошибка компилятора

Одним из подходов в C# является позволение следующего синтаксиса :

new Cart(1, , 15.5d);

Тем не менее, это выглядит ужасно с одним пропущенным параметром, еще хуже - если будет пропущено больше параметров. (Как же читать следующий код: MethodWithManyFields(,,,15,,”param”,,)?)

Поэтому, идеальное решение заключается в поименованных параметрах:

new Cart(1, amount: 15.5d);

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

Одним из подходов является создание конструктора, создающего экземпляр такого класса. К примеру:

new BigClass.Builder().attr1(“value”).attr2(“value”)...attrN(“value”);

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

С C# 3. 0 это не так страшно, поскольку мы можем использовать  инициализатор объекта и сделать что-то наподобие этого:

new BigClass {Attr1 = “value”, Attr2 = “value”, …, AttrN = “value”}

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

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

new BigClass{attr1: “value”, attr2: “value”, …, attrN: “value”);

// Следующая строка выполняет ту же функциональность    
new BigClass{attrN: “value”, attr1: “value”, …, attr2: “value”);

Относительно реализации всего этого на уровне CIL , то компилятор достаточно умен для создания верного порядка аргументов и простого выполнения стандартного вызова метода.

Вывод

Вот и все ключевые свойства C# 4.0. Вам стоит быть довольным тем фактом, что C# поддерживает их. Возможно, кому-то кажется, что C# становится более сложным, и язык начал терять свое изящество? Может, с какой-то точки зрения это и так, но все не так плохо. Хотя C# обрастает все большими конструкциями для поддержки функционального и динамического программирования, статически типизированная природа и старые конструкции языка все еще на своем месте, и ни один разработчик не обязан использовать новые функции, если в этом нет необходимости. С другой стороны, данные функции предлагают больше возможностей тем, кому они нужны,  ведь все же лучше иметь больше возможностей, чем оказаться "связанным" ограничениями.