среда, 19 сентября 2012 г.

Структуры и конструкторы по умолчанию

Я решил немного развить тему, поднятую в Google+ о том, почему большинство языков программирования для платформы .NET не позволяют объявлять конструкторы по умолчанию для структур (т.е. для значимых типов).

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

Вот простой пример, как вы ответите на следующий вопрос: сколько значимых типов из .NET Framework содержит конструкторы по умолчанию? Интуитивным ответом кажется "все", и будете не правы, поскольку на самом деле, ни один из значимых типов .NET Framework не содержит конструктора по умолчанию.

Но давайте обо всем по порядку и начнем со сравнения таких понятий, как конструктор по умолчанию (default constructor) и значения по умолчанию (default values).

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

Но когда речь заходит о структурах, то тут все становится немного сложнее. Значением по умолчанию экземпляра значимого типа является «нулевое» представление всех его полей, т.е. все числовые поля равны 0, а все ссылочные поля равны null. С точки зрения языка C# конструктор по умолчанию значимого типа делает тоже самое – он возвращает экземпляр структуры со значением по умолчанию. Это значит, что следующий код является эквивалентным:

Size size1 = new Size();

Size size2 = default(Size);

В данном случае для обоих строк кода компилятор сгенерирует одну и ту же инструкцию iniobj, которая приведет к идентичному результату в обоих случаях – обнулению всех полей структуры Size.

В спецификации языка C# сказано, что пользователю запрещается создавать конструктор по умолчанию явно, поскольку любая структура содержит его неявно. Однако это не совсем так: если мы получим список конструкторов типа Size, то мы не увидим там конструктора без параметров. То что в языке C# называется конструктором по умолчанию, на самом деле является «обнулением» объекта и не является конструктором в привычном смысле этого слова и не содержит никакого специализированного кода в типе Size.

Аналогичное смешивание понятий существует не только при вызове оператора new, но и при инициализации полей структуры в конструкторе. Так, для конструкторов структур применяются те же правила обязательной инициализации всех полей структуры, аналогичные правилам для локальных переменных (definite assignment rules). Это означает, что до завершения тела конструктора все поля структуры должны быть явно или неявно проинициализированы:

struct SomeStruct
{
    private int _i;
    private double _d;

    public SomeStruct(int i)
        : this() // вызываем “конструктор по умолчанию”
    {
        _i = i;
        // Поле _d инициализировано неявно!
    }
}

Вызов this() выглядит в точности как вызов конструктора по умолчанию и предотвращает ошибку компиляции за счет того, что он обнуляет (а значит и инициализирует) все поля структуры. На самом же деле вызов this() превращается все в ту же инструкцию initobj, используемую ранее для получения значения по умолчанию экземпляра структуры.

Такое смешивание понятий конструктора по умолчанию с получением значения по умолчанию для значимых типов является общепринятым на платформе .NET, но не является обязательным. Некоторые языки, как например, «голый» IL или Managed C++, поддерживают полноценные пользовательские конструкторы по умолчанию для значимых типов, которые позволяют инициализировать состояние структуры произвольным образом, а не только значениями по умолчанию.

Хотя язык C# не позволяет создавать конструкторы по умолчанию для структур он позволяет их использовать. Когда компилятор языка C# встречает инструкцию “new SomeType”, то генерируемый код зависит от того, является ли указанный тип классом или структурой, а также от того, содержит ли структура полноценный конструктор по умолчанию или нет:

StringBuilder sb = new StringBuilder(); // 1

Size size = new Size(); // 2

CustomValueType cvt = new CustomValueType(); // 3

В первом случае будет вызван конструктор по умолчанию класса StringBuiilder с помощью инструкции newobj, однако результат создания экземпляра значимого типа зависит от его реализации.

Оба типа Size и CustomValueType являются значимыми типами, при этом тип CustomValueType содержит конструктор по умолчанию (мы рассмотрим позднее, как этого добиться). В строке 2 происходит инициализация переменной size значениями по умолчанию, с помощью инструкции initobj, а в строке 3 происходит вызов конструктора типа CustomValueType с помощью инструкции call CustomValueType..ctor.

