ООП
Основа об’єктно-орієнтованого проєктування: принципи, на яких тримається ООП, чому успадкуванням зловживають, як оцінити якість зв’язків між класами і де об’єкти краще замінити функціями та незмінними даними. На співбесіді це перевіряють не визначеннями, а вмінням пояснити рішення в коді.
Перед цією темою: Мова C#.
Які принципи лежать в основі ООП?
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: параметричний — один код для будь-якого 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); аналогprivateіз C# там —strict private. - Чи можна писати об’єктно без успадкування класів? — Так: інтерфейси та композиція; так влаштовані Go і Rust.
- Що таке подвійна диспетчеризація? — Вибір методу за типами двох об’єктів; у C# — Visitor або pattern matching за кортежем.
Чому радять «композицію замість успадкування» і коли успадкування все ж доречне?
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); }}
// Композиція: залежить лише від публічного контракту 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) і як їх оцінювати?
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)); // 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#?
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)); // 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? — Логіка в чистих функціях, введення-виведення і стан — у тонкому зовнішньому шарі.