《敏捷软件开发》— 设计原则(二)
一、Liskov 替换原则(LSP)
如果我们不遵守这项原则,则导致在使用派生类对象作为参数调用以基类对象为参数的方法时,程序的运行错误行为。这种错误有可能是抛出了异常,也有可能是错误的运行状态。
1、关键仍是抽象
想要满足里氏替换原则,关键仍在抽象上。在某个程序或者某个领域中满足此原则的设计,在经过迁移后可能会变得不满足这项原则。这取决于我们的抽象能否描述领域问题。
2、几维鸟
相关的例子有很多,我们先用一个比较常见的几维鸟的例子来说明。
(1)几维鸟是一种鸟
假设我们现在需要开发一个生物百科图鉴,每个种类的生物描述组成由父类决定,具体描述由子类实现:
class Bird
{
public:
string getDescription()
{
string desc = "name: " + getName() +
"\n\t flight ability: " + getFlightAbilityDesc() +
"\n\t food: " + getFoodDesc();
return desc;
}
protected:
virtual string getName()
{
return name;
}
virtual string getFlightAbilityDesc() = 0;
virtual string getFoodDesc() = 0;
private:
string name;
};
class BrownKiwi : public Bird
{
protected:
virtual string getFlightAbilityDesc() override
{
return "cannot fly";
}
virtual string getFoodDesc() override
{
return "insects";
}
};
class Swallow : public Bird
{
virtual string getFlightAbilityDesc() override
{
return "long-distance fly";
}
virtual string getFoodDesc() override
{
return "insects";
}
};
(2)几维鸟不是鸟
上述的例子很好的反映了在当前的领域模型中,几维鸟是一种鸟类。然而,如果我们的需求是,计算各种鸟类飞行100公里所需要的时间,那么几维鸟就不能被和燕子被直接抽象为鸟了。几维鸟本身不具备飞行的能力,因此计算结果要么抛出异常,要么返回0。无论哪种结果都是违反里式替换原则的。那么我们就需要针对特定的需求改变我们的抽象方式:
3、正方形和矩形
这个问题也是一个很常见的问题,我们在《Effictive C++》学习笔记 — 继承与面向对象设计中讨论过。
(1)问题
如果使用 Square 继承 Rectangle 并将 setWidth 和 setHeight 设置为虚函数,我们可以满足部分里氏替换原则。然而,考虑如下的测试代码:
void g(Rectangle& r)
{
r.setWidth(5);
r.setHeight(4);
assert(r.Area() == 20);
}
我们无法通过这样的测试。
显然,在设计这个测试的函数时,我们假设改变一个长方形的宽不会影响它的长的是合理的。然而,如果我们把一个 Square 实例传递给 g,那么函数的行为是错误的,这违反了里式替换原则。
(2)有效性并非本质属性
LSP 让我们得出一个非常重要的结论:一个模型,如果孤立地看,并不具有真正意义上的有效性。模型的有效性只能通过它的客户程序来表现。
在考虑一个特定设计是否恰当时,不能完全孤立地来看这个解决方案。必须根据该设计的使用者所做出的合理假设来审视它。这也是使用 TDD 的一大好处。
(3)IS-A 是关于行为的
这个问题的产生来源于我们从小学到的:正方形是一个矩形。事实上,对于不是 g 的编写者而言,正方形可以是长方形;但是从 g 的角度来看,正方形对象绝不是矩形对象。因为 Square 对象的行为方式和函数 g 所期待的 Rectangle 对象的行为方式不相容。从行为方式的角度来看,Square 不是 Rectangle,尽管从概念上来说,它们满足 IS-A 的关系。对象的行为方式才是软件真正所关注的问题。
(4)基于契约设计
有一项可以明确合理假设的技术,被称为基于契约设计(Design By Contract,DBC)。
使用DBC,累的编写者显式地规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法生命的前置条件和后置条件来制定的。要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。
按照Meyer所述,派生类的前置和后置条件规则是:
换句话说,当通过基类的接口使用对象时,用户知道基类的前置条件和后置条件。因此,派生类对象不能期望这些用户遵从比基类更强的前置条件。也就是说,它们必须接受基类可以接受的一切。同时,派生类必须和积累的所有后置条件一致。也就是说,他们的行为方式和输出不能违反基类已经确立的任何限制。
显然,Square::setWidth 的后置条件比 Rectangle::setWidth 的后置条件弱(如果 X 没有遵从 Y 的所有约束,那么 X 就比 Y 弱)。因而,Square 的 setWidth 方法违反了基类定下的契约。
4、公共部分提取
书中提到了两个实际开发中的问题,它们都是通过提取公共部分解决的。
(1)背景
假设我们在程序中使用第三方的代码库,其中包含了使用数组定义的 BoundedSet 以及使用链表定义的 UnboundedSet。为了满足依赖倒置原则,我们应该提供一个公共接口。在使用第三方厂家库时,我们也经常会使用适配器模式以便日后进行库的替换和升级:
(2)问题
如果我们想引入用于持久化存储的集合类(类似于数据库),可以在不同的模块进行读取写入操作。然而,我们唯一提供持久化功能的第三方荣气哭不是一个模板类。它只接受虚基类 PersistentObject 的派生对象作为 add 方法的参数。如果将其加入我们的程序中,初版设计如下:
显然,这并不是一个符合 LSP 的设计。因为 PersistentSet 的前置条件比 Set 的前置条件更强。因此,如果在配置模块将使用 PersistentSet 替换 UnboundedSet 可能导致客户程序运行异常。
(3)不符合 LSP 的解决方案
(4)符合 LSP 的解决方案
首先,我们要明确问题产生的本质原因仍是从 add 行为的角度来说,PersistentSet 不是 Set。那么如果我们把该行为放到子类中呢?其余行为就可以满足 IS-A 关系,自然也可以使用继承关系: