Go 中 type、方法与指针接收者:从 str_name.Name() 看懂 Go 的类型系统

很多 Go 程序员都会写:

复制代码
type User struct {
    Name string
}

func (u *User) GetName() string {
    return u.Name
}

但如果问一句:

为什么方法前面要写 (u *User)

为什么方法不是写在 struct 里面?

为什么有时候用 User,有时候用 *User

为什么一个方法突然就决定了一个类型是否实现某个接口?

真正理解这些问题,你才算真正理解了 Go 的方法系统。

本文就从一个看起来非常简单的例子开始。


一、先看最简单的例子

假设我们定义一个类型:

复制代码
type str_name struct {
    name string
}

然后给它增加一个方法:

复制代码
func (h *str_name) Name() string {
    return h.name
}

调用:

复制代码
s := &str_name{name: "Tom"}

fmt.Println(s.Name())

输出:

复制代码
Tom

初学者通常会把它理解成:

str_name 有一个 Name() 方法。

这个理解没有错。

但还不够。

实际上,这两行代码背后包含了 Go 类型系统中非常重要的几个概念:

复制代码
type str_name struct {
    name string
}

func (h *str_name) Name() string {
    return h.name
}

分别涉及:

  1. type

  2. 自定义类型

  3. struct

  4. method

  5. receiver

  6. pointer receiver

  7. method set

  8. interface

我们一个一个拆开。


二、type str_name struct 到底是什么?

先看:

复制代码
type str_name struct {
    name string
}

可以把它粗略理解成:

创建了一个叫 str_name 的新类型。

例如:

复制代码
var s str_name

此时:

复制代码
s
│
└── 类型:str_name

它拥有一个字段:

复制代码
s.name

类型是:

复制代码
string

所以:

复制代码
s := str_name{
    name: "Tom",
}

就创建了一个 str_name 类型的值。


三、为什么 Go 要有 type

这是理解 Go 很关键的一步。

很多人会觉得:

复制代码
type User struct {
    Name string
}

就是"定义一个结构体"。

实际上更准确的说法是:

type 是在定义一个新的类型。

struct 只是这个类型的底层结构之一。

例如:

复制代码
type User struct {
    Name string
}

定义了一个 User 类型。

但是 Go 不仅可以基于 struct 创建类型。

还可以:

复制代码
type UserID int
type Username string
type Money int64
type Status int

例如:

复制代码
type UserID int64

此时:

复制代码
var id UserID = 100

UserID 是一个独立的命名类型。

它虽然底层是 int64,但语义上是一个新的类型。

这就是 Go 一个非常重要的设计:

用类型表达业务语义。


四、真正重要的事情来了:方法

现在:

复制代码
type str_name struct {
    name string
}

只有数据。

我们可以:

复制代码
s := str_name{
    name: "Tom",
}

但是如果我们希望:

复制代码
s.Name()

怎么办?

于是我们定义:

复制代码
func (h *str_name) Name() string {
    return h.name
}

这就是 Go 的 method(方法)

注意:

Go 的方法并不是这样定义:

复制代码
type str_name struct {
    name string

    func Name() string {
        ...
    }
}

这是错误的。

Go 的设计是:

复制代码
type str_name struct {
    name string
}

func (h *str_name) Name() string {
    return h.name
}

也就是说:

数据定义和行为定义是分开的。

这和 Java、C#、Kotlin 这种语言的 class 写法有明显区别。


五、func (h *str_name) 是什么鬼?

这是本文真正的核心。

看:

复制代码
func (h *str_name) Name() string {
    return h.name
}

这里:

复制代码
(h *str_name)

叫做:

receiver(接收者)

所以:

复制代码
func (h *str_name) Name()

可以理解成:

*str_name 这个类型定义一个叫 Name 的方法。

其中:

复制代码
h

是接收者变量。

而:

复制代码
*str_name

是接收者类型。

于是:

复制代码
h.name

实际上就是:

从当前 str_name 对象中读取 name 字段。


六、为什么叫 Receiver?

因为:

复制代码
s.Name()

实际上可以理解成:

复制代码
Name(s)

只不过 Go 把调用方式设计成:

复制代码
s.Name()

而方法内部:

复制代码
func (h *str_name) Name() string {
    return h.name
}

这里的:

复制代码
h

就是调用这个方法时传进来的那个对象。

所以:

复制代码
s.Name()

可以脑补成:

复制代码
s
│
│ 调用 Name()
▼
h
│
▼
h.name

这就是 receiver。


七、那么为什么是 *str_name

