ООАП. Лекция 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. Инкапсуляция и сокрытие¶
Определение¶
Инкапсуляция — результат сокрытия представления и реализации в объекте. Представление невидимо и недоступно извне; получить к нему доступ и модифицировать можно только с помощью операций объекта.
Два смысла, которые обычно путают:
- Объединение данных и операций в одну капсулу. Тривиально выглядит изнутри ООП, но именно этого не было в процедурном подходе — там данные лежали отдельно от процедур.
- Сокрытие представления. Наружу торчит договор (операции), внутрь спрятано, как он выполняется.
И сразу термин, который дальше будет встречаться постоянно. Инвариант — утверждение о состоянии объекта, которое обязано быть истинным всё время его жизни: сразу после конструктора и после каждого завершённого вызова метода. «У прямоугольника ширина и высота положительны», «сумма заказа равна сумме позиций», «список гостей не содержит дубликатов» — это инварианты. Внутри метода состояние может быть временно противоречивым, наружу такое состояние выходить не должно.
Сокрытие нужно ровно затем, чтобы инвариант было где проверять: если состояние меняется только через методы класса, класс отвечает за его правильность. Если же поле открыто наружу, инвариант превращается в устную договорённость.
Аналогия для аудитории: у человека есть публичная часть — имя, возраст, «поговорить», «пройтись». И есть закрытая — как сердце качает кровь, как переваривается пища. Снаружи на это не влияют, и не потому, что «секрет», а потому, что вмешательство сломает работу системы.
Поля против свойств: почему публичное поле в 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
Дружественный класс (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, а не на класс; - подтиповый полиморфизм — какая реализация подставлена, решается в рантайме;
- инверсия зависимостей — верхний слой не зависит от нижнего, оба зависят от абстракции.
Завтра появится FileUserRepository — UserService не изменится ни на строку. И тесты
пишутся тривиально: подставили 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.
Итоги лекции¶
- ООП склеивает данные с операциями и даёт единицу проектирования с границей и инвариантами.
- Класс описывает интерфейс и реализацию; объект — экземпляр во время выполнения.
- Инкапсуляция — про защиту инвариантов, а не про генерацию геттеров.
- Наследование выражает отношение «является» и даёт самую сильную связанность.
- Полиморфизм бывает ad-hoc, параметрический и подтиповый; работает благодаря динамическому связыванию.
- Композиция и агрегация различаются владением и временем жизни; делегирование — базовый механизм почти всех паттернов.
- Проектируем на уровне интерфейсов: абстрактная связанность позволяет менять детали, не трогая клиентов.
- DI — практическое воплощение агрегации + абстрактной связанности + инверсии зависимостей.
- 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 | — |
| Тип | Имя конкретного интерфейса | имя класса/интерфейса | имя класса/протокола |
| Чёрный ящик | Повторное использование через композицию: компоненты не раскрывают внутреннего устройства | работа через интерфейсы компонентов | то же |
Вопросы для самопроверки¶
- Чем интерфейс объекта отличается от ключевого слова
interface? - Почему
user.Password = "123"хуже, чемuser.ChangePassword(old, new)? - В чём разница между наследованием интерфейса и наследованием реализации?
- Приведите пример ad-hoc полиморфизма и объясните, почему его называют мнимым.
- Почему в C# методы не виртуальны по умолчанию, а в Python — виртуальны всегда?
- Композиция или агрегация: комната и стены; плейлист и треки; заказ и позиции заказа; сервис и логгер?
- Почему
NotSupportedExceptionв реализации интерфейса — признак архитектурной проблемы? Какие два принципа SOLID при этом нарушаются? - Чем каркас отличается от инструментальной библиотеки? Кто кого вызывает?
- Что такое абстрактная связанность и как она связана с тестируемостью кода?
- Почему единственность объекта лучше выражать временем жизни в контейнере, чем паттерном Singleton?
Домашнее задание¶
Задача. Спроектировать и реализовать на C# мини-систему «Библиотека»:
- сущности
Book,Reader,Loan(выдача книги) с защищёнными инвариантами (нельзя выдать уже выданную книгу; срок возврата строго в будущем); - интерфейс
IRepository<T>и две реализации:InMemoryRepository<T>иFileRepository<T>; - сервис
LibraryService, получающий репозитории через конструктор и не знающий, какая реализация используется; - уведомления о просрочке через интерфейс
INotifierс реализациямиConsoleNotifierиEmailNotifier; - в
Program.cs— композиционный корень: собрать граф объектов и продемонстрировать подмену одной реализации на другую без измененияLibraryService.
Требования к сдаче. Нарисовать схему классов (любая нотация, хоть на бумаге). В README на 10 строк объяснить: где у вас композиция, где агрегация, где делегирование, где абстрактная связанность, и какой принцип SOLID вы соблюли осознаннее всего.
Со звёздочкой. Реализовать то же самое на Python, использовав примесь (mixin) там, где в C# пришлось обойтись интерфейсом. Сравнить, что получилось короче и почему.