Към съдържанието
wannacode

ООП принципи

Накратко: Обектно-ориентираното програмиране стои на четири принципа: капсулация, наследяване, полиморфизъм и абстракция. Те пазят данните, пишат общото веднъж и заменят веригите от if-ове по вид на обекта.

Проблем

Заплати без принципи

В края на месеца фирмата пуска заплатите. Програмата е писана набързо: един клас с публични полета, ролята е текст, а заплатата се смята на две места. Функцията monthlyPay прави фиша, а transfer превежда парите в банката.

class Employee {
constructor(name, role, salary, bonus) {
this.name = name;
this.role = role;
this.salary = salary;
this.bonus = bonus;
}
}
function monthlyPay(e) {
if (e.role === "мениджър") {
return e.salary + e.bonus;
} else if (e.role === "стажант") {
return Math.trunc(e.salary / 2);
}
return e.salary;
}
function transfer(staff) {
let total = 0;
for (const e of staff) {
let amount = e.salary;
if (e.role === "мениджър") {
amount += e.bonus;
}
console.log(`Превод: ${e.name} ${amount} €`);
total += amount;
}
console.log(`Общо: ${total} €`);
}

Днес счетоводството въвежда повишение за Иван. Във формуляра обаче е написано -10 вместо 10:

const staff = [
new Employee("Иван", "служител", 2000, 0),
new Employee("Мария", "мениджър", 3000, 500),
new Employee("Ана", "стажант", 1600, 0),
];
const percent = -10;
staff[0].salary += Math.trunc(staff[0].salary * percent / 100);
for (const e of staff) {
console.log(`Фиш: ${e.name} ${monthlyPay(e)} €`);
}
transfer(staff);
Изход
Фиш: Иван 1800 €
Фиш: Мария 3500 €
Фиш: Ана 800 €
Превод: Иван 1800 €
Превод: Мария 3500 €
Превод: Ана 1600 €
Общо: 6900 €

Програмата не спира и не се оплаква. А в изхода има три пукнатини:

  1. Грешен запис. Заплатата на Иван падна на 1800 €. Полето е отворено за всеки ред в програмата и никой не пита дали -10% е повишение.
  2. Две копия на едно правило. Фишът на Ана казва 800 €, а банката ѝ превежда 1600 €. Правилото „стажантът взима половината“ е във фиша, а в превода са го забравили. Копието се е разминало с оригинала и никой не е разбрал.
  3. Нов вид, много поправки. Утре идва служител на хонорар. Трябва нов else if във фиша, в превода, в данъците, в отпуските, навсякъде, където някой пита за ролята. А всеки служител вече носи поле bonus, макар че бонус има само мениджърът.

Обектно-ориентираното програмиране е отговорът точно на тези три неща.

Идеята

Четирите принципа

Обектно-ориентираното програмиране (ООП) подрежда програмата около обекти: данни заедно с методите, които ги пипат. Стои на четири принципа. С всеки от тях вече си работил в модулите „Класове и обекти“ и „Наследяване и полиморфизъм“:

Принцип Какво казва Коя пукнатина оправя
Капсулация Обектът пази данните си сам. Отвън се стига до тях само през методи, които проверяват. Грешният запис: -10% се отказва.
Наследяване Общото на няколко класа е написано веднъж, в родител. Бонусът е само в Manager, а общото (име, заплата, проверката в повишението) е веднъж в Employee.
Полиморфизъм Едно извикване, а всеки обект отговаря по своя начин. If-ът по роля: всеки клас казва сам заплатата си, без второ копие.
Абстракция Кодът зависи от това какво може обектът, а не как го прави. Нов вид плащане влиза, без да пипаш превода.

Накратко: капсулацията пази, наследяването споделя, полиморфизмът избира, абстракцията обещава.

Нататък всеки принцип оправя по една пукнатина в програмата за заплати. Накрая ще ги видиш заедно, в същата програма.

Идеята

Капсулация: обектът пази данните си

Заплатата е на служителя. Нека само той я сменя. Полето става частно, с #, а повишението е метод, който проверява:

