Статьи

SOLID: Часть 3 — Принципы подстановки и разделения интерфейса Лискова

Одиночная ответственность (SRP) , открытый / закрытый (OCP) , подстановка Лискова, разделение интерфейса и инверсия зависимости. Пять гибких принципов, которыми вы должны руководствоваться при написании кода.

Поскольку и принцип замещения Лискова (LSP), и принцип разделения интерфейсов (ISP) довольно легко определить и привести в качестве примера, в этом уроке мы поговорим о них обоих.

Дочерние классы никогда не должны нарушать определения типов родительского класса.

Концепция этого принципа была введена Барбарой Лисков в программной речи 1987 года, а затем опубликована в статье вместе с Джаннет Уинг в 1994 году. Их первоначальное определение выглядит следующим образом:

Пусть q (x) — свойство, доказуемое для объектов x типа T. Тогда q (y) должно быть доказуемо для объектов y типа S, где S — подтип T.

Позже, с публикацией принципов SOLID Роберта К. Мартина в его книге Agile Software Development, Principles, Patterns and Practices, а затем переизданной в версии C # книги Agile Principles, Patterns и Practices in C # , определение стал известен как принцип замещения Лискова.

Это приводит нас к определению, данному Робертом К. Мартином:

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

Так просто, что подкласс должен переопределять методы родительского класса таким образом, чтобы не нарушать функциональность с точки зрения клиента. Вот простой пример для демонстрации концепции.

01
02
03
04
05
06
07
08
09
10
class Vehicle {
 
    function startEngine() {
        // Default engine start functionality
    }
 
    function accelerate() {
        // Default acceleration functionality
    }
}

Для данного класса Vehicle (он может быть абстрактным) и двух реализаций:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Car extends Vehicle {
 
    function startEngine() {
        $this->engageIgnition();
        parent::startEngine();
    }
 
    private function engageIgnition() {
        // Ignition procedure
    }
 
}
 
class ElectricBus extends Vehicle {
 
    function accelerate() {
        $this->increaseVoltage();
        $this->connectIndividualEngines();
    }
 
    private function increaseVoltage() {
        // Electric logic
    }
 
    private function connectIndividualEngines() {
        // Connection logic
    }
 
}

Клиентский класс должен иметь возможность использовать любой из них, если он может использовать Vehicle .

1
2
3
4
5
6
class Driver {
    function go(Vehicle $v) {
        $v->startEngine();
        $v->accelerate();
    }
}

Что приводит нас к простой реализации шаблона проектирования метода шаблона, который мы использовали в руководстве по OCP.

template_method

Основываясь на нашем предыдущем опыте работы с Открытым / Закрытым Принципом, мы можем заключить, что Принцип замещения Лискова находится в тесной связи с OCP. Фактически, «нарушение LSP является скрытым нарушением OCP» (Роберт К. Мартин), и шаблон разработки шаблонных методов является классическим примером уважения и реализации LSP, который, в свою очередь, является одним из решений для соблюдения OCP. ,

Чтобы проиллюстрировать это полностью, мы пойдем с классическим примером, потому что он очень важен и легко понятен.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Rectangle {
 
    private $topLeft;
    private $width;
    private $height;
 
    public function setHeight($height) {
        $this->height = $height;
    }
 
    public function getHeight() {
        return $this->height;
    }
 
    public function setWidth($width) {
        $this->width = $width;
    }
 
    public function getWidth() {
        return $this->width;
    }
 
}

Мы начнем с базовой геометрической формы, Rectangle . Это просто простой объект данных с установщиками и получателями для width и height . Представьте, что наше приложение работает и уже развернуто на нескольких клиентах. Теперь им нужна новая функция. Им нужно уметь манипулировать квадратами.

В реальной жизни в геометрии квадрат — это особая форма прямоугольника. Таким образом, мы могли бы попытаться реализовать класс Square который расширяет класс Rectangle . Часто говорят, что дочерний класс является родительским классом, и это выражение также соответствует LSP, по крайней мере, на первый взгляд.

