🗃️Ссылочные и значимые типы в C# Ключевые слова ref, out, in Что нужно знать новичку

Автор: Орц Орцхоев

Глава 1. Значимые и ссылочные типы в С#

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

В программировании важно понимать, что тип данных - это способ организации и хранения информации в программе. В языке C#, выделяются два основных типа: значимые и ссылочные.

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


Почему у нас есть два типа памяти?

В C# значимые типы данных (примитивные типы), такие как int, double, bool и т.д., содержат одно значение. С другой стороны, ссылочные типы данных - они же составные типы. Ссылочный тип может иметь ссылки на другие объекты и другие примитивные типы данных. К примеру ссылочный тип может в один момент держать ссылку на объект, который занимает много памяти. А затем сменить ссылку на объект поменьше.

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

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

В стеке хранятся данные, которые не могут внезапно изменить занимаемую ими память. Здесь могут храниться значимые типы, которые в процессе жизни не изменят свой объем занимаемой памяти. Например: int = 2; - здесь int как занимал 4 байта памяти, так и будет столько занимать, какое бы мы число в него не записали (в пределах его границ, до 2^32) Также в стеке хранятся все ссылки. Ведь ссылка не может изменять свой размер, это просто адрес, указывающий на значение.

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

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

Хранение значимых типов

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

В .NET, когда мы присваиваем значение из одной переменной в другую переменную, это создает совершенно другую копию в памяти стека. Это то, что вы можете видеть на рисунке выше. Таким образом, если вы измените значение одной переменной, это не повлияет на другую переменную. В .NET эти типы данных называются типами значений. Примерами типов значений являются bool, byte, char, decimal, double, enum, float, long, sbyte, int, short, ulong , struct, uint ushort.

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

Хранение ссылочных типов

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

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


Пример работы со Значимыми типами

int a = 0;
int b = 10;

Console.WriteLine(a); // 0
Console.WriteLine(b); // 10

b = a;

Console.WriteLine(a); // 0
Console.WriteLine(b); // 0

a++;

Console.WriteLine(a); // 1
Console.WriteLine(b); // 0

Здесь мы объявили две переменные значимого типа a и b.

int a = 0;
int b = 10;

Затем провели операцию присваивания, и переменной b присвоили значение переменной a. Переменная b получила копию значения от a.

b = a;

Console.WriteLine(a); // 0
Console.WriteLine(b); // 0

И наконец, меняя значение переменной a, на переменную b это не возымеет эффекта. При присваивании b = a; мы не получили ссылку на ячейку памяти a, а получили копию. По этой причине, при операции a++ изменилось лишь значение, хранящееся в a.

a++;

Console.WriteLine(a); // 1
Console.WriteLine(b); // 0

Пример работы с Ссылочными типами

Со значимыми типами разобрались, они при операции присваивания ( = ) отдают копию своего значения. Теперь поговорим о ссылочных типах, как ведут себя они? Ниже пример кода, демонстрирующий все возможные ситуации, о которых нужно знать для начала:

int[] arrayA = [0, 0, 0];
int[] arrayB = [1, 2, 3, 4];

Print(arrayA);           // 0 0 0
Print(arrayB);           // 1 2 3 4

arrayB = arrayA;

Print(arrayA);           // 0 0 0
Print(arrayB);           // 0 0 0

arrayB[0] = 10;

Print(arrayA);          // 10 0 0
Print(arrayB);          // 10 0 0

arrayB = null;

Print(arrayA);          // 10 0 0
Print(arrayB);          // null

Определение метода Print:

void Print(int[] array)
{
    if(array == null)
    {
        Console.WriteLine("null");
        return;
    }

    foreach (var number in array)
    {
        Console.Write($"{number} ");
    }

    Console.WriteLine();
}

Для начала мы объявили два массива - переменные с ссылочным типом данных:

int[] arrayA = [0, 0, 0];
int[] arrayB = [1, 2, 3, 4];

Print(arrayA);           // 0 0 0
Print(arrayB);           // 1 2 3 4

Следом мы смотрим на поведение: при присваивании одного массива другому, пока ничего необычного не заметим, просто в arrayB лежат те же данные, что и в arrayA.

arrayB = arrayA;

Print(arrayA);           // 0 0 0
Print(arrayB);           // 0 0 0

