Свойства, автосвойства и инкапсуляция
«Чем свойство отличается от поля?» Ответ «у свойства есть 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даёт бесконечную рекурсию и падение процесса.