Golang 方法
与多数现代编程语言类似,Go 允许为类型绑定方法(method)。此外,Go 还支持类型抽象(type abstraction),开发者可以编写调用方法的代码而无需显式指定具体实现。
然而,Go 语言处理方法(methods)、接口(interfaces)和类型(types)的方式与当今主流语言截然不同。它的设计哲学是倡导软件工程的最佳实践——避免继承(inheritance),推崇组合(composition)。本章我们将深入探讨类型、方法与接口,并掌握如何运用它们构建可测试且易维护的程序。
7.1 Go 语言中的类型
“3.5 结构体<52页>”一节中,了解了如何创建结构体类型:
type Person struct {
FirstName string
LastName string
Age int
}
这段代码应理解为:声明了一个名为 Person 的用户自定义类型,其底层类型是随后定义的结构体字面量(Literal)。除了结构体字面量外,还可以使用任何原生类型(Primitive Type)或复合类型字面量来定义具体类型。以下是一些示例:
type Score int
type Converter func(string) Score
type TeamScores map[string]Score
Go 语言允许在任意代码块层级声明类型——从包级作用域到最内层代码块均可。但需要注意的是,类型的作用域遵循常规可见性规则:只能在定义它的作用域范围内访问。唯一的例外是从其他包导入的类型(关于这点,将在“十 模块、包与导入<197页>”详细讨论)。
7.2 方法
与多数现代编程语言类似,Go 支持为用户定义的类型绑定方法(Method)。为类型绑定的方法是在包级别定义的,这意味着它们可以被该类型的所有实例调用。例如:
type Person struct {
FirstName string
LastName string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}
方法声明与函数声明类似,但增加了一部分:接收者(receiver)说明。接收者说明位于关键字 func 和方法名之间。与其他所有变量声明一样,接收者名称位于类型名称之前。按照惯例,接收者名称是类型名称的简短缩写,通常是类型名称的首字母。使用 this 或 self 是非惯用做法。
声明方法与声明函数之间有一个关键区别:方法只能在包级别定义,而函数可以在任何块级别内定义。
与函数名一样,方法名也不能被重载。可以对不同类型的方法使用相同的方法名,但不能对同一个类型的两个不同方法使用相同的方法名。虽然对于那些来自具有方法重载的语言的人来说,这种理念可能感觉有些限制,但不重复使用名称是 Go 让代码意图清晰的哲学的一部分。
将在“十 模块、包与导入<197页>”更详细地讨论包,但请注意,方法必须与其关联的类型在同一包中声明;Go 不允许为不受控制的类型绑定方法。虽然可以在同一包的不同文件中定义方法(该包包含类型声明),但最好将类型定义及其相关方法放在一起,以便于理解代码。
方法调用对于使用过其他语言方法的人来说应该看起来很熟悉:
p := Person{
FirstName: "Fred",
LastName: "Fredson",
Age: 52,
}
output := p.String()
7.2.1 指针接收者(Pointer Receiver)与值接收者(Value Receiver)
正如在“六 指针<105页>”中介绍的,Go 使用指针类型的参数来表示该参数可能会被函数修改,方法接收者也是如此。方法接收者可以是指针类型(即指针接收者),也可以是值类型(即值接收者)。以下规则可以帮助确定何时该使用哪种类型的接收者:
- 如果方法修改了接收者,则必须使用指针接收者,否则仅仅修改了一份拷贝。
- 如果方法需要处理
nil实例(见“7.2.2 为 nil 实例编写方法<131页>”),则必须使用指针接收者。 - 如果方法不修改接收者,则可以使用值接收者。
是否为不修改接收者的方法使用值接收者,取决于该类型上声明的其他方法。当类型有任何指针接收者方法时,一个常见的做法是与该方法保持一致,并为所有方法(即使是那些不修改接收者的方法)都使用指针接收者。
- 值接收者:总是拷贝外部变量
- 指针接收者:总是接受外部变量的地址
变量本身是值还是指针不重要,Go 会自动处理取址和解引用。
实际上把接收者当成一个函数参数理解就行了,这个概念类似于 C++ 中成员函数有一个隐藏的 this 指针参数表示调用的变量。
以下是一些简单的代码,用于演示指针接收者(Pointer Receiver)和值接收者(Value Receiver)。该代码从一个类型开始,该类型有两个方法,一个使用值接收者,另一个使用指针接收者:
type Counter struct {
total int
lastUpdated time.Time
}
func (c *Counter) Increment() {
c.total++
c.lastUpdated = time.Now()
}
func (c Counter) String() string {
return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}
然后,使用如下代码尝试这些方法。可以在 Go Playground 上或本章资源库的 sample_code/pointer_value 目录中尝试这些代码。
var c Counter
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())
运行上述代码,将得到如下输出:
total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001
如您所见,即使变量 c 是值类型,仍然能够调用指针接收者(Pointer Receiver)方法。当在调用方法时使用一个值类型的局部变量作为指针接收者,Go 会在调用方法时自动获取局部变量的地址。在这种情况下,
c.Increment()
被转换为:
(&c).Increment()
当在指针变量上调用值接收者时,Go 会在调用方法时自动解引用指针。在如下代码中:
c := &Counter{}
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())
c.String() 被转换为 (*c).String()。
如果使用值为
nil的指针实例调用一个值接收器方法,代码可以正常编译,但在运行时将引发panic。有关panic的详细介绍,参见“9.9 panic 与 recover<194页>”。
请注意,函数参数传递的规则仍然适用。如果将值类型传递给函数,并在该传递的值上调用指针接收者方法,实际上是在调用该副本的方法。
func doUpdateWrong(c Counter) {
c.Increment()
fmt.Println("in doUpdateWrong:", c.String())
}
func doUpdateRight(c *Counter) {
c.Increment()
fmt.Println("in doUpdateRight:", c.String())
}
func main() {
var c Counter
doUpdateWrong(c)
fmt.Println("in main:", c.String())
doUpdateRight(&c)
fmt.Println("in main:", c.String())
}
运行上述代码,将得到如下输出:
in doUpdateWrong: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
m=+0.000000001
in main: total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
in doUpdateRight: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
m=+0.000000001
in main: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001
doUpdateRight 函数的参数类型是 *Counter(一个指针实例)。如你所见,既可以在指针上面调用 Increment 方法,也可以调用 String 方法。Go 语言认为指针接收者和值接收者方法都属于指针实例的方法集。而对于值实例,只有值接收者方法属于其方法集。目前看来这似乎是个过于较真的细节,但稍后讨论接口时会再展开说明。
这一细节可能会让 Go 语言初学者感到困惑。但 Go 语言在指针类型和值类型之间的自动转换纯粹是一种语法糖(Syntactic Sugar),与方法集的概念无关。Alexey Gronskiy 写过一篇博客文章 Summary to the difference between T and T* method sets in Go,详细探讨了为什么指针实例的方法集会同时包含指针接收者和值接收者方法,而值实例的方法集却只包含值接收者方法。
最后一点注意事项:不要为 Go 的结构体编写 getter 和 setter 方法,除非你需要它们来实现某个接口(有关接口的介绍,详见“7.6 接口快速入门<140页>”)。Go 鼓励直接通过 struct.Field 访问字段,而方法应该用于封装业务逻辑(如计算、验证等),而非简单的字段存取。需要方法替代直接赋值的两种典型场景:
- 原子性操作:需要以单一操作同时更新多个关联字段(避免状态不一致)。例如前文的
Increment()方法。 - 复杂操作:更新操作不是简单的新值赋值,可能包含校验(如年龄不能为负数)、触发事件(如数据库回调)或线程安全控制等。例如前文的
Increment()方法。
7.2.2 为 nil 实例编写方法
上一节讨论了指针接收者,这可能会让你好奇:如果对 nil 实例调用方法会发生什么?在大多数语言中,这类操作会引发某种错误(例如空指针异常)。不过 Objective-C 允许对 nil 调用方法,只是永远不会有任何实际效果。
Go 语言对 nil 实例调用方法的处理机制非常独特。与其他语言直接报错不同,Go 会真实尝试执行方法调用。如果方法的接收者是值类型(值接收者),将会引发 panic,因为指针没有指向任何值。如果方法的接收者是指针类型(指针接收者),则方法调用可以正常执行——但前提是该方法内部已经妥善处理了 nil 的情况(比如做了 nil 判断)。
在某些情况下,合理利用 nil 接收者反而能让代码更简洁。下面是一个二叉树的实现示例,它充分利用了接收者为 nil 的特性:
type IntTree struct {
val int
left, right *IntTree
}
func (it *IntTree) Insert(val int) *IntTree {
if it == nil {
return &IntTree{val: val}
}
if val < it.val {
it.left = it.left.Insert(val)
} else if val > it.val {
it.right = it.right.Insert(val)
}
return it
}
func (it *IntTree) Contains(val int) bool {
switch {
case it == nil:
return false
case val < it.val:
return it.left.Contains(val)
case val > it.val:
return it.right.Contains(val)
default:
return true
}
}
这个
Contains方法虽然不会修改*IntTree,但仍然使用了指针接收者。这正体现了之前提到的语法规则:指针接收者方法能够支持 nil 接收者。
以下代码演示了二叉树的使用。
func main() {
var it *IntTree
it = it.Insert(5)
it = it.Insert(3)
it = it.Insert(10)
it = it.Insert(2)
fmt.Println(it.Contains(2)) // true
fmt.Println(it.Contains(12)) // false
}
Go 允许在 nil 接收者上调用方法的特性确实很巧妙,在某些场景下(如前文的二叉树节点示例)非常实用。但多数情况下,这个特性的实际用途有限。指针接收者(Pointer Receiver)的工作机制类似于函数中的指针参数——传入方法的是指针的副本。就像函数中的 nil 参数一样,即使修改了这个指针副本,原始指针依然不会改变。这意味着:无法通过指针接收者方法在处理 nil 的同时,使原始指针变得非 nil。
如果指针接收者(Pointer Receiver)方法无法处理 nil 接收者,则必须明确决定如何处理这种情况。以下是两种典型处理方案:
- 当 nil 接收者代表程序逻辑的严重错误时(类似切片越界访问),则应主动触发
panic。需要配合单元测试,详见“十五 编写测试用例<327页>”。 - 当 nil 接收者是可预见的合法场景时(如文件操作),则应检查 nil 并返回错误对象。有关错误处理的介绍,详见“九 错误处理<181页>”。
7.2.3 方法也是函数
Go 语言中的方法与函数如此相似,以至于只要存在函数类型的变量或参数时,都可以将方法作为函数的替代品使用。
让我们从如下这一简单类型开始:
type Adder struct { // 定义结构体
start int
}
func (a Adder) AddTo(val int) int { // 定义方法
return a.start + val
}
可以按常规方式创建该类型的实例,并调用该实例的方法:
myAdder := Adder{start: 10}
fmt.Println(myAdder.AddTo(5)) // prints 15
还可以将方法分配给变量,或者将方法传递给 func(int) int 类型的参数。这称为方法值(Method Value):
myAdder := Adder{start: 10} // 创建实例
f1 := myAdder.AddTo // 将方法分配给变量
fmt.Println(f1(10)) // prints 20
方法值(Method Value)有点类似于闭包,因为它可以访问创建它的实例中的字段值。
可以直接从类型(而非实例)本身创建一个函数,即直接引用类型中的方法。这被称为方法表达式(Method Expression)¹。
myAdder := Adder{start: 10} // 创建实例
f2 := Adder.AddTo // 方法表达式,通过类型(而非实例)直接引用方法
fmt.Println(f2(myAdder, 15)) // prints 25
在方法表达式(Method Expression)中,第一个参数是方法的接收者;函数签名为 func(Adder, int) int。
方法值(Method Value)和方法表达式(Method Expression)的详细介绍的边缘特性:“7.16 隐式接口使依赖注入更简单<155页>”一节,将会介绍二者的一种实际应用场景——依赖注入(Dependency Injection,DI)。
¹ 方法值(如
p.Distance)译为“从实例创建的函数”,而方法表达式译为“从类型创建的函数”,以区分两者差异。
7.2.5 类型声明不是继承
除了可以基于 Go 内置的原生类型(Primitive Type)或结构体字面量(Literal)声明类型之外,还可以基于用户定义类型声明另一个新类型。例如:
type Score int // 基于 int 类型,声明 Score 类型
type HighScore Score // 基于 Score 类型,声明一个新类型
type Employee Person
在众多可被归类为“面向对象”的概念中,继承(Inheritance)是最核心的特性之一。通过继承,子类型(Child Type)可以自动获得父类型(Parent Type)的状态(字段)和方法,且子类型的值能够替代父类型使用(即“子类型即父类型”的替换原则)。
在 Go 语言中,基于现有类型声明新类型(如 type HighScore Score)看似类似继承,实则完全不同。这两种类型(HighScore 与 Score)只是拥有相同的基础类型,仅此而已。二者之间没有层次结构(无依赖关系)。在具有继承的语言(如 Java)中,子实例可以在使用父实例的任何位置使用。子实例还具有父实例的所有方法与数据结构。但在 Go 语言中,若要将 HighScore 类型的实例分配给 Score 类型的变量,必须先进行类型转换,反之亦然。同样,也不能在没有类型转换的情况下,将 Score 或 HighScore 类型的实例赋值给 int 类型变量。除此之外,在 Score 上定义的任何方法都不会在 HighScore 上定义:
// 无类型常量(untyped constants)可以直接赋值给变量
var i int = 300
var s Score = 100
var hs HighScore = 200
hs = s // 编译错误,需要类型转换!
s = i // 编译错误,需要类型转换!
s = Score(i) // ok
hs = HighScore(s) // ok
在 Go 语言中,基于内置类型(built-in types)定义的自定义类型(user-defined types)具有以下关键特性:
- 字面量和常量赋值:只要字面量或常量与底层类型兼容,就可以直接赋值给自定义类型的变量,无需显式类型转换(见 Golang 常量的无类型特性 - libilibi)。
- 运算符继承:自定义类型自动继承底层类型的所有运算符(如
+、-、==等),可直接参与运算。 - 类型安全隔离:尽管支持运算符和常量赋值,自定义类型与底层类型仍是完全独立的类型,需显式转换才能相互操作。
var s Score = 50
scoreWithBonus := s + 100 // scoreWithBonus 的类型为 Score
共享相同底层类型的类型转换具有如下特性:
- 保持相同的底层存储结构;
- 绑定不同的方法集:即转换后的新类型将失去原类型的所有方法,只保留新类型自身定义的方法。
7.2.6 类型是可执行文档
在编程实践中,虽然人们普遍理解应该通过结构体(struct)来组织相关数据集,但何时需要基于内置类型或现有用户自定义类型声明新类型往往存在困惑。简而言之,类型本质上是代码的文档化工具——通过类型来命名抽象概念并明确定义数据使用意图,使代码具备更强的可读性和约束性。例如,当方法参数类型为 Percentage 而非 int 时,不仅能让读者更直观地理解参数含义,还能有效避免传递无效值的风险。
上述逻辑也适用于基于一个用户定义类型声明另一个类型。例如,当两种类型需要具有相同的基础数据结构,但要执行不同的操作时,可以创建两种类型——基于一个类型声明另一个类型,可以共享底层数据结构(如 int),但各自拥有独立的方法集。
评论