Переопределение виртуального логического чистого метода без прерывания LSP - PullRequest
0 голосов
/ 26 апреля 2018

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

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? Давайте представим, что это просто метод анализа, мы не можем изменить состояние экземпляра. Можем ли мы сделать это? Я заинтересован в любом примере. Я пытаюсь понять, является ли виртуальный (телесный) логический метод анти-паттерном или нет в общем случае.

Ответы [ 4 ]

0 голосов
/ 08 мая 2018

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

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 является подтипом Т. (Лисков, 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? Поскольку компиляторы не фиксируют семантику класса. У вас может быть подкласс, который не является реализацией подтипа.

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

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

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

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

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

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

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

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

Но я думаю, что выбранный вами пример не лучший для проверки предварительных / постусловных условий и инвариантов: поскольку функция чистая, она не может, по замыслу, нарушить любой инвариант; так как возвращаемое значение является логическим (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. В частности, все предварительные / последующие условия и инвариант выполнены. Другими словами, условия LSP выполняются, и, следовательно, соблюдается LSP: в каждой программе мы можем заменить любой объект типа Collection на аналог объект типа RandomAccesCollection без нарушения поведения программ.

Заключение

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

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

Ссылки

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

0 голосов
/ 05 мая 2018

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

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

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

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

0 голосов
/ 06 мая 2018

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

  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
        } 
     }
    

и более ... Я надеюсь, что вы получите идею.

0 голосов
/ 03 мая 2018

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

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

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

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

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

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

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

===

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

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

...