1. 函数调用
函数是Go语言中的一等公民。从函数的调用惯例和参数的传递方法两方面分别介绍函数的执行过程。
1.1 调用惯例
调用惯例是调用方和被调用方对于参数和返回值传递的约定。
C语言
当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:
- 六个以及六个以下的参数会按照顺序分别使用
edi
、esi
、edx
、ecx
、r8d
和r9d
六个寄存器传递; - 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;
而函数的返回值是通过 eax
寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。
Go语言
package main
func myFunction(a, b int) (int, int) {
return a + b, a - b
}
func main() {
myFunction(66, 77)
}
使用 go tool compile -S -N -l main.go
命令编译上述代码可以得到如下所示的汇编指令
注:如果编译时不使用 -N -l 参数,编译器会对汇编代码进行优化,编译结果会有较大差别。
通过分析 Go 语言编译后的汇编指令,我们发现 Go 语言使用栈传递参数和接收返回值,所以它只需要在栈上多分配一些内存就可以返回多个值。
结论:
C 语言和 Go 语言在设计函数的调用惯例时选择也不同的实现。C 语言同时使用寄存器和栈传递参数,使用 eax 寄存器传递返回值;而 Go 语言使用栈传递参数和返回值。我们可以对比一下这两种设计的优点和缺点:
C 语言的方式能够极大地减少函数调用的额外开销,但是也增加了实现的复杂度;
1)CPU 访问栈的开销比访问寄存器高几十倍3;
2)需要单独处理函数参数过多的情况;Go 语言的方式能够降低实现的复杂度并支持多返回值,但是牺牲了函数调用的性能;
1)不需要考虑超过寄存器数量的参数应该如何传递;
2)不需要考虑不同架构上的寄存器差异;3)函数入参和出参的内存空间需要在栈上进行分配;
Go 语言使用栈作为参数和返回值传递的方法是综合考虑后的设计,选择这种设计意味着编译器会更加简单、更容易维护。
1.2 参数传递
传值和传引用两者的区别:
- 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
- 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。
不同语言会选择不同的方式传递参数,Go 语言选择了传值
的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝。
整型和数组
-
main
函数和被调用者myFunction
中参数的地址是完全不同的。 - 函数中对参数的修改也仅仅影响了当前函数,没有影响调用方 `main1 函数
Go 语言中对于整型和数组类型的参数都是值传递的,也就是在调用函数时会对内容进行拷贝,需要注意的是如果当前数组的大小非常的大,这种传值方式就会对性能造成比较大的影响。
结构体和指针
- 传递结构体时:会对结构体中的全部内容进行拷贝;
- 传递结构体指针时:会对结构体指针进行拷贝;
所以将指针作为参数传入某一个函数时,在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中『传指针』也是传值。
当我们对 Go 语言中大多数常见的数据结构进行验证之后,其实就能够推测出 Go 语言在传递参数时其实使用的就是传值的方式,接收方收到参数时会对这些参数进行复制;了解到这一点之后,在传递数组或者内存占用非常大的结构体时,我们在一些函数中应该尽量使用指针作为参数类型来避免发生大量数据的拷贝而影响性能。
小结
- 通过堆栈传递参数,入栈的顺序是从右到左;
- 函数返回值通过堆栈传递并由调用者预先分配内存空间;
- 调用函数时都是传值,接收方会对入参进行复制再计算;
2 接口
本节会介绍使用接口时遇到的一些常见问题以及它的设计与实现,包括接口的类型转换、类型断言以及动态派发机制,帮助各位读者更好地理解接口类型。
2.1 概述
在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。接口的本质就是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。
接口定义:
type error interface {
Error() string
}
实现:
type RPCError struct {
Code int64
Message string
}
func (e *RPCError) Error() string {
return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}
上述代码根本就没有 error
接口的影子,这是为什么呢?Go
语言中接口的实现都是隐式的
,我们只需要实现 Error() string
方法实现了 error
接口
Go 语言实现接口的方式与 Java 完全不同:
- 在 Java 中:实现接口需要显式的声明接口并实现所有方法;
- 在 Go 中:实现接口的所有方法就隐式的实现了接口;
接收器只能是结构体吗
接收器类型可以是(几乎)任何类型,不仅仅是结构体类型,任何类型都可以有方法,甚至可以是函数类型,可以是 int、bool、string 或数组的别名类型,但是接收器不能是一个接口类型,因为接口是一个抽象定义,而方法却是具体实现,如果这样做了就会引发一个编译错误 invalid receiver type
…。
接收器也不能是一个指针类型,但是它可以是任何其他允许类型的指针,一个类型加上它的方法等价于面向对象中的一个类,一个重要的区别是,在Go语言中,类型的代码和绑定在它上面的方法的代码可以不放置在一起,它们可以存在不同的源文件中,唯一的要求是它们必须是同一个包的。
类型
接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的 interface{}
:
Go 语言使用 iface
结构体表示第一种接口,使用 eface
结构体表示第二种空接口,两种接口虽然都使用 interface
声明,但是由于后者在 Go
语言中非常常见,所以在实现时使用了特殊的类型。
需要注意的是,与 C 语言中的 void *
不同,interface{}
类型不是任意类型,如果我们将类型转换成了 interface{}
类型,这边变量在运行期间的类型也发生了变化,获取变量类型时就会得到 interface{}
。
指针和接口
Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 —— method redeclared。
实现接口的类型和初始化返回的类型两个维度组成了四种情况,这四种情况并不都能通过编译器的检查:
作为指针的 &Cat{}
变量能够隐式地获取到指向的结构体,所以能在结构体上调用 Walk
和 Quack
方法。我们可以将这里的调用理解成 C
语言中的 d->Walk()
和 d->Speak()
,它们都会先获取指向的结构体再执行对应的方法。
首先要知道 Go 语言在传递参数时都是传值的
接口的nil
nil 和 non-nil
我们可以通过一个例子理解『Go 语言的接口类型不是任意类型』这一句话,下面的代码在 main 函数中初始化了一个 *TestStruct 结构体指针,由于指针的零值是 nil,所以变量 s 在初始化之后也是 nil:
package main
type TestStruct struct{}
func NilOrNot(v interface{}) bool {
return v == nil
}
func main() {
var s *TestStruct
fmt.Println(s == nil) // #=> true
fmt.Println(NilOrNot(s)) // #=> false
}
$ go run main.go
true
false
我们简单总结一下上述代码执行的结果
- 将上述变量与 nil 比较会返回 true;
- 将上述变量传入 NilOrNot 方法并与 nil 比较会返回 false;
出现上述现象的原因是 —— 调用 NilOrNot
函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*TestStruct
类型会转换成 interface{}
类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct
,所以转换后的变量与 nil
不相等。
ST:接口类型比较nil时比较的是类型是否为nil,类型的值无法直接比较,需要通过反射才能获取类型的值。
2.2 数据结构
Go 语言根据接口类型『是否包含一组方法』对类型做了不同的处理。我们使用 iface
结构体表示包含方法的接口;使用 eface
结构体表示不包含任何方法的 interface{}
类型,eface
结构体在 Go 语言的定义是这样的:
type eface struct { // 16 bytes
_type *_type
data unsafe.Pointer
}
由于 interface{}
类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 — Go
语言中的任意类型都可以转换成 interface{}
类型。
另一个用于表示接口的结构体就是 iface
,这个结构体中有指向原始数据的指针 data
,不过更重要的是 itab
类型的 tab 字段。
type iface struct { // 16 bytes
tab *itab
data unsafe.Pointer
}
接下来我们将详细分析 Go 语言接口中的这两个类型,即 _type
和 itab
。
类型结构体
_type
是 Go 语言类型的运行时表示。下面是运行时包中的结构体,结构体包含了很多元信息,例如:类型的大小、哈希、对齐以及种类等。
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}
-
size
字段存储了类型占用的内存空间,为内存空间的分配提供信息; -
hash
字段能够帮助我们快速确定类型是否相等; -
equal
字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从typeAlg
结构体中迁移过来的;
itab 结构体
itab
结构体是接口类型的核心组成部分,每一个 itab 都占 32 字节的空间,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter 和 _type 两个字段表示:
type itab struct { // 32 bytes
inter *interfacetype
_type *_type
hash uint32
_ [4]byte
fun [1]uintptr
}
除了 inter
和 _type
两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用:
-
hash
是对_type.hash
的拷贝,当我们想将interfac
e 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型_type
是否一致; -
fun
是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以fun
数组中保存的元素数量是不确定的;
我们会在类型断言中介绍 hash
字段的使用,在动态派发一节中介绍 fun
数组中存储的函数指针是如何被使用的。
2.3 类型转换
2.5 动态派发
动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是一种在面向对象语言中常见的特性。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。
- 调用结构体方法时,每一次调用需要 ~3.03ns;
- 使用动态派发时,每一调用需要 ~3.58ns;
在关闭编译器优化的情况下,从上面的数据来看,动态派发生成的指令会带来 ~18% 左右的额外性能开销。
这些性能开销在一个复杂的系统中不会带来太多的影响。一个项目不可能只使用动态派发,而且如果我们开启编译器优化后,动态派发的额外开销会降低至 ~5%,这对应用性能的整体影响就更小了,所以与使用接口带来的好处相比,动态派发的额外开销往往可以忽略。
从上述表格我们可以看到使用结构体来实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口。
使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。
动态派发应用:
- 为一个接口类型多次赋值,方便调用传递参数
- 作为参数,接收实现类
反射
反射是 Go
语言比较重要的特性。虽然在大多数的应用和服务中并不常见,但是很多框架都依赖 Go
语言的反射机制实现简化代码的逻辑。因为 Go
语言的语法元素很少、设计简单,所以它没有特别强的表达能力,但是 Go
语言的 reflect
包能够弥补它在语法上的一些劣势。
reflect
实现了运行时的反射能力,能够让程序操作不同类型的对象1。反射包中有两对非常重要的函数和类型,reflect.TypeOf
能获取类型信息,reflect.ValueOf
能获取数据的运行时表示,另外两个类型是 Type
和 Value
,它们与函数是一一对应的关系:
类型 Type
是反射包定义的一个接口,我们可以使用 reflect.TypeOf
函数获取任意变量的类型,Type
接口中定义了一些有趣的方法,MethodByName
可以获取当前类型对应方法的引用、Implements
可以判断当前类型是否实现了某个接口:
type Type interface {
Align() int
FieldAlign() int
Method(int) Method
MethodByName(string) (Method, bool)
NumMethod() int
...
Implements(u Type) bool
...
}
反射包中 Value
的类型与 Type
不同,它被声明成了结构体。这个结构体没有对外暴露的字段,但是提供了获取或者写入数据的方法:
type Value struct {
// contains filtered or unexported fields
}
func (v Value) Addr() Value
func (v Value) Bool() bool
func (v Value) Bytes() []byte
...
反射包中的所有方法基本都是围绕着 Type
和 Value
这两个类型设计的。我们通过 reflect.TypeOf
、reflect.ValueOf
可以将一个普通的变量转换成『反射』包中提供的 Type
和 Value
,随后就可以使用反射包中的方法对它们进行复杂的操作。
3.1 三大法则
运行时反射是程序在运行期间检查其自身结构的一种方式。反射带来的灵活性是一把双刃剑,反射作为一种元编程方式可以减少重复代码,但是过量的使用反射会使我们的程序逻辑变得难以理解并且运行缓慢。我们在这一节中会介绍 Go
语言反射的三大法则,其中包括:
- 从 interface{} 变量可以反射出反射对象;
- 从反射对象可以获取 interface{} 变量;
- 要修改反射对象,其值必须可设置;
第一法则:
有了变量的类型之后,我们可以通过 Method
方法获得类型实现的方法,通过 Field
获取类型包含的全部字段。对于不同的类型,我们也可以调用不同的方法获取相关信息:
- 结构体:获取字段的数量并通过下标和字段名获取字段
StructField
; - 哈希表:获取哈希表的
Key
类型; - 函数或方法:获取入参和返回值的类型;
第二法则:
反射的第二法则是我们可以从反射对象可以获取 interface{}
变量。既然能够将接口类型的变量转换成反射对象,那么一定需要其他方法将反射对象还原成接口类型的变量,reflect
中的 reflect.Value.Interface
方法就能完成这项工作:
不过调用 reflect.Value.Interface
方法只能获得 interface{}
类型的变量,如果想要将其还原成最原始的状态还需要经过如下所示的显式类型转换:
v := reflect.ValueOf(1)
v.Interface().(int)
从反射对象到接口值的过程就是从接口值到反射对象的镜面过程,两个过程都需要经历两次转换:
从接口值到反射对象:
1)从基本类型到接口类型的类型转换;
2)从接口类型到反射对象的转换;从反射对象到接口值:
1)反射对象转换成接口类型;
2)通过显式类型转换变成原始类型;
当然不是所有的变量都需要类型转换这一过程。如果变量本身就是 interface{}
类型,那么它不需要类型转换,因为类型转换这一过程一般都是隐式的,所以我不太需要关心它,只有在我们需要将反射对象转换回基本类型时才需要显式的转换操作。
第三法则:
Go 语言反射的最后一条法则是与值是否可以被更改有关,如果我们想要更新一个 reflect.Value
,那么它持有的值一定是可以被更新的,假设我们有以下代码:
func main() {
i := 1
v := reflect.ValueOf(i)
v.SetInt(10)
fmt.Println(i)
}
$ go run reflect.go
panic: reflect: reflect.flag.mustBeAssignable using unaddressable value
goroutine 1 [running]:
reflect.flag.mustBeAssignableSlow(0x82, 0x1014c0)
/usr/local/go/src/reflect/value.go:247 +0x180
reflect.flag.mustBeAssignable(...)
/usr/local/go/src/reflect/value.go:234
reflect.Value.SetInt(0x100dc0, 0x414020, 0x82, 0x1840, 0xa, 0x0)
/usr/local/go/src/reflect/value.go:1606 +0x40
main.main()
/tmp/sandbox590309925/prog.go:11 +0xe0
运行上述代码会导致程序崩溃并报出 reflect: reflect.flag.mustBeAssignable using unaddressable value
错误,仔细思考一下就能够发现出错的原因,Go 语言的函数调用都是传值的,所以我们得到的反射对象跟最开始的变量没有任何关系,所以直接对它修改会导致崩溃。
想要修改原有的变量只能通过如下的方法:
func main() {
i := 1
v := reflect.ValueOf(&i)
v.Elem().SetInt(10)
fmt.Println(i)
}
$ go run reflect.go
10
- 调用
reflect.ValueOf
函数获取变量指针; - 调用
reflect.Value.Elem
方法获取指针指向的变量; - 调用
reflect.Value.SetInt
方法更新变量的值:
由于 Go 语言的函数调用都是值传递的,所以我们只能先获取指针对应的 reflect.Value
,再通过 reflect.Value.Elem
方法迂回的方式得到可以被设置的变量,我们通过如下所示的代码理解这个过程:
func main() {
i := 1
v := &i
*v = 10
}
如果不能直接操作 i
变量修改其持有的值,我们就只能获取 i
变量所在地址并使用 *v
修改所在地址中存储的整数。
3.2 类型和值
Go 语言的 interface{}
类型在语言内部是通过 emptyInterface
这个结体来表示的,其中的 rtype
字段用于表示变量的类型,另一个 word
字段指向内部封装的数据:
type emptyInterface struct {
typ *rtype
word unsafe.Pointer
}
3.3 更新变量
在变量更新的过程中,reflect.Value.assignTo
返回的 reflect.Value
中的指针会覆盖当前反射对象中的指针实现变量的更新。
3.4 实现协议
3.5 方法调用
使用反射来调用方法非常复杂,原本只需要一行代码就能完成的工作,现在需要十几行代码才能完成,但这也是在静态语言中使用动态特性需要付出的成本。
3.6 小结
Go 语言的 reflect
包为我们提供的多种能力,包括如何使用反射来动态修改变量、判断类型是否实现了某些接口以及动态调用方法等功能,通过对反射包中方法原理的分析能帮助我们理解之前看起来比较怪异、令人困惑的现象。
反射性能优化:
https://zhuanlan.zhihu.com/p/138777955
ast?
可以通过类型指针直接操作数据,那如结构体第一个元素的地址就是结构体的地址。
https://zhuanlan.zhihu.com/p/25474088
reflect 为什么慢
reflect慢主要有两个原因:
- 一是涉及到内存分配以后GC;
(valueOf操作reflect.unpackEface
函数会将传入的接口转换成emptyInterface
结构体,然后将具体类型和指针包装成Value
结构体并返回。设计到了内存分配。
) - 二是reflect实现里面有大量的枚举,也就是for循环,比如类型之类的。
变量能否被更新的条件
值可修改条件之一:可被寻址
值可修改条件之一:被导出
java和golang反射区别
https://blog.csdn.net/qsort_/article/details/108007581
https://zhuanlan.zhihu.com/p/25474088
因为golang typeof取出的对象没办法之际用来取值,取值还要用valueof,但valueof取出的值是具体的值,不是可以复用的反射对象。