Перейти к содержимому

ООП

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

Перед этой темой: Язык C#.

Junior+
Ответ

Что это

Принцип Суть В 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: параметрический — один код для любого T
Console.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 со своей реализацией Area
class 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}";
}
Middle+

Почему советуют «композицию вместо наследования» и когда наследование всё-таки уместно?

Заголовок раздела «Почему советуют «композицию вместо наследования» и когда наследование всё-таки уместно?»
Ответ

Что это

  • Наследование — отношение «является»: 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); }
}
// Композиция: зависит только от публичного контракта Bag
class 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 не заметит изменений: так выглядит хрупкий базовый класс.

Middle+

Что такое связность (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)); // True
Console.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;
}
}
Senior+

Где ООП не лучший выбор и как его сочетают с функциональным стилем в 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)); // 10
Console.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;
Data-oriented: массив объектов против массива значений · Senior
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 на реальном объёме данных.