SquareRect

Но является ли Square действительно Rectangle в программировании?

01
02
03
04
05
06
07
08
09
10
11
12
class Square extends Rectangle {
 
    public function setHeight($value) {
        $this->width = $value;
        $this->height = $value;
    }
 
    public function setWidth($value) {
        $this->width = $value;
        $this->height = $value;
    }
}

Квадрат — это прямоугольник с равной шириной и высотой, и мы могли бы сделать странную реализацию, как в примере выше. Мы могли бы перезаписать оба сеттера, чтобы установить высоту и ширину. Но как это повлияет на код клиента?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
class Client {
 
    function areaVerifier(Rectangle $r) {
        $r->setWidth(5);
        $r->setHeight(4);
 
        if($r->area() != 20) {
            throw new Exception(‘Bad area!’);
        }
 
        return true;
    }
 
}

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

1
2
3
function area() {
    return $this->width * $this->height;
}

Конечно, мы добавили вышеупомянутый метод в наш класс Rectangle чтобы обеспечить область.

1
2
3
4
5
6
7
8
9
class LspTest extends PHPUnit_Framework_TestCase {
 
    function testRectangleArea() {
        $r = new Rectangle();
        $c = new Client();
        $this->assertTrue($c->areaVerifier($r));
    }
 
}

И мы создали простой тест, отправив пустой прямоугольный объект в верификатор области, и тест прошел. Если наш класс Square определен правильно, отправка его клиенту areaVerifier() не должна нарушать его функциональность. В конце концов, Square — это Rectangle во всем математическом смысле. Но наш класс?

1
2
3
4
5
function testSquareArea() {
    $r = new Square();
    $c = new Client();
    $this->assertTrue($c->areaVerifier($r));
}

Тестировать это очень легко, и это ломает время. Исключение выдается нам, когда мы запускаем тест выше.

1
2
3
4
5
PHPUnit 3.7.28 by Sebastian Bergmann.
 
Exception : Bad area!
#0 /paht/: /…/…/LspTest.php(18): Client->areaVerifier(Object(Square))
#1 [internal function]: LspTest->testSquareArea()

Итак, наш класс Square не является Rectangle конце концов. Это нарушает законы геометрии. Он терпит неудачу и нарушает принцип подстановки Лискова.

Мне особенно нравится этот пример, потому что он не только нарушает LSP, но и демонстрирует, что объектно-ориентированное программирование не связано с отображением реальной жизни на объекты. Каждый объект в нашей программе должен быть абстракцией над концепцией. Если мы попытаемся отобразить реальные объекты один на один с запрограммированными объектами, мы почти всегда потерпим неудачу.

Принцип единой ответственности касается действующих лиц и архитектуры высокого уровня. Принцип Open / Closed касается дизайна классов и расширений функций. Принцип подстановки Лискова о подтипах и наследовании. Принцип сегрегации интерфейса (ISP) касается бизнес-логики для взаимодействия с клиентами.

Во всех модульных приложениях должен быть какой-то интерфейс, на который может положиться клиент. Это могут быть фактические объекты интерфейса или другие классические объекты, реализующие шаблоны проектирования, такие как фасады. Неважно, какое решение используется. Он всегда имеет одну и ту же область: сообщать клиенту код о том, как использовать модуль. Эти интерфейсы могут находиться между различными модулями в одном приложении или проекте или между одним проектом в качестве сторонней библиотеки, обслуживающей другой проект. Опять же, это не имеет значения. Общение — это общение, а клиенты — это клиенты, независимо от того, кто на самом деле пишет код.

Итак, как мы должны определить эти интерфейсы? Мы могли бы подумать о нашем модуле и раскрыть все функции, которые мы хотим предложить.

hugeInterface

