Нашли ошибку или опечатку? Выделите текст и нажмите

Поменять цветовую

гамму сайта?

Поменять
Обновления сайта
и новые разделы

Рекомендовать в Google +1

Внутреннее устройство типов значений C#

145

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

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

public struct Point2D
{
    public int X;
    public int Y;
}

При сохранении экземпляра Point2D в памяти, инициализированного значениями координат x=5 и y=7, он имеет простой вид (на рисунке ниже) и в нем отсутствуют «лишние» поля:

Схема размещения в памяти экземпляра типа значения Point2D

В некоторых редких случаях бывает желательно изменить схему размещения типов значений в памяти, например, для организации взаимодействий, когда экземпляр типа значения передается в неизменном виде неуправляемому коду. Возможность такой настройки обеспечивается двумя атрибутами, StructLayout и FieldOffset из пространства имен System.Runtime.InteropServices.

Атрибут StructLayout может использоваться для определения полей объекта, которые должны размещаться последовательно, в соответствии с объявлением типа (действует по умолчанию). Атрибут FieldOffset позволяет явно определить смещения полей в памяти, что дает возможность создавать объединения в стиле языка C, где поля могут накладываться друг на друга.

Ниже приводится простой пример типа значения, способного «преобразовывать» числа с плавающей запятой в 4-байтовое представление:

[StructLayout(LayoutKind.Explicit)]
public struct FloatingPintExplorer
{
    [FieldOffset(0)]
    public float F;

    [FieldOffset(0)]
    public byte B1;

    [FieldOffset(0)]
    public byte B2;

    [FieldOffset(0)]
    public byte B3;

    [FieldOffset(0)]
    public byte B4;
}

Если присвоить полю F объекта вещественное число, он автоматически изменит значения полей B1-B4, и наоборот. Фактически, поле F и поля B1-B4 перекрываются в памяти, как показано на рисунке ниже:

Схема размещения в памяти экземпляра FloatingPointExplorer

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

Ограничения типов значений

Сначала коснемся слова заголовка объекта. Если программа попытается использовать экземпляр типа значения для синхронизации, в большинстве случаев это является ошибкой (как будет показано чуть ниже). Но должна ли среда выполнения рассматривать такое использование недопустимым и возбуждать исключение? Взгляните на следующий пример, что произойдет, если метод Increment одного и того же экземпляра класса Counter будет вызван одновременно двумя потоками выполнения?

class Counter
{
    private int _i;

    public int Increment()
    {
        lock (_i)
        {
            return ++_i;
        }
    }
}

При попытке проверить это, мы наткнулись на неожиданное препятствие: компилятор C# не позволяет использовать типы значений с ключевым словом lock. Однако теперь мы вооружены знанием особенностей работы lock и можем попытаться обойти это препятствие:

class Counter
{
    private int _i;

    public int Increment()
    {
        bool acquired = false;

        try
        {
            Monitor.Enter(_i, ref acquired);
            return ++_i;
        }
        finally
        {
            if (acquired)
                Monitor.Exit(_i);
        }
    }
}

В результате, мы внесли в программу ошибку - оказывается, что сразу несколько потоков выполнения смогут одновременно приобрести блокировку и изменить значение поля _i, а кроме того, вызов Monitor.Exit возбудит исключение. Проблема в том, что метод Monitor.Enter принимает параметр типа System.Object, который является ссылочным типом, а мы передаем ему экземпляр типа значения - по значению. Но, даже если бы была возможность передать значение там, где ожидается ссылка, значение, переданное методу Monitor.Enter, имело бы другую идентичность (identity), чем значение, переданное методу Monitor.Exit. Аналогично, значение, переданное методу Monitor.Enter в одном потоке, имеет иную идентичность, чем значение, переданное методу Monitor.Enter в другом потоке. При передаче значений (по значению!) там, где ожидаются ссылки, нет никакой возможности обеспечить правильную семантику работы блокировок.

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

object GetInt()
{
    int i = 42;
    return i;
}

// ...

object obj = GetInt();

