Перейти до вмісту

ООП

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

Перед цією темою: Мова C#.

Junior+

Які принципи лежать в основі ООП?

Section titled “Які принципи лежать в основі ООП?”
Відповідь

Що це

Принцип Суть У 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+

Чому радять «композицію замість успадкування» і коли успадкування все ж доречне?

Section titled “Чому радять «композицію замість успадкування» і коли успадкування все ж доречне?”
Відповідь

Що це

  • Успадкування — відношення «є»: 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) і як їх оцінювати?

Section titled “Що таке зв’язність (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#?

Section titled “Де ООП не найкращий вибір і як його поєднують із функціональним стилем у 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 на реальному обсязі даних.