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

3. Возвращается адрес объекта. Этот адрес является ссылкой на объект, то есть значимый тип превращается в ссылочный.

Компилятор C# создает IL-код, необходимый для упаковки экземпляра значимого типа, автоматически, но вы должны понимать, что происходит «за кулисами» и помнить об опасности «распухания» кода и снижения производительности.

В предыдущем примере компилятор C# обнаружил, что методу, требующему ссылочный тип, в параметре передается значимый тип, и автоматически создал код для упаковки объекта. Вследствие этого поля экземпляра р значимого типа Point в период выполнения копируются во вновь созданный в куче объект Point. Полученный адрес упакованного объекта Point (теперь это ссылочный тип) передается методу Add. Объект Point остается в куче до очередной уборки мусора. Переменную р значимого типа Point можно использовать повторно, так как ArrayList ничего о ней не знает. Заметьте: время жизни упакованного значимого типа превышает время жизни неупакованного значимого типа.

ПРИМЕЧАНИЕ

В состав FCL входит новое множество обобщенных классов коллекций, из-за которых необобщенные классы коллекций считаются устаревшими. Так, вместо класса System. Collections.ArrayList следует использовать класс System.Collections.Generic.List<T>. Обобщенные классы коллекций во многих отношениях совершеннее своих необобщенных аналогов. В частности, API-интерфейс стал яснее и совершеннее, кроме того, повышена производительность классов коллекций. Но одно из самых ценныхулучше- ний заключается в предоставляемой обобщенными классами коллекций возможности работать с коллекциями значимых типов, не прибегая кихупаковке/распаковке. Одна эта особенность позволяет значительно повысить производительность, так как радикально сокращается число создаваемых в управляемой куче объектов, что, в свою очередь, сокращает число проходов сборщика мусора в приложении. В результате обеспечивается безопасность типов на этапе компиляции, а код становится понятнее за счет сокращения числа приведений типов (см. главу 12).

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

Point р = (Point) а[0];

Здесь ссылка (или указатель), содержащаяся в элементе с номером 0 массива ArrayList, помещается в переменную р значимого типа Point. Для этого все поля, содержащиеся в упакованном объекте Point, надо скопировать в переменную р значимого типа, находящуюся в стеке потока. CLR выполняет эту процедуру в два этапа. Сначала извлекается адрес полей Point из упакованного объекта Point. Этот процесс называют распаковкой (unboxing). Затем значения полей копируются из кучи в экземпляр значимого типа, находящийся в стеке.

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

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

При распаковке упакованного значимого типа происходит следующее.

1. Если переменная, содержащая ссылку на упакованный значимый тип, равна null, генерируется исключение NullReferenceException.

2. Если ссылка указывает на объект, не являющийся упакованным значением требуемого значимого типа, генерируется исключение InvalidCastException[6].

Из второго пункта следует, что приведенный ниже код не работает так, как хотелось бы:

public static void Main() {

Int32 x = 5;

Object о = x; 11 Упаковка x; о указывает на упакованный объект

Intl6 у = (Intl6) о; // Генерируется InvalidCastException

}

Казалось бы, можно взять упакованный экземпляр Int32, на который указывает о, и привести к типу Intl6. Однако при распаковке объекта должно быть выполнено приведение к неупакованному типу (в нашем случае — к Int32). Вот как выглядит правильный вариант:

public static void Main() {

Int32 x = 5;

Object о = xj // Упаковка x; о указывает на упакованный объект Intl6 у = (Intl6)(Int32) о; // Распаковка, а затем приведение типа

}

Как я уже отмечал, распаковка часто сопровождается копированием полей. Следующий код на C# демонстрирует, что операции распаковки и копирования

часто работают совместно:

public static void Main() {

Point p; p.x = p.y = 1;

Object о = p; // Упаковка p; о указывает на упакованный объект р = (Point) о; // Распаковка о и копирование полей из экземпляра в стек

}

В последней строке компилятор C# генерирует IL-команду для распаковки о (получение адреса полей в упакованном экземпляре) и еще одну IL-команду для копирования полей из кучи в переменную р, располагающуюся в стеке. Теперь посмотрите на следующий пример:

public static void Main() {

Point p; p.x = p.y = 1;

Object о = p; 11 Упаковка p; о указывает на упакованный экземпляр

// Изменение поля х структуры Point (присвоение числа 2). р = (Point) о; // Распаковка о и копирование полей из экземпляра // в переменную в стеке

р.х = 2; // Изменение состояния переменной в стеке о = р; // Упаковка р; о ссылается на новый упакованный экземпляр

}

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

В некоторых языках, например в C++/CLI, разрешается распаковать упакованный значимый тип, не копируя поля. Распаковка возвращает адрес неупакованной части упакованного объекта (дополнительные члены — указатель на типовой объект и индекс блока синхронизации — игнорируются). Затем, используя полученный указатель, можно манипулировать полями неупакованного экземпляра (который находится в упакованном объекте в куче). Например, реализация приведенного выше кода на C++/CLI существенно повысит его производительность, потому что вы можете изменить значение поля х структуры Point в уже упакованном экземпляре Point. Это позволит избежать как выделения памяти для нового объекта, так и повторного копирования всех полей!

ВНИМАНИЕ

Если вы хотя бы в малейшей степени заботитесь о производительности своего приложения, вам необходимо знать, когда компилятор создает код, выполняющий эти операции. К сожалению, многие компиляторы неявно генерируют код упаковки, поэтому иногда бывает сложно узнать о происходящей упаковке. Если меня действительно волнует производительность приложения, я прибегаю к такому инструменту, как ILDasm.exe, просматриваю IL-код готовых методов и смотрю, присутствуют ли в нем команды упаковки.

Рассмотрим еще несколько примеров, демонстрирующих упаковку и распаковку:

public static void Main() {

Int32 v = 5; // Создание неупакованной переменной значимого типа о

Object о = у; // указывает на упакованное Int32, содержащее 5 v = 123; // Изменяем неупакованное значение на 123

Console.WriteLine(v + ", " + (Int32) о); // Отображается "123, 5"

}

Сколько в этом коде операций упаковки и распаковки? Вы не поверите целых три! Разобраться в том, что здесь происходит, нам поможет IL-код метода Main. Чтобы быстрее найти отдельные операции, я снабдил распечатку комментариями.

.method public hidebysig static void Main() ell managed