Самое интересное начинается, когда мы начинаем менять состояние одной из переменных ссылочного типа. После операции arrayB[0] = 10; данные изменятся и в arrayA. Происходит это потому, что при операции arrayB = arrayA; - мы в переменную arrayB скопировали ссылку на адрес переменной arrayA. Теперь они указывают на один и тот же массив.

arrayB[0] = 10;

Print(arrayA);          // 10 0 0
Print(arrayB);          // 10 0 0

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

В следующем коде демонстрируется поведение, когда мы обрубаем ( = null ) ссылку у переменной arrayB на область памяти, где хранится значение. Заметьте, мы обрубили ссылку для одной переменной, но вторая всё ещё указывает на наш массив. Поэтому было важно знать, что передается именно копия ссылки. Одну ссылку обрубили, вторая ещё указывает на массив. Ещё это называют “занулить”.

arrayB = null;

Print(arrayA);          // 10 0 0
Print(arrayB);          // null

Вот небольшая демонстрация того, что же произошло в последнем примере кода:

Логика ссылочных операций

На этом всё, теперь вы знаете базовые принципы работы с ссылочными и значимыми типами. Теперь вы готовы изучить принцип работы ref, out и in.


Глава 2. Применение ref к Значимым и Ссылочным Типам

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


Применение ref к значимым типам (struct)

Мы уже знаем, как ведут себя простые значимые типы в C# (int, float, bool и т.д.). Для следующего примера создадим свой значимый тип struct Dog. Обычные присвоения мы разобрали, теперь рассмотрим ещё и передачу переменных в метод.

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

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

Dog dog = new Dog() { Name = "Doggy" };
Console.WriteLine(dog.Name);         // Doggy

ChangeField(dog);
Console.WriteLine(dog.Name);         // Doggy
void ChangeField(Dog dog)
{
    dog.Name = "Changed Name";
}
struct Dog
{
    public string Name;
}

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

Теперь немножко изменим наш пример, чтобы разобраться с модификатором ref.

Dog dog = new Dog() { Name = "Doggy" };
Console.WriteLine(dog.Name);         // Doggy

ChangeField(ref dog);
Console.WriteLine(dog.Name);         // ChangedName
void ChangeField(ref Dog dog)
{
    dog.Name = "Changed Name";
}

В чём разница? Всё просто, ref говорит, что будет передана ссылка на ту ячейку памяти, где находится оригинальный объект.

В случае со значимыми типами, мы так избегаем копирования целой переменной. Это играет на руку производительности, так как мы не тратим ресурсы на выделение памяти для копий. В главе “Примеры работы с in” это будет продемонстрировано.

Пару слов про изменение значения переменной, если она передана без ref. Думаю ответ уже очевиден, но вот пример для наглядности:

void ChangeReference(Dog dog)
{
    dog = new Dog() { Name = "new Dog name" }; // значение new Dog() присваивается копии
    dog = default;                             // Тоже присваивается копии
}

Ключевое слово default - назначает переменной значение по умолчанию. Значение по умолчанию зависит от типа переменной. Для ссылочных это null, для значимых - зависит от конкретного типа. Для int это 0, для bool это false и т.д. Для более объемных объектов - объект с полями, которые установлены в значение по умолчанию. Подробнее о ключевых словах и значениях по умолчанию найдёте в конце статьи.


Применение ref к ссылочным типам (class)

Пример ниже продемонстрирует работу с ref с ссылочными типами данных. Здесь мы попробуем поменять состояние объекта (cat.Name = "Changed Name";), и попробуем поменять ему ссылку (cat = null;).

Cat cat = new Cat() { Name = "Kitty" };
Console.WriteLine(cat.Name);           // Kitty

ChangeField(cat);
Console.WriteLine(cat.Name);           // Changed Name

ChangeReference(cat);
Console.WriteLine(cat.Name);           // Changed Name
Console.WriteLine(cat == null);        // False
void ChangeField(Cat cat)
{
    cat.Name = "Changed Name";
}
private static void ChangeReference(Cat cat)
{
    cat = new Cat() { Name = "new Cat name" };
    cat = null;
}
class Cat
{
    public string Name;
}

В этот раз ситуация немножко меняется, ведь после срабатывания вызова ChangeField(cat); - поле успешно будет изменено. А после вызова ChangeReference(cat); изменений никаких не будет.

Но почему так? Тип ведь “ссылочный”, и передается по ссылке, разве нет?

