Ссылочные типы (reference type) в C#

Урок посвящен ссылочным типам данных и их особенностям и правилам использования.

Большая часть всех типов в C# – это ссылочные типы. Это относится как к стандартной библиотеке (BCL), так и к пользовательским типам, которые вы будете создавать в своей работе.

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

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

Примитивные ссылочные типы:

Ключевое слово в C#.NET тип
objectSystem.Object
stringSystem.String
dynamicSystem.Object

Например, полный синтаксис объявления и инициализации строковой переменной будет выглядеть так:

System.String str = new System.String("Hello");

Сокращенный вариант:

string str = "Hello";

Как вы уже знаете из предыдущих уроков, у запущенной программы в распоряжении есть два вида памяти: стек и куча (или управляемая куча).

Рассмотрим на примере, как выделяется память для объектов ссылочного типа.

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

Пусть у нас есть некоторый класс Car, объекты которого мы будем создавать:

class Car
{
    public string Producer { get; set; }
}

Также есть метод для создания объекта класса Car с предопределенным значением свойства Producer:

public Car CreateBMW()
{
    Car car = new Car() { Producer="BMW"; }
    return car;
}

Предположим, что где-то в нашей программе вызывается метод CreateBMW().

В момент, когда мы запускаем нашу программу, операционная система в оперативной памяти выделяет специальное место, в которое загружается среда исполнения CLR, там же создается управляемая куча и поток со своим стеком. Наш код будет выполняться в рамках созданного потока. Вся эта сложная конструкция с памятью и потоками называется процесс. Когда байт-код нашего метода будет преобразовываться в машинный код JIT-компилятором, будут определены все используемые типы, и в управляемую кучу загрузятся соответствующие объекты-типы, в нашем случае – это Car.

Получим вот такую картину:

В объекте-типе Car содержатся статические поля, методы класса и два дополнительных поля: указатель на объект-тип и индекс блока синхронизации, о последнем в рамках этого курса говорить не будем, он приведен для полноты “картины”. Объект-тип описывает класс, экземпляры которого мы будем создавать. Указанные два дополнительных поля, добавляются ко всем объектам, которые создаются в куче. На картинке мы не стали их отображать, чтобы не загромождать изображение.

Когда выполнится первая строка в методе CreateBMW(), в стеке будет создана локальная переменная car со значением  null. Такое поведение является вариантом “по умолчанию” для всех переменных ссылочного типа.

После того как выполнится вторая строка, в куче будет создан соответствующий экземпляр типа Car, у которого поле Producer проинициализировано значением “BMW”. При этом указатель на объект-тип объекта car, будет указывать на "Объект-тип Car" созданный в момент загрузки программы и сканирования ее JIT’ом.

После завершения работы метода стек будет очищен и произойдет возврат в то место, где был вызван CreateBMW(), но объект car будет жить в куче до тех пор, пока его не удалит сборщик мусора.

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

Рассмотрим этот аспект более подробно.

После создания переменной её нужно проинициализировать. Это можно сделать с помощью оператора new, либо присвоить ей значение другой переменной.

Для дальнейшей демонстрации будем пользоваться классом Person:

class Person
{
    public string Name { get; set; } = string.Empty;
}

Варианты создания и инициализации переменных типа Person:

// Создание и инициализация с помощью оператора new
Person p1 = new Person() { Name = "John" };

// Создание и инициализация через присвоение значения другой переменной
Person p2 = p1;

Когда мы проинициализировали переменную с помощью оператора new, в куче будет создан объект типа Person, полю Name будет присвоено определенное значение, в нашем случае это “John”, а ссылка на этот объект присваивается переменной p1.

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

Это приводит к ряду преимуществ, но в то же время имеет и некоторые недостатки.

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

Недостатком является то, что изменяя что-то в одном объекте, мы автоматически получаем эти изменения в других. Если это так и задумывалось по дизайну приложения, то всё хорошо. Но довольно часто эти “эффекты” возникают неожиданно. Т.е. разработчик не предусматривал такое поведение.

Вернемся к нашему примеру.

// Изменим значение свойства Name у p1
p1.Name = "Mary";

// Выведем значение свойства Name у p1 и p2
Console.WriteLine(p1.Name); // Mary
Console.WriteLine(p2.Name); // Mary

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

Этот эффект возникает везде, где происходит присваивание одной переменной ссылочного типа другой, даже если это делается не явно (не напрямую через знак = ). Например, создадим метод, который принимает на вход объект класса Person и изменяет значение поля Name (стирает его).

private static void PrintName(Person p)
{
    Console.WriteLine($"Name is: {p.Name}");
    p.Name = string.Empty;
}

Продолжим нашу основную программу следующими строчками:

PrintName(p1);
Console.WriteLine(p1.Name);

В свойстве Name у p1 (и у p2) вместо “Mary” окажется пустая строка. Дело в том, что при передаче p1 в метод PrintName, было выполнено присваивание p = p1, в результате p стала указывать на тот же объект в куче, что и p1.

Ниже представлен полный код программы:

internal class Program
{
    static void Main(string[] args)
    {
        // Создание и инициализация с помощью оператора new
        Person p1 = new Person() { Name = "John" };

        // Создание и инициализация через присвоение значения другой переменной
        Person p2 = p1;

        // Изменим значение свойства Name у p1
        p1.Name = "Mary";

        // Выведем значение Name у p1 и p2
        Console.WriteLine(p1.Name); // Mary
        Console.WriteLine(p2.Name); // Mary

        PrintName(p1);
        Console.WriteLine(p1.Name);
    }

    private static void PrintName(Person p)
    {
        Console.WriteLine($"Name is: {p.Name}");

        p.Name = string.Empty;
    }
}

class Person
{
    public string Name { get; set; } = string.Empty;
}

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

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

Для демонстрации вернемся к классу Person:

class Person
{
    public string Name { get; set; } = string.Empty;
}

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

// Разные объекты - одна и та же область памяти
Person p1 = new Person() { Name = "John" }; 
Person p2 = p1;

// Разные объекты, разные области памяти, но одинаковое содержимое
Person p3 = new Person() { Name = "Mary" };
Person p4 = new Person() { Name = "Mary" };

Сравним их между собой на “равенство” (пока это слово до сих пор в кавычках), для этого воспользуемся оператором ==:

Console.WriteLine(p1 == p2); // True
Console.WriteLine(p3 == p4); // False

Результат первой строки – True, второй – False. Помните, в одном из уроков мы говорили про то, что все объекты в C# являются наследниками System.Object, у System.Object есть метод Equals() для сравнения объектов? Когда мы сравниваем наши объекты класса Person с помощью оператора ==, это компилируется в вызов метода Equals(). То есть приведенный выше код, аналогичен следующему:

Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p3.Equals(p4)); // False

Метод Equals() сравнивает ссылки объектов, то есть проверяет, указывают ли они на один и тоже объект в памяти. Если да, то возвращает true, иначе — false. Именно поэтому, несмотря на то, что p3 и p4 структурно и содержательно равны – оба являются объектами типа Person и значение свойства Name у них равно “Mary”, результат сравнения через Equals() выдает false.

Для того чтобы сравнивать не тождественность объектов, а их равенство, необходимо переопределить метод Equals() в классе Person. Корректное написание такого метода — довольно нетривиальная задача, и она требует бóльших знаний, чем те, что были получены в рамках курса на текущий момент. Этот вопрос мы разберем позже, в одном из последних модулей.

Таким образом, мы с вами выявили, что существует два понятия:

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

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

Если Вы хотите больше узнать про язык C#, приглашаем Вас на наш курс “C#. Базовый уровень“.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *