TypeScript 中实用的 SOLID 原则(三):Liskov 替换原则

本文解释了Liskov替换原则,强调了如何避免违背原则,确保代码的稳定性和可维护性。通过实例分析和重构,展示了如何正确使用子类型和接口设计,尤其是在Go语言中,即使不依赖于类继承也能遵循这一原则。
摘要由CSDN通过智能技术生成

Image

继续我们的 SOLID 之旅,本篇来介绍定义最复杂的 SOLID 原则——Liskov 替换原则。

我并不是一个阅读爱好者。通常,当我阅读时,我发现自己在过去的几分钟里失去了文本的主题。我经常在整个章节结束前都不知道它到底讲了什么。

当我试图专注于内容时,这可能会令人沮丧,但我不断意识到我需要回头。这就是当我转向各种类型的媒体来了解一个话题时发生的情况。

我第一次遇到这个阅读问题是在SOLID原则,特别是Liskov替换原则,它的定义对我来说太复杂了,特别是它的正式格式。

正如你所猜测的,LSP代表SOLID这个单词中的字母“L”。这并不难理解,尽管一个更少的数学定义将受到赞赏。

利斯科夫替代原理的理解

Image

Liskov 替换原则指出,如果 S 是 T 的一个子类型,那么程序中 T 类型的对象可以用 S 类型的对象替换,而不会改变该程序的任何期望属性。

我们第一次听说这个原则是在1988年,由Barbara Liskov提出,后来,Uncle Bob在他的论文中提出了他对这个主题的看法,后来又把它作为SOLID原则之一。

换句话说(图 1),如果ObjectAClassA的一个实例,而  ObjectB  是  ClassB  的一个实例,而  ClassB  是  ClassA  的一个子类型 — 如果我们在代码中的某个地方使用ObjectB而不是  ObjectA ,应用程序的功能一定不会被破坏。

Image

图 1:继承 ClassA 的 ClassB 对象仍然可以适应旧结构


如果我们决定改变  ClassB  的行为,它仍然保持其方法的相同签名,但以一种在逻辑上保持整体的方式大幅度改变父类的行为,尽管  ObjectB  仍然符合接口,但它违反了 Liskov 替换原则(图 2)。

Image

图 2: ClassB 对象仍然可以适应旧结构,但它不可避免地破坏逻辑

 

 

 

怎么算违反利斯科夫替代原则
 

Image

下面你能找到一个简单的例子,它违反了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的第三点:如果需要改变或完全删除继承的功能,类不应该盲目地遵循现实生活中的继承模式。

所以我们可以总结一下,如果我们打破它,会出什么问题:

  1. 它为实现提供了错误的捷径。

  2. 它可能会导致过时的代码。

  3. 它可能会破坏预期的代码执行。

  4. 它可能会破坏预期的用例。

  5. 它可能导致一个不可维护的接口结构。


如何遵循利斯科夫替代原则

Image

我们应该在程序设计语言中提供子类型,但前提是必须尊重父类型的用途。

针对  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 不支持这些,我们仍然可以应用这个原则来实现多态性和接口。

 欢迎关注公众号:文本魔术,了解更多

  • 46
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值