Принцип підстановки Барбари Лісков. Про передумови, постумови та інваріанти

Чому у багатьох виникають проблеми з цим принципом? Якщо взяти не якесь там «заумне» визначення, а щось простіше, то звучить воно так:

Клас-спадкоємець має доповнювати, а не заміщати поведінку базового класу.

Звучить зрозуміло і цілком логічно, правда? Ну все, розходимось. Але блін, як цього досягти? 😅 Чомусь багато хто просто пропускає інфу про передумови і постумови, які якраз класно пояснюють що саме треба і не треба робити.

Але спочатку — навіщо воно взагалі?

Уявіть ситуацію: ви написали код, який працює з базовим класом PaymentProcessor. Все чудово, тести зелені, код на проді. А потім приходить колега і каже: «О, я тут створив PremiumPaymentProcessor, він наслідується від твого, давай використаємо його для VIP-клієнтів».

Ви підставляєте новий клас, і... все падає 💥 Чому? Бо новий клас поводиться не так, як ви очікували від PaymentProcessor. Ось саме це LSP і намагається попередити.

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

У цій статті ми трошки докладніше зупинимось на поняттях «передумови», «постумови», розглянемо, що таке коваріантність, контраваріантність і інваріантність, а також «історичні обмеження» або «правило історії». Усі ці правила про передумови та постумови прийшли з концепції «Design by Contract».

Передумови не можуть бути посилені в підкласі

​️Іншими словами, дочірні класи не мають створювати більше передумов, ніж це визначено в базовому класі.

Тобто якщо батьківський клас каже «дай мені будь-яке позитивне число», то дочірній не може казати «ні, давай тільки парні позитивні числа». Це буде посилення вимог.

От приклад:

class Customer
{
    protected float $account = 0;

    public function putMoneyIntoAccount(int|float $sum): void
    {
        if ($sum < 1) {
            throw new Exception('Ви не можете покласти на рахунок менше 1$');
        }

        $this->account += $sum;
    }
}

class MicroCustomer extends Customer
{
    public function putMoneyIntoAccount(int|float $sum): void
    {
        if ($sum < 1) {
            throw new Exception('Ви не можете покласти на рахунок менше 1$');
        }

        // Посилення передумов - от цього не треба
        if ($sum > 100) { 
            throw new Exception('Ви не можете покласти більше 100$');
        }

        $this->account += $sum;
    }
}

​Додавання другої умови — це якраз і є посилення.

Але чому це проблема? Уявіть код, який використовує ваші класи:

function processDeposit(Customer $customer) {
    $customer->putMoneyIntoAccount(500); // Все ок для Customer
}

// Але якщо передати MicroCustomer - буде exception
processDeposit(new MicroCustomer()); 

Контраваріантність параметрів

До передумов також варто віднести «контраваріантність» — складне слово, але просте правило:

Підклас може приймати ширший діапазон параметрів, але він має приймати ВСІ параметри, які приймає батьківський.

З PHP 7.4 навіть підтримується на рівні мови:

// Батьківський клас очікує конкретний тип
class PaymentGateway {
    public function process(CreditCard $payment) { }
}

// Дочірній може прийняти ширший тип
class FlexibleGateway extends PaymentGateway {
    public function process(PaymentMethod $payment) { } // CreditCard є PaymentMethod
}

Постумови не можуть бути послаблені в підкласі

​️Тобто підкласи мають виконувати всі постумови, які визначені в базовому класі. Постумови перевіряють стан об’єкта, що повертається на виході з функції.

Різниця з передумовами:

  • Передумови — що ми ПЕРЕВІРЯЄМО на вході (параметри, які нам дали).
  • Постумови — що ми ГАРАНТУЄМО на виході (результат, який повертаємо).

Простіше кажучи: передумови — це вимоги до клієнта, постумови — це наші обіцянки клієнту.

class Customer
{
    protected Dollars $account;

    public function chargeMoney(Dollars $sum): float
    {
        $result = $this->account - $sum->getAmount();

        if ($result < 0) { // Постумова - гарантуємо що результат не від'ємний
            throw new Exception();
        }

        return $result; // Обіцянка: повертаємо невід'ємне число
    }
}

