База знаний ЯЮниор
  • C#
    • 🗃️Ссылочные и значимые типы в C# Ключевые слова ref, out, in Что нужно знать новичку
    • 📋Использование констант
    • 🔖Snippet или фрагмент кода
    • 📝Пустые строки
    • 🛡️Инкапсуляция
  • Unity
    • 🚶‍♂️Управление Параметрами Аниматора
    • ⚙️Динамическое изменение объектов
      • 🛠️Создание объектов
      • 🪛Изменение объектов
      • 🪚Добавление и изменение компонентов
      • 🔥Удаление объектов и компонентов
    • 🔊События
      • 🧬Параметризация
      • 🔗Совмещение событий
      • 📡Action и UnityAction
      • 🕹️UnityEvent
    • 🔌Подключение среды разработки к Unity
    • ⌚Корутины
      • 🪄Управление корутинами
      • ⏰Yield Instruction
      • 🕵️‍♂️Как устроены корутины?
  • Git и GitHub
    • 🗃️Git
    • 🗄️GitHub
    • 🖥️GitHub Desktop
Powered by GitBook
On this page
  • Глава 1. Значимые и ссылочные типы в С#
  • Почему у нас есть два типа памяти?
  • Пример работы со Значимыми типами
  • Пример работы с Ссылочными типами
  • Глава 2. Применение ref к Значимым и Ссылочным Типам
  • Применение ref к значимым типам (struct)
  • Применение ref к ссылочным типам (class)
  • Глава 3: Частые Ошибки при Работе с ref
  • Глава 4: Ключевые Слова out и in: Глубже в понимание передачи параметров
  • Пример применения out
  • Пример применения in
  • Упражнения
  • Ссылки для дальнейшего изучения

Was this helpful?

  1. C#

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

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

NextИспользование констант

Last updated 1 year ago

Was this helpful?

Глава 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. Снаружи метода данные не сохранятся, просто потому что мы работали с копией, но не с оригиналом. Внутри метода теперь новый объект, занимающий точно столько же памяти.

Да, внутри метода мы заметим изменения в объекте dog и сможем этим воспользоваться. Например, сможем вывести их в теле метода через команду -Console.WriteLine(dog.Name); и увидим вывод “ChangedName”.

Теперь немножко изменим наш пример, чтобы разобраться с модификатором 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 мы должны модификатор ref к параметру Dog dog при объявлении метода. Также мы обязаны указывать этот модификатор и при вызове этого метода.

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

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

В программировании мы не пользуемся ref ради оптимизации в каждом методе, просто потому что становится тяжелее следить за долгоживущими переменными, которые могут меняться множество раз. Хотя где-то такой оптимизации обязательно найдется место. Но это бьёт по читаемости кода.

Пару слов про изменение значения переменной, если она передана без 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" };
}

При работе с ref - если внутри метода написать cat = null; Мы оборвём оригинальную ссылку на кота. И в следующий раз, при попытке доступа к cat - получим NullReferenceException.


Глава 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 = tempArray;ссылка на массив всё таки изменилась.

Мы уже знаем как ведут себя ссылочные типы, при передаче в методы - они передают туда копию своей ссылки. Поэтому - изменения на 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. Заметьте, что второй способ занял на выполнение операции меньше тиков процессора.

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

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

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

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


Упражнения

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

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

1 - Что будет выведено в консоль?

Примечание: В этой задаче кроется ещё одна тема - boxind-unboxing, но для ее решения достаточно знать материал статьи. Здесь важно понимание того, что передастся в ссылочный тип object b - копия значения a, или ссылка на переменную a?

int a = 0;
object b = a;

a++;

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

2 - Что будет выведено в консоль?

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;
}

3 - Как исправить код, показанный в главе “Частые ошибки при работе с ref”, применив модификатор ref? Измените код, и убедитесь, что всё работает правильно.

4 - Закончите фразу: “При передаче в метод переменной ссылочного типа, она…”:

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

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

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

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

5 - Для чего мы можем применить модификатор out?

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

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

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

6 - Какой будет результат вывода в консоль?

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;
}

7 - Почему так вышло, что ссылочные типы обязательно надо хранить в куче, а значимые в стеке?

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

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

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

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

Код можно посмотреть по ссылке:

🗃️
Система типов 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
Example copy-ref.cs
Хранение значимых типов
Хранение ссылочных типов
Логика ссылочных операций
Результат работы с массивом на 100000 элементов типа int
Результат работы с массивом на 10 элементов типа int