面向对象设计原则

标签:设计模式首次发布:2025-02-10最近修改:2026-05-27

单一职责原则

单一职责原则(Single Responsibility Principle, SRP)是面向对象设计中的一个重要原则,它强调一个类应该只有一个引起其变化的原因。也就是说,一个类应该只负责一项职责或功能。这样可以提高代码的可读性、可维护性和可复用性。

正面例子

假设我们在开发一个博客系统,这个系统需要处理博客文章的管理和日志记录。

博客文章管理类

go
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)    // 这里省略删除文章的逻辑}

日志记录类

go
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)    // 这里省略日志记录的逻辑}

使用示例

go
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 类只负责记录日志。这两个类都遵循了单一职责原则,因为它们各自只处理一种职责。

反面例子

假设将博客文章管理和日志记录的功能混合在同一个类中。

go
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)    // 这里省略日志记录的逻辑}

使用示例

go
package mainfunc main() {    blogService := BlogService{}    blogService.CreateArticle("Go语言介绍", "这是一篇关于Go语言的文章。")    blogService.DeleteArticle(1)}

在这个例子中,BlogService 类同时负责博客文章的管理和日志记录。这个类违反了单一职责原则,因为它有两个职责:管理博客文章和记录日志。任何一个职责的变化都会导致这个类的修改,从而增加了类的复杂度和维护难度。

开闭原则

开闭原则(Open-Closed Principle,OCP)是面向对象设计的五大原则之一。它要求软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。也就是说,应该通过增加新代码来扩展功能,而不是通过修改已有代码来实现。

下面是一个使用 Go 语言的示例,通过正反两个例子来说明开闭原则。假设我们正在开发一个订单处理系统,需要根据不同的订单类型计算订单的总价格。初始版本只支持普通订单和折扣订单。

反面例子

在这个反例中,如果需要增加新的订单类型,我们必须修改已有的代码。

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 函数。这违反了开闭原则。

正面例子

在这个正例中,通过增加新的代码来扩展功能,而不修改已有的代码。

go
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 接口,而不需要修改现有的代码。

添加新的订单类型:假设我们要添加一个带税订单类型,只需增加以下代码:

go
// 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 原则中的一部分,旨在减少模块之间的耦合,使得系统更加灵活和可维护。这个原则的核心观点在于:

  1. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
  2. 抽象不应该依赖于具体细节,具体细节应该依赖于抽象

如下图所示:

image-20250205152649330

以下是用 Go 语言举的正反例子来说明这一原则的好处。

正面例子

首先,需要定义一个日志接口:

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 实现:

go
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 的一些行为。

go
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()) // 输出: 巴迪}

使用继承的情况下:

  1. 多态行为
    • Dog 结构体嵌入了 nimal 结构体,并且重写了 MakeSound 方法。
    • 当调用 dog.MakeSound() 时,实际调用的是 Dog 结构体中的 MakeSound 方法,输出 “汪汪”。
  2. 方法继承
    • Dog 结构体继承了 Animal 结构体中的 GetName 方法。
    • 当调用 dog.GetName() 时,输出的是 Animal 结构体中的 name 字段的值 “巴迪”。

耦合问题:

  1. 父类接口变化的影响

    • 如果需要在 Animal 结构体中修改 MakeSound 方法的签名,例如增加一个参数。

      go
      func (a *Animal) MakeSound(volume int) {    fmt.Println("某种动物叫声,音量为", volume)}
    • 这样就会要求 Dog 结构体也必须同步修改,否则会导致编译错误。

      go
      func (d *Dog) MakeSound(volume int) {    fmt.Println("汪汪,音量为", volume)}
  2. 父类内部实现变化的影响

    • 如果在 Animal 结构体中添加了一个方法 Describe,并且在方法内部依赖于 MakeSound:

      go
      func (a *Animal) Describe() {    fmt.Print("这是一只", a.name, ",它的叫声是:")    a.MakeSound()}
    • 如果 Dog 结构体没有意识到 Describe 方法的存在,这可能会导致意外的行为:

      go
      func main() {    dog := Dog{Animal{name: "巴迪"}}    dog.Describe() // 输出: 这是一只 巴迪 ,它的叫声是:某种动物叫声}
    • 如果 Animal 结构体中的 Describe 方法实现发生了变化,例如使用新的逻辑,这可能会影响到 Dog 结构体的行为。

      go
      func (a *Animal) Describe() {    fmt.Print("动物名称:", a.name, ",叫声:")    a.MakeSound()}

正面例子

改用组合的方式来降低耦合度。

go
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 结构体。

go
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 中添加一个方法来遵循迪米特法则。

go
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 的内部结构,从而遵循了迪米特法则。