0
点赞
收藏
分享

微信扫一扫

《敏捷软件开发》— 设计原则(二)

夏沐沐 2022-02-23 阅读 71

《敏捷软件开发》— 设计原则(二)

一、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 并将 setWidthsetHeight 设置为虚函数,我们可以满足部分里氏替换原则。然而,考虑如下的测试代码:

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 弱)。因而,SquaresetWidth 方法违反了基类定下的契约。

4、公共部分提取

书中提到了两个实际开发中的问题,它们都是通过提取公共部分解决的。

(1)背景

假设我们在程序中使用第三方的代码库,其中包含了使用数组定义的 BoundedSet 以及使用链表定义的 UnboundedSet。为了满足依赖倒置原则,我们应该提供一个公共接口。在使用第三方厂家库时,我们也经常会使用适配器模式以便日后进行库的替换和升级:
在这里插入图片描述

(2)问题

如果我们想引入用于持久化存储的集合类(类似于数据库),可以在不同的模块进行读取写入操作。然而,我们唯一提供持久化功能的第三方荣气哭不是一个模板类。它只接受虚基类 PersistentObject 的派生对象作为 add 方法的参数。如果将其加入我们的程序中,初版设计如下:
在这里插入图片描述
显然,这并不是一个符合 LSP 的设计。因为 PersistentSet 的前置条件比 Set 的前置条件更强。因此,如果在配置模块将使用 PersistentSet 替换 UnboundedSet 可能导致客户程序运行异常。

(3)不符合 LSP 的解决方案

(4)符合 LSP 的解决方案

首先,我们要明确问题产生的本质原因仍是从 add 行为的角度来说,PersistentSet 不是 Set。那么如果我们把该行为放到子类中呢?其余行为就可以满足 IS-A 关系,自然也可以使用继承关系:
在这里插入图片描述

举报

相关推荐

0 条评论