Go语言SOLID实践系列三之里氏替换原则
@ 归零 | 星期一,一月 10 日,2022 年 | 4 分钟阅读 | 更新于 星期一,一月 10 日,2022 年

引用wikipedia上对里氏替换原则的解释:

里氏替换原则(Liskov Substitution principle)是对子类型的特别定义, 指“派生类(子类)对象可以在程序中代替其基类(超类)对象”。

不遵守里氏替换原则

我们第一次听说这个原理是在1988年,由芭芭拉·里斯克(Barbara Liskov)创作。后来,Bob大叔在他的论文中对这个话题发表了自己的看法,后来又将其作为SOLID原则之一。让我们看看它说了什么:

Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.

说实话这是一个什么样的定义?在写这篇文章的时候,即使从根本上理解了LSP,我仍然无法抓住这个定义。让我们再试一次:

If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.

翻译:

如果 S 是 T 的子类型,则程序中 T 类型的对象可以替换为 S 类型的对象,而不会更改该程序的任何所需属性。

也就是说,如果 ObjectA 是 ClassA 的实例,而 ObjectB 是 ClassB 的实例,而 ClassB 是 ClassA 的子类型— 如果我们在代码中的某个位置使用 ObjectB 而不是 ObjectA,则应用程序的功能不得中断。

我们在这里讨论类和继承,这是我们在Go中不认识的两种范式。尽管如此,我们仍然可以通过使用接口和多态性来应用这一原则。

type User struct {
	ID uuid.UUID
	//
	// some fields
	//
}

type UserRepository interface {
	Update(ctx context.Context, user User) error
}

type DBUserRepository struct {
	db *gorm.DB
}

func (r *DBUserRepository) Update(ctx context.Context, user User) error {
	return r.db.WithContext(ctx).Delete(user).Error
}

以上代码示例,我几乎找不到比这个更糟糕,更愚蠢的了。例如,它不是像 Update 方法所说的那样更新数据库中的用户,而是将其删除。

但是,接口 UserRepository 之后,我们有一个结构体 DBUserRepository。尽管此结构实现了初始接口,但它没有执行接口声明它应该执行的操作。

它破坏了接口的功能,而不是遵循。以下是 LSP 在 Go 中的观点:结构体不得违反接口的用途。如下示例:

type UserRepository interface {
	Create(ctx context.Context, user User) (*User, error)
	Update(ctx context.Context, user User) error
}

type DBUserRepository struct {
	db *gorm.DB
}

func (r *DBUserRepository) Create(ctx context.Context, user User) (*User, error) {
	err := r.db.WithContext(ctx).Create(&user).Error
	return &user, err
}

func (r *DBUserRepository) Update(ctx context.Context, user User) error {
	return r.db.WithContext(ctx).Save(&user).Error
}

type MemoryUserRepository struct {
	users map[uuid.UUID]User
}

func (r *MemoryUserRepository) Create(_ context.Context, user User) (*User, error) {
	if r.users == nil {
		r.users = map[uuid.UUID]User{}
	}
	user.ID = uuid.New()
	r.users[user.ID] = user
	
	return &user, nil
}

func (r *MemoryUserRepository) Update(_ context.Context, user User) error {
	if r.users == nil {
		r.users = map[uuid.UUID]User{}
	}
	r.users[user.ID] = user

	return nil
}

在这里,我们有新的 UserRepository 接口及其两个实现:DBUserRepository 和 MemoryUserRepository。正如我们所看到的,MemoryUserRepository确实需要context参数,但它仍然存在以尊重接口。

问题从这里开始。我们调整了 MemoryUserRepository 来支持该接口,尽管这种意图是不自然的。因此,我们可以在应用程序中切换数据源,其中一个源不是永久存储。

存储库模式的目的是表示基础永久数据存储(如数据库)的接口。它不应该扮演缓存系统的角色,就像我们在这里将用户存储在内存中一样。 有时,不自然的实现会在编码本身中产生后果,而不仅仅是在语义上。这些情况更明显,难以实现,因为它们需要重大重构。

为了说明这种情况,我们可以检查有关几何形状的着名示例。这个例子的有趣之处在于,它与几何学中的事实相矛盾。

type ConvexQuadrilateral interface {
	GetArea() int
}

type Rectangle interface {
	ConvexQuadrilateral
	SetA(a int)
	SetB(b int)
}

type Oblong struct {
	Rectangle
	a int
	b int
}

func (o *Oblong) SetA(a int) {
	o.a = a
}

func (o *Oblong) SetB(b int) {
	o.b = b
}

func (o Oblong) GetArea() int {
	return o.a * o.b
}

type Square struct {
	Rectangle
	a int
}

func (o *Square) SetA(a int) {
	o.a = a
}

func (o Square) GetArea() int {
	return o.a * o.a
}

func (o *Square) SetB(b int) {
	//
	// should it be o.a = b ?
	// or should it be empty?
	//
}

在上面的示例中,我们可以看到Go中几何形状的实现。在几何学中,我们可以将凸四边形,矩形,长方形和正方形与子类型进行比较。

如果我们将其移动到Go代码中以实现面积计算的逻辑,我们最终可能会得到类似于我们上面看到的东西。在顶部,我们有一个接口凸四边形。 此接口仅定义一个方法,即 GetArea。作为凸四边形的子类型,我们可以定义一个接口矩形。此子类型有两个涉及其区域的边,因此我们必须提供 SetA 和 SetB。

接下来是实际实现。第一个是长方形,它可以有更宽或更宽的高度。在几何中,它是任何非正方形的矩形。实现此结构的逻辑很容易。