class VIPCustomer extends Customer
{
    public function chargeMoney(Dollars $sum): float
    {
        $result = $this->account - $sum->getAmount();

        // Пропущена постумова базового класу - результат може бути від'ємним

        return $result; // Порушили обіцянку - можемо повернути від'ємне число
    }
}

​Умовний вираз, що перевіряє результат, є постумовою в базовому класі, а в наслідника її вже нема.

Чому це критично? Клієнтський код покладається на гарантії:

$balance = $customer->chargeMoney($amount);
// Клієнт впевнений що $balance >= 0 (бо це гарантія методу)
$this->displayBalance($balance); // Може впасти якщо від'ємне

Сюди ж можна віднести і «коваріантність», яка дозволяє оголошувати в методі дочірнього класу типом значення, що повертається, підтип того типу (шо?! 😂), який повертає батьківський метод.

На прикладі буде простіше. Тут в методі render() дочірнього класу, JpgImage оголошений типом значення, що повертається, який в свою чергу є підтипом Image, який повертає метод батьківського класу Renderer:

class Image {}
class JpgImage extends Image {}

class Renderer
{
    public function render(): Image
    {
    }
}

class PhotoRenderer extends Renderer
{
    public function render(): JpgImage
    {
    }
}

​️Таким чином в дочірньому класі ми звузили значення, що повертається. Не послабили. Посилили :)

Це безпечно, бо JpgImage — це все ще Image, просто з додатковими можливостями. Клієнтський код, який очікує Image, отримає його (просто більш специфічний).

Інваріантність

Тут має бути трошки простіше.

Всі умови базового класу — також мають бути збережені і в підкласі.

Інваріанти — це деякі умови, які залишаються істинними протягом всього життя об’єкта. Як правило, інваріанти передають внутрішній стан об’єкта.

Важлива різниця:

  • Передумови/постумови — про конкретні методи.
  • Інваріанти — про стан об’єкта, незалежно від методів.

Наприклад, бізнес-правила базового класу не повинні змінюватись в дочірньому:

class EmailList
{
    private array $emails = [];

    // ІНВАРІАНТ: список містить тільки валідні email
    public function addEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailException();
        }
        $this->emails[] = $email;
    }

    public function getEmails(): array
    {
        return $this->emails; // Завжди повертає валідні emails
    }
}

// Якщо дочірній клас додасть метод, який дозволить 
// додати невалідний email - він порушить інваріант

Тут також варто згадати історичні обмеження («правило історії»):

Підклас не повинен створювати нових мутаторів властивостей базового класу.

Якщо базовий клас не передбачав методів для зміни визначених в ньому властивостей, підтип цього класу також не повинен створювати таких методів. Іншими словами, якщо в батьківському класі щось було незмінним (immutable), не робіть це змінним (mutable) в дочірньому.

class Order
{
    private float $totalAmount;
    private string $status = 'pending';

    public function __construct(float $amount)
    {
        $this->totalAmount = $amount;
    }

    public function getTotal(): float
    {
        return $this->totalAmount;
    }

    // Зверніть увагу - немає методу setTotal()
    // Сума замовлення незмінна після створення
}

class EditableOrder extends Order
{
    public function setTotal(float $newAmount): void
    {
        $this->totalAmount = $newAmount; // порушення
    }
}

В такому випадку варто розглянути додавання мутатора в базовий клас або використання композиції замість наслідування.

Exceptions — це теж частина контракту

Про це часто забувають, але LSP стосується і виключень. Підклас не може кидати ширші виключення:

class PaymentProcessor
{
    public function charge(float $amount): void
    {
        if ($amount > $this->limit) {
            throw new PaymentLimitExceededException(); // Специфічний
        }
    }
}

class CustomPaymentProcessor extends PaymentProcessor
{
    public function charge(float $amount): void
    {
        if ($amount > $this->limit) {
            throw new \Exception('Limit exceeded'); // ПОРУШЕННЯ! Ширший exception
        }
    }
}

// Клієнтський код
try {
    $processor->charge(1000);
} catch (PaymentLimitExceededException $e) {
    // Не зловить загальний Exception від CustomPaymentProcessor!
}

