单一职责原则
单一职责原则(Single Responsibility Principle, SRP)是面向对象设计中的一个重要原则,它强调一个类应该只有一个引起其变化的原因。也就是说,一个类应该只负责一项职责或功能。这样可以提高代码的可读性、可维护性和可复用性。
正面例子
假设我们在开发一个博客系统,这个系统需要处理博客文章的管理和日志记录。
博客文章管理类
package articleimport ( "fmt")// ArticleManager 负责博客文章的管理type ArticleManager struct{}func (a *ArticleManager) CreateArticle(title string, content string) { fmt.Printf("Creating article: %s\n", title) // 这里省略创建文章的逻辑}func (a *ArticleManager) DeleteArticle(articleID int) { fmt.Printf("Deleting article ID: %d\n", articleID) // 这里省略删除文章的逻辑}日志记录类
package logimport ( "fmt" "time")// Logger 负责记录日志type Logger struct{}func (l *Logger) Log(message string) { fmt.Printf("%s: %s\n", time.Now().Format(time.RFC3339), message) // 这里省略日志记录的逻辑}使用示例
package mainfunc main() { articleManager := ArticleManager{} logger := Logger{} articleManager.CreateArticle("Go语言介绍", "这是一篇关于Go语言的文章。") logger.Log("Created a new article: Go语言介绍") articleManager.DeleteArticle(1) logger.Log("Deleted article ID: 1")}在这个例子中,ArticleManager 类只负责博客文章的管理,而 Logger 类只负责记录日志。这两个类都遵循了单一职责原则,因为它们各自只处理一种职责。
反面例子
假设将博客文章管理和日志记录的功能混合在同一个类中。
package mainimport ( "fmt" "time")// BlogService 负责博客文章的管理和日志记录type BlogService struct{}func (b *BlogService) CreateArticle(title string, content string) { fmt.Printf("Creating article: %s\n", title) // 这里省略创建文章的逻辑 b.Log("Created a new article: " + title)}func (b *BlogService) DeleteArticle(articleID int) { fmt.Printf("Deleting article ID: %d\n", articleID) // 这里省略删除文章的逻辑 b.Log("Deleted article ID: " + fmt.Sprint(articleID))}func (b *BlogService) Log(message string) { fmt.Printf("%s: %s\n", time.Now().Format(time.RFC3339), message) // 这里省略日志记录的逻辑}使用示例
package mainfunc main() { blogService := BlogService{} blogService.CreateArticle("Go语言介绍", "这是一篇关于Go语言的文章。") blogService.DeleteArticle(1)}在这个例子中,BlogService 类同时负责博客文章的管理和日志记录。这个类违反了单一职责原则,因为它有两个职责:管理博客文章和记录日志。任何一个职责的变化都会导致这个类的修改,从而增加了类的复杂度和维护难度。
开闭原则
开闭原则(Open-Closed Principle,OCP)是面向对象设计的五大原则之一。它要求软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。也就是说,应该通过增加新代码来扩展功能,而不是通过修改已有代码来实现。
下面是一个使用 Go 语言的示例,通过正反两个例子来说明开闭原则。假设我们正在开发一个订单处理系统,需要根据不同的订单类型计算订单的总价格。初始版本只支持普通订单和折扣订单。
反面例子
在这个反例中,如果需要增加新的订单类型,我们必须修改已有的代码。
package mainimport ( "fmt")// OrderType 用于表示不同的订单类型type OrderType intconst ( RegularOrder OrderType = iota DiscountOrder)// Order 表示一个订单type Order struct { Type OrderType Amount float64 DiscountRate float64}// CalculateTotal 计算订单的总价func CalculateTotal(order Order) float64 { switch order.Type { case RegularOrder: return order.Amount case DiscountOrder: return order.Amount * (1 - order.DiscountRate) default: return 0 }}func main() { orders := []Order{ {Type: RegularOrder, Amount: 100.0}, {Type: DiscountOrder, Amount: 200.0, DiscountRate: 0.1}, } for _, order := range orders { fmt.Printf("Order total: %.2f\n", CalculateTotal(order)) }}在这个反例中,如果我们需要增加新的订单类型(例如,带税订单),我们需要修改 Order 结构体和 CalculateTotal 函数。这违反了开闭原则。
正面例子
在这个正例中,通过增加新的代码来扩展功能,而不修改已有的代码。
package mainimport ( "fmt")// Order 接口代表一个订单type Order interface { CalculateTotal() float64}// RegularOrder 表示一个普通订单type RegularOrder struct { Amount float64}// CalculateTotal 计算普通订单的总价func (o RegularOrder) CalculateTotal() float64 { return o.Amount}// DiscountOrder 表示一个折扣订单type DiscountOrder struct { Amount float64 DiscountRate float64}// CalculateTotal 计算折扣订单的总价func (o DiscountOrder) CalculateTotal() float64 { return o.Amount * (1 - o.DiscountRate)}func main() { orders := []Order{ RegularOrder{Amount: 100.0}, DiscountOrder{Amount: 200.0, DiscountRate: 0.1}, } for _, order := range orders { fmt.Printf("Order total: %.2f\n", order.CalculateTotal()) }}在这个正例中定义了一个 Order 接口,并为每种订单类型(例如 RegularOrder 和 DiscountOrder)实现了该接口。这样,如果需要增加新的订单类型(例如带税订单),只需添加一个新的结构体并实现 Order 接口,而不需要修改现有的代码。
添加新的订单类型:假设我们要添加一个带税订单类型,只需增加以下代码:
// TaxOrder 表示一个带税订单type TaxOrder struct { Amount float64 TaxRate float64}// CalculateTotal 计算带税订单的总价func (o TaxOrder) CalculateTotal() float64 { return o.Amount * (1 + o.TaxRate)}func main() { orders := []Order{ RegularOrder{Amount: 100.0}, DiscountOrder{Amount: 200.0, DiscountRate: 0.1}, TaxOrder{Amount: 150.0, TaxRate: 0.2}, } for _, order := range orders { fmt.Printf("Order total: %.2f\n", order.CalculateTotal()) }}在这个符合开闭原则的例子中,我们通过添加新的 TaxOrder 结构体和实现 Order 接口,扩展了系统的功能,而无需修改已有的 RegularOrder 和 DiscountOrder 代码。这展示了开闭原则的一个重要优点:通过扩展来适应变化,而不是通过修改现有代码,从而提高了系统的可维护性和可扩展性。
依赖倒转原则
依赖倒转原则(Dependency Inversion Principle,DIP)是 SOLID 原则中的一部分,旨在减少模块之间的耦合,使得系统更加灵活和可维护。这个原则的核心观点在于:
- 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
- 抽象不应该依赖于具体细节,具体细节应该依赖于抽象
如下图所示:

以下是用 Go 语言举的正反例子来说明这一原则的好处。
正面例子
首先,需要定义一个日志接口:
package mainimport "fmt"/* 抽象层 */// Logger 是一个日志接口type Logger interface { Log(message string)}/* 实现层 */// ConsoleLogger 是一个控制台日志实现type ConsoleLogger struct{}func (c ConsoleLogger) Log(message string) { // 此处省略写入日志到控制台的具体实现 fmt.Println("ConsoleLogger: " + message)}// FileLogger 是一个文件日志实现type FileLogger struct{}func (f FileLogger) Log(message string) { // 此处省略写入日志到文件的具体实现 fmt.Println("FileLogger: " + message)}/* 业务逻辑层 */// Application 是一个高层模块,依赖于 Logger 接口type Application struct { // 依赖注入 logger Logger}// NewApplication 是 Application 的构造函数,接受一个 Logger 接口func NewApplication(logger Logger) *Application { return &Application{logger: logger}}func (app *Application) DoSomething() { app.logger.Log("Doing something")}func main() { logger := ConsoleLogger{} // 可以替换为 FileLogger{} app := NewApplication(logger) app.DoSomething()}在这个例子中,Application 依赖于 Logger 接口,而不是具体的 ConsoleLogger 或 FileLogger 实现。这样就很容易地更换日志实现而不需要修改 Application 类的代码。
反面例子
下面是一个反面例子,其中 Application 直接依赖于具体的 ConsoleLogger 实现:
package mainimport "fmt"// ConsoleLogger 是一个控制台日志实现type ConsoleLogger struct{}func (c ConsoleLogger) Log(message string) { fmt.Println("ConsoleLogger: " + message)}// Application 是一个高层模块,直接依赖于 ConsoleLoggertype Application struct { logger ConsoleLogger}func NewApplication() *Application { return &Application{logger: ConsoleLogger{}}}func (app *Application) DoSomething() { app.logger.Log("Doing something")}func main() { app := NewApplication() app.DoSomething()}在这个例子中,如果需要将日志记录改为 FileLogger,那么必须修改 Application 类中的代码,将 ConsoleLogger 替换为 FileLogger,这违反了依赖倒转原则,导致代码的耦合度高,修改和维护成本增加。
合成复用原则
合成复用原则建议优先使用组合(Composition)而不是继承(Inheritance)来实现代码复用。通过组合对象来实现功能,可以降低类之间的耦合度,提高系统的灵活性和可维护性。
主要思想:
- 继承:子类继承父类的属性和方法,但会导致子类与父类之间的强依赖。父类的变动可能会影响到所有子类。
- 组合:通过在类中包含其他类的实例,并通过这些实例实现功能。这样可以减少类之间的耦合,改变一个类不会直接影响其它类。
反面例子
假设有一个基本的 Animal 结构体,并且希望创建一个 Dog 结构体来继承 Animal 的一些行为。
package mainimport "fmt"// Animal 结构体type Animal struct { name string}func (a *Animal) MakeSound() { fmt.Println("某种动物叫声")}func (a *Animal) GetName() string { return a.name}// Dog 结构体继承自 Animaltype Dog struct { Animal}func (d *Dog) MakeSound() { fmt.Println("汪汪")}func main() { dog := Dog{Animal{name: "巴迪"}} dog.MakeSound() // 输出: 汪汪 fmt.Println(dog.GetName()) // 输出: 巴迪}使用继承的情况下:
- 多态行为:
- Dog 结构体嵌入了 nimal 结构体,并且重写了 MakeSound 方法。
- 当调用 dog.MakeSound() 时,实际调用的是 Dog 结构体中的 MakeSound 方法,输出 “汪汪”。
- 方法继承:
- Dog 结构体继承了 Animal 结构体中的 GetName 方法。
- 当调用 dog.GetName() 时,输出的是 Animal 结构体中的 name 字段的值 “巴迪”。
耦合问题:
-
父类接口变化的影响:
-
如果需要在 Animal 结构体中修改 MakeSound 方法的签名,例如增加一个参数。
gofunc (a *Animal) MakeSound(volume int) { fmt.Println("某种动物叫声,音量为", volume)} -
这样就会要求 Dog 结构体也必须同步修改,否则会导致编译错误。
gofunc (d *Dog) MakeSound(volume int) { fmt.Println("汪汪,音量为", volume)}
-
-
父类内部实现变化的影响:
-
如果在 Animal 结构体中添加了一个方法 Describe,并且在方法内部依赖于 MakeSound:
gofunc (a *Animal) Describe() { fmt.Print("这是一只", a.name, ",它的叫声是:") a.MakeSound()} -
如果 Dog 结构体没有意识到 Describe 方法的存在,这可能会导致意外的行为:
gofunc main() { dog := Dog{Animal{name: "巴迪"}} dog.Describe() // 输出: 这是一只 巴迪 ,它的叫声是:某种动物叫声} -
如果 Animal 结构体中的 Describe 方法实现发生了变化,例如使用新的逻辑,这可能会影响到 Dog 结构体的行为。
gofunc (a *Animal) Describe() { fmt.Print("动物名称:", a.name, ",叫声:") a.MakeSound()}
-
正面例子
改用组合的方式来降低耦合度。
package mainimport ( "fmt")type Animal struct { name string}func (a *Animal) MakeSound() { fmt.Println("某种动物叫声")}func (a *Animal) GetName() string { return a.name}func (a *Animal) Describe() { fmt.Print("动物名称:", a.name, ",叫声:") a.MakeSound()}type Dog struct { animal *Animal}func (d *Dog) MakeSound() { fmt.Println("汪汪")}func (d *Dog) GetName() string { return d.animal.GetName()}// Describe 如果需要,可以为 Dog 定义自己的 Describe 方法func (d *Dog) Describe() { fmt.Print("这是一只", d.animal.GetName(), ",它的叫声是:") d.MakeSound()}func main() { animal := &Animal{name: "动物"} animal.MakeSound() // 输出: 某种动物叫声 fmt.Println(animal.GetName()) // 输出: 动物 animal.Describe() // 输出: 动物名称:动物,叫声:某种动物叫声 dog := &Dog{animal: &Animal{name: "欧迪"}} dog.MakeSound() // 输出: 汪汪 fmt.Println(dog.GetName()) // 输出: 欧迪 dog.Describe() // 输出: 这是一只欧迪,它的叫声是:汪汪}通过这种方式,成功地解耦了 Dog 和 Animal,避免了父类接口和实现变化对子类的影响,遵循了合成复用原则。
迪米特法则
迪米特法则,也称为“最少知识原则”,建议一个对象应当对其他对象尽可能少的了解。目的是降低对象之间的耦合,提高系统的可维护性。
主要思想:
- 一个对象只应与直接相关的对象交互,不应深入了解其他对象的内部结构。
- 通过限制对象之间的交互减少依赖,改进系统的灵活性和可维护性。
反面例子
假设有一个 Person 结构体包含一个 Address 结构体,而 Address 结构体包含一个 City 结构体。
package mainimport "fmt"type City struct { Name string}type Address struct { City *City}type Person struct { Address *Address}func (p *Person) GetCityName() string { return p.Address.City.Name}func main() { city := &City{Name: "New York"} address := &Address{City: city} person := &Person{Address: address} fmt.Println(person.GetCityName()) // 输出: New York}在这个例子中,Person 直接访问了 Address 的 City 属性,违反了迪米特法则。Person 过多地了解了 Address 和 City 的内部结构。
正面例子
通过在 Address 中添加一个方法来遵循迪米特法则。
package mainimport "fmt"type City struct { Name string}type Address struct { City *City}func (a *Address) GetCityName() string { return a.City.Name}type Person struct { Address *Address}func (p *Person) GetCityName() string { return p.Address.GetCityName()}func main() { city := &City{Name: "New York"} address := &Address{City: city} person := &Person{Address: address} fmt.Println(person.GetCityName()) // 输出: New York}在这个例子中,通过在 Address 中添加了一个 GetCityName 方法,Person 通过调用 Address 的 GetCityName 方法来获取城市名称。这样 Person 只需要知道 Address 的接口,而不是 Address 和 City 的内部结构,从而遵循了迪米特法则。