Здравствуйте, igor-booch, Вы писали:
ГВ>>Правильно понял, но это справедливо только при условии, что наследование (inheritance) типов в данной программной системе действительно выражает отношение "тип-подтип" в том смысле, который подразумевает LSP. Обычно полагают, что так оно и есть, но в отдельных случаях это правило может нарушаться.
IB>Приведите пожалуйста примеры неправильного и правильного наследования сточки зрения LSP.
С точки зрения LSP нет никакого "наследования". Есть отношение "тип-подтип", а воплощено оно наследованием или нет — не имеет никакого значения. Главное — сформулировать набор требований к соответствующим сущностям. То есть прямо сформулировать, а не надеяться на "само собой получится, если мы правильно постигнем дзен". Не получится, не надейтесь.
Итак, записываем набор требований к сигнатурам и поведению соответствующих типов. Для примера возьмём набившую оскомину задачу рисования кругов и прочих треугольников. Обобщать мы их будем в виде некоторых графических примитивов, которые обозначим как сущность Shape.
Что должен уметь графический примитив? В общем-то, ничего, если не определён контекст его использования. Значит, мы должны разобраться с контекстом. Итак, в контексте:
— Некоторое "Графическое пространство" (Canvas);
— Модель представления координат (X-Y);
— Цветовое пространство (Palette, Color);
— "Микропримитивная" операция: putPixel(Canvas, Color, Coordinate x, Coordinate y).
Вот теперь, в этом наборе символов начинаем игру в графические примитивы. Чего мы хотим от графического примитива? Допустим, что мы хотим, чтобы он умел себя нарисовать в заданной базовой точке на заданном холсте. Получаем первую формальную запись требований к такому объекту (здесь и далее — псевдокод):
void draw(Shape shape, Canvas canvas, Coordinate X, Coordinate Y);
Вторая формальная запись относится к цветам и прочей колористике. На данный момент нам безразлично, каким именно цветом будет рисовать себя примитив, главное, чтобы он не выходил за рамки дозволенного, поэтому сделаем так:
void setPalette(Shape shape, Palette palette);
Palette getPalette(Shape shape);
Прикидываем одно место к носу и замечаем, что язык у нас объектно-ориентированный, значит, общий для всех параметр shape можно вынести в "класс", то есть:
class Shape {
void draw(Canvas canvas, Coordinate X, Coordinate Y);
void setPalette(Palette palette);
Palette getPalette();
}
Но и это ещё не всё. Скорее всего Canvas тоже будет меняться не слишком часто, да и о базовой точке мы тоже, вполне вероятно, захотим получать какую-никакую справку, а менять её потребуется нечасто. Значит, получаем вот такой набор функций (впрочем, это уже не функции, а методы класса, и всё в сумме вполне похоже на некий интерфейс) :
class Shape {
void setCanvas(Canvas canvas); // Выносим отдельно управление Canvas
Canvas getCanvas(); // Должно выполняться условие: s.setCanvas(c); s.getCanvas() == c; Что положили, то и вытащили.
void setBasePoint(Coordinate X, Coordinate Y);
Coordinate getBasePointX();
Coordinate getBasePointY();
void draw(); // draw растерял все свои аргументы
void setPalette(Palette palette);
Palette getPalette();
}
Получив такой интерфейс, мы можем заняться разработкой системы рисования, не имея ещё ни одного примитива. Опускаю промежуточные рассуждения, сразу приведу примерно то, что получается:
class Canvas {
void addShape(Shape);
void drawAll(); // Рисуем всё, что накопилось
void beginDraw();
void endDraw();
void putPixel(Color c, Coordinate X, Coordinate Y);
private:
Shape m_allShapes[]; // Не суть важно, как именно выглядит этот массив. Главное - что это одномерный массив.
}
Собственно, дальше пирожки врозь — детализация Canvas будет проводиться отдельно, а детализация Shape — отдельно. Отмечу один момент: Canvas содержит два метода — beginDraw и endDraw. Эти два метода должны вызываться "фигурой" в начале и конце цикла рисования соответственно. Соответствующее требование вносится в список требований Shape:
class Shape {
...
// requires: Canvas.beginDraw / Canvas.endDraw call.
void draw();
...
}
Пока что требование записано неформально. Чтобы привести его к формализованному виду, создадим некий базовый класс:
class BasicShape : public Shape {
void setCanvas(Canvas canvas) { m_canvas = canvas; }
Canvas getCanvas() { return m_canvas; }
// Аналогично воплощаем здесь управление палитрой и базовыми точками.
void draw() {
m_canvas.beginDraw();
drawPixels();
m_canvas.endDraw();
}
void drawPixels(); // Этот метод предназначен для перекрытия классами-наследниками BasicShape,
// Canvas про него ничего не знает
protected:
Canvas m_canvas;
}
Теперь можно переходить к кругам-треугольникам и прочим примитивам:
class Bar : public BasicShape { ... };
class Circle : public BasicShape { ... };
class Triangle : public BasicShape { ... };
class Polygon : public BasicShape { ... };
class ShapeAdapter : public Shape { ... };
class SomethingSmart {
Shape anchorShape() { return m_shapeAdapter; } // Можно и так сделать
protected:
ShapeAdapter m_shapeAdapter; // Этот объект каким-то загадочным способом связывает интерфейс Shape и методы класса SomethingSmart
};
Как ты понимаешь, нам совершенно не важно, как именно технически связан интерфейс Shape с его реализацией. Наследование, агрегация, ещё как-нибудь. Важно, чтобы поведение реализации соответствовало
требованиям, предъявляемым к Shape. Тогда мы автоматически удовлетворяем Liskov Substitution Principle, соответствующие классы находятся в отношении "тип-подтип" (т.е. — являются подтипами типа Shape) и у нас не возникает противоречий при чтении инструкции:
class Bar : public BasicShape { ... };
Здесь наследование реализует отношение "тип-подтип" в LSP-смысле этого слова. Сие интуитивно понятно и не вызывает вопросов.
Сложности начнутся, если кому-то стукнет в голову, например, "сэкономить" на наследовании от BasicShape, вместо этого перенеся соответствующий код в Canvas:
class CrazyShape : public Shape { // Наследование очень похоже на предыдущее...
void draw() {
m_canvas.putPixel(0, 1, 1); // ...но только внешне.
}
}
// В Canvas придётся добавить такую фишку:
class Canvas {
void drawAll() {
...
if (typeOf(someShape) == CrazyShape) {
// Для этого идиота придётся делать исключение
beginDraw();
someShape.draw();
endDraw();
} else {
// Остальные - в норме
someShape.draw();
}
}
}
В данном случае формальная часть, соблюдение которой требуется компилятором, вполне удовлетворена: новый класс унаследован от интерфейса Shape и экземпляр CrazyShape может быть подставлен туда, где должен использоваться Shape. А вот остальная часть контракта, которая была реализована в BasicShape — нарушена. Вместе с ней нарушен LSP, поскольку новый класс (внимание!) не обладает
свойством: "вызывает пару beginDraw()/endDraw()". То есть не смотря на "правильное" наследование класс CrazyShape
не является LSP-compliant подтипом класса Shape.
Это нюанс, о котором часто забывают в пылу споров о Глубоком Смысле того или иного сугубо синтаксического приёма. Например, часто ломают копья по вопросу о том, является ли public- (protected, private) -наследование
реализацией отношения "тип-подтип" или нет. Проблема состоит в том, что синтаксическая конструкция "наследование" не является сама по себе ничем таким, что позволяло бы сделать вывод о более общих характеристиках наследуемых классов. LSP-compliant отношение "тип-подтип" можно как соблюсти с использованием private-наследования, так и нарушить при public-наследовании. То есть подобные споры, в общем-то, есть споры ни о чём.
Возвращаясь к твоему вопросу: с точки зрения LSP не бывает правильного и неправильного наследования, поскольку LSP определён на более общих характеристиках, нежели наследование. Единственная причина, по которой в современных языках программирования иной раз необходимо пользоваться наследованием для соблюдения LSP состоит в том, что компилятор разрешит подстановку класса B на место класса A только в том случае, если B является наследником A. Но повторюсь, само себе это никак не гарантирует соблюдения LSP, то есть наследование — это условие нередко необходимое, но отнюдь не достаточное. Например типы, используемые в качестве аргументов шаблона могут просто содержать методы с нужными сигнатурами, при этом наследниками какого-то типа они быть вовсе не обязаны. И с другой стороны, помним о таком приёме:
class ShapeAdapter : public Shape { ... };
class SomethingSmart {
Shape anchorShape() { return m_shapeAdapter; } // Можно и так сделать
protected:
ShapeAdapter m_shapeAdapter; // Этот объект каким-то загадочным способом связывает интерфейс Shape и методы класса SomethingSmart
};
Здесь SomethingSmart не унаследован прямо от Shape, но тем не менее, посредством m_shapeAdapter и метода anchorShape() он может быть использован там, где ожидается Shape. Отличие такой агрегации от наследования становится пренебрежимо малым, если учесть, что создание класса часто упаковывается в "фабрику":
Shape createShape(string typeName) {
if (typeName == "SomethingSmart") {
SomethingSmart obj = new SomethingSmart(...);
return obj.anchorShape();
}
...
}
Так что, наследование — это только один из возможных приёмов.
Я знаю только две бесконечные вещи — Вселенную и человеческую глупость, и я не совсем уверен насчёт Вселенной. (c) А. Эйнштейн
P.S.: Винодельческие провинции — это есть рулез!