Re[3]: Принцип подстановки Лисков (LSP из SOLID)
От: Геннадий Васильев Россия http://www.livejournal.com/users/gesha_x
Дата: 26.07.11 01:07
Оценка: 160 (9)
Здравствуйте, 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.: Винодельческие провинции — это есть рулез!
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.