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

ООАП. Лекция 1 — Объектная модель: от процедур к абстракциям

Конспект лектора. Понятия этой лекции языконезависимы: класс, инкапсуляция, полиморфизм существуют в любом объектном языке. C# взят как основной язык примеров просто потому, что он знаком аудитории. Python, JS/TypeScript и другие языки появляются там, где конструкция языка даёт реальный выигрыш и показывает понятие с новой стороны — множественное наследование, метаклассы, структурная типизация. Терминология — по глоссарию GoF («Приёмы объектно-ориентированного проектирования»); в самой книге те же понятия проиллюстрированы на C++ и Smalltalk, так что этот срез студенты увидят при чтении.

Глоссарий в конце — раздаточный материал, вслух не читаем.


Блок 1. Процедурный подход и его предел

Что говорим

Программа в процедурном стиле — это «данные на вход → цепочка процедур → результат». Процедура (она же функция) не владеет данными: данные живут отдельно, процедуры отдельно.

// процедурный стиль
static double Area(double width, double height) => width * height;

double w = 5, h = 10;
double a = Area(w, h);   // 50

Так писали все программы изначально, и для маленьких задач это отлично работает. Проблемы начинаются на масштабе:

  • данные и поведение разъезжаются — кто гарантирует, что Area вызовут именно с шириной и высотой, а не с высотой и температурой? Типы double совместимы, компилятор молчит;
  • нет естественных границ — состояние расползается по глобальным переменным и длинным спискам параметров;
  • нет единицы декомпозиции — «модуль» есть, а «сущность предметной области» отсутствует;
  • инварианты негде хранить — правило «ширина не может быть отрицательной» приходится повторять в каждой процедуре, которая эту ширину принимает.

Ключевая мысль блока

ООП — это не «классы вместо функций». Это способ склеить данные с операциями над ними и получить единицу проектирования, у которой есть граница, инварианты и договор с внешним миром.

⚠️ Не говорим студентам «ООП пришло и решило все проблемы». Оно решило проблему масштаба и породило свои: избыточные иерархии, размазанную логику, «архитектуру ради архитектуры». Об этом — в конце курса.


Блок 2. Класс и объект

Понятия, без которых дальше нельзя

  • Класс — описывает устройство и поведение объекта: какие у него данные и что он умеет.
  • Объект — конкретный экземпляр класса, существующий во время выполнения: у него есть собственное состояние и своя идентичность.
  • Поле — данные внутри объекта. В C# рядом живёт свойство — пара методов чтения/записи с синтаксисом поля.
  • Метод — то, что объект умеет делать. В книге GoF это называется «операция» (в Smalltalk — «метод», в C++ — «функция-член»); мы говорим «метод», потому что так говорят все.
  • Сигнатура — имя метода, его параметры и возвращаемое значение.
  • Интерфейс объекта — весь набор его публичных сигнатур, то есть множество запросов, на которые объект отвечает. Это не то же самое, что ключевое слово interface.
  • Конструктор — код, который выполняется при рождении объекта.
  • Ссылка на объект — значение, по которому один объект находит другой.

На лекции этих восьми понятий достаточно. В глоссарии в конце конспекта термины даны полностью и в формулировках GoF — он справочный, наизусть его никто не спрашивает.

Пример на C

public class Rectangle
{
    private readonly double _width;
    private readonly double _height;

    // конструктор
    public Rectangle(double width, double height)
    {
        _width = width;
        _height = height;
    }

    // метод; сигнатура: Area() -> double
    public double Area() => _width * _height;

    // статический метод: принадлежит классу, а не объекту
    public static Rectangle Square(double side) => new Rectangle(side, side);
}

var r1 = new Rectangle(5, 10);   // объект №1
var r2 = new Rectangle(3, 4);    // объект №2 — тот же класс, другое состояние
var sq = Rectangle.Square(7);    // вызов статического метода

Класс — это описание характеристик и поведения. Объект — конкретный экземпляр, у которого каждая характеристика имеет значение. Из одного класса создаётся столько объектов, сколько нужно.

Параллели в других языках

Создание и инициализация — разные вещи. Конструктор занимается инициализацией: объект к этому моменту уже создан в памяти, конструктор лишь приводит его в правильное состояние. Само создание — отдельный шаг, и его можно забрать себе: тогда решение, какой объект вернуть и надо ли его вообще создавать, принимает не оператор new, а метод.

public sealed class Color
{
    private static readonly Dictionary<string, Color> Cache = new();

    public string Name { get; }

    private Color(string name) => Name = name;         // инициализация: конструктор закрыт

    public static Color Of(string name)                // создание: решаем мы
    {
        if (!Cache.TryGetValue(name, out var color))   // повторный запрос — тот же объект,
            Cache[name] = color = new Color(name);     // новый экземпляр не создаётся
        return color;
    }
}

var red  = Color.Of("red");
var red2 = Color.Of("red");
Console.WriteLine(ReferenceEquals(red, red2));         // True
class Color:
    _cache: dict[str, "Color"] = {}

    def __new__(cls, name):            # СОЗДАНИЕ: решаем, создавать ли объект вообще
        if name in cls._cache:
            return cls._cache[name]    # возвращаем готовый — __init__ отработает на нём же
        obj = super().__new__(cls)
        cls._cache[name] = obj
        return obj

    def __init__(self, name):          # ИНИЦИАЛИЗАЦИЯ: объект уже существует
        self.name = name

red, red2 = Color("red"), Color("red")
print(red is red2)                     # True — второй вызов нового объекта не создал

В Python эти два шага разведены явно, на уровне языка: __new__ создаёт объект и возвращает его, __init__ только настраивает уже созданный. Именно поэтому __new__ перехватывают, когда нужен пул объектов или единственный экземпляр. В C# такого разделения нет: new всегда создаёт новый объект, поэтому решение «создавать или отдать готовый» приходится выносить в отдельный метод, как в примере выше.

Такой метод называется фабричным: у него есть имя (Color.Of, Rectangle.Square), он может вернуть уже готовый объект или объект производного класса, а конструктор при этом остаётся закрытым и отвечает только за инициализацию. Полноценно разберём это в лекции по порождающим паттернам.

Освобождение ресурсов. В C++ за это отвечает деструктор, который вызывается детерминированно (идиома RAII). В C#, Python и JS момент освобождения памяти решает сборщик мусора, а ресурсы (файлы, соединения, сокеты) освобождают явно: в C# — IDisposable и using, в Python — контекстный менеджер и with, в Go — defer. Разбираем подробно в самостоятельной работе по этому разделу.

Метакласс. В C# аналога почти нет: есть System.Type и рефлексия, но класс не является объектом первого класса, который можно сконструировать «на лету» естественным образом. В Python — является, и метаклассы работают буквально по определению GoF:

class Meta(type):                      # метакласс = класс объекта-класса
    def __new__(mcls, name, bases, ns):
        print(f"создаётся класс {name}")
        return super().__new__(mcls, name, bases, ns)

class Person(metaclass=Meta):          # печатает «создаётся класс Person»
    pass

print(type(Person))                    # <class '__main__.Meta'>

Ссылка на объект. В C# важно различать ссылочные типы (class) и значимые (struct): две переменные могут указывать на один объект либо содержать копии. В Python всё — ссылки (id(obj) — идентичность), в JS — объекты по ссылке, примитивы по значению.

Ловушка для студентов

«Интерфейс объекта» ≠ «ключевое слово interface». Интерфейс есть у любого объекта — это множество его публичных сигнатур. Ключевое слово — лишь способ объявить интерфейс отдельно от реализации. Разведём эти два смысла сразу, иначе блок 7 не зайдёт.


Блок 3. Инкапсуляция и сокрытие

Определение

Инкапсуляция — результат сокрытия представления и реализации в объекте. Представление невидимо и недоступно извне; получить к нему доступ и модифицировать можно только с помощью операций объекта.

