面向对象的七大设计原则

发布于:2025-08-06 ⋅ 阅读:(20) ⋅ 点赞:(0)

面向对象的七大设计原则

大家公认的是,接口设计的五大核心原则(SOLID)。
本篇,除了包含(SOLID)。
除此之外,拓展了(迪米特原则 / 组合聚合)

目录

面向对象的七大设计原则

一、开闭原则(The Open-Closed Principle ,OCP)

概念理解:

案例剖析:

错误案例:

正确示例:

总结给后端兄弟:

现实场景:

二、 里式替换原则(Liskov Substitution Principle ,LSP)

概念理解:

错误案例:

正确示例:

总结一句话: 

三、 迪米特原则(最少知道原则)(Law of Demeter ,LoD)

概念理解:

错误案例:

正确示例:

问题引申:

四、单一职责原则

概念理解:

错误案例:

正确示例:

五、 接口分隔原则(Interface Segregation Principle ,ISP)

概念理解:

错误案例:

正确示例:

六、 依赖倒置原则(Dependency Inversion Principle ,DIP)

概念理解:

错误案例:

正确示例:

七、 组合/聚合复用原则(Composite/Aggregate Reuse Principle ,CARP)

概念理解:

组合:

聚合:

错误案例:

正确示例:

接口设计的五大核心原则(SOLID)


一、开闭原则(The Open-Closed Principle ,OCP)

概念理解:

核心思想一句话: 对修改关闭,对扩展开放。 意思就是,当你要加新功能时,尽量别去改已经写好的、测试过的、正在运行的旧代码(改旧代码容易出错),而是通过添加新代码来实现。

为什么?为了:

  1. 稳如老狗 (稳定性):核心功能不动,系统就不容易崩。想象你在开车,引擎(核心代码)正跑得好好的,你突然想听新歌,难道要拆引擎盖接线吗?不!你直接用蓝牙连手机(扩展)就行。

  2. 灵活扩展 (扩展性):加新功能就像乐高积木,直接插新模块就行,不用把整个城堡拆了重搭。

  3. 好维护 (可维护性):别人(或者未来的你)看代码,旧逻辑很清晰,新功能都在新地方,找起来改起来都方便。

  4. 能复用 (可复用性):定义好的接口(规矩)大家都能用,不同的实现(具体干活的人)按规矩办事就行。

关键实现武器:接口 (Go 里的 interface)

大白话解释接口: 接口就是一份合同或者一份任务说明书。它只规定“做什么”(有哪些方法),但不规定“怎么做”。谁来干活(哪个具体的结构体)不管,只要你能按合同完成任务就行。

开闭原则的实现:

  1. 定义稳定的接口 (关上门):把系统中那些不变的、核心的操作抽象出来,写成接口。这个接口一旦定义好,就不要轻易改它(关闭修改)。

  2. 通过实现接口来扩展 (打开窗):当需要新功能时,就写一个新的结构体(类型),让它去实现这个接口。这样,新功能就通过添加新代码(新结构体)的方式加进来了(开放扩展)。

  3. 依赖接口,不依赖具体 (关键!):使用功能的地方(比如一个处理函数),它只认接口这个“合同”。它只管对着接口喊:“喂!那个谁(实现了接口的结构体),按合同干活!”。它根本不在乎具体是张三还是李四(哪个结构体)在干活。

案例剖析:

场景: 我们需要一个系统,能计算不同几何图形的面积。一开始只有矩形和圆形。

错误案例:

❌ 不符合开闭原则的设计 (直接依赖具体类 - 左边的设计):

package main

import (
    "fmt"
    "math"
)

// 矩形结构体
type Rectangle struct {
    Width  float64
    Height float64
}

// 矩形计算面积方法
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// 圆形结构体
type Circle struct {
    Radius float64
}

// 圆形计算面积方法
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// 计算总面积 (问题所在!)
func TotalArea(shapes []interface{}) float64 {
    total := 0.0
    for _, shape := range shapes {
        // 这里必须判断类型!很麻烦,而且每加一个新图形都要改这里!
        switch s := shape.(type) {
        case Rectangle:
            total += s.Area()
        case Circle:
            total += s.Area()
        // 如果增加三角形,需要在这里添加 case Triangle: ...
        }
    }
    return total
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    circle := Circle{Radius: 7}

    shapes := []interface{}{rect, circle}

    area := TotalArea(shapes)
    fmt.Println("Total area:", area)
}

问题在哪?

  • TotalArea 函数直接依赖具体的 Rectangle 和 Circle 类型。

  • 当需要增加一个新的图形(比如 Triangle 三角形)时:

    1. 你要定义 Triangle 结构体和它的 Area() 方法。

    2. 你必须修改 TotalArea 函数! 在里面增加 case Triangle: 分支。

  • 违反了“对修改关闭”:为了加新功能(三角形),你不得不修改已经存在的、可能在其他地方也被调用的 TotalArea 函数。这容易引入 Bug,也让代码越来越臃肿、脆弱。

