|
© 2026
Александр Легалов
Содержание
LSP (Liskov Substitution Principle). Принцип подстановки Барбары Лисков
Этот принцип остается для меня самым загадочным и непонятным. В смысле: а причем здесь принцип? Возможно, описывая ниже свою точку зрения, я сильно накосячил, но решил выразить возникшее субъективное восприятие.
Во-первых это связано с формулировкой принципа подстановки, в виде цитаты Барбары Лисков [bl-1988Barbara Liskov. Data Abstraction and Hierarchy. SIGPLAN Notices, 23,5 (May, 1988).], по сути являющейся лишь определением подтипа:
Определение подтипа из статьи Барбары Лисков
Из всего разнообразия переводов как книг Роберта Мартина, так и переводчиков в сети Интернет я решил остановиться на следующей интерпретации.
Иерархия типов состоит из подтипов и супертипов. Интуитивное представление о подтипе — это тип, объекты которого обеспечивают все поведение объектов другого типа (супертипа) плюс нечто дополнительное. Здесь требуется нечто вроде следующего свойства подстановки: если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P остается неизменным при подстановке o1 вместо o2, то S является подтипом T.
Варианты определений, прочитанные в ряде других источников, например, в [mr2004Мартин Р. Быстрая разработка программ: принципы, примеры, практика. – М.: Издательский дом “Вильямс”, 2004. – 752 с., mr2011Мартин Р., Мартин М. Принципы, паттерны и методики гибкой разработки на языке C#. – СПб.: Символ-Плюс, 2011. – 768 с.], сделаны похоже пьяными переводчиками. Но вряд ли эту цитату, даже переведенную в некоторых книгах Роберта Мартина правильно [mr2018Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. — СПб.: Питер, 2018. — 352 с.], можно трактовать как принцип. Скорее всего это, все-таки, просто определение подтипа. Если вчитаться, то создается впечатление, что речь идет только об агрегатном расширении без изменения функциональности супертипа. Или хотя бы его одного из методов, только относительно которого новый тип является подтипом. Необязательно, что другие методы не будут переопределяться так что для них отношение подтипа будет сохранено. На это также намекает и формула, определяющая требования к подтипу [en-wikiLiskov substitution principle. Википедия (англоязычная версия).].
Формула, определяющая подтип
Во-вторых, несмотря на множество описаний SOLID, мало где демонстрируются примеры правильного применения этого принциа. В основном повторяются глупости дядюшки Боба о прямоугольнике и квадрате (иногда об эллипсе и круге). Возможно я плохо искал? В видеозаписи своей лекции про SOLID, я тоже повторил эту глупость [my-solidЛегалов А.И. Принципы SOLID и процедурно-параметрическое программирование. Видео на rutube.]. Хотя в упомянутом выше первоисточнике Барбары Лисков [bl-1988Barbara Liskov. Data Abstraction and Hierarchy. SIGPLAN Notices, 23,5 (May, 1988).] приведены некоторые обобщенные примеры, демонстрирующие корректные иерархии, связывающие типы и подтипы. Непонятно и то, как определение, написанное в наш период застоя (в 80-е), когда еще не было принципов, и ООП развивалось беспринципно, в наши лихие девяностые, в период рассвета паттернов проектирования, вдруг превратилась в принцип.
Почему описание антипримера "прямоугольник-квадрат" на мой взгляд является глупостью. Хотя бы просто потому, что в реальной жизни данные отношение выражаются через ограничения (частные случаи) значенией параметров (сами знаете каких в данном случае), а не наследование. Чтобы было понятнее, можно расширить набор фигур, являющихся этими частными случаями для других более общих понятий. Тогда использование наследования как средства уточнения отпадет само собой. Поэтому доведем идею до абсурда:
- квадрат - частный случай прямоугольника;
- квадрат - частный случай ромба;
- прямоугольник - частный случай параллелограмма;
- ромб - частный случай параллелограмма;
- паралеллограмм - частный случай трапеции (по одном из двух определений трапеции [trapОпределение трапеции]);
- трапеция - частный случай четырехугольника.
В своей статье Барбара лисков приводит более подробное описание, что принадлежит подтипу, а что нет. Цитирую.
"... Подтип должен иметь все операции своего супертипа, иначе программа-пользователь не сможет использовать операцию, от которой он зависит. Однако простого наличия операций с правильными именами и сигнатурами недостаточно. (Сигнатура операции определяет количество и типы ее входных и выходных аргументов.) Операции также должны делать одно и то же..."
Исходя представленных выше определений и цитат квадрат Роберта Мартина изначально не может быть подтипом прямоугольника уже только потому, что изменение его методов, стало иначе воздействовать на состояние родительского класса, что автоматически приводит к изменению поведения ранее написанного кода. В порождаемом дочернем классе (подклассе) не стоит изменять методы родительского класса (суперкласса), а имеет смысл только добавлять новые методы и состояния, которые, при подстановке в ранее написанный и используемый код прямо или косвенно не будут влиять на его поведение. Но зато можно написать дополнительный код, использующий методы производного класса в соответствии с новой функциональностью расширенного агрегата. То есть, использовать дополнительные возможности "устаревшего" после появления ОО паттернов проектирования наследования реализации.
Что же тогда дядюшка Боб формулирует в качестве принципа подстановки?
LSP. Что же это такое?
Пятьдесят оттенков наследования
Наследование в ООП использовалось и используется в различных интерпретациях. Ряд их признаны устаревшими, другие продолжают использоваться. В качестве примера приведу одну из ранних классификаций форм наследования, заимствованную из книги Т. Бадда [bl-1988Бадд Т. Объектно-ориентированное программирование в действии. /Пер. с англ. - СПб: Питер, 1997 - 464 с.].
- Порождение подклассов для специализации (порождение подтипов).
- Порождение подкласса для спецификации (наследование интерфейсов).
- Порождение подкласса с целью конструирования.
- Порождение подкласса для обобщения.
- Порождение подкласса для расширения (версия подтипов).
- Порождение подкласса для ограничения.
- Порождение подкласса для варьирования (версия наследования интерфейсов).
- Порождение подкласса для комбинирования (множественное наследование)
Опс... 50 не набралось. Но если еще поискать или придумать...
Варианты для специализации и расширения можно отнести к порождению подтипов и, следовательно, они могут соответствовать определению Барбары Лисков. То, что выдается за него Робертом Мартином, на мой взгляд, является подменой на наследование интерфейсов. Попытаюсь это пояснить. На примерах из его же книг.
В своих более ранних книгах, затрагивающих SOLID, при описании LSP большой акцент делается на то, что не является этим принципом. Приводятся примеры из процедурного программирования. Далее следует долгое рассуждение о прямоугольнике и квадрате. После этого приводится правильный пример из личного опыта в виде диаграммы классов, соответствующей LSP. Эта диаграмма одинакова во обоих цитируемых мною книгах с отличием только на используемый язык программирования и квалификацию переводчика [mr2004Мартин Р. Быстрая разработка программ: принципы, примеры, практика. – М.: Издательский дом “Вильямс”, 2004. – 752 с.,
mr2011Мартин Р., Мартин М. Принципы, паттерны и методики гибкой разработки на языке C#. – СПб.: Символ-Плюс, 2011. – 768 с.]. Для демонстрации приведу более качественную картинку из книги, использующей в качестве языка C#.
Решение, удовлетворяющее принципу LSP!?
Описание, приведенное к этой схеме в книгах, явно указывает на изменение поведений при использовании различных классов Set, не обеспечивая общую адекватную подмену. Помимо этого схемы подмен не соответствуют определению Барбары Лисков, где речь идет о замене суперкласса его подклассом. В данном случае суперкласс (конкретно - интерфейс) подменяется на альтернативные, не связанные между собой классы. Но такие схемы полностью соответствуют наследованию интерфейсов, что вполне привязано к современным методам разработки ОО программ.
В книге по чистой архитектуре [mr2018Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. — СПб.: Питер, 2018. — 352 с.] в качестве примера соответствия LSP приводится класс с именем License, который имеет метод с именем calcFee(), вызываемый приложением Billing. Заявляется что, классы PersonalLicense и BusinessLicense, реализуя разные алгоритмы, являются при этом "подтипами" класса License. Однако вряд ли это соответствует определению подтипов по Лисков, когда подмена одного класса другим при разных алгоритмах ведет к разному поведению. Но наследование интерфейсов явно проявляется, демонстрируя следование тренду, задаваемому ОО паттернами проектирования.
License и его производные, соответствуют LSP!?
Таким образом, интерпретация в SOLID определения подтипа Барбары Лисков и использование его в качестве принципа, приводят меня в ступор. Исходя из выше изложенного и демонстрируемого в примерах, я бы обозвал его принципом подстановки (или наследования) интерфейсов (The interfaces substitution principle, ISP). Но возможно из-за занятости буквы I в другом принципе дядюшка Боб отдал дань уважения обладателю премии Тьюринга? Думаю, это его право.
Вариации на тему подтипов и ОО подстановки
Первоначальная программа
Ничто не мешает посмотреть на то, во что, может вылиться непосредственное использование LSP. В качестве простого примера, демонстрирующего использование наследования, тоже обратимся к прямоугольнику, который может вычислять свою площадь (solid/lsp/oop/01-rectangle-start). Его класс выглядит следующим образом.
class Rectangle {
int x, y; // стороны прямоугольника
public:
void setX(int _x) {x = _x;}
void setY(int _y) {y = _y;}
int getX() {return x;}
int getY() {return y;}
double Area() {
return double(x * y);
}
virtual void Print() {
std::cout << "Rectangle. x = " << x << "; y = " << y << "\n";
}
};
Объекты данного класса передаются для обработки методу в "большом приложении", имитирующему базовую программу.
// Код, использующий прямоугольник в большом приложении
class Application {
public:
// Метод, обеспечивающий использование подстановки
void BigMethod(Rectangle& r) {
auto result = r.Area() / -2.0;
std::cout << "Result = " << result << "\n";
}
};
Это приложение было предоставлено клиенту, который и использовал его по своему усмотрению.
// Клиентский код, использующий разработанное большое приложение
int main(int argc, char* argv[]) {
if(argc != 3) {
std::cout << "Incorrect command line. Use " << argv[0] << "X Y\n";
return -1;
}
//----------------------------------------------------------------------------
std::cout << "---------- Using a simple rectangle ----------\n";
Rectangle r;
r.setX(std::stoi(argv[1]));
r.setY(std::stoi(argv[2]));
r.Print();
// Использование приложения
Application a;
a.BigMethod(r);
}
Расширение функциональности в подменяющем классе
Спустя какое-то время разработчики решили расширить функциональность, сохранив возможность работать клиенту с использованием старого приложения, но измененным классом прямоугольника, в который добавили параметр цвета и метод вычисления периметра (solid/lsp/oop/02-rectangle-color).
// Подтип, расширяющий родителя добавлением цвета и вычислением периметра
class ColoredRectangle: public Rectangle {
int color;
public:
void setColor(int c) {color = c;}
double Perimeter() {
return 2.0 * (getX() + getY());
}
virtual void Print() {
Rectangle::Print();
std::cout << " Color = " << color << "\n";
}
};
Помимо работы со старым приложением был добавлен код, использующий возможности нового прямоугольника.
// Дополнительный код расширяющий ранее написанное приложение
class ColoredExpanded {
public:
// Метод, обеспечивающий поддержку дополнительной функциональности
void ExpandedMethod(ColoredRectangle &cr) {
auto result = cr.Perimeter() * 10.0;
std::cout << "Colored Expanded result = " << result << "\n";
}
};
Сформированный клиентский код был расширен на новую функциональность. При этом предшествующий код продолжил также выполняться с объектом, сформированным на основе производного класса, являющегося для вычисления площади подтипом обычного прямоугольника.
// Новый клиентский код, использующий разработанное большое приложение
// И дополнительную приладу, связанную с появлением подставляемого подтипа
int main(int argc, char* argv[]) {
if(argc != 4) {
std::cout << "Incorrect command line. Use " << argv[0] << "X Y Color\n";
return -1;
}
//----------------------------------------------------------------------------
std::cout << "---------- Using a simple rectangle ----------\n";
Rectangle r;
r.setX(std::stoi(argv[1]));
r.setY(std::stoi(argv[2]));
r.Print();
// Использование приложения
Application a;
a.BigMethod(r);
//----------------------------------------------------------------------------
std::cout << "---------- Using a colored rectangle ----------\n";
// Формирование подтипа
ColoredRectangle cr;
cr.setX(std::stoi(argv[1]));
cr.setY(std::stoi(argv[2]));
cr.setColor(std::stoi(argv[3]));
cr.Print();
// Использование прежнего приложения
a.BigMethod(cr);
// Подключение расширенных функций
ColoredExpanded ce;
ce.ExpandedMethod(cr);
}
Следует отметить, что для простейших подстановок, использующих методы базового класса, не стоит даже задействовать механизм виртуализации. Вновь добавляемый код не влияет на состояние своего суперкласса, но изменяет поля своего производного класса.
Использование виртуализации
Конечно можно использовать и виртуальные методы, переопределяя методы базового класса. Но все равно побочный эффект должен затрагивать только то, что связано с новым классом. При этом еще в исходной программе для изменяемых в последствии методов необходимо заложить виртуализацию. В противном случае придется в C++ слегка модифицировать родительский класс (во многих языках все методы класса изначально виртуальные). В следующем примере (solid/lsp/oop/03-rectangle-counter) переопределение виртуального метода вычисления площади изменяет счетчик, расположенный в производном классе.
// Подтип, расширяющий родителя посчетом числа вызвов метода
// вычисления площади и вычислением периметра
class CountedRectangle: public Rectangle {
int counter;
public:
CountedRectangle(): counter(0) {}
double Area() override {
++counter;
return Rectangle::Area();
}
virtual void Print() {
Rectangle::Print();
std::cout << " Counter = " << counter << "\n";
}
double Perimeter() {
return 2.0 * (getX() + getY());
}
};
Неизменность обработки для старой задачи обеспечивается вызовом из виртуального метода производного класса метода вычисления площади, размещенного в базовом классе. При этом производный класс, не влияя на родителя, осуществляет внутри себя подсчет этих вызовов, изменяя, тем самым, только свою переменную и не трогая состояние родительского класса.
Дополнительный код, как и в предыдущем примере, использует вычисление периметра, а клиенту предоставляется для порождения объектов класс прямоугольника со счетчиком. При этом ранее написанный метод вызывается дважды, чтобы проиллюстрировать изменение счетчика.
// Дополнительный код расширяющий ранее написанное приложение
class Expanded {
public:
// Метод, обеспечивающий поддержку дополнительной функциональности
void ExpandedMethod(CountedRectangle &cr) {
auto result = cr.Perimeter() * 20.0;
std::cout << "Counted Expanded result = " << result << "\n";
}
};
// Новый клиентский код, использующий разработанное большое приложение
// И дополнительную приладу, связанную с появлением подставляемого подтипа
int main(int argc, char* argv[]) {
if(argc != 3) {
std::cout << "Incorrect command line. Use " << argv[0] << "X Y\n";
return -1;
}
//----------------------------------------------------------------------------
std::cout << "---------- Using a simple rectangle ----------\n";
Rectangle r;
r.setX(std::stoi(argv[1]));
r.setY(std::stoi(argv[2]));
r.Print();
// Использование приложения
Application a;
a.BigMethod(r);
//----------------------------------------------------------------------------
std::cout << "---------- Using a rectangle with a counter ----------\n";
// Формирование подтипа
CountedRectangle cr;
cr.setX(std::stoi(argv[1]));
cr.setY(std::stoi(argv[2]));
cr.Print();
// Использование прежнего приложения дважды для изменения счетчика
a.BigMethod(cr);
a.BigMethod(cr);
// Подключение расширенных функций
Expanded e;
e.ExpandedMethod(cr);
cr.Print();
}
В целом реализация наследования обеспечивает поддержку гибкого расширения кода. Особенно в простых ситуациях. Оно позволяет наращивать функциональность путем добавления новых данных и методов в производных классах, сохраняя при этом возможность подстановки формируемых объектов вместо старых без изменения поведения. В этом в общем то и заключается принцип подстановки, который вряд ли нужно специально и целенаправленно использовать. Почему? Пришли паттерны и все испортили.
Композиция вместо наследования
Современные ОО подходы предполагают выделение абстрактных интерфейсов и композицию вместо наследования реализации. Поэтому во многих ситуациях про LSP, и его размытое толькование, можно забыть. Исходя их этого в большинстве аналогичных ситуаций сейчас обычно применяется несколько иной код. По сути это, как и связка "прямоугольник - квадрат", это тоже антипример для подстановки. Прямоугольник порождается не напрямую, а с использованием интерфейсов (для упрощения, игнорируя разделение ответственности, собрал все в одном), которые могут быть подвержены модификации в различных классах. В этом случае отношение подтипов между классами может отсутствовать. Они связаны только через интерфейс. Вариант, в котором добавляются прямоугольники со счетчиками, демонстрируется в следующем примере (solid/lsp/oop/04-rectangle-composition).
// Интерфейс, используемый для родственных связей
class RectInterface {
public:
virtual double Area() = 0;
virtual void Print() = 0;
};
class Rectangle: public RectInterface {
int x, y; // стороны прямоугольника
public:
void setX(int _x) {x = _x;}
void setY(int _y) {y = _y;}
int getX() {return x;}
int getY() {return y;}
double Area() override {
return double(x * y);
}
void Print() override {
std::cout << "Rectangle. x = " << x << "; y = " << y << "\n";
}
};
Первоначальный код, имитирующий "большую программу", должен взаимодействовать не напрямую с прямоугольником, а с предоставляемым интерфейсом.
// Код, использующий прямоугольник в большом приложении
class Application {
public:
// Метод, обеспечивающий использование подстановки
void BigMethod(RectInterface& r) {
auto result = r.Area() / -2.0;
std::cout << "Result = " << result << "\n";
}
};
А далее - в пекло наследование реализации. Заменяем его на композицию с использованием интерфейса для организации подмены. При этом методы прямоугольника будут вызываться косвенно через указатель на него (можно использовать и непосредственное включение в класс счетчика).
// Подтип, расширяющий родителя добавлением счетчика и вычислением периметра
class Counter: public RectInterface {
int counter;
Rectangle* rect;
public:
Counter(Rectangle* r): counter{0}, rect{r} {}
double Area() override {
++counter;
return rect->Area();
}
void Print() override {
rect->Print();
std::cout << " Counter = " << counter << "\n";
}
double Perimeter() {
return 2.0 * (rect->getX() + rect->getY());
}
};
Дополнительный код практически также вычисляет периметр.
// Дополнительный код расширяющий ранее написанное приложение
class Expanded {
public:
// Метод, обеспечивающий поддержку дополнительной функциональности
void ExpandedMethod(Counter &c) {
auto result = c.Perimeter() * 20.0;
std::cout << "Counted Expanded result = " << result << "\n";
}
};
А клиент выполняет те же действия, что и в предыдущем примере, используя при этом связывание с ранее разработанным прямоугольником.
// Новый клиентский код, использующий разработанное большое приложение
// И дополнительную приладу, связанную с появлением подставляемого подтипа
int main(int argc, char* argv[]) {
if(argc != 3) {
std::cout << "Incorrect command line. Use " << argv[0] << "X Y\n";
return -1;
}
//----------------------------------------------------------------------------
std::cout << "---------- Using a simple rectangle ----------\n";
// Формирование прямоугольника
Rectangle r;
// Инициализация прямоугольника
r.setX(std::stoi(argv[1]));
r.setY(std::stoi(argv[2]));
r.Print();
Application a;
a.BigMethod(r);
//----------------------------------------------------------------------------
std::cout << "---------- Using a rectangle with a counter ----------\n";
// Формирование счетчика с подключением прямоугольника
Counter c(&r);
c.Print();
// Использование отдельного прямоугольника, вызывающее первоначальный метод
// Использование прежнего приложения с новым прямоугольником
a.BigMethod(c);
a.BigMethod(c);
// Использование расширенных функций
Expanded e;
e.ExpandedMethod(c);
c.Print();
}
Немного некромантии
В завершении о LSP в ООП хотелось бы побыть в роли некроманта и поднять из могилы ряд MS DOSовских исходников, которые когда-то в рамках учебного процесса в "золотую и наивную" допаттерновскую эпоху демонстрировали возможности наследования реализации для расширения кода (пресловутый принцип подстановки). Правда, чтобы воскресить эту нежить (проверено только под Linux), пришлось вызвать архивных духов и поработать над ними напильником.
Все примеры демонстрируют формирование стека и очереди на основе наследования от однораправленного кольцевого списка. Это различные версии принципа подстановки, для демонстрации которых достаточно показать заголовочные файлы. Стиль моих 90-х сохранился. В первом версии (solid/lsp/oop/retro-list/01-common-list-for-queue-and-stack) рассмотрен вариант независимого расширения стека и очереди.
// list.h
// Разработка производных классов СТЕК и ОЧЕРЕДЬ на основе базового списка
#include <stdio.h>
#include <string.h>
// Элемент списка со значением
class ELEMENT {
char* name;
public:
ELEMENT(char*); // конструктор класса
~ELEMENT() {delete name;} // Деструктор класса
// вывод элемента класса
void Out(FILE* filePtr) {fprintf(filePtr, "%s ", name);}
};
// Промежуточный узел списка
struct NODE {
public:
ELEMENT* elemPtr;
NODE* next;
NODE(ELEMENT* e) {elemPtr = e;} // Конструктор
~NODE() {delete elemPtr;} // Деструктор
void Out(FILE* f) {elemPtr->Out(f);}
};
// Сам однонаправленный кольцевой базовый список
class LIST0 {
public:
NODE* tail; // указатель на последний элемент списка
char* name; // Имя списка (для идентификации при выводе).
LIST0(char* n); // Конструктор списка
~LIST0() {delete name;} // Деструктор
void Out(FILE*);
};
// Производный класс ОЧЕРЕДЬ
class QUEUE: public LIST0 {
public:
QUEUE(char* n): LIST0(n) {}; // Конструктор очереди
void Empty(void); // Опустошение очереди
~QUEUE() {Empty();} // Деструктор
void Append(ELEMENT*); // Добавление элемента в очередь
void Pop(void); // Выталкивание элемента из головы очереди
ELEMENT* Read(void); // Чтение элемента в голове очереди
};
// Производный класс СТЕК
class STACK: public LIST0 {
public:
STACK(char* n): LIST0(n) {}; // Конструктор стека
void Empty(void); // Опустошение стека
~STACK() {Empty();} // Деструктор
void Push(ELEMENT*); // Занесение элемента в стек
void Pop(void); // Выталкивание элемента из стека
ELEMENT* Read(void); // Чтение элемента в голове стека
};
Второй пример (solid/lsp/oop/retro-list/02-list0-list1-queue-and-stack) демонстрирует более извращенный пример расширения, когда в начале создается некоторый кастрированный образ списка. Затем, с использованием наследования, он расширяется до более функциональной версии, на основе которой уже формируются стек и очередь.
// list.h
// Разработка производных классов СТЕК и ОЧЕРЕДЬ на основе базового списка
// LIST1, построенного на основе базового списка LIST0
#include <stdio.h>
#include <string.h>
// Элемент списка со значением
class ELEMENT {
char* name;
public:
ELEMENT(char*); // конструктор класса
~ELEMENT() {delete name;} // Деструктор класса
// вывод элемента класса
void Out(FILE* filePtr) {fprintf(filePtr, "%s ", name);}
};
// Промежуточный узел списка
struct NODE {
public:
ELEMENT* elemPtr;
NODE* next;
NODE(ELEMENT* e) {elemPtr = e;} // Конструктор
~NODE() {delete elemPtr;} // Деструктор
void Out(FILE* f) {elemPtr->Out(f);}
};
// Сам однонаправленный кольцевой базовый список
class LIST0 {
public:
NODE* tail; // указатель на последний элемент списка
char* name; // Имя списка (для идентификации при выводе).
LIST0(char* n); // Конструктор списка
~LIST0() {delete name;} // Деструктор
void Out(FILE*);
};
// Однонаправленный кольцевой список второго уровня
class LIST1: public LIST0 {
public:
LIST1(char* n): LIST0(n) {}
void Empty(void); // Опустошение списка
~LIST1() {Empty();} // Деструктор
void Pop(void); // Выталкивание элемента из списка
ELEMENT* Read(void); // Чтение элемента в голове списка
};
// Производный класс ОЧЕРЕДЬ
class QUEUE: public LIST1 {
public:
QUEUE(char* n): LIST1(n) {} // Конструктор очереди
void Append(ELEMENT*); // Добавление элемента в очередь
};
// Производный класс СТЕК
class STACK: public LIST1 {
public:
STACK(char* n): LIST1(n) {} // Конструктор стека
void Push(ELEMENT*); // Занесение элемента в стек
};
Ну и, наконец, апофеоз извращенности конструкторской мысли - создание стека от недосписка, а очереди от стека (solid/lsp/oop/retro-list/03-list0-stack-queue).
// list.hpp
// Разработка производных классов СТЕК и ОЧЕРЕДЬ
// Класс STACK разрабатывается на основе класса LIST0.
// Класс QUEUE разрабатывается на основе класса STACK.
#include <stdio.h>
#include <string.h>
// Элемент списка со значением
class ELEMENT {
char* name;
public:
ELEMENT(char*); // конструктор класса
~ELEMENT() {delete name;} // Деструктор класса
// вывод элемента класса
void Out(FILE* filePtr) {fprintf(filePtr, "%s ", name);}
};
// Промежуточный узел списка
struct NODE {
public:
ELEMENT* elemPtr;
NODE* next;
NODE(ELEMENT* e) {elemPtr = e;} // Конструктор
~NODE() {delete elemPtr;} // Деструктор
void Out(FILE* f) {elemPtr->Out(f);}
};
// Сам однонаправленный кольцевой базовый список
class LIST0 {
public:
NODE* tail; // указатель на последний элемент списка
char* name; // Имя списка (для идентификации при выводе).
LIST0(char* n); // Конструктор списка
~LIST0() {delete name;} // Деструктор
void Out(FILE*);
};
// Производный класс СТЕК
class STACK: public LIST0 {
public:
STACK(char* n): LIST0(n) {}; // Конструктор стека
void Empty(void); // Опустошение стека
~STACK() {Empty();} // Деструктор
void Push(ELEMENT*); // Занесение элемента в стек
void Pop(void); // Выталкивание элемента из стека
ELEMENT* Read(void); // Чтение элемента в голове стека
};
// Производный класс ОЧЕРЕДЬ
class QUEUE: public STACK {
public:
QUEUE(char* n): STACK(n) {}; // Конструктор очереди
// Добавление элемента в очередь
void Append(ELEMENT* e) {Push(e); tail = tail->next;}
};
Когда-то это казалось вершиной объектно-ориентированного мышления.
Эти примеры использовались в переломном для меня чтении лекций на курсах по повышению квалификации преподавателей [oop-badЛегалов А.И. Об объектно-ориентированной парадигме программирования (путешествие в ООП и обратно).]. Я с упоеним рассказывал сказки об этом ОО способе построения кода, который еще не вымершие процедурные программисты называли методом для ленивых. Такое расширение считалось модным и по сути являлось использованием LSP который, правда, я и за принцип не считаю. Если честно, то о работах Барбары Лисков узнал в конце нулевых из книг по SOLID Роберта Мартина (да простит меня компьютерная наука и техника). Это было гораздо позже прочтения книги о паттернах проектирования и их практического использования. Поэтому, видимо за давностью лет, принцип подстановки на меня не произвел впечатления. В то время я уже погрузился в процедурно-параметрическое программирование.
Принцип подстановки и ППП
ПП подход использует иную терминологию для данных, поддерживающих полиморфизм. Поэтому определение Барабары Лисков для типа и его подтипа напрямую не подходят. Но в принципе ничто, с позиций 4П, не мешает трактовать отношение между обобщением и специциализацией как тип и подтип. При этом подстановка проявляется не на уровне методов классов а на уровне функций, используемых для доступа к содержимому обобщений и специализаций.
Старт. Просто процедурная программа
ПП подход расширяет процедурный. Когда на начальном этапе мы не думаем о том, что дальше появится что-то более грандиозное, то можем просто написать процедурную программу (solid/lsp/ppp/rectangle/01-rectangle-start).
typedef struct Rectangle {
int x, y; // стороны прямоугольника
}<> Rectangle;
void setX(Rectangle* r, int _x) {r->x = _x;}
void setY(Rectangle* r, int _y) {r->y = _y;}
int getX(Rectangle* r) {return r->x;}
int getY(Rectangle* r) {return r->y;}
double Area(Rectangle* r) {return (double)(r->x * r->y);}
void Print(Rectangle* r) {
printf("Rectangle. x = %d; y = %d\n", r->x, r->y);
}
// Функция, использующая прямоугольник
void BigMethod(Rectangle* r) {
double result = Area(r) / -2.0;
printf("Result = %lf\n", result);
}
// Клиентский код, использующий разработанное большое приложение
int main(int argc, char* argv[]) {
if(argc != 3) {
printf("Incorrect command line. Use %s X Y\n", argv[0]);
return -1;
}
//----------------------------------------------------------------------------
printf("---------- Using a simple rectangle ----------\n");
Rectangle r;
setX(&r, atoi(argv[1]));
setY(&r, atoi(argv[2]));
Print(&r);
// Использование приложения
BigMethod(&r);
}
Данный код просто напрямую реализует требуемую обработку прямоугольника и не требует дополнительных комментариев. Пока ППП не пахнет.
Новые прямоугольники. Появление ППП
Особенностью использования полиморфизма при процедурно-параметрическом подходе является то, что будущие полиморфные расширения необходимо изначально заложить в структуре. Точнее в обобщенной структуре. Это касается прямоугольника, который должен показать, что является основой обобщения и позволяет формировать различные специализации. В PPC это выражается в добавлении к структуре угловых скобок (solid/lsp/ppp/rectangle/02-rectangle-color-count). А их то и не было. Поэтому, если мы это не предусмотрели заранее (но могли бы, в предыдущей программе ничего бы не испортилось), то их нужно будет добавить. Остальные функции сформированной основы обобщенной структуры можно не менять.
typedef struct Rectangle {
int x, y; // стороны прямоугольника
}<> Rectangle;
void setX(Rectangle* r, int _x) {r->x = _x;}
void setY(Rectangle* r, int _y) {r->y = _y;}
int getX(Rectangle* r) {return r->x;}
int getY(Rectangle* r) {return r->y;}
double RectangleArea(Rectangle* r) {return (double)(r->x * r->y);}
void RectanglePrint(Rectangle* r) {
printf("Rectangle. x = %d; y = %d\n", r->x, r->y);
}
Однако (как и в ООП) использование "большого приложения" тоже необходимо предусмотреть заранее. В нем осуществляется вычисление площади, которое может отличаться в разных типах специализированных прямоугольников. Поэтому в нем нужно вызывать обобщенную функцию вычисления площади. Также будет обрастать полиморфизмом и функция вывода информации о разных прямоугольниках. Для исходного прямоугольника достаточно вложить вызовы функций для уже разработанной структуры.
double Area<Rectangle* r>() {return RectangleArea(r);}
void Print<Rectangle* r>() {RectanglePrint(r);}
// Функция, использующая прямоугольник
void BigMethod(Rectangle* r) {
double result = Area<r>() / -2.0;
printf("Big Result = %lf\n", result);
}
Появление цветных прямоугольников можно реализовать за счет использования цвета в качестве основы специализаци, что и определяет расширение обобщения еще одной специализацией. По сути эта альтернатива при подмене простого прямоугольника и будет играть роль расширенного агрегата. Для удобства вычисление периметра можно реализовать как функцию обычного прямоугольника (еще пригодится), а также сформировать обработчики специализаций для всех созданных обобщающих функций. Также можно реализовать дополнительный код, ориентированный на специфическую обработку цветных прямоугольников (ColorExpandedMethod).
// Периметр можно обобщить. Он может использоваться с любыми прямоугольниками.
double Perimeter<Rectangle* r>() {
return 2.0 * (r->x + r->y);
}
//==============================================================================
// Специализированный прямоугольник с цветом
Rectangle + <Color: int;>;
void setColor(struct Rectangle.Color* cr, int c) {
cr->@ = c;
}
// Отдельный обработчик для печати прямоугольников с цветом
void Print<Rectangle.Color* cr>() {
RectanglePrint((Rectangle*)cr); // явное обращение к основе
printf(" Color = %d\n", cr->@);
}
// Метод, обеспечивающий поддержку дополнительной функциональности
// для прямоугольников с цветом
void ColorExpandedMethod(struct Rectangle.Color* cr) {
double result = Perimeter<(Rectangle*)cr>() * 10.0;
printf("Color Expanded result = %lf\n", result);
}
Чтобы не повторяться в программу можно также, и по такой же схеме, добавить и прямоугольник со счетчиком. Практически ничего нового кроме дополнительных обработчиков специализации и кода, используемого только с этими прямоугольниками.
// Специализированный прямоугольник со счетчиком
Rectangle + <Counter: int;>;
void setCounter(struct Rectangle.Counter* ct, int v) {
ct->@ = v;
}
// Переопределение вычисление площади с добавлением счетчика
double Area<Rectangle.Counter* ct>() {
++ct->@;
return RectangleArea((Rectangle*)ct);
}
// Отдельный обработчик для печати прямоугольников со счетчиком
void Print<Rectangle.Counter* ct>() {
RectanglePrint((Rectangle*)ct); // явное обращение к основе
printf(" Counter = %d\n", ct->@);
}
// Метод, обеспечивающий поддержку дополнительной функциональности
// для прямоугольников со счетчиком
void CounterExpandedMethod(struct Rectangle.Counter* ct) {
double result = Perimeter<(Rectangle*)ct>() * 20.0;
printf("Counter Expanded result = %lf\n", result);
}
Наличие стольких разновидностей прямоугольников несколько удлинило клиентский код, использующий их.
// Клиентский код, использующий расширенное приложение
int main(int argc, char* argv[]) {
if(argc != 4) {
printf("Incorrect command line. Use %s X Y Color\n", argv[0]);
return -1;
}
//----------------------------------------------------------------------------
// Использование обычного прямоугольника, но уже в обертке.
printf("---------- Using a simple rectangle ----------\n");
struct Rectangle r;
// Использование исходного приложения для обернутого прямоугольника
setX(&r, atoi(argv[1]));
setY(&r, atoi(argv[2]));
Print<&r>();
BigMethod(&r);
//----------------------------------------------------------------------------
// Создание и использование прямоугольника с цветом
printf("---------- Using a colored rectangle ----------\n");
struct Rectangle.Color cr;
setX((Rectangle*)&cr, atoi(argv[1]));
setY((Rectangle*)&cr, atoi(argv[2]));
setColor(&cr, atoi(argv[3]));
Print<(Rectangle*)&cr>();
// Использование прежнего приложения для прямоугольника с цветом
BigMethod((Rectangle*)&cr);
// Подключение расширенных функций
ColorExpandedMethod(&cr);
//----------------------------------------------------------------------------
// Создание и использование прямоугольника со счетчиком
printf("---------- Using a rectangle with a counter ----------\n");
struct Rectangle.Counter ctr;
setX((Rectangle*)&ctr, atoi(argv[1]));
setY((Rectangle*)&ctr, atoi(argv[2]));
setCounter(&ctr, 0); // начальная установка счетчика
Print<(Rectangle*)&ctr>();
// Использование прежнего приложения для прямоугольника со счетчиком
BigMethod((Rectangle*)&ctr);
BigMethod((Rectangle*)&ctr);
// Подключение расширенных функций
CounterExpandedMethod(&ctr);
Print<(Rectangle*)&ctr>();
}
А что с композицией?
Приведенный пример показывает достаточно простое, на мой взгляд, использование принципа подстановки с применением ПП полиморфизма. Формируемые агрегаты используют основу обобщенной структуры для подмены в полиморфных функциях, расширяясь за счет основ специализаций, являющихся в общем случае произвольными типами данных. Вопрос может вызывать только необходимость изменения структуры прямоугольника, в которую нужно добавить угловые скобки. Можно ли обойтись без них? В принципе да. За счет использования прямоугольника в качестве основы специализации. Но все равно полиморфизм потребует построения другого обобщения, от которого можно будет реализовать обработку подключаемых альтернативных прямоугольников. В целом, как мне кажется такое решение вносить дополнительные сущности, нарушая тем самым принцип KISS. Но для очистки совести попробую его предоставить (solid/lsp/ppp/rectangle/03-rectangle-composition).
Для обобщения разных прямоугольников (как и в случае интерфейсов) можно ввести некоторый коннектор, обобщающий что угодно.
// Универсальный коннектор, позволяющий обобщать что угодно
typedef struct Connector {}<> Connector;
// Площадь чего угодно
double Area<Connector* c>() {
printf("You need redefine Area function\n");
exit(-1);
}
// Печать чего угодно
void Print<Connector* c>() {
printf("You need redefine Area function\n");
exit(-2);
}
Он является аргументом обобщающих функций, реализующих общую функциональность, выбрасывая ошибку, если отсутствует обработчик требуемой специализации.
Обычный прямоугольник реализует функции, требуемые для первоначального приложения.
// Обычный прямоугольник
typedef struct Rectangle {
int x, y; // стороны прямоугольника
} Rectangle;
void setX(Rectangle* r, int _x) {r->x = _x;}
void setY(Rectangle* r, int _y) {r->y = _y;}
int getX(Rectangle* r) {return r->x;}
int getY(Rectangle* r) {return r->y;}
double RectangleArea(Rectangle* r) {return (double)(r->x * r->y);}
void RectanglePrint(Rectangle* r) {
printf("Rectangle. x = %d; y = %d\n", r->x, r->y);
}
В стартовом "большом приложении" он используется для формирования специализации коннектора. Для этой специализации создаются обработчики, осуществляющие вычисление площади и вывод полей прямоугольника. "Большое приложение" в качестве аргумента принимает обобщенный коннектор, что позволяет подключать любую специализацию в вызовах внутри него используемых полиморфных функций.
// Использование предварительной унификации для последующих разных подстановок
Connector + <Rectangle;>;
// Определение полиморфно, чтобы обеспечить поддержку LSP
double Area<Connector.Rectangle* cr>() {return RectangleArea(&(cr->@));}
// Площадь через коннектор
void Print<Connector.Rectangle* cr>() {RectanglePrint(&(cr->@));}
// Функция, изначально использующая прямоугольник, но учитывающая
// появление новых специализаций чтобы использовать LSP.
void BigMethod(struct Connector* с) {
double result = Area<с>() / -2.0;
printf("Big Method Result = %lf\n", result);
}
Цветной треугольник формируется как обычная структура, содержащее поле цвета и непосредственно включающее поле прямоугольника. Также добавляется функция, осуществляющая вычисление периметра.
// Периметр для прямоугольника появился позднее.
// Он будет использоваться только с новыми специализациями
// и их функциями, расширяющими общую функциональность.
double RectanglePerimeter(Rectangle* r) {
return 2.0 * (r->x + r->y);
}
//==============================================================================
// Специализированный прямоугольник с цветом, использующий композицию
typedef struct ColorRectangle {
int color;
Rectangle r; // но можно и через указатель...
} ColorRectangle;
void setColor(ColorRectangle* cr, int c) {
cr->color = c;
}
С использованием его в качестве основы создается очередная специализация обобщения и дополнительные ее обработчики. Также формируется код, который использует только эту специализацию.
// Добавление в специализацию прямоугольников с цветом
Connector + <ColorRectangle;>;
// Так как будет использоваться LSP, то нужно площадь определить полиморфно.
double Area<Connector.ColorRectangle* cr>() {
return RectangleArea(&(cr->@r)); // явное обращение к основе
}
// Отдельный обработчик для печати прямоугольников с цветом
void Print<Connector.ColorRectangle* cr>() {
RectanglePrint(&(cr->@r)); // явное обращение к основе
printf(" Color = %d\n", cr->@color);
}
// Метод, обеспечивающий поддержку дополнительной функциональности
// для прямоугольников с цветом
void ColorExpandedMethod(struct ColorRectangle* cr) {
double result = RectanglePerimeter(&(cr->r)) * 10.0;
printf("Color Expanded result = %lf\n", result);
}
Аналогично организуется использование счетчика и формирование для его соответствующего обработчика
// Специализированный прямоугольник со счетчиком, использующий композицию
typedef struct CountRectangle {
int counter;
Rectangle* r; // но можно и обчным включением...
} CountRectangle;
void setCounter(CountRectangle* cc, int c) {
cc->counter = c;
}
//------------------------------------------------------------------------------
// Добавление в специализацию прямоугольника со счетчиком
Connector + <CountRectangle;>;
// Переопределение вычисления площади с добавлением счетчика
double Area<Connector.CountRectangle* cc>() {
++(cc->@counter);
return RectangleArea(cc->@r);
}
// Отдельный обработчик для печати прямоугольников со счетчиком
void Print<Connector.CountRectangle* cc>() {
RectanglePrint(cc->@r); // явное обращение к основе
printf(" Counter = %d\n", cc->@counter);
}
// Метод, обеспечивающий поддержку дополнительной функциональности
// для прямоугольников со счетчиком
void CounterExpandedMethod(CountRectangle* cc) {
double result = RectanglePerimeter(cc->r) * 20.0;
printf("Counter Expanded result = %lf\n", result);
}
Для работы с конкретными прямоугольниками в клиенте достаточно использовать соответствующие специализации, которые используются как в полиморфных функциях, так и расширителях, написанных специально для специализаций.
// Клиентский код, использующий расширенное приложение
int main(int argc, char* argv[]) {
if(argc != 4) {
printf("Incorrect command line. Use %s X Y Color\n", argv[0]);
return -1;
}
//----------------------------------------------------------------------------
// Использование обычного прямоугольника, но уже в обертке.
struct Connector.Rectangle cr;
// Использование исходного приложения для обернутого прямоугольника
printf("---------- Using a simple rectangle ----------\n");
setX(&(cr.@), atoi(argv[1]));
setY(&(cr.@), atoi(argv[2]));
Print<(Connector*)&cr>();
BigMethod((Connector*)&cr);
//----------------------------------------------------------------------------
// Создание и использование прямоугольника с цветом
printf("---------- Using a colored rectangle ----------\n");
struct Connector.ColorRectangle ccr;
setX(&(ccr.@r), atoi(argv[1]));
setY(&(ccr.@r), atoi(argv[2]));
setColor(&(ccr.@), atoi(argv[3]));
Print<(Connector*)&ccr>();
// Использование прежнего приложения для прямоугольника с цветом
BigMethod((Connector*)&ccr);
// Подключение расширенных функций
ColorExpandedMethod(&(ccr.@));
//----------------------------------------------------------------------------
// Создание и использование прямоугольника со счетчиком
printf("---------- Using a rectangle with a counter ----------\n");
struct Connector.CountRectangle ctr;
Rectangle r;
ctr.@r = &r;
setX(&r, atoi(argv[1]));
setY(&r, atoi(argv[2]));
setCounter(&(ctr.@), 0); // начальная установка счетчика
Print<(Connector*)&ctr>();
// Использование прежнего приложения для прямоугольника со счетчиком
BigMethod((Connector*)&ctr);
BigMethod((Connector*)&ctr);
// Подключение расширенных функций
CounterExpandedMethod(&(ctr.@));
Print<(Connector*)&ctr>();
}
Резюме
Использование данного технического приема вряд ли можно называть принципом проектирования или кодирования. Зачастую, когда требуется достижение определенных критериев качества, наследование реализации заменяется на композицию и наследование интерфейса. Вместе с тем в ряде ситуаций расширение агрегата, обеспечивающее использование легаси кода, включая и поддержку полиморфизма, может оказаться полезным как для ООП, так и в случае ППП. Процедурно-параметрический подход может в этих ситуаций оказаться более гибким, так как позволяет легко расширять ранее существующие данные не предъявляя специальных требований к полиморфным аргументам и не используя лишние композиции.
Содержание
|