Два смысла, которые обычно путают:

  1. Объединение данных и операций в одну капсулу. Тривиально выглядит изнутри ООП, но именно этого не было в процедурном подходе — там данные лежали отдельно от процедур.
  2. Сокрытие представления. Наружу торчит договор (операции), внутрь спрятано, как он выполняется.

И сразу термин, который дальше будет встречаться постоянно. Инвариант — утверждение о состоянии объекта, которое обязано быть истинным всё время его жизни: сразу после конструктора и после каждого завершённого вызова метода. «У прямоугольника ширина и высота положительны», «сумма заказа равна сумме позиций», «список гостей не содержит дубликатов» — это инварианты. Внутри метода состояние может быть временно противоречивым, наружу такое состояние выходить не должно.

Сокрытие нужно ровно затем, чтобы инвариант было где проверять: если состояние меняется только через методы класса, класс отвечает за его правильность. Если же поле открыто наружу, инвариант превращается в устную договорённость.

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

Поля против свойств: почему публичное поле в C# ломает инкапсуляцию

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

public class Point
{
    public double X;              // поле: прямой доступ к памяти
    public double Y { get; set; } // свойство: под капотом два метода
}

Снаружи p.X и p.Y пишутся одинаково. Разница в том, что во втором случае у класса есть точка контроля, а в первом её нет и никогда не появится.

Чего нельзя сделать с публичным полем.

  • Проверить значение. Инвариант «радиус > 0» негде записать: присваивание идёт прямо в память, класс о нём не узнаёт.
  • Сделать значение вычисляемым. Total как поле придётся кому-то пересчитывать снаружи и не забывать это делать; как свойство — считается само.
  • Ограничить запись. У свойства бывает get без set, private set, init — у поля только readonly, и то лишь «после конструктора никому, включая сам класс».
  • Отследить изменение. Логирование, уведомление интерфейса (INotifyPropertyChanged из MVVM), инвалидация кеша — всё это делается в сеттере, которого у поля нет.
  • Объявить в интерфейсе. Договор может требовать свойство, но не поле: interface IShape { double Area { get; } } — законно, поле в интерфейсе объявить нельзя.
  • Переопределить в наследнике. Свойство бывает virtual и abstract, поле — нет.

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

public double X;                  // было
public double X { get; set; }     // стало — и это уже другой бинарный контракт

Сборки, скомпилированные со старой версией, перестанут работать без перекомпиляции; сломается код, передающий поле по ref/out (Interlocked.Increment(ref p.X) перестанет компилироваться); отвалится всё, что смотрит на поля через рефлексию — сериализаторы, ORM, привязка данных. Для библиотеки это ломающее изменение версии, для приложения — весёлый день правок.

Поэтому практическое правило C#: поля бывают только приватными. Не «публичных полей не бывает», а именно так — ни public, ни protected, ни internal: любое поле, видимое за пределами своего класса, отдаёт состояние без права контроля. Наружу — только свойства и методы, даже если сегодня сеттер тривиален. Автосвойство public double X { get; set; } стоит ровно ноль: JIT встраивает такие геттеры, накладных расходов в рантайме нет.

А в Python и Kotlin всё наоборот. Там публичные атрибуты — нормальная практика, потому что атрибут можно превратить в вычисляемый, не трогая клиентов: в Python для этого есть @property, в Kotlin — свойства с кастомными get/set. Класс начинает с простого атрибута и добавляет контроль тогда, когда он понадобился, — код вызывающих не меняется. В C# такой возможности нет, поэтому и правило строже.

class Circle:
    def __init__(self, radius): self.radius = radius   # обычный атрибут, всем видно

# позже добавили проверку — вызывающий код не поменялся ни на строку
class Circle:
    @property
    def radius(self): return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0: raise ValueError("Радиус должен быть положительным")
        self._radius = value
// Kotlin: свойство с самого начала, поле за ним компилятор создаёт сам
class Circle(radius: Double) {
    var radius: Double = radius
        set(value) {                       // проверку добавили позже — вызывающие не тронуты
            require(value > 0) { "Радиус должен быть положительным" }
            field = value                  // field — то самое скрытое поле
        }
}

В Kotlin поля вообще нельзя объявить публично: var/val — это всегда свойство, а поле за ним (field) доступно только внутри аксессора. То есть язык принудительно делает то, к чему в C# приходится приучать дисциплиной.

Когда публичное поле всё-таки уместно. Приватные и internal-структуры данных, где нет инвариантов и нет внешних потребителей: узел связного списка внутри своей же коллекции, поля в горячем цикле, где важна каждая наносекунда и профилировщик это подтвердил. Признак такой ситуации: класс не является частью публичного договора.

Пример: почему сокрытие — это про инварианты

public class Rectangle
{
    private double _width;
    private double _height;

    public Rectangle(double width, double height)
    {
        Width = width;               // проходим через сеттер — инвариант соблюдён с рождения
        Height = height;
    }

    public double Width
    {
        get => _width;
        set => _width = value > 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value), "Ширина должна быть > 0");
    }

    public double Height
    {
        get => _height;
        private set => _height = value > 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value), "Высота должна быть > 0");
    }

    public double Perimeter() => 2 * (_width + _height);   // внутри — прямой доступ, это норма
}

var r = new Rectangle(5, 10);
r.Width = -2;                     // ArgumentOutOfRangeException: объект остался валидным
// r.Height = 3;                  // ошибка компиляции: сеттер приватный

Важный момент, который стоит проговорить: сеттер бросает исключение, а не исправляет значение молча. Тихая подмена -2 на 1 — плохая идея: вызывающий уверен, что записал минус два, объект думает иначе, и ошибка всплывёт где-то далеко от места, где её сделали. Правило: обнаружили нарушение инварианта — сообщите об этом громко и в точке нарушения.

Пример: неизменяемый идентификатор

public class User
{
    public string Username { get; set; }
    public string Password { get; private set; }
    public Guid Id { get; }                       // только чтение: сеттера нет вообще

    public User(string username, string password)
    {
        Username = username;
        Password = password;
        Id = Guid.NewGuid();                      // идентичность создаём мы, а не вызывающий
    }

    public void ChangePassword(string oldOne, string newOne)
    {
        if (oldOne != Password) throw new InvalidOperationException("Неверный пароль");
        Password = newOne;                        // смена пароля — операция, а не присваивание
    }
}

Разница принципиальная: user.Password = "123" — это дырка в капсуле, user.ChangePassword(old, new) — операция с правилами. Инкапсуляция — про второе.

Пример: коллекция внутри объекта

Самая частая утечка представления — публичный List<T>.

public class Database
{
    private readonly List<Table> _tables = new();

    public string Url { get; }
    public int Port { get; }

    public Database(string url, int port) => (Url, Port) = (url, port);

    // наружу отдаём только чтение
    public IReadOnlyList<Table> Tables => _tables;

    public void AddTable(Table table)
    {
        if (_tables.Any(t => t.Name == table.Name))
            throw new InvalidOperationException("Таблица уже существует");
        _tables.Add(table);
    }

    public void RemoveTable(string name) => _tables.RemoveAll(t => t.Name == name);
}

Если бы Tables был публичным List<Table>, любой код мог бы сделать db.Tables.Clear() и класс потерял бы контроль над собственным состоянием. Проверка уникальности имени была бы бесполезной.

Параллели

# Python: настоящего private нет — есть соглашение и name mangling
class Rectangle:
    def __init__(self, w, h):
        self._width = w        # «не трогай» — соглашение, доступ технически возможен
        self.__height = h      # name mangling → _Rectangle__height, случайно не заденешь

    @property
    def width(self): return self._width

    @width.setter
    def width(self, value): self._width = 1 if value <= 0 else value