正确示例:

package main

import (
    "fmt"
    "math"
)

// 核心:定义稳定的接口 (合同) - "能计算面积的东西"
type Shape interface {
    Area() float64 // 合同要求:必须有一个计算面积的方法
}

// 矩形结构体 (实现者)
type Rectangle struct {
    Width  float64
    Height float64
}

// 矩形履行合同:实现Area方法
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// 圆形结构体 (实现者)
type Circle struct {
    Radius float64
}

// 圆形履行合同:实现Area方法
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// 计算总面积 (关键!只依赖Shape接口)
func TotalArea(shapes []Shape) float64 { // 参数是Shape接口的切片
    total := 0.0
    for _, shape := range shapes {
        // 这里不需要知道具体是矩形还是圆形!它只管调用合同规定的方法 Area()
        total += shape.Area()
    }
    return total
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    circle := Circle{Radius: 7}

    // 注意:切片里的元素是 Shape 接口类型。Rectangle 和 Circle 都实现了 Shape,所以可以放进来。
    shapes := []Shape{rect, circle}

    area := TotalArea(shapes)
    fmt.Println("Total area:", area)
}
  • 你做了什么? 你只添加了新代码 (Triangle 结构体和它的 Area 方法)。

  • 你 没有 做什么? 你完全不需要修改 Shape 接口的定义,也完全不需要修改 TotalArea 函数的任何一行代码!

  • 完美体现: 对修改关闭Shape接口、TotalArea函数没动),对扩展开放(通过新增 Triangle 实现 Shape 来扩展功能)。

总结给后端兄弟:

  • 开闭原则的精髓就是: 加新功能,别动老代码! 用添加代替修改。

  • Go 里的法宝就是 interface 定义好接口(规矩),让不同的结构体(干活的人)去实现它。

  • 关键编程习惯: 写函数(比如 TotalArea)时,参数和内部操作尽量用接口类型 (Shape),而不是具体的结构体类型 (RectangleCircle)。这样函数就能自动适配任何未来实现了该接口的新类型。

  • 好处大大的: 系统稳如泰山,加功能灵活如泥鳅,代码干净好维护,组件还能复用。这就是设计模式的魅力!

现实场景:

想象你写一个支付模块,定义好 PaymentProcessor 接口 (ProcessPayment(amount float64) error)。最开始有 AlipayProcessor 实现。后面要加微信支付?写个 WechatPayProcessor 实现同一个接口就行。调用支付的业务代码 (Checkout 函数) 完全不用改!这就是开闭原则在后端的威力。

二、 里式替换原则(Liskov Substitution Principle ,LSP)

概念理解:

子类型必须能够替换它们的基类型而不改变程序的正确性。

核心思想一句话: 子类必须能完全顶替父类的岗位,而且不会捅娄子。 也就是说,在任何使用父类的地方,你换成子类对象,程序还能正常运行,不会出错。(不坑爹)。

为什么这么重要?

  1. 防止继承滥用:不是两个类看起来有点像就能随便继承,关键看子类能不能完美替代父类

  2. 保证代码安全:避免出现"父类能用,子类一用就崩"的坑爹情况

  3. 支持开闭原则:只有满足LSP,才能安全地扩展子类而不改原有代码

关键要求(Go中的体现):

1、别用类型断言搞特殊对待

// 违反LSP的写法 ❌
func Process(animal interface{}) {
    if dog, ok := animal.(Dog); ok {
        dog.Bark()
    } else if cat, ok := animal.(Cat); ok {
        cat.Meow()
    }
    // 每加一个新动物都要修改这里!
}

换句话说:

  • 如果程序接受父类型T

  • 那么它应该不加修改地接受任何T的子类型S

  • 不需要对S做任何特殊处理

2、子类必须完全实现父类承诺的功能

  • 不能削弱父类方法的功能(比如父类方法保证不返回错误,子类实现却可能返回错误)

  • 不能加强前置条件(比如父类方法接受负数,子类却只接受正数)

  • 不能削弱后置条件(比如父类方法保证返回非负数,子类实现却可能返回负数)

错误案例:

案例1:经典的正方形vs矩形(违反LSP)

// 定义图形接口
type Shape interface {
    Area() float64
}

// 矩形独立实现
type Rectangle struct{ w, h float64 }

func (r Rectangle) Area() float64 { return r.w * r.h }
func (r *Rectangle) SetDimensions(w, h float64) {
    r.w, r.h = w, h
}

// 正方形独立实现
type Square struct{ side float64 }

func (s Square) Area() float64 { return s.side * s.side }
func (s *Square) SetSide(length float64) {
    s.side = length
}

// 统一处理函数
func PrintArea(s Shape) {
    fmt.Printf("图形面积: %.1f\n", s.Area())
}