这又是一个非常重要的问题。

我们可以写:

复制代码
func (h str_name) Name() string {
    return h.name
}

也可以写:

复制代码
func (h *str_name) Name() string {
    return h.name
}

两者都合法。

区别在于:

复制代码
h str_name

是:

值接收者(value receiver)

而:

复制代码
h *str_name

是:

指针接收者(pointer receiver)


八、值接收者到底意味着什么?

例如:

复制代码
type User struct {
    Name string
}

func (u User) GetName() string {
    return u.Name
}

调用:

复制代码
u := User{
    Name: "Tom",
}

fmt.Println(u.GetName())

没有问题。

但是这里的 u 是一个值。

调用方法的时候,相当于把这个值传给方法。

可以粗略理解为:

复制代码
u
│
│ copy
▼
method receiver

也就是说:

值接收者天然带有"复制"的语义。


九、指针接收者又是什么?

改成:

复制代码
func (u *User) GetName() string {
    return u.Name
}

此时 receiver 是:

复制代码
*User

也就是:

User 的指针。

例如:

复制代码
u := User{
    Name: "Tom",
}

fmt.Println(u.GetName())

这里看起来非常神奇。

明明 u 是:

复制代码
User

为什么可以调用:

复制代码
u.GetName()

而方法要求:

复制代码
*User

因为 Go 会在适当情况下自动取地址。

可以近似理解成:

复制代码
(&u).GetName()

所以 Go 允许你写:

复制代码
u.GetName()

而不是强制:

复制代码
(&u).GetName()

这就是 Go 很舒服的地方。


十、什么时候必须使用指针接收者?

最重要的场景:

方法需要修改对象本身。

例如:

复制代码
type Counter struct {
    value int
}

func (c *Counter) Increment() {
    c.value++
}

使用:

复制代码
c := Counter{}

c.Increment()
c.Increment()

fmt.Println(c.value)

结果:

复制代码
2

为什么?

因为:

复制代码
c *Counter

拿到的是对象地址。

所以:

复制代码
c.value++

修改的是原来的对象。


十一、如果使用值接收者呢?

如果写成:

复制代码
func (c Counter) Increment() {
    c.value++
}

那么:

复制代码
c := Counter{}

c.Increment()

fmt.Println(c.value)

结果仍然是:

复制代码
0

因为:

复制代码
原对象
   │
   │ copy
   ▼
方法里的 c
   │
   ▼
修改副本

原来的 c 没有变化。

所以一个非常重要的经验:

需要修改 receiver 的状态,使用指针接收者。


十二、但是 Name() 明明不修改对象,为什么还可以用 *str_name

这就是很多 Go 项目里最容易让人困惑的地方。

例如:

复制代码
type User struct {
    name string
}

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

这里实际上没有修改:

复制代码
u

所以从纯技术角度来说:

复制代码
func (u User) Name() string

也是完全可以的。

那么为什么工程代码里大量使用:

复制代码
func (u *User) Name()

原因主要有三个。


十三、第一个原因:保持 receiver 一致

例如:

复制代码
type User struct {
    ID   int64
    Name string
}

可能有很多方法:

复制代码
func (u *User) Name() string {
    return u.Name
}

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

func (u *User) Save() error {
    ...
}

既然这个类型存在大量需要修改状态的方法,那么通常会统一使用:

复制代码
*User

作为 receiver。

这会让整个类型的 API 更一致。


十四、第二个原因:避免复制大型结构体

假设:

复制代码
type User struct {
    ID       int64
    Name     string
    Profile  [1024]byte
    Settings [4096]byte
}

如果:

复制代码
func (u User) Name() string

每次调用理论上都存在 receiver 值复制的语义。

而:

复制代码
func (u *User) Name() string

传递的是指针。

对于大型结构体来说,通常更合理。

当然,不要把它理解成:

"所有方法都必须用指针,否则性能就差。"

不是。

现代 Go 编译器会做大量优化。

应该首先考虑语义和 API 设计 ,而不是看到 struct 就机械地使用指针。


十五、第三个原因:Method Set

这才是最关键的。

Go 的接口系统依赖:

方法集(Method Set)

假设:

复制代码
type User struct {
    name string
}

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

那么:

复制代码
*User

拥有:

复制代码
Name()

这个方法。

而:

复制代码
User

的方法集和:

复制代码
*User

并不是完全一样。

这是 Go 接口系统非常核心的规则。


十六、一个接口就能看懂 Method Set

定义:

复制代码
type Namer interface {
    Name() string
}

然后:

复制代码
type User struct {
    name string
}

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

