Краевой случай принципа замещения Лискова

Краевой случай принципа замещения Лискова
Краевой случай принципа замещения Лискова - lumitar_legends @ Unsplash

Я думал, что понял принцип подстановки Лискова, но потом вспомнил один случай и захотел спросить у сообщества, правильно ли я его понял.

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

Ниже я привел пример, пожалуйста, помогите мне понять, не нарушил ли я LSP.

class A {

 void doWork(){
    // superclass specific implementation
 }

 void doSomeOtherWork(){
    // superclass specific implementation
 }
}

class B extends A {
 void doWork(){
    // subclass specific implementation
 }
}

Код клиента NOTE: Предположим, что код клиента фиксирован и не может быть изменен.

class WorkProcessor{

 void process(A a){
   a.doWork();
 }
}

Теперь в общем случае верно, что суперкласс A имеет поведение, т.е. doSomeOtherWork(), которое не относится к подклассу B, поэтому в идеале это будет нарушением LSP, так как подтип не может быть заменен супертипом.

Но если посмотреть на код клиента и гипотетически предположить, что код клиента исправлен, мы увидим, что вызывается a.doWork(), и он будет работать, даже если мы передадим экземпляр подкласса B.

Таким образом, в контексте клиента мы не нарушаем LSP.

Прав ли я, делая вышеприведенное утверждение?

Класс не нарушает LSP, просто имея дополнительный метод

Поведение, о котором говорит LSP, — это не методы, а правила, отношения и ограничения, которым должен следовать/обеспечивать класс, когда вы нажимаете его по-разному (правила, управляющие наблюдаемыми клиентом преобразованиями, когда вы вызываете различные методы для типа) . Например, в (не) известном примере с прямоугольником-квадратом поведением является не «я могу установить ширину» и «я могу установить высоту», а релевантное поведение (контракт, что тип обещает иметь место) — это «я может устанавливать ширину и высоту независимо". Это требование для целей этого примера не заложено в интерфейсе Rectangle (хотя было бы естественно предположить, что оно выполняется), оно навязывается разработчиком извне — и это то, что нарушается в Square». подтип" (что затем делает его не настоящим подтипом по определению LSP).

Тем не менее, вы правы в том, что тот класс, который наследует или реализует что-то еще (или, в случае структурной типизации, имеет структуру, совместимую с чем-то еще), может нарушать LSP в одном контексте, но не в другом.

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

Теперь, в языке со статической типизацией, вы должны связать этот контракт с ролевым интерфейсом. То есть вместо:

class B extends A {
 void doWork(){
    // subclass specific implementation
 }
}

у вас было бы что-то вроде этого

interface Worker {
  void doWork()
}

class B extends A implements Worker {
 void doWork(){
    // subclass specific implementation
 }
}


class WorkProcessor{

 void process(Worker a){
   a.doWork();
 }
}

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

Например. посмотрите на sort() в JavaScript; он может принимать compareFn - что-то, что говорит ему, как сравнивать два значения, которые затем он может использовать для сортировки массива значений, которые он иначе не понимает.

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

Подпись compareFn говорит вам, что она принимает два значения и возвращает число: (a, b) -> Number. Сигнатура похожа на формальный тип функции.

Но поведение функции, то, что полностью определяет тип в смысле LSP, будет определено именно так.

Во-первых, он должен следовать этой спецификации из документации функции sort().

compareFn(a, b) возвращаемое значение Порядок сортировки > 0 сортировать а после б < 0 сортировать а перед б === 0 сохранить первоначальный порядок a и b

Во-вторых, из-за ваших требований к порядку реализация должна убедиться, что эти вещи верны:

  • compare(a, a) должен вернуться 0
  • Если compare(a, b) < 0 и compare(b, c) < 0, то сравнение (a, c) также должно возвращать значение < 0.
  • Если compare(a, b) === 0, то compare(b, a) тоже должно быть === 0
  • compare(a, b) < 0 и compare(b, a) < 0 не могут быть правдой одновременно

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

Кроме того, термин «поведение» на самом деле является математическим/академическим жаргоном – он относится к этим правилам/ограничениям/инвариантам, которые управляют тем, что происходит, когда вы что-то делаете с объектом (когда вы передаете определенный набор параметров или когда он переходит от одного состояние другому и т. д.).

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

Этот пример просто демонстрирует поведенческую спецификацию одной функции, но вы можете себе представить, что вы можете сделать это для классов и, что более важно, для интерфейсов — и это не обязательно должно быть таким абстрактным или математическим по своей природе.

Проблема с вашим примером заключается в том, что он не имеет какого-либо нетривиального поведения, наблюдаемого клиентом, в указанном выше смысле - это просто вызов функции "запустить и забыть".


LetsCodeIt, 18 января 2023 г., 11:38