{

.entrypolnt

II Размер кода 45 (0x2d)

.maxstack 3

.locals init ([0]int32 v,

[1] object o)

// Загружаем 5 в v.

IL0000: ldc.14.5

IL0001: stloc.0

// Упакуем v и сохраняем указатель в о.

IL 0002: ldloc.0

IL_000 3: box [mscorlibJSystem.Int32

IL_0008: stloc.l

// Загружаем 123 в v.

IL_0009: ldc.14.s 123

IL_000b: stloc.0

// Упакуем v и сохраняем в стеке указатель для Concat IL_000c: ldloc.0

IL_000d: box [mscorlibJSystem.Int32

// Загружаем строку в стек для Concat IL0012: ldstr ", "

// Распакуем о: берем указатель в поле Int32 в стеке IL0017: ldloc.l

IL0018: unbox.any [mscorlibJSystem.Int32

// Упакуем Int32 и сохраняем в стеке указатель для Concat IL_001d: box [mscorlibJSystem.Int32

// Вызываем Concat

IL0022: call string [mscorlibJSystem.String::Concat(object,

object,

object)

// Строку, возвращенную из Concat, передаем в WriteLine

IL_0027: call void [mscorlibJSystem.Console::WrlteLlne(strlng)

// Метод Main возвращает управление, и приложение завершается IL_002c: ret

} // Конец метода App::Main

Вначале в стеке создается экземпляр v неупакованного значимого типа Int32, которому присваивается число 5. Затем создается переменная о типа Object, которая инициализируется указателем на v. Однако поскольку ссылочные типы всегда должны указывать на объекты в куче, C# генерирует соответствующий IL-код для упаковки v и заносит адрес упакованной «копии» v в о. Теперь величина 123 помещается в неупакованный значимый тип V, но это не влияет на упакованное значение типа Int32, которое остается равным 5.

Дальше вызывается метод WriteLine, которому нужно передать объект String, но такого объекта нет. Вместо строкового объекта мы имеем неупакованный экземпляр значимого типа Int32 (v), объект String (ссылочного типа) и ссылку на упакованный экземпляр значимого типа Int32 (о), который приводится к неупакованному типу Int32. Эти элементы нужно как-то объединить, чтобы получился объект String.

Чтобы создать String, компилятор C# формирует код, в котором вызывается статический метод Concat объекта String. Есть несколько перегруженных версий этого метода, различающихся лишь количеством параметров. Поскольку строка формируется путем конкатенации трех элементов, компилятор выбирает следующую версию метода Concat:

public static String Concat(Object arg0, Object argl, Object arg2);

В качестве первого параметра, arg0, передается v. Но v — это неупакованное значение, a arg0 — это значение Object, поэтому экземпляр v нужно упаковать, а его адрес передать в качестве arg0. Параметром argl является строка "," в виде ссылки на объект String. И наконец, чтобы передать параметр arg2, о (ссылка на Object) приводится к типу Int32. Для этого нужна распаковка (но без копирования), при которой извлекается адрес неупакованного экземпляра Int32 внутри упакованного экземпляра Int32. Этот неупакованный экземпляр Int32 надо опять упаковать, а его адрес в памяти передать в качестве параметра arg2 методу Concat.

Метод Concat вызывает методы ToString для каждого указанного объекта и выполняет конкатенацию строковых представлений этих объектов. Возвращаемый из Concat объект String передается затем методу WriteLine, который отображает окончательный результат.

Полученный IL-код станет эффективнее, если обращение к WriteLine переписать:

Console.WriteLine(v + ", " + о); // Отображается "123, 5"

Этот вариант строки отличается от предыдущего только отсутствием для переменной о операции приведения типа (Int32). Этот код выполняется быстрее, так как о уже является ссылочным типом Ob j ect и его адрес можно сразу передать методу Concat. Отказавшись от приведения типа, я избавился от двух операций: распаковки и упаковки. В этом легко убедиться, если заново собрать приложение и посмотреть на сгенерированный IL-код:

.method public hidebysig static void Main() ell managed

{

.entrypolnt

// Размер кода 35 (0x23)

.maxstack 3

.locals init ([0] int32 v,

[1] object o)

// Загружаем 5 в v IL_0000: ldc.14.5

IL_0001: stloc.0

// Упакуем v и сохраняем указатель в о IL_0002: ldloc.0

IL_0003: box [mscorlib]System.Int32

IL_0008: stloc.l

// Загружаем 123 в v IL_0009: ldc.14.s 123 IL 000b: stloc.0

// Упакуем v и сохраняем в стеке указатель для Concat IL_000c: ldloc.0

IL_000d: box [mscorlibJSystem.Int32

// Загружаем строку в стек для Concat IL0012: ldstr ", "

// Загружаем в стек адрес упакованного Int32 для Concat IL0017: ldloc.l

// Вызываем Concat

IL0018: call string [mscorlibJSystem.String::Concat(object,

object,

object)

// Строку, возвращенную из Concat, передаем в WrlteLlne

IL_001d: call void [mscorlibJSystem.Console::WriteLine(string)

// Main возвращает управление, чем завершается работа приложения IL0022: ret

} // Конец метода Арр::Ма1п

Беглое сравнение двух версий IL-кода метода Main показывает, что вариант без приведения типа Int32 на 10 байт меньше, чем вариант с приведением типа. Дополнительные операции распаковки/упаковки, безусловно, приводят к разрастанию кода. Если мы пойдем дальше, то увидим, что эти операции потребуют выделения памяти в управляемой куче для дополнительного объекта, которую в будущем должен освободить уборщик мусора. Конечно, обе версии приводят к одному результату и разница в скорости незаметна, однако лишние операции упаковки, выполняемые многократно (например, в цикле), могут заметно повлиять на производительность приложения и расходование памяти.

Предыдущий код можно улучшить, изменив вызов метода Write Line:

Console.WriteLine(v.ToString() + ", " + о); // Отображается "123, 5"

Для неупакованного значимого типа v теперь вызывается метод ToString, возвращающий String. Строковые объекты являются ссылочными типами и могут легко передаваться в метод Concat без упаковки.

Вот еще один пример, демонстрирующий упаковку и распаковку:

public static void Main() {

Int32 v = 5; // Создаем неупакованную переменную значимого типа

Object о = v; 11 о указывает на упакованную версию v

v = 123; // Изменяет неупакованный значимый тип на 123

Console.WriteLine(v); // Отображает “123"

v = (Int32) о; // Распаковывает и копирует о в v

Console.WriteLine(v); // Отображает "5"

}

Сколько операций упаковки вы насчитали в этом коде? Правильно — одну. Дело в том, что в классе System. Con sole описан метод Write Line, принимающий в качестве параметра тип Int32:

public static void WriteLine(Int32 value);

В показанных ранее вызовах WriteLine переменная v, имеющая неупакованный значимый тип Int32, передается по значению. Возможно, где-то у себя WriteLine упакует это значение Int32, но тут уж ничего не поделаешь. Главное — мы сделали то, что от нас зависело: убрали упаковку из своего кода.

Пристально взглянув на FCL, можно заметить, что многие перегруженные методы используют в качестве параметров значимые типы. Так, тип System. Con sole предлагает несколько перегруженных вариантов метода WriteLine:

static void WriteLine(Boolean); static void WriteLine(Char); static void WriteLine(Char[]); static void WriteHne(Int32); static void WriteLine(UInt32); static void WriteLine(Int64); static void WriteLine(UInt64); static void WriteLine(Single); static void WriteLine(Double); static void WriteLine(Decimal); static void WriteLine(Object); static void WriteLine(String);

Аналогичный набор перегруженных версий есть у метода Write типа System. Console, у метода Write типа System. 10. BinaryWriter, у методов Write и WriteLine типа System. 10.TextWriter, у метода AddValue типа System.Runtime.Serialization. Serializationlnfо, у методов Append и Insert типа System.Text. StringBuilder и т. д. Большинство этих методов имеет перегруженные версии только затем, чтобы уменьшить количество операций упаковки для наиболее часто используемых значимых типов.

Если вы определите собственный значимый тип, у этих FCL-классов не будет соответствующей перегруженной версии для вашего типа. Более того, для ряда значимых типов, уже существующих в FCL, нет перегруженных версий указанных методов. Если вызывать метод, у которого нет перегруженной версии для передаваемого значимого типа, результат в конечном итоге будет один — вызов перегруженного метода, принимающего Object. Передача значимого типа как Object приведет к упаковке, что отрицательно скажется на производительности. Определяя собственный класс, можно задать в нем обобщенные методы (возможно, содержащие параметры типа, которые являются значимыми типами). Обобщения позволяют определить метод, принимающий любой значимый тип, не требуя при этом упаковки (см. главу 12).

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

using System;

public sealed class Program { public static void Main() {

Int32 v = 5; // Создаем переменную упакованного значимого типа #if INEFFICIENT

// При компиляции следующей строки v упакуется // три раза, расходуя и время, и память Console.WriteLine("{0}, {1}, {2}", v, v, v);

#else

// Следующие строки дают тот же результат,

//но выполняются намного быстрее и расходуют меньше памяти Object о = v; // Упакуем вручную v (только единожды)

// При компиляции следующей строки код упаковки не создается Console.WriteLine("{0}, {1}, {2}", о, о, о);

#endif

}

}

Если компилировать этот код с определенным символическим именем INEFFICIENT, компилятор создаст код, трижды выполняющий упаковку v и выделяющий память в куче для трех объектов! Это особенно расточительно, так как каждый объект будет содержать одно значение — 5. Если же компилировать код без определения символа INEFFICIENT, значение v будет упаковано только раз и только один объект будет размещен в куче. Затем при обращении к Console.WriteLine трижды передается ссылка на один и тот же упакованный объект. Второй вариант выполняется намного быстрее и расходует меньше памяти в куча

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

Помните, мы говорили, что неупакованные значимые типы «легче» ссылочных, поскольку:

□ память в управляемой куче им не выделяется;

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

Поскольку неупакованные значимые типы не имеют индекса блока синхронизации, то не может быть и нескольких потоков, синхронизирующих свой доступ к экземпляру через методы типа System. Threading. Monitor (или инструкция lock языка С#).

Хотя неупакованные значимые типы не имеют указателя на типовой объект, вы все равно можете вызывать виртуальные методы (такие, как Equals, GetHashCode или ToString), унаследованные или прееопределенные этим типом. Если ваш значимый тип переопределяет один из этих виртуальных методов, CLR может вызвать метод невиртуально, потому что значимые типы неявно запечатываются и поэтому не могут выступать базовыми классами других типов. Кроме того, экземпляр значимого типа, используемый для вызова виртуального метода, не упаковывается. Но если ваше переопределение виртуального метода вызывает реализацию этого метода из базового типа, экземпляр значимого типа упаковывается при вызове реализации базового типа, чтобы в указателе this базового метода передавалась ссылка на объект в куче.

Вместе с тем вызов невиртуального унаследованного метода (такого, как GetType или MembenwiseClone) всегда требует упаковки значимого типа, так как эти методы определены в System. Object, поэтому методы ожидают, что в аргументе this передается указатель на объект в куче.

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

using System;

internal struct Point : IComparable { private Int32 m_x, m_y;

// Конструктор, просто инициализирующий поля public Point(Int32 x, Int32 y) { m_x = x; m_y = y;

}

// Переопределяем метод ToString, унаследованный от System.ValueType public override String ToStringO {

// Возвращаем Point как строку (вызов ToString предотвращает упаковку) return String.Format("({0}, {1})", m_x. ToStringO, m_y.ToStringO);

}

// Безопасная в отношении типов реализация метода CompareTo public Int32 CompareTo(Point other) {

// Используем теорему Пифагора для определения точки,

// наиболее удаленной от начала координат (0, 0) return Math.Sign(Math.Sqrt(m_x * m_x + m_y * m_y)

- Math.Sqrt(other.m_x * other.m_x + other.m_y * other.m_y));

}

// Реализация метода CompareTo интерфейса IComparable public Int32 CompareTo(Object o) { if (GetType() != o.GetType()) {

throw new ArgumentException("о is not a Point");

>

// Вызов безопасного в отношении типов метода CompareTo return CompareTo((Point) о);

>

public static class Program { public static void Main() {

// Создаем в стеке два экземпляра Point Point pi = new Point(10, 10);

Point p2 = new Point(20, 20);

// pi HE пакуется для вызова ToString (виртуальный метод) Console.WriteLine(pl.ToString()); // "(10, 10)"

//pi ПАКУЕТСЯ для вызова GetType (невиртуальный метод)

Console.WriteLine(pl.GetType()); // "Point"

// pi HE пакуется для вызова CompareTo

// p2 HE пакуется, потому что вызван CompareTo(Point)

Console.WriteLine(pl.CompareTo(p2)); // "-1"

//pi ПАКУЕТСЯ, а ссылка размещается в с IComparable с = pi;

Console.WriteLine(c.GetTypeQ); // "Point"

// pi HE пакуется для вызова CompareTo

// Поскольку в CompareTo не передается переменная Point,

// вызывается CompareTo(Ob]ect), которому нужна ссылка // на упакованный Point

// с НЕ пакуется, потому что уже ссылается на упакованный Point Console.WriteLine(pl.CompareTo(c)); // "0"

// с HE пакуется, потому что уже ссылается на упакованный Point // р2 ПАКУЕТСЯ, потому что вызывается CompareTo(Ob]ect)

Console.WriteLine(c.CompareTo(p2));// "-1"

// с пакуется, а поля копируются в p2 p2 = (Point) c;

// Убеждаемся, что поля скопированы в р2 Console.WriteLine(p2.ToString());// "(10, 10)"

}

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

□ Вызов ToString. При вызове ToString упаковка pi не требуется. Казалось бы, тип pi должен быть упакован, так как ToString — метод, унаследованный от базового типа, System. ValueType. Обычно для вызова виртуального метода нужен указатель на типовой объект, а поскольку pi является неупакованным значимым типом, то нет ссылки на типовой объект Point. Однако JIT-компилятор видит, что метод ToString переопределен в Point, и создает код, который напрямую (невиртуально) вызывает ToString. Компилятор знает, что полиморфизм здесь невозможен, коль скоро Point является значимым типом, а значимые типы не могут применяться для другого типа в качестве базового и по-другому реализовывать виртуальный метод. Ели бы метод ToString из Point во внутренней реализации вызывал base.ToString(), то экземпляр значимого типа был бы упакован при вызове метода ToString типа System. ValueType.

□ Вызов GetType. При вызове невиртуального метода GetType упаковка pi необходима, поскольку тип Point не реализует GetType, а наследует его от System. Object. Поэтому для вызова GetType нужен указатель на типовой объект Point, который можно получить только путем упаковки pi.

□ Первый вызов CompareTo. При первом вызове CompareTo упаковка pi не нужна, так как Point реализует метод CompareTo, и компилятор может просто вызвать его напрямую. Заметьте: в CompareTo передается переменная р2 типа Point, поэтому компилятор вызывает перегруженную версию CompareTo, которая принимает параметр типа Point. Это означает, что р2 передается в CompareTo по значению, и никакой упаковки не требуется.

□ Приведение типа к IComparable. Когда выполняется приведение типа pi к переменной интерфейсного типа (с), упаковка pi необходима, так как интерфейсы по определению имеют ссылочный тип. Поэтому выполняется упаковка pi, а указатель на этот упакованный объект сохраняется в переменной с. Следующий вызов GetType подтверждает, что с действительно ссылается на упакованный объект Point в куче.

□ Второй вызов CompareTo. При втором вызове CompareTo упаковка pi не производится, потому что Point реализует метод CompareTo, и компилятор может вызывать его напрямую. Заметьте, что в CompareTo передается переменная с интерфейса IComparable, поэтому компилятор вызывает перегруженную версию CompareTo, которая принимает параметр типа Object. Это означает, что передаваемый параметр должен являться указателем, ссылающимся на объект в куче. К счастью, с уже ссылается на упакованный объект Point, по этой причине адрес памяти из с может передаваться в CompareTo и никакой дополнительной упаковки не требуется.

□ Третий вызов CompareTo. При третьем вызове CompareTo переменная с уже ссылается на упакованный объект Point в куче. Поскольку переменная с сама по себе имеет интерфейсный тип IComparable, можно вызывать только метод CompareTo интерфейса, а ему требуется параметр Object. Это означает, что передаваемый аргумент должен быть указателем, ссылающимся на объект в куче. Поэтому выполняется упаковка р2 и указатель на этот упакованный объект передается в CompareTo.

□ Приведение типа к Point. Когда выполняется приведение с к типу Point, объект в куче, на который указывает с, распаковывается, и его поля копируются из кучи в р2, экземпляр типа Point, находящийся в стеке.

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

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

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

using System;

// Point - значимый тип. internal struct Point { private Int32 m_x, m_y;

public Point(Int32 Xj Int32 y) { m_x = x; my = y;

}

public void Change(Int32 x, Int32 y) { m_x = x; m_y = y;

}

public override String ToStringQ {

return String. Format("({0}j {1})", m_x.ToStringQ, my. ToStringQ);

}

}

public sealed class Program { public static void MainQ {

Point p = new Point(lj 1);

Console.WriteLine(p);

p.Change(2, 2);

Console.WriteLine(p);

продолжение

Console.Write Line(о);

((Point) o).Change(3, 3);

Console.Write Line(o);

}

}

Все просто: Main создает в стеке экземпляр р типа Point и устанавливает его поля ш_х и ш_у равными 1. Затем р пакуется до первого обращения к методу Write Line, который вызывает ToString для упакованного типа Point, в результате выводится, как и ожидалось, (1,1). Затем р применяется для вызова метода Change, который изменяет значения полей ш_х и ш_у объекта р в стеке на 2. При втором обращении к Write Line, как и предполагалось, выводится (2, 2).

Далее р упаковывается в третий раз — о ссылается на упакованный объект типа Point. При третьем обращении к WriteLine снова выводится (2, 2), что опять вполне ожидаемо. И наконец, я обращаюсь к методу Change для изменения полей в упакованном объекте типа Point. Между тем Object (тип переменной о) ничего не «знает» о методе Change, так что сначала нужно привести о к Point. При таком приведении типа о распаковывается, и поля упакованного объекта типа Point копируются во временный объект типа Point в стеке потока. Поля ш_х и ш_у этого временного объекта устанавливаются равными 3, но это обращение к Change не влияет на упакованный объект Point. При обращении к WriteLine снова выводится (2, 2). Для многих разработчиков это оказывается неожиданным.

Некоторые языки, например C++/CLI, позволяют изменять поля в упакованном значимом типе, но только не С#. Однако и C# можно обмануть, применив интерфейс. Вот модифицированная версия предыдущего кода:

using System;

// Интерфейс, определяющий метод Change internal interface IChangeBoxedPoint { void Change(Int32 x, Int32 y);

>

// Point - значимый тип

internal struct Point : IChangeBoxedPoint { private Int32 m_x, m_y;

public Point(Int32 x, Int32 y) { m_x = x;

m_y = y;

>

public void Change(Int32 x, Int32 y) { m_x = x; m_y = y;

>

public override String ToStringQ {

return String.Format("({0}, {!})", m_x.To_String(), m_y.ToStringQ);

}

}

public sealed class Program { public static void Main() {

Point p = new Point(l, 1);

Console.WriteLine(p);

p.Change(2, 2);

Console.WriteLine(p);

Object о = p;

Console.WriteLine(o);

((Point) o).Change(3, 3);

Console.WriteLine(o);

// p упаковывается, упакованный объект изменяется и освобоадается ((IChangeBoxedPoint) p).Change(4, 4);

Console.WriteLine(p);

// Упакованный объект изменяется и выводится ((IChangeBoxedPoint) o).Change(5, 5);

Console.WriteLine(o);

}

Этот код практически совпадает с предыдущим. Основное отличие заключается в том, что метод Change определяется интерфейсом IChangeBoxedPoint и теперь тип Point реализует этот интерфейс. Внутри Main первые четыре вызова WniteLine те же самые и выводят те же результаты (что и следовало ожидать). Однако в конец Main я добавил пару примеров.

В первом примере р — неупакованный объект типа Point — приводится к типу IChangeBoxedPoint. Такое приведение типа вызывает упаковку р. Метод Change вызывается для упакованного значения, и его поля т_х и т_у становятся равными 4, но при возврате из Change упакованный объект немедленно становится доступным для уборки мусора. Так что при пятом обращении к Write Line на экран выводится (2, 2), что для многих неожиданно.

В последнем примере упакованный тип Point, на который ссылается о, приводится к типу IChangeBoxedPoint. Упаковка здесь не производится, поскольку тип о уже упакован. Затем вызывается метод Change, который изменяет поля ш_х и ш_у упакованного типа Point. Интерфейсный метод Change позволил мне изменить поля упакованного объекта типа Point! Теперь при обращении к WriteLine выводится (5, 5). Я привел эти примеры, чтобы продемонстрировать, как метод интерфейса может изменить поля в упакованном значимом типе. В C# сделать это без интерфейсов нельзя.

ВНИМАНИЕ

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

Некоторые главы этой книги я показал разработчикам. Познакомившись с примерами программ (например, изэтого раздела), они сказали, что решили держаться подальше от значимых типов. Должен сказать, что эти незначительные нюансы значимых типов стоили мне многодневной отладки, поэтому я и описываю их в этой книге. Надеюсь, вы не забудете об этих нюансах, тогда они не застигнут вас врасплох. Не бойтесь значимых типов — они полезны и занимают свою нишу. Просто не забывайте, что ссылочные и значимые типы ведут себя по-разному в зависимости от того, как применяются. Возьмите предыдущий код и объявите Point как class, а не struct — увидите, что все получится. И наконец, радостная новость заключается в том, что значимые типы, содержащиеся в библиотеке FCL— Byte, Int32, Ulnt32, Int64, Ulnt64, Single, Double, Decimal, Biglnteger, Complex и все перечислимые типы, — являются неизменяемыми и не преподносят никаких сюрпризов.

Равенство и тождество объектов

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

У типа System.Object есть виртуальный метод Equals, который возвращает true для двух «равных» объектов. Вот как выглядит реализация метода Equals для Object:

public class Object {

public virtual Boolean Equals(ObJect obj) {

// Если обе ссылки указывают на один и тот же объект,

// значит, эти объекты равны if (this == obj) return true;

// Предполагаем, что объекты не равны return false;

}

На первый взгляд эта реализация выглядит вполне разумно: сравниваются две ссылки, переданные в аргументах this и ob j, и если они указывают на один объект, возвращается true, в противном случае возвращается false. Это кажется логичным, так как Equals «понимает», что объект равен самому себе. Однако если аргументы ссылаются на разные объекты, Equals сложнее определить, содержат ли объекты одинаковые значения, поэтому возвращается false. Иначе говоря, оказывается, что стандартная реализация метода Equals типа Object реализует проверку на тождество, а не на равенство значений.

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

1. Если аргумент obj равен null, вернуть false, так как ясно, что текущий объект, указанный в this, не равен null при вызове нестатического метода Equals.

2. Если аргументы obj и this ссылаются на объекты одного типа, вернуть true. Этот шаг поможет повысить производительность в случае сравнения объектов

с многочисленными полями.

3. Если аргументы obj и this ссылаются на объекты разного типа, вернуть false. Понятно, что результат сравнения объектов String и FileStream равен false.

4. Сравнить все определенные в типе экземплярные поля объектов obj и this. Если хотя бы одна пара полей не равна, вернуть false.

5. Вызвать метод Equals базового класса, чтобы сравнить определенные в нем поля. Если метод Equals базового класса вернул false, тоже вернуть false, в противном случае вернуть true.

Учитывая это, компания Microsoft должна была бы реализовать метод Equals типа Object примерно так:

public class Object {

public virtual Boolean Equals(Object obj) {

// Сравниваемый объект не может быть равным null if (obj == null) return false;

// Объекты разных типов не могут быть равны if (this.GetTypeQ != obJ.GetTypeQ) return false;

// Если типы объектов совпадают, возвращаем true при условии,

// что все их поля попарно равны.

// Так как в System.Object не определены поля,

// следует считать, что поля равны return true;

}

}

Однако, поскольку в Microsoft метод Equals реализован иначе, правила собственной реализации Equals намного сложнее, чем кажется. Если ваш тип переопределяет Equals, переопределенная версия метода должна вызывать реализацию Equals базового класса, если только не планируется вызывать реализацию в типе Object. Это означает еще и то, что поскольку тип может переопределять метод Equals типа Object, этот метод больше не может использоваться для проверки на тождественность. Для исправления ситуации в Object предусмотрен статический метод RefenenceEquals со следующим прототипом:

public class Object {

public static Boolean ReferenceEquals(Object objA, Object objB) { return (objA == objB);

}

}

Для проверки на тождественность нужно всегда вызывать ReferenceEquals (то есть проверять на предмет того, относятся ли две ссылки к одному объекту). Не нужно использовать оператор == языка C# (если только перед этим оба операнда не приводятся к типу Object), так как тип одного из операндов может перегружать этот оператор, в результате чего его семантика перестает соответствовать понятию «тождественность».

Как видите, в области равенства и тождественности в .NET Framework дела обстоят довольно сложно. Кстати, в System. ValueType (базовом классе всех значимых типов) метод Equals типа Object переопределен и корректно реализован для проверки на равенство (но не тождественность). Внутреняя реализация переопределенного метода работает по следующей схеме:

1. Если аргумент obj равен null, вернуть false.

2. Если аргументы obj и this ссылаются на объекты разного типа, вернуть false.

3. Для каждого экземплярного поля, определенного типом, сравнить значение из объекта obj со значением из объекта this вызовом метода Equals поля. Если хотя бы одна пара полей не равна, вернуть false.

4. Вернуть true. Метод Equals типа ValueType не вызывает одноименный метод типа Object.

Для выполнения шага 3 в методе Equals типа ValueType используется отражение (см. главу 23). Так как отражение в CLR работает медленно, при создании собственного значимого типа нужно переопределить Equals и создать свою реализацию, чтобы повысить производительность сравнения значений на предмет равенства экземпляров созданного типа. И, конечно же, не стоит вызывать из этой реализации метод Equals базового класса.

Определяя собственный тип и приняв решение переопределить Equals, обеспечьте поддержку четырех характеристик, присущих равенству:

□ Рефлексивность: х. Equals(x) должно возвращать true.