Переопределение виртуального логического чистого метода без нарушения LSP

Например, мы имеем следующую структуру:

class Base
{
    [pure]
    public virtual bool IsValid(/*you can add some parameters here*/)
    {
       //body
    }
}

class Child : Base
{
    public override bool IsValid(/*you can add some parameters here*/)
    {
       //body
    }
}

Не могли бы вы заполнить Base::IsValid() и Child::IsValid() с разными телами, но без конфликтов с LSP? Представьте, что это просто метод анализа, мы не можем изменить состояние экземпляра. Можем ли мы это сделать? Меня интересует любой пример. Я пытаюсь понять, являются ли виртуальные (физические) логические методы анти-шаблонами или нет в общем случае.

Ответ 1

Идея LSP не запрещает полиморфизм детских классов. Скорее, он подчеркивает, что можно изменить, а что нет. В общем, это означает, что:

  1. Любая переопределяющая функция принимает и возвращает те же типы переопределенной функции; который включает в себя, возможно, заброшенные исключения (типы ввода могут расширять типы переопределенных и выходных типов, могут сузить их - это все равно сохранит это ограничение).
  2. "Правило истории" - "базовая" часть объекта "Дети" не может быть изменено дочерней функцией до состояния, которое никогда не может быть достигнуто с использованием функций базового класса. Таким образом, функция, которая ожидает объект Base, никогда не получит неожиданных результатов.
  3. Инварианты Базы не должны быть изменены в Ребенке. То есть любое общее предположение о поведении Базового класса должно быть сохранено Ребенком.

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

В вашем случае IsValid() означает что-то, и что "что-то" должно храниться под всеми дочерними типами. Например, допустим, что ваш базовый класс определяет продукт, а IsValid() указывает, действителен ли этот продукт для продажи. То, что делает каждый продукт действительным, может отличаться. Например, он должен иметь свою цену, чтобы быть действительной для продажи. Но продукт Child также должен пройти тест на электричество, прежде чем он может быть продан.

В этом примере мы соблюдаем все требования:

  1. Типы ввода и вывода функции не изменяются.
  2. Состояние объекта Base-часть дочернего объекта не изменяется таким образом, которого не может ожидать класс Base.
  3. Сохраняются инварианты класса: объект Child без цены все еще не может быть продан; смысл недействительности остается тем же (не разрешается продавать), он просто рассчитывается таким образом, который соответствует ребенку.

Вы можете получить некоторые дополнительные объяснения здесь.

===

Изменить - некоторые дополнительные пояснения в соответствии с примечаниями

Вся идея полиморфизма заключается в том, что одна и та же функция выполняется по-разному по каждому подтипу. LSP не нарушает полиморфизм, но описывает, что должен заботиться о полиморфизме. В частности, LSP требует, чтобы любой подтип Child мог использоваться там, где код требует Base и что любое допущение, сделанное для Base hold для любого из его Child 's. В приведенном выше примере IsValis() не означает, что "имеет цену". Скорее, это означает, что: действительно ли продукт действителен? В некоторых случаях достаточно цены. В других случаях также требуются проверки электроэнергии, а в других же могут потребоваться некоторые другие свойства. Если разработчик Base класса не требует, чтобы, установив цену, продукт стал действительным, а скорее оставил IsValid() в качестве отдельного теста, тогда нарушение LSP не произойдет. Какой пример допустил бы это нарушение? Пример, когда вы запрашиваете объект, если он IsValid(), затем вызывает функцию базового класса, которая не должна изменять действительность, и эта функция изменяет Child на недействительность. Это является нарушением правила истории LSP. Известный пример, представленный другими здесь, является квадратным как дочерний элемент прямоугольника. Но пока одна и та же последовательность вызовов функций не требует определенного поведения (опять же - не определено, что установка цены делает продукт действительным, как раз так бывает в некоторых типах) - LSP поддерживается по мере необходимости,

Ответ 2

Основная идея LSP заключается не в том, чтобы помешать возможности Override метод Base класса, а во избежание изменения внутреннего состояния Base класса (смены членов данных класса базового класса) таким образом, чтобы базового класса не было.

В нем просто говорится: Любой тип (класс), который наследует другой тип, должен быть заменен этому типу, поэтому, если класс Child наследует Base класс, то в любом месте кода, где ожидается объект Base класса, мы можем предоставить класс Child объект без изменения поведения системы.

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

В вашем случае, поскольку вы просто анализируете некоторые данные в IsValid() и не изменяете внутреннее состояние Base класса, не должно быть никаких нарушений LSP.

Ответ 3

Во-первых, ваш ответ:

class Base
{
    [pure]
    public virtual bool IsValid()
    {
       return false;
    }
}

class Child : Base
{
    public override bool IsValid()
    {
       return true;
    }
}

В основном, LSP говорит (это определение "подтип"):

Если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется при замене o1 на o2, тогда S является подтипом T. ( Лисков, 1987)

"Но я не могу заменить o1 типа Base на любой o2 типа Child, потому что они, очевидно, ведут себя по-другому!" Чтобы ответить на это замечание, мы должны сделать обход.