Метод GetInt() возвращает экземпляр типа значения, что вполне типично для возвращаемых значений. Однако, вызывающая программа ожидает, что метод вернет ссылку на объект. Метод мог бы вернуть указатель на область памяти в стеке, где хранилась переменная i во время выполнения метода. Но, к сожалению, эта ссылка будет недействительной, потому что сразу после выхода из метода его кадр стека будет уничтожен. Этот пример показывает, что семантика «копирования по значению» (copy-by-value), которой по умолчанию обладают типы значений, плохо согласуется с ситуациями, когда ожидается ссылка на объект (в управляемой динамической памяти).

Виртуальные методы типов значений

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

Однако типы значений снабжаются несколькими виртуальными методами, унаследованными от System.Object. Вот некоторые из них: Equals(), GetHashCode(), ToString() и Finalize(). Здесь мы рассмотрим только первые два, но, все, что будет говориться далее, в значительной степени относится и к остальным методам. Начнем с исследования их сигнатур:

public class Object
{
	public virtual bool Equals(object obj) // ...
	public virtual int GetHashCode() // ...
}

Эти виртуальные методы реализованы для каждого типа в .NET, включая и типы значений. Это означает, что виртуальный метод можно вызвать для любого экземпляра типа значения, даже при том, что он не имеет указателя на таблицу методов! Это - третий пример, как схема размещения типов значений в памяти влияет на наши возможности выполнять даже простейшие операции с экземплярами типов значений, требующих некоторого механизма, который «превращал» бы их в нечто, что могло бы использоваться как «настоящий» объект.

Упаковка (box)

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

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

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

.method private hidebysig static object GetInt() cil managed
{
	.maxstack 8
	L_0000: ldc.i4.s 0x2a
	L_0002: box int32
	L_0007: ret
}

Упаковка - довольно дорогостоящая операция, включающая выделение памяти, копирование данных и впоследствии добавляет накладные расходы на сборку мусора, необходимую для утилизации временных упакованных экземпляров. С появлением поддержки обобщенных типов в CLR 2.0, надобность в упаковке, кроме как в механизме рефлексии и в ряде других редких ситуаций, практически отпала. Тем не менее, упаковка остается одной из важнейших проблем производительности во многих приложениях; как будет показано далее, «правильное применение типов значений» с целью предотвращения упаковки невозможно без представлений о том, как выполняется вызов методов типов значений.

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

Аналогично, методу Monitor.Enter(), ожидающему получить ссылку на объект, можно передать ссылку на упакованный объект, который он будет использовать для синхронизации. К сожалению, упакованные объекты, созданные на основе одного и того же экземпляра типа значения, не будут считаться идентичными. То есть, упакованный объект, переданный методу Monitor.Exit(), будет отличаться от упакованного объекта, переданного методу Monitor.Enter(), а упакованный объект, переданный методу Monitor.Enter() в одном потоке выполнения, будет отличаться от упакованного объекта, переданного методу Monitor.Enter() в другом потоке выполнения. Это означает, что использование любых типов значений для синхронизации на основе монитора (с использованием ключевого слова lock) ошибочно в принципе.

Еще одним важным вопросом остаются виртуальные методы, наследуемые от System.Object. Как оказывается, типы значений не наследуют класс System.Object непосредственно - они наследуют промежуточный тип System.ValueType. Как ни странно, System.ValueType является ссылочным типом - среда выполнения CLR различает типы значений и ссылочные типы по следующему критерию: типы значений наследуют System.ValueType. Согласно этому критерия System.ValueType является ссылочным типом.

Класс System.ValueType переопределяет виртуальные методы Equals и GetHashCode, унаследованные от System.Object, и на то есть веская причина: типы значений по умолчанию используют иную семантику сравнения, и эта семантика должна быть где-то реализована. Например, переопределенная версия метода Equals в классе System.ValueType гарантирует, что сравнение типов значений будет выполняться по их содержимому, тогда как оригинальный метод Equals в классе System.Object сравнивает ссылки на объекты (их идентичность).

Не вдаваясь в подробности реализации виртуальных методов в классе System.ValueType, рассмотрим следующую ситуацию. У вас имеется десять миллионов объектов Point2D в списке List<Point2D> , и требуется найти единственный объект Point2D, используя метод Contains(). Метод Contains() не имеет лучшего способа поиска, как выполнить обход всех десяти миллионов объектов в списке и сравнить каждый из них с образцом:

List<Point2D> polygon = new List<Point2D>();

