Свойства, автосвойства и инкапсуляция

«Чем свойство отличается от поля?» Ответ «у свойства есть get и set» интервьюер услышит от каждого второго. Хороший кандидат объясняет, почему это вообще важно.

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

Зачем вообще прослойка

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

using System;

class Account
{
    private decimal _balance;

    public decimal Balance
    {
        get { return _balance; }
        private set
        {
            if (value < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(value), "Баланс не может быть отрицательным");
            }
            _balance = value;
        }
    }

    public void Deposit(decimal amount)
    {
        Balance += amount;
    }

    public bool TryWithdraw(decimal amount)
    {
        if (amount > _balance)
        {
            return false;
        }
        Balance -= amount;
        return true;
    }
}

class Program
{
    static void Main()
    {
        var account = new Account();

        account.Deposit(100);
        Console.WriteLine(account.Balance);

        Console.WriteLine(account.TryWithdraw(30));
        Console.WriteLine(account.Balance);

        Console.WriteLine(account.TryWithdraw(1000));
        Console.WriteLine(account.Balance);
    }
}

Результат:

100
True
70
False
70

Здесь сразу три идеи, за которые дают плюс. Первая: асимметричный доступget публичный, set приватный. Полем такое не выразить: readonly-поле нельзя менять даже изнутри после конструктора, а обычное публичное — можно менять снаружи. Вторая: value — неявный параметр сеттера, его никто не объявляет. Третья: инвариант («баланс не бывает отрицательным») живёт в одном месте, а не размазан по вызывающему коду.

Автосвойства: тот же контракт, ноль церемоний

Если валидации нет, писать явное поле скучно. { get; set; } — и компилятор сам создаст скрытое поле.

using System;

class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int BirthYear { get; set; }

    // вычисляемое свойство: только get, значение считается при каждом обращении
    public string FullName => FirstName + " " + LastName;

    public int AgeAt(int year) => year - BirthYear;
}

class Program
{
    static void Main()
    {
        var person = new Person
        {
            FirstName = "Ада",
            LastName = "Лавлейс",
            BirthYear = 1815
        };

        Console.WriteLine(person.FullName);
        Console.WriteLine(person.AgeAt(1852));
    }
}

Результат:

Ада Лавлейс
37

Заметьте разницу между FullName и AgeAt. Свойство — это про состояние: дёшево, без побочных эффектов, при двух вызовах подряд возвращает одно и то же. Метод — это про действие: может быть долгим, может принимать аргументы. Если ваш «геттер» ходит в базу — сделайте его методом. Это отдельный вопрос-ловушка: «когда свойство, а когда метод?»

