go学习记录——第十四天
方法和未导出字段
考虑 person2.go
中的 person
包:类型 Person
被明确的导出了,但是它的字段没有被导出。例如在 use_person2.go
中 p.firstName
就是错误的。该如何在另一个程序中修改或只读取一个 Person
的名字呢?
这可以通过面向对象语言中一个众所周知的技术来完成:提供 getter()
和 setter()
方法。对于 setter()
方法使用 Set...
前缀,对于 getter()
方法只使用成员名。
1 |
|
1 |
|
这里需要说一下,person2.go
的路径是chapter10/person/person2.go
,use_person2.go
的路径是chapter10/use_person2.go
,go.mod
在chapter10
这个路径下,原本是在最外层路径,设置的时候注意一下。
并发访问对象
对象的字段(属性)不应该由2个或2个以上的不同线程在同一时间去改变。如果在程序发生这种情况,为了安全并发访问,可以使用包 sync
(参考第9.3节中的方法。在第14.17节中将通过 goroutines
和 channels
探索另一种方式。虽然但是咱也不知道为啥突然在这里提一句并发访问)
内嵌类型的方法和继承
当一个匿名类型被内嵌在结构体中时,匿名类型的可见方法也同样被内嵌,这在效果上等同于外层类型继承了这些方法:将父类型放在子类型中来实现亚型。这个机制提供了一种简单的方式来模拟经典面向对象语言中的子类和继承相关的效果,也类似 Ruby
中的混入(mixin)
下面是一个实例:假设有一个 Engine
接口类型,一个 Car
结构体类型,其包含一个 Engine
类型的匿名字段。
1 |
|
可以构建如下代码
1 |
|
示例method3.go 展示了内嵌结构体上的方法可以直接在外层类型的实例上调用:
1 |
|
内嵌将一个已存在的类型的字段和方法注入到了另一个类型里:匿名字段上的方法“晋升”成为了外层类型的方法。当然类型可以有只作用于其本身实例而不作用于内嵌“父”类型上的方法。
可以覆写方法(像字段一样):和内嵌类型方法具有同样名字的外层类型的方法会覆写内嵌类型对应的方法。
示例method4.go(为method3.go添加如下字段)
1 |
|
因为一个结构体可以嵌入多个匿名类型,所以实际上我们可以有一个简单版本的多重继承,就像:type Child struct {Father; Mother}
。
结构体内嵌和自己在同一个包中的结构体时,可以彼此访问对方所有的字段和方法。
当不在同一个包时,只能访问公开的字段和方法,区别在于开头为大写字母即为公开字段/方法,小写字母为私有字段/方法。
如何在类型中嵌入功能
主要有两种方法来实现在类型中嵌入功能:
A:聚合(或组合):包含一个所需功能类型的具名字段
B:内嵌:内嵌(匿名地)所需功能类型,像前面的那个part里的那样
方便理解,假设有一个 Customer
类型,我们想让它通过 Log
类型来包含日志功能,Log
类型只是简单地包含一个累积的消息。如果想让特定类型都具备日志功能,可以实现一个这样的 Log
类型,然后将它作为特定类型的一个字段,并提供 Log()
,它将返回这个日志的引用。
方式A可以通过如下方法实现(使用了后续的第10.7节中的 String()
功能)
1 |
|
输出结果
1 |
|
相对的方式B会像这个示例embed_func2.go一样
1 |
|
输出结果
1 |
|
内嵌的类型不需要指针,Customer
也不需要 Add
方法,它使用 Log
的 Add
方法,Customer
有自己的 String
方法,并且在它里面调用了 Log
的 String
方法
如果内嵌类型嵌入了其他类型,也是可以的,那些类型的方法可以直接在外层类型中使用
因此一个好的策略是创建一些小的可以复用的类型作为工具箱,用于组成域类型。
多重继承
多重继承指的是类型获得多个父类型行为的能力,它在传统的面向对象语言中通常是不被实现的(C++和Python例外,不对这俩为啥会被算作面向对象的)。因为在类继承层次中,多重继承会给编译器引入额外的复杂度。但是在 Go 语言中,通过在类型中嵌入必要的父类型,可以很简单的实现多重继承。
举个例子,假设有一个类型 CameraPhone
,通过它可以 Call()
,也可以 TakeAPicture()
,但是第一个方法属于类型 Phone
第二个方法属于 Camera
。
只要嵌入这两个类型就可以解决这个问题
1 |
|
输出结果
1 |
|
通用方法和方法命名
在编程中一些基本操作会一遍又一遍的出现,比如打开(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 具有更大的灵活性。
下面的模式就很好的说明了这个问题:
Go 不需要一个显式的类定义,如同Java、C++、C#等那样,相反地,“类”是通过提供一组作用于共同类型的方法集来隐式定义的。类型可以是结构体或者任何用户自定义类型。
比如:我们想定义自己的 Interger
类型,并添加一些类似转换成字符串的方法,在 Go 中可以如下定义:
1 |
|
在 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 |
|
输出结果
1 |
|
当广泛使用一个自定义类时,最好为他定义 String()
方法、上面的例子中也可以看出,格式化描述符 %T
会给出类型的完全规格,%#v
会给出实例的完全输出,包括它的字段(在程序自动生成的 Go
代码时也很有用)
备注:
不要在 String()
方法里面调用设计 String()
方法的方法,它会导致意料之外的错误,比如下面的例子,它会导致一个无限递归调用( TT.String()
调用 fmt.Sprintf
,而 fmt.Sprintf
又会反过来调用 TT.String()
,很快就会导致内存溢出
1 |
|
垃圾回收和和SetFinalizer
Go 开发者不需要写代码来释放程序中不再使用的变量和结构占用的内存,在 Go 运行时中有一个独立的进程,即垃圾收集器(GC
),会处理这些事情,它搜索不再使用的变量然后释放它们的内存。可以通过 runtime
包访问 GC
进程。
通过调用 runtime.GC()
函数可以显式的触发 GC
,但这只在某些罕见的场景下才有用,比如当内存资源不足时调用 runtime.GC()
,它会在此函数执行的点上立即释放一大片内存,此时程序可能会有短时的性能下降(因为 GC
进程在执行)。
如果想知道当前内存状态,可以使用
1 |
|
上面的程序会给出已分配内存的总量,单位是KB。详细参考文档
如果需要在一个对象 obj
被从内存移除前执行一些特殊操作,比如写到日志文件中,可以通过下面的方式调用函数来实现:
1 |
|
func(obj *typeObj)
需要一个 typeObj
类型的指针参数 obj
,特殊操作会在它上面执行,func
也可以是一个匿名函数。
在对象被 GC
进程选中并从内存中移除以前,SetFinalizer
都不会执行,即使程序正常结束或发生错误。