class Employee {
#name;
#salary;
constructor(name, salary) {
this.#name = name;
this.#salary = salary;
}
get name() {
return this.#name;
}
get salary() {
return this.#salary;
}
raise(percent) {
if (percent > 0) {
this.#salary += Math.trunc(this.#salary * percent / 100);
}
}
}
const ivan = new Employee("Иван", 2000);
ivan.raise(-10);
console.log(ivan.salary);
ivan.raise(10);
console.log(ivan.salary);
Изход
2000
2200

-10% е отказано, 10% минава. Проверката е на едно място, в класа, и всеки код, който иска да смени заплатата, минава през нея. Това е капсулация: данните и правилата за тях са заедно в обекта, а отвън се вижда само каквото класът реши да покаже.

С # полето е истински частно. Програма, в която отвън има ред

ivan.#salary = -2000;

изобщо не тръгва:

Грешка
SyntaxError: Private field '#salary' must be declared in an enclosing class

Отвън заплатата се чете през getter-а salary, който няма setter, а се сменя само с raise().

Кога вреди: getter и setter за всичко. Ако всяко поле има getter и setter, който записва каквото му дадеш, полето пак е отворено, само с повече редове. Капсулацията не е „скрито поле плюс get и set“. Тя е: обектът решава какво може да се промени и как. Затова Employee няма метод, който записва заплата, а има повишение с проверка. Започни със скрито и отваряй само каквото трябва.

По-точно, за любопитните
  • # пази и от наследниците: Manager също не вижда #salary. Затова той чете заплатата през getter-а salary.
  • Преди да има # (до 2022 г.) в JavaScript се ползваше договорката _salary, както в Python. Ще я срещнеш в по-стар код.

Пробвай

Кое скрива заплатата?