В таком определении есть одна недосказанность - передается здесь лишь копия ссылки. В стек кладётся ещё одна копия ссылки, которая тоже указывает на наш объект, их теперь две. Да, хоть это и копия ссылки, ведёт-то она на один и тот же класс, верно? Поэтому мы без проблем может менять его состояние.

Но при этом, так как в руках у нас лишь копия ссылки, меняя ее таким образом: cat = new Cat() или cat = null; - мы изменим значение конкретно для этой скопированной ссылки. Оригинальная ссылка всё ещё никак не тронута, и ничем не заменена.

Осталось понять, что мы получим, если передать ссылочный тип с помощью ref. Следующий код описывает метод, который заменит оригинальную ссылку на объект, и по итогу мы получим абсолютно нового кота, с именем “Banana”. Вместе с ref, ссылка не копируется, а передается ссылка на ссылку.

void ChangeReference(ref Cat cat)
{
    cat = new Cat() { Name = "Banana" };
}

Глава 3: Частые Ошибки при Работе с ref

Код ниже описывает ручное расширение массива, и добавление ещё одной буквы в конец.

private static void Main(string[] args)
{
    char[] letters = new char[] { 'A', 'B', 'C' };
    
    Print(letters);             // A B C
    Add(letters, 'D');
    Print(letters);             // A B C
}

private static void Add(char[] array, char letter)
{
    char[] tempArray = new char[array.Length + 1];

    for (int i = 0; i < array.Length; i++)
    {
        tempArray[i] = array[i];
    }

    int lastIndex = tempArray.Length - 1;
    tempArray[lastIndex] = letter;

    array = tempArray;
}

private static void Print(char[] array)
{
    foreach (var item in array)
    {
        Console.Write($"{item} ");
    }

    Console.WriteLine();
}

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

Решения два:

  • использовать модификатор ref, чтобы изменять не копию ссылки, а оригинальную ссылку. Тогда изменения сохранятся. Вывод будет A B C D, вместо A B C. Решение этим способом будет предоставлено в главе “Упражнения”.

  • использовать return. И возвращать мы будем ссылку на измененный массив char[] tempArray. А снаружи метода эти возвращаемые данные используем, чтобы изменить ссылку на массив вот так:

private static void Main(string[] args)
{
    char[] letters = new char[] { 'A', 'B', 'C' };
    
    Print(letters);             // A B C
    letters = Add(letters, 'D');
    Print(letters);             // A B C D
}

private static char[] Add(char[] array, char letter)
{
    char[] tempArray = new char[array.Length + 1];

    for (int i = 0; i < array.Length; i++)
    {
        tempArray[i] = array[i];
    }

    int lastIndex = tempArray.Length - 1;
    tempArray[lastIndex] = letter;

    return tempArray;
}

Заметьте, изменения мы внесли в трёх местах:

  • строка 6 - letters = Add(letters, 'D'); Присваиваем ссылку, которую возвращает метод.

  • строка 10 - private static char[] Add(char[] array, char letter) Изменяем возвращаемый тип с void на char[].

  • строка 22 - return tempArray; Возвращаем ссылку на расширенный массив.


Глава 4: Ключевые Слова out и in: Глубже в понимание передачи параметров

Немного расширим понимание передачи параметров с использованием ключевых слов out и in. Объясним, как эти инструменты делают код более гибким и удобным для использования.

Фактически, эти два ключевых слова передают переменную по ссылке, но у них есть ещё задачи. К примеру out будет требовать, чтобы мы в обязательном порядке инициализировали переменную с этим модификатором внутри нашего метода. Иначе возникнет ошибка компиляции, требующая ее инициализировать.

Ключевое слово in - наоборот, не разрешит нам менять эту переменную внутри метода. Позже в язык добавили синтаксис, делающий ту же самую работу - ref readonly. У него есть свои особенности, но в основном они делают одну и ту же работу.


Пример применения out

Частый пример использования out:

class Program
{
    private static void Main(string[] args)
    {
        Database database = new Database();
        string nameToFind = "Иван";

        if (database.TryFind(nameToFind, out string result))
        {
            Console.WriteLine($"{nameToFind} найден. Результат: {result}");
            // Результат: Иван
        }
        else
        {
            Console.WriteLine($"{nameToFind} не оказалось в коллекции. Результат: {result}");
            // Результат:  (здесь будет просто пустая строка, так как мы присваивали default)
        }
    }

}

