设计原则
单一职责原则(SRP)
一个类只负责完成一个职责或者是功能。不要设计大而全的类,要设计粒度小、功能单一的类。
优势
高内聚、低耦合,提高代码的复用性、可读性、可维护性
单一职责的界定
不同的场景下,对单一职责的界定都会有不同的标准,可以通过一些侧面的判断指标来判断是否符合单一职责原则
- 类代码中的行数、函数、方法或者属性过多
- 类依赖的其他类过多
- 比较难给类起一个合适的名字
- 类中的大量方法都是集中操作类中的某几个属性
例子
下面以一个名为Customer
的类型为例来说明不使用单一职责原则的情况,以及使用单一职责原则的情况。
不使用单一职责原则的情况下,Customer
类型可能既承担了记录客户信息的职责,又承担了发送电子邮件的职责,导致一个类型负责了多个不相关的职责。
package main
import (
"fmt"
"net/smtp"
)
// Customer 类型承担了记录客户信息和发送电子邮件的职责
type Customer struct {
Name string
Email string
}
func (c *Customer) Save() {
// 保存客户信息到数据库
fmt.Printf("Saved customer %s\n", c.Name)
}
func (c *Customer) SendEmail(subject, body string) error {
// 发送电子邮件给客户
// 使用 c.Email 和 smtp 发送邮件的代码
fmt.Printf("Sent email to %s\n", c.Email)
return nil
}
func main() {
customer := &Customer{Name: "John Doe", Email: "john@example.com"}
customer.Save()
customer.SendEmail("Welcome", "Welcome to our website!")
}
在上述代码中,存在一个拓展性受限的问题,如果我们还需要其他的功能,例如将客户信息同步到外部系统中,我们需要修改 Save 方法,或者在同一个类型中添加一个新的方法,这会导致该类型的职责越来越多。
- 修改的影响范围:当一个类型承担多个职责时,其中的一个职责的修改可能会影响到其他职责的实现。如果你需要修改一个职责,可能需要考虑其对其他职责的可能影响,这增加了代码修改的复杂性和风险。
- 代码整体复杂性:将多个职责集中在一个类型中,会导致类型的代码变得复杂、冗长,不同职责之间的代码交织在一起。这使得理解和修改特定职责的代码变得困难,因为你需要同时考虑其他职责的影响。
- 依赖关系的扩散:当一个类型承担多个职责时,其他使用该类型的代码也会因此而增加复杂性。如果其他代码也需要使用类型的某个职责,它们可能需要同时了解并处理其他不相关的职责。
使用单一职责原则对其进行重构,将保存客户信息的职责和发送电子邮件的职责拆分成两个独立的类型。
package main
import "fmt"
// Customer 类型只负责记录客户信息
type Customer struct {
Name string
Email string
}
func (c *Customer) Save() {
// 保存客户信息到数据库
fmt.Printf("Saved customer %s\n", c.Name)
}
// EmailSender 类型只负责发送电子邮件
type EmailSender struct{}
func (es *EmailSender) SendEmail(customerEmail, subject, body string) error {
// 发送电子邮件给客户
// 使用 customerEmail 和 smtp 发送邮件的代码
fmt.Printf("Sent email to %s\n", customerEmail)
return nil
}
func main() {
customer := &Customer{Name: "John Doe", Email: "john@example.com"}
customer.Save()
emailSender := &EmailSender{}
emailSender.SendEmail(customer.Email, "Welcome", "Welcome to our website!")
}
在重构后的代码中,我们使用单一职责原则将记录客户信息的职责和发送电子邮件的职责分别分配给了Customer
类型和EmailSender
类型。Customer
类型只关注客户信息的保存,EmailSender
类型只负责发送电子邮件。
- 可读性提高:现在每个类型只关注自己职责相关的代码,代码组织更清晰,容易理解每个类型的功能和用法。
- 耦合性降低:现在
Customer
类型和EmailSender
类型相互独立,修改或扩展其中一个职责不会影响到另一个职责的代码。这样,即使更改了发送电子邮件的逻辑,Customer
类型的代码也不需要进行任何修改。 - 可维护性提高:代码的拆分使得每个类型都有自己明确的职责,当你需要修改或扩展保存客户信息或发送电子邮件的逻辑时,只需关注特定的类型,降低了查找和修改的复杂性。这样,代码维护的成本将会降低。
开发-封闭原则(OCP)
在添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。
- 第一点,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
- 第二点,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
如何做
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
在Go语言中,可以通过接口和多态性的特性来实现开放封闭原则。
例子
下面以一个消息通知器为例,不使用开放-封闭原则
type MessageNotificationService struct{}
// 发送邮件
func (mns *MessageNotificationService) SendEmail(message string) {
// 发送邮件的逻辑
fmt.Println("Sending email:", message)
}
// 发送短信
func (mns *MessageNotificationService) SendSMS(message string) {
// 发送短信的逻辑
fmt.Println("Sending SMS:", message)
}
在上述示例中,我们将发送邮件和发送短信的逻辑都集中在了MessageNotificationService
类型中,而没有使用接口抽象出发送消息的行为。这导致了以下问题:
- 存在耦合性:
MessageNotificationService
类型与具体的发送邮件和发送短信的逻辑紧密耦合在一起。如果需要修改其中一个发送方式,就必须修改MessageNotificationService
的代码,这样会增加对该类型的修改和测试的复杂性。 - 可扩展性受限:当需要增加新的发送方式,例如发送微信消息,就需要修改
MessageNotificationService
类型的代码,并添加新的方法,这破坏了开放封闭原则中的对修改封闭的原则。 - 可读性下降:聚合多个不同发送逻辑的方法在同一个类型中,逻辑上较为混乱,降低了代码的可读性。同时,该类型的方法名也可能变得冗长和难以理解。
使用开放-封闭原则进行重构
// 定义一个接口,用于描述发送消息的行为
type MessageSender interface {
Send(message string)
}
// 定义一个邮件发送器
type EmailSender struct{}
// 实现发送邮件的行为
func (es *EmailSender) Send(message string) {
// 发送邮件的逻辑
fmt.Println("Sending email:", message)
}
// 定义一个短信发送器
type SmsSender struct{}
// 实现发送短信的行为
func (ss *SmsSender) Send(message string) {
// 发送短信的逻辑
fmt.Println("Sending SMS:", message)
}
// 定义一个消息通知服务,通过接口进行消息发送
type MessageNotificationService struct {
Sender MessageSender
}
// 发送消息的方法
func (mns *MessageNotificationService) SendNotification(message string) {
// 调用底层发送器的发送方法
mns.Sender.Send(message)
}
通过以上的设计,当有新的消息发送方式需要添加时,我们只需要实现MessageSender
接口即可,而不需要修改MessageNotificationService
的代码。这样就实现了对扩展开放,对修改封闭的原则。
里氏替换原则(LSP)
在介绍里氏替换原则前,需要先介绍一下面向对象编程的利弊
面向对象好处
- 代码复用:子类可以继承父类的属性和方法,无需重新实现。这样,在基类中定义的通用行为可以在多个子类中共享和复用。
- 扩展性:通过继承,可以在不修改现有代码的情况下,对类进行扩展。子类可以添加新的属性和方法,或者重写从父类继承的方法,以满足新的需求。这种扩展是在现有代码的基础上进行的,减少了对原有代码的影响。
- 多态性:继承为多态提供了基础。通过基类的引用,可以在运行时调用子类特定的方法,实现不同对象的不同行为。多态性可以增加代码的灵活性和可扩展性,并提高代码的可读性和维护性。
面向对象弊端
- 强耦合性:继承会导致父类和子类之间紧密耦合,子类依赖于父类的实现细节。如果父类的属性或方法发生了变化,可能会对子类造成影响,需要相应的修改。这种紧密耦合关系会增加代码的复杂性和脆弱性。
- 继承是侵入性的。只要继承,就必须拥有父类的属性和方法。
- 降低代码的灵活性。子类会多一些父类的约束。
为了让继承的利大于弊,引入了里氏替换原则
定义
子类对象能够替换程序(program)中父类对象出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。通俗点讲,就是只要父类能出现的地方,子类就可以出现,而且替换为子类也不会产生任何错误或异常。
里式替换原则不仅仅是说子类可以替换父类,它有更深层的含义。
子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。所以我们可以通过几个点判断是否违反里氏替换原则:
- 子类违背父类声明要实现的功能:如排序函数父类按照金额排序,子类按照时间排序
- 子类违背父类对输入、输出、异常的约定,改变或削弱父类的前置条件、后置条件和约束条件。
- 子类违背父类注释中所罗列的任何特殊说明
实现
在 go 语言中,里氏替换原则的体现为接口与多态特性,下面以一个计算图形面积的案例来说明里氏替换原则的好处。
首先不使用里氏替换原则的情况下,计算不同图形的面积的代码如下:
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
type Square struct {
SideLength float64
}
func CalculateRectangleArea(rectangle Rectangle) float64 {
return rectangle.Width * rectangle.Height
}
func CalculateSquareArea(square Square) float64 {
return square.SideLength * square.SideLength
}
func CalculateTotalArea(rectangles []Rectangle, squares []Square) float64 {
totalArea := 0.0
for _, rectangle := range rectangles {
totalArea += CalculateRectangleArea(rectangle)
}
for _, square := range squares {
totalArea += CalculateSquareArea(square)
}
return totalArea
}
func main() {
rectangle := Rectangle{Width: 4, Height: 5}
square := Square{SideLength: 4}
rectangles := []Rectangle{rectangle}
squares := []Square{square}
totalArea := CalculateTotalArea(rectangles, squares)
fmt.Println("Total Area:", totalArea)
}
代码中没有明确的父类型和子类型的关系。而是直接调用各自的计算面积函数进行计算。这样的设计在仅有两种图形类型的情况下可能还可以接受,但随着更多的图形类型引入,代码会变得更加冗长、不灵活且难以扩展和维护
接下来使用里氏替换原则进行重构
package main
import "fmt"
// 父类型
type Shape interface {
Area() float64
}
// 子类型1:矩形
type Rectangle struct {
Width float64
Height float64
}
// 实现矩形的Area方法
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// 子类型2:正方形
type Square struct {
SideLength float64
}
// 实现正方形的Area方法
func (s Square) Area() float64 {
return s.SideLength * s.SideLength
}
// 计算图形的总面积
func CalculateTotalArea(shapes []Shape) float64 {
totalArea := 0.0
for _, shape := range shapes {
totalArea += shape.Area()
}
return totalArea
}
func main() {
rectangle := Rectangle{Width: 4, Height: 5}
square := Square{SideLength: 4}
shapes := []Shape{rectangle, square}
totalArea := CalculateTotalArea(shapes)
fmt.Println("Total Area:", totalArea)
}
在CalculateTotalArea
函数中,我们将Rectangle
和Square
作为Shape
类型的切片进行参数传递。这意味着我们可以无缝地将子类型替换为父类型,并且在不修改CalculateTotalArea
函数的情况下,代码可以正常工作。
接口隔离原则(ISP)
介绍
接口隔离原则强调客户端不应该强迫依赖它不需要的接口。将大型的接口拆分为更小、更具体的接口,以便实现类只需依赖于其需要使用的接口方法,而不需要依赖于不需要的方法。
以下是接口隔离原则的几个要点:
- 接口应该尽可能小而专注于特定的功能领域。不要设计臃肿庞大的接口,而是将其拆分成多个小接口。这样可以避免实现类不必要地实现一些它们并不需要的方法。
- 客户端代码应该只依赖于它所需要的接口。类或模块不应该依赖于它们不需要的接口方法。这样可以减少代码的耦合性,提高代码的可维护性和灵活性。
- 接口设计应该基于具体的使用场景和需求。针对不同的客户端,可以设计出不同的接口,以满足它们的特定需求,而不是采用“一刀切”的策略。
案例
下面以动物的四个行为吃饭、睡觉、游泳、飞行为案例,首先,我们来看下使用接口隔离原则之前的代码示例:
package main
import "fmt"
type Animal interface {
Eat()
Sleep()
Swim()
Fly()
}
type Bird struct {
name string
}
func (b Bird) Eat() {
fmt.Println(b.name, "is eating")
}
func (b Bird) Sleep() {
fmt.Println(b.name, "is sleeping")
}
func (b Bird) Swim() {
fmt.Println(b.name, "is swimming")
}
func (b Bird) Fly() {
fmt.Println(b.name, "is flying")
}
func main() {
bird := Bird{
name: "Sparrow",
}
bird.Eat()
bird.Sleep()
bird.Swim()
bird.Fly()
}
在上述代码中,我们定义了一个Animal
接口,该接口包含了Eat
、Sleep
、Swim
和Fly
的方法。然后,我们定义了一个Bird
结构体,并实现了Eat
、Sleep
、Swim
和Fly
方法,使其满足Animal
接口的要求。
然而,在实际应用场景中,我们可能会遇到一些问题。比如,有些动物(比如海豚)并不具备飞行的能力。这时,使用上述代码会违反接口隔离原则,因为不同类型的动物被迫实现了不符合自身特性的方法。
为了遵守接口隔离原则,我们可以对代码进行改进,将接口进行拆分,并根据具体情况定义更小、更具体的接口。下面是使用接口隔离原则后的代码示例:
package main
import "fmt"
type Eater interface {
Eat()
}
type Sleeper interface {
Sleep()
}
type Swimmer interface {
Swim()
}
type Flier interface {
Fly()
}
type Bird struct {
name string
}
func (b Bird) Eat() {
fmt.Println(b.name, "is eating")
}
func (b Bird) Sleep() {
fmt.Println(b.name, "is sleeping")
}
func (b Bird) Fly() {
fmt.Println(b.name, "is flying")
}
func main() {
bird := Bird{
name: "Sparrow",
}
bird.Eat()
bird.Sleep()
bird.Fly()
}
在上述代码中,我们将Animal
接口拆分为Eater
、Sleeper
和Flier
三个接口,每个接口只包含相关的方法。对于不具备飞行能力的鸟类,我们可以只实现Eater
和Sleeper
接口中的方法,而不需要实现不相关的Swim
和Fly
方法。
通过这种方式,我们遵守了接口隔离原则,每个接口都专注于特定的功能领域,并且实现类只需要依赖于它们需要使用的接口方法。这样做可以提高代码的灵活性和可维护性,使代码更加精确和易于理解。
依赖反转原则(DIP)
介绍
高级模块不应该依赖于低级模块,而是应该依赖于抽象接口。抽象接口不应该依赖于具体实现,而是具体实现应该依赖于抽象接口。说白了就是面向接口编程,在函数或方法传参时使用接口传参而不是用具体的实现类型。
实现
在之前讲的里氏替换原则中的计算图形面积的CalculateTotalArea
方法中的参数是 shape
类型(接口)而不是具体的实现类型 Rectangle
或Square
类型。
迪米特法则(LoD)
介绍
一个对象应该只与其近邻(即直接交互的对象)通信,而不是和它的远邻(即间接交互的对象)通信。这可以减少对象之间的耦合度,从而提高代码的可维护性和可扩展性。(只和朋友交流)
何为“朋友”?出现在属性或方法的输入输出的类型即为“朋友”类
案例
下面以一个老师让班长清点班级女生人数的案例来介绍迪米特法则。
在这个例子中可以看到,老师只和班长有关联,而与女生是没有关联的,所以老师和班长是“朋友”
package use
import "fmt"
type Girl struct {
Name string
ID int64
}
type GroupLeader struct {
Girls *[]Girl
}
func (leader *GroupLeader) CountGirls() int {
for i := 0; i < 10; i++ {
*leader.Girls = append(*leader.Girls, Girl{
Name: fmt.Sprintf("Girl-%d", i),
})
}
fmt.Println("the leader/president count girls in class")
return len(*leader.Girls)
}
type Teacher struct {
leader *GroupLeader
}
func (t *Teacher) Command() {
fmt.Println("the teacher give the order")
counter := t.leader.CountGirls()
fmt.Println(counter)
}
func Case() {
teacher := &Teacher{
leader: &GroupLeader{
Girls: &[]Girl{},
},
}
teacher.Command()
}