Заплатата трябва да се вижда само в класа. Как се казва полето?
class Employee {
  #name;
  ;

Идеята

Наследяване: общото веднъж

Мениджърът е служител с бонус. Вместо копие на Employee с още едно поле, Manager наследява всичко и добавя само бонуса:

class Manager extends Employee {
#bonus;
constructor(name, salary, bonus) {
super(name, salary);
this.#bonus = bonus;
}
get bonus() {
return this.#bonus;
}
}
const maria = new Manager("Мария", 3000, 500);
maria.raise(-10);
maria.raise(10);
console.log(`${maria.name}: ${maria.salary} + ${maria.bonus}`);
Изход
Мария: 3300 + 500

Мениджърът няма своя проверка в повишението. Взима я от Employee: -10% е отказано, а 10% вдига 3000 на 3300. Ако утре правилото за повишенията се смени, сменяш го на едно място и то важи за всички наследници. Това е наследяване: общото е в родителя, а наследникът пише само своето.

super(name, salary) дава името и заплатата на конструктора на родителя. #salary е скрито и от наследника, затова Manager чете заплатата през getter-а salary. След extends стои точно един клас.

Проверката дали наследяването има смисъл е изречението „X е Y“. „Мениджърът е служител“ е вярно.

Кога вреди: дълги вериги и наследяване заради готов код. Employee → Manager → SeniorManager → RegionalSeniorManager: за да разбереш какво прави най-долният клас, четеш четири класа, а промяна в най-горния мърда всички под него. Друга честа грешка е да наследиш клас само за да вземеш готов метод: фактура, която наследява Employee, за да има име. „Фактурата е служител“ не е вярно, и тя ще получи заплата и повишение. Какво се прави вместо това, е в картата „Какво се бърка“ по-долу.

Идеята

Полиморфизъм: всеки клас казва сам

Сега пукнатината с копията. Вместо if по роля, всеки клас казва сам каква е месечната му заплата. Родителят има обща версия, а наследниците я презаписват:

class Employee {
// ... #name, #salary, salary и raise() отпреди
monthlyPay() {
return this.#salary;
}
}
class Manager extends Employee {
// ... #bonus и конструкторът
monthlyPay() {
return this.salary + this.#bonus;
}
}
class Intern extends Employee {
monthlyPay() {
return Math.trunc(this.salary / 2);
}
}

И фишът, и преводът викат един и същ метод:

function transfer(staff) {
let total = 0;
for (const e of staff) {
console.log(`Превод: ${e.name} ${e.monthlyPay()} €`);
total += e.monthlyPay();
}
console.log(`Общо: ${total} €`);
}
const staff = [
new Employee("Иван", 2000),
new Manager("Мария", 3000, 500),
new Intern("Ана", 1600),
];
for (const e of staff) {
console.log(`Фиш: ${e.name} ${e.monthlyPay()} €`);
}
transfer(staff);
Изход
Фиш: Иван 2000 €
Фиш: Мария 3500 €
Фиш: Ана 800 €
Превод: Иван 2000 €
Превод: Мария 3500 €
Превод: Ана 800 €
Общо: 6300 €

Ана получава 800 € и във фиша, и в превода. Правилото за стажанта е само в Intern и няма второ копие, което да се размине. Нов вид служител е нов клас със своя метод, а цикълът и преводът не се пипат. Това е полиморфизъм: едно извикване, e.monthlyPay(), а всеки обект отговаря по своя начин.

JavaScript търси метода по име: първо в класа на обекта, после в родителя му. Нищо не се проверява предварително. Всеки обект, който има метод с това име, става за цикъла, независимо от класа си. Това се казва duck typing: „ако ходи като патица и квака като патица, значи е патица“.

Кога вреди. Два случая, които никога няма да станат три, не искат йерархия от класове: един if е по-кратък и по-ясен. И още: наследник, който презаписва метода, но нарушава обещанието на родителя (заплата, която излиза отрицателна, или метод, който спира програмата), чупи всеки цикъл, който вярва на родителя.

Пробвай

Колко ще преведе банката?

Класовете са от картите горе, заедно с договора за плащане от следващата карта: amountDue() е в Employee и връща this.monthlyPay().

const e = new Intern("Ана", 1600);
e.raise(10);
console.log(e.amountDue());
Отговор

Идеята

Абстракция: какво, а не как

Счетоводството иска в същия превод и фактурите на доставчиците. Фактурата не е служител. Но преводът и не се интересува кой какъв е. Трябват му две неща: на кого и колко. Това е договорът:

// Договорът „може да бъде платено“. Всеки платим обект има:
// amountDue(): колко се дължи
// payee(): на кого
class Employee {
// ... всичко отпреди
amountDue() {
return this.monthlyPay();
}
payee() {
return this.#name;
}
}
function transfer(payments) {
let total = 0;
for (const p of payments) {
console.log(`Превод: ${p.payee()} ${p.amountDue()} €`);
total += p.amountDue();
}
console.log(`Общо: ${total} €`);
}
class Invoice {
#supplier;
#amount;
constructor(supplier, amount) {
this.#supplier = supplier;
this.#amount = amount;
}
amountDue() {
return this.#amount;
}
payee() {
return this.#supplier;
}
}
const payments = [
new Employee("Иван", 2000),
new Manager("Мария", 3000, 500),
new Intern("Ана", 1600),
new Invoice("Доставчик на ток", 340),
];
transfer(payments);
Изход
Превод: Иван 2000 €
Превод: Мария 3500 €
Превод: Ана 800 €
Превод: Доставчик на ток 340 €
Общо: 6640 €

transfer не знае нищо за заплати, бонуси и фактури. Знае само договора. Това е абстракция: показваш какво може обектът и криеш как го прави. Преводът зависи от абстракцията, затова всичко, което я изпълнява, влиза в него без промяна.

JavaScript няма нито interface, нито abstract. Договорът е уговорка за имената на методите, записана в коментар. Никой не я проверява: обект без amountDue() влиза в масива спокойно, а програмата спира едва когато цикълът стигне до него, с TypeError: p.amountDue is not a function. Това е цената на duck typing.

Договорът е едната форма на абстракцията. Другата е клас с методи, които спират програмата, ако наследникът не ги напише: Shape обещава лице, а всяка фигура казва как се смята („Абстрактни класове“). Кога кое, е в урока „Интерфейси“.

Кога вреди: абстракция твърде рано. Договор с една-единствена реализация, „за всеки случай“, е още едно име, още един файл и още един скок, докато четеш. Абстракцията се изплаща, когато има поне две реализации или когато трябва да подмениш едната, например в тест. Payable се появи, когато дойде фактурата, не преди това.

Виж го

Четирите заедно

Сцената минава през програмата от началото: всеки принцип оправя своята пукнатина, а числата долу са от същата програма.

const staff = [
new Employee("Иван", 2000),
new Manager("Мария", 3000, 500),
new Intern("Ана", 1600),
];
staff[0].raise(-10);
for (const e of staff) {
console.log(`Фиш: ${e.name} ${e.monthlyPay()} €`);
}
const payments = [staff[0], staff[1], staff[2], new Invoice("Доставчик на ток", 340)];
transfer(payments);
Стъпка по стъпка, с думи
  1. Преди: ролята е текст, правилото е копирано в две вериги от if, а заплатата я пише всеки. Иван е на 1800 €, а фишът и преводът на Ана се разминават.
  2. Капсулация: заплатата е скрита, а raise() отказва -10%. Иван остава на 2000 €.
  3. Наследяване: Manager взима името, заплатата и проверката в повишението от Employee и добавя само бонуса. Обектите вече имат класове, а не роля в текст.
  4. Полиморфизъм: всеки клас казва сам своя monthlyPay(). Преводът вика същия метод като фиша и Ана получава 800 € и в двата.
  5. Абстракция: преводът знае само договора: amountDue() и payee(). Фактурата за ток влиза, без да е служител.
  6. Четирите заедно: същите -10% са отказани, всеки фиш съвпада с превода си, а общо е 6640 €.

Ето програмата преди и след, със 10% за Иван. Процедурата „преди“ е поправена на ръка: правилото за стажанта е добавено и в превода.

Преди
function monthlyPay(e) {
if (e.role === "мениджър") {
return e.salary + e.bonus;
} else if (e.role === "стажант") {
return Math.trunc(e.salary / 2);
}
return e.salary;
}
function transfer(staff) {
let total = 0;
for (const e of staff) {
let amount = e.salary;
if (e.role === "мениджър") {
amount += e.bonus;
} else if (e.role === "стажант") {
amount = Math.trunc(e.salary / 2);
}
console.log(`Превод: ${e.name} ${amount} €`);
total += amount;
}
console.log(`Общо: ${total} €`);
}
const staff = [
new Employee("Иван", "служител", 2000, 0),
new Employee("Мария", "мениджър", 3000, 500),
new Employee("Ана", "стажант", 1600, 0),
];
const percent = 10;
staff[0].salary += Math.trunc(staff[0].salary * percent / 100);
for (const e of staff) {
console.log(`Фиш: ${e.name} ${monthlyPay(e)} €`);
}
transfer(staff);
Изход
Фиш: Иван 2200 €
Фиш: Мария 3500 €
Фиш: Ана 800 €
Превод: Иван 2200 €
Превод: Мария 3500 €
Превод: Ана 800 €
Общо: 6500 €
След
function transfer(payments) {
let total = 0;
for (const p of payments) {
console.log(`Превод: ${p.payee()} ${p.amountDue()} €`);
total += p.amountDue();
}
console.log(`Общо: ${total} €`);
}
const staff = [
new Employee("Иван", 2000),
new Manager("Мария", 3000, 500),
new Intern("Ана", 1600),
];
staff[0].raise(10);
for (const e of staff) {
console.log(`Фиш: ${e.name} ${e.monthlyPay()} €`);
}
transfer(staff);
Изход
Фиш: Иван 2200 €
Фиш: Мария 3500 €
Фиш: Ана 800 €
Превод: Иван 2200 €
Превод: Мария 3500 €
Превод: Ана 800 €
Общо: 6500 €

Изходът е същият. Разликата е в това, което ще стане утре:

  • Някой въведе -10 → raise() го отказва (капсулация).
  • Ново правило за повишенията → сменя се на едно място, в Employee (наследяване).
  • Нов вид служител → нов клас със своя monthlyPay(); фишът и преводът не се пипат (полиморфизъм).
  • Ново нещо за плащане → нов клас, който изпълнява договора; преводът не се пипа (абстракция).

Същата програма с грешните -10% от проблема и с фактурата:

const staff = [
new Employee("Иван", 2000),
new Manager("Мария", 3000, 500),
new Intern("Ана", 1600),
];
staff[0].raise(-10);
for (const e of staff) {
console.log(`Фиш: ${e.name} ${e.monthlyPay()} €`);
}
const payments = [staff[0], staff[1], staff[2], new Invoice("Доставчик на ток", 340)];
transfer(payments);
Изход
Фиш: Иван 2000 €
Фиш: Мария 3500 €
Фиш: Ана 800 €
Превод: Иван 2000 €
Превод: Мария 3500 €
Превод: Ана 800 €
Превод: Доставчик на ток 340 €
Общо: 6640 €
Всички класове на едно място
// Договорът „може да бъде платено“. Всеки платим обект има:
// amountDue(): колко се дължи
// payee(): на кого
class Employee {
#name;
#salary;
constructor(name, salary) {
this.#name = name;
this.#salary = salary;
}
get name() {
return this.#name;
}
get salary() {
return this.#salary;
}
raise(percent) {
if (percent > 0) {
this.#salary += Math.trunc(this.#salary * percent / 100);
}
}
monthlyPay() {
return this.#salary;
}
amountDue() {
return this.monthlyPay();
}
payee() {
return this.#name;
}
}
class Manager extends Employee {
#bonus;
constructor(name, salary, bonus) {
super(name, salary);
this.#bonus = bonus;
}
get bonus() {
return this.#bonus;
}
monthlyPay() {
return this.salary + this.#bonus;
}
}
class Intern extends Employee {
monthlyPay() {
return Math.trunc(this.salary / 2);
}
}
class Invoice {
#supplier;
#amount;
constructor(supplier, amount) {
this.#supplier = supplier;
this.#amount = amount;
}
amountDue() {
return this.#amount;
}
payee() {
return this.#supplier;
}
}

Пробвай

Подреди мениджъра

Редовете на Manager са разбъркани. Подреди ги в обичайния ред: полето, конструкторът, после методът за заплатата.