Клієнтський код, який ловить PaymentLimitExceededException, не зловить загальний Exception.

Tell, Don’t Ask

Ще одне важливе правило — замість перевірки типу об’єкта, дайте йому самому вирішити, що робити:

// Погано - питаємо про тип (порушення LSP)
if ($account instanceof PremiumAccount) {
    $account->applyPremiumBenefits();
}

// Добре - кажемо що робити (LSP дотримано)
$account->applyBenefits(); // кожен тип сам знає свої benefits

Як це працює в реальному коді?

Замість того щоб робити глибоку ієрархію наслідування і потім боротись з порушеннями LSP, часто краще використовувати композицію.

Проблема з наслідуванням полягає в тому, що ви змушені взяти всю поведінку батьківського класу, навіть ту, яка вам не підходить.

За допомогою композиції ви створюєте новий клас, який використовує потрібну функціональність через делегування:

// Замість наслідування з обмеженнями
interface AccountInterface {
    public function deposit(float $amount): void;
    public function getBalance(): float;
}

class StandardAccount implements AccountInterface {
    // стандартна реалізація
}

class LimitedAccount implements AccountInterface {
    private AccountInterface $account;
    private float $maxDeposit = 100;

    public function __construct(AccountInterface $account) {
        $this->account = $account;
    }

    public function deposit(float $amount): void {
        if ($amount > $this->maxDeposit) {
            throw new LimitExceededException();
        }
        $this->account->deposit($amount); // делегуємо
    }

    // ...
}

// Клієнтський код працює однаково з обома
function processPayment(AccountInterface $account, float $amount) {
    $account->deposit($amount);
    echo "Депозит прийнято";
}

В чому перевага?

  • LimitedAccount не порушує жодних правил StandardAccount.
  • Ми не змушені перевизначати всі методи.
  • Можемо комбінувати різну поведінку.

Висновки

LSP — це не про складні правила типів. Це про передбачувану поведінку. Коли дочірній клас поводиться «як батьківський, тільки краще» — все добре. Коли він починає додавати обмеження або міняти очікувану поведінку — маєте проблему.

Пам’ятайте:

  • Передумови — можна послабити (приймати більше), не можна посилити.
  • Постумови — можна посилити (гарантувати більше), не можна послабити.
  • Інваріанти — не можна змінювати взагалі.
  • Поведінка — має залишатись передбачуваною.

І головне — якщо сумніваєтесь, використовуйте композицію замість наслідування. Це вирішує 90% проблем з LSP 😊

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

не дуже зрозуміло, як в прикладі були посилені передумови, якшо і там і там просто Exception.
тобто якщо замінити бейс клас новим — то все буде працювати як і працювало

На жаль більшість девелоперів просто не розуміють практичний сенс цієї теорії. Бо, на жаль, ще не напрацювали те, що називається «інженерне мислення».
Насправді ООП та більшість принципів — це спроба перенести давно працюючи інженерні підході з інших галузей. У першу чергу — з електронники.
Усім принципам можна знайти цілком практичні приклади у реальному житті. Наприклад цьому принципу відповідає сумісність зарядок для мобільних телефонів. Користувач очікує що якщо він бачить зарядку і може фізично її підключити до свого телефона — то усе має працювати.
Якщо ж виявиться що це нова, покращена зарядка яка удвічі потужніша і від неї старі телефони просто плавляться — то вочевидь це буде не краще рішення. Так само якщо новий телефон буде хотіти тільки «свою» зарядку і не заряджатися від інших.
Чому ж цей очевидний принцип так часто порушують при розробці софта. Тільки тому що код поміняти набагато легше, ніж фізичний об’єкт! Уявіть собі що вам раптом потрібно джерело живлення для чогось незвичного. Скажімо ви знайшли стару іграшкову залізну дорогу.
Але ж ви інженер! Ви берете зарядку для мобільного телефона, паяльник, трохи електронного мотлоху — і тепер ваша зарядка живить залізну дорогу. Правда чи буде вона правильно заряджати телефон після цього? І чи скажуть вам спасибі колеги, якщо ця зарядка лежала в офісі і нею часто користувалися...
У цьому проблема усіх інженерних принципів та кращих практик: їх вигадують спеціалісти з досвідом, які за багато років натанцювалися по граблях і намагаються сформулювати правила як їх уникати. Але на будь-яку чудову ідею достатньо одного дурня аби її зламати!
Бо дурень не розуміє навіщо усі ці складні правила, які його тільки обмежують. Йому ліньки розбиратися — і він робить як хоче. І коли щось запрацювало — він думає що він увесь такий швидкий і креативний. Не розуміючи що він просто не бачить усіх проблем у своєму рішенні.
Біда що такий «креативний» підхід в ІТ часто вітають та винагороджують. Якщо десь у будівництві молодий інженер принесе на конкурс свій проєкт хмарочоса з «гівна та палок» то його відправлять вчити сопромат. А от чийсь новий фронтенд — фреймвок «убивця Реакту» може знайти своїх шанувальників (особливо серед таких самих ньюбів).

