【问题标题】:Overriding virtual boolean pure method without LSP breaking在不破坏 LSP 的情况下覆盖虚拟布尔纯方法
【发布时间】:2018-10-07 08:25:06
【问题描述】:

例如我们有如下结构:

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 冲突?让我们想象它只是一种分析方法,我们不能改变实例的状态。 我们能做到吗? 我对任何例子都感兴趣。 我试图了解虚拟(实体)布尔方法在一般情况下是否反模式

【问题讨论】:

    标签: oop design-patterns solid-principles liskov-substitution-principle


    【解决方案1】:

    首先,你的答案:

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

    基本上,LSP 说(它是“子类型”的定义):

    如果对于每个 S 类型的对象 o1 都有一个 T 类型的对象 o2 使得对于所有以 T 定义的程序 P,当 o1 替换 o2 时 P 的行为不变,那么 S 是T.(里斯科夫,1987 年)

    “但是我不能用任何o2 类型的Child 替换Base 类型的o1,因为它们的行为显然不同!”为了解决这个问题,我们不得不绕道而行。

    什么是子类型?

    首先,请注意 Liskov 不仅在谈论类,还谈论类型。类是类型的实现。类型的实现有好有坏。我们将尝试区分它们,尤其是在涉及子类型时。

    里氏替换原则背后的问题是:什么是子类型? 通常,我们假设子类型是其超类型的特化和其能力的扩展:

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

    另一方面,大多数编译器假定子类型是一个类,它至少具有相同的方法(相同的名称、相同的签名,包括协变和异常),无论是继承的还是重新定义的(或首次定义的),并且标签(inheritsextends,...)。

    但是这些标准是不完整的并且会导致错误。以下是两个臭名昭著的例子:

    • SortedList 是 (?) List 的子类型:它表示已排序(特化)的列表。
    • Square 是 (?) Rectangle 的子类型:它表示一个具有四个相等边的矩形(特化)。

    为什么SortedList 不是List?因为List 类型的语义。类型不仅是签名的集合,方法也具有语义。 通过语义,我指的是一个对象的所有授权使用(记住维特根斯坦:“一个词的含义是它在语言中的使用”)。例如,您希望在放置它的位置找到一个元素。但是如果列表总是排序的,一个新插入的元素将被移动到它的“正确”位置。因此,您不会在放置它的位置找到该元素。

    为什么Square 不是Rectangle?想象一下你有一个方法set_width:用一个正方形,你也必须改变高度。但是set_width 的语义是改变宽度但保持高度不变。

    (正方形不是长方形吗?这个问题有时会引起热烈的讨论,所以我会详细阐述这个问题。我们都知道正方形是长方形。但在纯数学的天空中是这样的,在哪里对象是不可变的。如果你定义了一个ImmutableRectangle(具有固定的宽度、高度、位置、角度和计算的周长、面积……),那么根据 LSP,ImmutableSquare 将是ImmutableRectangle 的子类型。乍一看,这样的不可变类似乎不是很有用,但有一种方法可以解决这个问题:用创建新对象的方法替换 setter,就像在任何函数式语言中所做的那样。例如,ImmutableSquare.copyWithNewHeight(h) 将返回一个新对象。 ..ImmutableRectangle,其高度为h,宽度为正方形的size。)

    我们可以使用 LSP 来避免这些错误。

    为什么我们需要 LSP?

    但是为什么,在实践中,我们需要关心 LSP? 因为编译器不捕获类的语义。您可能有一个子类不是子类型的实现。

    对于 Liskov(和 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 ]
    }
    

    这个集合有一些抽象方法,但setget 方法已经是实体方法。此外,我们可以说他们 对于链表是可以的,但对于由数组支持的列表则不行。让我们尝试为随机访问集合创建一个更好的实现:

    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
    }
    

    很明显getsetRandomAccessCollection中的语义符合Collection类的定义。特别是,满足所有前置/后置条件和不变量。换句话说,LSP 的条件得到满足,因此 LSP 得到尊重:在每个程序中,我们可以用 @ 类型的 analog 对象替换 Collection 类型的任何对象987654372@ 不会破坏程序的行为。

    结论

    如您所见,尊重 LSP 比破坏它更容易。但有时我们会破坏它(例如,尝试创建一个继承 RandomAccessCollectionSortedRandomAccessCollection)。 LSP 清晰的表述有助于我们缩小问题的范围以及应该采取哪些措施来纠正设计。

    更一般地说,如果基类有足够的肉体来实现方法,则虚拟(实体)布尔方法不是反模式。但是如果基类太抽象以至于每个子类都必须重新定义方法,那么就让方法抽象吧。

    参考文献

    Liskov 有两篇主要的原始论文:Data Abstraction and Hierarchy (1987) 和 Behavioral Subtyping Using Invariants and Constraints (1994, 1999, with J. M. Wing)。请注意,这些是理论论文。

    【讨论】:

    • 好答案,谢谢!你说In Eiffel, the invariants, the pre-conditions and the post-conditions are essential parts of the definition of a class. Therefore, you don't have to care about the LSP: the runtime will do it for you. 那么为什么不用埃菲尔来回答呢?您的 IsValid() 版本的前置(后)条件是什么?据我了解 Base.IsValid() 的后置条件 - 在任何情况下都返回 false 。如果该语言支持代码契约,则应该为方法配置它。但随后 Child.IsValid() 打破了这个后置条件。附言在阅读原始论文...
    • @Serg046。本段最后一句是:That would be nice, but Eiffel has also limitations。这种语言(任何语言?)的表达力不足以定义isValid() 的完整语义,因为这种语义不包含在前置/后置条件或不变量中。在这里,我们对这个语义的唯一指示是方法的名称:如果对象有效,它应该返回 true,否则返回 false。您显然需要上下文(可能还需要详细的规范或领域知识)来了解什么是有效的,什么是无效的。
    • 但我建议使用任何上下文。您可以介绍任何人,并尽量不要在孩子身上破坏它。
    • @Serg046 这就是护照示例的想法。您想要一个包含前置/后置条件和不变量的示例吗?
    • 是的,如果您有时间,我们将不胜感激
    【解决方案2】:

    LSP 的思想并不禁止子类的多态性。相反,它强调什么可以改变,什么不能改变。 一般来说,这意味着:

    1. 任何覆盖函数都接受并返回相同类型的覆盖函数;这包括可能引发的异常(输入类型可能会扩展被覆盖的异常,而输出类型可能会缩小它们 - 这仍将保持该限制)。
    2. “历史规则”- Child 对象的“Base”部分不得被 Child 的函数更改为使用 Base 类函数永远无法达到的状态。因此,期望 Base 对象的函数永远不会得到意外结果。
    3. 不得在 Child 中更改 Base 的不变量。也就是说,任何关于 Base 类行为的一般假设都必须由 Child 保留。

    前两个项目符号的定义非常明确。 “不变量”更多是一个意义问题。例如,如果实时环境中的某个类要求其所有函数在某个恒定时间内运行,则其子类型中的所有覆盖函数也必须遵守该要求。

    在您的情况下, IsValid() 意味着某事,并且“某事”必须保留在所有子类型下。例如,假设您的 Base 类定义了一个产品,并且 IsValid() 告诉您该产品是否可以出售。使每种产品有效的确切原因可能会有所不同。例如,它必须设置其价格才能有效出售。但儿童产品还必须经过电力测试才能出售。

    在这个例子中,我们保留了所有的要求:

    1. 函数的输入输出类型没有改变。
    2. Child 对象的 Base 部分的状态未以 Base 类无法预期的方式更改。
    3. 保留类的不变量:没有价格的子对象仍然不能出售;无效的意思还是一样的(不允许出售),只是按照与Child匹配的方式计算。

    你可以得到更多的解释here

    ===

    编辑 - 根据注释进行一些额外的解释

    多态性的整体思想是相同的功能被每个子类型以不同的方式完成。 LSP 不违反多态性,但描述了多态性应该注意的事项。 特别是,LSP 要求任何子类型Child 可以在代码需要Base 的地方使用,并且对Base 所做的任何假设都适用于他的任何Childs。在上面的示例中,IsValis() 表示“有价格”。相反,它的确切含义是:产品有效吗?在某些情况下,有一个价格就足够了。在其他情况下,它还需要电力检查,但在其他情况下,它可能还需要一些其他属性。如果Base 类的设计者不要求通过设置价格使产品生效,而是将IsValid() 作为单独的测试,则不会发生违反 LSP。什么样的例子会造成这种违反?例如,询问对象是否为IsValid(),然后调用不应更改有效性的基类 函数,并且该函数将Child 更改为不再有效。这违反了 LSP 的历史规则。此处其他人提供的已知示例是正方形作为矩形的子代。但是只要相同的函数调用序列不需要特定的行为(同样 - 没有定义设置价格使产品有效;它恰好在某些类型中是这样的) - LSP 根据需要保留.

    【讨论】:

    • 感谢您的回答。你是第一个,现在提供了一个更好的答案,恕我直言。我已经阅读了你的链接。它说:Any type (class) that inherits another type, must be substitutive to that type, so that if class B inherits class A, then anywhere in the code where an object of type A is expected, we can provide a B object without changing the system behavior.
    • 但在您的示例中,它看起来像电力测试破坏了Base 类的行为。例如,我们有一个参数为Base 类型的方法。在方法内部,我们有 IsValid 调用。我们知道该参数在设置价格时有效。但是如果Child 被传递给该方法,那么我们会有另一种行为。所以它不是100%的替代品。对于您的示例,我想要IProduct 与方法IsValid 的接口,然后为BaseChild 实现它。对我来说就像ICollection.AddAdd 的 List 每次都添加一个元素,但是 HashSet - 没有。
    【解决方案3】:

    Barbara Liskov,Jeannette Wing 1994:
    “设 q(x) 是关于 T 型对象 x 的可证明性质。那么 q(y) 应该对于 S 型对象 y 是可证明的 其中 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
          } 
       }
      

    还有更多...我希望你能得到这个想法。

    【讨论】:

      【解决方案4】:

      LSP 背后的基本思想不是阻碍OverrideBase 类的方法的能力,而是避免改变Base 类的内部状态(改变基类类的数据成员)。基类不会有。

      它简单地说:任何继承另一个类型的类型(类)必须是 替代该类型,因此如果 Child 类继承 Base 类,然后在代码中Base 类的对象所在的任何位置 预期,我们可以提供一个Child 类对象而不改变 系统行为。

      然而它并不妨碍我们修改 Child 类的成员。违反此示例的著名示例是方形/矩形问题。您可以找到示例here的详细信息。

      在您的情况下,由于您只是分析 IsValid() 中的一些数据,而不是修改 Base 类的内部状态,因此不应该违反 LSP。

      【讨论】:

      • @Serg046 For example we have a method with parameter of Base type 是您的 Base 类的此方法成员还是在 Base 类的层次结构之外的某个其他类的成员?
      • 我相信没关系。让我们说另一个类。
      • 我问的原因是因为这行:但是如果Child is passed to the method then we have another behavior. 我想理解这个说法。由于此方法或您的IsValid (),行为有所不同
      • 因为 Child.IsValid 中的电测试,如 Mike 所述。无论如何,您可以建议您的示例并填写问题中的方法主体。
      猜你喜欢
      • 1970-01-01
      • 2012-06-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-12-29
      • 1970-01-01
      • 1970-01-01
      • 2012-12-02
      相关资源
      最近更新 更多