init-only: неизменяемость без боли (C# 9)

Классическая дилемма: хочется объект, который нельзя менять после создания, но и конструктор на 12 параметров писать не хочется. С C# 9 есть аксессор init: присвоить можно только при создании объекта.

class Order
{
    public string Id { get; init; }        // задать можно только при создании
    public DateTime CreatedAt { get; init; }
    public string Comment { get; set; }    // а это менять можно всегда
}

var order = new Order
{
    Id = "A-100",
    CreatedAt = DateTime.UtcNow
};

order.Comment = "срочно";   // OK
// order.Id = "A-101";      // ОШИБКА КОМПИЛЯЦИИ: init-only свойство

Разница с readonly-полем тонкая, но её любят спрашивать: readonly-поле можно задать только в конструкторе (или инициализаторе), а init работает ещё и в объектном инициализаторе — то есть снаружи, но ровно один раз, в момент создания. Это и позволило появиться record-типам с их «неизменяемыми по умолчанию» свойствами.

Почему публичные поля — плохая практика

Стандартный аргумент «это нарушает инкапсуляцию» звучит абстрактно. Вот конкретика, которая работает на собеседовании:

  • Нельзя добавить логику потом. Понадобилась валидация, логирование или ленивая загрузка — придётся менять поле на свойство и трогать API.
  • Это ломает бинарную совместимость. Чтение поля компилируется в ldfld, чтение свойства — в call get_X. Замена поля на свойство ломает сборки, скомпилированные против старой версии: их придётся пересобирать. Для библиотеки это критично.
  • Поле нельзя передать по ref/out… а свойство — нельзя вообще. Код вида int.TryParse(s, out obj.Field) перестанет компилироваться после превращения поля в свойство.
  • Интерфейсы не умеют требовать поля. В контракт можно вынести только свойство. Хотите, чтобы тип реализовал IHasName, — это будет string Name { get; }.
  • Полей нет в мире инструментов. Data binding, сериализаторы, ORM, валидаторы, маппиры по умолчанию смотрят на свойства.
  • Поле нельзя сделать виртуальным. Свойство можно объявить virtual или abstract и переопределить в наследнике.

Приятный бонус: за прослойку вы не платите производительностью — тривиальный аксессор JIT встраивает (inline), и в машинном коде остаётся тот же доступ к памяти, что и у поля.

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

Свойство — синтаксический сахар над методами. Компилятор генерирует get_Balance() и set_Balance(decimal value) с флагом specialname плюс запись .property в метаданных, чтобы IDE и рефлексия знали: эти два метода — на самом деле одно свойство.

Для автосвойства public string Name { get; set; } компилятор дополнительно создаёт скрытое поле с непроизносимым именем <Name>k__BackingField — угловые скобки в имени гарантируют, что вы не наткнётесь на него из C#. Никакой магии: то же поле, просто написанное за вас.

init — это по-прежнему обычный set_-метод, но помеченный специальным модификатором IsExternalInit. Никакой рантайм-защиты нет: правило «только при создании» проверяет компилятор. Именно поэтому init — это про удобство и намерение, а не про безопасность в духе readonly-полей.

И ещё одна классика, которую иногда дают прямо кодом: что будет, если сеттер присвоит сам себе?

private int _count;

public int Count
{
    get { return _count; }
    set { Count = value; }   // ОПЕЧАТКА: Count вместо _count
}

Сеттер вызывает сам себя — бесконечная рекурсия и StackOverflowException, который в .NET нельзя даже поймать: процесс просто падает. Классическая опечатка, и её обожают показывать на скриншотах.

Таблица-шпаргалка

Что нужноКак писать
Простое хранилище значенияpublic int Age { get; set; }
Читать всем, писать только классуpublic int Age { get; private set; }
Задать один раз при созданииpublic int Age { get; init; } (C# 9)
Задать только в конструктореpublic int Age { get; }
Вычислять на летуpublic string Full => A + " " + B;
Проверять значение при записиявное свойство с полем и if в set

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

  • «Свойство — это поле с get и set». Нет: это методы, притворяющиеся полем. Отсюда и виртуальность, и место в интерфейсах, и ломающаяся бинарная совместимость.
  • «Свойства медленнее полей». Заученный миф. Тривиальные аксессоры JIT инлайнит — разницы нет. Отвечайте так: «в теории лишний вызов, на практике его нет».
  • Считать init синонимом readonly. init проверяется компилятором и допускает объектный инициализатор; readonly-поле — только конструктор.
  • Коллекция с публичным сеттером. public List<string> Items { get; set; } позволяет подменить весь список снаружи. Правильнее { get; } с инициализацией в конструкторе.
  • Геттер с побочным эффектом. Свойство, которое лезет в сеть или меняет состояние, — источник неотлаживаемых багов: отладчик вызывает геттеры сам, просто чтобы показать значение.
  • Автосвойства ради автосвойств. Класс из десяти { get; set; } — это не инкапсуляция, а публичные поля с лишними скобками. Инкапсуляция — про инварианты, а не про синтаксис.

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

  • Свойство компилируется в методы get_X/set_X; автосвойство добавляет скрытое поле <X>k__BackingField.
  • value — неявный параметр сеттера. Аксессоры могут иметь разный доступ: { get; private set; }.
  • init (C# 9) даёт «записать при создании и больше никогда» — основа неизменяемых моделей и record.
  • Публичные поля плохи не «по канону», а по делу: нельзя добавить логику, нельзя вынести в интерфейс, нельзя сделать виртуальным, и замена ломает совместимость.
  • Свойство — про состояние (дёшево, без сюрпризов), метод — про действие. Долгие вычисления прячьте в метод.
  • Опечатка set { Count = value; } вместо _count даёт бесконечную рекурсию и падение процесса.
Проверьте себя
1. Что произойдёт при выполнении: private int _count; public int Count { get { return _count; } set { Count = value; } } и вызове obj.Count = 5; ?
AЗначение 5 запишется в _count — компилятор поймёт, что имелось в виду
BКомпилятор выдаст ошибку: свойство не может ссылаться на себя
CСеттер рекурсивно вызовет сам себя, и программа упадёт со StackOverflowException
DНичего не произойдёт: присваивание будет проигнорировано
2. Свойство объявлено как public string Id { get; init; }. Где ему можно присвоить значение?
AТолько в конструкторе класса, как readonly-полю
BВ конструкторе и в объектном инициализаторе при создании объекта
CВ любом месте, где доступен объект — init только подсказка IDE
DНигде: init делает свойство доступным только для чтения