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

Типы-значения в среде .Net

QA или Полезные мелочи

Автор: Андрей Мартынов
The RSDN Group

Источник: RSDN Magazine #3
Опубликовано: 08.04.2003
Исправлено: 13.03.2005
Версия текста: 1.0
Две категории типов
Определение
Размещение
Размещение без инициализации
Обязательная инициализация
Инициализация по умолчанию
Инициализирующие значения
Удаление, код очистки
IDisposable и using
try/finally
Равенство
GetHashCode
Присваивание, копирование
MemberwiseClone
Параметры методов
Особый случай – string
Boxing/unboxing
Асимметрия boxing/unboxing
Приведение к object
Вызов унаследованного виртуального метода
Приведение к интерфейсу
using & boxing
Массивы
Массивы ссылок
Массивы значений
Разное
Синхронизация
Размещение полей
MC++ и типы-значения
Явный boxing/unboxing
Доступ внутрь box’а
Доступ внутрь box’а через интерфейс
Массивы в стиле C
Итоги
Таблица различий ссылочных типов и типов–значений
Литература

Две категории типов

Начиная программировать в среде .Net, довольно часто сталкиваешься с трудностями, в основе которых лежит недостаточно чёткое понимание различий в свойствах ссылочных типов (reference based types) и типов-значений (value based types). Между тем, мотивация применять типы-значения велика, т.к. умелое применение типов-значений может существенно повысить эффективность программного кода. Однако необходимо постоянно помнить, что типы-значения имеют ряд особенностей, которые необходимо учитывать как при разработке (определении) этих типов, так и при их использовании. Эти особенности value-типов и их отличия от ссылочных типов рассмотрены ниже.

Определение

Как уже упоминалось, в среде .Net есть две категории типов – ссылочные типы и типы значения. То, к какой категории будет принадлежать определяемый тип, задаётся при его определении. Ссылочные типы в языке C# определяются с помощью ключевого слова class.

class AClass
{
  public int n;
  public string m;
}

С точки зрения CLR, ссылочные типы – это типы, унаследованные от любых типов, кроме типов System.ValueType и System.Enum. Тип Enum сам является наследником System.ValueType. Потому ссылочные типы – это все типы, кроме прямых или косвенных потомков System.ValueType.

ПРИМЕЧАНИЕ

Массивы – это тоже ссылочные типы (хотя они определяются и без слова class). О массивах будет сказано ниже.

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

Типы-значения в языке C# – это типы, определённые с помощью ключевых слов struct, enum, а также все фундаментальные (то есть, входящие в список основных предопределенных типов, непосредственно понимаемых компилятором) типы (int, char, float,…), за исключением типа System.String (System.String – это единственный ссылочный фундаментальный тип).

enum AEnum { First, Second }
struct AValue
{
  public int n;
  public string s;
}

Компилятор C# считает базовым классом для типов-значений тип object (System.Object), хотя на самом деле (с точки зрения CLR) System.Object не является прямым базовым типом для типов-значений. Типы-значения – это наследники System.ValueType или System.Enum. Но C# скрывает это от программиста, и не позволяет явно наследоваться ни от ValueType, ни от Enum.

Размещение

Теперь о главном. Для чего типы разделены на две категории? Главная цель введения двух категорий типов в том, чтобы обеспечить два принципиально разных механизма управления памятью, два разных механизма выделения и освобождения памяти.

Экземпляры ссылочных типов размещаются в свободной памяти (в heap’е), а типы-значения размещаются в стеке создающего их потока. Это существенно различающиеся по своим свойствам способы выделения памяти.

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

Другое дело – объекты, размещённые в памяти. Срок их жизни не ограничен так жестко, как у переменных в стеке. Правда, за такое их долголетие приходится расплачиваться сложной процедурой сбора уже неиспользуемых объектов (сборкой мусора). Несмотря на все ухищрения разработчиков .Net, сборка мусора не может соревноваться в скорости с механизмом освобождением стековых переменных.

ПРИМЕЧАНИЕ

Заметьте, что в .Net применён совершенно другой, чем в C/C++, подход к управлению способом размещения объекта в памяти. Способ размещения объекта определяется не при использовании (применении) типа (как в C/C++), а при его определении (при его разработке). Программист, использующий готовый тип в своей программе, не волен изменить способ размещения объектов этого типа.

Переменные ссылочных типов содержат адреса объектов, а переменные типов-значений содержат сами объекты (их тела). Поэтому обращение к полям value-типов осуществляется непосредственно, без лишней косвенности. Это делает использование типов значений ещё более эффективным.

Список упрощений в работе value-типов на этом не исчерпан. Следующее упрощение связано с инициализацией.

Размещение без инициализации

То, что и для ссылочных типов, и для типов-значений применяется единый синтаксис инициализации объекта, не должно вводить вас в заблуждение. Несмотря на использование new, value-типы всё равно создаются в стеке.

AClass c = new AClass();  // размещается в heap’e 
AValue a = new AValue();  // размещается в стеке

Отличие состоит в том, что value-типы могут создаваться и без применения new. В этом случае объект value-типа создаётся неинициализированным (под него только выделяется память), и вам придётся его инициализировать перед первым использованием. Причем компилятор будет следить за этим, и не позволит использовать объект до тех пор, пока все поля не будут проинициализированы.

AValue a;  
a.n = 5; 
// a.s = "6";  
Console.WriteLine(a); // error CS0165: Use of unassigned local variable 'a'

Для value-типов new необходим только в тех случаях, когда вы считаете необходимым проинициализировать объект и хотите указать подходящий для этого конструктор. Способ выбора конструктора – традиционный, по количеству и типам параметров.

Обязательная инициализация

На конструктор value-типа накладывается ограничение – он должен обязательно проинициализировать все поля объекта. За этим опять будет следить компилятор!

struct AValue
{
  AValue(int n){ this.n = n; } // error CS0171: Field 'Values.AValue.s' must be 
                               // fully assigned before control leaves 
                               // the  constructor

  AValue(){ this.n = 5; this.s = "6"; } // error CS0568: Structs cannot contain 
                                        // explicit parameterless constructors
  public int n;
  public string s; // остался не проинициализированным
}

Обязательная-то она обязательная, но обмануть компилятор и поглядеть на объект в неинициализированном состоянии можно. Правда, для этого придётся скрутить руки компилятору при помощи слова unsafe.

struct AValue
{
  public int n;
}

unsafe static void Main(string[] args)
{
  AValue a;                 // а – не проинициализирован
  AValue* pa = &a;          // обманываем компилятор
  Console.WriteLine(pa->n); // выводим мусор на экран
  Console.ReadLine();
}

Инициализация по умолчанию

Ещё одно ограничение – конструктор обязательно должен иметь параметры. Дело в том, что для типов-значений нельзя задать свой конструктор по умолчанию (без параметров). На самом деле конструктор по умолчанию у каждого типа-значения имеется, его генерирует компилятор, но самостоятельно писать такие конструкторы не разрешено.

