go学习记录——第十四天

方法和未导出字段

考虑 person2.go 中的 person 包:类型 Person 被明确的导出了,但是它的字段没有被导出。例如在 use_person2.gop.firstName 就是错误的。该如何在另一个程序中修改或只读取一个 Person 的名字呢?

这可以通过面向对象语言中一个众所周知的技术来完成:提供 getter()setter() 方法。对于 setter() 方法使用 Set... 前缀,对于 getter() 方法只使用成员名。

示例person2.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package person

type Person struct {
firstName string
lastName string
}

func (p *Person) FirstName() string {
return p.firstName
}

func (p *Person) SetFirstName(newName string) {
p.firstName = newName
}

示例use_person2.go

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"chapter10/person"
"fmt"
)

func main() {
p := new(person.Person)
p.SetFirstName("John")
fmt.Println(p.FirstName())
}

这里需要说一下,person2.go的路径是chapter10/person/person2.gouse_person2.go的路径是chapter10/use_person2.gogo.modchapter10这个路径下,原本是在最外层路径,设置的时候注意一下。

并发访问对象

对象的字段(属性)不应该由2个或2个以上的不同线程在同一时间去改变。如果在程序发生这种情况,为了安全并发访问,可以使用包 sync (参考第9.3节中的方法。在第14.17节中将通过 goroutineschannels 探索另一种方式。虽然但是咱也不知道为啥突然在这里提一句并发访问)

内嵌类型的方法和继承

当一个匿名类型被内嵌在结构体中时,匿名类型的可见方法也同样被内嵌,这在效果上等同于外层类型继承了这些方法:将父类型放在子类型中来实现亚型。这个机制提供了一种简单的方式来模拟经典面向对象语言中的子类和继承相关的效果,也类似 Ruby 中的混入(mixin)

下面是一个实例:假设有一个 Engine 接口类型,一个 Car 结构体类型,其包含一个 Engine 类型的匿名字段。

1
2
3
4
5
6
7
8
type Engine interface {
Start()
Stop()
}

type Car struct {
Engine
}

可以构建如下代码

1
2
3
4
5
6
7
func (c *Car) GoToWorkIn() {
// get in car
c.Start()
// drive to work
c.Stop()
// get out of car
}

示例method3.go 展示了内嵌结构体上的方法可以直接在外层类型的实例上调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"math"
)

type Point struct {
x, y float64
}

func (p *Point) Abs() float64 {
return math.Sqrt(p.x*p.x + p.y*p.y)
}

type NamedPoint struct {
Point
name string
}

func main() {
n := &NamedPoint{Point{3, 4}, "Origin"}
fmt.Println(n.Abs()) // Output: 5
}

内嵌将一个已存在的类型的字段和方法注入到了另一个类型里:匿名字段上的方法“晋升”成为了外层类型的方法。当然类型可以有只作用于其本身实例而不作用于内嵌“父”类型上的方法。

可以覆写方法(像字段一样):和内嵌类型方法具有同样名字的外层类型的方法会覆写内嵌类型对应的方法。

示例method4.go(为method3.go添加如下字段)

1
2
3
func (n *NamedPoint) Abs() float64{
return n.Point.Abs() * 100
} // 最终输出变为500

因为一个结构体可以嵌入多个匿名类型,所以实际上我们可以有一个简单版本的多重继承,就像:type Child struct {Father; Mother}

结构体内嵌和自己在同一个包中的结构体时,可以彼此访问对方所有的字段和方法。

当不在同一个包时,只能访问公开的字段和方法,区别在于开头为大写字母即为公开字段/方法,小写字母为私有字段/方法。

如何在类型中嵌入功能

主要有两种方法来实现在类型中嵌入功能:

A:聚合(或组合):包含一个所需功能类型的具名字段

B:内嵌:内嵌(匿名地)所需功能类型,像前面的那个part里的那样

方便理解,假设有一个 Customer 类型,我们想让它通过 Log 类型来包含日志功能,Log 类型只是简单地包含一个累积的消息。如果想让特定类型都具备日志功能,可以实现一个这样的 Log 类型,然后将它作为特定类型的一个字段,并提供 Log() ,它将返回这个日志的引用。

方式A可以通过如下方法实现(使用了后续的第10.7节中的 String() 功能)

示例embed_func1.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import "fmt"

type Log struct {
msg string
}

type Customer struct {
Name string
log *Log
}

