契约式设计(DBC)违反了Liskov替代原则(LSP)


Liskov Substitution Principle (LSP) violated via Design By Contract (DBC)?

我正在用PHP编写一个框架,并且遇到了一个很糟糕的模式。看来我正在实现一个违反Liskov替代原则(LSP)的合约(即契约式设计)。由于原始示例非常抽象,所以我将把它放到一个真实的环境中:

(注意。我不是一个引擎/车辆/空间-空间的人,原谅我,如果这是不现实的)


假设我们有一个关于车辆的抽象类,并且我们有两种子类型的车辆——可以加油的和不能加油的(例如推单车)。对于本例,我们将只关注可加油类型:

abstract class AbstractVehicle {}
abstract class AbstractFuelledVehicle extends AbstractVehicle
{
    private $lastRefuelPrice;
    final public function refuelVehicle(FuelInterface $fuel)
    {
        $this->checkFuelType($fuel);
        $this->lastRefuelPrice = $fuel->getCostPerLitre;
    }
    abstract protected function checkFuelType(FuelInterface $fuel);
}
abstract class AbstractNonFuelledVehicle extends AbstractVehicle { /* ... */ }

现在,让我们看看"fuel"类:

abstract class AbstractFuel implements FuelInterface
{
    private $costPerLitre;
    final public function __construct($costPerLitre)
    {
        $this->costPerLitre = $costPerLitre;
    }
    final public function getCostPerLitre()
    {
        return $this->costPerLitre;
    }
}
interface FuelInterface
{
    public function getCostPerLitre();
}

这是所有的抽象类,现在让我们看看具体的实现。首先,燃料的两个具体实现,包括一些贫血接口,以便我们可以正确地输入提示/嗅探它们:

interface MotorVehicleFuelInterface {}
interface AviationFuelInterface {}
final class UnleadedPetrol extends AbstractFuel implements MotorVehicleFuelInterface {}
final class AvGas extends AbstractFuel implements AviationFuelInterface {}

现在,我们终于有了车辆的具体实现,它确保使用正确的燃料类型(接口)来为特定的车辆类加油,如果不兼容则抛出异常:

class Car extends AbstractFuelledVehicle
{
    final protected function checkFuelType(FuelInterface $fuel)
    {
        if(!($fuel instanceof MotorVehicleFuelInterface))
        {
            throw new Exception('You can only refuel a car with motor vehicle fuel');
        }
    }
}
class Jet extends AbstractFuelledVehicle
{
    final protected function checkFuelType(FuelInterface $fuel)
    {
        if(!($fuel instanceof AviationFuelInterface))
        {
            throw new Exception('You can only refuel a jet with aviation fuel');
        }
    }
}

Car和Jet都是AbstractFuelledVehicle的子类型,所以根据LSP,我们应该可以替换它们。

由于checkFuelType()在提供了错误的AbstractFuel子类型时抛出异常,这意味着如果我们将AbstractFuelledVehicle子类型Car替换为Jet(反之亦然),而不替换相关的fuel子类型,我们将触发一个异常。

:

  1. 明确违反LSP,因为替换不应该引起导致抛出异常的行为改变
  2. 根本没有冲突,因为接口和抽象函数都是正确实现的,并且仍然可以在没有类型冲突的情况下调用
  3. 有点灰色地带,答案是主观的

将注释合并成答案…

我同意对LSP的分析:原来的版本是一种违反,我们总是可以通过削弱层级顶端的契约来解决LSP的违反。然而,我不认为这是一个优雅的解决方案。类型检查总是一种代码气味(在OOP中)。用OP自己的话来说,"…包括一些贫血接口,以便我们可以输入提示/嗅探它们……"这里闻到的是糟糕设计的恶臭。

我的观点是LSP在这里是最不重要的;instanceof是一种OO代码气味。在这里,遵守LSP就像给一栋烂房子刷新漆:它可能看起来很漂亮,但基础从根本上来说仍然不牢固。从设计中消除类型检查。此时才需要担心LSP。


一般来说,面向对象设计的SOLID原则,特别是LSP,作为面向对象设计的一部分是最有效的。在OOP中,类型检查被多态取代。

再考虑一下,我认为这个在技术上违反了Liskov替换原则。一种重新表述LSP的方法是"一个子类不应该要求更多,也不应该承诺更少"。在这种情况下,Car和Jet具体类都需要特定类型的燃料,以便代码继续执行(这违反了LSP),此外,方法checkFuelType()可以被重写以包含各种奇怪和奇妙的行为。我认为一个更好的方法是:


修改AbstractFuelledVehicle类以在提交加油之前检查燃料类型:

abstract class AbstractFuelledVehicle extends AbstractVehicle
{
    private $lastRefuelPrice;
    final public function refuelVehicle(FuelInterface $fuel)
    {
        if($this->isFuelCompatible($fuel))
        {
            $this->lastRefuelPrice = $fuel->getCostPerLitre;
        } else {
            /* 
              Trigger some kind of warning here,
              whether externally via a message to the user
              or internally via an Exception
            */
        }
    }
    /** @return bool */
    abstract protected function isFuelCompatible(FuelInterface $fuel);
}

对我来说,这是一个更优雅的解决方案,并且没有任何代码气味。我们可以将燃料从UnleadedPetrol交换到AvGas,超类的行为保持不变,尽管有两种可能的结果(即它的行为是而不是由具体类决定,这可能会抛出异常,记录错误,跳快步舞等)