ООП
Основа объектно-ориентированного проектирования: принципы, на которых держится ООП, почему наследованием злоупотребляют, как оценить качество связей между классами и где объекты лучше заменить функциями и неизменяемыми данными. На собеседовании это проверяют не определениями, а умением объяснить решение в коде.
Перед этой темой: Язык C#.
Какие принципы лежат в основе ООП?
Заголовок раздела «Какие принципы лежат в основе ООП?»Что это
| Принцип | Суть | В C# |
|---|---|---|
| Инкапсуляция | состояние скрыто, менять его можно только через методы | private-поля, свойства с private set |
| Абстракция | наружу виден только существенный контракт | интерфейс, абстрактный класс |
| Наследование | новый класс получает члены базового и может их расширить | class Circle : Shape |
| Полиморфизм | один вызов — разное поведение в зависимости от типа | virtual/override, интерфейсы |
Зачем
Код, который работает с абстракцией Shape, не меняется, когда появляется новая фигура. А инкапсуляция не даёт внешнему коду записать в объект некорректное состояние, например отрицательную сторону квадрата.
Частая ошибка
Считать инкапсуляцией «приватное поле плюс публичные get и set». Если сеттер ничего не проверяет, состояние по-прежнему меняют снаружи как угодно.
Как устроено
Полиморфизм — способность одного кода работать с разными типами. Видов три:
| Вид | Механизм в C# | Когда выбирается |
|---|---|---|
| Подтипов | virtual/override, интерфейсы |
в рантайме, по типу объекта |
| Параметрический | generics: List<T>, Max<T> |
при компиляции, один код для любого T |
| Ad hoc | перегрузка методов и операторов | при компиляции, по статическим типам аргументов |
Абстракция или инкапсуляция
- Абстракция отвечает на вопрос «что объект делает»: какие операции есть в контракте.
- Инкапсуляция — «как это скрыто»: поля закрыты, а инварианты проверяются в одном месте.
Ловушки
- Перегрузка не виртуальна.
object o = "x"; Format(o)вызовет перегрузку дляobject, а не дляstring: выбор сделан по статическому типу. - Утечка изменяемого состояния. Свойство, которое возвращает внутренний
List<T>, ломает инкапсуляцию: вызывающий может его изменить. ВозвращайтеIReadOnlyList<T>или копию. - Наследование ради кода. Если подтип не может заменить базовый тип, полиморфный код сломается — об этом принцип подстановки Лисков из SOLID.
Правило
Сначала решите, какой контракт видят клиенты, потом прячьте всё остальное.
Под капотом
- Подтипы. Виртуальный вызов идёт через таблицу методов объекта, интерфейсный — через механизм поиска реализации для конкретного типа. Подробно — в теме «Язык C#».
- Generics. Для ссылочных типов JIT генерирует один общий код, для каждого значимого типа — отдельный. Поэтому
List<int>работает без boxing. - Перегрузка. Компилятор выбирает метод по правилам overload resolution и записывает в IL конкретный вызов. В рантайме выбора нет.
- Инкапсуляция проверяется компилятором и рантаймом, но не защищает данные: рефлексия и
UnsafeAccessor(.NET 8+) читаютprivate-поля.
Компромиссы
- Определение Алана Кея — сообщения, сокрытие состояния, позднее связывание — показывает, что наследование не обязательно для ООП. Go и Rust обходятся без наследования классов.
- Виртуальный вызов гибок, но мешает inlining. Generics со значимыми типами дают прямые вызовы, но каждая специализация — отдельный машинный код.
- Встроенной двойной диспетчеризации, то есть выбора метода по типам двух объектов, в C# нет. Её делают через Visitor,
dynamicили pattern matching по паре значений.
Как проверить в проде
Наличие виртуальных вызовов и boxing на горячем пути показывают BenchmarkDotNet с [DisassemblyDiagnoser] и [MemoryDiagnoser].
Итог: «четыре кита» — удобная схема, а на собеседовании Senior ценится разница между видами полиморфизма и их ценой.
Shape[] shapes = [new Rect(2, 3), new Square(2)];foreach (var shape in shapes) Console.WriteLine(shape.Area()); // 6, 4: полиморфизм подтипов
Console.WriteLine(Max(3, 7)); // 7: параметрический — один код для любого TConsole.WriteLine(Format.Of(5)); // int 5: ad hoc — перегрузка по типу аргументаConsole.WriteLine(Format.Of("x")); // string x
static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
// Абстракция: что умеет любая фигура, без деталейabstract class Shape { public abstract double Area(); }
// Наследование: Rect — это Shape со своей реализацией Areaclass Rect(double w, double h) : Shape { public override double Area() => w * h; }
// Инкапсуляция: поле скрыто, инвариант «сторона больше нуля» проверяет конструкторclass Square : Shape{ private readonly double _side; public Square(double side) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(side); _side = side; } public override double Area() => _side * _side;}
static class Format{ public static string Of(int x) => $"int {x}"; public static string Of(string x) => $"string {x}";}- Чем инкапсуляция отличается от сокрытия данных? — Сокрытие — средство; цель инкапсуляции — держать данные и правила их изменения в одном месте.
- Чем перегрузка отличается от переопределения? — Перегрузку выбирает компилятор по статическим типам, переопределение — рантайм по типу объекта.
- Чем
privateв C# отличается отprivateв Delphi? — В Delphi он виден всему модулю (unit); аналог C#-ногоprivateтам —strict private. - Можно ли писать объектно без наследования классов? — Да: интерфейсы и композиция; так устроены Go и Rust.
- Что такое двойная диспетчеризация? — Выбор метода по типам двух объектов; в C# — Visitor или pattern matching по кортежу.
Почему советуют «композицию вместо наследования» и когда наследование всё-таки уместно?
Заголовок раздела «Почему советуют «композицию вместо наследования» и когда наследование всё-таки уместно?»Что это
- Наследование — отношение «является»:
Dog : Animal. Наследник получает всё, что есть у базового класса. - Композиция — отношение «содержит»:
CarхранитEngineв поле и вызывает его методы.
Зачем предпочитать композицию
- В C# базовый класс один, а полей-компонентов сколько угодно.
- Компонент можно заменить: передать другой объект через конструктор, подставить заглушку в тесте.
- Изменения внутри компонента не задевают того, кто его использует, пока не меняется его публичный контракт.
Когда наследование уместно
- Подтип полностью заменяет базовый тип:
ArgumentNullException : ArgumentException. - Фреймворк ждёт наследника:
ControllerBase,BackgroundService,Exception.
Частая ошибка
Унаследоваться ради готового кода: class OrderList : List<Order>. Наружу утекут Clear и RemoveAt, а перехватить Add нельзя: методы List<T> не виртуальные.
Как устроено
| Признак | Наследование | Композиция |
|---|---|---|
| Когда связываются | при компиляции, навсегда | при создании объекта, можно подменить |
| Что видно | protected-члены и порядок самовызовов |
только публичный контракт |
| Сколько | один базовый класс | любое число компонентов |
Ловушки
- Хрупкий базовый класс. Наследник переопределяет
AddиAddRange, а базовыйAddRangeвнутри вызываетAdd— элементы считаются дважды. В примере ниже это видно. - Квадрат и прямоугольник.
Square : Rectangleломается, когда код меняет ширину отдельно от высоты: подтип не заменяет базовый тип. Это нарушение принципа подстановки Лисков — LSP, подробно в теме SOLID. - Сокрытие через
new. Если метод базового класса не виртуальный,newв наследнике его не заменит: через ссылку на базовый тип вызовется старая версия.
Когда что выбирать
- Переиспользовать поведение — композиция и делегирование.
- Нужен подтип для полиморфизма — реализуйте интерфейс.
- Расширить класс, спроектированный для этого, — наследование:
Collection<T>сprotected virtual InsertItem, а неList<T>.
Правило
Наследуйте интерфейсы, а реализацию переиспользуйте композицией.
Под капотом
- Наследование интерфейса даёт подтип: объект можно передать туда, где ждут базовый тип.
- Наследование реализации копирует поведение вместе с внутренними зависимостями. Какие методы базового класса вызывают какие виртуальные — теперь часть контракта.
- Блох («Effective Java») поэтому требует: проектируйте и документируйте класс для наследования, иначе запрещайте его. В C# это
sealed.
Цена
- Композиция — лишний объект и косвенный вызов. Обычно это незаметно, а JIT может встроить невиртуальный вызов.
- В C# нет синтаксиса делегирования: методы-прокладки пишут руками или генерируют source generator.
Компромиссы
- Framework Design Guidelines (для публичных библиотек): не запечатывайте класс без причины, незапечатанный класс без виртуальных членов — дешёвая расширяемость.
- В прикладном коде наследников обычно нет. CA1852 предлагает запечатывать
internal-типы, аsealedпомогает JIT девиртуализировать вызовы. - Декоратор добавляет поведение композицией, без подкласса на каждую комбинацию.
Как проверить в проде
- Метрика Depth of Inheritance в Visual Studio и правило CA1501 (по умолчанию — пять и более уровней) находят глубокие иерархии.
- На ревью ищут
protected-поля и цепочкиbase.вызовов.
Итог: открытый для наследования класс — публичный контракт; если вы его не проектировали, запечатайте.
var inherited = new CountingBag();inherited.AddRange(["a", "b"]);Console.WriteLine(inherited.Added); // 4: базовый AddRange сам вызывает Add
var composed = new CountingWrapper(new Bag());composed.AddRange(["a", "b"]);Console.WriteLine(composed.Added); // 2
class Bag{ public List<string> Items { get; } = []; public virtual void Add(string item) => Items.Add(item); // Деталь реализации: AddRange вызывает Add public virtual void AddRange(string[] items) { foreach (var i in items) Add(i); }}
// Наследование: результат зависит от того, как устроен базовый классclass CountingBag : Bag{ public int Added { get; private set; } public override void Add(string item) { Added++; base.Add(item); } public override void AddRange(string[] items) { Added += items.Length; base.AddRange(items); }}
// Композиция: зависит только от публичного контракта Bagclass CountingWrapper(Bag inner){ public int Added { get; private set; } public void Add(string item) { Added++; inner.Add(item); } public void AddRange(string[] items) { Added += items.Length; inner.AddRange(items); }}Если автор Bag перепишет AddRange без вызова Add, CountingBag начнёт считать правильно, а CountingWrapper не заметит изменений: так выглядит хрупкий базовый класс.
- Почему
Square : Rectangle— плохая иерархия? — Квадрат не выдерживает независимого изменения сторон, подтип не заменяет базовый тип (LSP). - Почему не стоит наследоваться от
List<T>? — Его методы не виртуальные, а наружу утекает весь API; для расширения естьCollection<T>. - Чем композиция отличается от агрегации? — При композиции компонент живёт и умирает вместе с владельцем, при агрегации существует независимо.
- Как сделать делегирование без рутины? — Default interface methods, source generator или декоратор через DI.
- Когда вы откроете класс для наследования? — Когда это точка расширения фреймворка, с документированными
virtual-методами и порядком их вызова.
Что такое связность (cohesion) и зацепление (coupling) и как их оценивать?
Заголовок раздела «Что такое связность (cohesion) и зацепление (coupling) и как их оценивать?»Что это
- Связность (cohesion) — насколько члены класса относятся к одной задаче.
OrderService, который ещё шлёт письма и рисует PDF, связан слабо. - Зацепление (coupling) — насколько класс знает о других и зависит от них. Класс, который сам создаёт
new SqlConnection(...), жёстко зацеплен с SQL Server.
Зачем
При высокой связности и слабом зацеплении изменение остаётся в одном классе, а класс легко протестировать отдельно.
Как пользоваться
- Один класс — одна задача, данные и методы, которые с ними работают, — вместе.
- Зависимости приходят через конструктор как абстракции.
- Передавайте методу только то, что ему нужно, а не весь объект.
Частая ошибка
Считать, что интерфейсы автоматически ослабляют зацепление. Интерфейс, который повторяет все детали реализации, связывает так же сильно.
Как устроено
Классические виды зацепления (Майерс, Константайн), от сильного к слабому:
| Вид | Пример в C# |
|---|---|
| По содержимому | рефлексией меняют private-поле чужого класса |
| Общее | несколько классов пишут в изменяемое static-поле |
| По управлению | флаг меняет поведение: Save(order, sendEmail: true) |
| По структуре | в метод передают весь Order, а нужен только Id |
| По данным | передают только нужные значения |
Худшая связность — случайная: Utils, Helpers. Лучшая — функциональная: все члены работают на одну задачу.
Закон Деметры и Tell, Don’t Ask
- Метод объекта вызывает методы только самого объекта, своих параметров, созданных в нём объектов и полей объекта.
order.Customer.Address.Cityнарушает правило: код знает всю структуру. - Цепочки LINQ и fluent-билдеры обычно не считают нарушением: они возвращают тот же тип или новые данные, а не лезут во внутренности.
- Tell, Don’t Ask: вместо «прочитать баланс, проверить, записать» —
wallet.TryWithdraw(amount).
Ловушки
- Feature Envy: метод работает с данными чужого класса больше, чем со своими. Его место — в том классе.
- Shotgun surgery («стрельба дробью»): одно изменение требует правок во многих классах — сильное зацепление.
Правило
Поведение кладите рядом с данными, которые оно меняет.
Как оценивать
| Метрика | Что показывает |
|---|---|
| Class Coupling (VS), CA1506 | число уникальных типов, от которых зависит тип или метод; порог по умолчанию — 95 для типа, 40 для метода |
| LCOM (Chidamber, Kemerer) | насколько методы класса используют общие поля; рост — слабая связность |
| Ca, Ce и нестабильность (Мартин) | входящие и исходящие зависимости модуля, I = Ce / (Ca + Ce) |
CA1506 по умолчанию выключено, его включают в .editorconfig.
Connascence
Мейлир Пейдж-Джонс описал связь как connascence: два элемента связаны, если изменение одного требует изменения другого. Слабые виды — по имени и типу, сильные — по порядку параметров, значению «магических» констант, алгоритму, времени выполнения. Правило: чем сильнее вид, тем ближе должны быть элементы.
Компромиссы
- Каждый интерфейс и событие ослабляют зацепление ценой косвенности: код труднее читать и отлаживать.
- DRY между модулями создаёт зацепление: общий класс начинают менять ради двух разных потребителей.
- Асинхронное взаимодействие снимает связанность по времени, но не связь по формату данных.
Как проверить в проде
- Анализ истории git: файлы, которые меняются в одних коммитах, скрыто связаны.
- Архитектурные тесты (NetArchTest, ArchUnitNET) запрещают зависимости между слоями и модулями в CI.
Итог: метрики указывают на подозрительные места, а реальную связь показывает история изменений.
var customer = new Customer(new Wallet(100m));
// Ask: вызывающий знает устройство Customer и Wallet и сам решает// if (customer.Wallet.Balance >= 30m) customer.Wallet.Balance -= 30m;
// Tell: просим действие, правило живёт рядом с даннымиConsole.WriteLine(customer.TryPay(30m)); // TrueConsole.WriteLine(customer.TryPay(80m)); // False: осталось 70
class Customer(Wallet wallet){ // Закон Деметры: Customer говорит только со своим полем public bool TryPay(decimal amount) => wallet.TryWithdraw(amount);}
class Wallet(decimal balance){ public decimal Balance { get; private set; } = balance;
public bool TryWithdraw(decimal amount) { if (amount <= 0 || amount > Balance) return false; Balance -= amount; return true; }}- Нарушает ли LINQ-цепочка закон Деметры? — Нет, она преобразует данные, а не ходит по внутренней структуре объектов.
- Чем плох параметр
boolв публичном методе? — Это зацепление по управлению: вызывающий управляет логикой метода; лучше два метода. - Какие запахи кода говорят о слабой связности? — Divergent change (класс меняют по разным причинам), классы
UtilsиManager. - Всегда ли нужно избавляться от геттеров по Tell, Don’t Ask? — Нет, Фаулер предостерегает от этого: запросы данных нормальны, правило — держать решения рядом с данными.
- Как увидеть скрытое зацепление? — По истории git: файлы, которые часто меняются вместе.
Где ООП не лучший выбор и как его сочетают с функциональным стилем в C#?
Заголовок раздела «Где ООП не лучший выбор и как его сочетают с функциональным стилем в C#?»Что это
- Объектный стиль — данные и методы вместе, объект сам следит за своим состоянием.
- Функциональный стиль — данные отдельно и неизменяемы, функции получают значения и возвращают новые.
- Чистая функция — результат зависит только от аргументов, и нет побочных эффектов: не пишет в поля, файлы, базу.
Зачем
Чистую функцию легко тестировать: подал вход — проверил выход, без моков. Неизменяемые данные безопасно передавать между потоками.
Как пользоваться
- Данные:
record Order(int Id, decimal Total), изменение —order with { Total = 0 }. - Обработка: LINQ
Where,Select,Sum. - Выбор по виду данных:
switch-выражение.
Частая ошибка
Делать класс-«калькулятор» с полями, которые заполняют перед вызовом Calculate(). Порядок вызовов становится скрытым правилом; функция с параметрами проще и честнее.
Когда что выбирать
| Задача | Стиль |
|---|---|
| Сущность с инвариантами: заказ, счёт | объект с методами |
| Расчёт, преобразование, валидация | чистая функция над record |
| Конвейер обработки коллекции | LINQ |
| Разбор фиксированного набора вариантов | switch-выражение по record |
Как устроено
recordгенерирует равенство по значению,ToStringи копирование черезwith. Свойства позиционногоrecord class—init, изменить их после создания нельзя.- Изменяемую логику держат по краям: контроллер читает данные, вызывает чистое ядро и сохраняет результат. Эту схему Гэри Бернхардт назвал functional core, imperative shell.
Анемичная модель
Анемичная модель — сущности-«мешки со свойствами» и вся логика в сервисах. Это не функциональный стиль: данные там изменяемы, а инварианты не защищены. Подробно — в теме Архитектурные стили.
Ловушки
withкопирует неглубоко:List<T>внутри записи останется общим для копии и оригинала.recordсо свойством-коллекцией сравнивает коллекцию по ссылке, а не по содержимому.
Правило
Сущности с правилами — классы, данные и сообщения — записи, вычисления — чистые функции.
Под капотом
Проблема выражения (Вадлер, 1998) — как добавлять и новые варианты данных, и новые операции без правки существующего кода:
| Добавить | Иерархия классов | record и switch |
|---|---|---|
| Новый тип | новый класс, старый код не трогаем | правка каждого switch |
| Новую операцию | новый метод в каждом классе или Visitor | новая функция |
Visitor переводит ООП-иерархию в «функциональный» режим. По открытой иерархии компилятор C# не может доказать, что switch исчерпывающий, поэтому нужна ветка _. Union-типы C# 15 (.NET 11, ноябрь 2026) задают закрытый набор вариантов с проверкой исчерпываемости.
Цена
- Неизменяемость — это копии: каждый
withаллоцирует новый объект.ImmutableList<T>построен на дереве, индексация у него O(log n). - Граф мелких объектов — ссылки и разбросанная по куче память. На горячем пути массив структур или отдельные массивы полей читаются последовательно и лучше ложатся в кеш процессора.
Компромиссы
- Data-oriented подход жертвует инкапсуляцией ради производительности, поэтому его ограничивают горячими участками, найденными профилировщиком.
- Для данных, которые читают часто, а меняют редко, есть
FrozenDictionary(.NET 8+): создаётся дольше, читается быстрее.
Как проверить в проде
- Цену копий видно в BenchmarkDotNet с
[MemoryDiagnoser]и по скорости аллокаций вdotnet-counters. - Горячие участки ищут через dotnet-trace или PerfView, а не на глаз.
Итог: стиль выбирают по оси изменений: растут типы — полиморфизм, растут операции — данные и функции, важна каждая наносекунда — data-oriented.
Shape[] shapes = [new Rect(2, 3), new Square(2)];
// Новая операция — новая функция, типы не меняютсяConsole.WriteLine(shapes.Sum(Area)); // 10Console.WriteLine(string.Join(", ", shapes.Select(Name))); // rect, square
// Неизменяемость: with создаёт изменённую копиюvar wide = new Rect(2, 3) with { W = 4 };Console.WriteLine(wide); // Rect { W = 4, H = 3 }Console.WriteLine(wide == new Rect(4, 3)); // True: равенство по значению
// Чистые функции: результат зависит только от аргументаstatic double Area(Shape s) => s switch{ Rect r => r.W * r.H, Square q => q.Side * q.Side, _ => throw new ArgumentOutOfRangeException(nameof(s)) // новый тип — правка каждого switch};
static string Name(Shape s) => s switch { Rect => "rect", Square => "square", _ => "?" };
abstract record Shape;sealed record Rect(double W, double H) : Shape;sealed record Square(double Side) : Shape;const int N = 1_000_000;
// Объектный стиль: массив ссылок, каждый объект — отдельно в кучеvar orders = new OrderObj[N];for (int i = 0; i < N; i++) orders[i] = new OrderObj(i, i % 7);
// Data-oriented: поле, нужное циклу, лежит подряд в отдельном массивеvar prices = new decimal[N];for (int i = 0; i < N; i++) prices[i] = i % 7;
decimal a = 0;foreach (var o in orders) a += o.Price; // переход по ссылке на каждый элементdecimal b = 0;foreach (var p in prices) b += p; // последовательное чтение памятиConsole.WriteLine(a == b); // True
sealed record OrderObj(int Id, decimal Price);Результат одинаковый, а разницу в скорости показывает только замер: BenchmarkDotNet на реальном объёме данных.
- Что такое expression problem? — Нельзя одновременно легко добавлять и типы, и операции: ООП облегчает первое,
switchпо данным — второе. - Делает ли
recordобъект неизменяемым? — Позиционные свойстваrecord class—init, но коллекции внутри остаются изменяемыми, аwithкопирует неглубоко. - Чем анемичная модель отличается от функционального стиля? — В ней данные изменяемы и без инвариантов, а логика в сервисах всё равно меняет состояние.
- Когда вы замените полиморфизм на
switch? — Когда набор вариантов закрыт, а операций над ним много и они растут. - Что такое functional core, imperative shell? — Логика в чистых функциях, ввод-вывод и состояние — в тонком внешнем слое.