继续我们的 SOLID 之旅,本篇来介绍定义最复杂的 SOLID 原则——Liskov 替换原则。
我并不是一个阅读爱好者。通常,当我阅读时,我发现自己在过去的几分钟里失去了文本的主题。我经常在整个章节结束前都不知道它到底讲了什么。
当我试图专注于内容时,这可能会令人沮丧,但我不断意识到我需要回头。这就是当我转向各种类型的媒体来了解一个话题时发生的情况。
我第一次遇到这个阅读问题是在SOLID原则,特别是Liskov替换原则,它的定义对我来说太复杂了,特别是它的正式格式。
正如你所猜测的,LSP代表SOLID这个单词中的字母“L”。这并不难理解,尽管一个更少的数学定义将受到赞赏。
利斯科夫替代原理的理解
Liskov 替换原则指出,如果 S 是 T 的一个子类型,那么程序中 T 类型的对象可以用 S 类型的对象替换,而不会改变该程序的任何期望属性。
我们第一次听说这个原则是在1988年,由Barbara Liskov提出,后来,Uncle Bob在他的论文中提出了他对这个主题的看法,后来又把它作为SOLID原则之一。
换句话说(图 1),如果ObjectA
是ClassA
的一个实例,而 ObjectB
是 ClassB
的一个实例,而 ClassB
是 ClassA
的一个子类型 — 如果我们在代码中的某个地方使用ObjectB
而不是 ObjectA
,应用程序的功能一定不会被破坏。
图 1:继承 ClassA 的 ClassB 对象仍然可以适应旧结构
如果我们决定改变 ClassB
的行为,它仍然保持其方法的相同签名,但以一种在逻辑上保持整体的方式大幅度改变父类的行为,尽管 ObjectB
仍然符合接口,但它违反了 Liskov 替换原则(图 2)。
图 2: ClassB 对象仍然可以适应旧结构,但它不可避免地破坏逻辑
怎么算违反利斯科夫替代原则
下面你能找到一个简单的例子,它违反了Liskov替代原则,与 DBUserRepository
的 Update
方法有关:
UserRepository
interface UserRepository {
update(user: User)
}
class DBUserRepository {
constructor(private manager: EntityManager) {}
public async update(user: User) {
this.manager.delete(user);
}
}
这是一个接口, UserRepository
. 在这个接口之后,我们有一个类, DBUserRepository
. 尽管这个类实现了初始接口 — 它并没有做接口声明它应该做的事情 — 它删除了记录而不是更新。
它破坏了接口的功能,而不是遵循预期。这是LSP的第一点:类必须不违反接口中方法的预期功能。
另一个例子
interface UserRepository {
create(user: User): User
}
class MemoryUserRepository {
constructor(private users: Record<string, User>) {}
public create(user: User): User {
if (!this.users) {
this.users = {};
}
user.id = crypto.randomUUID()
this.users[user.id] = user;
return user;
}
}
我们采用了 MemoryUserRepository
来支持接口,尽管这种意图是不自然的。因此,我们可以在应用程序中切换数据源,其中一个源不是永久存储。
Repository 模式的目的是表示底层永久数据存储的接口,比如数据库。它不应该扮演缓存系统的角色,就像我们在这里将 Users
存储在内存中一样。
在这种情况下, MemoryUserRepository
表示缓存机制的实现,而不是永久存储。
有时候不自然的实现会在代码本身产生影响,而不仅仅是语义上,这些情况在实现上更明显,也最难解决,因为它们需要进行主要的重构。
为了说明这个情况,我们可以检查一下关于几何形状的著名例子,它与几何学中的事实相矛盾。
几何问题
interface ConvexQuadrilateral {
getArea(): number;
}
interface Rectangle extends ConvexQuadrilateral {
setA(a: number);
setB(b: number);
}
class Oblong implements Rectangle {
constructor(
private a: number,
private b: number
) {}
public setA(a: number) {
this.a = a;
}
public setB(b: number) {
this.b = b;
}
public getArea(): number {
return this.a * this.b;
}
}
class Square extends Oblong {
public setB(b: number) {
//
// should it be this.a = b ?
// or should it be empty?
//
// or to throw exception?
}
public getArea(): number {
//
// is this rewriting of parent method
// actual violation of the logic?
//
return this.a * this.a;
}
}
在上面的例子中,我们可以看到代码中几何形状的实现。在几何形状中,我们可以比较凸四边形、矩形、长方形和正方形的子类型。
如果我们把它移动到代码中,以实现面积计算的逻辑,我们可能会得到类似于上面看到的代码。在顶部,我们有一个接口 ConvexQuadrilateral
。
这个接口只定义了一个方法,GetArea
。作为子类型, ConvexQuadrilateral
,我们可以定义一个接口 Rectangle
。这个子类型有两个方面涉及到它的领域,所以我们必须提供 SetA
和 SetB
。
接下来是实际的实现,第一个是 Oblong
,它可以有宽度或高度,在几何上,它是任何非正方形的矩形,实现这个类的逻辑很简单。
Rectangle
的第二个子类型是 Square
,在几何学中,正方形是矩形的子类型,但是如果我们在软件开发中遵循这个规则,我们只能在实现中制造问题。
正方形有四条等边,因此,这使得 SetB
过时了,为了遵循我们最初选择的子类型,我们意识到我们的代码有过时的方法。
这里是LSP的第三点:如果需要改变或完全删除继承的功能,类不应该盲目地遵循现实生活中的继承模式。
所以我们可以总结一下,如果我们打破它,会出什么问题:
-
它为实现提供了错误的捷径。
-
它可能会导致过时的代码。
-
它可能会破坏预期的代码执行。
-
它可能会破坏预期的用例。
-
它可能导致一个不可维护的接口结构。
-
…
如何遵循利斯科夫替代原则
我们应该在程序设计语言中提供子类型,但前提是必须尊重父类型的用途。
针对 UserRepository
接口不同实现的修复:
UserRepository
interface UserRepositor {
create(user: User): User;
update(user: User);
}
class MySQLUserRepository extends UserRepository {
//
// concrete logic
}
class CassandraUserRepository extends UserRepositor {
//
// concrete logic
}
UserCache
interface UserCache {
store(user: User);
load(): User;
}
class MemoryUserCache extends UserCache {
//
// concrete logic
}
在这个例子中,我们把接口分成了两个,目的很明确,并且给不同的方法签名,现在我们有 UserRepository
接口和 UserCache
接口。
UserRepository
的目的现在明确是将用户数据永久存储在某个存储器中。为此,我们准备了具体的实现,如 MySQLUserRepository
和 CassandraUserRepository
。
另一方面,我们有 UserCache
接口,我们清楚地知道我们需要它来暂时将用户数据保存在某个缓存中,作为具体的实现,我们可以使用 MemoryUserCache
。
还有几何的例子,情况有点复杂:
重构几何问题
interface ConvexQuadrilateral {
getArea(): number;
}
interface EquilateralQuadrilateral extends ConvexQuadrilateral {
setA(a: number);
}
interface NonEquilateralQuadrilateral extends ConvexQuadrilateral {
setA(a: number);
setB(b: number);
}
interface NonEquiangularQuadrilateral extends ConvexQuadrilateral {
setAngle(angle: number);
}
class Oblong implements NonEquilateralQuadrilateral {
constructor(
private a: number,
private b: number
) {}
//
// concrete implementation
}
class Square implements EquilateralQuadrilateral {
constructor(
private a: number
) {}
//
// concrete implementation
}
class Parallelogram implements NonEquilateralQuadrilateral, NonEquiangularQuadrilateral {
constructor(
private a: number,
private b: number,
private angle: number
) {}
//
// concrete implementation
}
class Rhombus implements EquilateralQuadrilateral, NonEquiangularQuadrilateral {
constructor(
private a: number,
private b: number,
private angle: number
) {}
//
// concrete implementation
}
为了支持代码中几何形状的子类型化,我们应该考虑它们的所有特性,以避免破坏或过时的方法。
在本例中,我们引入了三个新的接口: EquilateralQuadrilateral
(一个四边相等的四边形)、 NonEquilateralQuadrilateral
(一个两对边相等的四边形)和 NonEquiangularQuadrilateral
(一个两对角相等的四边形)。
每个接口都提供了额外的方法,以提供面积计算所需的数据。
现在,我们可以定义一个只有SetA
方法的Square
,同时具有 SetA
和SetB
方法的Oblong
,以及所有这些方法加上 SetAngle
的 Parallelogram
。因此,我们在这里没有使用子类型,而是更像特性。
通过这两个修复后的例子,我们重新组织了代码,使其始终能够满足最终用户的期望。它还删除了过时的方法,并且不会破坏任何一个。代码现在是稳定的。
结论
利斯科夫替换原则教给我们应用子类型的正确方法。我们应该避免强制多态性,即使它模仿了现实世界的情况。LSP代表单词SOLID中的字母L。
虽然它通常与继承和类相关,但 Go 不支持这些,我们仍然可以应用这个原则来实现多态性和接口。
欢迎关注公众号:文本魔术,了解更多