Перейти к содержанию

Структура классов и объектов; инкапсуляция; конструкторы и деструкторы

Об этом материале

Объём: четыре темы, ориентировочно неделя — 3–4 часа чтения, 3–4 часа кода и час письменных ответов. Отчётность: письменный отчёт и код практического задания в репозитории. Язык примеров: C#; параллели на других языках — там, где они показывают понятие с новой стороны.

Материал для самостоятельной работы после лекции 1. Лекция дала минимум, на котором можно работать; здесь — то, что отличает человека, который «знает ООП», от человека, который понимает, во что оно превращается в рантайме и почему в разных языках выглядит по-разному. Язык примеров — C# по знакомости; другие языки появляются там, где показывают понятие с новой стороны. Ориентировочно неделя: 3–4 часа чтения, 3–4 часа кода, час на письменные ответы.

Что входит в этот материал

  1. Из чего физически состоит объект
  2. Инкапсуляция глубже, чем private
  3. Конструкторы: порядок, ловушки, альтернативы
  4. Деструкторы, финализаторы и детерминированное освобождение

Что уже было в лекции

Класс — описание, объект — экземпляр; инкапсуляция — сокрытие представления ради защиты инвариантов; конструктор инициализирует, деструктора в 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 выглядит именно так.


Вопросы для самопроверки

  1. Что напечатает пример с виртуальным вызовом в конструкторе и почему?
  2. Чем protected internal отличается от private protected?
  3. Когда readonly struct быстрее обычной struct и почему?
  4. Ваш класс получил HttpClient через конструктор. Должен ли Dispose его закрывать? Как выразить владение явно?
  5. Зачем нужен GC.SuppressFinalize и что произойдёт, если его забыть?
  6. Почему record не решает проблему инкапсуляции сам по себе?

Практика

Реализовать тип Money (сумма + валюта) так, чтобы: невалидное состояние было невыразимо; операции +, -, сравнение работали только в пределах одной валюты; тип был значимым и не аллоцировал; равенство и хеш-код были согласованы. Затем — TempDirectory с корректным IDisposable, тестом на идемпотентность Dispose и осознанным ответом (в README), нужен ли там финализатор.

Литература

  • Э. Гамма и др. Приёмы объектно-ориентированного проектирования — глоссарий и глава 1: объект, класс, инкапсуляция, операция, конструктор.
  • С. Макконнелл. Совершенный код — главы о классах, инкапсуляции и защитном программировании.
  • Документация .NET: IDisposable, финализаторы, SafeHandle, record, readonly struct — первоисточник, а не пересказ в блогах.

Как я буду это проверять

Письменный отчёт: ответы на вопросы для самопроверки и вывод по практике, плюс код в репозитории. Критерий — не «сделано», а объяснено: почему выбран такой вариант, чем платим, что сломается при изменении требования. Формулировка «так принято» не засчитывается: принято кем и почему.