Структура классов и объектов; инкапсуляция; конструкторы и деструкторы¶
Об этом материале
Объём: четыре темы, ориентировочно неделя — 3–4 часа чтения, 3–4 часа кода и час письменных ответов. Отчётность: письменный отчёт и код практического задания в репозитории. Язык примеров: C#; параллели на других языках — там, где они показывают понятие с новой стороны.
Материал для самостоятельной работы после лекции 1. Лекция дала минимум, на котором можно работать; здесь — то, что отличает человека, который «знает ООП», от человека, который понимает, во что оно превращается в рантайме и почему в разных языках выглядит по-разному. Язык примеров — C# по знакомости; другие языки появляются там, где показывают понятие с новой стороны. Ориентировочно неделя: 3–4 часа чтения, 3–4 часа кода, час на письменные ответы.
Что входит в этот материал¶
- Из чего физически состоит объект
- Инкапсуляция глубже, чем private
- Конструкторы: порядок, ловушки, альтернативы
- Деструкторы, финализаторы и детерминированное освобождение
Что уже было в лекции¶
Класс — описание, объект — экземпляр; инкапсуляция — сокрытие представления ради защиты инвариантов; конструктор инициализирует, деструктора в C# в C++-смысле нет. Дальше — детали, которые определяют, как это работает на самом деле.
Как работать с этим документом¶
- Читайте не подряд, а по темам: у каждой есть вопрос, на который она отвечает.
- Каждый блок кода запускайте, а не просматривайте. Особенно там, где результат контринтуитивен, — сама постановка эксперимента и есть обучение.
- В конце — письменные вопросы. Отвечайте текстом, а не «понял». Если ответ не формулируется в три предложения, тема не закрыта.
- Инструменты: sharplab.io (смотреть, во что компилируется C#),
профилировщик или
dotnet-counters, любой дизассемблер IL.
Тема 1. Из чего физически состоит объект¶
Вопрос: что лежит в памяти, когда вы написали new Order()?
В CLR у объекта в куче есть заголовок (синхроблок для lock и хеш-кода) и указатель на
таблицу методов типа, и только потом поля. Отсюда следуют вещи, которые иначе выглядят
магией: почему lock (obj) работает на любом объекте, почему пустой класс занимает не ноль
байт, почему у struct нет виртуальных вызовов без упаковки.
Копайте:
- Ссылочные и значимые типы.
classпротивstruct: где живут, как копируются, что такое упаковка (boxing) и почемуobject o = 42;— это аллокация. readonly structиrecord struct— почему компилятор делает защитные копии у не-readonlyструктур и как это бьёт по производительности.record— что именно генерируется:Equals,GetHashCode,ToString,Deconstruct,with-выражение. Посмотрите на sharplab, во что разворачиваетсяrecord Point(int X, int Y).- Семантика равенства. Ссылочное равенство против структурного; контракт
«равные объекты имеют равный хеш-код»; что происходит, если положить в
Dictionaryобъект и потом поменять поле, входящее в хеш-код.
// Классический капкан: мутабельный ключ
var key = new List<int> { 1 };
var dict = new Dictionary<List<int>, string>(/* компаратор по содержимому */);
// после мутации key объект «теряется» в словаре — он лежит не в той корзине
Другие языки. В Python объект — это словарь атрибутов, и добавить поле можно на лету;
__slots__отключает словарь и делает объекты компактными — редкий случай, когда в динамическом языке структура объекта задаётся явно. В JS объекты — это прототипные цепочки, и «класс» является синтаксическим сахаром надprototype. В Rust у типов нет заголовка вообще:struct— это ровно её поля, а динамическая диспетчеризация появляется только при явномdyn Trait. Полезно сравнить: C# платит за объект памятью всегда, Rust — только когда вы попросили.
Тема 2. Инкапсуляция глубже, чем private¶
- Все модификаторы доступа C# и когда какой нужен:
private,protected,internal,protected internal(или),private protected(и),file(C# 11). Умение объяснить разницу между двумя средними — хороший маркер зрелости. - Самоинкапсуляция поля — обращаться к собственному полю через свойство даже внутри класса. Когда это оправдано (ленивая инициализация, валидация), когда лишнее.
- Инкапсуляция коллекций — почему возвращать
List<T>наружу нельзя;IReadOnlyList<T>,ImmutableArray<T>,AsReadOnly(), и чем «только для чтения» отличается от «неизменяемого». - Инварианты и конструирование. Объект не должен существовать в невалидном состоянии
даже долю секунды: отсюда параметризованные конструкторы вместо сеттеров,
init-свойства, фабричные методы с валидацией и приватный конструктор. - Публичный контракт как обязательство. Всё, что вы сделали
public, вы обещали поддерживать. Отсюда практика: начинать с самого закрытого модификатора и открывать по необходимости, а не наоборот.
// Инвариант держится конструктором, а не дисциплиной вызывающего
public sealed class DateRange
{
public DateOnly From { get; }
public DateOnly To { get; }
private DateRange(DateOnly from, DateOnly to) => (From, To) = (from, to);
public static DateRange Create(DateOnly from, DateOnly to) =>
to < from ? throw new ArgumentException("Конец раньше начала") : new DateRange(from, to);
}
Другие языки. В Python приватности нет вообще — только соглашение
_и name mangling__; вся инкапсуляция держится на культуре, и@propertyпозволяет превратить поле в вычисляемое без изменения кода клиентов (в C# для этого поле изначально должно было быть свойством — важное практическое отличие). В JS#field— настоящий приватный слот, недоступный даже через рефлексию. В Go доступ определяется регистром имени:Name— экспортируемое,name— нет, а единицей сокрытия является пакет, а не тип.
Тема 3. Конструкторы: порядок, ловушки, альтернативы¶
- Точный порядок инициализации в C#: инициализаторы полей производного класса → вызов базового конструктора → тело базового → тело производного. Проверьте экспериментом, большинство ошибается.
- Виртуальный вызов в конструкторе — почему это капкан: переопределённый метод выполнится до того, как поля наследника проинициализированы.
public class Base
{
public Base() => Init(); // вызывается ДО конструктора наследника
protected virtual void Init() { }
}
public class Derived : Base
{
private readonly string _name = "готово";
protected override void Init() => Console.WriteLine(_name); // напечатает null
}
- Статический конструктор — когда вызывается, гарантии потокобезопасности,
beforefieldinitи почему статический конструктор может незаметно ухудшить производительность. - Первичные конструкторы (C# 12) и чем они отличаются от конструкторов
record. - Альтернативы конструктору: фабричный метод (говорящее имя, может вернуть подтип
или закешированный объект), Builder для сложной сборки,
required-свойства и объектные инициализаторы. Понять критерий выбора, а не выучить список. - Исключения в конструкторе — объект не создан; что при этом происходит с уже захваченными ресурсами.
Другие языки. В Python конструирование разделено на два шага:
__new__создаёт объект,__init__его инициализирует — и именно перехват__new__даёт синглтон или пул объектов без всяких статических свойств. В Kotlin есть первичный и вторичные конструкторы плюс блокinit, аdata classдаётcopy()— прямой аналогwithв C#. В Rust конструкторов нет как понятия: есть соглашениеType::new()— обычная ассоциированная функция, и это отлично показывает, что конструктор является не необходимостью, а решением языка.
Тема 4. Деструкторы, финализаторы и детерминированное освобождение¶
Самая недооценённая подтема раздела — и самая частая на собеседованиях.
- RAII в C++: деструктор вызывается детерминированно при выходе из области видимости; на этом строится вся работа с ресурсами. В управляемых языках такой гарантии нет.
- Финализатор в C# (
~Class()): когда вызовется — неизвестно, вызовется ли вообще — не гарантировано; объект с финализатором переживает лишнюю сборку и попадает в очередь финализации. Правило: финализатор нужен только для неуправляемых ресурсов, и почти всегда вместо него нуженSafeHandle. - Паттерн
IDisposableцеликом:Dispose(bool disposing),GC.SuppressFinalize,using-объявления,IAsyncDisposableиawait using. - Кто владеет ресурсом. Главный вопрос: если ваш класс принял чужой
Streamв конструктор — должен ли он его закрывать? (Ответ: нет, если не получил владение явно.) Это ровно различие композиции и агрегации из лекции 1, только с последствиями в рантайме. - Поколения GC в общих чертах: почему короткоживущие объекты дёшевы, а «почти вечные» —
дороги, и почему
GC.Collect()в прикладном коде почти всегда ошибка.
public sealed class TempFile : IDisposable
{
private readonly string _path = Path.GetTempFileName();
private bool _disposed;
public string Path => _disposed ? throw new ObjectDisposedException(nameof(TempFile)) : _path;
public void Dispose()
{
if (_disposed) return; // Dispose обязан быть идемпотентным
File.Delete(_path);
_disposed = true;
}
}
using var temp = new TempFile(); // освобождение детерминировано
Другие языки. Python освобождает по подсчёту ссылок, поэтому
__del__часто (но не всегда — циклы!) срабатывает сразу; штатный способ всё равноwithи протокол__enter__/__exit__. В Go естьdefer, выполняющий отложенное действие при выходе из функции — читается как «закрою, когда закончу», и это, пожалуй, самая наглядная форма. В Rust освобождение встроено в систему владения: значение уничтожается в момент выхода из области видимости,Dropвызывается детерминированно, а компилятор не даст использовать освобождённое. Сравнение C++/Rust/C#/Python на этой теме объясняет, почемуusingвыглядит именно так.
Вопросы для самопроверки¶
- Что напечатает пример с виртуальным вызовом в конструкторе и почему?
- Чем
protected internalотличается отprivate protected? - Когда
readonly structбыстрее обычнойstructи почему? - Ваш класс получил
HttpClientчерез конструктор. Должен лиDisposeего закрывать? Как выразить владение явно? - Зачем нужен
GC.SuppressFinalizeи что произойдёт, если его забыть? - Почему
recordне решает проблему инкапсуляции сам по себе?
Практика¶
Реализовать тип Money (сумма + валюта) так, чтобы: невалидное состояние было невыразимо;
операции +, -, сравнение работали только в пределах одной валюты; тип был значимым
и не аллоцировал; равенство и хеш-код были согласованы. Затем — TempDirectory с корректным
IDisposable, тестом на идемпотентность Dispose и осознанным ответом (в README), нужен ли
там финализатор.
Литература¶
- Э. Гамма и др. Приёмы объектно-ориентированного проектирования — глоссарий и глава 1: объект, класс, инкапсуляция, операция, конструктор.
- С. Макконнелл. Совершенный код — главы о классах, инкапсуляции и защитном программировании.
- Документация .NET:
IDisposable, финализаторы,SafeHandle,record,readonly struct— первоисточник, а не пересказ в блогах.
Как я буду это проверять¶
Письменный отчёт: ответы на вопросы для самопроверки и вывод по практике, плюс код в репозитории. Критерий — не «сделано», а объяснено: почему выбран такой вариант, чем платим, что сломается при изменении требования. Формулировка «так принято» не засчитывается: принято кем и почему.