// ... добавление 10 млн точек в список 

Point2D point = new Point2D { X = 5, Y = 7 };
bool contains = polygon.Contains(point);

Обход списка с десятью миллионами точками и сравнение каждой из них с указанным образцом требует времени, но сама по себе это довольно быстрая операция. В ходе поиска придется извлечь из памяти примерно 80000000 байт (по восемь байтов на каждый объект Point2D), и операция сравнения выполняется очень быстро. Досадно, но для сравнения двух объектов Point2D требуется вызвать виртуальный метод Equals:

Point2D a = ..., b = ...;
a.Equals(b);

Здесь мы столкнулись с двумя проблемами. Во-первых, метод Equals - даже его переопределенная версия в System.ValueType - принимает ссылку на экземпляр System.Object. Чтобы иметь возможность интерпретировать объект Point2D как экземпляр ссылочного типа, его необходимо упаковать, как было описано выше, то есть в данном примере объект b должен быть упакован. Кроме того, для вызова метода Equals требуется также упаковать объект a, чтобы получить указатель на таблицу методов!

JIT-компилятор способен производить вычисления по короткой схеме, что могло бы обеспечить возможность непосредственного вызова метода Equals, потому что типы значений объявлены конечными (sealed), а вызываемый виртуальный метод известен уже на этапе компиляции, независимо оттого, переопределяет ли тип Point2D метод Equals или нет (такую возможность допускает префикс constrained языка CIL). Однако, из-за того, что System.ValueType является ссылочным типом, метод Equals может интерпретировать свой неявный параметр this как экземпляр ссылочного типа, даже при том, что для вызова метода используется экземпляр типа значения (Point2D a) - а это требует упаковки.

В итоге, на каждый вызов Equals приходится две операции упаковки экземпляров Point2D. Для 10 000 000 вызовов Equals получается 20 000 000 операций упаковки, каждая из которых выделяет 16 байт памяти (в 32-разрядной системе), а в общей сложности выделяется 320 000 000 байт и копируется 160 000 000 байт. Продолжительность этих манипуляций с памятью намного превосходит время, действительно необходимое для сравнения двух точек.

Предотвращение упаковки типов значений с помощью метода Equals

Можно ли полностью избавиться от этих операций упаковки? Один из способов - переопределить метод Equals и предоставить реализацию, подходящую для нашего типа значения:

public struct Point2D
{
    public int X;
    public int Y;

    public override bool Equals(object obj)
    {
        if (!(obj is Point2D))
            return false;

        Point2D other = (Point2D)obj;
        return X == other.X && Y == other.Y;
    }
}

Благодаря способности JIT-компилятора производить вычисления по короткой схеме, как описывалось выше, вызов a.Equals(b) требует упаковки только значения b, потому что метод принимает ссылку на объект. Чтобы избавиться от второй операции упаковки, нужно добавить перегруженную версию метода Equals:

public struct Point2D
{
    public int X;
    public int Y;

    public override bool Equals(object obj)
    {
        // ...
    }
	
	public bool Equals(Point2D other)
    {
        return X == other.X && Y == other.Y;
    }
}

Теперь, когда компилятор встретит вызов a.Equals(b), он безусловно предпочтет использовать вторую, перегруженную версию метода, потому что тип его параметра более точно соответствует типу аргумента. Пока мы не отвлеклись, заметим, что существуют и другие методы, кандидаты на перегрузку - достаточно часто мы сравниваем объекты с помощью операторов == и !=:

public struct Point2D
{
    public int X;
    public int Y;

    public override bool Equals(object obj)
    {
        // ...
    }

    public bool Equals(Point2D other)
    {
        // ...
    }

    public static bool operator ==(Point2D a, Point2D b)
    {
        return a.Equals(b);
    }

    public static bool operator !=(Point2D a, Point2D b)
    {
        return !(a == b);
    }
}