func main() {
// 初始化第一个 Customer 实例
c1 := new(Customer)
c1.Name = "Barak Obama"
c1.log = new(Log)
c1.log.msg = "1 - Yes, I am a citizen of the United States."
fmt.Println("First log message:", c1.log.msg) // 第一个 println

// 初始化第二个 Customer 实例
c2 := &Customer{"Barak Obama", &Log{"2 - I am a citizen of the United States."}}
fmt.Println("Second log message:", c2.Log().String()) // 第二个 println

// 修改第二个 Customer 实例的 log
c2.Log().Add("3 - I am a citizen of the United States.")
fmt.Println("Third log message:", c2.Log().String()) // 第三个 println
}

func (l *Log) Add(s string) {
l.msg += "\n" + s
}

func (l *Log) String() string {
return l.msg
}

func (c *Customer) Log() *Log {
return c.log
}

输出结果

1
2
3
4
First log message: 1 - Yes, I am a citizen of the United States.
Second log message: 2 - I am a citizen of the United States.
Third log message: 2 - I am a citizen of the United States.
3 - I am a citizen of the United States. // 因为c2被add了一条,所以最后会输出为2和3

相对的方式B会像这个示例embed_func2.go一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import "fmt"

type Log struct {
msg string
}

type Customer struct {
Name string
Log
}

func main() {
c := &Customer{"Barack Obama", Log{"Hello, world!"}}
c.Add("2 - After meeting with the team")
fmt.Println(c)
}

func (l *Log) Add(s string) {
l.msg += "\n" + s
}

func (l *Log) String() string {
return l.msg
}

func (c *Customer) String() string {
return c.Name + "\nLog:" + fmt.Sprintln(c.Log.String())
}

输出结果

1
2
3
Barack Obama
Log:Hello, world!
2 - After meeting with the team

内嵌的类型不需要指针,Customer 也不需要 Add 方法,它使用 LogAdd 方法,Customer 有自己的 String 方法,并且在它里面调用了 LogString 方法

如果内嵌类型嵌入了其他类型,也是可以的,那些类型的方法可以直接在外层类型中使用

因此一个好的策略是创建一些小的可以复用的类型作为工具箱,用于组成域类型。

多重继承

多重继承指的是类型获得多个父类型行为的能力,它在传统的面向对象语言中通常是不被实现的(C++和Python例外,不对这俩为啥会被算作面向对象的)。因为在类继承层次中,多重继承会给编译器引入额外的复杂度。但是在 Go 语言中,通过在类型中嵌入必要的父类型,可以很简单的实现多重继承。

举个例子,假设有一个类型 CameraPhone ,通过它可以 Call() ,也可以 TakeAPicture() ,但是第一个方法属于类型 Phone 第二个方法属于 Camera

只要嵌入这两个类型就可以解决这个问题

示例mult_inheritance.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import "fmt"

type Camera struct{}

func (c *Camera) TakeAPicture() string {
return "Click"
}

type Phone struct{}

func (p *Phone) Call() string {
return "Ring Ring"
}

type CameraPhone struct {
Camera
Phone
}

func main() {
cp := new(CameraPhone)
fmt.Println("Our new CameraPhone exhibits multiple behaviors...")
fmt.Println("It exhibits behavior of a Camera:", cp.TakeAPicture())
fmt.Println("It works like a Phone too:", cp.Call())
}

输出结果

1
2
3
Our new CameraPhone exhibits multiple behaviors...
It exhibits behavior of a Camera: Click
It works like a Phone too: Ring Ring

通用方法和方法命名

