go语言--笔记--封装、组合(继承)

Go 语言没有 class,没有 extends,没有 implements,但封装、组合(继承)、多态这三大 OOP 特性,Go 不仅全都有,而且实现得更干净、更贴近底层硬件。(多态在接口的章节单独讲)

第一特性:封装(Encapsulation)

1.1 宏观设计:Go 的封装是"命名约定"而非"访问修饰符"

Java/C++ 用 private/protected/public 关键字控制访问,Go 用首字母大小写

  • 首字母大写:导出的,对本包外的世界可见。

  • 首字母小写 :未导出的,包外不可见,连反射都穿透不了(除非你用 unsafe 走邪道)。

Go 复制代码
package user

type Account struct {
    ID       int64   // 导出(类似public)------ 首字母大写
    balance  float64 // 未导出(类似private)------ 首字母小写
    password string  // 未导出
}

func (a *Account) Deposit(amount float64) { // 导出方法(其他包可以使用)
    if amount > 0 {
        a.balance += amount
    }
}

func (a *Account) Balance() float64 { // Getter,导出字段
    return a.balance
}

关键认知 :Go 的封装粒度是包(package) ,不是类型。同一个包内的任何代码,都可以访问其他类型的未导出字段。这是 Go 设计者(Rob Pike 等人)的刻意选择:封装是包之间的契约,不是类型之间的围墙。

1.2 深入底层:编译器如何强制执行封装

当你写 import "user" 并在另一个包访问 a.balance 时,编译器在 cmd/compile类型检查阶段(typecheck) 就会报错:

Go 复制代码
a.balance undefined (cannot refer to unexported field or method balance)

编译器内部发生了什么?

Go 复制代码
源代码 → 词法分析 → 语法分析(AST) → 类型检查(typecheck) → 编译通过?→ 生成代码 → 链接

typecheck 阶段 ,编译器看到 a.balance

  1. 查左边a 是什么类型?→ user.Account

  2. 查右边balanceuser.Account 的字段吗?→ 是

  3. 查权限 :当前包是 main,字段所在包是 userbalance 首字母是小写 b未导出

  4. 直接报错cannot refer to unexported field or method balance

底层机制

  1. AST 解析阶段:编译器识别标识符的首个字符。Go 使用 Unicode 判定,只要首字符是 Unicode 大写字母,就是导出的。

  2. 导出表(Export Data) :编译 .go 文件时,导出的类型、变量、函数签名被写入 .a 归档文件的导出区。其他包编译时只读取这些导出符号,未导出符号对它们完全不可见。

  3. 链接器层面 :你可以用 go tool nm yourbinary | grep balance 查看,未导出符号在最终二进制中通常被标记为 d(data)或 t(text),但外部包在编译期就被拒绝,根本走不到链接。

1.3 封装的"漏洞"与技巧:reflect 和 unsafe

reflect 可以绕过封装

reflect 能看到未导出字段的值,但不能修改(Go 1.6+ 的限制)。这是编译器和运行时的双重保护。

Go 复制代码
package main

import (
    "fmt"
    "reflect"
    "yourapp/user"
)

func main() {
    a := &user.Account{}
    v := reflect.ValueOf(a).Elem()
    
    // 可以"看"到未导出字段
    balanceField := v.FieldByName("balance")
    fmt.Println(balanceField) // 0
    
    // 但不能直接 Set,除非用 unsafe(生产环境绝对禁止)
    // balanceField.SetFloat(1000) // panic: reflect.Value.SetFloat using value obtained using unexported field
}

1.4 高级封装技巧

技巧 A:工厂函数替代暴露的零值

Go 复制代码
package user

type Config struct {
    host string
    port int
}

// 强制外部必须通过工厂函数创建,确保有效性
func NewConfig(host string, port int) (*Config, error) {
    if host == "" || port <= 0 {
        return nil, fmt.Errorf("invalid config")
    }
    return &Config{host: host, port: port}, nil
}

技巧 B:接口作为"防暴门"

Go 复制代码
package storage

// 接口导出,实现不导出
type Store interface {
    Get(key string) ([]byte, error)
    Put(key string, val []byte) error
}

type storeImpl struct {
    data map[string][]byte
}

