函数
1 - 函数概述
在go的世界中,函数是一等公民,可以给变量赋值,可以作为参数传递,也可以直接赋值。
多值返回
https://golang.org/doc/effective_go.html#multiple-returns
Go的一个不同寻常的特点是,函数和方法可以返回多个值。这种形式可以用来改进C程序中的几个笨拙的习语:带内错误返回,如-1代表EOF和修改按地址传递的参数。
在C语言中,写错误的信号是一个负数,错误代码被秘密存放在一个易失性的位置。在Go中,Write可以返回计数(count)和错误(error)。“是的,你写了一些字节,但不是全部,因为你填满了设备”. 来自包os的文件上的Write方法的签名是。
func (file *File) Write(b []byte) (n int, err error)
和文档中说的一样,当 n != len(b) 时,它返回写入的字节数和一个非nil错误。这是一种常见的风格;更多的例子请参见错误处理一节。
类似的方法避免了传递指针到返回值以模拟引用参数的需要。下面是一个简单的函数,用于从字节片中的某个位置抓取一个数字,返回数字和下一个位置。
func nextInt(b []byte, i int) (int, int) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}
你可以用它来扫描输入切片b中的数字,像这样。
for i := 0; i < len(b); {
x, i = nextInt(b, i)
fmt.Println(x)
}
命名结果参数
Go函数的返回或结果 “参数 “可以被赋予名称,并作为常规变量使用,就像传入参数一样。当命名时,它们在函数开始时被初始化为其类型的零值;如果函数执行一个没有参数的返回语句,结果参数的当前值被用作返回值。
这些名称并不是强制性的,但它们可以使代码更短、更清晰:它们是文档。如果我们给 nextInt 的结果命名,就会很明显地知道哪个返回的 int 是哪个。
func nextInt(b []byte, pos int) (value, nextPos int) {
因为被命名的结果是初始化的,并且与一个不加修饰的返回相联系,所以它们可以简化以及澄清。下面是一个很好地使用它们的 io.ReadFull 版本。
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
defer
详见 defer 语句。
函数的本质
摘录自 函数——go世界中的一等公民 “函数的本质” 一节
在go的世界中,函数是一等公民,可以给变量赋值,可以作为参数传递,也可以直接赋值。
函数在go语言里的本质其实就是指向 __TEXT
段内存地址的一个指。
参考资料
2 - init 函数
Effective Go
https://golang.org/doc/effective_go.html#init
最后,每个源文件都可以定义自己的niladic init函数来设置任何需要的状态。(其实每个文件都可以有多个init函数。) 而finally的意思是:init是在包中的所有变量声明都评估了初始化器之后才被调用的,而那些初始化器只有在所有导入的包都有初始化器之后才被评估。(其实每个文件都可以有多个init函数。)而finally的意思是最后:init是在包中所有的变量声明都评估了它们的初始化器之后才被调用的,而这些初始化器只有在所有的导入包都被初始化之后才被评估。
除了不能用声明来表达的初始化之外,init函数的一个常见用途是在真正执行开始之前验证或修复程序状态的正确性。
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}
参考资料
五分钟理解golang的init函数
https://zhuanlan.zhihu.com/p/34211611
init函数的主要作用:
- 初始化不能采用初始化表达式初始化的变量。
- 程序运行前的注册。
- 实现 sync.Once 功能。
- 其他
init函数的主要特点:
- init函数先于main函数自动执行,不能被其他函数调用;
- init函数没有输入参数、返回值;
- 每个包可以有多个init函数;
- 包的每个源文件也可以有多个init函数,这点比较特殊;
- 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
- 不同包的init函数按照包导入的依赖关系决定执行顺序。
golang程序初始化:
golang程序初始化先于main函数执行,由runtime进行初始化,初始化顺序如下:
- 初始化导入的包(包的初始化顺序并不是按导入顺序(“从上到下”)执行的,runtime需要解析包依赖关系,没有依赖的包最先初始化,与变量初始化依赖关系类似
- 初始化包作用域的变量(该作用域的变量的初始化也并非按照“从上到下、从左到右”的顺序,runtime解析变量依赖关系,没有依赖的变量最先初始化);
- 执行包的init函数;
几个值得注意的地方:
- 初始化顺序:变量初始化 -> init() -> main()
- 同一个包不同源文件的init函数执行顺序,golang spec没做说明,以上述程序输出来看,执行顺序是源文件名称的字典序。
- init函数不可以被调用,上面代码会提示:undefined: init
- init函数比较特殊,可以在包里被多次定义。
- init函数的主要用途:初始化不能使用初始化表达式初始化的变量
- golang对没有使用的导入包会编译报错,但是有时我们只想调用该包的init函数,不使用包导出的变量或者方法:
import _ "net/http/pprof"
3 - 函数调用
理解Go语言中的函数调用
https://draveness.me/golang/docs/part2-foundation/ch04-basic/golang-function-call/
调用惯例
调用惯例是调用方和被调用方对于参数和返回值传递的约定。
-
c
当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:
- 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;
- 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;
而函数的返回值是通过 eax 寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。
-
Go 语言使用栈传递参数和接收返回值,所以它只需要在栈上多分配一些内存就可以返回多个值。
思考:
C 语言和 Go 语言在设计函数的调用惯例时选择也不同的实现。C 语言同时使用寄存器和栈传递参数,使用 eax 寄存器传递返回值;而 Go 语言使用栈传递参数和返回值。我们可以对比一下这两种设计的优点和缺点:
- C 语言的方式能够极大地减少函数调用的额外开销,但是也增加了实现的复杂度;
- CPU 访问栈的开销比访问寄存器高几十倍3;
- 需要单独处理函数参数过多的情况;
- Go 语言的方式能够降低实现的复杂度并支持多返回值,但是牺牲了函数调用的性能;
- 不需要考虑超过寄存器数量的参数应该如何传递;
- 不需要考虑不同架构上的寄存器差异;
- 函数入参和出参的内存空间需要在栈上进行分配;
Go 语言使用栈作为参数和返回值传递的方法是综合考虑后的设计,选择这种设计意味着编译器会更加简单、更容易维护。
参数传递
除了函数的调用惯例之外,Go 语言在传递参数时是传值还是传引用也是一个有趣的问题,这个问题影响的是当我们在函数中对入参进行修改时会不会影响调用方看到的数据。我们先来介绍一下传值和传引用两者的区别:
- 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
- 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。
不同语言会选择不同的方式传递参数,Go 语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝。
备注:Go的方式和Java是类似的。Java也是传值,如果是对象也传递对象句柄的值)。
总结:
- Go 语言中对于整型和数组类型的参数都是值传递的
- 如果数组很大,传值方式(拷贝)会对性能造成比较大的影响
- 传递结构体时:会对结构体中的全部内容进行拷贝;
- 传递结构体指针时:会对结构体指针进行拷贝;
Go 语言在传递参数时其实使用的就是传值的方式,接收方收到参数时会对这些参数进行复制;
摘要:函数调用的过程
摘录自 函数——go世界中的一等公民 “函数调用的过程” 一节
在go语言中,每一个goroutine持有一个连续栈,栈基础大小为2kb,当栈大小超过预分配大小后,会触发栈扩容,也就是分配一个大小为当前栈2倍的新栈,并且将原来的栈拷贝到新的栈上。使用连续栈而不是分段栈的目的是,利用局部性优势提升执行速度,原理是CPU读取地址时会将相邻的内存读取到访问速度比内存快的多级cache中,地址连续性越好,L1、L2、L3 cache命中率越高,速度也就越快。
在go中,和其他一些语言有所不同,函数的返回值、参数都是由被caller保存。每次函数调用时,会在caller的栈中压入函数返回值列表、参数列表、函数返回时的PC地址,然后更改bp和pc为新函数,执行新函数,执行完之后将变量存到caller的栈空间中,利用栈空间中保存的返回地址和caller的栈基地址,恢复pc和sp回到caller的执行过程。
对于栈变量的访问是通过bp+offset的方式来访问,而对于在堆上分配的变量来说,就是通过地址来访问。在go中,变量被分配到堆上还是被分配到栈上是由编译器在编译时根据逃逸分析决定的,不可以更改,只能利用规则尽量让变量被分配到栈上,因为局部性优势,栈空间的内存访问速度快于堆空间访问。
4 - 方法
一般语言中,比如c/c++/Java/python等,函数和方法是等同的。但是在Go语言中,函数和方法有明确的区分,是两个不同的概念:
-
函数/Function:不属于任何结构体、类型,没有接收者
-
func Add(a, b int) int { return a + b }
-
-
方法/Method:和特定的结构体、类型关联,带有接收者
-
type person struct { name string } func (p person) String() string{ return "the person name is "+p.name }
-
简单总结:
Remember: a method is just a function with a receiver argument.
请记住:方法只是一个带有接收者参数的函数。
摘要:Methods vs Functions in Golang
https://medium.com/@ishagirdhar/methods-vs-functions-in-golang-c60586bfa6b4
方法还是函数?函数还是方法?
对于从Java或者其他面向对象的语言背景过来的人看来,第一直觉是处处都用结构体(struct)和方法(method),因为对象的行为总是由方法定义的。但在Golang中,我们既有函数又有方法,这种做法正确吗?
哪些地方我们需要使用方法,哪些地方我们需要函数?
我们先来看看Golang中什么是函数,什么是方法。
**函数(function)**接受一些参数作为输入,并产生一些输出。对于相同的输入,函数总是会产生相同的输出。这意味着它不依赖于状态。类型是作为函数的参数传递的。
Go中的**方法(Method)**是带有特定 receiver(type) 参数上的函数。它定义了类型的行为,它应该使用类型的状态。
但是,如果我们在结构体里面没有状态,那么我们是不是根本就不要定义方法呢?答案是我们可以定义,但这主要是为了对该特定类型的方法进行逻辑分组。
不定义方法的规则是,如果
- 不需要依赖状态
- 可以在任何实现特定接口的类型上执行这个函数,这意味着不需要限制这个函数属于某个特定的类型。
接收者的按值传递
Go语言里有两种类型的接收者:值接收者和指针接收者。
-
使用值接收者: 在调用的时候,方法使用的其实是值接收者的一个副本,所以对该值的任何操作,不会影响原来的值接收者(或者说类型变量)。
-
使用指针接收者:因为指针接收者传递的是一个指向原值指针的副本(即指针的副本),其指向的还是原来类型的值,所以修改时,同时也会影响原来值接收者(类型变量)的值。
总结:
在调用方法的时候,传递的接收者本质上都是副本,只不过可以是值的副本,也可以是指向这个值的指针的副本。指针具有指向原有值的特性,所以修改了指针指向的值,也就修改了原有的值。
Effective Go
Pointers vs. Values
https://golang.org/doc/effective_go.html#pointers_vs_values
正如我们在ByteSize中看到的那样,可以为任何命名类型(除了指针或接口)定义方法;接收者不一定是结构体。
在上面关于切片的讨论中,我们写了一个Append函数。我们可以把它定义为一个关于切片的方法。要做到这一点,我们首先声明一个可以绑定方法的命名类型,然后使该方法的接收者是该类型的值。
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// Body exactly the same as the Append function defined above.
}
这仍然需要该方法返回更新后的切片。我们可以通过重新定义该方法来消除这种笨拙,将一个指向ByteSlice的指针作为它的接收器,这样该方法就可以覆盖调用者的切片。
func (p *ByteSlice) Append(data []byte) {
slice := *p
// Body as above, without the return.
*p = slice
}
事实上,我们还可以做得更好。如果我们修改函数,使它看起来像一个标准的写方法,像这样。
func (p *ByteSlice) Write(data []byte) (n int, err error) {
slice := *p
// Again as above.
*p = slice
return len(data), nil
}
那么*ByteSlice类型满足标准接口io.Writer,这很方便。例如,我们可以打印成一个。
var b ByteSlice
fmt.Fprintf(&b, "This hour has %d days\n", 7)
关于接收者的指针与值的规则是:值方法可以在指针和值上被调用,但指针方法只能在指针上被调用。
这个规则的产生是因为指针方法可以修改接收者;在值上调用它们会导致该方法接收到一个值的副本,所以任何修改都会被丢弃。因此,语言不允许这种错误。不过有一个方便的例外。当值是可寻址的时候,语言通过自动插入地址操作符来处理在值上调用指针方法的常见情况。在我们的例子中,变量b是可寻址的,所以我们可以只用b.Write来调用它的Write方法。编译器会帮我们改写成 (&b).Write。
顺便说一下,在字节切片上使用Write的想法是实现bytes.Buffer的核心。
摘要:方法的本质
摘录自 函数——go世界中的一等公民 “方法的本质” 一节
go的方法就是语法糖:实际上Method就是将receiver作为函数的第一个参数输入的语法糖而已,本质上和函数没有区别
参考资料
5 - 可变参数
golang语言规范
Passing arguments to ...
parameters
https://golang.org/ref/spec#Passing_arguments_to_..._parameters
如果 f 是变数(variadic),其最终参数p的类型为…T,那么在 f 中,p的类型等同于类型 []T。如果调用f时,p没有实际参数,那么传递给p的值就是nil。否则,传递的值是一个类型为 []T 的新的底层数组的分片,其连续的元素是实际的参数,这些参数都必须是可以分配给T的,因此分片的长度和容量是与p绑定的参数数字,对于每个调用点来说可能会有所不同。
给定函数和调用:
func Greeting(prefix string, who ...string)
Greeting("nobody")
Greeting("hello:", "Joe", "Anna", "Eileen")
Greeting在第一次调用时的值为nil,第二次调用时的值为 []string{“Joe”, “Anna”, “Eileen”} 。
如果最后的参数可以分配给一个分片类型 []T,那么如果参数后面有 …T 参数,它将作为 …T 参数的值不变地传递。在这种情况下,不会创建新的分片。
给定分片s并调用
s := []string{"James", "Jasmine"}
Greeting("goodbye:", s...)
在Greeting,它的值将与s的底层数组相同。
摘要:Go 语言“可变参数函数”终极指南
https://studygolang.com/articles/11965
可变参数函数即其参数数量是可变的 —— 0 个或多个。声明可变参数函数的方式是在其参数类型前带上省略符(三个点)前缀。
可变参数的使用场景:
- 避免创建仅作传入参数用的临时切片
- 当参数数量未知
- 传达你希望增加可读性的意图
可变参数函数会在其内部创建一个”新的切片”。事实上,可变参数是一个简化了切片类型参数传入的语法糖。
当不传入参数的时候,可变参数会成为一个空值切片( nil
):
所有的非空切片都有内建的数组,而 nil 切片则没有。
然而,当你向 nil 切片添加元素时,它会自动内建一个包含该元素的数组。这个切片也就再也不是一个 nil 切片了。
可以通过向一个已有的切片添加可变参数运算符 ”…“ 后缀的方式将其传入可变参数函数。
names := []string{"carl", "sagan"}
toFullname(names...)
这就好比通常的传参方式:
toFullname("carl", "sagan")
**不过,这里还是有一点差异:**函数会在内部直接使用这个传入的切片,并不会创建一个的新的。
可以像下面这样将数组转化成切片后传入可变参数函数:
names := [2]string{"carl", "sagan"}
toFullname(names[:]...)
传入的切片和函数内部使用的切片共享同一个底层数组,因此在函数内部改变这个数组的值同样会影响到传入的切片:
参考资料
- Go 语言“可变参数函数”终极指南:推荐阅读,非常详尽
6 - 闭包
闭包相关的经典总结:
-
闭包=函数+引用环境
-
对象是附有行为的数据,而闭包是附有数据的行为
https://gobyexample-cn.github.io/closures
Go 支持匿名函数, 并能用其构造闭包。 匿名函数在你想定义一个不需要命名的内联函数时是很实用的。
intSeq
函数返回一个在其函数体内定义的匿名函数。 返回的函数使用闭包的方式 隐藏 变量 i
。 返回的函数 隐藏 变量 i
以形成闭包。
func intSeq() func() int {
i := 0
return func() int {
i++
return i
}
}
我们调用 intSeq
函数,将返回值(一个函数)赋给 nextInt
。 这个函数的值包含了自己的值 i
,这样在每次调用 nextInt
时,都会更新 i
的值。
func main() {
nextInt := intSeq()
// 通过多次调用 nextInt 来看看闭包的效果:
fmt.Println(nextInt()) // 1
fmt.Println(nextInt()) // 2
fmt.Println(nextInt()) // 3
// 为了确认这个状态对于这个特定的函数是唯一的,我们重新创建并测试一下。
newInt2 := intSeq()
fmt.Println(newInt2()) // 1
}
参考资料
7 - 递归函数
什么是递归函数:
Technically, a recursive function is a function that makes a call to itself. To prevent infinite recursion, you need an if-else statement (of some sort) where one branch makes a recursive call, and the other branch does not. The branch without a recursive call is usually the base case (base cases do not make recursive calls to the function).
从技术上讲,递归函数是一个对自己进行调用的函数。为了防止无限递归,你需要一个 if-else 语句(或它的某种形式),其中一个分支进行递归调用,而其他分支不进行递归调用。没有递归调用的分支通常是基础案例(基础案例不对函数进行递归调用)。