Этого достаточно в большинстве случаев. Но иногда возникают крайние ситуации, связанные с особенностями реализации обобщенных типов в CLR, которые вызывают упаковку, когда List<Point2D> обращается к методу Equals для сравнения двух экземпляров Point2D, с типом Point2D, как реализацией его параметра обобщенного типа (T). Отметим, что тип Point2D должен наследовать интерфейс IEquatable<Point2D>, обеспечивающий более подходящее поведение в List<T>, и EqualityComparer<T>, чтобы дать возможность вызывать перегруженную версию метода Equals через интерфейс (ценой вызова виртуального метода в абстрактном методе EqualityComparer<T>.Equals). Результатом является 10-кратное увеличение производительности и полное избавление от манипуляций с динамической памятью (обусловленных процедурой упаковки) при поиске точки в списке из 10000000 экземпляров Point2D!

public struct Point2D : IEquatable<Point2D>
{
    public int X;
    public int Y;

    public bool Equals(Point2D other)
    {
        // ... как и прежде
    }
}

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

Point2D point = ...;
IEquatable<Point2D> equatable = point; // здесь выполняется упаковка

Однако, когда вызов метода интерфейса выполняется через статически типизированную переменную типа значения, упаковка не производится (здесь действуют все та же поддержка вычислений по короткой схеме, которая обеспечивается префиксом constrained в языке CIL):

Point2D point = ..., anotherPoint = ...;
point.Equals(anotherPoint); 	// упаковка не выполняется, вызывается Point2D.Equals(Point2D)

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

Point2D point = new Point2D { X = 5, Y = 7 };
Point2D anotherPoint = new Point2D { X = 6, Y = 7 };

// Здесь выполняется упаковка
IEquatable<Point2D> equatable = point;
equatable.Equals(anotherPoint);		// вернет false

point.X = 6;
point.Equals(anotherPoint);			// вернет true
equatable.Equals(anotherPoint);		// вернет false, упакованный объект не изменился!

Это является одной из причин, почему часто рекомендуется создавать типы значений неизменяемыми, а изменения производить только за счет создания копий. (Примером такого неизменяемого типа значения может служить System.DateTime.)

Еще одной проблемой метода ValueType.Equals является его фактическая реализация. Сравнение двух экземпляров произвольных типов значений по их содержимому далеко не тривиальная задача. В результате дизассемблирования получается следующая картина (немного отредактированная для краткости):

public override bool Equals(object obj)
{
    if (obj == null) return false;
    RuntimeType type = (RuntimeType) base.GetType();
    RuntimeType type2 = (RuntimeType) obj.GetType();

    if (type2 != type) return false;
    object a = this;
    if (CanCompareBits(this))
    {
        return FastEqualsCheck(a, obj);
    }
    
    FieldInfo[] fields = type.GetFields(BindingFlags.NonPublic | 
        BindingFlags.Public | BindingFlags.Instance);

    for (int i = 0; i < fields.Length; i++)
    {
        object obj3 = ((RtFieldInfo) fields[i]).InternalGetValue(a, false);
        object obj4 = ((RtFieldInfo) fields[i]).InternalGetValue(obj, false);

        if (obj3 == null && obj4 != null)
            return false;
        else if (!obj3.Equals(obj4))
            return false;
    }

    return true;
}

Проще говоря, если CanCompareBits вернет истинное значение, проверка равенства выполняется с помощью FastEqualsCheck; в противном случае метод входит в цикл, где с помощью класса FieldInfo рекурсивно извлекаются поля и сравниваются вызовом метода Equals. Разумеется, использование механизма рефлексии в цикле крайне отрицательно сказывается на производительности - механизм рефлексии весьма дорог в использовании и все остальные потери производительности бледнеют на его фоне. Определения методов CanCompareBits и FastEqualsCheck в CLR являются «внутренними вызовами», не реализованными на языке IL, поэтому их нельзя так просто дизассемблировать. Однако экспериментальным путем мы установили, что CanCompareBits возвращает истинное значение, если выполняется одно из следующих условий:

  1. Тип значения содержит только поля простых типов и не переопределяет метод Equals.

  2. Тип значения содержит только поля типов значений, для которых выполняется условие (1) и не переопределяет метод Equals.

  3. Тип значения содержит только поля типов значений, для которых выполняется условие (2) и не переопределяет метод Equals.

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

Метод GetHashCode

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