在编程中一些基本操作会一遍又一遍的出现,比如打开(Open)、关闭(Close)、读(Read)、写(Write)、排序(Sort)等等,并且它们都有一个大致的意思:打开 (Open)可以作用于一个文件、一个网络连接、一个数据库连接等等。虽然具体实现起来可能千差万别,但基本逻辑是一致的。在 Golang 中,通过使用接口(参考第十一章),标准库广泛的应用了这些规则,在标准库中这些通用方法有一致的名字,比如 Open()Read()Write()等。想写规范的 Go 程序,就应该遵守这些规则,给方法合适的名字和签名,就像那些通用方法那样。这样做会使 Go 开发的软件有一致性和可读性。比如需要一个 convert-to-string() 方法,应该命名为 String() ,而不是 ToString() (总感觉这个名字特别怪)(参考第10.7节

和其他面向对象语言比较 Go 的类型和方法

在如C++、Java、C#和Rudy这样的面向对象语言中,方法在类的上下文中被定义和继承:在一个对象上调用方法时,运行时会检测类及它的超类中是否有此方法,如果没有会导致异常发生。

在 Go 语言中,这样的继承层次是完全没必要的:如果方法在此类型定义了,就可以调用它,和其他类型上是否存在这个方法没有关系。在这个意义上,Go 具有更大的灵活性。

下面的模式就很好的说明了这个问题:

img

Go 不需要一个显式的类定义,如同Java、C++、C#等那样,相反地,“类”是通过提供一组作用于共同类型的方法集来隐式定义的。类型可以是结构体或者任何用户自定义类型。

比如:我们想定义自己的 Interger 类型,并添加一些类似转换成字符串的方法,在 Go 中可以如下定义:

1
2
3
4
type Integer int
func (i *Integer) String() string{
return strconv.Itoa(int(*i))
}

在 Java 或 C# 中,这个方法需要和类 Integer 的定义放在一起,在 Rudy 中可以直接在基本类型 int 上定义这个方法。

总结

在 Go 中,类型就是类(数据和关联的方法)。Go 不知道类似面向对象语言的类继承的概念。继承有两个好处:代码复用和多态。

在 Go 中,代码复用通过组合和委托实现,多态通过接口来实现:有时也叫 组件编程(Component Programming)

类型的 String() 方法和格式化描述符

当定义了一个有很多方法的类型时,十之八九会使用 String() 方法来定制类型的字符串形式的输出,换句话说:一种可阅读性和打印性的输出。如果类型定义了 String() 方法,它会被用在 fmt.Printf() 中生成默认的输出:等同于使用格式化描述符 %v 产生的输出。还有 fmt.Print()fmt.Println() 也会自动使用 String() 方法。

我们使用 第10.4节中的程序来进行测试:

这个就是我们前面用的 method_string.go 跳转链接就不写了,直接把代码写出来吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"strconv"
)

type TwoInts struct {
a int
b int
}

func main() {
two1 := new(TwoInts)
two1.a = 12
two1.b = 10
fmt.Printf("two1 is %v\n", two1)
fmt.Println("two1 is:", two1)
fmt.Printf("two is: %T\n", two1)
fmt.Printf("two1 is: %v\n", two1)
}

func (tn *TwoInts) string() string {
return "(" + strconv.Itoa(tn.a) + "/" + strconv.Itoa(tn.b) + ")"
}

输出结果

1
2
3
4
two1 is &{12 10}
two1 is: &{12 10}
two is: *main.TwoInts
two1 is: &{12 10}

当广泛使用一个自定义类时,最好为他定义 String() 方法、上面的例子中也可以看出,格式化描述符 %T 会给出类型的完全规格,%#v 会给出实例的完全输出,包括它的字段(在程序自动生成的 Go 代码时也很有用)

备注:

不要在 String() 方法里面调用设计 String() 方法的方法,它会导致意料之外的错误,比如下面的例子,它会导致一个无限递归调用( TT.String() 调用 fmt.Sprintf ,而 fmt.Sprintf 又会反过来调用 TT.String() ,很快就会导致内存溢出

1
2
3
4
5
type TT float64
func (t TT) String() string {
return fmt.Sprintf(%v", t)
}
t.String()

垃圾回收和和SetFinalizer

Go 开发者不需要写代码来释放程序中不再使用的变量和结构占用的内存,在 Go 运行时中有一个独立的进程,即垃圾收集器(GC),会处理这些事情,它搜索不再使用的变量然后释放它们的内存。可以通过 runtime 包访问 GC 进程。

通过调用 runtime.GC() 函数可以显式的触发 GC ,但这只在某些罕见的场景下才有用,比如当内存资源不足时调用 runtime.GC() ,它会在此函数执行的点上立即释放一大片内存,此时程序可能会有短时的性能下降(因为 GC 进程在执行)。

如果想知道当前内存状态,可以使用

1
2
3
var m runtime.MemStats
runtime.ReadMemstates(&m)
fmt.Printf("%d Kb\n",m.Alloc / 1024)

上面的程序会给出已分配内存的总量,单位是KB。详细参考文档

如果需要在一个对象 obj 被从内存移除前执行一些特殊操作,比如写到日志文件中,可以通过下面的方式调用函数来实现:

1
runtime.SetFinalizer(obj, func(obj *typeObj))

func(obj *typeObj) 需要一个 typeObj 类型的指针参数 obj ,特殊操作会在它上面执行,func 也可以是一个匿名函数。

在对象被 GC 进程选中并从内存中移除以前,SetFinalizer 都不会执行,即使程序正常结束或发生错误。


go学习记录——第十四天
https://www.lx02918.ltd/2025/01/03/go-study-fourteenth-day/
作者
Seth
发布于
2025年1月3日
许可协议