Это выглядит как хорошее начало, отличный способ определить, что мы хотим реализовать в нашем модуле. Или это? Такое начало приведет к одной из двух возможных реализаций:

  • Огромный класс Car или Bus реализующий все методы интерфейса Vehicle . Только явные размеры таких классов должны указывать нам избегать их любой ценой.
  • Или множество небольших классов, таких как LightsControl , SpeedControl или RadioCD которые реализуют весь интерфейс, но на самом деле предоставляют что-то полезное только для реализуемых ими частей.

Очевидно, что ни одно из решений не является приемлемым для реализации нашей бизнес-логики.

specializedImplementationInterface

Мы могли бы принять другой подход. Разбейте интерфейс на части, специализированные для каждой реализации. Это поможет использовать небольшие классы, которые заботятся о собственном интерфейсе. Объекты, реализующие интерфейсы, будут использоваться различными типами транспортных средств, такими как автомобиль на изображении выше. Автомобиль будет использовать реализации, но будет зависеть от интерфейсов. Таким образом, схема, подобная приведенной ниже, может быть еще более выразительной.

carUsingInterface

Но это в корне меняет наше восприятие архитектуры. Car становится клиентом вместо реализации. Мы все еще хотим предоставить нашим клиентам способы использования всего нашего модуля, который является типом транспортного средства.

oneInterfaceManyClients

Предположим, мы решили проблему реализации и у нас стабильная бизнес-логика. Самое простое, что можно сделать, — это предоставить единый интерфейс со всеми реализациями и позволить клиентам, в нашем случае BusStation , HighWay , Driver и т. Д., Использовать все, что угодно от реализации интерфейса. По сути, это переносит ответственность за выбор поведения на клиентов. Такое решение можно найти во многих старых приложениях.

Принцип сегрегации интерфейса (ISP) гласит, что ни один клиент не должен зависеть от методов, которые он не использует.

Однако у этого решения есть свои проблемы. Теперь все клиенты зависят от всех методов. Почему BusStation должен зависеть от состояния освещения шины или от радиоканалов, выбранных водителем? Не должно. Но что, если это произойдет? Это имеет значение? Что ж, если мы подумаем о принципе единой ответственности, то это родственная концепция. Если BusStation зависит от многих отдельных реализаций, даже не используемых ею, она может потребовать изменений, если какая-либо из отдельных небольших реализаций изменится. Это особенно верно для скомпилированных языков, но мы все еще можем видеть эффект изменения LightControl BusStation . Эти вещи никогда не должны происходить.

Интерфейсы принадлежат их клиентам, а не реализациям. Таким образом, мы всегда должны разрабатывать их таким образом, чтобы они лучше подходили нашим клиентам. Иногда мы можем, иногда мы не можем точно знать наших клиентов. Но когда мы можем, мы должны разбить наши интерфейсы на множество более мелких, чтобы они лучше удовлетворяли точные потребности наших клиентов.

segregatedInterfaces

Конечно, это приведет к некоторой степени дублирования. Но помните! Интерфейсы — это просто определения имен функций. В них нет никакой реализации какой-либо логики. Таким образом, дублирование является небольшим и управляемым.

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

Еще один приятный трюк заключается в том, что в нашей бизнес-логике один класс может реализовывать несколько интерфейсов, если это необходимо. Таким образом, мы можем предоставить единую реализацию для всех распространенных методов между интерфейсами. Разделенные интерфейсы также заставят нас больше думать о нашем коде с точки зрения клиента, что, в свою очередь, приведет к слабой связи и легкому тестированию. Таким образом, мы не только сделали наш код лучше для наших клиентов, но и упростили для себя понимание, тестирование и реализацию.

LSP научил нас, почему реальность не может быть представлена ​​как отношение один-к-одному с запрограммированными объектами и как подтипы должны уважать своих родителей. Мы также изложили это в свете других принципов, которые мы уже знали.

Интернет-провайдер учит нас уважать наших клиентов больше, чем мы считали необходимым. Уважение их потребностей сделает наш код лучше, а жизнь программистам — проще.

Спасибо за уделенное время.