Создание структуры с конструктором по умолчанию

Для генерации структуры воспользуемся модулем System.Reflection.Emit, который поддерживает весь необходимый функционал. Процесс создания нового типа начинается создания объекта AssemblyBuilder, внутри которого создается экземпляр DynamicModuleBuilder, в котором уже создается тип. Такой порядок объясняется тем, что сборка, на самом деле, содержит лишь метаданные сборки (зависимости и т.п.) и модули, которые, в свою очередь уже содержат пользовательские типы.

public static Type GenerateValueTypeWithDefaultConstructor(string name,

    string outputString = "Hello, value type's default ctor!")

{

    // Генерируем сам тип

    var assemblyName = new AssemblyName("StructEmitter");

    var appDomain = AppDomain.CurrentDomain;

    var assemblyBuilder = appDomain.DefineDynamicAssembly(assemblyName,

                AssemblyBuilderAccess.RunAndSave);

 

    var moduleBuilder = assemblyBuilder.DefineDynamicModule(

            assemblyName.Name,

            Path.ChangeExtension(assemblyName.Name, "dll"));

 

    var typeBuilder = moduleBuilder.DefineType(name,

            TypeAttributes.Public, typeof(ValueType));

 

    // Генерируем конструктор по умолчанию

    var constructorBuilder = typeBuilder.DefineConstructor(MethodAttributes.Public,

        CallingConventions.Standard, new Type[] { });

 

    var ilGenerator = constructorBuilder.GetILGenerator();

 

    // Вызываем метод Console.WriteLine с указанным параметром

    ilGenerator.EmitWriteLine(outputString);

    ilGenerator.Emit(OpCodes.Ret);

 

    //assemblyBuilder.Save();

 

    // "Закрываем" сгенерированный тип и возвращаем результат

    return typeBuilder.CreateType();

}

Метод GenerateValueTypeWithDefaultConstructor принимает имя типа и строку, выводимую на консоль (строка нужна для написания модульного теста, проверяющего работу этого метода). После этого, мы генерируем простой значимый тип с конструктором, вызывающим Console.WriteLine с указанной строкой.

После этого мы можем создать данный тип с помощью Activator.CreateInstance или же сохранить сгенерированную сборку с помощью AssemblyBuilder.Save и использовать ее обычным образом. После этого, можно будет пользоваться сгенерированным типом обычным образом и спокойно вызвать конструктор по умолчанию значимого типа:

// Генерируем тип и создаем экземпляр с помощью Activtor.CreateInstance

var type = CustomStructEmitter

                .GenerateValueTypeWithDefaultConstructor("CustomValueType");

var cvt1 = Activator.CreateInstance(type);

 

// Или создаем его обычным образом

var cvt2 = new CustomValueType();

Правила вызова конструктора по умолчанию

Существует несколько моментов использования из языка C# структур с настоящим конструктором по умолчанию.

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

var array = new CustomValueType[5];

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

Для вызова конструктора всех элементов нужно сделать это явно:

// Приведет к вызову конструкторов всех экземпляров массива

array.Initialize();

Но даже если бы разработчики CLR и языков платформы .NET пошли бы на падение производительности за счет вызова десятков кастомных конструкторов, то это не избавило бы от всех проблем.

Давайте рассмотрим следующий код:

var list = new List<CustomValueType>(50);

Данный конструктор принимает емкость (capacity) списка, а значит внутри него будет создан массив соответствующего размера, что, в свою очередь, приведет к вызову не менее 50 конструкторов по умолчанию, хотя текущий размер списка все равно будет равным 0. Для решения этой проблемы пришлось бы разделить процесс выделения памяти для массива от процесса создания экземпляра значимого типа внутри него. Такая техника применяется в языке С++ с помощью размещающего оператора new, однако это явно добавило бы больше проблем, нежели пользы, поэтому достаточно разумно, что разработчики .NET на это не пошли.