  1. super(name, salary);
  2. class Manager extends Employee {
  3. this.#bonus = bonus;
  4. constructor(name, salary, bonus) {
  5. }
  6. #bonus;
  7. }
  8. }
  9. monthlyPay() {
  10. return this.salary + this.#bonus;

Пробвай

Намери грешката

Колега „опрости“ договора в Employee. Програмата е като в „Четирите заедно“, а печата:

Изход
Фиш: Иван 2200 €
Фиш: Мария 3500 €
Фиш: Ана 800 €
Превод: Иван 2200 €
Превод: Мария 3000 €
Превод: Ана 1600 €
Общо: 6800 €

Фишът на Мария е 3500 €, а банката ѝ превежда 3000 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

Какво се бърка

Абстракция или капсулация? И двете крият нещо, но различно. Капсулацията крие данните на един обект, за да не ги развали никой отвън: заплатата е скрита, повишението проверява. Абстракцията крие разликите между много класове зад общо обещание: преводът вижда само amountDue() и payee(). Капсулацията гледа обекта отвътре, абстракцията гледа кода, който го ползва отвън.

Наследяване или композиция? Наследяването казва „е“: мениджърът е служител. Композицията казва „има“: служителят има банкова сметка. Ако направиш служителя наследник на сметката от урока „Капсулация“, за да вземеш deposit() наготово,

class Employee extends Account {
// ... name и salary
}

той ще получи и теглене, и баланс, и всичко, което сметката научи утре. А „служителят е сметка“ не е вярно. Вместо това сметката е поле на служителя:

class Employee {
#name;
#salary;
#account;
constructor(name, salary, account) {
this.#name = name;
this.#salary = salary;
this.#account = account;
}
payday() {
this.#account.deposit(this.#salary);
}
}
const account = new Account(100);
const ivan = new Employee("Иван", 2000, account);
ivan.payday();
console.log(account.balance);
Изход
2100

Затова честият съвет е „предпочитай композиция пред наследяване“. Наследявай само когато „X е Y“ е вярно и ще остане вярно. Иначе обектът да има другия като поле.

Претоварване или презаписване? В Java и C# и двете понякога се наричат полиморфизъм. Претоварването е няколко метода с едно име и различни параметри. В JavaScript го няма: втора функция със същото име замества първата, а вместо това има стойности по подразбиране (урокът за тях). Полиморфизмът в ООП е презаписването: изборът е по обекта, докато програмата работи.

По-точно, за любопитните
  • Някои книги изброяват три стълба: капсулация, наследяване и полиморфизъм. Абстракцията тогава е общата идея под тях. На интервю по-често се очакват четирите.
  • Принципите не са правила, които се спазват „по 100%“. Всеки има цена: скрити полета значат повече методи, йерархиите значат повече класове, договорите значат още едно име. Слагаш ги там, където пукнатината е истинска.
  • След тези четири идват петте принципа SOLID за код, който понася промени, и шаблоните за дизайн: готови решения, построени от същите четири. Те са в курса „Софтуерно инженерство“: SOLID и Шаблони за дизайн.

Упражнение

Кой принцип е нарушен?

Класически въпрос на интервю. За всеки фрагмент отговори на глас: кой принцип е нарушен, какво ще се счупи и как го оправяш.

1.

class Cart {
total = 0;
}
// ... на 12 места в програмата:
cart.total -= discount;

2.

function area(s) {
if (s instanceof Circle) return 3.14 * s.r * s.r;
if (s instanceof Rectangle) return s.w * s.h;
return 0;
}

3.

class Invoice extends Employee {
// взима name и payee() наготово
}

4.

// единствената реализация:
class DefaultSalaryProvider {
provide(e) {
return e.monthlyPay();
}
}
const salaries = new DefaultSalaryProvider();
console.log(salaries.provide(maria));
Отговорите
  1. Капсулация. Сумата е отворена и се намалява от 12 места. Отстъпка, по-голяма от сумата, я прави отрицателна, и никой не пита. Сумата става #total, а отстъпката е метод на количката, който проверява.
  2. Полиморфизъм (и абстракция). If по вид на фигурата: нова фигура значи нов ред тук и навсякъде, където някой пита за вида. Лицето става абстрактен метод на Shape, а всяка фигура го пише сама.
  3. Наследяване на грешното място. „Фактурата е служител“ не е вярно: тя получава заплата, повишение и месечно плащане. Общото им е, че се плащат: договор Payable.
  4. Абстракция твърде рано. Клас, който само препраща към monthlyPay(), е още едно име и още един скок при четене, без да пази от нищо. Махни го и викай метода направо. Договорът ще има смисъл, когато се появи втора реализация.

Упражнение

Хонорар и тераса

Реши задачите в редактора си.

1. Изпълнител на хонорар. Contractor (Петър, 40 часа по 25 €) не е служител: няма фиш, но трябва да е в превода. Добави го така, че transfer да не се промени нито с един ред. В превода сложи и фактура за наем, 1800 € за наемодателя.

Изход
Превод: Иван 2000 €
Превод: Мария 3500 €
Превод: Ана 800 €
Превод: Петър 1000 €
Превод: Наемодател 1800 €
Общо: 9100 €

2. Терасата без if. Тази процедура смята лице по вид на фигурата:

function area(kind, a, b) {
if (kind === "правоъгълник") return a * b;
if (kind === "триъгълник") return a * b / 2;
if (kind === "квадрат") return a * a;
return 0;
}

Напиши я с абстрактен Shape и класове Rectangle, Triangle и Square. Терасата е правоъгълник 4 × 3, триъгълник с основа 2 и височина 3 и квадрат със страна 2. Квадратът е правоъгълник с равни страни: напиши го като наследник на Rectangle, без своя формула за лицето.

Изход
Правоъгълник: 12 м²
Триъгълник: 3 м²
Квадрат: 4 м²
Общо: 19 м²

Още задачи

  • Хонорар и във фиша. Ако изпълнителят все пак иска фиш, без да става служител: какво трябва да има в договора и кой го изпълнява?
  • Заплата по сметка. Дай на служителя сметка (композиция, както в „Какво се бърка“) и метод, който в деня на заплатата внася monthlyPay(), а не заплатата. Мениджърът трябва да получи и бонуса, без да пипаш monthlyPay() на Manager.
  • Повишение за всички. Вдигни заплатите на всички служители с 5% в един цикъл. Кои класове пипаш?

Тест

Провери се

1. Заплатата е скрита, а се сменя само през raise(), който отказва процент под нула. Кой принцип е това?
2. Фишът и преводът смятат заплатата с две копия на if-а по роля, и в едното стажантът е забравен. Кой принцип маха второто копие?
3. По какво се различават абстракцията и капсулацията?
4. Фактурата трябва да има име и сума. Колега предлага Invoice да наследи Employee, за да ги вземе наготово. Защо не?
5. Какво трябва да има един обект, за да мине през transfer()?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·