Типы значений и ссылочные типы

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

Тип значения (value type) хранит данные прямо в переменной. Ссылочный тип (reference type) хранит только адрес — сами данные лежат отдельно. При передаче в метод C# всегда копирует содержимое переменной: у одних это само значение, у других — всего лишь ссылка на него.

Вопрос: что выведет этот код?

Прочитайте и ответьте про себя, прежде чем нажимать «Запустить». Именно так вас и попросят на интервью — с листком бумаги, без IDE.

using System;

class Box
{
    public int Value;
}

class Program
{
    static void ChangeInt(int n)
    {
        n = 100;
    }

    static void ChangeBox(Box b)
    {
        b.Value = 100;
    }

    static void ReplaceBox(Box b)
    {
        b = new Box { Value = 999 };
    }

    static void Main()
    {
        int number = 1;
        ChangeInt(number);
        Console.WriteLine(number);

        Box box = new Box { Value = 1 };
        ChangeBox(box);
        Console.WriteLine(box.Value);

        ReplaceBox(box);
        Console.WriteLine(box.Value);
    }
}

Результат:

1
100
100

Почему так

1. int не изменился — потому что уехала копия

int — тип значения. В переменной number лежит само число 1. Когда мы зовём ChangeInt(number), в параметр n копируется четвёрка байт. Метод меняет свою копию, копия умирает вместе с кадром метода, оригинал не в курсе. Вывод — 1.

2. Поле объекта изменилось — потому что копия ссылки указывает туда же

Box — класс, то есть ссылочный тип. В переменной box лежит не объект, а адрес объекта в куче. При вызове ChangeBox(box) копируется адрес — маленькое число (8 байт на 64-битной платформе). Но указывает эта копия на тот же самый объект. Строчка b.Value = 100 идёт по адресу и правит поле оригинального объекта. Вывод — 100.

3. Замена объекта не видна снаружи — вот где ломаются кандидаты

ReplaceBox делает b = new Box { Value = 999 }. Здесь мы меняем не объект, а переменную b — то есть локальную копию адреса. Она начинает указывать на новый объект, а box в Main по-прежнему указывает на старый. Вывод — снова 100, а не 999.

Отсюда правильная формулировка, которую ждёт интервьюер: в C# по умолчанию всё передаётся по значению; для ссылочного типа «значением» является ссылка. Фраза «классы передаются по ссылке» — популярная неточность, и третья строка вывода — её опровержение.

А как передать по-настоящему по ссылке

Для этого есть модификаторы. С ref в метод уезжает не копия переменной, а сама переменная (её адрес):

using System;

class Box
{
    public int Value;
}

class Program
{
    static void ReplaceBox(ref Box b)
    {
        b = new Box { Value = 999 };
    }

    static void Bump(ref int n)
    {
        n++;
    }

    static void Main()
    {
        Box box = new Box { Value = 1 };
        ReplaceBox(ref box);
        Console.WriteLine(box.Value);

        int number = 41;
        Bump(ref number);
        Console.WriteLine(number);
    }
}

Результат:

999
42
МодификаторСмысл
refпеременная передаётся по ссылке, до вызова должна быть инициализирована
outто же, но инициализировать обязан сам метод (классика — int.TryParse)
inпо ссылке, но только для чтения — чтобы не копировать большую структуру

Структуры: копируется всё содержимое

Со struct та же история, что с int, — при присваивании копируются все поля:

using System;

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

class Program
{
    static void Main()
    {
        Point a = new Point();
        a.X = 1;
        a.Y = 2;

        Point b = a;
        b.X = 42;

        Console.WriteLine($"a.X = {a.X}, b.X = {b.X}");
    }
}

Результат:

a.X = 1, b.X = 42

Будь Point классом, обе переменные смотрели бы на один объект и вывелось бы a.X = 42. Одна буква в объявлении типа — и поведение программы противоположное.

Ловушка со string

Любимый добивающий вопрос: «А string — это value или reference?»

using System;

class Program
{
    static void Shout(string s)
    {
        s = s.ToUpper();
    }

    static void Main()
    {
        string name = "codechick";
        Shout(name);
        Console.WriteLine(name);
    }
}

Результат:

codechick

stringссылочный тип, но неизменяемый: ToUpper() не правит строку на месте, а создаёт новую. Метод переприсвоил свою копию ссылки — снаружи ничего не поменялось. Строка ведёт себя «как значение» не потому, что она value type, а потому что immutable.

Как это работает под капотом

Учебная формула «значимые типы — в стеке, ссылочные — в куче» верна лишь наполовину, и хороший интервьюер это проверит. Точнее так:

  • Объект класса всегда живёт в куче (managed heap), а переменная-ссылка — там, где объявлена.
  • Значимый тип живёт там, где живёт его владелец. Локальная переменная int — да, обычно в стеке (или прямо в регистре процессора). Но поле int внутри класса лежит в куче, внутри объекта. Элементы int[] — тоже в куче. Структура, захваченная лямбдой или живущая в async-методе, уезжает в объект-замыкание или в машину состояний — то есть снова в кучу.
  • Стек и куча — это деталь реализации JIT и рантайма, а не буква спецификации C#. Спецификация говорит про семантику копирования и время жизни, а не про регион памяти.

Правильный ответ на собеседовании звучит так: «Разница не в стеке и куче, а в том, что копируется при присваивании: у значимых типов — данные, у ссылочных — адрес». Это сразу отличает зубрилу от человека, который понимает модель памяти.

Частые ошибки на собеседовании

  • «Классы передаются по ссылке». Нет: по значению передаётся копия ссылки. Разницу видно, только когда метод присваивает параметру новый объект.
  • «Значимые типы всегда в стеке». Нет: поля, элементы массивов, замыкания и async утаскивают их в кучу.
  • Путать ref и out. out обязывает метод присвоить значение и не требует инициализации до вызова.
  • Считать string значимым типом. Он ссылочный, просто неизменяемый.
  • Забывать про null. Ссылочная переменная может не указывать никуда; у int такого состояния нет — нужен Nullable<int> (он же int?).

Итоги-шпаргалка

  • Значимые типы (int, double, bool, char, enum, любой struct, включая DateTime, Guid, decimal) хранят данные; ссылочные (class, interface, delegate, массивы, string, object) хранят адрес.
  • В C# всё передаётся по значению — вопрос лишь в том, что является значением: данные или ссылка.
  • Изменение полей объекта видно вызывающему коду; переприсваивание параметра — нет.
  • Настоящая передача по ссылке — только через ref / out / in.
  • Присваивание структуры копирует все её поля; присваивание объекта копирует один адрес.
  • string — ссылочный и неизменяемый: любая «модификация» рождает новую строку.
Проверьте себя
1. struct Point { public int X; } — переменные Point a = new Point(); a.X = 1; Point b = a; b.X = 42; Что выведет Console.WriteLine(a.X)?
A1
B42
C0
DОшибка компиляции
2. Метод void Replace(Box b) { b = new Box { Value = 999 }; } вызван для объекта box со значением 1. Что покажет box.Value после вызова?
A999, потому что классы передаются по ссылке
B1, потому что метод переприсвоил свою копию ссылки
Cnull, объект стал недоступен
DОшибка компиляции: параметр нельзя переприсваивать