Дело в том, что по законам .Net используемый по умолчанию конструктор value-типа должен всегда инициализировать все поля нулями (0 или null). Такое вот жесткое правило. А чтобы нам было легче его соблюдать, компилятор всё взял на себя.

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

int i = new int();

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

public enum Enum1 { a = 2, b, c };

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

Enum1 e1 = new Enum1();
Console.WriteLine("Enum1 e1 = " + e1.ToString());

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

ПРИМЕЧАНИЕ

В чем причина введения правила нулевой инициализации value-типов? Ответ на этот вопрос связан с проблемами, возникающими на стыке управляемого и неуправляемого кода. Вот как об этом пишет Джеффри Рихтер [2]. “There are rare situation when the runtime must initialize a valuetype and is unable to call its default constructor. For example, this can happen when a thread local value type must be allocated and initialized when an unmanaged thread first executes managed code. In this situation, the runtime can’t call the type’s constructor but still ensure that all members are initialized to zero or null. For this reason, it is recommended that you don’t define a parameterless constructor on a value type.”

Есть редкие ситуации, когда runtime должен инициализировать value-тип, но не может вызвать его конструктор по умолчанию. Это может произойти, например, если локальный для потока (thread local) value-тип должен быть размещен и инициализирован, когда неуправляемый поток в первый раз исполняет управляемый код. В такой ситуации runtime не может вызвать конструктор типа, но обеспечивает инициализацию всех членов 0 или null. По этой причине рекомендуется не определять для value-типов конструкторов без параметров. (Вообще, больше похоже на слабую отмазку. – прим. ред.)

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

AValue a = new AValue(); // Использован конструктор по умолчанию. 
                         // Объект готов к работе

AValue b;                // память выделена, но объект не проинициализирован

Конструктор по умолчанию может вызываться и неявно. Например, при создании массива:

AValue[] arr = new AValue[1];
Console.WriteLine(arr[0].n); // Всё в порядке. arr[0] – проинициализирован
ПРИМЕЧАНИЕ

Если вы предпримете попытку исследовать value-тип с помощью механизма отражения (например, с помощью Ildasm.exe), то обнаружить информацию о конструкторе по умолчанию вам не удастся. Дело в том, что этот конструктор не существует как метод. Просто компилятор C# в тех местах, где требуется используемая по умолчанию инициализация value-типов, вставляет код инициализации (специальную команду языка IL – initobj). Таким образом, получается, что применяемый по умолчанию конструктор value-типов – это не метод, это инструкция языка IL.

Инициализирующие значения

С правилом нулевой инициализации связано ещё одно ограничение типов-значений. Инициализирующие значения полей запрещены. Вернее, они есть, они подразумеваются – это всегда или 0 или null, их нельзя изменить.

Что такое инициализирующие значения? Язык C# предоставляет удобную возможность инициализировать поля классов не только с помощью конструкторов, но и с помощью задания инициализирующих значений. Инициализирующие значения указываются прямо при объявлении поля, с помощью синтаксиса присваивания:

class AClass
{
  public AClass(int n) { this.n = n; }
  public int n;
  public string s = "6"; // s будет проинициализирован до вызова конструктора
}

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

.method public hidebysig specialname rtspecialname 
        instance void  .ctor(int32 n) cil managed
{
  .maxstack  8
  .language '{3F5162F8-07C6-11D3-9053-00C04FA302A1}',…
//000012:     public string s = "6"; 
  IL_0000:  ldarg.0
  IL_0001:  ldstr      "6"
  IL_0006:  stfld      string Values.AClass::s
//000010:     public AClass(int n) { this.n = n; }
  IL_000b:  ldarg.0
  IL_000c:  call       instance void [mscorlib]System.Object::.ctor()
  IL_0011:  ldarg.0
  IL_0012:  ldarg.1
  IL_0013:  stfld      int32 Values.AClass::n
  IL_0018:  ret
} // end of method AClass::.ctor

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

Удаление, код очистки

Объекты типов-значений располагаются в стеке. Поэтому память, занятая ими, освобождается при открутке стека при возврате из метода. Получается, что value-типы работают аналогично автоматическим переменным в C/C++. Но в C++ накоплен богатый опыт использования автоматических переменных для управления ресурсами. Не помогут ли value-типы использовать этот опыт и в C#?

Увы, нет. Вспомним, что главную роль в управлении ресурсами в C++ играл деструктор. А вот у .Net-типов деструктор вызывается совсем по другим правилам – только сборщиком мусора, и в тот момент, когда тот сочтёт нужным. Так что value-типы не помогут нам использовать старые трюки. Боюсь, я совсем вас расстрою, но должен сообщить, что у value-типов вообще не бывает деструкторов.

struct AValue
{
  ~AValue() {} // error CS0575: Only class types can contain destructors
  public int n;
}