Что такое подтип?

Во-первых, обратите внимание, что Лисков говорит не только о классах, но и о типах. Классы - это реализации типов. Существуют хорошие и плохие реализации типов. Мы попытаемся их отличить, особенно когда дело доходит до подтипов.

Вопрос, лежащий в основе Принципа замещения Лискова: что такое подтип? Обычно мы предполагаем, что подтип является специализацией его супертипа и расширением его возможностей:

> The intuitive idea of a subtype is one whose objects provide all the behavior of objects of another type (the supertype) plus something extra (Liskov, 1987)

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

Но эти критики неполны и приводят к ошибкам. Вот два печально известных примера:

  • SortedList является (?) Подтипом List: он представляет список, который сортируется (специализация).
  • Square является (?) Подтипом Rectangle: он представляет собой прямоугольник с четырьмя равными сторонами (специализация).

Почему SortedList не является List? Из-за семантики типа List. Тип - это не только набор подписей, методы также имеют семантику. По смыслу, я имею в виду все авторизованные использования объекта (помните Витгенштейна: "смысл слова - его использование в языке"). Например, вы ожидали найти элемент в том месте, где вы его положили. Но если список всегда сортируется, вновь вставленный элемент будет перемещен в "правильном" месте. Таким образом, вы не найдете этот элемент в том месте, где вы его положили.

Почему Square не является Rectangle? Представьте, что у вас есть метод set_width: с квадратом вы также должны изменить высоту. Но семантика set_width заключается в том, что изменяется ширина, но не изменяется.

(Квадрат не является прямоугольником? Этот вопрос иногда приводит к горячей дискуссии, поэтому я расскажу об этом. Мы все узнали, что квадрат является прямоугольником. Но это справедливо в небе чистой математики, где объекты неизменяемы Если вы определите ImmutableRectangle (с фиксированной шириной, высотой, положением, углом и вычисленным периметром, площадью,...), то ImmutableSquare будет подтипом ImmutableRectangle соответствии с LSP. На первый взгляд такие неизменные классы не кажутся очень полезно, но есть способ справиться с этим: заменить сеттеры методами, которые создадут новый объект, как вы бы сделали на любом функциональном языке. Например, ImmutableSquare.copyWithNewHeight(h) вернет новый... ImmutableRectangle, высота которого равна h а ширина - size квадрата.)

Мы можем использовать LSP, чтобы избежать этих ошибок.

Зачем нам нужен LSP?

Но почему на практике нам нужно заботиться о LSP? Потому что компиляторы не захватывают семантику класса. У вас может быть подкласс, который не является реализацией подтипа.

Для Liskov (и Wing, 1999) спецификация типа включает:

  • Название типа
  • Описание пространства значений типа
  • Определение свойств инварианта типа и истории;
  • Для каждого типа метода:
    • Его имя;
    • Его подпись (включая сигнальные исключения);
    • Его поведение с точки зрения предварительных условий и пост-условий

Если компилятор смог обеспечить выполнение этих спецификаций для каждого класса, он мог бы (во время компиляции или времени выполнения, в зависимости от характера спецификации) сказать нам: "эй, это не подтип!".

(На самом деле есть язык программирования, который пытается захватить семантику: Эйфель. В Эйфеле инварианты, предпосылки и пост-условия являются существенными частями определения класса. Поэтому вам не нужно забота о LSP: время выполнения сделает это за вас. Это было бы неплохо, но у Эйфеля тоже есть ограничения. Этот язык (любой язык?) не будет достаточно выразительным, чтобы определить полную семантику isValid(), потому что этот семантический не содержится в условии pre/post или инварианте.)

Теперь вернемся к примеру. Здесь единственное указание на семантику isValid - это имя метода: оно должно возвращать true, если объект действителен, а false - иначе. Очевидно, вам нужен контекст (и, возможно, подробные спецификации или знания домена), чтобы знать, что есть и что недействительно.

Фактически, я могу представить себе дюжину ситуаций, когда любой объект типа Base действителен, но все объекты типа Child недопустимы (см. Код в верхней части ответа). Например, замените Base by Passport и Child на FakePassword (предполагая, что поддельный пароль - это пароль...).

Таким образом, даже если класс Base говорит: "Я действителен", тип Base говорит: "Почти все мои экземпляры действительны, но те, кто недействителен, должны сказать это!" Вот почему у вас есть класс Child реализующий Base тип (и выводящий Base класс), который гласит: "Я недействителен".

Более интересный пример

Но я думаю, что выбранный вами пример не самый лучший для проверки условий pre/post и инвариантов: поскольку функция чиста, она не может, по дизайну, прервать любой инвариант; поскольку возвращаемое значение является логическим (2 значения), нет никакого интересного пост-состояния. Единственное, что у вас есть, это интересное предварительное условие, если у вас есть некоторые параметры.

Возьмем более интересный пример: сборник. В псевдокоде у вас есть:

abstract class Collection {
    abstract iterator(); // returns a modifiable iterator
    abstract size();

