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:
-
查左边 :
a是什么类型?→user.Account -
查右边 :
balance是user.Account的字段吗?→ 是 -
查权限 :当前包是
main,字段所在包是user。balance首字母是小写b→ 未导出 -
直接报错 :
cannot refer to unexported field or method balance
底层机制:
-
AST 解析阶段:编译器识别标识符的首个字符。Go 使用 Unicode 判定,只要首字符是 Unicode 大写字母,就是导出的。
-
导出表(Export Data) :编译
.go文件时,导出的类型、变量、函数签名被写入.a归档文件的导出区。其他包编译时只读取这些导出符号,未导出符号对它们完全不可见。 -
链接器层面 :你可以用
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.Name或dog.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 的字段各自零值 |
*Animal 为 nil |
| 初始化 | 自动可用 | 必须手动 &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() 时:
-
名称查找 :在
Dog的方法集中找不到Speak。 -
提升查找 :检查嵌入字段(
*Animal)的方法集,找到了(*Animal).Speak。 -
调用重写 :编译器不生成包装函数 ,而是直接生成等价于
d.Animal.Speak()的代码。 -
地址计算 :生成指令计算
&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 也是可寻址变量,但 PtrMethod 是 Inner 的指针方法。嵌入提升有独立的静态规则 :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 服务能力和日志能力,没有继承的耦合。