8 February 2010

Принцип подстановки Барбары Лисков

Programming
Привет, хабрачеловеки!

Захотелось вот поделиться сокровенным знанием по этой теме. К тому же материалов по этому, достаточно важному принципу проектирования классов и их наследования, в Рунете как-то негусто. Имеются формулировки вида:

«Пусть q(x) является свойством верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.» © Wikipedia

Но они выносят мой мозг меня совершенно не радуют.

Если хочется услышать объяснение этой хрени умной фразы — прошу под кат.

Итак, принцип подстановки Барбары Лисков. Он же Liskov Substitution Principle. Он же LSP. Простыми словами принцип звучит так:

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

1. Что это значит на практике?


Если у нас есть класс A (не виртуальный, а вполне реально используемый в коде) и отнаследованный от него класс B, то если мы заменим все использования класса A на B, ничего не должно измениться в работе программы. Ведь класс B всего лишь расширяет функционал класса A. Если эта проверка работает, то поздравляю: ваша программа соответствует принципу подстановки Лисков! Если нет, стоит уволить ведущего программиста задуматься: «а правильно ли спроектированы классы?».

2. Ну и зачем это нужно?


Надеюсь, всем понятно, что принцип Лисков — это из области теории ООП. На практике, никто не заставляет следовать ему под дулом пистолета. Более того, могут быть случаи, когда следовать ему сложно и никому не нужно.

Словом, прям как с валидным HTML: сайт прошёл проверку на W3C валидаторе — плюсадин в карму верстальщика. Не прошёл — нужно чётко понимать почему он не прошёл: это ошибка или же очередной выкрунтас другими способами реализовать невозможно?

Из этого можно сделать выводы:
* следование принципу подстановки Лисков делает ваш проект ближе к духу ООП;
* это позволит избежать ряда ошибок (о них ниже).

3. Пример


Я решил не изобретать очередной велосипед, а к тому же мне очень понравился пример отсюда. Его то я и буду использовать (с лёгкими модификациями).

Итак, ситуация: мы проектируем программку для управления термостатами. Программа должна уметь работать с несколькими разными моделями устройств. Программа циклически проверяет температуру и пытается выправить её до требуемой. Саму программу мы, разумеется, писать не будем, а остановимся на проектировании иерархии классов-интерфейсов для термостатов.

3.1. Дебют

Первым делом — базовый класс. У него должны быть следующие методы:
* InitializeDevice: инициализация подключенного термостата. Понятное дело, метод pure virtual: для разных устройств могут потребоваться разные предварительные ласки, чтобы оно заработало как следует.
* Get/Set Reference: геттер/сеттер для требуемой (опорной) температуры. Вполне себе конкретные методы (не виртуальные) для установки переменной.
* GetTemperature: чтение температуры из устройства. Опять чисто вирутальный метод.
* AdjustTemperature: снова чисто виртуальный метод. Собственно, для установки температуры.

Опишем это более понятным языком, то есть C++:
  1. class TemperatureController
  2. {
  3. // Переменная для хранения опорной температуры
  4. int m_referenceTemperature;
  5. public:
  6. int GetReferenceTemperature() const
  7. {
  8. return m_referenceTemperature;
  9. }
  10. void SetReferenceTemperature(int referenceTemperature)
  11. {
  12. m_referenceTemperature = referenceTemperature;
  13. }
  14. virtual int GetTemperature() const = 0;
  15. virtual void AdjustTemperature(int temperature) = 0;
  16. virtual void InitializeDevice() = 0;
  17. };


3.2. Миттельшпиль

А теперь нарисуем 2 конкретных класса для работы с «реальными» термостатами широко известных и популярных фирм Brand_A и Brand_B (Как? Вы их не знаете? Я тоже):
  1. class Brand_A_TemperatureController : public TemperatureController
  2. {
  3. public:
  4. int GetTemperature() const
  5. {
  6. return (io_read(TEMP_REGISTER));
  7. }
  8. void AdjustTemperature(int temperature)
  9. {
  10. io_write(TEMP_CHANGE_REGISTER, temperature);
  11. }
  12. void InitializeDevice()
  13. {
  14. // Уговариваем девайс дружить с нами
  15. }
  16. };
  17. class Brand_B_TemperatureController : public TemperatureController
  18. {
  19. public:
  20. int GetTemperature() const
  21. {
  22. return (io_read(STATUS_REGISTER) & TEMP_MASK);
  23. }
  24. void AdjustTemperature(int temperature)
  25. {
  26. // Уж больно хитрый девайс попался: ему температуру в надо
  27. // Кельвинах предоставить! Хорошо, что не в Фаренгейтах.
  28. io_write(CHANGE_REGISTER, temperature + 273);
  29. }
  30. void InitializeDevice()
  31. {
  32. // Склоняем термостат к сотрудничеству
  33. }
  34. };

Вуаля! Осталось написать пару строчек в нашу программу:
  1. . . .
  2. TemperatureController *pTempCtrl = GetNextTempController();
  3. pTempCtrl->SetReferenceTemperature(10);
  4. pTempCtrl->InitializeDevice();
  5. . . .

