6

目前,代码协定不允许对派生类中的成员设置先决条件,其中成员已经在基类中设置了先决条件(我实际上目前收到警告而不是错误)。我不明白这背后的逻辑。我知道这与 Liskov 的替换规则有关,即派生类应该始终能够在预期父类的地方使用。当然,“使用”意味着按预期工作。对于接口,这对我来说似乎没问题,因为实现接口的不同类型不会添加状态,因此可以完全遵守合同。但是,当您从基类继承时,您这样做是为了添加状态和特殊功能,而且覆盖方法通常会有额外的要求。为什么可以'

看看下面:

class Speaker
{
    public bool IsPlugged { get; set; }
    protected virtual void Beep()
    {
        Contract.Requires(IsPlugged);
        Console.WriteLine("Beep");
    }
}

class WirelessSpeaker : Speaker
{
    public bool TransmitterIsOn { get; set; }
    protected override void Beep()
    {
        Contract.Requires(TransmitterIsOn);
        base.Beep();
    }
}

您可能会争辩说,这个类层次结构违反了 Liskov 的规则,因为无线扬声器在传递给期望Speaker. 但这不就是我们使用代码合约的原因吗?确保满足要求?

4

3 回答 3

9

代码契约不是关于需求的满足,而是它们的沟通。的调用者Speaker.Beep受仅在某些情况下生效的合同的约束。

WirelessSpeaker 缩小了功能空间Speaker——这就是 Liskov 发挥作用的地方。Speaker如果我知道它是无线的,我只能有效地使用它。在这种情况下,我应该明确地接受WirelessSpeaker,而不是Speaker,并避免替换问题。

根据评论进行编辑:

作者WirelessSpeaker选择如何解释Beep命令。选择一个在此级别可见但在基本级别不可见的新合约会施加约束,在使用Speakers 时应用的时间小于 100%。

如果它只是在发射器未打开时不发出哔哔声,我们就不会谈论代码合同。他们的意图不是在运行时进行通信,而是在设计时进行调用的语义(不仅仅是它的语法)。

在运行时发生异常,最终阻止“不正确”调用的事实在很大程度上无关紧要。

于 2014-11-05T19:49:27.863 回答
3

@BryanWatts 是对的。OP 提供的类违反了 Liskov 替换原则。而且你不应该使用异常来控制程序流程——这也是一种代码味道。异常意味着,好吧,异常 - 不允许您的对象以预期方式运行的异常情况,这可能导致您的对象状态和/或未来行为的损坏。

您需要确保您了解Liskov 替换原则 (LSP)的全部内容。LSP 并不是要确保interfaces 可以互换使用。

当一个对象从另一个对象继承时,它继承了它的所有父对象的行为。诚然,您可以覆盖该行为,但您必须小心这样做。让我们使用您的 aSpeaker和 a示例,WirelessSpeaker看看它是如何分崩离析的。

public class Speaker
{
    public bool IsPlugged { get; set; }

    public virtual void Beep()
    {
        if (!IsPlugged)
        {
            throw
            new InvalidOperationException("Speaker is not plugged in!");
        }

        Console.WriteLine("Beep.");
    }
}

public class WirelessSpeaker : Speaker
{
    public bool TransmitterIsOn { get; set }

    public override void Beep()
    {
        if (!TransmitterIsOn)
        {
            throw
            new InvalidOperationException("Wireless Speaker transmitter is not on!");
        }

        Console.WriteLine("Beep.");
    }
}

public class IBeepSpeakers
{
    private readonly Speaker _speaker;

    public IBeepSpeakers(Speaker speaker)
    {
        Contract.Requires(speaker != null);
        Contract.Ensures(_speaker != null && _speaker == speaker);
        _speaker = speaker;

        // Since we know we act on speakers, and since we know
        // a speaker needs to be plugged in to beep it, make sure
        // the speaker is plugged in.
        _speaker.IsPlugged = true;
    }

    public void BeepTheSpeaker()
    {
        _speaker.Beep();
    }
}

public static class MySpeakerConsoleApp
{
    public static void Main(string[] args)
    {
        BeepWiredSpeaker();

        try
        {
            BeepWirelessSpeaker_Version1();
        }
        catch (InvalidOperationException e)
        {
            Console.WriteLine($"ERROR: e.Message");
        }

        BeepWirelessSpeaker_Version2();
    }

    // We pass in an actual speaker object.
    // This method works as expected.
    public static BeepWiredSpeaker()
    {
        Speaker s = new Speaker();
        IBeepSpeakers wiredSpeakerBeeper = new IBeepSpeakers(s);
        wiredSpeakerBeeper.BeepTheSpeaker();
    }

    public static BeepWirelessSpeaker_Version1()
    {
        // This is a valid assignment.
        Speaker s = new WirelessSpeaker();

        IBeepSpeakers wirelessSpeakerBeeper = new IBeepSpeakers(s);

        // This call will fail!
        // In WirelessSpeaker, we _OVERRODE_ the Beep method to check
        // that TransmitterIsOn is true. But, IBeepSpeakers doesn't
        // know anything _specifically_ about WirelessSpeaker speakers,
        // so it can't set this property!
        // Therefore, an InvalidOperationException will be  thrown.
        wirelessSpeakerBeeper.BeepTheSpeaker();
    }