Однако давайте посмотрим на ссылочные типы, у которых деструкторы есть. Объекты ссылочных типов удаляются сборщиком мусора (Garbage Collector). Как известно Garbage Collector работает по своим законам, и когда он соизволит удалить объект и освободить занимаемую им память, никому не известно. Только перед самым удалением объекта Garbage Collector вызывает его деструктор (что для C# – деструктор, то для CLR – метод Finalize()). Так что, если памяти в системе много, то деструктор может вызваться не раньше, чем через месяц-другой.

IDisposable и using

Поэтому деструктор – это не то место, где нужно освобождать критичные ресурсы (кроме памяти), захваченные объектом (соединения с базами данных, открытые файлы, ресурсы GDI и т.п.). Для обеспечения детерминированного завершения объектов предназначены интерфейс IDisposable и конструкция using. Вот пример их использования:

using (TextWriter writer = new StreamWriter(@"c:\values.txt"))
{
  writer.Write("Value types are stored as efficiently as primitive types!");
}

Конструкция using действует очень просто и надёжно. Для объекта, указываемого в заголовке using, гарантируется вызов метода Dispose при любом развитии событий внутри блока using – и при благополучном, и при возникновении исключительной ситуации. Гарантия эта столь же надёжна, как гарантия вызова деструктора автоматической переменной в C++.

IDisposable и using работают как со ссылочными типами, так и со структурами, если те реализуют интерфейс IDisposable. Однако при использовании оператора using со структурами не все так гладко. Компилятор C# начинает возмущаться любой попыткой присвоения значения свойству или полю структуры:

struct AValue : IDisposable
{
  public AValue(int n) { this.n = n; }
  public int Prop { get { return n; } set { n = value; } }
  public void SetN(int n){ this.n = n; }
  public void Dispose()
  {
    Console.WriteLine("Dispose: n = " + n.ToString());
  }
  public int n;
}

class Class1
{
  static void Main(string[] args)
  {
    using (AValue a = new AValue(1))
    {
      a.n = 2; // Во время компиляции здесь будет выдана ошибка!
      a.Prop = 3; // И здесь тоже будет ошибка.
      a.SetN(4); // А это, как ни странно, пройдет.
      Console.WriteLine(a.n);
    } // здесь вызовется a.Dispose();
    Console.ReadLine();
  }
}

По всей видимости, это ошибка компилятора.

Обратите внимание также на то, что метод Dispose объявлен не как чистая реализация метода:

  void IDisposable.Dispose()
  {
    Console.WriteLine("Dispose: n = " + n.ToString());
  }

а как public-метод:

  public void Dispose()
  {
    Console.WriteLine("Dispose: n = " + n.ToString());
  }

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

try/finally

Пока для структур самым надежным способом осуществить некоторые действия по очистке при выходе из области видимости переменной является конструкция try/finally. Например:

AValue a = new AValue();
try
{
  Console.WriteLine(a); // Если произойдёт исключение, a.Close() вызовется.
  return;               // При благополучном исходе a.Close() вызовется тоже.
}
finally 
{
  a.Close();            // Close – код очистки объекта а
}

Конструкция try/finally работает в любых CLR-совместимых языках, включая IL. При этом using представляет собой лишь более лаконичную запись для try/finally. Вообще говоря, что использовать в C# – try/finally или using – это дело вкуса.

Хочу заметить, что и конструкция using, и try/finally – это не так изящно, как автоматический вызов деструкторов в C++, ведь писать вызов кода очистки приходится ручками, явно. Эти способы отличаются только большей многословностью, в остальном они аналогичны деструкторам в C++.

Равенство

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

По умолчанию оператор == для ссылочных типов возвращает true, если ссылки указывают на один и тот же объект. Т.е. они содержат указатели на одну и ту же область памяти.

Для value-типов по умолчанию оператор == не определен.

AValue a1 = new AValue();
AValue a2 = new AValue();
bool b = (a1 == a2); // error CS0019: Operator '==' cannot be applied 
                     // to operands of type 'Values.AValue' and 'Values.AValue'

Правда, если вспомнить, что у каждого объекта есть метод Equals (унаследованный от object), то оказывается, что сравнивать value-типы всё-таки можно. Но реализация метода Equals, имеющаяся по умолчанию у value-типов, работает совсем не так, как у ссылочных типов. Она сравнивает не адреса объектов, а содержимое полей объектов. Эта реализация Equals считает объекты value-типов равными, если все их поля равны. Метод Equals применяет для получения информации о полях механизм рефлексии. Отсюда следует, что работать этот метод должен медленнее, чем Equals для ссылочных типов.

Вы, должно быть, заметили, что в тексте постоянно упоминаются слова "по умолчанию". Это неспроста. Дело в том, что CLR предоставляет возможность переопределить как реализацию метода Equals, так и реализацию операторов сравнения. Таким образом, для своего класса или структуры можно реализовать принцип сравнения, подходящий с точки зрения внутренней логики типа. Вот простой пример, позволяющий изменить логику сравнения объекта ссылочного типа, сделав ее похожей на логику, применяемую для value-типов:

  class C
  {
    public C(int n) { this.n = n; }

    public static bool operator ==(C c1, C c2)
    {
      return c1.s == c2.s && c1.n ==  c2.n;
    }
    public override bool Equals(object o)
    {
      return this == (C)o;
    }
    public override int GetHashCode()
    {
      return n.GetHashCode() + s.GetHashCode();
    }
    public static bool operator !=(C c1, C c2)
    {
      return c1.s != c2.s || c1.n != c2.n;
    }

    public int n;
    public string s = null;
  };

...

C c1 = new C(1), c2 = new C(2), c3 = new C(2);

Console.WriteLine("c1 == c2 - " + (c1 == c2));
Console.WriteLine("c2 == c3 - " + (c2 == c3));
Console.WriteLine("c3 == c3 - " + (c3 == c3));

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

c1 == c2 - False
c2 == c3 - True
c3 == c3 - True

Так, сравнение встроенного типа string производится на основе сравнения его значения, хотя string является ссылочным типом. Дело в том, что string – это класс, для которого переопределены методы и операторы сравнения. Но все же string – это не совсем обыкновенный класс, так как он очень тесно интегрирован в CLR. Компиляторы, в том числе C#, обрабатывают его особым образом. Например, допускается инициализация строковыми литералами и использование таких литералов вместо этого класса. За что разработчикам .Net – наше большое программистское спасибо! Должен заметить, что Java-программистам приходится писать выражение str1.Equals(str2) в тех местах, где на C# можно просто и ясно написать str1 == str2.

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

К сожалению, переопределение операторов полноценно реализовано только в языке C#. Например, при сравнении двух одинаковых строк на MC++ с помощью оператора == всегда возвращается false, так как сравниваются ссылки. Однако на MC++ можно писать реализации операторов, которые впоследствии использовать в C#.

GetHashCode

Метод GetHashCode, который все объекты в .Net получают в наследство от System.Object, очень тесно связан с методом Equals.

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

Абсолютно жесткое требование к хэш-функции состоит в том, что для равных (в смысле Equals) объектов хэш-функция должна выдавать одинаковые значения. Именно благодаря этому свойству хэш-функции мы можем ограничить область поиска объекта только теми объектами, которые имеют тот же хэш. Чтобы обеспечить требование «равный хэш для равных объектов», методы Equals и GetHashCode должны быть согласованы. По меньшей мере, они должны опираться на одни и те же исходные данные.

ПРИМЕЧАНИЕ

Представьте себе, что равенство объектов было бы решено определять по совпадению их цветов, а хэш вычислять исходя из запаха объектов. Как вы думаете, можно ли рассчитывать на выполнение принципа «равный хэш для равных объектов» в этой ситуации?

Поэтому, если обнаружится, что в каком-нибудь классе метод Equals переопределён, а GetHashCode нет, то компилятор С# выдаст предупреждение. Он подозревает худшее. Он подозревает, что в результате Equals и GetHashCode могут стать не согласованными.

struct AValue 
{
  public override bool Equals(object o) {…}
}
// warning CS0659: 'Values.AValue' overrides Object.Equals(object o)
// but does not override Object.GetHashCode()

Ну вот. Теперь легко догадаться, что способ работы Equals и GetHashCode для типов одной категории должны совпадать. А поскольку мы уже разобрались, как по умолчанию работает Equals для разных категорий типов, то нам легко догадаться, как будет работать по умолчанию метод GetHashCode (в этом разделе вообще речь пойдет о реализации функций сравнения и получения хэша, наследуемых от типа object). Хэш для ссылочных типов вычисляется на основе адреса объекта, а для value-типов – на основе значений полей объекта. Т.е. два объекта ссылочного типа, имеющих совершенно одинаковые значения всех полей, дадут разные значения хэша. Если бы это были экземпляры value-типа, их хэш был бы идентичен.

ПРИМЕЧАНИЕ

Для string'а метод GetHashCode переопределен так же, как Equals и операторы сравнения! string.GetHashCode вычисляет хэш, на основе содержимого строки.

Интересно посмотреть, как генерируются GetHashCode для объектов ссылочных типов. Проведите эксперимент. Думаю, вы будете удивлены, когда получите в качестве хэша последовательные целые числа. Как будто хэш ссылочного типа – это номер созданного объекта по порядку. А почему и нет? Обычно хэш №1 получает объект – приложение. Он чаще всего создаётся в heap’е первым.

Кстати, это очень неплохой хэш! Ведь хэши у двух объектов совпадут только, еcли порядковые номера их создания отличаются на 4294967296. Согласитесь, что случай, когда объект пережил 4294967296 своих собратьев – довольно редок…

Присваивание, копирование

С присваиванием и копированием дела обстоят аналогично сравнению.

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

Для value-типов копирование означает выделение памяти (в стеке) для нового объекта и полное двоичное копирование в него всех полей объекта-источника. Этот процесс носит название клонирования объекта. Двоичное копирование полей происходит достаточно эффективно – для копирования объектов в стеке есть специальная команда языка IL.

MemberwiseClone

Можно научить клонироваться и объект ссылочного типа. Для этого достаточно реализовать интерфейс IСloneable (состоящий из единственного метода Clone). Можно использовать стандартную реализацию копирования, предоставляемую методом System.Object.MemberwiseClone(). Это protected-метод, копирующий содержимое всех полей объекта. Достаточно просто вызвать MemberwiseClone из метода Clone.

class AClass : ICloneable
{
  …
  public object Clone()
  {
    return MemberwiseClone();
  }
  …
}

В отношении реализации метода MemberwiseClone(), можно было бы предположить что этот универсальный метод, как и метод Equals, реализован с помощью механизма отражения. Тем более что у Рихтера [1] написано: “MemberwiseClone последовательно просматривает все поля и копирует их содержимое в новый объект”. Но заметьте, что Джеффри с присущей ему точностью не упомянул в этой фразе про отражение. И это неспроста.

СОВЕТ

Думаю, что некоторые из вас, дочитав до этого места, открыли Ildasm.exe и отыскали System.Object.MemberwiseClone(). Но посмотреть ассемблерный код этого метода вам не удастся. Не найдёте вы кода этого метода и в CLI. Остаётся экспериментировать…

Если сравнить скорость копирования ссылочного типа с помощью MemberwiseClone со скоростью работы метода, копирующего содержимое объекта вручную, обнаружится, что их скорости примерно одинаковы. Столь высокая скорость работы универсального метода клонирования MemberwiseClone была бы не возможна при реализации через отражение. Значит, в арсенале разработчиков библиотеки классов .Net есть средства посильнее отражения. По всей видимости, этот метод реализован на уровне самой среды исполнения CLR.

Ну что ж. Тем лучше. Значит, нам не имеет особого смысла писать код копирования для ссылочных типов самим – MemberwiseClone прекрасно справляется с этой задачей. Иначе ведёт себя MemberwiseClone с value-типами.

Напомню, что для переменных value-типов присваивание означает клонирование. Поэтому, если вам понадобится реализовать IСloneable, сделать это можно совсем просто:

class AValue : ICloneable
{
  …
  public object Clone()
  {
    return this; 
  }
  …
}

Ничто не мешает использовать для клонирования value-типов метод MemberwiseClone. Однако эксперименты показывают, что MemberwiseClone заметно проигрывает «естественному» копированию value-типов. Потому его использование с value–типами нецелесообразно.

ПРИМЕЧАНИЕ

Здесь вёлся разговор только об одной разновидности клонирования. А именно о поверхностном клонировании (shallow clone). При поверхностном клонировании поля-ссылки клона продолжают указывать на те же объекты, на которые указывали поля-ссылки оригинала (как это обычно происходит при копировании ссылок операцией присваивания).

При глубоком копировании (deep copy) для каждого поля-ссылки клона создаётся копия (клон) того объекта, на который указывает поле–ссылка оригинала (т.е. для полей-ссылок вызывается метод Clone()). Если объект, на который указывает поле–ссылка, тоже поддерживает глубокое клонирование, то волна глубокого клонирования распространяется по графу ссылок приложения… Но не будем сильно углубляться в глубокое клонирование – это может увести нас от основной темы.

Параметры методов

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

Это означает, что в случае ссылочного типа копируется сама ссылка (указатель), а при передаче типа-значения копируются все его поля. Если структура состоит из одного-двух полей небольшого размера, передача ее в качестве параметра происходит даже быстрее, чем передача ссылки на объект, так как отсутствуют дополнительные операторы взятия и разрешения ссылки. Но при больших размерах структуры эффективность ее передачи в качестве параметра резко снижается. Увеличить производительность передачи структуры можно, объявив параметр как ссылку (с ключевым словом ref в C#). Но при этом исходное содержимое структуры может быть изменено внутри вызываемого метода. Поскольку C# не позволяет создавать константных ссылок, при применении этой хитрости надо соблюдать осторожность.

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

А вот метод, получивший параметр типа "значение", работает с копией переданного ему объекта и никак не может изменить тот объект, что виден вызывающему коду.

A a  = new A();
DoAnything(a);  // может ли объект а изменить своё состояние в результате 
                // вызова DoAnything зависит от того к какой категории типов
                // относится тип A. Если тип A ссылочный – то может, 
                // если A это value-тип, то а измениться не может.

Если процедура должна изменять значение value-типа, необходимо или возвращать это значение в качестве возвращаемого значения функции, или объявить параметр с ключевым словом ref (C#).

Особый случай – string

String, являясь ссылочным типом, во многих случаях ведет себя очень похоже на value-типы. Дело в том, что это так называемый неизменяемый (immutable) тип, для которого специально реализованы методы/операторы сравнения и вычисления хэша. Что значит неизменяемый, спросите вы? Это значит, что объект, на который указывает ссылка типа string, не может быть изменён.

– Как это понимать? – спросите вы, – ведь я столько раз изменял содержимое строк, например с помощью операции +=.

Это иллюзия. Операция += не изменяет содержимое строки, к которой она применяется. Эта операция сохраняет старую строку неизменной, возвращая результат своей работы в виде ссылки на новую строку. Именно эту ссылку вы воспринимаете как изменённую строку, а на самом деле это уже другая строка.

С этим связана одна засада при передаче строк как параметров методов. Так как string – это ссылочный тип, то можно ожидать, что, передав строку в метод и изменив её там, мы сможем наблюдать результат изменения строки в вызывающем коде. Но это не так. Вот пример:


Рисунок 1. Строка как параметр.

  1. Ссылка s указывает на строку “a”.
  2. При передаче параметра ссылка s копируется, после чего параметр p указывает туда же, куда и s, т.е. на “a”.
  3. При выполнении операции += создаётся новая строка “ab”, на которую теперь указывает параметр p.
  4. Происходит возврат из метода. Но значение ссылки s осталась неизменным, она по-прежнему указывает на “a”. Параметр p вышел из области видимости, строка “ab” стала недоступной, и потому в скором времени она станет добычей сборщика мусора.

Таким образом, видно, что строки при передаче параметров работают, как будто они передаются по значению. Если вам нужно, чтобы строки работали в качестве параметров, как остальные ссылочные типы, объявляйте соответствующие параметры с ключевым словом ref (C#). Можно также просто возвращать строку в качестве возвращаемого значения функции.

* * *

Скажу заодно про ещё один сюрприз, связанный с типом string. Доступ из разных потоков к данным этого типа не требует синхронизации! Действительно, если строка никогда(!) не изменяется, зачем синхронизация?

Boxing/unboxing

Выше уже говорилось, что компилятор C# «считает» базовым классом для типов-значений тип object. Да, именно «считает». Дело в том, что настоящего базового класса у value-типов нет.

Это сильное утверждение надо пояснить. Что такое базовый класс? Как отражается наследие на классе-наследнике? Ответ прост. В объекте-наследнике можно найти ту его часть, которую он получил в наследство от родителя. Это могут быть поля в составе объекта, которые не определены в самом классе (а унаследованы), это могут быть виртуальные методы (записи в таблице виртуальных методов), которые в типе не определены (они унаследованы). Посмотрим теперь на структуру value-типов. Ничего такого в составе экземпляров value-типов нет. В их внутренней структуре нет ни одного унаследованного от object поля, нет ни одной унаследованной виртуальной функции. Более того, даже указателя на таблицу виртуальных функций экземпляры value-типов не содержат! Короче, по своей внутренней структуре value-типы не похожи на наследников object.

Но, используя value-типы в C# (или другом CLR-совместимом высокоуровневом языке), мы получим такую же функциональность, как и у объектов, унаследованных от типа object. Если всё это обеспечено, то чем это отличается от «настоящего» наследования? Но, в конце концов, способ реализации – дело десятое. (Нам ведь надо ехать, а не шашечки!).

Разберемся, каким необычным способом CLR обеспечивает реализацию наследования от object для value-типов. Если value-типу требуется функциональность его родительского класса, или если он должен предоставлять такую функциональность своим клиентам, CLR изменяет его внутреннюю структуру так, что он становится полноправным ссылочным типом (с нормальным родителем, с виртуальными функциями и т. д.). Этот чудо-процесс превращения value-типа в ссылочный тип называется boxing.

ПРИМЕЧАНИЕ

Ходят слухи, что в проектировании механизма boxing’а не последнюю роль играл знаменитый Don Box, в честь которого он и был назван, но точных данных по этому вопросу не имеется. :)

Приведём хрестоматийный пример boxing’а:

int    n   = 12345;
object obj = n;        // boxing
int    n2  = (int)obj; // unboxing

По смыслу, boxing – это операция приведения к базовому типу (к object’у). По смыслу, но не по механизму. А механизм этот таков. Сначала в heap’е создаётся специальный объект ссылочного типа (box) в который копируется содержимое исходного объекта value-типа. Заметьте, что старый объект value-типа никуда не делся, им по-прежнему можно пользоваться. T.e. процесс boxing’а – это клонирование с преобразованием в ссылочный тип.

ПРИМЕЧАНИЕ

Вся процедура boxing’а соответствует одной команде box языка IL. Отсюда следует, что невозможно разделить две стадии boxing’а – размещение нового объекта в heap’e и копирование в него значения.

Ну вот, что же мы получили в результате boxing’а? Теперь преобразованный value-тип обменял достоинства и недостатки типов-значений, на достоинства и недостатки ссылочных типов. Вопрос: вы для этого объявляли тип как тип-значение, чтобы потом отказаться от всех достоинств типов-значений? Думаю, нет. Потому процесс boxing’а – это всегда вынужденная для вас мера, которой по возможности надо избегать.

Асимметрия boxing/unboxing

Процедура, обратная boxing’у (получение значения из запакованного значения), носит название unboxing. Существенно то, что эта процедура не является противоположностью boxing’у. Дело в том, что для получения значения, лежащего внутри упаковки, в IL совершенно не нужно ни создавать новый объект, ни копировать в него значение. Достаточно получить адрес данных, лежащих внутри упаковки, и использовать этот адрес в операциях с этим значением.

Так вот, в IL unboxing – это и есть операция получения адреса значения внутри упаковки, то есть это разновидность операции приведения. Unboxing не требует ни выделения памяти (хоть бы и стековой), ни копирования. Только небольшое упражнение по адресной арифметике. Поэтому эффективность unboxing’а на порядок выше, чем boxing’а. Ещё короче, boxing = new instance + копирование, а unboxing = приведение (вычисление адреса).

Тут есть одна неприятность. Далеко не все языки программирования в .Net могут воспользоваться в полной мере быстротой операции unbox. Они не умеют получать прямой доступ к содержимому boxed-объекта. Поэтому компилятор каждый раз после операции unbox вставляет код копирования значения в стековую переменную. Так обстоит дело в C# и VB.Net. Для этих языков unboxing = приведение + копирование.

А вот в MC++ … Но об этом чуть позже.

Чтобы окончательно прояснить ситуацию, ниже приведён код на C++, который представляет в явном виде те операции, которые происходят при boxing/unboxing. Видно, что операция boxing на C++-манер сводится к выделению памяти и срабатыванию конструктора. А операция извлечения значения (unboxing) – это операция приведения к типу значения.

template <typename Value>
class Box
{
public:
  Box(const Value& val)
  {
    this->val = val;
  }

  operator Value&() 
  { 
    return val; 
  }

private:
  Value val;
};

int _tmain(void)
{
  int   n   = 12345;
  void* pObj= new Box<int>(n);  // boxing 
  int&  pn  = *(Box<int>*)pObj; // pure unboxing
  int   n2  = *(Box<int>*)pObj; // unboxing + copy
  return 0;
}

Теперь рассмотрим типичные ситуации, в которых возникает boxing, и способы борьбы с ним.

Приведение к object

Тут всё просто. Если какой либо метод принимает параметр типа object, а вы ему даёте свой value-тип, то компилятор, заметив такое положение вещей, вставит код boxing’а. Например:

AValue a = new AValue();
Console.WriteLine(a); // а будет упакован, вызывается WriteLine(object value)

Дело в том, что среди перегруженных методов WriteLine() нет метода, принимающего тип AValue. Поэтому компилятор решает применить WriteLine(object value), а «приводя» Avalue к object, делает boxing.

– Что же делать, как избежать boxing’а? – спросите вы.

Это зависит от ситуации.

Ситуация первая. Вызываемый объект ваш (он доступен вам для изменений).

Тогда подумайте, не стоит ли сделать ещё один вариант метода, принимающий не абстрактный object, а ваш конкретный тип. Сделайте специализированный метод, именно для вашего value-типа. Тогда при его вызове boxing’а не будет.

Например, если вы разработчик класса Console, добавьте ещё один метод WriteLine.

Class Console
{
  ...
  // Console.WriteLine(a) - без boxing’а
  public static void WriteLine(AValue a)
  {
    // нет boxing’а, используется WriteLine(int)
    Console.WriteLine(a.n); 
  }
  ...
}

Ситуация вторая. Тип-параметр ваш.

Поменяйте ролями вызывающий и вызываемый методы.

struct AValue
{
  public void WriteToConsole()
  {
    Console.WriteLine(this.n); // нет boxing’а, используется WriteLine(int)
  }
  public int n;
}

static void Main(string[] args)
{
  AValue a = new AValue();
  a.WriteToConsole(); // a выводит себя сам.
}

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

Ситуация третья. Вам принадлежит только вызывающий код. Типы, которыми вы оперируете, недоступны для модификации. Ситуация наихудшая. Возможно, вам придётся смириться с boxing’ом. Но все-таки посмотрите повнимательнее, может можно что-то сделать?

Например, выводим на консоль тип Point.

static void Main(string[] args)
{
  Point pt = new Point();
  Console.WriteLine(pt);                     // c boxing’ом 
  Console.WriteLine("{0}, {1}", pt.X, pt.Y); // нет boxing’а
}

Две последние строчки делают одно и тоже, но вторая намного эффективнее.

Если требуется приведение к строке, разумно просто переопределить базовый метод ToString.

Вызов унаследованного виртуального метода

Я уже рассказывал, что у value-типов нет таблицы виртуальных функций. Как же тогда они вызываются? Ответ прост – boxing. Да-да. Каждый вызов унаследованной виртуальной функции приводит к boxing’ у! О, ужас!

Но почему только унаследованной? А если value-тип переопределил какую виртуальную функцию? Тогда нет. Тогда boxing не нужен. Компилятор достаточно сообразителен, чтобы для value-типов заменить виртуальные вызовы на обыкновенные.

ПРИМЕЧАНИЕ

Почему-то в отношении завершённых (sealed) ссылочных типов компилятор C# не проявляет подобной сообразительности. Вызовы виртуальных функций завершённых классов он не заменяет обычными вызовами. Может, оставляет заботу об оптимизации этих вызовов JIT-компилятору?

Но ведь, если глубоко задуматься, то речь сейчас ведётся всего только о трёх виртуальных функциях object’а – Equals(), GetHashCode() и ToString(). В связи с этим такой совет: даже если вас вполне устраивает поведение этих функций, реализованное в типе object, всё равно переопределите эти функции, реализовав их самостоятельно. Одним источником boxing’а в ваших программах будет меньше.

struct AValue
{
  public override string ToString()
  {
    return Convert.ToString(n);
  }
  public int n;
}

static void Main(string[] args)
{
  AValue a = new AValue();
  string s = a.ToString();
  Console.ReadLine();
}

IL-код для вызова унаследованного ToString():

//000041:   string s = a.ToString();
  IL_0008:  ldloc.0
  IL_0009:  box        Values.Class1/AValue
  IL_000e:  callvirt   instance string [mscorlib]System.ValueType::ToString()
  IL_0013:  stloc.1

IL-код для вызова переопределённого ToString:

//000041:   string s = a.ToString();
  IL_0008:  ldloca.s   a
  IL_000a:  call       instance string Values.Class1/AValue::ToString()
  IL_000f:  stloc.1

Ни в коем случае не пытайтесь просто перенаправить вызов базовому типу. В этом случае вы получите тот же boxing и виртуальный вызов, красиво завернутые в свою реализацию метода.

Приведение к интерфейсу

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

Рассмотрим следующий пример:

interface Iface
{
  void Inc();
}

struct AValue : Iface
{
  public void Inc() { this.n++; }
  public int n;
}

class Class1
{
  static void Inc(Iface ia)
  {
    ia.Inc(); // вызов виртуального метода
  }

  static void Main(string[] args)
  {
    AValue a = new AValue();
    a.Inc(); // не виртуальный вызов
    Inc(a);  // boxing !
  }
}

Имеется метод, принимающий параметр–интерфейс. Объекту value-типа ничего не остаётся, как забокситься, чтобы предоставить нужный интерфейс.

И, наконец, совсем экзотический случай. Такая неприятность – надо реализовать два интерфейса, имеющие одноимённые методы. Ну что ж, придётся применить «явную реализацию интерфейса»:

interface Iface2
{
  void Inc();
}

struct AValue : Iface, Iface2
{
  void Iface.Inc()  { this.n++; }    // явная реализация интерфейса Iface
  void Iface2.Inc() { this.n += 2; } // явная реализация интерфейса Iface2
  public int n;
}

class Class1
{
  static void Main(string[] args)
  {
    AValue c = new AValue();
    ((Iface)c).Inc();   // boxing +  виртуальный вызов
    ((Iface2)c).Inc();  // и здесь тоже.
  }
}

using & boxing

Есть ещё один важный момент, связанный с применением оператора using. Дело в том, что в заголовок оператора using может содержать не только определение новой переменной. Там можно указывать определённую ранее переменную.

struct AValue : IDisposable
{
  public void Dispose()
  {
    Console.WriteLine(n);
  }
  public int n;
}

class Class1
{
  static void Main(string[] args)
  {
    AValue a;
    a.n = 5;
    using (a) // IDisposable d = __box(a);
    {
      a.n = 6;
    }  // d.Dispose()
    Console.ReadLine();
  }
}

Обратите внимание – переменная a объявлена вне using’a. При входе в using компилятор должен получить ссылку на интерфейс IDisposable. Для этого значение a он копирует в специально созданную упаковку (box). Интерфейс IDisposable этого упакованного значения будет вызван при выходе из using’a. Интерфейс именно упакованного значения, а не оригинального! В приведённом примере будет выведено число 5, а не 6 как может показаться на первый взгляд.

Если переменная объявлена в заголовке using’a, этих проблем нет. Именно поэтому мы до сих пор так смело использовали using с value-типами.

Если у вас нет возможности применять форму using’a с определением value-переменной в заголовке, то наверное от использования using’a надо отказаться и применить конструкцию try/finally:

  static void Main(string[] args)
  {
    AValue a;
    a.n = 5;
    try
    {
      a.n = 6;
    } 
    finally
    {
      a.Dispose(); 
    }
    Console.ReadLine();
  }

В данном случае сюрпризов не будет. Будет выведено 6. Кроме того вы получите ещё небольшой бонус за хлопоты по написанию try/finally: Dispose в этом случае будет вызываться не как виртуальный метод, а как простой.

***

Короче, вы видите, что при работе с value-типами boxing подстерегает нас на каждом шагу. А значит, бдительность и ещё раз бдительность! :) Если серьёзно, не надо думать, что boxing – это абсолютное зло. В работе программиста довольно часто встречаются ситуации, когда во главу угла ставится простота и ясность алгоритмов, когда сроки разработки программы играют решающую роль, а забота о лёгкой модифицируемости и простоте сопровождения программы превалируют над другими соображениями (над эффективностью программы в частности). Поэтому, как и во всяком деле, в борьбе с boxing нас не должно покидать чувство меры.

Массивы

Развеем теперь возможные иллюзии, которые могут быть связаны с массивами. Помните, я просил вас не заблуждаться на счёт слова new при размещении объекта value-типа. Несмотря на это слово, объект value-типа всё равно размещался в стеке, а не в heap’е. Может показаться, что такая же ситуация повторяется при размещении массивов значений. Но нет. Массивы значений в стеке расположить нельзя. Нет такой возможности в CLR. Намёк: не было сказано, что нет такой возможности в .Net. Но об этом позже…

Массивы – это объекты ссылочного типа, неявно унаследованные от типа System.Array. Они всегда размещаются в управляемом heap’е. Всегда. Способ размещения массивов не зависит от того, какого типа элементы содержит массив.

Массивы ссылок

Но от типа элементов существенно зависит внутреннее устройство массивов. Массивы ссылочных типов устроены, как массивы ссылок. Сами объекты, ссылки на которые содержит массив, могут быть размещены в любом месте управляемого heap’а.


Рисунок 2. Массив ссылок.

Массивы значений

Value-типы размещаются непосредственно в теле массива один за другим. Массив значений состоит из собственно тела массива и небольшой структуры данных, расположенной в начале, и обеспечивающей ему полноправное существование, как наследнику System.Array и System.Object.


Рисунок 3. Массив значений.

ПРИМЕЧАНИЕ

В MC++ адрес тела массива можно получить как &arrValues[0]. При этом с полученным указателем можно работать по всем правилам адресной арифметики.

Такая структура массивов значений приводит к существенно большей эффективности доступа к данным (не медленнее, чем в программе на C) и позволяет гораздо эффективнее осуществлять выделение и освобождение памяти, ведь для GC массив значений – это один, единый блок памяти!

Разное

Синхронизация

Помните, почему я настаивал на том, что System.Object – это «не настоящий» базовый класс для value-типов? В структуре value-типов нет таблицы виртуальных методов. Но это не единственная недостача в структуре value-типов.

Каждый настоящий объект в .Net (объект ссылочного типа) имеет два скрытых поля: указатель на таблицу виртуальных методов (vtbl) и индекс в глобальной таблице синхронизирующих объектов (SyncBlockIndex). Через этот индекс и соответствующий синхронизирующий объект (критическую секцию) объект легко может синхронизовать доступ из различных потоков к различным участкам своего кода с помощью методов Enter/Exit класса Monitor. Для большего удобства использования класса Monitor в языке C# предусмотрена конструкция lock.

class AClass
{
  string SafeProp // Защищённое свойство
  { 
    get 
    { 
      lock (this) 
      { 
        return s;  
      }           
    }
    set 
    { 
      lock (this) // Monitor.Enter(x); 
                  // try 
      {           // {
        s = value; 
      }           // } 
                  // finally 
                  // { 
                  //    Monitor.Exit(x); 
    }             // }
  }
  private string s;
}

A вот value-типы обделены этой возможностью.

struct AValue
{
  string GetNotSafe()
  { 
    lock (this) // error CS0185: 'Values.AValue' is not a reference type 
                // as required by the lock statement
    { 
      return s;  
    }
  }
  private string s;
}

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

Если объект value-типа создаётся в составе другого объекта или массива, то синхронизацию доступа можно организовать через этот объект.

Размещение полей

Вообще говоря .Net не гарантирует точного соответствия порядка следования полей в определении класса и в памяти. CLR старается оптимизировать как расход памяти, так и скорость доступа к членам класса. Эта хитрость CLR называется размещением полей (layout). У нас есть возможность управлять размещением с помощью атрибута StructLayoutAttribute.

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

Для типов-величин упаковка по умолчанию другая – Sequential, с выравниванием на 8 байт.

Посмотрите на список value-типов, определённых в библиотеке классов .Net. Вы увидите, что value-типы очень активно используются в операциях по взаимодействию со старым, неуправляемым кодом. А в этом деле точное двоичное соответствие структур, определённых в .Net и в Win32 API, имеет первостепенную важность. Поэтому по умолчанию удобнее использовать именно Sequential.

MC++ и типы-значения

До сих пор я не утомлял вас подробностями того, как решаются проблемы ссылочных и value-типов в других языках среды .Net. Но есть один язык, обойти который вниманием в контексте сегодняшней темы нельзя. Это Managed Extentions for С++ (MC++).

Для обозначения того, к какой категории относится тип, в MC++ применяются специальные ключевые слова __value и __gc, смысл которых пояснять, надеюсь, не нужно.

__value class AValue 
{
public:
  int   n;
  String* s;
};

__gc class AClass
{
public:
  int     n;
  String* s;
};

Но это не главное. Главное в другом. Посмотрите, как описано строковое поле в составе класса. Видите звёздочку? Это значит, что переменные объектов ссылочных типов в MC++ имеют тип указателей. То, что в C# тщательно скрывается от программиста (из лучших побуждений, конечно), то в MC++ явно видно. Если это указатель, то переменная ссылочная, а если это сам объект, то, очевидно, он имеет тип-значение.

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

AValue a;
a.n = 5;

AClass* pA = new AClass();
pA->n = 5;

Объект ссылочного типа выделяется в памяти через new. А value-тип – как обыкновенная автоматическая переменная языка C.

Короче, в MC++ все различия между ссылочными и value-типами не маскируются, а ясно видны, и контролировать то, что происходит в программе, легче. (зато писать тяжелее. – прим.ред.)

Явный boxing/unboxing

Никаких закулисных махинаций с boxing/unboxing MC++ себе не позволяет. Каждый раз, когда value-типу требуется преобразование в ссылочный тип, необходимо явно сделать это преобразование с помощью операции __box(). Компилятор честно подскажет те места, где он хотел бы видеть boxing. Остаётся только "дать добро" на boxing, или покумекать и как-нибудь обойтись без boxing'а (что предпочтительнее).

Доступ внутрь box’а

В примере, приведённом ниже, при добавлении объекта value-типа в ArrayList производится явный boxing. Но самое интересное в строчках, следующих за boxing'ом. Там только что положенное в ArrayList значение изменяется без копирования объекта во временную переменную, изменение значения производится прямо внутри box’а!

__value class AValue
{
public: int n;
};

int _tmain(void)
{
    ArrayList* pArr = new ArrayList();
    AValue a;
    pArr->Add(__box(a)); 

    AValue* pa = dynamic_cast<AValue*>(pArr->get_Item(0)); // распаковка
    pa->n += 5; // изменяем объект прямо в box’e
    return 0;
}

Для сравнения – то же самое на C#.

struct AValue
{
  public int n;
}

class Class1
{
  static void Main(string[] args)
  {
    ArrayList arr = new ArrayList();
    AValue a = new AValue();
    arr.Add(a); // boxing

    AValue a2 = (AValue)arr[0]; // unboxing и копирование
    a2.n += 5;
    arr[0] = a2; // опять boxing, опять копирование…   
  }
}

Посмотрите, насколько больше операций приходится выполнять программе на C# (на два копирование и на один boxing больше)!

Вот фрагмент IL кода, сгенерированный компилятором MC++.

//000036:     AValue* pa = dynamic_cast<AValue*>(pArr->get_Item(0));
IL_002b:  ldloc.0
IL_002c:  ldc.i4.0
IL_002d:  callvirt   instance 
    object [mscorlib]System.Collections.ArrayList::get_Item(int32)
IL_0032:  unbox      AValue
IL_0037:  stloc.1
//000037:     pa->n = 6;
IL_0038:  ldloc.1
IL_0039:  ldc.i4.6
IL_003a:  stfld      int32 AValue::n

Изящно? А вот чудовищный IL-код, сгенерированный компилятором C# для решения той же задачи:

//000032:       AValue a2 = (AValue)arr[0]; // распаковка и копирование
IL_001b:  ldloc.0
IL_001c:  ldc.i4.0
IL_001d:  callvirt   instance 
    object [mscorlib]System.Collections.ArrayList::get_Item(int32)
IL_0022:  unbox      Values.AValue
IL_0027:  ldobj      Values.AValue
IL_002c:  stloc.2
//000033:       a2.n += 5;
IL_002d:  ldloca.s   a2
IL_002f:  dup
IL_0030:  ldfld      int32 Values.AValue::n
IL_0035:  ldc.i4.5
IL_0036:  add
IL_0037:  stfld      int32 Values.AValue::n
//000034:       arr[0] = a2; // опять упаковка .
IL_003c:  ldloc.0
IL_003d:  ldc.i4.0
IL_003e:  ldloc.2
IL_003f:  box        Values.AValue
IL_0044:  callvirt   instance 
    void [mscorlib]System.Collections.ArrayList::set_Item(int32, object)

Не будем сильно сокрушаться о возможностях C#. Тем более что в этом языке всё-таки есть способ обратиться внутрь box’а. Он связан с использованием интерфейсов.

Доступ внутрь box’а через интерфейс

interface Iface
{
    void Inc(int m);
}

struct AValue : Iface
{
  public void Inc(int m)  { this.n += m; }
  public int n;
}

class Class1
{
  static void Main(string[] args)
  {
    ArrayList arr = new ArrayList();
    AValue a = new AValue();
    arr.Add(a);               // boxing

    Iface ia = (Iface)arr[0]; // castclass
    ia.Inc(5);
  }
}

Посмотрим на ассемблерный код. Вместо unbox мы видим команду castclass, и конечно, виртуальный вызов метода интерфейса. Но главное – никакого boxing’а!

//000027:     Iface ia = (Iface)arr[0]; // unboxing и копирование
  IL_001b:  ldloc.0
  IL_001c:  ldc.i4.0
  IL_001d:  callvirt   instance object [mscorlib]System.Collections.ArrayList::get_Item(int32)
  IL_0022:  castclass  Values.Iface
  IL_0027:  stloc.2
//000028:     ia.Inc(5);
  IL_0028:  ldloc.2
  IL_0029:  ldc.i4.5
  IL_002a:  callvirt   instance void Values.Iface::Inc(int32)

С доступом к значениям value-типов, размещенных в типизированных массивах, в C# проблем не возникает.

Массивы в стиле C

Я обещал рассказать, как сделать массив значений в стеке, а не в heap’e. Это совсем не сложно.

AValue arr __nogc [5];

Это обыкновенный массив в стиле языка C, созданный в стеке, безо всяких overhead’ов.

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

Пользоваться таким массивом можно только внутри кода на MC++. Передать такой массив в код на C# не удастся. Такой тип данных не CLS-совместим.

***

Как видите, язык MC++ имеет ряд уникальных свойств, делающих его очень полезным в ряде ситуаций. Судя по заявлениям MS, имеются планы довести уровень поддержки MC++ до уровня C# и VB.NET в дизайнерах WinForms и WebForms. В составе VS.NET версии 7.1, выходящей в начале 2003 года, должен появиться так называемый CodeDOM-провайдер, которого так не хватает в текущей версии. Это, несомненно, ещё более повысит привлекательность этого языка, широкое использование которого сдерживается сейчас недостаточной его поддержкой со стороны среды разработки Visual Studio.

Увы, MC++ можно использовать далеко не всегда, так как он принципиально порождает unsafe-код.

СОВЕТ

Подробнее о MC++ можно прочитать в статье Игоря Ткачева в нулевом (сигнальном) номере журнала RSDN Magazine.

Для более искушённых в программировании на MC++ будет интересна статья Максима Шеманарёва в этом номере журнала.

Итоги

Итак, мы выяснили, что value-типы надо рассматривать как облегчённые типы, как типы – «рабочие лошадки», эффективные, но ограниченные по своим возможностям. Для этих типов характерны:

  1. Быстрое выделение и освобождение памяти.
  2. Упрощённая инициализация
  3. Эффективный доступ к данным.

Но при этом не поддерживается ряд важных свойств:

  1. Нет наследования.
  2. Работа без виртуальных методов (без полиморфизма).
  3. Нет возможности задать конструктор по умолчанию.
  4. Нет деструкторов.
  5. Не поддерживается синхронизации доступа.

Value-типы обладают счастливой способностью при необходимости превращаться в ссылочные типы (boxing). Но этой способностью не стоит злоупотреблять, т.к. value-типы, преобразованные в ссылки, теряют свои преимущества.

Особенно эффективен и удобен при работе с value-типами язык MC++.

Таблица различий ссылочных типов и типов–значений

В заключении приведена таблица, в которую сведены все упомянутые выше особенности value-типов и их отличия от ссылочных типов.

Ссылочные типыТипы-значения
Определение сlass(C#), массивы__gc (MC++)Примитивы (без string), struct, enum(C#), __value (MC++)
НаследованиеБазовые классы: все, кроме ValueType, Enum и помеченных, как sealed.Производные классы: могут быть.Базовые классы: только ValueType или Enum.Производные классы: не бывает (sealed)
РазмещениеHeap процессаВ стеке потока, или внутри других объектов
УдалениеGarbage CollectionStack rollback
ИнициализацияСсылка может быть null.Автоматическая инициализация.Может быть определён конструктор по умолчанию.Инициализация полей производится перед вызовом конструктора.Не может быть null.Возможно создание неинициализированных объектов.Инициализация по умолчанию – 0 или null.Нельзя определить конструктор без аргументов (по умолчанию) (С#).Не применяются инициализаторы полей
Код очисткиДеструктор (С#),IDisposable, using, try/finallyНет деструктора.try/finally
По умолчанию Equals(), GetHashCode() и операторы сравнения используют:Адреса объектовСодержимое объектов
=(операция присваивания)Копируются адресаДвоичное копирование полей
Массивы хранятСсылки на объекты в хипеЗначения объектов
Вызов переопределённых виртуальных методовВсегда как виртуальные вызовыОбычные вызовы
Передача в качестве параметровПо ссылкеПо значению
СинхронизацияПоддерживаетсяне поддерживается
Размещение полей по умолчаниюAutoSequential, 8 byte pack

Желаю вам удачи в использовании типов-значений!

Литература

  1. Джеффри Рихтер. Программирование на платформе Microsoft .Net Framework. Русская редакция. 2002. Главы 5 и 6.
  2. Jeffrey Richter. .Net: Type Fundamentals. MSDN Magazine, 12, 2000
  3. Jeffrey Richter. Array Types in .Net. MSDN Magazine, 2, 2000
  4. Eric Gunnerson. Open the Box! Quick! MSDN Magazine, 3, 2001.
  5. Тэд Петтисон. Basic и .Net. Объекты и значения. MSDN Magazine. №4 2002.


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