func main() {
    rect := &Rectangle{}
    rect.SetDimensions(5, 4)
    PrintArea(rect) // ✅ 输出: 图形面积: 20.0
    
    sq := &Square{}
    sq.SetSide(4)
    PrintArea(sq) // ✅ 输出: 图形面积: 16.0
}

关键改进:

  • 通过Shape接口抽象共同行为

  • 矩形和正方形平级实现,不存在继承关系

  • 各自暴露符合自身特性的方法(矩形用SetDimensions,正方形用SetSide)

方案2:用组合替代继承(运动员案例)

// 自行车类
type Bike struct {
    color string
}

func (b *Bike) Move() { fmt.Println("自行车前进...") }
func (b *Bike) Repair() { fmt.Println("修理自行车...") }

// 运动员类(错误地继承自行车)
type Athlete struct {
    Bike // 内嵌继承
    strength int
}

func (a *Athlete) Train() { 
    fmt.Printf("运动员训练,力量值: %d\n", a.strength) 
}

// 使用自行车的函数
func ServiceBike(b *Bike) {
    fmt.Printf("正在服务%s的自行车...", b.color)
    b.Repair()
}

func main() {
    // 正确的使用
    bike := &Bike{color: "红色"}
    ServiceBike(bike) // ✅ 正常服务
    
    // 错误的使用:把运动员当自行车
    athlete := &Athlete{Bike: Bike{color: "蓝色"}, strength: 80}
    ServiceBike(&athlete.Bike) // 🤔 语法可行,但逻辑荒谬!
    // 实际是修理了运动员的自行车,不是修理运动员
}

问题在哪?

  • 语法上可行(通过内嵌字段),但语义上荒谬

  • 运动员不是自行车(is-a关系不成立)

  • 违反LSP:不能把运动员当作自行车使用

正确示例:

方案1:抽象出共同接口(四边形案例)

// 定义图形接口
type Shape interface {
    Area() float64
}

// 矩形独立实现
type Rectangle struct{ w, h float64 }

func (r Rectangle) Area() float64 { return r.w * r.h }
func (r *Rectangle) SetDimensions(w, h float64) {
    r.w, r.h = w, h
}

// 正方形独立实现
type Square struct{ side float64 }

func (s Square) Area() float64 { return s.side * s.side }
func (s *Square) SetSide(length float64) {
    s.side = length
}

// 统一处理函数
func PrintArea(s Shape) {
    fmt.Printf("图形面积: %.1f\n", s.Area())
}

func main() {
    rect := &Rectangle{}
    rect.SetDimensions(5, 4)
    PrintArea(rect) // ✅ 输出: 图形面积: 20.0
    
    sq := &Square{}
    sq.SetSide(4)
    PrintArea(sq) // ✅ 输出: 图形面积: 16.0
}
  • 通过Shape接口抽象共同行为

  • 矩形和正方形平级实现,不存在继承关系

  • 各自暴露符合自身特性的方法(矩形用SetDimensions,正方形用SetSide)

方案2:用组合替代继承(运动员案例)

// 自行车类(不变)
type Bike struct{ color string }

// 运动员类(包含自行车)
type Athlete struct {
    bike      *Bike  // 组合代替继承
    strength  int
}

func (a *Athlete) RideBike() {
    fmt.Printf("运动员骑行%s自行车...\n", a.bike.color)
    a.bike.Move()
}

func main() {
    athlete := &Athlete{
        bike: &Bike{color: "黄色"},
        strength: 90,
    }
    
    athlete.RideBike() // ✅ 运动员骑行黄色自行车...
    // 不再可能出现"把运动员当自行车修理"的逻辑错误
}

关键改进:

  • 用"has-a"(拥有)关系替代"is-a"(是)关系

  • 运动员包含自行车,而不是自行车

  • 符合现实世界的逻辑关系

切记组合优于继承

// 当不确定时,优先用组合
type MyService struct {
    Logger *log.Logger  // 组合logger
    DB     *sql.DB      // 组合数据库
}

总结一句话: 

在Go中,当你通过接口或内嵌结构体使用一个类型时,这个类型的行为应该符合使用者的合理预期,不会因为具体实现的不同而导致意外行为
就像你能把任何USB设备插入标准USB接口,设备都能正常工作而不会烧毁你的电脑一样,这就是LSP的精髓。

三、 迪米特原则(最少知道原则)(Law of Demeter ,LoD)

概念理解:

核心思想一句话:

管好你自己,别瞎打听! 每个对象只跟自己的"直系朋友"打交道,不该知道的别问,不该管的别碰。

 给Go开发者的白话解释:

想象你在公司里:

  • 你是后端工程师(一个对象)

  • 你的直系朋友:产品经理(参数传入)、数据库(字段引用)、日志系统(创建的对象)

  • 陌生人:前端同事、测试同事、运维同事

