SOLID принципы часть 2

Spread the love

В этой статье я продолжу описание SOLID принципов. Первая часть можно найти тут.

Принцип подстановки Лисков (LSP Liskov Substitution Principle)

Каноническое определение принципа:

Подтипы должны быть заменены их исходными типами

Барбара Лисков (1988)

Данный принцип касается наследование классов. Точнее он описывает то, как надо правильно создавать наследование.

Цель принципа
Помогает сделать правильное наследование классов.

В более неформальной интерпретации принцип гласит, что родительские экземпляры должны заменяться одним из их дочерних экземпляров, не создавая какое-либо неожиданное или неправильное поведение.

Что бы лучше разобраться с этим принципов рассмотрим его на выдуманном примере. Пусть у нас есть базовый класс прямоугольника Rectangle.

class Rectangle {
  int width
  int height
  function setWidth(width) {
    this.width = width
  }
  function setHeight(height) {
    this.height = height
  }
}

Потом нам понадобился класс квадрата Square. И первое что нам пришло в голову: так как квадрат это частный случай прямоугольника, то мы можем просто унаследовать Rectangle и переопределить некоторые методы.

class Square extends Rectangle {
  function setWidth(width) {
    parent.setWidth(width)
    parent.setHeight(width)
  }
  function setHeight(height) {
    parent.setWidth(height)
    parent.setHeight(height)
  }
}

Но тут у нас проблема. При изменение ширины мы меняем высоту а при изменение высоты мы так же меняем ширину. Что не совсем корректно с точки зрения базового класса. Ведь изначально для изменения высоты и ширины мы создали отдельные методы. И тем самым мы явным образом нарушаем принцип LSP.
А какое решение было бы корректно?

class Square {
  int size
  function setSize(size) {
    this.size = size
  }
}

В этом случае решение достаточное простое, вообще не наследоваться! Посколько квадрат это фигура с одним измерением то достаточно одного метода setSize. Почему у нас возникла такая проблема вроде бы все правильно фигуры геометрия, ООП, реальный мир и т.п.. А все потому что реальный мир и ООП на самом деле не всегда похожи друг на друга. То есть у нас получилось разность точек зрения. В геометрии квадрат – это частный случай прямоугольника. А в ООП важно в первую очередь поведение объекта.

Выводы решения
Правила взаимодействия в реальном мире могут отличаться от взаимодействия в объектно-ориентированных системах.

Так как понять, когда мы можем наследоваться а когда нет? Для этой цели была придумана следующая идея.

Проектирование по контракту

То есть можно как бы заключить контракт между базовым классом и классом наследником. И оценить сможем ли мы отнаследоваться от него или не сможем. Для этого мы рассматривается входные и выходные данные.

Контракт как бы формализует ожидаемое поведение класса. С помощью контракта определяются входные и выходные параметры. Тем самым определяются границы.

Условия границ

Входные условия, то есть то что приходит в качестве входных параметров, должны быть равные или более слабые (наследник может не выполнять все ограничение родительского класса)

assert (width.isNumber() == true)

Выходные условия, то есть то что возвращается из класса или меняется внутри класса, должны быть равные или более сильные (наследник выполняет все ограничение родительского класса и может дополнительно привносить свои ограничения)

assert((this.width == width) && (this.height == old.height))

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

По нашему примеру входные условия у нас совпали. То есть на вход мы подали число, но в выходных условиях у нас возникла проблема. Потому что при вызове метода setWidth у квадрата мы меняли не только не только ширину, но мы меняли и длину. То есть старое значение длины изменилось и тем самым мы нарушили этот принцип.

Что бы визуализировать этот принцип можно нарисовать такую диаграмму:

Где входные и выходные условия нарисованные в виде песочных часов. А голубым контуром нарисовано нарушение принципа в нашем примере.

Рассмотрим еще один пример

Допустим мы делаем какую нибудь компьютерную игру и у нас есть доска с координатами x и y и на ней есть юнеты и т.п. Мы создали класс Board:

class Board {
  int x, y, tile
  function getTile(x, y) {}
  function addUnit(x, y) {}
  function removeUnit(x, y) {}
  function getUnits(x, y) {}
}

В какой то момент мы решили сделать новую версию игры с 3d графикой на основе старого кода. Мы отнаследовались от старого класса Board и создали новые методы которые могут работать с 3d.

class Board3D extends Board {
  int z, tile3d
  function getTile3d(x, y, z) {}
  function addUnit3d(x, y, z) {}
  function removeUnit3d(x, y, z) {}
  function getUnits3d(x, y, z) {}
}

И в данном случае так же происходит нарушение принципа LSP, потому что при наследование методы базового класса ни куда не делись. Они так и будут находиться в коде, и соотвественно если у вас где нибудь в программе вызовится старые методы, то будет странная ситуация. Так как этот вызов будет в трехмерном мире а старые методы будут из двухмерного мира и станет вопрос какое значение будет у координаты z в этом случае.
В языках таких как Java можно было бы называть методы без приставки 3d и прописать три координаты и тогда эта проблема будет еще более не заметна, но тем не менее у вас все равно будет два типа методов. Из-за существования методов двухмерного и трехмерного мира, которые могут быть перемешаны. Правильным решением в этом случае будет не наследоваться вообще нужно сразу просто разделять два класса.

Суть принципа
Объекты в программе могут быть заменены их наследниками без изменения свойств программы

В третье заключительной статье, я опишу два последних принципа.


Spread the love

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *