Ссылочные и значимые типы в C# Ключевые слова ref, out, in Что нужно знать новичку
Автор: Орц Орцхоев
Last updated
Was this helpful?
Автор: Орц Орцхоев
Last updated
Was this helpful?
В современном мире программирования, где выбор типа данных влияет на производительность, понимание различий между значимыми и ссылочными типами в языке 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. Класс, интерфейс, объект, строка и делегат являются примерами ссылочных типов.
Здесь мы объявили две переменные значимого типа a
и b
.
Затем провели операцию присваивания, и переменной b
присвоили значение переменной a
. Переменная b
получила копию значения от a
.
И наконец, меняя значение переменной a
, на переменную b
это не возымеет эффекта. При присваивании b = a;
мы не получили ссылку на ячейку памяти a
, а получили копию. По этой причине, при операции a++
изменилось лишь значение, хранящееся в a
.
Со значимыми типами разобрались, они при операции присваивания ( = ) отдают копию своего значения. Теперь поговорим о ссылочных типах, как ведут себя они? Ниже пример кода, демонстрирующий все возможные ситуации, о которых нужно знать для начала:
Определение метода Print:
Для начала мы объявили два массива - переменные с ссылочным типом данных:
Следом мы смотрим на поведение: при присваивании одного массива другому, пока ничего необычного не заметим, просто в arrayB
лежат те же данные, что и в arrayA
.
Самое интересное начинается, когда мы начинаем менять состояние одной из переменных ссылочного типа. После операции arrayB[0] = 10;
данные изменятся и в arrayA
. Происходит это потому, что при операции arrayB = arrayA;
- мы в переменную arrayB
скопировали ссылку на адрес переменной arrayA
. Теперь они указывают на один и тот же массив.
В следующем коде демонстрируется поведение, когда мы обрубаем ( = null
) ссылку у переменной arrayB
на область памяти, где хранится значение. Заметьте, мы обрубили ссылку для одной переменной, но вторая всё ещё указывает на наш массив. Поэтому было важно знать, что передается именно копия ссылки. Одну ссылку обрубили, вторая ещё указывает на массив. Ещё это называют “занулить”.
Вот небольшая демонстрация того, что же произошло в последнем примере кода:
На этом всё, теперь вы знаете базовые принципы работы с ссылочными и значимыми типами. Теперь вы готовы изучить принцип работы ref
, out
и in
.
В этой главе мы исследуем, как ключевое слово ref
влияет на передачу значимых и ссылочных типов в методы. Я предоставлю примеры использования и объясню, почему это важно для эффективной работы с данными.
Мы уже знаем, как ведут себя простые значимые типы в C# (int, float, bool и т.д.). Для следующего примера создадим свой значимый тип struct Dog
. Обычные присвоения мы разобрали, теперь рассмотрим ещё и передачу переменных в метод.
Ниже приведён код, демонстрирующий работу со значимыми типами, у которых мы можем изменять состояние, то есть поля:
Здесь происходит копирование переменной dog
. Снаружи метода данные не сохранятся, просто потому что мы работали с копией, но не с оригиналом. Внутри метода теперь новый объект, занимающий точно столько же памяти.
Да, внутри метода мы заметим изменения в объекте dog
и сможем этим воспользоваться. Например, сможем вывести их в теле метода через команду -Console.WriteLine(dog.Name);
и увидим вывод “ChangedName”.
Теперь немножко изменим наш пример, чтобы разобраться с модификатором ref
.
Для работы с ref
мы должны модификатор ref
к параметру Dog dog
при объявлении метода. Также мы обязаны указывать этот модификатор и при вызове этого метода.
В чём разница? Всё просто, ref
говорит, что будет передана ссылка на ту ячейку памяти, где находится оригинальный объект.
В программировании мы не пользуемся ref
ради оптимизации в каждом методе, просто потому что становится тяжелее следить за долгоживущими переменными, которые могут меняться множество раз. Хотя где-то такой оптимизации обязательно найдется место. Но это бьёт по читаемости кода.
Пару слов про изменение значения переменной, если она передана без ref
. Думаю ответ уже очевиден, но вот пример для наглядности:
Никакое из этих изменений снаружи метода не применится, так как мы работаем с копией.
Пример ниже продемонстрирует работу с ref
с ссылочными типами данных. Здесь мы попробуем поменять состояние объекта (cat.Name = "Changed Name";
), и попробуем поменять ему ссылку (cat = null;
).
В этот раз ситуация немножко меняется, ведь после срабатывания вызова ChangeField(cat);
- поле успешно будет изменено. А после вызова ChangeReference(cat);
изменений никаких не будет.
Похожая ситуация была продемонстрирована в главе “Ссылочные типы”.
Осталось понять, что мы получим, если передать ссылочный тип с помощью ref
.
Следующий код описывает метод, который заменит оригинальную ссылку на объект, и по итогу мы получим абсолютно нового кота, с именем “Banana”.
Вместе с ref
, ссылка не копируется, а передается ссылка на ссылку.
При работе с ref
- если внутри метода написать cat = null;
Мы оборвём оригинальную ссылку на кота. И в следующий раз, при попытке доступа к cat
- получим NullReferenceException.
Код ниже описывает ручное расширение массива, и добавление ещё одной буквы в конец.
Ошибка здесь - считать, что на строке 22
array = tempArray;
ссылка на массив всё таки изменилась.
Мы уже знаем как ведут себя ссылочные типы, при передаче в методы - они передают туда копию своей ссылки. Поэтому - изменения на 22 строке произошли лишь с копией ссылки array, оригинальная ссылка изменений не получила.
Решения два:
использовать модификатор ref
, чтобы изменять не копию ссылки, а оригинальную ссылку. Тогда изменения сохранятся. Вывод будет A B C D
, вместо A B C
. Решение этим способом будет предоставлено в главе “Упражнения”.
использовать return
. И возвращать мы будем ссылку на измененный массив char[] tempArray
. А снаружи метода эти возвращаемые данные используем, чтобы изменить ссылку на массив вот так:
Заметьте, изменения мы внесли в трёх местах:
строка 6
- letters = Add(letters, 'D');
Присваиваем ссылку, которую возвращает метод.
строка 10
- private static char[] Add(char[] array, char letter)
Изменяем возвращаемый тип с void
на char[]
.
строка 22
- return tempArray;
Возвращаем ссылку на расширенный массив.
Немного расширим понимание передачи параметров с использованием ключевых слов out
и in
. Объясним, как эти инструменты делают код более гибким и удобным для использования.
Фактически, эти два ключевых слова передают переменную по ссылке, но у них есть ещё задачи. К примеру out
будет требовать, чтобы мы в обязательном порядке инициализировали переменную с этим модификатором внутри нашего метода. Иначе возникнет ошибка компиляции, требующая ее инициализировать.
Ключевое слово in
- наоборот, не разрешит нам менять эту переменную внутри метода. Позже в язык добавили синтаксис, делающий ту же самую работу - ref readonly
. У него есть свои особенности, но в основном они делают одну и ту же работу.
Частый пример использования out
:
Здесь мы создали класс 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);
Что по поводу in
, вы его вряд ли скоро примените. У него есть свои нюансы, но в основном, это просто передача значения по ссылке, с поправкой на только для чтения.
Менять внутри метода эту переменную не получится. Хотя в случае со значимыми типами - мы сэкономим память на копировании, так как передали ссылку, а не копию.
Ниже показаны результаты кода, в котором была создана структура с полем int[]
на 100_000
элементов. Выполним с массивом простую операцию, будем считать сумму всех элементов от 0
до 100_000
.
На изображении видно, что сначала структура передавалась в метод обычным способом, то есть копировалась. Затем она передавалась по ссылке, через in
. Заметьте, что второй способ занял на выполнение операции меньше тиков процессора.
Стоит отметить, что разница в скорости между передачей по ссылке и передачей копии - не сильно зависит от размеров массива. По сути, при работе с маленькими массивами, выигрыш в производительности намного больше. Для наглядности, взгляните на результат работы с массивом на 10
элементов типа int
:
На разных машинах время может быть разным. Также оно зависит от того, в первый раз был запущен код или нет. Главное, что нужно понять из примера - передача по ссылке работает гораздо быстрее, чем копирование. Ощутимая разница в скорости выполнения будет при малом размере структуры.
Последнее утверждение справедливо только для нашей операции суммирования, на деле код может быть разным.
От размера значимого типа зависит насколько сильно “мусорится” память. Сначала удваивая занимаемое пространство копией, а затем эту копию очищая.
Вы можете себя проверить, насколько хорошо вы усвоили материал, решив упражнения. Не стесняйтесь заглядывать обратно в статью, если не уверены в ответе. Она как раз нужна для того, чтобы дать вам ответы на эти вопросы.
Надеемся, что статья была вам полезна, и вы вынесли для себя что-то новое или закрепили уже имеющиеся знания. Спасибо тебе, читатель, за внимание!
1 - Что будет выведено в консоль?
Примечание: В этой задаче кроется ещё одна тема - boxind-unboxing, но для ее решения достаточно знать материал статьи. Здесь важно понимание того, что передастся в ссылочный тип object b
- копия значения a
, или ссылка на переменную a
?
2 - Что будет выведено в консоль?
3 - Как исправить код, показанный в главе “Частые ошибки при работе с ref”, применив модификатор ref
? Измените код, и убедитесь, что всё работает правильно.
4 - Закончите фразу: “При передаче в метод переменной ссылочного типа, она…”:
передается по ссылке, и ссылка на переменную может быть изменена. Изменение ссылки отразится на оригинальной переменной;
передается по ссылке, но передается лишь копия ссылки. Поэтому снаружи сохранятся только изменения состояния переменной. Изменение ссылки никак не отразится на оригинале;
передается копия переменной, и изменения внутри метода никак не отразятся на оригинале;
никак не может быть изменена, без ref
мы не сможем изменить ни состояние, ни ссылку;
5 - Для чего мы можем применить модификатор out?
Чтобы не дублировать проверки на null
, если метод может его вернуть;
Чтобы передать переменную по ссылке, и изменить ее внутри метода;
Он ничем не отличается от in
или ref
, это просто передача по ссылке;
6 - Какой будет результат вывода в консоль?
7 - Почему так вышло, что ссылочные типы обязательно надо хранить в куче, а значимые в стеке?
В куче хранятся именно ссылки, поэтому там хранятся ссылочные типы. А в стеке хранятся значения, поэтому там и лежат значимые типы;
Куча это большая неупорядоченная область памяти, где всё хранится вразброс, и объекты могут расширяться и уменьшаться динамически. В стеке хранятся данные, которые не могут внезапно изменить занимаемую ими память. Стек представлен как непрерывная упорядоченная память, в которой всё хранится друг за другом. Здесь могут храниться значимые типы, которые в процессе жизни не изменят свой объем занимаемой памяти;
Чтобы глубже погрузиться в тему, рекомендую для начала ознакомиться с информацией по ссылкам ниже:
Код можно посмотреть по ссылке:
100000
элементов типа int
10
элементов типа int