Golang结构体与接口
Go 没有传统面向对象语言中的 class,但可以用结构体表示数据,用方法表示行为,用接口表示能力。Go 更强调组合和小接口,而不是继承和庞大抽象。
这部分是 Go 语言设计的核心:结构体负责承载状态,方法负责绑定行为,接口负责表达“我需要什么能力”。理解它们之后,才能写出清晰、可测试、不过度设计的 Go 项目。
学习目标
学完本页你应该能回答:
- struct 和 class 有什么区别。
- 值接收者和指针接收者怎么选。
- 方法集是什么,为什么它会影响接口实现。
- Go 为什么使用组合而不是继承。
- 接口为什么是隐式实现。
- 接口应该由使用方定义是什么意思。
any和类型断言怎么用。- interface nil 陷阱是怎么产生的。
struct 是什么
结构体用于把多个字段组合成一个类型。
type User struct {
ID int64
Name string
Age int
}创建结构体:
u := User{
ID: 1,
Name: "Tom",
Age: 18,
}结构体字段访问:
fmt.Println(u.Name)
u.Age = 20Go 的 struct 只是数据结构,不自带继承、构造方法、注解式框架生命周期等概念。它的行为通过方法来定义。
结构体零值
Go 很重视零值可用。
var u User
fmt.Println(u.ID) // 0
fmt.Println(u.Name) // ""字段零值:
| 类型 | 零值 |
|---|---|
| int | 0 |
| string | "" |
| bool | false |
| pointer | nil |
| slice | nil |
| map | nil |
设计结构体时,如果零值就能安全使用,代码会更简单。例如 sync.Mutex 的零值就可以直接使用。
方法是什么
方法是带接收者的函数。
func (u User) DisplayName() string {
return fmt.Sprintf("%d-%s", u.ID, u.Name)
}接收者写在函数名前面:
func (接收者 类型) 方法名(参数) 返回值调用:
name := u.DisplayName()值接收者和指针接收者
值接收者会复制一份值:
func (u User) Rename(name string) {
u.Name = name
}这个方法不会修改原对象。
指针接收者可以修改原对象:
func (u *User) Rename(name string) {
u.Name = name
}选择建议:
| 场景 | 建议 |
|---|---|
| 需要修改接收者 | 指针接收者 |
| 结构体较大,复制成本高 | 指针接收者 |
| 包含 Mutex 等不可复制字段 | 指针接收者 |
| 小的不可变值对象 | 值接收者可以 |
| 保持方法集一致 | 通常统一用指针接收者 |
注意:不要复制含锁结构体。
type Counter struct {
mu sync.Mutex
n int
}这种类型的方法通常都应该用指针接收者。
方法集和接口实现
方法集会影响一个类型是否实现接口。
type Renamer interface {
Rename(name string)
}
type User struct {
Name string
}
func (u *User) Rename(name string) {
u.Name = name
}这里 *User 实现了 Renamer,但 User 值本身没有实现。
var r Renamer
u := User{Name: "Tom"}
r = &u // 可以
// r = u // 编译失败为什么?因为 Rename 的接收者是 *User,只有 *User 的方法集包含它。
简单记忆:
| 类型 | 方法集 |
|---|---|
T | 只包含接收者为 T 的方法 |
*T | 包含接收者为 T 和 *T 的方法 |
组合优先于继承
Go 没有 class 继承,而是通过嵌入实现组合。
type Address struct {
City string
}
type User struct {
Name string
Address
}使用:
u := User{
Name: "Tom",
Address: Address{City: "Shanghai"},
}
fmt.Println(u.City)结构:
flowchart TD
A["User"] --> B["Name 字段"]
A --> C["嵌入 Address"]
C --> D["City 字段"]组合表达的是“包含”,不是“父子关系”。这能避免继承层级过深导致的复杂性。
嵌入和方法提升
嵌入结构体的方法可以被外层类型直接调用。
type Logger struct{}
func (Logger) Info(msg string) {
fmt.Println(msg)
}
type Service struct {
Logger
}
func main() {
s := Service{}
s.Info("hello")
}这叫方法提升。它不是继承,只是字段嵌入带来的语法便利。外层类型仍然可以定义同名方法覆盖调用行为。
接口是什么
接口描述一组方法。
type Reader interface {
Read(p []byte) (n int, err error)
}只要某个类型实现了接口中的所有方法,就自动实现该接口。不需要写 implements。
type FileReader struct{}
func (f FileReader) Read(p []byte) (int, error) {
return 0, nil
}Go 的接口是隐式实现。好处是类型和接口解耦,坏处是你需要通过编译器或显式断言确认是否实现。
显式编译期检查:
var _ Reader = (*FileReader)(nil)如果 *FileReader 没有实现 Reader,这行会编译失败。
接口调用流程
flowchart TD
A["业务代码依赖接口"] --> B["传入具体实现"]
B --> C["接口值保存动态类型和动态值"]
C --> D["调用接口方法"]
D --> E["执行具体类型的方法"]接口让业务代码依赖能力,而不是依赖具体实现。
接口应该由使用方定义
不要在实现方提前定义一堆大接口。
不推荐:
type AssetRepositoryInterface interface {
Save(ctx context.Context, asset Asset) (Asset, error)
FindByID(ctx context.Context, id int64) (Asset, error)
Delete(ctx context.Context, id int64) error
}如果某个 Service 只需要 Save,它应该定义最小接口:
type assetSaver interface {
Save(ctx context.Context, asset Asset) (Asset, error)
}
type AssetService struct {
saver assetSaver
}这样接口更小,测试更容易 Fake,也不会被实现方的大接口绑住。
小接口设计
Go 标准库里很多接口都很小:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}小接口的好处:
- 容易实现。
- 容易组合。
- 容易测试。
- 依赖更精准。
大接口的问题:
- 实现成本高。
- 测试 fake 麻烦。
- 调用方依赖了不需要的方法。
- 后续新增方法会影响所有实现。
any 和空接口
any 是 interface{} 的别名。
func Print(v any) {
fmt.Println(v)
}any 可以接收任意类型,但会丢失静态类型信息。能用明确类型或泛型时,不要滥用 any。
适合场景:
- JSON 解码后的未知结构。
- 日志字段。
- 通用容器。
- 与反射配合。
不适合场景:
- 明确业务参数。
- 领域模型字段。
- 为了省事绕过类型系统。
类型断言和 type switch
类型断言:
value, ok := v.(string)
if ok {
fmt.Println(value)
}不带 ok 的断言失败会 panic:
value := v.(string) // 如果不是 string,会 panictype switch:
switch val := v.(type) {
case string:
fmt.Println("string", val)
case int:
fmt.Println("int", val)
default:
fmt.Println("unknown")
}生产代码中,除非非常确定类型,否则使用 value, ok 或 type switch。
interface nil 陷阱
这是 Go 面试高频点。
接口值内部可以理解为两部分:
- 动态类型。
- 动态值。
只有动态类型和动态值都为空时,接口才等于 nil。
flowchart TD
A["interface value"] --> B["dynamic type"]
A --> C["dynamic value"]
B --> D{"type 是否为 nil"}
C --> E{"value 是否为 nil"}
D --> F["两者都 nil 才是 nil interface"]
E --> F示例:
type MyError struct{}
func (e *MyError) Error() string {
return "my error"
}
func returnsError() error {
var e *MyError = nil
return e
}
func main() {
err := returnsError()
fmt.Println(err == nil) // false
}为什么不是 nil?
因为 err 这个接口值里有动态类型 *MyError,动态值是 nil。接口本身并不是 nil。
正确做法:
func returnsError() error {
var e *MyError = nil
if e == nil {
return nil
}
return e
}商业场景:Service 依赖 Repository 接口
资产服务只关心“保存资产”的能力,不关心底层是 MySQL、MongoDB 还是内存。
type Asset struct {
ID int64
Name string
}
type assetSaver interface {
Save(ctx context.Context, asset Asset) (Asset, error)
}
type AssetService struct {
saver assetSaver
}
func (s *AssetService) Create(ctx context.Context, name string) (Asset, error) {
if name == "" {
return Asset{}, errors.New("asset name required")
}
return s.saver.Save(ctx, Asset{Name: name})
}测试时可以传 Fake:
type fakeSaver struct{}
func (f fakeSaver) Save(ctx context.Context, asset Asset) (Asset, error) {
asset.ID = 1
return asset, nil
}这就是接口的工程价值:不是为了“像 Java 一样每个类都有接口”,而是为了让使用方依赖最小能力,便于替换和测试。
常见坑
| 问题 | 后果 | 正确做法 |
|---|---|---|
| 值接收者修改字段 | 原对象不变 | 修改状态用指针接收者 |
| 混用值/指针接收者 | 方法集和接口实现混乱 | 通常保持一致 |
| 复制含 Mutex 的结构体 | 锁状态异常 | 使用指针传递 |
| 大接口 | 实现和测试困难 | 接口小而精 |
| 实现方提前定义接口 | 抽象过度 | 接口由使用方定义 |
| 滥用 any | 丢失类型安全 | 优先明确类型 |
| 类型断言不带 ok | panic | 使用 value, ok |
| 返回 typed nil error | err != nil | 返回前判断 nil |
面试标准回答
Go 有继承吗?
Go 没有传统 class 继承。Go 使用结构体嵌入和接口组合来实现复用和多态。组合表达“包含”关系,比深层继承更简单,也更符合 Go 的设计哲学。
值接收者和指针接收者怎么选?
需要修改接收者、结构体较大、包含 Mutex 等不可复制字段时,用指针接收者;小的不可变值对象可以用值接收者。为了方法集清晰,同一个类型的方法通常保持接收者风格一致。
Go 接口为什么是隐式实现?
只要类型实现了接口要求的所有方法,就自动实现该接口,不需要显式声明 implements。这让接口和实现解耦,调用方可以定义自己需要的最小接口,提高可测试性和灵活性。
interface nil 陷阱是什么?
接口值内部包含动态类型和动态值。只有两者都为空时接口才等于 nil。如果返回一个动态类型为 *MyError、动态值为 nil 的 error,接口本身不为 nil,所以 err == nil 是 false。
关联知识点
小结
Go 的结构体和接口体现了“组合优于继承”的思想。struct 保存数据,方法定义行为,接口表达能力。写项目时要让接口小而精,由使用方定义;方法接收者要理解方法集;遇到 any、类型断言和 interface nil 时要格外谨慎。