Кстати, один из самых сложных для понимания.

Из какого набора (принципов) он один из самых сложных-то?

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

Це принцип, не ламайте наявний контракт з користувачем при ваших змінах, я правильно зрозуміла? :)

Це переважно проблема наслідування, бо там можна підмінити тип на підтип, і є простір щось зламати. В інтерфейсах та протоколах ти просто реалізуєш всі умови, і все. Контракт явний, порушити складніше.

В інтерфейсах та протоколах ти просто реалізуєш всі умови, і все.

Ніт. Ось наприклад є базовий SMTP, в ньому набір команд. А ось ESMTP, в ньому декілька нових. А ось є ще розширення, які описує початкове узгодження з ESMTP, типу прямої передачі у 8 бітах замість вимоги перекодування в QP або base64, або відправки по частинах.
Критична умова роботи ESMTP — що весь базовий SMTP, якщо з ним приходять, підтримується і саме так, як сказано згідно його стандарту. І це теж є LSP. Тільки замість класів у тебе сервер, з яким обмінюєшься порціями байтів, а замість того, хто використовує той клас — клієнт, що відкрив зʼєднання по TCP.

Класне пояснення, ніколи не дивився в такому розрізі

Контракти (предумови, післяумови, інваріанти) досить технічно просто реалізувати. Eiffel та Ada елегантно продемонстрували як це робиться ще 40 років тому. Але вони так і не прижилися у широковжиткових мовах. Чому? Можливо тому, що зміна вимог болючо б’є саме по контрактах? Яскраво ілюструє фундаментальні проблеми ООП підходу? Краще замести сміття під килим?

Наскільки я знаю Eiffel і Ada створювались для критичних систем (авіація, медицина), де коректність важливіша за швидкість розробки. Більшість бізнес-додатків живуть в іншій реальності.

Замість повноцінних контрактів мови пішли шляхом часткових рішень, які покривають 80% потреб:
Статична типізація (TypeScript, Kotlin, Swift)
Nullability annotations (C#, Kotlin)
Runtime assertions та property-based testing
Лінтери та статичні аналізатори

Так, зміна вимог справді бʼє по контрактах. Але контракти саме для того і існують, щоб зробити такі зміни явними і контрольованими. Питання в тому, чи потрібна вам така строгість.

То ж, як на мене, висновки занадто категоричні. Контракти не прижилися не через «проблеми ООП», а тому що індустрія знайшла більш прагматичний баланс між строгістю і швидкістю (чи продуктивністю)

Замість повноцінних контрактів мови пішли шляхом часткових рішень, які покривають 80% потреб:

Це ж не альтернатива контрактам. Контракт це передумови, післяумови, інваріанти класу. Вони перевіряються автоматично, є частиною сигнатури методу. А так в Ada є статична типізація, є nullability annotations ще з 80-х (type Not_Null_Ptr is not null access Integer;) є й runtime assertions, ... Це не замість.

А от приклад контрактів:

with Ada.Containers.Vectors;

package Bounded_Stack is
   Stack_Overflow  : exception;
   Stack_Underflow : exception;
   
   type Stack(Capacity : Positive) is tagged private
      with Type_Invariant => Stack.Size <= Stack.Capacity;
   
   procedure Push(Self : in out Stack; Item : Integer)
      with Pre  => not Self.Is_Full,
           Post => Self.Size = Self.Size'Old + 1 
                   and then Self.Top = Item;
   
   procedure Pop(Self : in out Stack; Item : out Integer)
      with Pre  => not Self.Is_Empty,
           Post => Self.Size = Self.Size'Old - 1;
   
   function Is_Empty(Self : Stack) return Boolean
      with Post => Is_Empty'Result = (Self.Size = 0);
   
   function Is_Full(Self : Stack) return Boolean
      with Post => Is_Full'Result = (Self.Size = Self.Capacity);
То ж, як на мене, висновки занадто категоричні. Контракти не прижилися не через «проблеми ООП», а тому що індустрія знайшла більш прагматичний баланс між строгістю і швидкістю (чи продуктивністю)

Пізнаю брєд від LLM. Який баланс, коли контракти нічого не варті? Хочеш використовуй, хоче ні. Вони ж ніяк не впливають, тому який баланс між продуктивністю? Чисто опція компілятора, перевіряти чи ні.

Вибачте, але не бачу сенсу продовжувати дискусію адже її нема

Ви праві, контракти нічого не варті, все придумали ще 40 років тому, LLM генерував не тільки коментарі, а і всю статтю

LLM згенерував все:)
«Нема нікого, тільки я» ([Тарас Шевченко])

