第三讲:面向对象与接口——Go 语言的结构体、方法与鸭子类型
大家好,欢迎来到第三讲。今天我们来聊聊 Go 语言里“面向对象”这件事。
说实话,刚接触 Go 的时候,我挺不习惯的。没有 class,没有继承,连个构造函数都没有。我当时心里直犯嘀咕:这玩意儿能写大型项目?后来用久了才发现,Go 的面向对象思路其实更纯粹——它不搞那些花里胡哨的继承树,而是用组合、接口和鸭子类型来解决问题。说白了,就是“你只要会叫,你就是鸭子”。
3.1 结构体与方法:Go 的“类”
在 Go 里,我们用结构体来组织数据。你可以把它理解成其他语言的 class,但更轻量。
type User struct {
Name string
Age int
}
有了数据,自然要有操作数据的行为。Go 通过方法来实现——方法就是带接收者的函数。
3.1.1 值接收者 vs 指针接收者
这是新手最容易踩坑的地方。我当年刚学 Go 时,就因为在方法里改了结构体字段,结果外面没生效,排查了半天。
值接收者:方法操作的是结构体的副本。
func (u User) SetName(name string) {
u.Name = name // 改的是副本,原对象不变
}
指针接收者:方法操作的是原对象本身。
func (u *User) SetName(name string) {
u.Name = name // 直接修改原对象
}
核心原则:
- 如果要修改接收者的状态,必须用指针接收者
- 如果结构体很大(比如包含大数组),用指针接收者避免拷贝开销
- 如果方法不修改状态,值接收者和指针接收者都可以,但建议保持一致
我的习惯:我一般统一用指针接收者。除非这个结构体特别小(比如只有几个 int 字段),而且我明确知道不需要修改它。这样团队里大家看代码时不用猜——看到方法签名就知道会不会改原对象。
3.2 接口定义与实现:Go 的鸭子类型
Go 的接口和其他语言最大的不同在于:它是隐式实现的。你不需要写 implements 关键字,只要你的类型实现了接口里的所有方法,它就自动实现了这个接口。
type Speaker interface {
Speak() string
}
type Dog struct{}
func (d Dog) Speak() string {
return "汪汪!"
}
type Cat struct{}
func (c Cat) Speak() string {
return "喵喵~"
}
你看,Dog 和 Cat 都没有显式声明实现了 Speaker 接口,但它们都有 Speak 方法,所以它们就是 Speaker。这就是鸭子类型——"如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子"。
为什么我喜欢这种设计?
因为它解耦了接口定义和实现。你可以先写实现,后定义接口。甚至不同包里的类型,只要方法签名对上,就能满足同一个接口。这在写测试时特别爽——你可以轻松 mock 任何接口。
3.2.1 空接口与类型断言
空接口 interface{} 没有任何方法,所以任何类型都实现了它。说白了,它就像 Java 里的 Object,可以装任何东西。
var anything interface{}
anything = 42
anything = "hello"
anything = User{Name: "张三"}
但问题来了:你拿到的值到底是什么类型?这时候就需要类型断言。
value, ok := anything.(string)
if ok {
fmt.Println("是字符串:", value)
} else {
fmt.Println("不是字符串")
}
我曾经踩过的坑:有一次我直接从空接口取值,没做 ok 判断,结果类型不匹配直接 panic 了。从那以后,我只要用类型断言,必写 value, ok := ... 这种安全写法。别偷懒,真的。
还有一种更优雅的方式——类型 switch:
switch v := anything.(type) {
case string:
fmt.Println("字符串:", v)
case int:
fmt.Println("整数:", v)
default:
fmt.Println("未知类型")
}
3.3 嵌入与组合:Go 的“继承”
Go 没有传统的继承,但它提供了结构体嵌入。你可以把一个结构体嵌入到另一个结构体里,然后自动获得它的所有字段和方法。
type Animal struct {
Name string
}
func (a Animal) Eat() {
fmt.Println(a.Name, "正在吃饭")
}
type Dog struct {
Animal // 嵌入 Animal
Breed string
}
现在 Dog 的实例可以直接调用 Eat() 方法:
dog := Dog{Animal: Animal{Name: "旺财"}, Breed: "金毛"}
dog.Eat() // 输出:旺财 正在吃饭
这看起来很像继承,对吧?但 Go 的设计哲学是“组合优于继承”。嵌入不是真正的继承,它只是语法糖——编译器会自动把方法调用转发给嵌入的类型。
组合 vs 继承:
- 继承是“is-a”关系(狗是动物)
- 组合是“has-a”关系(狗有动物属性)
- Go 鼓励用组合,因为组合更灵活,不会产生复杂的继承链
3.3.1 方法提升与覆盖
嵌入类型的方法会被“提升”到外层结构体。如果外层结构体定义了同名方法,就会覆盖内层的方法。
func (d Dog) Eat() {
fmt.Println(d.Name, "正在吃狗粮")
}
// 现在 dog.Eat() 会调用 Dog 自己的方法,而不是 Animal 的
嗯,这里要注意:如果你需要调用被覆盖的内层方法,可以显式指定:dog.Animal.Eat()。
3.4 实战建议:什么时候用什么?
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 定义数据模型 | 结构体 + 指针接收者方法 | 方便修改状态,性能好 |
| 定义行为契约 | 小接口(1-3个方法) | 接口越小,复用性越强 |
| 需要多态 | 接口 + 类型断言 | 鸭子类型让代码更灵活 |
| 代码复用 | 结构体嵌入 | 组合优于继承,避免复杂层次 |
我的经验:写 Go 项目时,我习惯先定义接口,再写实现。但接口不要一开始就设计得太大——你想想看,一个接口有十几个方法,谁愿意去实现它?我一般先写 1-2 个方法,等确实需要扩展时再加。这样接口既干净又实用。
好了,这一讲的内容就到这里。Go 的面向对象思路其实很简单:用结构体组织数据,用方法定义行为,用接口实现多态,用组合代替继承。你只要记住“鸭子类型”这个核心思想,剩下的都是细节。
下一讲我们会聊聊错误处理——Go 没有 try-catch,那它怎么处理异常?到时候见。