    // a generic way to set a value
    set(i, x) {
        [ precondition: 
            size: 0 <= i < size() ]

        it = iterator()
        for i=0 to i:
            it.next()
        it.set(x)

        [ postcondition:
            no_size_modification: size() = old size()
            no_element_modification_except_i: for all j != i, get(j) == old get(j)
            was_set: get(i) == x ]
    }

    // a generic way to get a value
    get(i) {
        [ precondition:
            size: 0 <= i < size() ]

        it = iterator()
        for i=0 to i:
            it.next()
        return it.get()

        [ postcondition:
            no_size_modification: size() = old size()
            no_element_modification: for all j, get(j) == old get(j) ]
    }

    // other methods: remove, add, filter, ...

    [ invariant: size_positive: size() >= 0 ]
}

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

class RandomAccessCollection {
    // all pre/post conditions and invariants are inherited from Collection.

    // fields:
    // self.count = number of elements.
    // self.data = the array.

    iterator() { ... }
    size() { return self.count; }

    set(i, x) { self.data[i] = x }

    get(i) { return self.data[i] }

    // other methods
}

Очевидно, что семантика get и set в RandomAccessCollection соответствует определениям класса Collection. В частности, выполняются все условия pre/post и инвариант. Другими словами, условия LSP удовлетворяются и, таким образом, LSP соблюдается: мы можем заменить в каждой программе любой объект типа Collection аналоговым объектом типа RandomAccesCollection не нарушая поведения программ.

Заключение

Как вы видите, легче уважать LSP, чем разорвать его. Но иногда мы SortedRandomAccessCollection его (например, попытаемся сделать SortedRandomAccessCollection который наследует RandomAccessCollection). Кристально понятная формулировка LSP помогает нам сузить то, что пошло не так, и что нужно сделать, чтобы исправить дизайн.

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

Рекомендации

Есть две основные оригинальные работы Лискова: " Абстракция данных и иерархия" (1987) и " Поведенческий подтипирование с использованием инвариантов и ограничений" (1994, 1999, с JM Wing). Обратите внимание, что это теоретические работы.

Ответ 4

Барбара Лисков, Jeannette Wing 1994:
"Пусть q (x) - свойство, доказуемое об объектах x типа T. Тогда q (y) должно быть доказуемо для объектов y типа S, где S - подтип T".
Простыми словами: Basetypes можно заменить Childtypes, когда поведение кода не изменится. Это подразумевает некоторые присущие ограничения.
Вот некоторые примеры:

  1. Исключения

    class Duck { void fly() {} }
    class RedheadDuck : Duck { void fly() {} }
    class RubberDuck : Duck { void fly() { throw new CannotFlyException(); }}
    class LSPDemo
    {
       public void Main()
       {
          Duck p = new Duck ();
          p.fly(); // OK
          p = new RedheadDuck();
          p.fly(); // OK
          p = new RubberDuck();
          p.fly(); // Fail, not same behavior as base class
       }
    }
    
  2. Контравариантность аргументов метода

    class Duck { void fly(int height) {} } 
    class RedheadDuck : Duck { void fly(long height) {} } 
    class RubberDuck : Duck { void fly(short height) {} }
    class LSPDemo 
    { 
       public void Main() 
       { 
          Duck p = new Duck(); p.fly(int.MaxValue);
          p = new RedheadDuck(); p.fly(int.MaxValue); // OK argumentType long(Subtype) >= int(Basetype)
          p = new RubberDuck(); p.fly(int.MaxValue); // Fail argumentType short(Subtype) < int(Basetype) 
       } 
    }
    
  3. Ковариация возвращаемых типов

    class Duck { int GetHeight() { return int.MaxValue; } } 
    class RedheadDuck: Duck { short GetHeight() { return short.MaxValue; } } 
    class RubberDuck: Duck { long GetHeight() { return long.MaxValue; } }
    class LSPDemo { 
       public void Main() 
       { 
          Duck p = new Duck(); int height = p.GetHeight();
          p = new RedheadDuck(); int height = p.GetHeight(); // OK returnType short(Subtype) <= int(Basetype)
          p = new RubberDuck(); int height = p.GetHeight(); // Fail returnType long(Subtype) > int(Basetype) 
       } 
    }
    
  4. Ограничение истории

     class Duck 
     { 
       protected string Food { get; private set; } 
       protected int Age { get; set; } 
       public Duck(string food, int age) 
       { 
          Food = food; 
          Age = age; 
       } 
     } 
    
     class RedheadDuck : Duck 
     { 
        void IncrementAge(int age) 
        { 
           this.Age += age; 
        } 
     } 
    
     class RubberDuck : Duck 
     { 
        void ChangeFood(string newFood) 
        { 
           this.Food = newFood; 
        } 
     } 
    
     class LSPDemo 
     { 
        public void Main() 
        { 
           Duck p = new Duck("apple", 10); 
    
           p = new RedheadDuck(); 
           p.IncrementAge(1); // OK 
    
           p = new RubberDuck(); 
           p.ChangeFood("pie"); // Fail, Food is defined as private set in base class
        } 
     }
    

и многое другое... Надеюсь, вы получите Идею.