那么:

复制代码
var n Namer

u := User{name: "Tom"}

n = &u

没问题。

因为:

复制代码
*User
    │
    └── Name()
          │
          ▼
       Namer

所以:

复制代码
*User

实现了:

复制代码
Namer

十七、但是 User 呢?

注意:

复制代码
n = u

通常会报错。

原因就是:

复制代码
Name()

定义在:

复制代码
*User

上。

不是:

复制代码
User

上。

因此:

复制代码
User
 └── 不满足 Namer

*User
 └── 满足 Namer

这就是:

指针接收者会影响接口实现。

这是 Go 开发中非常重要的知识。


十八、如果改成值接收者呢?

改成:

复制代码
func (u User) Name() string {
    return u.name
}

那么情况就不同了:

复制代码
User
 └── Name()

*User
 └── Name()

因此:

复制代码
var n Namer

n = u

可以。

同时:

复制代码
n = &u

也可以。

所以可以记住一个非常实用的规律:

复制代码
值接收者方法
        │
        ├── User 可以调用
        └── *User 也可以调用

指针接收者方法
        │
        └── *User 才属于这个方法集

十九、这就是为什么 Go 的接口看起来"没有 implements"

Java:

复制代码
class User implements Namer

Go:

复制代码
type Namer interface {
    Name() string
}

然后:

复制代码
type User struct {
    name string
}

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

没有:

复制代码
implements

没有:

复制代码
extends

甚至没有:

复制代码
implements Namer

但只要方法满足:

复制代码
Name() string

它就自动实现:

复制代码
Namer

这叫:

Structural Typing(结构化类型)

也是 Go 接口设计最漂亮的地方之一。


二十、str_name 这个例子真正厉害的地方

回头看最开始:

复制代码
type str_name struct {
    name string
}

func (h *str_name) Name() string {
    return h.name
}

它其实已经体现了 Go 的一个非常强大的设计思想:

复制代码
             str_name
                │
        ┌───────┴────────┐
        │                │
      数据              行为
        │                │
    name string       Name()

Go 并没有把:

复制代码
数据
行为

强行塞进一个 class 概念里。

而是:

复制代码
type str_name struct {
    name string
}

负责定义数据。

然后:

复制代码
func (h *str_name) Name() string

负责定义行为。

这让 Go 的类型系统非常轻。


二十一、再往前一步:方法不一定只返回字段

例如:

复制代码
type User struct {
    firstName string
    lastName  string
}

func (u *User) Name() string {
    return u.firstName + " " + u.lastName
}

这里:

复制代码
Name()

并不是简单 getter。

它实际上表达了一个业务行为:

返回用户的完整名称。

这比:

复制代码
u.firstName + " " + u.lastName

散落在项目各处要好得多。

因此,在优秀的 Go 代码里:

方法不是为了给字段机械地加一个 getter。

而应该承担类型本身的行为。


二十二、Go 为什么没有强制 getter/setter?

Go 程序员经常看到:

复制代码
func (u *User) Name() string {
    return u.name
}

然后会问:

这不就是 Java 的 getName() 吗?

从形式上看确实类似。

但 Go 的习惯通常不会写:

复制代码
GetName()

而是:

复制代码
Name()

例如:

复制代码
func (u *User) Name() string
func (u *User) ID() int64
func (u *User) Status() Status

因为 Go 更倾向于:

方法名称表达"获取什么",而不是机械表达"get"。

所以:

复制代码
user.Name()

比:

复制代码
user.GetName()

更符合 Go 风格。


二十三、再看一个更真实的例子

例如一个订单:

复制代码
type Order struct {
    ID     int64
    Amount int64
    Status string
}

可以定义:

复制代码
func (o *Order) IsPaid() bool {
    return o.Status == "paid"
}

调用:

复制代码
if order.IsPaid() {
    ...
}

这比:

复制代码
if order.Status == "paid" {
    ...
}

更有价值。

因为:

复制代码
业务规则

被封装进:

复制代码
IsPaid()

以后状态判断可能变复杂:

复制代码
func (o *Order) IsPaid() bool {
    return o.Status == "paid" ||
           o.Status == "completed"
}

调用方完全不用修改。

这就是方法真正的价值。


二十四、方法本质上是"类型的行为"

所以我们可以建立一个更成熟的认识:

复制代码
type Order struct {
    ...
}

定义:

Order 是什么。

而:

复制代码
func (o *Order) IsPaid() bool
func (o *Order) Cancel() error
func (o *Order) Pay() error

定义:

Order 能做什么。