class Database
{
    private string[] _names;

    public Database()
    {
        _names = ["Павел", "Иван", "Николай"];
    }

    public bool TryFind(string nameToFind, out string result)
    {
        foreach (var name in _names)
        {
            if (nameToFind == name)
            {
                result = name;
                return true;
            }
        }

        result = default;
        return false;
    }
}

Здесь мы создали класс Database, который будет хранить массив имён. В нём объявлен метод TryFind, который как раз и использует модификатор out.

Похожие примеры реализации вы можете найти в различных типах данных, по типу статического метода TryParse у int. Вот некоторые из них:

  • У целочисленного типа int - int.TryParse(string value, out int result);

  • У типа перечисления Enum - Enum.TryParse(string value, out TEnum result);

  • У словаря Dictionary - TryGetValue(T key, out T value);

  • У очереди Queue - TryPeek(out T result) и TryDequeue(out T result);

Конкретно в нашем случае, есть одна весомая причина использовать out - Мы не хотим возвращать null напрямую в return. В такой ситуации, нам бы пришлось каждый раз проверять результат выполнения метода на null. К примеру будь он таким: public string TryFind(string name). Проверка на null будет дублироваться везде, где мы вызовем этот метод.

В нашем коде, в return возвращается bool, и мы смогли просто вызвать этот метод в внутри условной конструкции if. И сразу можем принимать решения, основываясь на значении аргумента с out.

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

  • Если вернулось false, значит объект равен null, и эту ситуацию мы обрабатываем в else.


Пример применения in

Что по поводу in, вы его вряд ли скоро примените. У него есть свои нюансы, но в основном, это просто передача значения по ссылке, с поправкой на только для чтения. Менять внутри метода эту переменную не получится. Хотя в случае со значимыми типами - мы сэкономим память на копировании, так как передали ссылку, а не копию.

Ниже показаны результаты кода, в котором была создана структура с полем int[] на 100_000 элементов. Выполним с массивом простую операцию, будем считать сумму всех элементов от 0 до 100_000.

На изображении видно, что сначала структура передавалась в метод обычным способом, то есть копировалась. Затем она передавалась по ссылке, через in. Заметьте, что второй способ занял на выполнение операции меньше тиков процессора.

Результат работы с массивом на 100000 элементов типа int

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

Результат работы с массивом на 10 элементов типа int

Код можно посмотреть по ссылке: Example copy-ref.cs


Упражнения

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

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

int a = 0;
object b = a;

a++;

Console.WriteLine(a);
Console.WriteLine(b);

private static void Main(string[] args)
{
    Human human = new Human() { Age = 18 };

    Example(human);
    Console.WriteLine(human.Age);
}

private static void Example(Human human)
{
    human.Age = 0;
    human = null;
    human = new Human() { Age = 1 };
    human.Age = 2;
}

class Human
{
    public int Age;
}

  • передается по ссылке, и ссылка на переменную может быть изменена. Изменение ссылки отразится на оригинальной переменной;

  • передается по ссылке, но передается лишь копия ссылки. Поэтому снаружи сохранятся только изменения состояния переменной. Изменение ссылки никак не отразится на оригинале;

  • передается копия переменной, и изменения внутри метода никак не отразятся на оригинале;

  • никак не может быть изменена, без ref мы не сможем изменить ни состояние, ни ссылку;

  • Чтобы не дублировать проверки на null, если метод может его вернуть;

  • Чтобы передать переменную по ссылке, и изменить ее внутри метода;

  • Он ничем не отличается от in или ref, это просто передача по ссылке;

private static void Main(string[] args)
{
    int number = 0;

    number = MethodRef(ref number);
    Console.WriteLine(number);
}

private static int MethodRef(ref int number)
{
    number = 2;
    return 10;
}

  • В куче хранятся именно ссылки, поэтому там хранятся ссылочные типы. А в стеке хранятся значения, поэтому там и лежат значимые типы;

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

Ссылки для дальнейшего изучения

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

Система типов C# | Microsoft Learn

default — справочник по C# - C# | Microsoft Learn

Значения по умолчанию типов C# — справка по C# - C# | Microsoft Learn

C# и .NET | Типы значений и ссылочные типы (metanit.com)

Справочник по C#. Типы значений - C# | Microsoft Learn

Ссылочные типы. Справочник по C# - C# | Microsoft Learn

Last updated

Was this helpful?