    public static BeepWirelessSpeaker_Version2()
    {
        Speaker s = new WirelessSpeaker();
        // I'm using a cast, to show here that IBeepSpeakers is really
        // operating on a Speaker object. But, this is one way we can
        // make IBeepSpeakers work, even though it thinks it's dealing
        // only with Speaker objects.
        //
        // Since we set TransmitterIsOn to true, the overridden
        // Beep method will now execute correctly.
        //
        // But, it should be clear that IBeepSpeakers cannot act on both
        // Speakers and WirelessSpeakers in _exactly_ the same way and
        // have confidence that an exception will not be thrown.
        ((WirelessSpeaker)s).TransmitterIsOn = true;

        IBeepSpeakers wirelessSpeakerBeeper = new IBeepSpeaker(s);

        // Beep the speaker. This will work because TransmitterIsOn is true.
        wirelessSpeakerBeeper.BeepTheSpeaker();
}

这就是您的代码违反Liskov 替换原则 (LSP)的方式。正如 Robert & Micah Martin在第 142-143 页的 C# 中的敏捷原则、模式和实践中敏锐地指出的那样:

LSP 明确指出,在 OOD 中,IS-A 关系属于可以合理假设的行为,并且客户端依赖于....[当通过其基类接口使用对象时,用户只知道先决条件和基类的后置条件。因此,派生对象不能期望这些用户遵守比基类要求的更强大的先决条件。也就是说,用户必须接受基类可以接受的任何内容。此外,派生类必须符合基 [类] 的所有后置条件。

通过本质上具有 的方法的前提条件TransmitterIsOn == trueBeepWirelessSpeaker创建了比基类中存在的更强大的前提条件。Speaker对于WirelessSpeakers,两者IsPlugged和都TransmitterIsOn 必须true为了Beep使行为符合预期(从 a 的角度来看Speaker),即使 aSpeaker本身并没有 的概念TransmitterIsOn

此外,您违反了另一个 SOLID 原则,即接口隔离原则 (ISP)

不应强迫客户依赖他们不使用的方法。

在这种情况下, aWirelessSpeaker不需要插入。(我假设我们在这里谈论的是音频输入连接,而不是电气连接。)因此,WirelessSpeaker应该没有任何名为 的属性IsPlugged,因为它继承自Speaker,确实如此!这表明您的对象模型与您打算使用对象的方式不一致。再次注意,大部分讨论都集中在对象的行为上,而不是它们之间的关系。

此外,违反 LSP 和 ISP 都表明可能也违反了开放/封闭原则 (OCP)

软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。

因此,现在应该清楚的是,我们不使用代码契约只是为了确保在调用对象上的方法时满足某些先决条件。不,相反,代码合同用于根据所述的前置和后置条件以及您可能还定义的任何不变量来声明关于您的对象的行为状态及其方法的保证(因此使用了这个词contract) 。

因此,对于您的扬声器类,您要说的是:如果扬声器已插入,那么扬声器会发出哔哔声。好的,到目前为止,一切都很好;这很简单。现在,WirelessSpeaker课堂呢?

嗯,WirelessSpeaker继承自Speaker. 因此,WirelessSpeaker也具有IsPlugged布尔属性。此外,因为它继承自Speaker,所以为了WirelessSpeaker发出哔哔声,它还必须IsPlugged属性设置为true。“可是等等!” 你说,“我已经覆盖了发射器必须打开的实现。BeepWirelessSpeaker是的,这是真的。但它必须插入!WirelessSpeaker不仅继承了Beep方法,还继承了其父类实现的行为!(考虑何时使用基类引用代替派生类。)由于父类可以“插入”,因此也可以WirelessSpeaker; 我怀疑这是您最初想到此对象层次结构时的意图。

那么,你将如何解决这个问题?好吧,您需要想出一个更好地与相关对象的行为保持一致的模型。我们对这些物体及其行为了解多少?

  1. 他们都是一种演讲者。
    • 因此,无线扬声器可能是扬声器的专业化。相反,扬声器可以是无线扬声器的一般化。
    • 在当前的对象模型中(与您发布的一样多),这两个对象之间没有太多共享的行为或状态。
    • 由于两个对象之间没有太多共同的状态或行为,人们可能会争辩说这里不应该存在继承层次结构。我将和你一起扮演魔鬼的拥护者并维持继承等级。
  2. 他们都发出哔哔声。
    • 但是,每种扬声器发出蜂鸣声的条件不同。
    • 因此,这些说话者不能直接从另一个继承一个,否则,他们会共享可能不适合他们的行为(在这种情况下,现有的“共享行为”肯定不适用于所有类型的说话者)。这解决了 ISP 问题。

好的,所以这些扬声器的一个共同行为就是发出哔哔声。因此,让我们将该行为抽象为一个抽象基类:

// NOTE: I would prefer to simply call this Speaker, and call
// Speaker 'WiredSpeaker' instead--but to leave your concrete class
// names as they were in your original code, I've chosen to call this
// SpeakerBase.
public abstract class SpeakerBase
{
    protected SpeakerBase() { }

    public void Beep()
    {
        if (CanBeep())
        {
            Console.WriteLine("Beep.");
        }
    }

    public abstract bool CanBeep();
}

伟大的!现在我们有一个代表说话者的抽象基类。当且仅当CanBeep()方法返回时,此抽象类将允许扬声器发出哔声true。而且这个方法是抽象的,所以任何继承这个类的类都必须为这个方法提供自己的逻辑。通过创建这个抽象基类,我们允许任何依赖于该类的类在且仅当返回时SpeakerBase从扬声器发出哔声。这也解决了 LSP 违规!任何可以使用 a 并要求发出哔声的地方,都可以用 a或 a代替,我们可以确定其行为:如果扬声器可以发出哔声,它就会发出哔声。CanBeep()trueSpeakerBaseSpeakerWirelessSpeaker

现在剩下的就是从以下导出我们的每个扬声器类型SpeakerBase

public class Speaker : SpeakerBase
{
    public bool IsPlugged { get; set; }

    public override bool CanBeep() => IsPlugged;
}

public class WirelessSpeaker : SpeakerBase
{
    public bool IsTransmiterOn { get; set; }

    public override bool CanBeep() => IsTransmitterOn;
}

所以,现在我们有一个Speaker只有在插入时才会发出哔声的声音。我们还有一个WirelessSpeaker只有在发射器打开时才会发出哔声的声音。此外,WirelessSpeakers 对被“插入”一无所知。这根本不是他们本质的一部分。

此外,遵循依赖倒置原则(DIP)

  1. 高级模块不应该依赖于低级模块。两者都应该依赖于抽象。
  2. 抽象不应依赖于细节。细节应该取决于抽象。

这意味着扬声器的消费者不应直接依赖于SpeakerWirelessSpeaker,而应依赖于SpeakerBase。这样,无论出现什么样的说话者,如果它继承自SpeakerBase,我们知道如果条件允许,我们可以在依赖类中抽象类型引用的说话者子类型的情况下发出声音。这也意味着IBeepSpeakers不再知道如何将扬声器置于可以发出哔声的状态,因为扬声器类型之间没有共同的行为IBeepSpeakers可以用来做出这样的决定。因此,该行为必须作为依赖项传递给IBeepSpeakers. (这是一个可选依赖项;您可以让类接受 aSpeakerBase并调用Beep(),并且,如果SpeakerBase对象处于正确状态,它会发出哔哔声,否则不会。)

public class IBeepSpeakers
{
    private readonly SpeakerBase _speaker;
    private readonly Action<SpeakerBase> _enableBeeping;

    public IBeepSpeakers(SpeakerBase speaker, Action<SpeakerBase> enableBeeping)
    {
        Contract.Requires(speaker != null);
        Contract.Requires(enableBeeping != null);
        Contract.Ensures(
            _speaker != null && 
            _speaker == speaker);
        Contract.Ensures(
            _enableBeeping != null && 
            _enableBeeping == enableBeeping);

        _speaker = speaker;
        _enableBeeping = enableBeeping;
    }

    public void BeepTheSpeaker()
    {
        if (!_speaker.CanBeep())
        {
           _enableBeeping(_speaker);
        }
        _speaker.Beep();
    }
}

public static class MySpeakerConsoleApp
{
    public static void Main(string[] args)
    {
        BeepWiredSpeaker();

        // No more try...catch needed. This can't possibly fail!
        BeepWirelessSpeaker();
    }

    public static BeepWiredSpeaker()
    {
        Speaker s = new Speaker();
        IBeepSpeakers wiredSpeakerBeeper =
            new IBeepSpeakers(s, s => ((Speaker)s).IsPlugged = true);
        wiredSpeakerBeeper.BeepTheSpeaker();
    }

    public static BeepWirelessSpeaker()
    {
        WirelessSpeaker w = new WirelessSpeaker();
        IBeepSpeakers wirelessSpeakerBeeper =
            new IBeepSpeakers(w, s => ((WiredSpeaker)s).IsTransmitterOn = true);
        wirelessSpeakerBeeper.BeepTheSpeaker();
    }
}

如您所见,我们实际上根本不需要代码合同来告诉我们扬声器是否应该发出哔哔声。不,我们让对象本身的状态决定它是否可以发出哔哔声。

于 2016-02-21T03:24:58.943 回答
1

如果你真的想改变这样的行为,你可能想在基类中公开一个虚拟的“CanBeep”属性,然后为 WirelessSpeaker 实现它以返回 TransmitterIsOn。这样,您仍然可以将合同放在 Speaker 中,并且 Speaker 的消费者可以知道他们是否可以满足合同要求。

也就是说,可能与可变状态相关联的公共财产并不是合同要求的好选择。如果发送器在检查属性和调用方法之间中断,会发生什么?我认为仔细考虑合同的含义很重要。一个很好的问题是:这是我可以在编译时静态证明的条件,还是取决于运行时条件?顺便说一句,这个问题最容易通过运行静态合约分析工具来回答。

于 2014-11-05T19:55:54.873 回答