ПРИМЕЧАНИЕ
На платформе .NET проблема со списками, массивами и перечислениями все еще существует. Подробности можно прочитать в статье «Проблемы передачи списка перечислений или Почему абстракции текут».

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

 

var cvt = new CustomValueType();

Вызывается

 

var cvt = Activator.CreateInstance(typeof(CustomValueType));

Вызывается

 

var cvt = default(CustomValueType);

Не вызывается

static T CreateAsDefault<T>()

{

    return default(T);

}

 

CustomValueType cvt = CreateAsDefault<CustomValueType>();

Не вызывается

static T CreateWithNew<T>() where T : new()

{

    return new T();

}

 

CustomValueType cvt = CreateWithNew<CustomValueType>();

Не вызывается!!

var array = new CustomValueType[5];

 

Не вызывается

Хочу обратить внимание на рассогласованность поведения в следующем случае: известно, что создания экземпляра обобщенного (generic) параметра происходит с помощью Activator.CreateInstance, именно поэтому при возникновении исключения в конструкторе пользователь обобщенного метода получит его не в чистом виде, а в виде TargetInvocationException. Однако при создании экземпляра типа CustomValueType с помощью Activator.CreateInstance наш конструктор по умолчанию будет вызван, а при вызове метода CreateWithNew и создания экземпляра значимого типа с помощью new T() – нет.

Заключение

Итак, мы выяснили следующее:

  1. Конструктором по умолчанию в языке C# называется инструкция обнуления значения объекта.
  2. С точки зрения CLR конструкторы по умолчанию существуют и язык C# даже умеет их вызывать.
  3. Язык C# не позволяет создавать пользовательские конструкторы по умолчанию для структур, поскольку это привело бы к падению производительности при работе с массивами и существенной путанице.
  4. Работая на языках, поддерживающих создание конструкторов по умолчанию, их объявлять все равно не стоит по тем же причинам, по которым они запрещены в большинстве языков платформы .NET.
  5. Значимые типы не так просты как кажется: помимо проблем с изменяемостью (мутабельностью) у значимых типов даже с конструкторами по умолчанию и то не все просто.

5 комментариев:

  1. Спасибо, Сергей, полезно...

    Предпоследний случай (когда не вызывается конструктор по умолчанию) - неожиданно. Получается, что компилятор проверяет наличие конструктора по умолчанию для generic-ов типа new(). Раньше я думал, что там просто должен быть class вместо struct

    ОтветитьУдалить
  2. еще пример, когда конструктор не будет вызван - FormatterServices.GetUninitializedObject(typeof(customValueType))

    ОтветитьУдалить
  3. @Евгений: Если в ограничении будет struct, то ограничение new() будет избыточным, ведь любая структура в .NET-е содержит "конструктор по умолчанию", поэтому поведение для ограничений new() и struct будут одинаковыми.

    @Lonli-Lokli: Да, но в этом случае он не будет вызван не только для структуры, но и для класса.

    ОтветитьУдалить
  4. О, прикольно, сначала в g+ отписался. Теперь прочитал,что не ошибся. Сереж, а вот есть нужда в таких вещах? Все в курсе, что конструктора по умолчанию в ValueType нет(ну надеюсь :)). А ты смуту вносишь :). Я к тому, что ценность конструктора по умолчанию для ValueType мне представляется сомнительной. А тут получается как в известной поговорке: Если нельзя, но очень хочется - то можно... В общем, с академической точки зрения - все ок. На практике - я бы не стал делать такие типы. Слишком большая вероятность, что сопровождать будет не просто...

    ОтветитьУдалить
  5. @eugene: Жень, нужды в таких вещах, конечно, нет. Просто здесь было интересно разобраться в терминологических и внутренних особенностях значимых типах с точки зрения C# и с точки зрения CLR.

    Вот у меня теперь паззл сложился, а то до этого меня дико смущал этот вопрос в контексте языка C#.

    Ну и еще, для особо дерзких кандидатов теперь есть вопрос: "Ну ладно, а можно ли объявить конструктор по умолчанию для значимого типа на платформе .NET?" И потом долго спорить по этому поводу;)

    ОтветитьУдалить