Як на мере це досить конкретне питання про контракти, яке має пряме відношення до теми статті. До того ж це не 40 років тому, SPARK (система формальної верифікації для Ada на трійках Хоара) зʼявився у 2009, там контракти були коментарями. А потім вже у 2012 це війшло у стандарт Ada.

Але майже усе перелічене було в Ada, але все ж таки додали контракти.

Але..

які покривають 80% потреб

LLM стиль додати цифру, яка немає жодного сенсу. Як порахували 80%? Добре, чому б не покрити 20% потреб, якщо це безкоштовно? Скільки там потреб покриває множинне наслідування?

Статична типізація (TypeScript, Kotlin, Swift)
Nullability annotations (C#, Kotlin)
Runtime assertions та property-based testing
Лінтери та статичні аналізатори

Типовий ілюстративний LLM список з прикладами, досить беззмістовний в даному разі, бо в інтернеті мало інформації про контракти, тому й бачимо галюни. З цього усього контракти покриваються runtime assertions, але ти ж розумієш, що викликати писати після кожного метода assert Self.CheckInvariants особливо при умові що може бути багато return досить незручно. Тому так ніхто й не робить.

Це добре, але:

Всі умови базового класу — також мають бути збережені і в підкласі.

1) Не «всі умови», а тільки ті, що гарантовані контрактом! Але що є в контракті — питання більш складне, можуть бути такі речі, про які і не думали. Наприклад, в реалізації в похідному класі (якщо говоримо про клас) не повинно бути sleep(86400) :) Да, приклад маргінальний, але показовий.

2) Від принципу можна відхилятись, коли це навмисно — для тестів, зневадження, якогось спеціального перехоплення. Але кожний такий випадок має бути обгрунтований. Фінально все одно з цим працюватимуть люди, і винятки з правила повинні бути їм зрозумілі легко і одразу.

Підклас не може кидати ширші виключення:

А якщо викинуто якесь OutOfMemory? Воно може бути всюди. Є такі, на відсутність яких залагатись не можна, навіть якщо вони явно не указані.

І головне — якщо сумніваєтесь, використовуйте композицію замість наслідування. Це вирішує 90% проблем з LSP 😊

Ага, і створить декілька більших проблем.

В іншому — згоден.

можна було б подискусувати, але загально думки подобаються

з

OutOfMemory

не згоден, адже це розповсюджено і на клас і на підклас, вони ж закладеня на значно більш базовому рівні і є загальними

вони ж закладеня на значно більш базовому рівні і є загальними

Настільки загальними, що про них забувають, і якщо реалізація раніше такого не давала — приход такого винятка як дитячі граблі. Я про це.

От не певен, мені здається, що це проблема неврахування цього при роботі з базовим класом. Адже сам контракт імплементується на рівні мови, значить потенційно присутній всюди, незважаючи на неможливість його викликати