于是:

复制代码
Type = 数据 + 行为

虽然 Go 没有传统 class 那种语法,但从工程建模角度:

一个 Go 类型完全可以拥有非常丰富的行为。


二十五、那么 receiver 到底应该用值还是指针?

不要死记。

可以按照下面的原则判断。

第一原则:需要修改对象

使用:

复制代码
*Type

例如:

复制代码
func (u *User) Rename(name string) {
    u.Name = name
}

第二原则:结构体比较大

通常考虑:

复制代码
*Type

避免不必要的复制。


第三原则:类型内部包含不能复制的状态

例如:

复制代码
sync.Mutex

这类类型不能随便复制。

通常应该使用指针接收者。


第四原则:希望整个类型的方法保持一致

如果:

复制代码
User

的大多数方法都是:

复制代码
*User

那么新增方法通常也使用:

复制代码
*User

不要在同一个类型里随意混用。


第五原则:小型、不可变、值语义类型

可以考虑值接收者。

例如:

复制代码
type Point struct {
    X int
    Y int
}

func (p Point) Distance() float64 {
    ...
}

这种类型天然具有值语义。

值接收者非常合理。


二十六、一个非常重要的工程经验

不要把:

复制代码
*User

理解成:

"性能更好。"

也不要把:

复制代码
User

理解成:

"更简单。"

真正应该思考的是:

这个类型到底具有什么语义?

如果:

复制代码
User

代表一个具有身份、状态、生命周期的实体:

复制代码
type User struct {
    ID     int64
    Name   string
    Status string
}

通常:

复制代码
*User

更自然。

因为你实际上操作的是:

同一个 User 对象。

而不是不断复制 User。


二十七、一个更高级的例子:接口 + 指针接收者

现在我们定义:

复制代码
type Namer interface {
    Name() string
}

然后:

复制代码
type User struct {
    name string
}

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

我们可以:

复制代码
func PrintName(n Namer) {
    fmt.Println(n.Name())
}

调用:

复制代码
u := User{name: "Tom"}

PrintName(&u)

这就形成了一个非常典型的 Go 结构:

复制代码
        Namer
          │
       Name()
          ▲
          │
       *User
          │
       User data

调用方只关心:

复制代码
Name()

完全不需要知道:

复制代码
User

内部怎么实现。

这就是 Go 接口的真正价值:

依赖行为,而不是依赖具体类型。


二十八、为什么 Go 项目里大量出现这种代码?

你以后看 Go 项目,很可能看到:

复制代码
type UserRepository interface {
    FindByID(id int64) (*User, error)
    Save(user *User) error
}

然后:

复制代码
type userRepository struct {
    db *sql.DB
}

再:

复制代码
func (r *userRepository) FindByID(id int64) (*User, error) {
    ...
}

注意:

复制代码
userRepository

甚至可能是小写的。

但它通过方法:

复制代码
FindByID()
Save()

实现了:

复制代码
UserRepository

接口。

这就是 Go 非常典型的:

小类型 + 方法 + 接口组合

而不是:

巨大的 class hierarchy。


二十九、再看 Go 的嵌入

理解 receiver 之后,再看:

复制代码
type Base struct {
    Name string
}

func (b *Base) GetName() string {
    return b.Name
}

然后:

复制代码
type User struct {
    Base
}

此时:

复制代码
u := User{
    Base: Base{Name: "Tom"},
}

可以直接:

复制代码
u.GetName()

虽然:

复制代码
User

自己没有定义:

复制代码
GetName()

但是通过嵌入:

复制代码
Base

方法被提升了。

于是:

复制代码
User
 │
 └── Base
      │
      └── GetName()

这也是 Go 组合优于继承的重要体现。


三十、Go 真正的高级玩法:组合

很多传统面向对象程序员喜欢:

复制代码
Animal
  ↓
Mammal
  ↓
Dog
  ↓
GoldenRetriever

Go 更倾向于:

复制代码
Dog
 │
 ├── Name()
 ├── Move()
 ├── Bark()
 └── ...

再通过:

复制代码
type Animal interface {
    Move()
}

抽象行为。

核心思想是:

不要为了复用代码而建立继承体系。

而是:

通过组合复用实现,通过接口抽象行为。


三十一、回到最开始的 str_name

现在我们重新看:

复制代码
type str_name struct {
    name string
}

func (h *str_name) Name() string {
    return h.name
}

如果只是从语法层面理解:

str_name 有一个 Name() 方法。

这是初级理解。

如果理解 receiver:

*str_name 定义了 Name() 方法。

