Go 语言没有class没有extends没有implements但封装、组合继承、多态这三大 OOP 特性Go 不仅全都有而且实现得更干净、更贴近底层硬件。多态在接口的章节单独讲第一特性封装Encapsulation1.1 宏观设计Go 的封装是命名约定而非访问修饰符Java/C 用private/protected/public关键字控制访问Go 用首字母大小写首字母大写导出的对本包外的世界可见。首字母小写未导出的包外不可见连反射都穿透不了除非你用unsafe走邪道。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就会报错a.balance undefined (cannot refer to unexported field or method balance)编译器内部发生了什么源代码 → 词法分析 → 语法分析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查看未导出符号在最终二进制中通常被标记为ddata或ttext但外部包在编译期就被拒绝根本走不到链接。1.3 封装的漏洞与技巧reflect 和 unsafereflect 可以绕过封装reflect能看到未导出字段的值但不能修改Go 1.6 的限制。这是编译器和运行时的双重保护。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工厂函数替代暴露的零值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接口作为防暴门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 这个接口生产环境用真实实现。技巧 CFunctional Options 模式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 宏观设计嵌入不是继承是委托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 命名字段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 指针嵌入// 值嵌入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。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 深入底层编译器如何处理嵌入内存布局type Inner struct { x int } type Outer struct { Inner y int }Outer在内存中的布局----------- | Inner.x | - 偏移 0 ----------- | Outer.y | - 偏移 864位系统 -----------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直接变量可寻址—— 编译器会帮忙type S struct{} func (s *S) Foo() {} func main() { var s S s.Foo() // ✅ 可以编译器自动变成 (s).Foo() }这里s是可寻址的变量有内存地址所以编译器能偷偷s。情况 B直接字面量不可寻址—— 编译器不帮忙S{}.Foo() // ❌ 编译错误S{}没有分配具体的地址字面量不可寻址编译器无法 S{}对于结构体的方法不管是值类型的方法还是指针类型的方法编译的时候都不会有问题的可寻址的时候。然后对于嵌入字段(看下面的代码)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嵌入字段 —— 方法集规则说了算不是语法糖type Outer struct { Inner } func main() { var o Outer o.PtrMethod() // ❌ 编译错误 }这里o也是可寻址变量但PtrMethod是Inner的指针方法。嵌入提升有独立的静态规则Outer值的方法集只包含Inner的值方法不包含*Inner的指针方法。2.4 组合的高级技巧技巧 A模拟方法重写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()是显式调用。如果通过接口调用var a *Animal d.Animal a.Speak() // 还是 animal sound技巧 B嵌入接口实现必须实现某接口的约束type ReadWriter interface { io.Reader io.Writer }这是接口嵌入。结构体也可以嵌入接口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横向组合——一个结构体组合多个能力type Server struct { *http.Server *log.Logger metrics *Metrics }Server同时拥有了 HTTP 服务能力和日志能力没有继承的耦合。