func (s *storeImpl) Get(key string) ([]byte, error) { /* ... */ }
func (s *storeImpl) Put(key string, val []byte) error { /* ... */ }

// 外部只能拿到接口,永远接触不到具体实现
func NewStore() Store {
    return &storeImpl{data: make(map[string][]byte)}
}

这是 Go 后端最精髓的封装模式interface 暴露行为,struct 隐藏实现。测试时你可以 mock 这个接口,生产环境用真实实现。

技巧 C:Functional Options 模式

Go 复制代码
type Server struct {
    addr     string
    maxConns int
    timeout  time.Duration
}

type Option func(*Server)

func WithAddress(addr string) Option {
    return func(s *Server) { s.addr = addr }
}

func WithMaxConns(n int) Option {
    return func(s *Server) { s.maxConns = n }
}

func NewServer(opts ...Option) *Server {
    s := &Server{
        addr:     ":8080",
        maxConns: 100,
        timeout:  30 * time.Second,
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// 使用:NewServer(WithAddress(":9090"), WithMaxConns(200))

第二特性:组合(Composition)------ Go 对继承的终极回答

Go 的设计者之一 Rob Pike 说过:"Go 没有继承,继承是 OOP 最大的错误之一。" 但代码复用怎么办?答案是嵌入(Embedding)

2.1 宏观设计:嵌入不是继承,是"委托"

Go 复制代码
package main

type Animal struct {
    Name string
}

func (a *Animal) Speak() {
    fmt.Println("I am", a.Name)
}

func (a *Animal) Move() {
    fmt.Println("Moving")
}

// Dog 嵌入了 *Animal
type Dog struct {
    *Animal  // 匿名字段,这就是嵌入
    Breed    string
}

func main() {
    d := &Dog{
        Animal: &Animal{Name: "Buddy"},
        Breed:  "Golden",
    }
    
    d.Speak()        // 直接调用!编译器重写为 d.Animal.Speak()
    d.Move()         // 同上
    fmt.Println(d.Name) // 访问嵌入字段的属性,重写为 d.Animal.Name
}

核心区别

  • 继承:子类"是一个"父类(IS-A),有隐式的 this 指针调整,有虚函数表。

  • 嵌入 :外部类型"有一个"嵌入类型(HAS-A),编译器帮你写了一层语法糖,没有虚函数,没有 this 指针调整,零运行时开销

2.1.1 匿名字段 vs 命名字段

Go 复制代码
type Dog struct {
    Animal      // 匿名:这是嵌入(Embedding)
    Breed string
}

type Dog struct {
    Animal Animal  // 命名:普通字段,不是嵌入
    Breed string
}
  • 嵌入(匿名)Animal 的字段和方法会被提升到 Dog。你可以直接写 dog.Namedog.Speak()

  • 命名 :必须通过 dog.Animal.Name 访问,没有方法提升

2.1.2 值嵌入 vs 指针嵌入

Go 复制代码
// 值嵌入:Animal 内联在 Dog 的内存布局中
type Dog struct {
    Animal
    Breed string
}

// 指针嵌入:Dog 里只存一个指针,Animal 在别处分配
type Dog struct {
    *Animal
    Breed string
}
特性 值嵌入 Animal 指针嵌入 *Animal
零值 Animal 的字段各自零值 *Animalnil
初始化 自动可用 必须手动 &Animal{}new(Animal),否则访问会 panic
内存 内联在 Dog 结构体中 指针单独指向堆上对象
共享 每个 Dog 独立副本 多个 Dog 可以共享同一个 Animal 实例
方法集提升 Dog 获得 Animal 的值接收器方法;*Dog 获得值+指针接收器方法 Dog 获得 *Animal 的方法集(包含 Animal 的所有方法);*Dog 同样获得
  • 想要嵌入提升 :字段必须匿名(不写字段名)。

  • 值嵌入 Animal :零值安全、内存内联,但无法直接调用指针接收器方法 (除非用 &dog)。

  • 指针嵌入 *Animal :灵活、可共享、能提升所有方法,但必须初始化指针 ,且零值为 nil 时访问会 panic。

  • Animal Animal:不是嵌入,只是普通命名字段,没有提升效果。

再次强调:方法集包含指针方法只代表编译能通过 ,运行时如果内嵌指针为 nil 且方法解引用接收者,仍会 panic。值嵌入的零值是"可安全使用的" (所有字段都是零值,可以直接调用值接收者方法),但指针嵌入的零值是 nil 指针 ,此时调用指针接收者方法 如果方法体内解引用 a.Name 之类,会 runtime panic

2.1.3命名冲突与"重写"

当两个嵌入类型有同名方法或字段:

  • 同一层级冲突 :必须显式指定,如 c.A.Name,直接 .Name 编译报错。

  • 外层定义遮蔽内层:这相当于"重写"。外层方法优先级高,会把内层同名方法覆盖。

  • 访问嵌入指针的字段d.Name 如果 d.Animal 是 nil,一定 panic (因为编译器重写为 d.Animal.Name)。

  • 调用嵌入指针的方法 :取决于方法接收者是否解引用自身。如果 (*Animal).Speak() 内部没有访问字段,即使 d.Animal == nil,调用也不会 panic。

Go 复制代码
type Base struct{}
func (b Base) F() {}

type Outer struct { Base }
func (o Outer) F() {} // 遮蔽

o := Outer{}
o.F()    // 调 Outer 的
o.Base.F() // 仍可调用内嵌方法

技巧 :你可以利用遮蔽来模拟"重载"或装饰器,在内层方法调用的前后加上额外逻辑,只要在 Outer.F 里显式调用 o.Base.F()

2.2 深入底层:编译器如何处理嵌入

内存布局

Go 复制代码
type Inner struct { x int }
type Outer struct {
    Inner
    y int
}

Outer 在内存中的布局:

Go 复制代码
+-----------+
| Inner.x   |  <- 偏移 0
+-----------+
| Outer.y   |  <- 偏移 8(64位系统)
+-----------+

Inner 就是 Outer 的一个普通字段,只是它的字段名默认等于类型名。没有额外的指针,没有对象头,内存布局与手动展开完全一致。

方法提升(Method Promotion)的编译器行为

当编译器看到 d.Speak() 时:

  1. 名称查找 :在 Dog 的方法集中找不到 Speak

  2. 提升查找 :检查嵌入字段(*Animal)的方法集,找到了 (*Animal).Speak

  3. 调用重写 :编译器不生成包装函数 ,而是直接生成等价于 d.Animal.Speak() 的代码。

  4. 地址计算 :生成指令计算 &d.Animal(偏移量已知,编译期常量),将其作为接收者传入。

2.3 方法集的深层规则(面试重灾区)

Go 的方法集规则决定了嵌入能否满足接口:

嵌入类型 外部值 Outer 能调什么 外部指针 *Outer 能调什么 原因
Inner(值) 只有值方法 值方法 + 指针方法 不允许为嵌入的值类型字段自动生成指针接收者方法集 ;但指针可以 &o.Inner
*Inner(指针) 值方法 + 指针方法 值方法 + 指针方法 已经有指针了,解引用或直接用都行

核心逻辑一句话: 只有嵌入类型值类型外部类型 也是值类型 的时候是只能调用值方法。

推导原理

  • 嵌入 T(值):外部类型 Outer 包含一个 T 字段。Outer 值可以调用 T 的值接收者方法(直接复制接收者)。*Outer 可以调用 *T 的方法(先取地址 &o.T,再调用)。

  • 嵌入 *T(指针):Outer 值包含一个指针,可以通过指针调用 *T 的方法,也可以通过解引用调用 T 的方法。

补充(和前面的直接变量调指针方法的"自动取地址"做区分)

首先补充一点之前结构体讲的知识:

情况 A:直接变量(可寻址)------ 编译器会帮忙
Go 复制代码
type S struct{}
func (s *S) Foo() {}

func main() {
    var s S
    s.Foo()  // ✅ 可以!编译器自动变成 (&s).Foo()
}

这里 s可寻址的变量 (有内存地址),所以编译器能偷偷 &s

情况 B:直接字面量(不可寻址)------ 编译器不帮忙
Go 复制代码
S{}.Foo()  // ❌ 编译错误!S{}没有分配具体的地址,字面量不可寻址,编译器无法 &S{}

对于结构体的方法,不管是值类型的方法还是指针类型的方法,编译的时候都不会有问题的(可寻址的时候)。然后,对于嵌入字段(看下面的代码):

Go 复制代码
type Inner struct{}
func (i Inner) ValueMethod() {}
func (i *Inner) PtrMethod() {}

type Outer struct {
    Inner
}

var a1 interface{ ValueMethod() } = Outer{}  // ✅
var a2 interface{ PtrMethod() } = Outer{}    // ❌ 编译失败!
var a3 interface{ PtrMethod() } = &Outer{}   // ✅

方法集是类型的固有属性 ,在编译早期就确定了。直接变量调指针方法的"自动取地址"是编译器对单个表达式的语法糖 ;嵌入字段的方法提升是类型系统的静态规则 。前者发生在"写代码怎么调用",后者发生在"类型天生有什么能力"。不能用调用时的语法糖,去推导类型天生该有的方法集。

所以 注意注意**:** 不要弄混乱了两者直接的区别,一个是直接变量自己的方法,一个是嵌入字段提升的方法。

情况 C:嵌入字段 ------ 方法集规则说了算,不是语法糖
Go 复制代码
type Outer struct {
    Inner
}

func main() {
    var o Outer
    o.PtrMethod()  // ❌ 编译错误!
}

这里 o 也是可寻址变量,但 PtrMethodInner 的指针方法。嵌入提升有独立的静态规则Outer 值的方法集只包含 Inner 的值方法,不包含 *Inner 的指针方法。

2.4 组合的高级技巧

技巧 A:模拟"方法重写"

Go 复制代码
type Animal struct{}
func (a *Animal) Speak() { fmt.Println("animal sound") }

type Dog struct {
    *Animal
}

func (d *Dog) Speak() {
    fmt.Println("woof!")
    d.Animal.Speak() // 调用"父类"方法
}

func main() {
    d := &Dog{Animal: &Animal{}}
    d.Speak() // woof! \n animal sound
}

注意 :这不是真正的重写,因为 d.Animal.Speak() 是显式调用。如果通过接口调用:

Go 复制代码
var a *Animal = d.Animal
a.Speak() // 还是 "animal sound"

技巧 B:嵌入接口实现"必须实现某接口"的约束

Go 复制代码
type ReadWriter interface {
    io.Reader
    io.Writer
}

这是接口嵌入。结构体也可以嵌入接口:

Go 复制代码
type MyStruct struct {
    io.Reader  // 嵌入接口!
}

//strings.NewReader("hello") 返回的是 *strings.Reader,也就是 strings 包下的 Reader 结构体的指针。
//io.Reader是接口,将一个具体的指针赋值给了一个接口
// 使用:
var r io.Reader = strings.NewReader("hello")
s := MyStruct{Reader: r}
s.Read(buf) // 委托给内部的 Reader

用途:在 mock 测试或适配器模式中极其有用。

技巧 C:横向组合------一个结构体组合多个能力

Go 复制代码
type Server struct {
    *http.Server
    *log.Logger
    metrics *Metrics
}

Server 同时拥有了 HTTP 服务能力和日志能力,没有继承的耦合。

相关推荐
不动明王呀3 小时前
almalinux8.10用户添加到root权限笔记
笔记
不是光头 强3 小时前
Java 后端实战进阶:从踩坑到架构的系统化笔记
java·笔记·架构
叶~小兮3 小时前
ELK技术栈全套学习笔记(Elasticsearch+Logstash+Filebeat)
笔记·学习·elk
つ安静与叛逆的小籹人4 小时前
小红书笔记详情API实战总结(技术复盘)
笔记
sheeta19984 小时前
LeetCode 每日一题笔记 日期:2026.05.16 题目:154. 寻找旋转排序数组中的最小值 II
笔记·算法·leetcode
玄米乌龙茶1234 小时前
从 Token 到 API 调用: LLM 实战笔记
笔记
qeen874 小时前
【算法笔记】各种常见排序算法详细解析(下)
c语言·数据结构·c++·笔记·学习·算法·排序算法
姚不倒4 小时前
Go 语言基础入门:从零到实战,一篇文章掌握核心语法
云原生·golang
Yeh2020585 小时前
springboot+vue笔记
vue.js·spring boot·笔记