Skip to content

Golang结构体与接口

Go 没有传统面向对象语言中的 class,但可以用结构体表示数据,用方法表示行为,用接口表示能力。Go 更强调组合和小接口,而不是继承和庞大抽象。

这部分是 Go 语言设计的核心:结构体负责承载状态,方法负责绑定行为,接口负责表达“我需要什么能力”。理解它们之后,才能写出清晰、可测试、不过度设计的 Go 项目。

学习目标

学完本页你应该能回答:

  1. struct 和 class 有什么区别。
  2. 值接收者和指针接收者怎么选。
  3. 方法集是什么,为什么它会影响接口实现。
  4. Go 为什么使用组合而不是继承。
  5. 接口为什么是隐式实现。
  6. 接口应该由使用方定义是什么意思。
  7. any 和类型断言怎么用。
  8. interface nil 陷阱是怎么产生的。

struct 是什么

结构体用于把多个字段组合成一个类型。

go
type User struct {
    ID   int64
    Name string
    Age  int
}

创建结构体:

go
u := User{
    ID:   1,
    Name: "Tom",
    Age:  18,
}

结构体字段访问:

go
fmt.Println(u.Name)
u.Age = 20

Go 的 struct 只是数据结构,不自带继承、构造方法、注解式框架生命周期等概念。它的行为通过方法来定义。

结构体零值

Go 很重视零值可用。

go
var u User
fmt.Println(u.ID)   // 0
fmt.Println(u.Name) // ""

字段零值:

类型零值
int0
string""
boolfalse
pointernil
slicenil
mapnil

设计结构体时,如果零值就能安全使用,代码会更简单。例如 sync.Mutex 的零值就可以直接使用。

方法是什么

方法是带接收者的函数。

go
func (u User) DisplayName() string {
    return fmt.Sprintf("%d-%s", u.ID, u.Name)
}

接收者写在函数名前面:

text
func (接收者 类型) 方法名(参数) 返回值

调用:

go
name := u.DisplayName()

值接收者和指针接收者

值接收者会复制一份值:

go
func (u User) Rename(name string) {
    u.Name = name
}

这个方法不会修改原对象。

指针接收者可以修改原对象:

go
func (u *User) Rename(name string) {
    u.Name = name
}

选择建议:

场景建议
需要修改接收者指针接收者
结构体较大,复制成本高指针接收者
包含 Mutex 等不可复制字段指针接收者
小的不可变值对象值接收者可以
保持方法集一致通常统一用指针接收者

注意:不要复制含锁结构体。

go
type Counter struct {
    mu sync.Mutex
    n  int
}

这种类型的方法通常都应该用指针接收者。

方法集和接口实现

方法集会影响一个类型是否实现接口。

go
type Renamer interface {
    Rename(name string)
}

type User struct {
    Name string
}

func (u *User) Rename(name string) {
    u.Name = name
}

这里 *User 实现了 Renamer,但 User 值本身没有实现。

go
var r Renamer
u := User{Name: "Tom"}

r = &u // 可以
// r = u // 编译失败

为什么?因为 Rename 的接收者是 *User,只有 *User 的方法集包含它。

简单记忆:

类型方法集
T只包含接收者为 T 的方法
*T包含接收者为 T*T 的方法

组合优先于继承

Go 没有 class 继承,而是通过嵌入实现组合。

go
type Address struct {
    City string
}

type User struct {
    Name string
    Address
}

使用:

go
u := User{
    Name: "Tom",
    Address: Address{City: "Shanghai"},
}
fmt.Println(u.City)

结构:

mermaid
flowchart TD
    A["User"] --> B["Name 字段"]
    A --> C["嵌入 Address"]
    C --> D["City 字段"]

组合表达的是“包含”,不是“父子关系”。这能避免继承层级过深导致的复杂性。

嵌入和方法提升

嵌入结构体的方法可以被外层类型直接调用。

go
type Logger struct{}

