ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

GO [ 方法 ]

GO [ 方法 ] 方法前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串、映射表、指针、结构体和函数。接下来开始学习 Go 语言中把行为绑定到类型上的机制方法method。很多初学者会把方法直接理解成“类里面的函数”。这个理解可以帮助入门但 Go 的方法和传统面向对象语言中的成员函数有一个关键区别Go 没有 class方法通过接收者receiver绑定到一个已定义的类型上类型是否实现接口取决于它的方法集。按照 Go 官方语言规范Method declarations 的定义方法就是带有接收者的函数。简单理解普通函数说明“这段逻辑接收什么参数”方法额外说明“这段逻辑属于哪个类型”接收者是值还是指针决定了方法能否修改原对象也会影响接口实现。【这是需要注意的】本章按照“官方定义 → 基本方法 → 值接收者和指针接收者 → 方法集 → 接口实现 → 方法值与方法表达式 → 嵌入和提升 → 泛型类型方法 → 自定义数据结构实现”的顺序展开重点学习方法声明和接收者值接收者与指针接收者可寻址值的自动取地址T 和 *T 的方法集方法集与接口的隐式实现nil 指针接收者方法值和方法表达式结构体嵌入后的方法提升给切片、map 和泛型类型定义方法使用方法实现一个泛型栈。本文代码在 go1.27.0 darwin/arm64 环境中实际编译运行。地址、内存大小和系统相关输出可能因环境不同而变化。方法的基本声明方法的声明形式和函数很像只是函数名之前多了接收者func (接收者 接收者类型) 方法名(参数列表) 返回值列表 { // 方法体 }例如定义一个矩形类型再给它添加计算面积的方法package main import fmt type Rectangle struct { Width float64 Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height } func main() { rectangle : Rectangle{Width: 3, Height: 4} fmt.Println(面积:, rectangle.Area()) }运行结果面积: 12这里的 r 是方法接收者类型是 Rectangle。调用 rectangle.Area() 时Go 会把 rectangle 作为接收者传给 Area 方法。可以把它近似理解成下面这个普通函数func area(r Rectangle) float64 { return r.Width * r.Height }但方法比普通函数多了一个明确的类型归属。调用者看到 rectangle.Area()马上就能知道这个行为属于 Rectangle。方法只能绑定到已定义的本地类型Go 允许给当前包中定义的类型添加方法但不能给其他包已经定义的类型直接添加方法type Celsius float64 func (c Celsius) Fahrenheit() float64 { return float64(c)*9/5 32 }Celsius 是当前代码定义的新类型所以可以声明 Fahrenheit 方法。下面这种写法不允许// func (s string) First() byte { ... } // 编译错误不能给非本地类型 string 添加方法同样不能给来自其他包的类型添加方法// func (t time.Time) YearText() string { ... } // 编译错误不能给非本地类型添加方法如果确实需要为外部类型增加行为可以定义一个新的本地类型或使用组合type MyTime time.Time不过新类型不会自动继承原类型的方法。Go 官方规范明确指出定义一个新类型只会得到新的类型身份不会把原类型的方法直接复制过来。给非结构体类型定义方法方法接收者不要求必须是结构体。只要接收者是当前包定义的非指针、非接口类型就可以绑定方法package main import fmt type Celsius float64 func (c Celsius) Fahrenheit() float64 { return float64(c)*9/5 32 } func (c Celsius) String() string { return fmt.Sprintf(%.1f°C, c) } func main() { temperature : Celsius(25) fmt.Println(temperature) fmt.Println(华氏温度:, temperature.Fahrenheit()) }运行结果25.0°C 华氏温度: 77这里的 Celsius 底层类型是 float64但它仍然是一个独立的已定义类型可以拥有自己的方法。这个能力常用于给数字、字符串、切片等类型补充符合业务语义的行为。值接收者值接收者的写法是func (r Rectangle) Area() float64 { return r.Width * r.Height }调用方法时接收者会以值的形式传入方法拿到的是一份副本。如果方法只读取数据值接收者通常很合适package main import fmt type Counter struct { Value int } func (c Counter) Add(value int) { c.Value value } func main() { counter : Counter{Value: 10} counter.Add(5) fmt.Println(counter.Value) }运行结果10Add 方法内部确实执行了 c.Value value但 c 是 Counter 的副本所以原来的 counter.Value 没有改变。值接收者并不是“不能修改任何东西”。它可以修改副本中的字段只是这些修改会随着方法返回而丢失。如果结构体中包含切片、map 或指针字段值接收者复制的是这些字段的描述符或指针底层数据仍然可能被修改type Report struct { Tags []string } func (r Report) RenameFirstTag(name string) { r.Tags[0] name }这里 r 本身是副本但 r.Tags 可能和原对象共享底层数组因此修改元素仍然可能影响调用者。这种行为要特别小心。指针接收者指针接收者的写法是func (c *Counter) Add(value int) { c.Value value }指针接收者拿到的是对象地址可以修改原对象也可以避免复制较大的结构体package main import fmt type Counter struct { Value int } func (c *Counter) Add(value int) { c.Value value } func main() { counter : Counter{Value: 10} counter.Add(5) fmt.Println(counter.Value) }运行结果15调用 counter.Add(5) 时counter 是一个可寻址变量。Go 会自动把这次调用转换成(counter).Add(5)因此下面两种调用等价counter.Add(5) (counter).Add(5)这只是方法调用时的便利规则。普通函数不会自动取地址func add(counter *Counter, value int) { counter.Value value } add(counter, 5) // 正确 // add(counter, 5) // 编译错误指针接收者的选择理由下面几种情况通常应该使用指针接收者方法需要修改接收者结构体较大不希望每次调用都复制类型中包含互斥锁、不能复制的状态或其他不适合复制的字段这个类型的大部分方法已经使用指针接收者希望保持方法集和调用方式一致。如果一个类型的所有方法都只读取数据并且类型很小值接收者会更直观。实际项目中接收者选择应该保持一致不要让同一个类型的方法一半使用值、一半使用指针却没有明确理由。接收者为 nil 的方法指针接收者可能是 nil。方法调用本身不一定立即 panic是否 panic 取决于方法体有没有访问 nil 指针指向的字段package main import fmt type Logger struct { Prefix string } func (l *Logger) Name() string { if l nil { return nil logger } return l.Prefix } func (l *Logger) Print(message string) { fmt.Println(l.Prefix message) } func main() { var logger *Logger fmt.Println(logger.Name()) // logger.Print(hello) // panic方法体访问了 nil 接收者的字段 }运行结果nil logger允许 nil 接收者有时很有用例如链表节点、树节点或者“空对象”语义。但如果方法不支持 nil就应该在文档中明确说明或者在方法内部主动检查并返回错误。方法集方法集是学习 Go 方法时最容易混淆、也最重要的概念之一。按照 Go 官方语言规范Method sets已定义类型 T 的方法集包含所有接收者为 T 的方法类型 *T 的方法集包含接收者为 T 和 *T 的方法接口的方法集由接口声明的方法组成并由满足接口类型集合的交集决定。可以用下面的表格记忆类型方法集包含T接收者为 T 的方法*T接收者为 T 和 *T 的方法interface接口声明的方法例如type Counter struct { Value int } func (c Counter) ValueOf() int { return c.Value } func (c *Counter) Add(value int) { c.Value value }那么Counter 的方法集只有 ValueOf*Counter 的方法集包含 ValueOf 和 Add。这也是为什么指针类型比值类型“拥有更多方法”。方法调用时编译器可以在可寻址值上自动取地址但把一个值放进接口时不会为了满足接口而偷偷创建一个新的指针。方法集与接口实现Go 的接口是隐式实现的。一个类型只要拥有接口要求的全部方法就实现了这个接口不需要显式写 implements。package main import fmt type Speaker interface { Speak() string } type Dog struct { Name string } func (d Dog) Speak() string { return d.Name : wang } type Robot struct { Name string } func (r *Robot) Speak() string { return r.Name : beep } func announce(speaker Speaker) { fmt.Println(speaker.Speak()) } func main() { dog : Dog{Name: 旺财} robot : Robot{Name: R2} announce(dog) announce(robot) }运行结果旺财: wang R2: beepDog 的 Speak 使用值接收者所以 Dog 和 *Dog 都实现 Speaker。Robot 的 Speak 使用指针接收者所以只有 *Robot 实现 SpeakerRobot 本身不能直接传给 announce。可以用编译期断言把这种关系写得更明确var _ Speaker Dog{} var _ Speaker (*Robot)(nil)如果以后修改了方法签名编译器会在这里立刻报错而不是等到某个业务调用路径才发现问题。值和指针赋给接口的差异下面的代码不能编译var robot Robot // var speaker Speaker robot // 编译错误 var speaker Speaker robot // 正确原因不是 Robot 没有 Speak 方法而是 Robot 的方法集不包含指针接收者方法。对于一个可寻址的普通变量robot.Speak() 这样的调用可以自动取地址但接口保存的是一个已经确定的值编译器不能再替你改变它的动态类型。【核心注意点】接口中的值接收者副本值接收者方法会接收接口中保存值的副本package main import fmt type Counter struct { Value int } type Incrementer interface { Increment() } func (c Counter) Increment() { c.Value } func main() { counter : Counter{Value: 10} var incrementer Incrementer counter incrementer.Increment() fmt.Println(counter.Value) }运行结果10如果希望通过接口修改原对象接口中保存的应该是指针并且方法使用指针接收者func (c *Counter) Increment() { c.Value } counter : Counter{Value: 10} var incrementer Incrementer counter incrementer.Increment() fmt.Println(counter.Value) // 11方法值和方法表达式方法还可以像函数一样被保存和传递。方法值如果写成 object.Method得到的是方法值。接收者已经绑定在这个函数值里后续调用时不需要再次传入接收者package main import fmt type Counter struct { Value int } func (c *Counter) Add(value int) { c.Value value } func main() { counter : Counter{Value: 10} add : counter.Add add(5) fmt.Println(counter.Value) }运行结果15add 保存了 counter 这个接收者。由于 Add 是指针接收者方法add 调用时仍然会修改原来的 counter。值接收者的方法值会保存接收者的一份副本package main import fmt type Counter struct { Value int } func (c Counter) ValueOf() int { return c.Value } func main() { counter : Counter{Value: 10} valueOf : counter.ValueOf counter.Value 20 fmt.Println(valueOf()) }运行结果10方法值创建时值接收者已经被求值并保存所以之后修改 counter 不会影响 valueOf 保存的副本。方法表达式方法表达式把接收者变成普通函数的第一个参数package main import fmt type Counter struct { Value int } func (c *Counter) Add(value int) { c.Value value } func main() { counter : Counter{Value: 10} add : (*Counter).Add add(counter, 5) fmt.Println(counter.Value) }运行结果15(*Counter).Add 的函数类型相当于 func(*Counter, int)。方法表达式适合把方法传给只接收普通函数的 API也能帮助我们理解方法的本质方法只是带有接收者参数的函数。结构体嵌入和方法提升结构体可以嵌入另一个类型。嵌入后内部类型的方法可能会被外层类型提升package main import fmt type Logger struct { Prefix string } func (l Logger) Print(message string) { fmt.Println(l.Prefix message) } type Service struct { Logger Name string } func main() { service : Service{ Logger: Logger{Prefix: [API] }, Name: user, } service.Print(启动) service.Logger.Print(直接调用) }运行结果[API] 启动 [API] 直接调用service.Print(启动) 实际上使用了嵌入字段 Logger 的 Print 方法。这个方法提升让组合对象可以复用内部类型的行为。但嵌入不是继承Service 不是 Logger 的子类型Service 不会自动拥有 Logger 的类型身份提升方法的接收者仍然是内部 Logger如果外层定义了同名方法外层方法会优先。type Service struct { Logger } func (s Service) Print(message string) { fmt.Println([Service], message) }这时调用 service.Print() 会选择 Service 自己的 Print。若需要调用内部方法可以显式写 service.Logger.Print()。给切片和 map 定义方法方法不只适用于结构体。为切片定义一个命名类型就可以给它添加面向业务的操作package main import fmt type Scores []int func (scores Scores) Total() int { total : 0 for _, score : range scores { total score } return total } func (scores Scores) Average() float64 { if len(scores) 0 { return 0 } return float64(scores.Total()) / float64(len(scores)) } func (scores *Scores) Add(score int) { *scores append(*scores, score) } func main() { scores : Scores{80, 90} scores.Add(100) fmt.Println(成绩:, scores) fmt.Println(总分:, scores.Total()) fmt.Println(平均分:, scores.Average()) }运行结果成绩: [80 90 100] 总分: 270 平均分: 90Scores 的 Total 和 Average 使用值接收者因为它们只读取数据Add 使用指针接收者因为 append 可能改变切片的长度和底层数组需要把新的切片描述符写回原变量。给 map 定义方法也很常见type Headers map[string]string func (headers Headers) Set(key, value string) { headers[key] value } func (headers Headers) Get(key string) (string, bool) { value, ok : headers[key] return value, ok }这里 Set 可以使用值接收者因为 map 值本身包含对底层哈希表的引用。修改 headers[key] 会影响同一个底层 map。只有在方法需要让 map 变量本身指向另一个 map 时才需要返回新的 map 或使用指针。泛型类型的方法Go 支持给泛型类型定义方法。方法的接收者需要声明对应的类型参数package main import fmt type Box[T any] struct { value T } func (box Box[T]) Get() T { return box.value } func (box *Box[T]) Set(value T) { box.value value } func main() { box : Box[int]{} box.Set(42) fmt.Println(box.Get()) textBox : Box[string]{value: Go} fmt.Println(textBox.Get()) }运行结果42 GoBox[int] 和 Box[string] 是两个不同的实例化类型但它们共享同一套方法定义。Get 使用值接收者读取值Set 使用指针接收者修改值。方法上的类型参数来自接收者的泛型类型定义。方法不能像普通泛型函数那样随意增加一组独立的类型参数需要跨类型转换时通常把逻辑写成普通泛型函数或者重新设计泛型类型。使用方法实现一个泛型栈接下来实现一个完整的数据结构泛型栈Stack。栈遵循后进先出LIFO规则Push(10) → Push(20) → Push(30) 栈顶 │ ▼ ---- | 30 | ---- | 20 | ---- | 10 | ----我们用切片保存元素再通过方法限制外部操作。栈的零值应该直接可用因此不需要额外的构造函数。定义栈类型和 Push 方法type Stack[T any] struct { items []T } func (stack *Stack[T]) Push(value T) { stack.items append(stack.items, value) }items 使用未导出字段外部代码不能直接改变栈内部顺序。Push 使用指针接收者因为 append 可能改变切片长度和容量。实现 Pop、Peek 和 Lenfunc (stack *Stack[T]) Pop() (T, bool) { if len(stack.items) 0 { var zero T return zero, false } last : len(stack.items) - 1 value : stack.items[last] var zero T stack.items[last] zero stack.items stack.items[:last] return value, true } func (stack *Stack[T]) Peek() (T, bool) { if len(stack.items) 0 { var zero T return zero, false } return stack.items[len(stack.items)-1], true } func (stack Stack[T]) Len() int { return len(stack.items) } func (stack Stack[T]) IsEmpty() bool { return len(stack.items) 0 }Pop 删除元素后把最后一个位置写成 T 的零值。对于元素是指针、切片或 map 的情况这一步可以及时清理引用减少底层数组继续持有对象的时间。完整测试代码package main import fmt type Stack[T any] struct { items []T } func (stack *Stack[T]) Push(value T) { stack.items append(stack.items, value) } func (stack *Stack[T]) Pop() (T, bool) { if len(stack.items) 0 { var zero T return zero, false } last : len(stack.items) - 1 value : stack.items[last] var zero T stack.items[last] zero stack.items stack.items[:last] return value, true } func (stack *Stack[T]) Peek() (T, bool) { if len(stack.items) 0 { var zero T return zero, false } return stack.items[len(stack.items)-1], true } func (stack Stack[T]) Len() int { return len(stack.items) } func (stack Stack[T]) IsEmpty() bool { return len(stack.items) 0 } func main() { var stack Stack[int] fmt.Println(初始为空:, stack.IsEmpty()) stack.Push(10) stack.Push(20) stack.Push(30) top, _ : stack.Peek() fmt.Println(栈顶:, top) fmt.Println(长度:, stack.Len()) value, ok : stack.Pop() fmt.Println(弹出:, value, ok) fmt.Println(弹出后栈顶:, mustPeek(stack)) fmt.Println(长度:, stack.Len()) for !stack.IsEmpty() { value, _ : stack.Pop() fmt.Println(继续弹出:, value) } fmt.Println(最终为空:, stack.IsEmpty()) } func mustPeek(stack *Stack[int]) int { value, ok : stack.Peek() if !ok { panic(stack is empty) } return value }运行结果初始为空: true 栈顶: 30 长度: 3 弹出: 30 true 弹出后栈顶: 20 长度: 2 继续弹出: 20 继续弹出: 10 最终为空: true这个实现体现了方法的几个关键作用Push、Pop、Peek 和 Len 都围绕 Stack 类型组织外部代码只能通过方法操作 items指针接收者负责修改栈值接收者负责只读查询空栈通过零值和第二个 bool 返回值表达Stack[int] 可以直接替换成 Stack[string] 或其他类型。栈的时间复杂度操作时间复杂度说明Push摊销 O(1)切片尾部追加扩容时会复制PopO(1)读取并删除最后一个元素PeekO(1)读取最后一个元素LenO(1)直接读取切片长度IsEmptyO(1)判断长度是否为零栈的实现并不复杂真正值得理解的是数据结构的状态放在类型内部修改状态的操作使用指针接收者查询状态的操作使用值接收者。这样可以让方法名直接表达业务动作同时把内部表示隐藏起来。方法命名和标准接口Go 的方法名通常使用简洁的动词或名词不使用 Get、Do 这类没有额外含义的前缀。例如 Effective Go 建议如果类型有一个 owner 字段对外读取它的方法可以命名为 Owner而不是 GetOwner。标准库中有一些方法名已经形成约定String() string实现 fmt.StringerError() string实现 errorRead、Write、Close通常表示 io.Reader、io.Writer、io.Closer 的行为Len、Less、Swap用于实现 sort.Interface。下面实现 String 方法package main import fmt type Point struct { X int Y int } func (point Point) String() string { return fmt.Sprintf((%d, %d), point.X, point.Y) } func main() { point : Point{X: 3, Y: 4} fmt.Println(point) }运行结果(3, 4)fmt.Println 发现 Point 实现了 String() string就会调用这个方法得到文本表示。方法名和签名需要遵循标准接口约定不能随意写成 ToString否则标准库不会自动识别。常见错误和避坑提醒误区一把方法当成继承方法只绑定到接收者类型结构体嵌入带来的是组合和方法提升不会产生父子类型关系。需要抽象行为时应该使用接口而不是依赖嵌入模拟继承。误区二值接收者修改了副本如果方法要修改原对象应使用指针接收者。看到方法体中写了字段并不代表调用者的对象会被修改。误区三认为值能自动满足指针方法接口可寻址变量调用 pointer method 时Go 可以自动取地址但值赋给接口时不会自动转成指针。接口赋值前必须检查 T 和 *T 的方法集。误区四给非本地类型添加方法不能给 string、time.Time 或其他包里的类型添加方法。定义一个本地新类型后原类型的方法也不会自动继承。误区五复制包含锁的结构体如果结构体内部包含 sync.Mutex 等不能复制的状态值接收者可能产生隐蔽问题。此类类型通常应该使用指针接收者并避免复制整个结构体。误区六忽略 nil 接收者指针方法可能在 nil 指针上被调用。方法要么主动支持 nil要么在契约中明确不允许 nil并避免访问 nil 指针的字段。误区七用指针接收者包装每一个切片方法切片元素修改通常不需要 *[]T。只有在需要改变切片长度、容量或让切片变量指向新底层数组时才需要指针接收者或返回新切片。误区八方法名不符合标准接口如果想实现 fmt.Stringer、error、io.Writer 等接口方法名和签名必须完全匹配。String、Error、Write 的大小写和返回值都不能随意修改。一份可以直接复制的综合示例下面的例子把方法、接口、指针接收者和方法值放在一起模拟一个可统计的计数器package main import fmt type Counter struct { value int } type Metric interface { Add(int) Value() int } func (counter *Counter) Add(delta int) { counter.value delta } func (counter Counter) Value() int { return counter.value } func printMetric(metric Metric) { fmt.Println(当前值:, metric.Value()) } func main() { counter : Counter{} var metric Metric counter metric.Add(10) metric.Add(5) printMetric(metric) add : counter.Add add(20) fmt.Println(最终值:, counter.Value()) }运行结果当前值: 15 最终值: 35Counter 的 Add 使用指针接收者所以接口中保存的是 *CounterValue 使用值接收者因此 *Counter 同时拥有 Value 方法。最后把 counter.Add 保存成方法值 add调用 add(20) 仍然修改原来的计数器。总结本章我们学习了 Go 方法的完整基础方法是带接收者的函数方法只能绑定到当前包定义的非指针、非接口类型值接收者拿到副本适合只读操作指针接收者拿到地址可以修改原对象并避免复制可寻址值调用指针方法时Go 会自动取地址T 的方法集只包含 T 接收者方法*T 的方法集包含 T 和 *T 接收者方法接口实现由方法集决定不需要显式声明值可以调用指针方法不代表这个值一定满足接口方法值会保存接收者方法表达式会把接收者变成第一个参数结构体嵌入可以提升方法但不等同于继承切片、map 和泛型类型同样可以拥有方法方法可以把数据结构的内部状态和操作封装在一起方法名和签名遵循标准约定才能正确实现 fmt.Stringer、error、io.Writer 等接口。真正理解 Go 方法之后就不会把它简单看成“结构体里的函数”。更准确的理解是方法把行为绑定到类型接收者决定状态如何传递方法集决定接口是否成立而清晰的方法边界又让复杂数据结构能够被安全地使用。官方资料Go 语言规范Method declarationsGo 语言规范Method setsGo 语言规范Method expressionsGo 语言规范Method valuesGo 官方教程MethodsGo 官方教程Methods and pointer indirectionGo 官方文档Effective Go - MethodsGo 官方文档Effective Go - InterfacesGo WikiMethodSetsGo WikiCode Review Comments - Receiver Type
返回列表