И всё круто! Программка работает, заказчик доволен, мы читаем Хабр.

3.3. Эндшпиль

Проходит какое-то время и маркетологи (они не зря хлеб же едят!) придумали новый стильный термостат с большим сенсорным экраном и FM тюнером. Наш заказчик, приобрёв новый девайс, снова объявляется и с порога заявляет: «Хочу, понимаешь ли, чтобы программа поддерживала мою прелессссть!».

Ок, добавим ещё один девайс. Написать Brand_C_TemperatureController нам же труда не составит? В процессе доработки неожиданно выясняется, что новый термостат кроме своего сенсорного экрана имеет и продвинутую автоматику: т.е. его не надо вручную проверять и подгонять температуры. Достаточно скормить один раз требуемую температуру (у нас это ReferenceTemperature), а всё остальное он сделает сам. Это и хорошо (меньше возни), и плохо (наши классы не особо то приспособлены для такой ситуации).

Выход находим в 5 минут: Get/Set Reference в базовом классе объявляем виртуальными, а в классе для нового Brand_C термостата мы просто переопределяем эти методы для прямого чтения/записи температуры в термостат. Красота, не так ли? Сказано — сделано:

  1. class TemperatureController
  2. {
  3. // Переменная для хранения опорной температуры
  4. int m_referenceTemperature;
  5. public:
  6. // Геттер/сеттер теперь виртуальный
  7. // Наш новый концепт
  8. virtual int GetReferenceTemperature() const
  9. {
  10. return m_referenceTemperature;
  11. }
  12. virtual void SetReferenceTemperature(int referenceTemperature)
  13. {
  14. m_referenceTemperature = referenceTemperature;
  15. }
  16. virtual int GetTemperature() const = 0;
  17. virtual void AdjustTemperature(int temperature) = 0;
  18. virtual void InitializeDevice() = 0;
  19. };
  20. class Brand_C_TemperatureController : public TemperatureController
  21. {
  22. public:
  23. // Геттер/сеттер общается непосредственно с девайсом
  24. int GetReferenceTemperature() const
  25. {
  26. return (io_read(REFERENCE_REGISTER);
  27. }
  28. void SetReferenceTemperature(int referenceTemperature)
  29. {
  30. io_write(REFERENCE_REGISTER, referenceTemperature);
  31. }
  32. int GetTemperature() const
  33. {
  34. return (io_read(TEMP_MONITORING_REGISTER));
  35. }
  36. void AdjustTemperature(int temperature)
  37. {
  38. // Нафиг ненужный метод: мы температурой управляем в другом месте
  39. }
  40. void InitializeDevice()
  41. {
  42. // Тут шаманские пляски, чтобы термостат ниспослал нам хорошую погоду
  43. }
  44. };

По закону жанра, становится понятно, что сейчас будет кульминация. Самое время сказать: «Шах и мат!»

3.4. Ой, а что это было?

Перед разбором полётов ещё раз вспомним принцип подстановки Лисков: Наследующий класс должен дополнять, а не замещать поведение базового класса. А что мы только что сделали? Правильно! Мы заместили методы GetReferenceTemperature и SetReferenceTemperature. Мы изменили поведение класса. Чем это чревато? Процитирую ещё раз использование наших классов, дабы не изнашивать колесо вашей мышки:
  1. . . .
  2. TemperatureController *pTempCtrl = GetNextTempController();
  3. pTempCtrl->SetReferenceTemperature(10);
  4. pTempCtrl->InitializeDevice();
  5. . . .

Ещё не понятно? В случае работы с оборудованием Brand_A и Brand_B — всё отлично. А вот в случае использования Brand_C мы сначала пишем в устройство температуру, а потом только инициализируем устройство. Чем всё это может законичиться — фантазируйте сами. Возможно, что ничего страшного и не случится. А возможно, что полдня просидим в дебаге.

А вот если бы мы при создании класса Brand_C_TemperatureController (точнее, во время глупого переопределении злополучных геттеров/сеттеров) помнили про принцип подстановки, мы бы могли догадаться, что придуманная нами модель абстракции в новых реалиях — полное фуфло. Как эту ситуацию исправить? Увы, это не тема данной статьи. Я думаю, что итак всех утомил.

4. Хочу ещё!


По теме могу предложить почитать:
* Статья в Википедии (я предупреждал в самом начале!);
* The Liskov Substitution Principle — именно отсюда я и украл пример для этого топика;
* Гугл.

5. Десерт


О! Вспомнил! Статью положено разбавлять картинками. Вот:
Принцип подстановки Барбары Лисков©

Если этот демотиватор заставил вас улыбнуться, значит вам понятно, про что я настрогал столько текста.

Удачи! И пусть баги реже встречаются на вашем пути!
Tags:ооппроектированиеклассыпринцип подстановкиc++наследование
Hubs: Programming
+65
70.3k 117
Comments 55
Top of the last 24 hours