迪米特原则要求:你只能找直系朋友办事

  • 直接问产品经理需求(方法参数)
  • 直接操作数据库(字段引用)
  • 直接写日志(创建的对象)

 禁止
➔ 别直接找前端要数据(陌生人)
➔ 别直接命令测试改用例(陌生人)
➔ 别直接让运维重启服务器(陌生人)

错误案例:

违反原则的Go代码(反面教材):

package main

type DB struct{}

func (db *DB) Query() string {
    return "用户数据"
}

// 日志记录器
type Logger struct{}

func (l *Logger) Record(log string) {
    fmt.Println("记录日志:", log)
}

// 用户服务 - 违反迪米特原则!
type UserService struct {
    logger *Logger
}

func (s *UserService) GetUserInfo(db *DB) {
    // 问题1:直接操作DB(不是直系朋友)
    data := db.Query()
    
    // 问题2:知道太多细节(需要组装日志)
    log := fmt.Sprintf("查询用户: %s", data)
    
    // 问题3:直接操作Logger(虽然是朋友,但过度暴露细节)
    s.logger.Record(log)
}

func main() {
    db := &DB{}
    logger := &Logger{}
    service := &UserService{logger: logger}
    
    service.GetUserInfo(db)
}

问题分析:

  1. UserService 直接操作 DB 对象(DB不是它的字段,也不是参数)

  2. 它知道太多业务细节(需要组装日志内容)

  3. 虽然Logger是朋友,但直接调用其方法暴露了实现细节

正确示例:

package main

import "fmt"

// 数据访问层(抽象)
type UserRepository interface {
    GetUser() string
}

// DB实现
type DB struct{}

func (db *DB) GetUser() string {
    return "用户数据"
}

// 日志接口(抽象)
type Logger interface {
    Info(msg string)
}

// 日志实现
type AppLogger struct{}

func (l *AppLogger) Info(msg string) {
    fmt.Println("[INFO]", msg)
}

// 用户服务 - 符合迪米特!
type UserService struct {
    repo   UserRepository // 通过接口依赖
    logger Logger         // 通过接口依赖
}

// 通过构造函数注入依赖
func NewUserService(repo UserRepository, logger Logger) *UserService {
    return &UserService{repo: repo, logger: logger}
}

func (s *UserService) GetUserInfo() {
    // 只跟直系朋友(repo)打交道
    data := s.repo.GetUser()
    
    // 只跟直系朋友(logger)打交道
    s.logger.Info(fmt.Sprintf("查询用户: %s", data))
}

func main() {
    // 依赖组装(在高层模块)
    db := &DB{}
    logger := &AppLogger{}
    
    service := NewUserService(db, logger)
    service.GetUserInfo()
}

遵循迪米特原则,你的Go代码会像组织良好的团队一样:职责清晰、协作高效、维护轻松!

问题引申:

我之前,一直对依赖注入有很多疑问,在这里逐步解决问题

依赖注入与接口抽象的价值分析

你提出的问题非常关键——为什么需要这些看似"多余"的接口抽象和依赖注入?让我们深入分析这个设计模式的真正价值。

看似多余的代码?

type UserService struct {
    repo   UserRepository // 接口类型
    logger Logger         // 接口类型
}

func NewUserService(repo UserRepository, logger Logger) *UserService {
    return &UserService{repo: repo, logger: logger}
}

表面上,这确实比直接使用具体类型更复杂:

  • 需要定义接口

  • 需要写构造函数

  • 需要额外注入依赖

但这绝不是多此一举! 这些设计提供了重要的软件工程优势:

一、场景 1:切换数据库实现(MySQL → MongoDB)

假设原有代码直接依赖 MySQL 数据库,现在需要切换为 MongoDB,通过接口抽象 + 依赖注入实现无缝切换。

步骤 1:定义抽象接口

首先定义数据访问层的接口 UserRepository,抽象所有数据库操作:

// 定义用户仓储接口(抽象契约)
type UserRepository interface {
    Save(user User) error
    FindByID(id string) (*User, error)
}

// 用户实体
type User struct {
    ID       string
    Username string
    Email    string
}

步骤 2:实现不同数据库的具体类

分别实现 MySQL 和 MongoDB 的具体仓储:

// MySQL 实现
type MySQLUserRepository struct {
    db *sql.DB // 实际项目中需初始化数据库连接
}

func (r *MySQLUserRepository) Save(user User) error {
    // MySQL 特有的 SQL 逻辑
    _, err := r.db.Exec("INSERT INTO users (id, username, email) VALUES (?, ?, ?)", 
        user.ID, user.Username, user.Email)
    return err
}