В числе типичных классов хеш-таблиц в .NET Framework можно назвать Dictionary<TKey, TValue>, Hashtable и HashSet<T>. Обычно хеш-таблицы реализуются как динамический массив записей, каждая из которых содержит связанный список элементов. Чтобы добавить новый элемент в хеш-таблицу, сначала определяется его числовой хеш-код (вызовом метода GetHashCode), а затем вызывается хеш-функция, определяющая запись, куда следует поместить элемент. Наконец, элемент добавляется в связанный список выбранной записи:

Хеш-таблица

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

Производительность хеш-таблиц в значительной степени зависит от хеш-функции, которая предъявляет определенные требования к методу GetHashCode:

  • Если два объекта равны, их хеш-коды также должны быть равны.

  • Если два объекта не равны, вероятность равенства их хеш-кодов должна быть минимальной.

  • GetHashCode должен работать быстро (часто его производительность прямо пропорциональна размеру объекта).

  • Хеш-код объекта не должен изменяться.

Второе требование в этом списке не может быть выражено, как «если два объекта не равны, их хеш-коды не должны быть равны», потому что могут существовать типы, для которых возможное количество объектов больше количества целых чисел, тогда неизбежно будут существовать объекты с одинаковыми хеш-кодами. Рассмотрим, например, значения типа long. Всего существует 264 различных значений long, но различных целочисленных значений всего 232, поэтому существует, по крайней мере, одно целочисленное значение, являющееся хеш-кодом для 232 различных значений long.

Условия (1) и (2) подчеркивают взаимосвязь между равенством объектов и равенством их хеш-кодов. Если потребуется переопределить и перегрузить виртуальный метод Equals, вам придется соответственно изменить реализацию GetHashCode. Похоже, что типичная реализация GetHashCode должна каким-то образом учитывать поля объекта. Например, лучшая реализация GetHashCode для типа int должна просто возвращать целочисленное значение. Для объектов Point2D можно придумать некоторую линейную комбинацию двух координат или некоторых битов одной координаты с другими битами второй координаты. Проектирование хороших хеш-кодов обычно является сложной задачей.

Наконец перейдем к условию (4). За ним стоят следующие рассуждения: допустим, что имеется точка (5, 5) и ее необходимо добавить в хеш-таблицу, также допустим, что она имеет хеш-код 10. Если координаты точки изменить на (6, 6), ее хеш-код получит значение 12. В этом случае вы не сможете отыскать точку в хеш-таблице. Но это не должно вызывать беспокойства для типов значений, потому что невозможно изменить объекты, добавленные в хеш-таблицу - хеш-таблица хранит их копии, недоступные для вашего кода.

А что можно сказать о ссылочных типах? Определение равенства для ссылочных типов на основе их содержимого может порождать проблемы. Допустим, что у нас имеется следующая реализация Employee.GetHashCode (класс Employee мы использовали в предыдущей статье):

public class Employee
{
    public string Name { get; set; }
    public override int GetHashCode()
    {
        return Name.GetHashCode();
    }
}

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

HashSet<Employee> employees = new HashSet<Employee>();
Employee vasya = new Employee { Name = "Вася Пупкин" };

employees.Add(vasya);
vasya.Name = "Вася Пупкин-Иванов";
employees.Contains(vasya);  // вернет false!

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

Среда выполнения CLR предоставляет для ссылочных типов реализацию GetHashCode по умолчанию, которая основана на идентичности объектов. Если две ссылки на объекты равны и если они ссылаются на один и тот же объект, имеет смысл сохранить хеш-код где-то в самом объекте так, чтобы исключить возможность его изменения и затруднить доступ к нему. В действительности, когда создается экземпляр ссылочного типа, среда выполнения CLR встраивает хеш-код в слово заголовка объекта (для оптимизации, это происходит при первом обращении к хеш-коду; в конце концов, множество объектов никогда не используются в качестве ключей хеш-таблиц). Чтобы вычислить хеш-код, необязательно генерировать случайные числа или учитывать содержимое объекта - достаточно будет простого счетчика.

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

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

Эффективные приемы использования типов значений

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

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

  • используйте типы значений, если требуется высокая плотность размещения их в памяти;

  • переопределяйте Equals, определяйте перегруженные версии Equals, реализуйте интерфейс IEquatable<T>, перегружайте операторы == и != в своих типах значений;

  • перегружайте GetHashCode в своих типах значений;

  • подумайте о возможности сделать свои типы значений неизменяемыми.

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

Пройди тесты