Здравствуйте, Геннадий Васильев, Вы писали:
ГВ>Угу-угу, это как раз и есть источник сложностей ОО-проектирования. Вот и спроектируй программу так, чтобы замена класса MatrixPrinter на ColorPrinter проходила незаметно для использующего кода.
В чем проблема? Если забыть о том, что различие между матричным и цветным лазерном принтере лежит на уровне драйвера, а не приложения, вот код, который печатает одну и ту же картинку на всех принтерах, подключенных к компьютеру:
Здравствуйте, minorlogic, Вы писали:
M>Кривой пример. Для консольного логера тоже могут быть проблемы , нет консоли , нет райнайм библиотеки, не хватает памяти для логирования и т.п.
Это будут ошибки окружения, а не программы. Компьютер вообще сломаться может не вовремя, и что теперь?
Здравствуйте, igor-booch, Вы писали:
IB>Теперь меняем в программе SimpleLogger на DbLogger
LSP затрагивает публичный интерфейс, если я правильно помню. Сделайте конструктор закрытым и научите фабрику производить любой логгер, с требованием передать для консольного консоль, а для дэбэшного — connection string.
Или не рассматривайте конструктор как часть публичного интерфейса. То же самое, но без геморроя. Тогда вы сможете заменить SimpleLogger на DbLogger при использовании (как некого Logger'а), но не при инстанцировании.
Здравствуйте, SV., Вы писали:
SV.>LSP затрагивает публичный интерфейс, если я правильно помню.
LSP затрагивает не столько публичный интерфейс иерархии классов, сколько внешние методы с ней работающие. Именно они должны работать корректно с любым потомком. Если метод принимает на вход Printer, он должен точно так же корректно работать если ему передали ColorPrinter или MatrixPrinter.
ГВ>Правильно понял, но это справедливо только при условии, что наследование (inheritance) типов в данной программной системе действительно выражает отношение "тип-подтип" в том смысле, который подразумевает LSP. Обычно полагают, что так оно и есть, но в отдельных случаях это правило может нарушаться.
Приведите пожалуйста примеры неправильного и правильного наследования сточки зрения LSP.
Здравствуйте, igor-booch, Вы писали:
ГВ>>Правильно понял, но это справедливо только при условии, что наследование (inheritance) типов в данной программной системе действительно выражает отношение "тип-подтип" в том смысле, который подразумевает LSP. Обычно полагают, что так оно и есть, но в отдельных случаях это правило может нарушаться.
IB>Приведите пожалуйста примеры неправильного и правильного наследования сточки зрения LSP.
С точки зрения LSP нет никакого "наследования". Есть отношение "тип-подтип", а воплощено оно наследованием или нет — не имеет никакого значения. Главное — сформулировать набор требований к соответствующим сущностям. То есть прямо сформулировать, а не надеяться на "само собой получится, если мы правильно постигнем дзен". Не получится, не надейтесь.
Итак, записываем набор требований к сигнатурам и поведению соответствующих типов. Для примера возьмём набившую оскомину задачу рисования кругов и прочих треугольников. Обобщать мы их будем в виде некоторых графических примитивов, которые обозначим как сущность Shape.
Что должен уметь графический примитив? В общем-то, ничего, если не определён контекст его использования. Значит, мы должны разобраться с контекстом. Итак, в контексте:
— Некоторое "Графическое пространство" (Canvas);
— Модель представления координат (X-Y);
— Цветовое пространство (Palette, Color);
— "Микропримитивная" операция: putPixel(Canvas, Color, Coordinate x, Coordinate y).
Вот теперь, в этом наборе символов начинаем игру в графические примитивы. Чего мы хотим от графического примитива? Допустим, что мы хотим, чтобы он умел себя нарисовать в заданной базовой точке на заданном холсте. Получаем первую формальную запись требований к такому объекту (здесь и далее — псевдокод):
Вторая формальная запись относится к цветам и прочей колористике. На данный момент нам безразлично, каким именно цветом будет рисовать себя примитив, главное, чтобы он не выходил за рамки дозволенного, поэтому сделаем так:
Прикидываем одно место к носу и замечаем, что язык у нас объектно-ориентированный, значит, общий для всех параметр shape можно вынести в "класс", то есть:
Но и это ещё не всё. Скорее всего 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 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.: Винодельческие провинции — это есть рулез!
Здравствуйте, igor-booch, Вы писали:
IB>Спасибо за содержательный ответ! Буду его изучать.
IB>Может посоветуете какую-нибудь хорошую книгу по теме?
По LSP-то? На него много где ссылаются в литературе по проектированию ПО, в общем, это основополагающий принцип проектирования иерархий классов. Неплохой стартовый набор ссылок можно найти в английской версии Вики. Ну или вот, например, достаточно простая статья на Object Mentor.
Я знаю только две бесконечные вещи — Вселенную и человеческую глупость, и я не совсем уверен насчёт Вселенной. (c) А. Эйнштейн
P.S.: Винодельческие провинции — это есть рулез!
Здравствуйте, igor-booch, Вы писали:
IB>http://en.wikipedia.org/wiki/Liskov_substitution_principle
IB>Насколько я понял принцип гласит, что если в программе заменить базовые классы классами наследниками, то ничего не должно сломаться. По-мойму бред. Или я что-то неправильно понял.
Вы, когда наследуетесь от предка, специализируете поведение будущего объекта или, наоборот, обобщаете?
Здравствуйте, Ziaw, Вы писали:
Z>LSP затрагивает не столько публичный интерфейс иерархии классов, сколько внешние методы с ней работающие.
LSP затрагивает как саму иерархию, так и код, её использующий. Даже идеальная иерархия типов, разработанная для соблюдения LSP, может быть использована с его нарушением.
Здравствуйте, vdimas, Вы писали:
V>Здравствуйте, Ziaw, Вы писали:
Z>>LSP затрагивает не столько публичный интерфейс иерархии классов, сколько внешние методы с ней работающие.
V>LSP затрагивает как саму иерархию, так и код, её использующий. Даже идеальная иерархия типов, разработанная для соблюдения LSP, может быть использована с его нарушением.
Здравствуйте, vdimas, Вы писали:
V>LSP затрагивает как саму иерархию, так и код, её использующий. Даже идеальная иерархия типов, разработанная для соблюдения LSP, может быть использована с его нарушением.
Всё упирается в то, что мы считаем контрактом.
С точки зрения С++, контракт исчерпывается типами и количеством аргументов и возвращаемого результата.
Именно это проверяет компилятор.
То, что метод Numeral.Increment имеет ту же семантику (в общем смысле), что и метод Integer.Increment, компилятор проверить не может.
Но это — проблемы компилятора и конкретного языка, а не принципа в целом.
Уйдемте отсюда, Румата! У вас слишком богатые погреба.
Здравствуйте, Ziaw, Вы писали:
SV.>>LSP затрагивает публичный интерфейс, если я правильно помню.
Z>LSP затрагивает не столько публичный интерфейс иерархии классов, сколько внешние методы с ней работающие. Именно они должны работать корректно с любым потомком. Если метод принимает на вход Printer, он должен точно так же корректно работать если ему передали ColorPrinter или MatrixPrinter.
Ослу понятно, что если конструкторы будут различаться (требовать разных инициализационных данных — connection string / console), то и методы, которые инстанцируют логгеры, тоже будут различаться.
Вопрос в чистом виде такой: классы, которые дают возможность одинаково себя использовать, но требуют по-разному порождать — удовлетворяют LSP или нет? Я для себя ответил так: It's OK.
Пример с логгерами приведен. Другой пример: простенький векторный редактор. Вы щелкаете по кнопке "Рисую прямоугольник". Window [Controller + View] запоминает режим и в соответствии с ним показывает вам рамку после первого щелчка. После отпускания кнопки мыши отрабатывает switch, и в класс Document [Model] уходит команда "Новый прямоугольник". Тот инстанцирует Rect, добавляет его в коллекцию фигур, и только после этого разница между ним и каким-нибудь кругом исчезает.
Тимоти Бадд [27] приводит интересную классификацию форм наследования. Форма наследования определяет — для чего, с какой целью используется наследование. Бадд считает, что порождение дочернего класса может быть выполнено по следующим причинам:
специализация. Класс-наследник является специализированной формой родительского класса — в наследнике просто переопределяются методы. Принцип подстановки выполняется. Очевидно, что такая форма наследования в С++ реализуется простым открытым наследованием.
Примером является наследование часы -> будильник;
спецификация. Дочерний класс реализует поведение, описанное в родительском классе. Ясно, что в С++ эта форма реализуется простым открытым наследованием от абстрактного класса;
конструирование. Класс-наследник использует методы базового класса, но не является его подтипом (принцип подстановки не выполняется). В С++ такую форму можно реализовать простым закрытым наследованием;
расширение. В класс-потомок добавляют новые методы, расширяя поведение родительского класса; принцип подстановки в такой форме выполняется;
обобщение. Дочерний класс обобщает поведение базового класса. Обычно такое наследование используется в тех случаях, когда мы не можем изменить поведение базового класса (например, базовый класс является библиотечным классом);
ограничение. Класс-наследник ограничивает поведение родительского класса. Очевидно, что в С++ такой вид наследования реализуется простым закрытым наследованием (пример — TUniversalDeque -> TStack );
варьирование. Базовый класс и класс-потомок являются вариациями на одну тему, однако связь «класс-подкласс» произвольна, например, «квадрат-прямоугольник» или «прямоугольник-квадрат». Эта форма фактически не отличается от «конструирования», так как класс-наследник, очевидно, «использует методы базового класса, но не является его подтипом»;
комбинирование. Дочерний класс наследует черты нескольких классов — это множественное наследование.
Хочешь быть счастливым — будь им!
Без булдырабыз!!!