Бізнесові правила теж часто так звучать «та цього ніколи не станеться», але ж ми знаємо, що станеться

Хіба в останньому прикладі «як треба робити» ви не порушили LSP? Типи і підтипи це ж не тільки наслідування. В цьому прикладі інтерфейс є базовим типом, а реалізації — підтипами. І один з підтипів кидає виключення, тоді як інтерфейс не описує ніяких виключень (в PHP не можна на рівні мови, але все ж)

Согласен) нужно хотя бы php-doc добавить) я в боевых проектах с такого горю

Так, дякую, дійсно важливе зауваження

Що мене дивує, що весь цей SOLID і LSP як його найскладніша частина — це улюблене питання від пехепешніків і для пехепешніків на співбесіді. І ніхто жодного разу не спитав мене про це серед джавістів, наприклад. Цікаво, чому.

Бо джавісти, на відміну від похапе bottom feeders, Священним Ентерпрайзом займаються, а не богопротивним формошльопством.

гггггг

Та ні, все набагато простіше. Моя теорія — це тому, що ранні версії пехехе дозволяли не вказувати типу, що повертає метод. Ну і взагалі, слабка типізація. В пехепе 5.6 можна було таку дічь творити, аж дух захоплює. І воно все прекрасно інтерпретувалося. При тому, що там уже були якісь класи, і якесь кривеньке ООП. А джавісти просто на рівні мови не могли, і не можуть робити щось таке, що дуже б конфліктувало з Солід.

Не знаю... Одна з найкращих ООП архітектур, яку я знав, це Delphi VCL. Яка порушувала SOLID крізь де тільки можна: форми були одночасно і контролером, і view; жирні інтерфейси компонентів з купою методів; пряма залежність від конкретних класів замість абстракцій. Тому для мене SOLID це релігія, заповіді як не грішити. Але знайди когось без гріха? Тому завжди можна пояснити, за що бог покарав! І як завжди, релігія цвіте там, де більше болю.

В пехепе 5.6 можна було таку дічь творити, аж дух захоплює. І воно все прекрасно інтерпретувалося.

То воно й прекрасно працювало, попри критику. А потім, була відчайдушка спроба довести, що PHP справжня ООП мова. Тоді й прийшов SOLID. Тоді й прийшли проблеми.

Просто цікаво, на вашу думку, а які прийшли проблеми з там, що прийшов SOLID і намагання рухати PHP в сторону справжньої ООП, чи чим вона не справжня?

Я про PHP версії 3, то яке там ООП? Проста як двері мова, але вимоги до ресурсів для PHP сайтів були вдвічі нижчими за Java. Це була її сила. Враження у мене були дуже приємні.

Якщо брати PHP 5, там ООП формально з’явилося, але використовувалося мало. На той час я перетинався з багатьма стартапами, і бачив цікаву закономірність: якщо стартап писався на PHP з нуля, то доходив до релізу. Невелика команда справлялася. Але коли брався якийсь фреймворк на кшталт Bitrix, то бачив як часто тонули в проблемах. Складність з’їдала всі ресурси.
У той час було дуже багато критики PHP, що це не справжнє ООП, що архітектура неправильна. І вони взялися до роботи: почали додавати класи, інтерфейси, писати ООП-фреймворки, тягнути SOLID принципи, намагатися зробити PHP «правильною» мовою.

В результаті PHP став тим, чим він став зараз, але я знаю дуже поверхово, бо перестав перетинатися.

і LSP як його найскладніша частина

А чому «найскладніша»? Як на мене, найскладніша — open-closed, бо його і практично неможливо дотримуватись, і нормально пояснити складно (особливо якщо робити це тому, хто дивиться тільки на слова).

Взагалі весь SOLID це інфоциганство, відбір саме цих 5 принципів заради красивого акроніму. До того ж сам Мартін не вміє показувати його не через маячню. Але принцип Лісков сам по собі цінний, і стосується не тільки класів, а взагалі всього (наприклад, мережевих протоколів і API).

Ну, я точно пам’ятаю, що коли я про ЛСП прочитав, то з першого разу не зрозумів. Ну вона ж і сформулювала його, хм, не для покемонів.

Підписатись на коментарі