矩形的第二个子类型是正方形。在几何中,正方形是矩形的子类型,但是如果我们在软件开发中遵循这一点,我们只能在实现中制造问题。

正方形有四条相等的边。因此,这使得SetB过时了。为了尊重我们最初选择的子类型,我们意识到我们的代码具有过时的方法。同样的问题是,如果我们选择一条稍微不同的路径:

type ConvexQuadrilateral interface {
	GetArea() int
}

type EquilateralRectangle interface {
	ConvexQuadrilateral
	SetA(a int)
}

type Oblong struct {
	EquilateralRectangle
	a int
	b int
}

func (o *Oblong) SetA(a int) {
	o.a = a
}

func (o *Oblong) SetB(b int) {
	// where is this method defined?
	o.b = b
}

func (o Oblong) GetArea() int {
	return o.a * o.b
}

type Square struct {
	EquilateralRectangle
	a int
}

func (o *Square) SetA(a int) {
	o.a = a
}

func (o Square) GetArea() int {
	return o.a * o.a
}

在上面的示例中,我们引入了 EquilateralRectangle 接口,而不是 Rectangle。在几何中,这应该是一个具有所有四个相等边的矩形。

在这种情况下,当我们的接口仅定义 SetA 方法时,我们在实现中避免了过时的代码。尽管如此,这还是打破了LSP,因为我们为Oblong引入了一种额外的方法SetB,没有这种方法,我们就无法计算面积,即使我们的接口说我们可以。

因此,我们已经开始在Go中捕捉里氏替换原则的想法。因此,我们可以总结一下,如果我们打破它,可能会出错:

  • 它为实现提供了一个错误的快捷方式。
  • 它可能导致代码过时。
  • 它可能会损坏预期的代码执行。
  • 它可以打破所需的用例。
  • 它可能导致无法维护的接口结构。

如何遵守里氏替换原则

我们只能通过尊重接口的用途和方法,在 Go 接口中提供子类型。

我将避免为我们的第一个示例添加正确的实现,因为它非常清楚 - Update方法应该更新User而不是删除它。

因此,让我们首先着手解决 UserRepository 接口的不同实现的问题:

type UserRepository interface {
	Create(ctx context.Context, user User) (*User, error)
	Update(ctx context.Context, user User) error
}

type MySQLUserRepository struct {
	db *gorm.DB
}

type CassandraUserRepository struct {
	session *gocql.Session
}

type UserCache interface {
	Create(user User)
	Update(user User)
}

type MemoryUserCache struct {
	users map[uuid.UUID]User
}

在此示例中,我们将接口一分为二,具有明确的用途和不同方法的签名。现在,我们有了 UserRepository 接口和 UserCache 接口。

用户存储库的目的现在肯定是将用户数据永久存储到某个存储中。为此,我们准备了具体的实现,如MySQLUserRepository和CassandraUserRepository。

另一方面,我们有UserCache接口,清楚地知道我们需要它来暂时将用户数据保存在某些缓存中。作为具体实现,我们可以使用MemoryUserCache。 现在我们可以切换到几何示例,其中的情况有点复杂:

type ConvexQuadrilateral interface {
	GetArea() int
}

type EquilateralQuadrilateral interface {
	ConvexQuadrilateral
	SetA(a int)
}

type NonEquilateralQuadrilateral interface {
	ConvexQuadrilateral
	SetA(a int)
	SetB(b int)
}

type NonEquiangularQuadrilateral interface {
	ConvexQuadrilateral
	SetAngle(angle float64)
}

type Oblong struct {
	NonEquilateralQuadrilateral
	a int
	b int
}

type Square struct {
	EquilateralQuadrilateral
	a int
}

type Parallelogram struct {
	NonEquilateralQuadrilateral
	NonEquiangularQuadrilateral
	a     int
	b     int
	angle float64
}

type Rhombus struct {
	EquilateralQuadrilateral
	NonEquiangularQuadrilateral
	a     int
	angle float64
}

为了支持Go中几何形状的子类型,我们应该考虑它们的所有特征,以避免损坏或过时的方法。

在本例中,我们引入了三个新接口:EquilateralQuadrilateral(具有所有四个相等边的四边形)、NonEquilateralQuadrilateral(具有两对相等边的四边形)和NonEquiangularQuadrilateral(具有两对相等角的四边形)。

这些接口中的每一个都提供了提供面积计算所需数据所需的其他方法。

现在,我们可以定义一个正方形,仅使用 SetA 方法,长方形同时包含 SetA 和 SetB,并行四边形包含所有这些方法和 SetAngle。因此,我们在这里没有使用子类型,而更像是功能。 通过这两个固定的示例,我们重构了代码,以便它始终可以满足最终用户的期望。它还会删除过时的方法,并且不会破坏其中任何方法。代码现在稳定。

总结

里氏替换原理告诉我们什么是正确的替代方法。我们永远不应该做强制多态性,即使它反映了现实世界的情况。

LSP 代表单词 SOLID 中的字母 L。尽管它绑定到 Go 中不支持的继承和类,但我们仍然可以将此原则用于多态性和接口。

参考:

1、https://levelup.gitconnected.com/practical-solid-in-golang-liskov-substitution-principle-e0d2eb9dd39

2、https://zh.wikipedia.org/wiki/%E9%87%8C%E6%B0%8F%E6%9B%BF%E6%8D%A2%E5%8E%9F%E5%88%99

© 2014 - 2022 Lionel's Blog

Powered by Hugo with theme Dream.