这是中级理解。

如果理解 method set:

*str_name 的方法集中包含 Name()

这是更深入的理解。

如果理解 interface:

任何要求 Name() string 的接口,都可以由 *str_name 实现。

这是接口层面的理解。

如果再理解类型设计:

Name() 不只是 getter,而是 str_name 对外暴露的行为契约。

这才是工程层面的理解。


三十二、最后建立一张脑图

以后看到:

复制代码
func (h *str_name) Name() string

不要再把它当成一行普通函数。

你应该在脑子里自动展开成:

复制代码
func
 │
 ├── 方法,而不是普通函数
 │
 └── receiver
       │
       ▼
    *str_name
       │
       ├── 指针接收者
       │
       ├── 方法属于 *str_name
       │
       ├── 可以访问 str_name 的字段
       │
       ├── 可以修改原对象
       │
       └── 参与 *str_name 的 method set
                  │
                  ▼
               interface

这条链路非常重要:

复制代码
type
  ↓
自定义类型
  ↓
method
  ↓
receiver
  ↓
value receiver / pointer receiver
  ↓
method set
  ↓
interface
  ↓
组合
  ↓
Go 的类型设计

三十三、真正值得记住的,不是语法

如果只记住:

复制代码
func (h *str_name) Name() string

那还不够。

真正应该记住的是下面这几句话。

1. type 是定义类型

复制代码
type User struct {}

不是简单地"声明一个 struct"。


2. Go 的方法通过 receiver 绑定到类型

复制代码
func (u *User) Name() string

这里的:

复制代码
(u *User)

就是 receiver。


3. *UserUser 的方法集不同

这是理解 Go interface 的关键。


4. 需要修改对象时,通常使用指针接收者

复制代码
func (u *User) Rename(name string)

5. 小型值类型可以使用值接收者

复制代码
func (p Point) Distance() float64

6. 接口描述的是行为

复制代码
type Namer interface {
    Name() string
}

它不关心你到底是:

复制代码
User
Customer
Employee
Admin

只要:

复制代码
Name() string

满足契约即可。


7. Go 的高级抽象不是继承,而是组合 + 接口

这是理解 Go 工程设计最重要的一步。


结语

Go 的语法其实非常少。

真正难的从来不是:

复制代码
func (u *User) Name() string

怎么写。

真正难的是理解:

为什么这个方法属于 *User 而不是 User
为什么接口不需要 implements
为什么一个 receiver 的选择,会影响接口实现?
什么时候应该使用指针?什么时候应该使用值?
为什么 Go 宁愿让类型通过方法表达行为,也不建立复杂的继承体系?

当你把:

复制代码
type
+
method
+
receiver
+
method set
+
interface
+
composition

串起来以后,你会发现:

Go 的"面向对象"其实一直都在,只是它把 class、继承、implements 这些传统 OO 语言的复杂外壳全部拆掉了。

最后留下来的只有三个东西:

复制代码
数据
行为
契约

也就是:

复制代码
type User struct {
    ...
}

func (u *User) Name() string {
    ...
}

type Namer interface {
    Name() string
}

这三个东西,基本就是 Go 类型系统的骨架。

而真正优秀的 Go 代码,本质上就是在不断回答一个问题:

这个行为,究竟应该属于哪个类型?

相关推荐
quantdash_cc2 小时前
Python 股票 K 线数据质量校验:字段、缺失值、重复行和价格异常
开发语言·python·数据分析·量化交易·股票数据·quantdash
Zenova EdgeOS2 小时前
工业网关重试机制:从固定间隔到指数退避的工程实战
开发语言·网络·php
嘻哈baby2 小时前
Go 函数中的参数为什么不支持默认值?
java·开发语言·jvm
一晌小贪欢2 小时前
python-第29天:Python面向对象之多态与抽象类
开发语言·python·数据可视化·面向对象·python办公·python多态
摇滚侠2 小时前
《SpringBoot 3:入门与应用实战》第 14 章 打包与部署 Spring Boot 应用打包 阅读笔记 41
spring boot·笔记·后端
Chester_19992 小时前
CSP202312C.树上搜索
开发语言·数据结构·c++·蓝桥杯·stl
Figo_Cheung2 小时前
Figo共振网络宇宙演化论(RNC) :从原初对称破缺到全息大和谐的演化路径研究
开发语言·php·量子计算
重生之我来学Python3 小时前
Git-SVN 混合开发,从入门到精通!
开发语言·git·svn·github
CoderYanger3 小时前
前端基础——JavaScript(基础语法)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展