// JS: приватные поля класса — настоящие, доступ извне даёт SyntaxError
class Rectangle {
  #width;
  constructor(w) { this.#width = w; }
  get width() { return this.#width; }
  set width(v) { this.#width = v <= 0 ? 1 : v; }
}

Дружественный класс (friend в C++) — класс, обладающий теми же правами доступа, что и сам класс. В C# точного аналога нет; ближайшее — internal (виден внутри сборки) плюс атрибут [assembly: InternalsVisibleTo("MyProject.Tests")], чтобы тесты видели внутренности. В Python вопрос не возникает: всё публично по факту.

Мифы, которые ломаем сразу

  • «Инкапсуляция = сделать поля приватными и нагенерить геттеры/сеттеры на каждое». Нет. Класс, у которого на каждое поле есть публичный get/set, инкапсуляции не имеет — это структура данных с лишним синтаксисом. Сокрытие имеет смысл там, где есть инвариант или право на изменение.
  • «Публичные поля — всегда зло». Для DTO, координат точки, конфигурации — часто нормально. Спрашиваем себя: есть ли правило, которое кто-то может нарушить?

Блок 4. Наследование

Определения

  • Наследование — отношение, определяющее одну сущность в терминах другой. Новый класс определяется в терминах одного или нескольких родительских, наследуя их интерфейс и реализацию.
  • Подкласс (производный класс) — класс, наследующий другому.
  • Родительский класс — синонимы: суперкласс (Smalltalk), базовый класс (C#/C++), класс-предок.
  • Наследование реализации — потомок получает код родителя: поля, готовые методы, защищённые детали.
  • Наследование интерфейса — речь о договоре, а не о коде. Здесь важно не путать три вещи: класс наследует класс (получает и договор, и реализацию), интерфейс наследует интерфейс (interface IReadWrite : IRead, IWrite — расширение договора) и класс реализует интерфейс (class File : IRead — обязуется выполнить договор, кода не наследует). В C# первое — : BaseClass, второе и третье выглядят одинаково, но означают разное.
  • Прозрачный ящик — стиль повторного использования, основанный на наследовании классов: подкласс видит защищённые (protected) детали родителя. Внутренности родителя ему видны — ящик «прозрачный».
  • Примесь (mixin) — класс, спроектированный так, чтобы сочетаться с другими классами путём наследования. Обычно абстрактен, самостоятельно не используется.

Пример: цепочка Person → Employee → Developer

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

    public Person(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName  = lastName;
        SetAge(age);
    }

    public string FullName => $"{FirstName} {LastName}";   // доступен всем наследникам

    public void SetAge(int age)
    {
        if (age < 0) throw new ArgumentOutOfRangeException(nameof(age));
        Age = age;
    }

    public virtual void Greet() => Console.WriteLine($"Привет, я человек, {FullName}");
}

public class Employee : Person
{
    public string TaxId { get; }
    public string PassportNumber { get; }

    public Employee(string firstName, string lastName, int age, string taxId, string passport)
        : base(firstName, lastName, age)     // сначала конструктор родителя
    {
        TaxId = taxId;
        PassportNumber = passport;
    }

    public override void Greet() => Console.WriteLine($"Привет, я работник, {FullName}");
}

public class Developer : Employee
{
    public string Level { get; }             // Junior / Middle / Senior

    public Developer(string firstName, string lastName, int age,
                     string taxId, string passport, string level)
        : base(firstName, lastName, age, taxId, passport)
    {
        Level = level;
    }

    public override void Greet() => Console.WriteLine($"Привет, я {Level} разработчик, {FullName}");
}

Что показываем вслух:

  • base(...) — вызов конструктора родителя; он отрабатывает первым;
  • в C#, в отличие от Java и Python, метод не виртуален по умолчанию — нужны virtual + override. Это осознанное решение языка: расширяемость объявляется явно;
  • sealed override запрещает дальнейшее замещение; sealed class запрещает наследование вообще.

Множественное наследование: где оно есть

В C#, Java, JS множественного наследования классов нет — наследуемся ровно от одного класса. Причина — «проблема ромба»: если D наследует B и C, а те оба наследуют A, чью реализацию получит D?

В Python оно есть, и решается это линеаризацией C3 (MRO — method resolution order):

class Loggable:
    def log(self, msg): print(f"[{self.__class__.__name__}] {msg}")

class Serializable:
    def to_dict(self): return self.__dict__

class Person(Loggable, Serializable):        # два родителя-примеси
    def __init__(self, name): self.name = name

p = Person("Вася")
p.log("создан")            # [Person] создан
print(p.to_dict())         # {'name': 'Вася'}
print(Person.__mro__)      # порядок разрешения методов — вот он, явно

Loggable и Serializable здесь — классические примеси: самостоятельного смысла не имеют, существуют, чтобы подмешиваться.

Чем это заменяют в C#:

// 1) интерфейсы + реализация по умолчанию (C# 8+) — ограниченная примесь
public interface ILoggable
{
    string Name { get; }
    void Log(string msg) => Console.WriteLine($"[{Name}] {msg}");   // тело в интерфейсе
}

// 2) методы расширения — примесь без состояния
public static class SerializationExtensions
{
    public static IDictionary<string, object?> ToDict<T>(this T self) => /* рефлексия */ null!;
}

Два ограничения, о которых надо знать.

Первое: интерфейс с реализацией по умолчанию не может иметь состояния. Настоящая примесь с полями в C# невозможна — это цена отказа от ромба.

Второе: метод с телом в интерфейсе доступен только через тип интерфейса. Это подводит нас к различию, которое нужно проговорить отдельно, — неявная и явная реализация интерфейса:

public interface ILogger { void Write(string message); }

public class ConsoleLogger : ILogger                    // НЕЯВНАЯ реализация
{
    public void Write(string message) => Console.WriteLine(message);
}

public class SilentLogger : ILogger                     // ЯВНАЯ реализация
{
    void ILogger.Write(string message) { }              // метод не публичный у класса
}

var a = new ConsoleLogger();
a.Write("ok");                                          // работает: метод виден у класса

var b = new SilentLogger();
// b.Write("ok");                                       // ошибка компиляции
((ILogger)b).Write("ok");                               // только через интерфейс

Зачем явная реализация: развести два интерфейса с одинаковой сигнатурой; спрятать служебную часть договора, чтобы она не мозолила глаза в подсказках IDE; и — главное для нас — именно так ведут себя методы с реализацией по умолчанию: они видны только через интерфейс, как будто реализованы явно.

В JS/TS примеси делают через функции над классами:

type Ctor<T = {}> = new (...args: any[]) => T;

function Loggable<TBase extends Ctor>(Base: TBase) {
  return class extends Base {
    log(msg: string) { console.log(msg); }
  };
}

class Person {}
class LoggablePerson extends Loggable(Person) {}

Предупреждение, ради которого весь блок

Наследование — самая сильная связанность из доступных. Подкласс зависит не только от публичного договора родителя, но и от его защищённых деталей и порядка вызовов. Меняем родителя — рискуем сломать всех потомков, часто молча.

Формулировка на доску: наследование — это «является» (is-a), а не «использует» (has-a). Developer — это Employee. Но Car не является Engine — там нужна композиция (блок 6).

Второе правило, полезнее первого: наследованием выражают то, что известно и зафиксировано навсегда; всё, что определяется позже или может меняться, выражают композицией. Иерархия классов складывается при компиляции и после этого неизменна — значит, в неё можно класть только те отношения, в которых вы уверены. Если ответ на вопрос «кем является этот объект» зависит от настройки, от роли, от момента времени или от того, что подставит вызывающий, — это не наследование, а поле с объектом, который можно подменить.


Блок 5. Полиморфизм

Определение

Полиморфизм — способность подставить во время выполнения вместо одного объекта другой с совместимым интерфейсом. poly — много, morphe — форма: один и тот же код работает с разными формами.

Динамическое связывание — связь между вызовом и конкретным методом, которая устанавливается во время выполнения, а не компиляции. Именно оно делает полиморфизм возможным.

Замещение — потомок переопределяет унаследованный метод и подставляет своё поведение (в C# override). Это механическая основа подтипового полиморфизма: замещённый метод выбирается по фактическому типу объекта.

Подтип — тип S является подтипом T, если интерфейс S содержит интерфейс T. Супертип — тип родителя.

Три вида полиморфизма — раскладываем аккуратно

В популярных роликах эти виды часто путают: подтиповый полиморфизм называют «параметрическим». Даём студентам корректную классификацию, иначе на собеседовании будет больно.

Вид Механизм Пример
Ad-hoc (специальный) перегрузка операций и методов, приведение типов Add(int, int) и Add(string, string)
Параметрический обобщения (generics), один код для любого типа-параметра List<T>, IRepository<T>
Подтиповый (полиморфизм включения) наследование/реализация интерфейса + динамическое связывание Person p = new Developer(); p.Greet();

Ad-hoc — «мнимый» полиморфизм. Компилятор просто выбирает нужную перегрузку по типам аргументов; во время выполнения ничего не решается:

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public string Add(string a, string b) => a + b;
}

new Calculator().Add(5, 5);       // 10
new Calculator().Add("5", "5");   // "55" — это конкатенация, другой метод

Подтиповый — тот, ради которого всё затевалось.

Задача: в комнате собрались человек, работник и разработчик; надо, чтобы каждый представился. Наивное решение — три массива и три цикла:

// ПЛОХО: при 15 классах будет 15 массивов
void GreetAll(Person[] people, Employee[] employees, Developer[] developers) { /* ... */ }

Полиморфное решение:

var room = new List<Person>          // статический тип — Person, для всех
{
    new Person("Иван", "Иванов", 30),
    new Employee("Пётр", "Петров", 40, "123", "AB1234"),
    new Developer("Аня", "Сидорова", 25, "456", "CD5678", "Senior")
};

void GreetAll(IEnumerable<Person> room)
{
    foreach (var person in room)
        person.Greet();              // какой именно Greet — решается в рантайме
}

GreetAll(room);
// Привет, я человек, Иван Иванов
// Привет, я работник, Пётр Петров
// Привет, я Senior разработчик, Аня Сидорова

Внутри GreetAll нет ни слова про Employee и Developer. Мы добавим завтра Designer — метод не изменится вообще. Вот это и есть цель: код, который не знает о конкретных типах, но работает с ними правильно.

Механика: у объекта есть таблица виртуальных методов; вызов person.Greet() разрешается по фактическому типу объекта. Именно поэтому в C# нужен virtual — невиртуальный метод связывается статически, и new вместо override даст «сюрприз»:

public class A { public void Hi() => Console.WriteLine("A"); }
public class B : A { public new void Hi() => Console.WriteLine("B"); }

A x = new B();
x.Hi();          // "A" — статическое связывание! Классическая ловушка на собеседовании

Параметрический — обобщения.

public interface IRepository<T>
{
    T? GetById(Guid id);
    IReadOnlyList<T> GetAll();
    void Add(T item);
    void Remove(Guid id);
}

Один и тот же код обслуживает IRepository<User> и IRepository<Car>. Параметризованный тип — тип, часть составляющих которого оставлена неопределённой и передаётся параметром в точке использования. В C++ это шаблоны, в C#/Java — дженерики, в TypeScript — generics, в Python — TypeVar/Generic.

Полиморфизм без наследования: утиная типизация

В Python и JS подтип определяется не объявлением, а фактическим набором операций:

class Duck:
    def speak(self): print("Кря")

class Robot:
    def speak(self): print("Бип")     # ничего не наследует, ничего не реализует

for x in (Duck(), Robot()):
    x.speak()                         # работает: важно наличие операции, а не родословная

В TypeScript то же самое, но проверяется компилятором — структурная типизация:

interface Speaker { speak(): void }
class Robot { speak() { console.log("Бип"); } }   // implements не написано
const s: Speaker = new Robot();                    // и всё равно подходит

В C# типизация номинальная: чтобы объект считался ISpeaker, класс обязан явно написать : ISpeaker. Это ограничение, но и страховка от случайного совпадения имён.

Тем не менее утиная типизация есть и в C# — не для типов вообще, а для конкретных языковых конструкций. Компилятор ищет метод по имени и форме, а не по интерфейсу:

// foreach не требует IEnumerable — достаточно метода GetEnumerator нужной формы
public sealed class Countdown
{
    public Enumerator GetEnumerator() => new Enumerator(3);

    public struct Enumerator
    {
        private int _current;
        public Enumerator(int from) => _current = from + 1;
        public int Current => _current;
        public bool MoveNext() => --_current > 0;
    }
}

foreach (var i in new Countdown()) Console.Write(i);    // 321 — интерфейсов нет вообще

Так же устроены await (нужен метод GetAwaiter() подходящей формы — его можно добавить даже методом расширения), инициализаторы коллекций (нужен Add), деконструкция (Deconstruct), паттерн using для ref struct, индексы и диапазоны (Count/Length и Slice). Хороший вопрос аудитории: почему для foreach сделали именно так? Ответ — производительность: перебор по структуре-перечислителю обходится без упаковки и без вызова через интерфейс. Прямой аналог структурной типизации в Python — typing.Protocol:

from typing import Protocol

class Speaker(Protocol):
    def speak(self) -> None: ...

def announce(s: Speaker) -> None: s.speak()   # Robot подойдёт без наследования

Ловушка

GreetAll работает одинаково со всеми, но результат разный. Часто студенты спрашивают: «а где здесь много форм, если код один?». Ответ: форм много у объектов; код один у клиента. Полиморфизм — это про то, что клиент один, а поведений много.


Блок 6. Композиция, агрегация, делегирование

Определения

  • Композиция объектов — объединение нескольких объектов для получения более сложного поведения.
  • Агрегированный объект — объект, составленный из подобъектов; подобъекты называются частями агрегата, и агрегат отвечает за них.
  • Делегирование — механизм реализации, при котором объект перенаправляет (делегирует) запрос другому объекту — уполномоченному, а тот выполняет запрос от имени исходного.
  • Отношение осведомлённости — одному классу известно о другом, если первый ссылается на второй.
  • Связанность — степень зависимости компонентов программы друг от друга.
  • Чёрный ящик — стиль повторного использования, основанный на композиции объектов. Компоненты не раскрывают друг другу деталей внутреннего устройства.

Композиция: части не живут без целого

public class Engine
{
    public void Start() => Console.WriteLine("Двигатель запущен");
}

public class Wheel
{
    public void Spin() => Console.WriteLine("Колесо крутится");
}

public class Car
{
    private readonly Engine _engine;
    private readonly List<Wheel> _wheels = new();

    public Car()
    {
        _engine = new Engine();                      // создаём ВНУТРИ
        for (var i = 0; i < 4; i++) _wheels.Add(new Wheel());
    }

    public void Drive()
    {
        _engine.Start();                             // делегирование
        foreach (var wheel in _wheels) wheel.Spin(); // делегирование
    }
}

Признак композиции: части создаются внутри целого, снаружи о них никто не знает, и вместе с автомобилем они исчезают. Автомобиль владеет двигателем.

Car.Drive() сам ничего не делает — он раздаёт запросы уполномоченным. Это делегирование, и это основной приём почти во всех паттернах GoF.

Агрегация: часть живёт своей жизнью

public class Freshener
{
    public string Scent { get; }
    public Freshener(string scent) => Scent = scent;
}

public class Car
{
    private readonly Engine _engine = new();
    private readonly Freshener? _freshener;

    public Car(Freshener? freshener = null) => _freshener = freshener;  // приходит ИЗВНЕ
}

public class Apartment
{
    private readonly Freshener _freshener;
    public Apartment(Freshener freshener) => _freshener = freshener;    // тот же объект
}

var pine = new Freshener("Ёлочка");
var car  = new Car(pine);
var flat = new Apartment(pine);   // один освежитель может быть где угодно

Разница между композицией и агрегацией — время жизни и владение:

Композиция Агрегация
Кто создаёт часть целое кто-то снаружи
Может ли часть жить отдельно нет да
Что при удалении целого часть исчезает часть продолжает жить
Как передаётся не передаётся через конструктор/сеттер

Практическая ремарка: агрегация — это и есть механика Dependency Injection (блок 8). Когда сервис получает репозиторий в конструктор — это агрегация в чистом виде.

Чёрный ящик против прозрачного

Наследование (прозрачный ящик) Композиция (чёрный ящик)
Что переиспользуем интерфейс + реализацию родителя публичный интерфейс компонента
Что видно protected-детали родителя только договор
Когда фиксируется на этапе компиляции во время выполнения — можно подменить
Связанность высокая низкая

Отсюда классическая рекомендация GoF: предпочитайте композицию объектов наследованию класса. Не «никогда не наследуйтесь», а «наследование — не инструмент переиспользования кода, это инструмент выражения отношения is-a».

Три уровня «набора классов» — не путать

  • Подсистема — независимая группа классов, совместно выполняющих набор обязанностей.
  • Инструментальная библиотека (toolkit) — набор классов с полезной функциональностью, который не диктует дизайн приложения. Пример: System.Text.Json, Newtonsoft.Json, numpy, lodash. Вы вызываете библиотеку.
  • Каркас (framework) — набор взаимодействующих классов, описывающих повторно применимый дизайн категории программ. Каркас задаёт архитектуру: разбивает приложение на классы с определёнными функциями и взаимодействиями, а вы настраиваете его подклассами и композицией. Пример: ASP.NET Core, Django, Angular. Каркас вызывает вас — это инверсия управления, «не звоните нам, мы позвоним вам».

Отличный вопрос аудитории: EF Core — библиотека или каркас? (Ответ: скорее каркас — он диктует модель работы с данными, наследование от DbContext, соглашения об именовании.)


Блок 7. Абстрактные классы и интерфейсы

Определения

  • Абстрактная операция — метод, у которого объявлена сигнатура, но нет реализации, и наследник обязан её дать. В C# это abstract-метод, в C++ — чисто виртуальная функция, в Python — метод с @abstractmethod. Метод интерфейса — близкое, но не тождественное: он тоже объявляет сигнатуру без реализации, однако принадлежит не классу, а договору, и словом abstract в C# не помечается.
  • Абстрактный класс — класс, единственное назначение которого — определить интерфейс. Полностью или частично делегирует реализацию подклассам. Экземпляры создавать нельзя.
  • Конкретный класс — класс без абстрактных операций; может иметь экземпляры.
  • Интерфейс — набор всех сигнатур, определённых операциями объекта.
  • Протокол — расширяет понятие интерфейса, включая допустимую последовательность запросов.
  • Абстрактная связанность — в классе A есть ссылка на абстракцию B (абстрактный класс или интерфейс). Абстрактным здесь обязан быть только B: сам A — обычный конкретный класс. Связанность названа абстрактной потому, что A знает лишь тип, а не конкретный класс объекта, который в него подставят.

Аналогия для доски: интерфейс — это оглавление учебника. Говорит, что должно быть, не говорит, как это сделано. Из оглавления нельзя сделать книгу — из интерфейса нельзя создать объект.

Интерфейсы: маленькие и составные

public interface IReader { string Read(string path); }
public interface IWriter { void Write(string path, string content); }

public class FileClient : IReader, IWriter        // реализуем сколько угодно интерфейсов
{
    public string Read(string path) => File.ReadAllText(path);
    public void Write(string path, string content) => File.WriteAllText(path, content);
}

public class HttpClientAdapter : IReader, IWriter { /* своя реализация */ }

public class FileLogReader : IReader { /* только чтение */ }

Ключевое: наследоваться можно от одного класса, реализовывать — сколько угодно интерфейсов. Записывать умеют база данных, файл, сокет и человек — договор один, реализации разные.

Абстрактный класс vs интерфейс

public abstract class Repository<T>
{
    protected readonly ILogger Logger;                   // ← состояние: интерфейс так не умеет
    protected Repository(ILogger logger) => Logger = logger;

    public abstract T? GetById(Guid id);                 // абстрактная операция — обязана быть в наследнике
    public abstract void Add(T item);

    public T GetRequired(Guid id)                        // обычная операция с готовой логикой
    {
        var item = GetById(id);
        if (item is null) { Logger.Warn($"Не найдено: {id}"); throw new KeyNotFoundException(); }
        return item;
    }
}

// var r = new Repository<User>(logger);   // ошибка компиляции: экземпляр создать нельзя
Абстрактный класс Интерфейс
Состояние (поля) да нет
Готовая реализация да только default-методы (C# 8+), без полей
Конструктор да нет
Сколько можно взять один сколько угодно
Отвечает на вопрос «чем я являюсь» «что я умею»

Практическое правило: интерфейс — по умолчанию; абстрактный класс — когда есть общее состояние или шаблонная логика, которую нельзя дублировать (это, кстати, будущий паттерн Template Method).

Абстрактная связанность + обобщения — как проектировать «сверху»

public interface IRepository<T>
{
    T? GetById(Guid id);
    IReadOnlyList<T> GetAll();
    void Add(T item);
    void Update(T item);
    void Remove(Guid id);
}

public class UserRepository : IRepository<User>
{
    public User? GetById(Guid id) => /* запрос к БД */ null;
    public IReadOnlyList<User> GetAll() => Array.Empty<User>();
    public void Add(User item) { /* INSERT */ }
    public void Update(User item) { /* UPDATE */ }
    public void Remove(Guid id) { /* DELETE */ }
}

public class CarRepository : IRepository<Car> { /* та же форма, другая сущность */ }

Почему обобщение обязательно: репозиторий — понятие обобщённое. Если в интерфейсе написать void Add(User item), то репозиторий машин его не реализует. Параметр типа T — это и есть «тип оставлен неопределённым и передаётся в точке использования».

Класс, который держит поле типа IRepository<User>, абстрактно связан с этим интерфейсом: он ссылается на тип, а не на конкретный объект и не на конкретную реализацию.

Протокол — интерфейс плюс порядок

Интерфейс не выражает, что Read нельзя звать до Open, а после Close — нельзя вообще:

public interface IConnection
{
    void Open();
    string Read();
    void Close();
}
// протокол: Open → (Read)* → Close, повторный Open после Close запрещён

Часть протокола можно затащить в типы («сделать недопустимые состояния невыразимыми»):

public interface IClosedConnection { IOpenConnection Open(); }
public interface IOpenConnection  { string Read(); IClosedConnection Close(); }
// теперь Read() у закрытого соединения — ошибка компиляции, а не исключение в рантайме

Это хорошая точка входа в разговор о типах-состояниях; в лекции 1 достаточно упомянуть.

Параллели

from abc import ABC, abstractmethod

class Repository(ABC):
    @abstractmethod
    def get_by_id(self, id): ...

    def get_required(self, id):          # обычный метод рядом с абстрактным
        item = self.get_by_id(id)
        if item is None: raise KeyError(id)
        return item

# Repository()  → TypeError: Can't instantiate abstract class

В Python есть оба варианта: ABC — номинальный договор (надо наследоваться), Protocol — структурный (достаточно совпадения по форме). В TypeScript interface — всегда структурный. В C# — только номинальный.


Блок 8. Dependency Injection и Singleton

Три похожих названия, которые надо развести

Прежде чем смотреть код — три термина, которые постоянно путают:

  • Инверсия зависимостей (Dependency Inversion, DIP)принцип проектирования: зависеть надо от абстракций, а не от конкретных классов; модули верхнего уровня не должны зависеть от модулей нижнего. Это про направление стрелок на диаграмме.
  • Внедрение зависимости (Dependency Injection, DI)приём: объект не создаёт свои зависимости сам, а получает их извне — через конструктор, свойство или параметр метода. Это про способ передать зависимость.
  • IoC-контейнеринструмент: библиотека, которая по конфигурации сама создаёт объекты и раздаёт им зависимости, чтобы вы не собирали граф руками.

Связь простая: DIP говорит, от чего зависеть, DI — как эту зависимость получить, контейнер — чем автоматизировать сборку. Можно соблюдать DIP без всякого контейнера (передавать интерфейс в конструктор руками) и, наоборот, использовать контейнер, продолжая зависеть от конкретных классов, — тогда никакого DIP нет, есть только удобная фабрика.

DI — сборка всего изученного в одном приёме

Задача. Приложение разделено на слои: сервисный слой с бизнес-логикой и слой доступа к данным. У слоя данных две реализации — Mongo и Postgres. Сервису не должно быть важно, с какой из них он работает.

public interface IUserRepository            // абстракция принадлежит слою бизнес-логики
{
    IReadOnlyList<User> GetAll();
}

public class MongoUserRepository : IUserRepository
{
    public IReadOnlyList<User> GetAll() => new[] { new User("Пользователь из Mongo", "***") };
}

public class PostgresUserRepository : IUserRepository
{
    public IReadOnlyList<User> GetAll() => new[] { new User("Пользователь из Postgres", "***") };
}

public class UserService
{
    private readonly IUserRepository _repository;    // ссылка на ТИП, не на реализацию

    public UserService(IUserRepository repository)   // зависимость приходит извне
        => _repository = repository;

    public IReadOnlyList<User> FindAdults()
        => _repository.GetAll().Where(u => u.Age >= 18).ToList();
}

// собираем на границе приложения — в композиционном корне
var service = new UserService(new MongoUserRepository());
// var service = new UserService(new PostgresUserRepository());   // подмена в ОДНОМ месте

Что здесь произошло, на языке терминов:

  • агрегация — репозиторий создан снаружи и передан в конструктор;
  • абстрактная связанностьUserService ссылается на IUserRepository, а не на класс;
  • подтиповый полиморфизм — какая реализация подставлена, решается в рантайме;
  • инверсия зависимостей — верхний слой не зависит от нижнего, оба зависят от абстракции.

Завтра появится FileUserRepositoryUserService не изменится ни на строку. И тесты пишутся тривиально: подставили FakeUserRepository, никакой базы не нужно.

DI-контейнер лишь автоматизирует эту сборку:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IUserRepository, MongoUserRepository>();
builder.Services.AddScoped<UserService>();          // зависимости подставит контейнер

Времена жизни в ASP.NET Core, коротко: Transient — новый объект на каждый запрос зависимости, Scoped — один на HTTP-запрос, Singleton — один на всё приложение.

На DI построены Spring, ASP.NET Core, NestJS, Angular. Концепция простая, но её сложно «увидеть» без всех предыдущих блоков — поэтому она и стоит последней.

Singleton — и почему с ним аккуратно

Задача: гарантировать, что объект существует ровно в одном экземпляре.

public sealed class Database
{
    private static readonly Lazy<Database> _instance = new(() => new Database());
    public static Database Instance => _instance.Value;

    public string Url { get; }
    private Database() => Url = Guid.NewGuid().ToString();   // приватный конструктор!
}

Console.WriteLine(Database.Instance.Url);
Console.WriteLine(Database.Instance.Url);   // тот же самый Url — тот же объект

Что важно проговорить:

  • ключевой элемент — приватный конструктор: new Database() снаружи невозможен;
  • Lazy<T> по умолчанию потокобезопасен: конструктор с фабрикой использует режим ExecutionAndPublication, при котором фабрика выполняется ровно один раз. Но это именно режим по умолчанию: new Lazy<T>(factory, isThreadSafe: false) (режим None) гарантий не даёт, а PublicationOnly допускает несколько запусков фабрики, из которых «побеждает» первый. Плюс два подводных камня этого режима: он кеширует исключение фабрики и может дать взаимоблокировку, если внутри инициализации есть свои блокировки. Самописные if (_instance == null) в многопоточной среде ломаются;
  • в некоторых языках (JS/TS) можно вернуть другой объект из конструктора, и тогда работает трюк «constructor возвращает instance». В C#, Java, Python конструктор так не умеет: в Python перехватывают __new__, в C# — прячут конструктор за статическим свойством;
  • критика: Singleton — это глобальное состояние с красивым лицом. Он мешает тестам, скрывает зависимости (по сигнатурам не видно, что класс лезет в базу), плохо живёт в многопоточности. Современный ответ — AddSingleton<T>() в контейнере: единственность есть, а зависимость по-прежнему приходит через конструктор и подменяема в тестах.

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


Блок 9. SOLID — обзор

Если по времени не успеваем — блок переносится в лекцию 2, он самодостаточен. Здесь даём формулировку, задачу, которую принцип решает, и один маленький пример на каждый.

Зачем принципы вообще. Проект живёт и меняется: приходят новые требования, отменяются старые. Внесение изменений стоит времени разработчиков и тестировщиков, а бизнесу нужно реагировать быстро. Значит, цель — система, которую просто и безболезненно менять: правка в одном месте не должна тянуть за собой правки в четырёх других. Пять принципов SOLID — это пять способов удержать такую гибкость.

S — Single Responsibility Principle

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

Причина изменения — это ответственность, которую мы на класс возложили. Много ответственностей — класс меняется часто, и каждое изменение рискует сломать соседнюю функциональность.

// ПЛОХО: правило валидации зашито в сам объект
public class Product
{
    public int Price { get; set; }

    public bool IsValid() => Price > 0;
}

// приходит второй сценарий, где «валидный» значит другое —
// и мы лезем править сам Product:
public bool IsValid(bool forWholesale) => forWholesale ? Price > 100_000 : Price > 0;
// ХОРОШО: правило — отдельная ответственность, у товара её больше нет
public interface IProductValidator { bool IsValid(Product product); }

public class DefaultValidator  : IProductValidator { public bool IsValid(Product p) => p.Price > 0; }
public class WholesaleValidator: IProductValidator { public bool IsValid(Product p) => p.Price > 100_000; }

public class Product
{
    private readonly IProductValidator _validator;
    public Product(IProductValidator validator) => _validator = validator;

    public int Price { get; set; }
    public bool IsValid() => _validator.IsValid(this);
}

Теперь товар отдельно, любое количество правил отдельно. Предел нарушения принципа — God object: класс, который умеет всё сразу, меняется от каждого чиха и тянет за собой всех, кто им пользуется.

Что даёт: изменения локальны; классы легче тестировать; двое могут править разные правила одновременно, не встречаясь в одном файле.

O — Open/Closed Principle

Формулировка: программные сущности (классы, модули, функции) должны быть открыты для расширения, но закрыты для изменения.

Самое частое нарушение выглядит так — проверка абстракции на тип:

// ПЛОХО: каждый новый вид фигуры — правка этого метода
public double Area(Shape shape)
{
    if (shape is Circle c)    return Math.PI * c.Radius * c.Radius;
    if (shape is Square sq)   return sq.Side * sq.Side;
    return 0;
}
// ХОРОШО: новый вид фигуры — новый класс, старый код не трогаем
public abstract class Shape { public abstract double Area(); }

public sealed class Circle : Shape
{
    public double Radius { get; init; }
    public override double Area() => Math.PI * Radius * Radius;
}

public sealed class Square : Shape
{
    public double Side { get; init; }
    public override double Area() => Side * Side;
}

Правило-подсказка: увидели is или typeof по своей же иерархии — код запахл. Класс, который проверяет абстракцию на конкретный тип, снова становится зависимым от реализаций, и ценность абстракции теряется.

Важная оговорка, которую делает и сам автор блога, откуда взят пример: не надо с самого начала строить самую гибкую гибкость на свете. Пока класс меняется спокойно и правки не расходятся кругами — абстракция не нужна. Она вводится в тот момент, когда изменение одного класса потянуло за собой изменения в нескольких других.

Что даёт: новый функционал добавляется дописыванием, а не переписыванием; регрессия не нужна там, где старый код не тронут.

L — Liskov Substitution Principle

Формулировка (Мартин): функции, которые используют ссылки на базовые классы, должны уметь работать с объектами производных классов, не зная об этом.

// Список, который дублирует каждый добавленный элемент
public class DoubleList<T> : IList<T>
{
    private readonly IList<T> _inner = new List<T>();
    public void Add(T item) { _inner.Add(item); _inner.Add(item); }
    public int Count => _inner.Count;
    // ...остальное делегируется
}

[Fact]
public void ОбычныйСписок()
{
    IList<int> list = new List<int>();
    list.Add(1);
    Assert.Equal(1, list.Count);          // ок
}

[Fact]
public void НашСписок()
{
    IList<int> list = new DoubleList<int>();
    list.Add(1);
    Assert.Equal(1, list.Count);          // падает: 2
}

Изолированно класс не опасен. Опасен он для клиента, который работает с IList<T> и ждёт от него обычного поведения. Теперь клиенту придётся знать, какой именно список ему подсунули, и делать проверки — то есть полиморфизм сломан.

Формальный способ поймать это — проектирование по контракту (Бертран Мейер): наследник может ослабить предусловие и усилить постусловие, но не наоборот. Для IList.Add:

предусловие постусловие
IList<T> item != null Count = Count + 1
DoubleList<T> item != null Count = Count + 2 ⚠️

Постусловие базового типа не выполняется — нарушение налицо. Правильное решение здесь: не притворяться IList<T>, а объявить собственный интерфейс с честным договором.

Тот же вопрос для обсуждения: «квадрат — это прямоугольник?» В математике да; в коде, где у прямоугольника независимо меняются ширина и высота, — нет.

Что даёт: клиент может работать с абстракцией и не проверять типы; наследование остаётся безопасным.

I — Interface Segregation Principle

Формулировка: клиенты не должны зависеть от методов, которые они не используют.

// ПЛОХО: ножу нечего перезаряжать
public interface IWeapon { void Attack(); void Reload(); }

public class Knife : IWeapon
{
    public void Attack() => Console.WriteLine("Удар ножом");
    public void Reload() => throw new NotSupportedException();   // ← лишний метод
}
// ХОРОШО: два маленьких договора вместо одного толстого
public interface IAttackable { void Attack(); }
public interface IReloadable { void Reload(); }

public class Knife  : IAttackable { public void Attack() { } }
public class Pistol : IAttackable, IReloadable { public void Attack() { } public void Reload() { } }

«Толстый» интерфейс заставляет реализовывать то, чего у класса нет, и появляется NotSupportedException — а это, если вспомнить предыдущий принцип, ещё и нарушение подстановки. Принципы связаны: нарушив один, обычно нарушаешь два.

Что даёт: класс зависит ровно от того, что использует; меньше поводов для изменения; реализации проще писать и подменять в тестах.

D — Dependency Inversion Principle

Формулировка: модули верхнего уровня не должны зависеть от модулей нижнего уровня — и те и другие зависят от абстракций. Абстракции не зависят от деталей; детали зависят от абстракций.

// ПЛОХО: плеер знает конкретный источник музыки
public class Player
{
    private readonly YandexMusicApi _api = new();          // жёсткая связь
    public void PlayFirst(string query) => _api.GetTracks(query).First().Play();
}
// ХОРОШО: плеер знает только договор, источник подставляется снаружи
public interface IMusicSource { IReadOnlyList<Track> Search(string query); }

public class YandexMusic : IMusicSource { /* ... */ }
public class Spotify     : IMusicSource { /* ... */ }

public class Player
{
    private readonly IMusicSource _source;
    public Player(IMusicSource source) => _source = source;  // зависимость приходит извне

    public void PlayFirst(string query) => _source.Search(query).First().Play();
}

Что здесь инвертировалось: раньше стрелка зависимости шла от плеера вниз, к конкретному API. Теперь и плеер, и оба API зависят от одной абстракции — направление стрелок снизу изменилось на противоположное. Инвертируется и ход мысли: не «плееру нужны Яндекс и Spotify», а «что общего у источников музыки, что можно из них абстрагировать».

Что даёт: заменить источник — одна строка при сборке; в тестах подставляется заглушка; верхний уровень переживает смену деталей.

Напомню различие из блока 8: DIP — принцип (от чего зависеть), DI — приём (как получить зависимость), контейнер — инструмент (кто соберёт граф объектов). В примере выше соблюдены и принцип, и приём, а контейнера нет вовсе — он для этого не обязателен.

Итог по SOLID одной строкой на принцип

Принцип Одной фразой
S Single Responsibility одна причина для изменения
O Open/Closed расширяем добавлением, не правкой
L Liskov Substitution потомок дополняет, а не ломает контракт
I Interface Segregation не навязывай лишних методов
D Dependency Inversion завись от абстракции, не от детали

Все пять — про одно и то же: снизить связанность и повысить связность модулей, чтобы изменение стоило дёшево. И все пять — ориентиры, а не законы: применяются тогда, когда без них изменение начинает расходиться кругами по коду.

Разбор с примерами на русском, на которых построен этот блок, — серия статей «Принципы проектирования классов (S.O.L.I.D.)» в блоге Александра Бындю: blog.byndyu.ru.

Итоги лекции

  1. ООП склеивает данные с операциями и даёт единицу проектирования с границей и инвариантами.
  2. Класс описывает интерфейс и реализацию; объект — экземпляр во время выполнения.
  3. Инкапсуляция — про защиту инвариантов, а не про генерацию геттеров.
  4. Наследование выражает отношение «является» и даёт самую сильную связанность.
  5. Полиморфизм бывает ad-hoc, параметрический и подтиповый; работает благодаря динамическому связыванию.
  6. Композиция и агрегация различаются владением и временем жизни; делегирование — базовый механизм почти всех паттернов.
  7. Проектируем на уровне интерфейсов: абстрактная связанность позволяет менять детали, не трогая клиентов.
  8. DI — практическое воплощение агрегации + абстрактной связанности + инверсии зависимостей.
  9. SOLID — пять формулировок одной идеи: слабая связанность, сильная связность.

Одна фраза на всю лекцию: зависьте от того, что стабильно (от договоров), и не зависьте от того, что меняется (от реализаций).


Глоссарий (раздаточный материал)

Полный список терминов книги — справочный: на лекции мы проговариваем только те понятия, что перечислены в блоке 2, остальные встречаются в литературе, и их полезно уметь опознать. Определения — по глоссарию GoF. В книге они проиллюстрированы на C++ и Smalltalk; здесь колонки показывают, во что то же понятие превращается в языках, с которыми студенты работают.

Термин Определение В C# В Python / JS-TS
Абстрактная операция Объявляет сигнатуру, но не реализует её; наследник обязан реализовать abstract метод (метод интерфейса — близкое, но не тождественное) @abstractmethod; метод в Protocol
Абстрактная связанность В A есть ссылка на абстракцию B; абстрактен обязан быть только B поле типа IRepository<T> аннотация типа Repository; TS private repo: IRepo
Абстрактный класс Класс, назначение которого — определить интерфейс; реализацию делегирует подклассам; экземпляры создавать нельзя abstract class class X(ABC); TS abstract class
Агрегированный объект Объект, составленный из подобъектов; агрегат отвечает за свои части объект с полями-компонентами то же
Делегирование Объект перенаправляет запрос другому объекту (уполномоченному), тот выполняет его от имени исходного Drive() => _engine.Start() то же; в JS ещё и делегирование по прототипу
Деструктор Операция, автоматически вызываемая для очистки объекта перед удалением (в C# такого механизма нет) финализатор ~Class() (недетерминирован); детерминированно — IDisposable + using __del__ (не гарантирован); with / контекстный менеджер; в JS — FinalizationRegistry, использовать не стоит
Динамическое связывание Связь между запросом и операцией, устанавливаемая во время выполнения virtual / override; вызовы через интерфейс все методы динамические по умолчанию
Дружественный класс Класс с теми же правами доступа, что и сам класс (C++ friend) аналога нет; ближе всего internal + InternalsVisibleTo не требуется: приватности как барьера нет (Python), #private в JS обходится только рефлексией
Закрытое наследование Наследование только ради реализации (C++ private) отсутствует — заменяется композицией отсутствует
Замещение Переопределение унаследованной операции в подклассе override просто объявить метод с тем же именем; super() для вызова родителя
Инкапсуляция Результат сокрытия представления и реализации; доступ к состоянию — только через операции private поля + свойства/методы _name (соглашение), __name (mangling); #field в JS
Инструментальная библиотека (toolkit) Набор классов с полезной функциональностью, не определяющий дизайн приложения System.Text.Json, Polly numpy, lodash
Интерфейс Набор всех сигнатур операций объекта; описывает множество запросов, на которые объект отвечает публичная поверхность типа; ключевое слово interface публичные методы; Protocol / TS interface
Каркас (framework) Набор взаимодействующих классов, задающий архитектуру приложения; настраивается подклассами и композицией ASP.NET Core, MAUI Django, Angular
Класс Определяет интерфейс и реализацию объекта: представление и операции class class
Композиция объектов Объединение объектов для получения более сложного поведения поля-компоненты, создаваемые внутри то же
Конкретный класс Класс без абстрактных операций; может иметь экземпляры обычный class обычный class
Конструктор Операция, автоматически вызываемая для инициализации нового экземпляра public Class(...), primary constructor __init____new__ для создания); constructor
Метакласс Класс объекта-класса (классы сами являются объектами) аналога нет; есть System.Type и рефлексия type, metaclass= в Python; в JS класс — объект-функция
Наследование Определение одной сущности в терминах другой; наследование класса = наследование интерфейса + реализации : BaseClass, только один родитель Python — несколько родителей + MRO; JS/TS — один extends
Объект Сущность времени выполнения, хранящая данные и процедуры работы с ними экземпляр class/record экземпляр класса; литерал объекта в JS
Операция То, чем можно воздействовать на данные объекта; выполняется при получении запроса метод метод
Операция класса Операция, определённая для класса в целом, а не для экземпляра static метод @staticmethod, @classmethod; static в JS
Отношение агрегирования Отношение агрегата и его частей поле-компонент, переданное извне то же
Отношение осведомлённости Классу известно о другом, если он на него ссылается using + упоминание типа импорт и упоминание
Параметризованный тип Тип, часть составляющих которого не определена и передаётся параметром в точке использования дженерики List<T> TypeVar/Generic[T]; TS Array<T>
Паттерн проектирования Именует, мотивирует и объясняет приём проектирования для часто возникающей задачи; описывает задачу, решение, применимость и результаты
Переменная экземпляра Элемент данных, составляющий часть представления объекта поле (field) атрибут экземпляра; свойство объекта
Подкласс Класс, наследующий другому (производный класс) class B : A class B(A)
Подсистема Независимая группа классов, совместно выполняющих набор обязанностей сборка / модуль / namespace пакет / модуль
Подтип Тип, интерфейс которого содержит интерфейс другого типа наследник или реализация интерфейса (номинально) структурно: достаточно совпадения формы
Полиморфизм Возможность подставить во время выполнения другой объект с совместимым интерфейсом через virtual/интерфейсы/дженерики утиная типизация + Protocol
Получатель Объект, которому направлен запрос this self / this
Примесь (mixin) Абстрактный класс, спроектированный для сочетания с другими путём наследования нет полноценной; интерфейс с default-методами, методы расширения Python — прямо и естественно; TS — mixin-функции над классами
Прозрачный ящик Повторное использование через наследование: подкласс видит защищённые детали родителя protected _protected по соглашению
Протокол Интерфейс + допустимая последовательность запросов договор в документации; типы-состояния то же; Protocol в Python — про форму, не про порядок
Родительский класс Класс, которому наследует другой. Синонимы: суперкласс, базовый класс, предок базовый класс, base super()
Связанность Степень зависимости компонентов друг от друга
Сигнатура Имя операции + параметры + возвращаемое значение
Ссылка на объект Значение, идентифицирующее другой объект ссылочные типы (class) vs значимые (struct) всё ссылки; id() в Python
Супертип Тип родителя, которому наследует данный тип базовый класс/интерфейс то же
Схема взаимодействий Схема потока запросов между объектами UML sequence diagram
Схема классов Схема классов, их структуры, операций и статических связей UML class diagram
Схема объекта Схема структуры конкретных объектов во время выполнения UML object diagram
Тип Имя конкретного интерфейса имя класса/интерфейса имя класса/протокола
Чёрный ящик Повторное использование через композицию: компоненты не раскрывают внутреннего устройства работа через интерфейсы компонентов то же

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

  1. Чем интерфейс объекта отличается от ключевого слова interface?
  2. Почему user.Password = "123" хуже, чем user.ChangePassword(old, new)?
  3. В чём разница между наследованием интерфейса и наследованием реализации?
  4. Приведите пример ad-hoc полиморфизма и объясните, почему его называют мнимым.
  5. Почему в C# методы не виртуальны по умолчанию, а в Python — виртуальны всегда?
  6. Композиция или агрегация: комната и стены; плейлист и треки; заказ и позиции заказа; сервис и логгер?
  7. Почему NotSupportedException в реализации интерфейса — признак архитектурной проблемы? Какие два принципа SOLID при этом нарушаются?
  8. Чем каркас отличается от инструментальной библиотеки? Кто кого вызывает?
  9. Что такое абстрактная связанность и как она связана с тестируемостью кода?
  10. Почему единственность объекта лучше выражать временем жизни в контейнере, чем паттерном Singleton?

Домашнее задание

Задача. Спроектировать и реализовать на C# мини-систему «Библиотека»:

  • сущности Book, Reader, Loan (выдача книги) с защищёнными инвариантами (нельзя выдать уже выданную книгу; срок возврата строго в будущем);
  • интерфейс IRepository<T> и две реализации: InMemoryRepository<T> и FileRepository<T>;
  • сервис LibraryService, получающий репозитории через конструктор и не знающий, какая реализация используется;
  • уведомления о просрочке через интерфейс INotifier с реализациями ConsoleNotifier и EmailNotifier;
  • в Program.cs — композиционный корень: собрать граф объектов и продемонстрировать подмену одной реализации на другую без изменения LibraryService.

Требования к сдаче. Нарисовать схему классов (любая нотация, хоть на бумаге). В README на 10 строк объяснить: где у вас композиция, где агрегация, где делегирование, где абстрактная связанность, и какой принцип SOLID вы соблюли осознаннее всего.

Со звёздочкой. Реализовать то же самое на Python, использовав примесь (mixin) там, где в C# пришлось обойтись интерфейсом. Сравнить, что получилось короче и почему.