func (r *MySQLUserRepository) FindByID(id string) (*User, error) {
    // MySQL 查询逻辑
    var user User
    err := r.db.QueryRow("SELECT id, username, email FROM users WHERE id = ?", id).
        Scan(&user.ID, &user.Username, &user.Email)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

// MongoDB 实现
type MongoDBUserRepository struct {
    client *mongo.Client // MongoDB 客户端
    collection *mongo.Collection
}

func (r *MongoDBUserRepository) Save(user User) error {
    // MongoDB 特有的文档插入逻辑
    _, err := r.collection.InsertOne(context.Background(), user)
    return err
}

func (r *MongoDBUserRepository) FindByID(id string) (*User, error) {
    // MongoDB 查询逻辑
    var user User
    err := r.collection.FindOne(context.Background(), bson.M{"id": id}).Decode(&user)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

步骤 3:业务逻辑层依赖接口

UserService 仅依赖 UserRepository 接口,不关心具体数据库实现:

type UserService struct {
    repo UserRepository // 依赖抽象接口,而非具体实现
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Register(user User) error {
    // 业务逻辑(如参数校验、密码加密等)
    err := s.repo.Save(user) // 调用抽象接口,无数据库细节
    if err != nil {
        return fmt.Errorf("注册失败: %w", err)
    }
    return nil
}

步骤 4:运行时动态切换实现

通过依赖注入在初始化时选择具体实现:

func main() {
    // 1. 初始化数据库连接(根据配置选择 MySQL 或 MongoDB)
    var repo UserRepository
    dbType := os.Getenv("DB_TYPE") // 从环境变量读取配置
    
    if dbType == "mongodb" {
        // 初始化 MongoDB 客户端和仓储
        client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://localhost:27017"))
        if err != nil {
            log.Fatal(err)
        }
        collection := client.Database("demo").Collection("users")
        repo = &MongoDBUserRepository{client: client, collection: collection}
    } else {
        // 默认使用 MySQL
        db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/demo")
        if err != nil {
            log.Fatal(err)
        }
        repo = &MySQLUserRepository{db: db}
    }

    // 2. 注入仓储到 UserService
    userService := NewUserService(repo)
    
    // 3. 调用业务逻辑
    user := User{ID: "123", Username: "Alice", Email: "alice@example.com"}
    err := userService.Register(user)
    if err != nil {
        log.Fatal(err)
    }
}

四、单一职责原则

永远不要让一个类存在多个改变的理由。

概念理解:

核心思想一句话:

一个模块只干一件事! 就像餐厅里:

  • 厨师只管做饭 

  • 服务员只管上菜 

  • 收银员只管结账 

为什么重要?

  1. 改菜单不影响结账:修改厨师的工作不会影响收银系统

  2. 换人不乱套:新招一个配菜工,不会干扰服务员

  3. 修东西不牵连:收银机坏了只需要修收银模块

错误案例:

// 这个类既管用户又管订单还管日志,简直是"上帝类"
type UserService struct {
    db *sql.DB
    logger *log.Logger
}

func (s *UserService) ProcessUser(userID int) {
    // 1. 查询用户(用户管理职责)
    user := s.db.QueryRow("SELECT * FROM users WHERE id = ?", userID)
    
    // 2. 创建订单(订单管理职责)
    orderID := createOrder(user)
    
    // 3. 记录日志(日志管理职责)
    s.logger.Println("创建订单:", orderID)
    
    // 4. 发送通知(通知职责)
    sendEmail(user.Email, "订单创建成功")
}

问题在哪?

  1. 这个类同时负责:

    • 用户管理

    • 订单管理

    • 日志记录

    • 邮件通知

  2. 修改其中任何一部分都可能影响其他功能

  3. 测试时要模拟所有依赖,极其困难

正确示例:

// 职责1:只负责用户数据
type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) GetUser(userID int) (*User, error) {
    // 仅用户查询逻辑
}

// 职责2:只负责订单管理
type OrderService struct{}

func (s *OrderService) CreateOrder(user *User) (int, error) {
    // 仅订单创建逻辑
}

// 职责3:只负责日志记录
type Logger interface {
    Log(message string)
}

// 职责4:只负责通知
type Notifier interface {
    Notify(email, message string)
}

// 协调层(组合各单一职责组件)
type UserProcessor struct {
    userRepo  *UserRepository
    orderSvc  *OrderService
    logger    Logger
    notifier  Notifier
}

func (p *UserProcessor) Process(userID int) {
    // 1. 获取用户(调用单一职责组件)
    user, _ := p.userRepo.GetUser(userID)
    
    // 2. 创建订单(调用单一职责组件)
    orderID, _ := p.orderSvc.CreateOrder(user)
    
    // 3. 记录日志(调用单一职责组件)
    p.logger.Log(fmt.Sprintf("创建订单: %d", orderID))
    
    // 4. 发送通知(调用单一职责组件)
    p.notifier.Notify(user.Email, "订单创建成功")
}

记得:接口最小化,职责单一。

五、 接口分隔原则(Interface Segregation Principle ,ISP)

概念理解:

核心思想一句话:

别逼用户接没用的功能! 就像买手机:

  • 打电话用户:不需要强行搭配游戏手柄

  • 游戏用户:不需要强行搭配老人模式

为什么重要?

  1. 避免被强塞垃圾:用户不会被强迫实现不需要的方法

  2. 改功能不误伤:修改游戏功能不会影响打电话用户

  3. 接口清爽好用:每个接口都短小精悍,一目了然

错误案例:

门禁系统:

// 错误:臃肿的万能接口 ❌
type SuperDoor interface {
    Lock()       // 锁门
    Unlock()     // 解锁
    AlarmOn()    // 打开警报
    AlarmOff()   // 关闭警报
    FingerprintAuth() // 指纹验证
    FaceAuth()   // 人脸识别
}

// 普通门实现(被迫实现不需要的方法)
type BasicDoor struct{}

func (d *BasicDoor) Lock()   { fmt.Println("上锁") }
func (d *BasicDoor) Unlock() { fmt.Println("解锁") }

// 普通门根本不需要这些功能!
func (d *BasicDoor) AlarmOn()          { panic("不支持!") }
func (d *BasicDoor) AlarmOff()         { panic("不支持!") }
func (d *BasicDoor) FingerprintAuth()  { panic("不支持!") }
func (d *BasicDoor) FaceAuth()         { panic("不支持!") }

// 使用普通门
func main() {
    var door SuperDoor = &BasicDoor{}
    door.Lock() // ✅ 正常
    door.AlarmOn() // 💥 运行时panic!
}

问题在哪?

  1. 普通门被强迫实现警报/生物识别

  2. 调用不存在的方法会导致运行时崩溃

  3. 添加新功能时所有门都要修改

正确示例:

// 拆分成最小功能接口 ✅

// 基础门接口
type BasicDoor interface {
    Lock()
    Unlock()
}

// 警报功能接口
type Alarmer interface {
    AlarmOn()
    AlarmOff()
}

// 生物识别接口
type BiometricAuth interface {
    FingerprintAuth()
    FaceAuth()
}

// 普通门实现
type SimpleDoor struct{}

func (d *SimpleDoor) Lock()   { fmt.Println("机械锁上锁") }
func (d *SimpleDoor) Unlock() { fmt.Println("机械锁解锁") }

// 智能门实现
type SmartDoor struct {
    BasicDoor  // 嵌入基础功能
    Alarmer    // 组合警报功能
    BiometricAuth // 组合生物识别
}

// 实现警报功能
func (d *SmartDoor) AlarmOn()  { fmt.Println("警报启动") }
func (d *SmartDoor) AlarmOff() { fmt.Println("警报关闭") }

// 实现生物识别
func (d *SmartDoor) FingerprintAuth() { fmt.Println("指纹验证通过") }
func (d *SmartDoor) FaceAuth()        { fmt.Println("人脸识别通过") }

// 使用场景
func SecureAreaAccess(door Alarmer) {
    door.AlarmOff() // 只需警报功能
    defer door.AlarmOn()
    fmt.Println("进入安全区域...")
}

func main() {
    // 普通门用户
    simpleDoor := &SimpleDoor{}
    simpleDoor.Lock() // ✅ 只使用需要的方法
    
    // 智能门用户
    smartDoor := &SmartDoor{}
    smartDoor.FaceAuth() // ✅ 使用高级功能
    
    // 安全系统只关心警报
    SecureAreaAccess(smartDoor) // ✅ 传入Alarmer接口
    // SecureAreaAccess(simpleDoor) // ❌ 编译直接报错(类型安全)
}

重构要点:

  1. 拆!拆!拆!

    • BasicDoor:基础开关

    • Alarmer:警报功能

    • BiometricAuth:生物识别

  2. 按需组合

    • 普通门只实现基础功能

    • 智能门组合多个接口

  3. 编译时保护

    • 试图把普通门当警报器用?编译直接失败!

    • 不需要等到运行时崩溃

六、 依赖倒置原则(Dependency Inversion Principle ,DIP)

概念理解:

核心思想一句话:

高层定规矩,底层去实现! 就像老板定战略(高层),员工去执行(底层),老板不需要知道员工具体怎么做。

为什么重要?

  1. 换员工不影响战略:更换数据库不影响业务逻辑

  2. 老板更专注:高层只关心做什么,不关心怎么做

  3. 系统更灵活:底层实现可以随意替换

  4. 测试更容易:可以用模拟对象代替真实数据库

错误案例:

违反DIP的Go代码(反面教材)

// 低层模块:MySQL数据库操作
type MySQLDatabase struct{}

func (db *MySQLDatabase) Query(query string) string {
    fmt.Println("执行MySQL查询:", query)
    return "MySQL查询结果"
}

// 高层模块:业务逻辑(直接依赖低层实现)
type ReportService struct {
    db *MySQLDatabase // 直接依赖具体实现 ❌
}

func (s *ReportService) GenerateReport() {
    result := s.db.Query("SELECT * FROM reports")
    fmt.Println("生成报告:", result)
}

func main() {
    service := &ReportService{db: &MySQLDatabase{}}
    service.GenerateReport()
    
    // 如果想换成PostgreSQL?必须修改ReportService代码!
}

问题在哪?

  1. 高层ReportService直接依赖低层MySQLDatabase

  2. 更换数据库需要修改业务逻辑

  3. 单元测试需要真实MySQL连接

正确示例:

// 1. 定义抽象接口(高层制定规则)
type Database interface {
    Query(string) string
}

// 2. 高层模块依赖抽象接口 ✅
type ReportService struct {
    db Database // 依赖抽象,不依赖具体实现
}

func (s *ReportService) GenerateReport() {
    result := s.db.Query("SELECT * FROM reports")
    fmt.Println("生成报告:", result)
}

// 3. 低层模块实现接口 ✅
type MySQLDatabase struct{}

func (db *MySQLDatabase) Query(query string) string {
    fmt.Println("执行MySQL查询:", query)
    return "MySQL查询结果"
}

// 新增PostgreSQL实现(低层模块)
type PostgreSQLDatabase struct{}

func (db *PostgreSQLDatabase) Query(query string) string {
    fmt.Println("执行PostgreSQL查询:", query)
    return "PostgreSQL查询结果"
}

// 4. 依赖注入(在程序入口组装)
func main() {
    // 使用MySQL
    mysqlService := &ReportService{db: &MySQLDatabase{}}
    mysqlService.GenerateReport()
    
    // 切换到PostgreSQL只需改这里
    pgService := &ReportService{db: &PostgreSQLDatabase{}}
    pgService.GenerateReport()
    
    // 单元测试可以用Mock
    mockService := &ReportService{db: &MockDatabase{}}
    mockService.GenerateReport()
}

// Mock实现(用于测试)
type MockDatabase struct{}

func (db *MockDatabase) Query(query string) string {
    return "模拟数据"
}

重构要点:

  1. 高层定接口ReportService定义它需要什么功能

  2. 低层实现接口:MySQL/PostgreSQL实现这个接口

  3. 依赖注入:在main函数中"注入"具体实现

  4. 面向接口编程:所有代码都依赖接口而非具体实现

这就是精髓所在!!!

这就是依赖注入,多优美啊。真帅!

记住这个口诀:
🔁 高层定规矩,底层去实现
🔁 依赖抽象不依赖具体
🔁 接口隔离是前提
🔁 依赖注入来组装
🔁 系统灵活又健壮

七、 组合/聚合复用原则(Composite/Aggregate Reuse Principle ,CARP)

概念理解:

核心思想一句话:

能组装就别继承! 就像造车:

  • 发动机是买来的(组合)

  • 轮胎是采购的(聚合)

  • 而不是让车厂自己"生"出发动机(继承)

为什么重要?

方式 优点 缺点
组合/聚合 灵活更换零件
降低耦合
运行时动态调整
支持多种功能组合
需要管理更多对象
继承 代码复用简单
修改父类自动影响子类
破坏封装性
父类改动影响所有子类
灵活性差
容易导致类爆炸
组合:

示例:汽车(Car)和发动机(Engine)
发动机是汽车的核心组件,无法脱离汽车独立存在(汽车销毁时,发动机也随之 "失效")。

package main

import "fmt"

// 发动机(部分)
type Engine struct {
	horsepower int
}

func NewEngine(horsepower int) *Engine {
	fmt.Println("发动机被制造")
	return &Engine{horsepower: horsepower}
}

// 汽车(整体)
type Car struct {
	brand  string
	engine *Engine // 组合:汽车"包含"发动机,发动机的生命周期由汽车控制
}

func NewCar(brand string, horsepower int) *Car {
	// 汽车创建时,必须同时创建发动机(强依赖)
	return &Car{
		brand:  brand,
		engine: NewEngine(horsepower),
	}
}

func (c *Car) Run() {
	fmt.Printf("%s汽车(%d马力)启动\n", c.brand, c.engine.horsepower)
}

func main() {
	// 创建汽车时,自动创建发动机
	car := NewCar("特斯拉", 300)
	car.Run()

	// 当car被垃圾回收时,其内部的engine也会被一同回收
}
聚合:

示例:公司(Company)和员工(Employee)
员工可以属于公司,但员工的存在不依赖于公司(公司倒闭后,员工仍可独立存在)。

package main

import "fmt"

// 员工(部分)
type Employee struct {
	name string
}

func NewEmployee(name string) *Employee {
	return &Employee{name: name}
}

// 公司(整体)
type Company struct {
	name      string
	employees []*Employee // 聚合:公司"关联"员工,员工可独立存在
}

func NewCompany(name string) *Company {
	return &Company{
		name: name,
	}
}

// 公司招聘员工(关联已有员工)
func (c *Company) Hire(e *Employee) {
	c.employees = append(c.employees, e)
}

func (c *Company) ShowStaff() {
	fmt.Printf("%s公司的员工:\n", c.name)
	for _, emp := range c.employees {
		fmt.Printf("- %s\n", emp.name)
	}
}

func main() {
	// 先创建独立的员工(不依赖公司)
	emp1 := NewEmployee("张三")
	emp2 := NewEmployee("李四")

	// 再创建公司,关联已有的员工
	company := NewCompany("字节跳动")
	company.Hire(emp1)
	company.Hire(emp2)
	company.ShowStaff()

	// 即使company被销毁,emp1和emp2仍可被其他对象引用
}
  • 组合Car 创建时必须同时创建 Engine,两者生命周期完全绑定(Engine 无法独立于 Car 存在)。
  • 聚合Company 可以关联已存在的 EmployeeEmployee 可独立于 Company 存在(可被多个 Company 共享)。

错误案例:

// 错误:用继承实现角色系统 ❌
type Person struct {
    Name string
}

// 雇员继承"人"
type Employee struct {
    Person
    Company string
}

// 学生继承"人"
type Student struct {
    Person
    School string
}

func main() {
    // 张三既是雇员又是学生?
    emp := Employee{Person{"张三"}, "腾讯"}
    stu := Student{Person{"张三"}, "清华"}
    
    // 同一个张三被分裂成两个对象!
}

问题在哪?

  1. 现实中一个人可以同时是雇员和学生

  2. 使用继承导致角色分裂

  3. 添加新角色(如父亲)需要新建类型

  4. 违反现实世界的逻辑关系

正确示例:

// 正确:用组合实现角色 ✅

// 基础人类型
type Person struct {
    Name string
    Roles []Role // 聚合多个角色
}

// 角色接口
type Role interface {
    Describe() string
}

// 雇员角色
type EmployeeRole struct {
    Company string
}

func (r EmployeeRole) Describe() string {
    return fmt.Sprintf("雇员@%s", r.Company)
}

// 学生角色
type StudentRole struct {
    School string
}

func (r StudentRole) Describe() string {
    return fmt.Sprintf("学生@%s", r.School)
}

// 父亲角色
type ParentRole struct {
    Children int
}

func (r ParentRole) Describe() string {
    return fmt.Sprintf("父亲(%d孩)", r.Children)
}

func main() {
    // 张三有多个角色
    zhangsan := Person{
        Name: "张三",
        Roles: []Role{
            EmployeeRole{"腾讯"},
            StudentRole{"清华夜校"},
            ParentRole{Children: 2},
        },
    }
    
    // 展示所有角色
    for _, role := range zhangsan.Roles {
        fmt.Println(role.Describe())
    }
    
    // 运行时移除学生角色
    zhangsan.Roles = zhangsan.Roles[:2]
}

重构要点:

  1. 角色独立存在:每个角色实现统一接口

  2. 人组合角色:通过切片聚合多个角色

  3. 动态增删:运行时添加/移除角色

  4. 符合现实:一个人可同时有多个身份

接口设计的五大核心原则(SOLID)

SOLID 是五个原则的首字母缩写,具体包括:

  1. 单一职责原则(Single Responsibility Principle, SRP)
    一个接口或类应该只负责一项职责,即仅有一个引起它变化的原因。
    例如:用户接口应仅处理用户信息相关操作(注册、登录),不应同时包含订单处理逻辑。

  2. 开放 - 封闭原则(Open-Closed Principle, OCP)
    接口或类应对扩展开放,对修改关闭。即通过扩展现有接口来新增功能,而非修改原有代码。
    例如:设计支付接口时,预留扩展点支持新增支付方式(如从支付宝扩展到微信支付),无需修改原有接口逻辑。

  3. 里氏替换原则(Liskov Substitution Principle, LSP)
    子类必须能够替换其基类(或实现类必须能替换接口),且不影响程序正确性。
    例如:实现 “形状” 接口的 “正方形” 和 “圆形”,在计算面积时应符合接口预期,不能出现逻辑冲突。

  4. 接口隔离原则(Interface Segregation Principle, ISP)
    不应强迫客户端依赖其不需要的接口方法,应将大接口拆分为多个小而专的接口。
    例如:“动物” 接口不应包含 “飞” 的方法,而应拆分为 “会飞的动物”“会跑的动物” 等细分接口,避免不会飞的动物被迫实现该方法。

  5. 依赖倒置原则(Dependency Inversion Principle, DIP)
    高层模块不应依赖低层模块,两者都应依赖抽象(接口);抽象不应依赖细节,细节应依赖抽象。
    例如:业务逻辑层(高层)应依赖 “数据存储接口”,而非直接依赖 MySQL(低层实现),这样切换数据库时无需修改高层代码。


借鉴博客:

1、 面向对象设计的七大设计原则详解



网站公告

今日签到

点亮在社区的每一天
去签到