func (Logger) Info(msg string) {
    fmt.Println(msg)
}

type Service struct {
    Logger
}

func main() {
    s := Service{}
    s.Info("hello")
}

这叫方法提升。它不是继承,只是字段嵌入带来的语法便利。外层类型仍然可以定义同名方法覆盖调用行为。

接口是什么

接口描述一组方法。

go
type Reader interface {
    Read(p []byte) (n int, err error)
}

只要某个类型实现了接口中的所有方法,就自动实现该接口。不需要写 implements

go
type FileReader struct{}

func (f FileReader) Read(p []byte) (int, error) {
    return 0, nil
}

Go 的接口是隐式实现。好处是类型和接口解耦,坏处是你需要通过编译器或显式断言确认是否实现。

显式编译期检查:

go
var _ Reader = (*FileReader)(nil)

如果 *FileReader 没有实现 Reader,这行会编译失败。

接口调用流程

mermaid
flowchart TD
    A["业务代码依赖接口"] --> B["传入具体实现"]
    B --> C["接口值保存动态类型和动态值"]
    C --> D["调用接口方法"]
    D --> E["执行具体类型的方法"]

接口让业务代码依赖能力,而不是依赖具体实现。

接口应该由使用方定义

不要在实现方提前定义一堆大接口。

不推荐:

go
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,它应该定义最小接口:

go
type assetSaver interface {
    Save(ctx context.Context, asset Asset) (Asset, error)
}

type AssetService struct {
    saver assetSaver
}

这样接口更小,测试更容易 Fake,也不会被实现方的大接口绑住。

小接口设计

Go 标准库里很多接口都很小:

go
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

小接口的好处:

  1. 容易实现。
  2. 容易组合。
  3. 容易测试。
  4. 依赖更精准。

大接口的问题:

  1. 实现成本高。
  2. 测试 fake 麻烦。
  3. 调用方依赖了不需要的方法。
  4. 后续新增方法会影响所有实现。

any 和空接口

anyinterface{} 的别名。

go
func Print(v any) {
    fmt.Println(v)
}

any 可以接收任意类型,但会丢失静态类型信息。能用明确类型或泛型时,不要滥用 any

适合场景:

  1. JSON 解码后的未知结构。
  2. 日志字段。
  3. 通用容器。
  4. 与反射配合。

不适合场景:

  1. 明确业务参数。
  2. 领域模型字段。
  3. 为了省事绕过类型系统。

类型断言和 type switch

类型断言:

go
value, ok := v.(string)
if ok {
    fmt.Println(value)
}

不带 ok 的断言失败会 panic:

go
value := v.(string) // 如果不是 string,会 panic

type switch:

go
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 面试高频点。

接口值内部可以理解为两部分:

  1. 动态类型。
  2. 动态值。

只有动态类型和动态值都为空时,接口才等于 nil。

mermaid
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

示例:

go
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。

正确做法:

go
func returnsError() error {
    var e *MyError = nil
    if e == nil {
        return nil
    }
    return e
}

商业场景:Service 依赖 Repository 接口

资产服务只关心“保存资产”的能力,不关心底层是 MySQL、MongoDB 还是内存。

go
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:

go
type fakeSaver struct{}

func (f fakeSaver) Save(ctx context.Context, asset Asset) (Asset, error) {
    asset.ID = 1
    return asset, nil
}

这就是接口的工程价值:不是为了“像 Java 一样每个类都有接口”,而是为了让使用方依赖最小能力,便于替换和测试。

常见坑

问题后果正确做法
值接收者修改字段原对象不变修改状态用指针接收者
混用值/指针接收者方法集和接口实现混乱通常保持一致
复制含 Mutex 的结构体锁状态异常使用指针传递
大接口实现和测试困难接口小而精
实现方提前定义接口抽象过度接口由使用方定义
滥用 any丢失类型安全优先明确类型
类型断言不带 okpanic使用 value, ok
返回 typed nil errorerr != 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 时要格外谨慎。