在编程领域,我们经常听到一个词——“轮子”,也经常听到一句话——“不要重复造轮子”。这其中的“轮子”在一定程度上就是指本讲以及后续几讲中介绍的“函数”。
实际上,在前面的几讲内容中,我们已经或多或少地用过函数了。比如获取容器内元素个数的len(),用于在控制台输出字符串的fmt.println()等等。这些都是Go SDK中内置的函数,通过这些内置的函数,可以实现一些需求,但并不是全部。有些时候还需要我们实现自定义的函数,以满足项目的独特性需求。
举个例子,还记得我们一直使用的查找素数示例吗?如果要更改查找范围,一直以来的方法是修改最外层循环的终止条件。实际上,更“聪明”的做法是将查找素数的逻辑封装为函数,将循环终止的条件设置为函数的参数。调用这个函数时,只要传入不同的参数,即可实现对查找范围的控制了。是不是很方便呢?
像这种做一次即可反复使用的函数、库等等,就可以称其为“轮子”。它们的共同特点便是:直接拿来就能用,塞进项目中就能实现想要的一个或多个功能。因此,对于已经存在且足够好用的轮子,就“不要重复造轮子”了。
本讲就来介绍如何定义、调用函数及相关的技巧和注意事项,具体如下:
- 普通函数的定义和调用
- 函数的延迟调用
# 普通函数的定义和调用
现在,我们就来逐步将查找素数的逻辑封装为函数。
在Go语言中,定义一个普通函数的格式如下:
go
复制代码func function_name([params_list])([return_values_list]){
// 函数体
}
其中,func关键字表示定义一个函数;function_name是函数名;params_list表示参数列表;return_values_list表示函数的返回值列表;使用大括号包裹起来的部分称为函数体,是函数内部要执行的代码。其中,参数列表和返回值列表是可选的。有些函数无需参数,有些参数运行后并不会有任何返回值,有些函数则无需参数也无需返回值。
先举个简单的例子,下面的函数实现了将参数(即传入的string类型字符串)作为返回值(类型也为string)输出。
go
复制代码func stringLoop(content string) string {
return content
}
这段代码一共有三行,我们逐行拆解:
- 第一行定义了名为stringLoop的函数,参数列表中,content是参数名,string是参数的类型。当需要多个参数是,使用英文的逗号(,)隔开。再往后表示该函数运行后将返回string类型的结果;
- 第二行是函数体,return表示返回函数运行的结果。本例是直接将传入的参数——content变量的值返回;
- 第三行是函数的结尾。
❗️ 注意: return语句一般在函数结尾处出现,在该语句后的代码不会被执行。
下面,尝试调用这个函数,整个函数定义与调用的示例如下:
go
复制代码func main() {
result := stringLoop("字符串回环测试")
fmt.Println(result)
}
// 用于测试的自定义函数
func stringLoop(content string) string {
return content
}
显然,整个程序代码由两个函数组成:main()和stringLoop()。程序运行后,main()函数被调用,声明了result变量,该变量的值来自stringLoop()函数的运行结果。在调用stringLoop()函数时,向其中传入了“字符串回环测试”的字样,因此这个函数运行后,也将原样返回这些文字。并将这些文字赋值到result变量。最后,调用了fmt.Println()函数,输出了result的值。因此,甚至无需真的运行,我们便可得知程序的运行输出结果应该是:
字符串回环测试
❗️ 注意: main()函数和stringLoop()函数时两个不同的函数,Go语言不允许函数的嵌套,因此两个函数应独立定义和实现。
接下来,我们依葫芦画瓢,改造查找素数的案例,使其用起来更加自由,成为可靠的“轮子”。
为了方便对比,我先将原有的代码搬过来:
go
复制代码func main() {
var resultSlice []int
for i := 2; i < 10; i++ {
//假定i为素数
flag := true
for j := 2; j < i; j++ {
if i%j == 0 {
//当i能被某个整数整除时,不是素数
flag = false
}
}
//如果依旧为true,则i为素数
if flag {
//将素数存放到resultSlice数组中
resultSlice = append(resultSlice, i)
}
}
fmt.Println(resultSlice)
}
要做的只有一件事:将参数作为查找范围实现更自由地调用。
我们已经知道,上述代码中查找素数的逻辑实际上就是从头开始,直到输出resultSlice变量为止。因此,我们便可将这部分代码提取出来,封装为一个函数,我们将这个函数起名为findPrimeNumber(查找素数)。显然,该函数需要一个int型的参数,表示查找的最大范围;还需要切片类型的返回值,表示查找的结果。因此,该函数的定义和实现代码如下:
go
复制代码func findPrimeNumber(max int) []int {
var resultSlice []int
for i := 2; i < max; i++ {
//假定i为素数
flag := true
for j := 2; j < i; j++ {
if i%j == 0 {
//当i能被某个整数整除时,不是素数
flag = false
}
}
//如果依旧为true,则i为素数
if flag {
//将素数存放到resultSlice数组中
resultSlice = append(resultSlice, i)
}
}
return resultSlice
}
接着,继续改动main()函数,调用findPrimeNumber(),并输出查询结果。
go
复制代码func main() {
var resultSlice []int
resultSlice = findPrimeNumber(10)
fmt.Println(resultSlice)
}
运行整个程序,如无意外,则可在控制台中看到如下输出:
[2 3 5 7]
到此,findPrimeNumber()函数便成为了一个合格的“轮子”。通过传入不同的int值,可实现查找特定范围内的素数之功能。
接下来抛出一个思考题:在这段代码中,main()函数中的resultSlice和findPrimeNumber()中的resultSlice有何关系呢?
答案是:这二者之间没有任何关系!因为main()函数和findPrimeNumber()函数是两个函数,互相独立,互不影响。各自的resultSlice变量都是在各自的函数体内声明和使用的,无法共用,只是凑巧名称和类型都一样而已。大家不要将它俩混为一谈。
如果理解了这一点,我们再来看看下面这段代码。不要在GoLand中运行,先猜猜它的运行结果:
go
复制代码func main() {
var resultSlice []int
findPrimeNumber(resultSlice, 10)
fmt.Println(resultSlice)
}
func findPrimeNumber(result []int, max int) {
for i := 2; i < max; i++ {
//假定i为素数
flag := true
for j := 2; j < i; j++ {
if i%j == 0 {
//当i能被某个整数整除时,不是素数
flag = false
}
}
//如果依旧为true,则i为素数
if flag {
//将素数存放到result数组中
result = append(result, i)
}
}
}
答案揭晓:这段程序的运行结果为:
[]
相信不少人看到这里会一脸懵。为什么我把main()函数中的resultSlice传入findPrimeNumber()了,并且在findPrimeNumber()中对其做了修改,却并没有使resultSlice发生变化呢?
要解开这个谜团,我们不妨输出 main()函数中resultSlice和findPrimeNumber()中,result的内存地址。因为只有这二者的地址相同,才能证明这两个变量是“一回事”。
输出内存地址的代码示例如下:
go
复制代码ptr := &variable
fmt.Println(&ptr)
其中,variable表示变量名,对应本例为main()中的resultSlice和findPrimeNumber()中的result。
这一对比的结果是显而易见的,main()中的resultSlice和findPrimeNumber()中的result,二者内存地址是不同的!这也就意味着,无论result变量在findPrimeNumber()中作出如何改变,都无法作用到main()中的resultSlice变量上。问题也就跟着来了:在函数间传值的过程中,到底发生了什么呢?
实际上,这里涉及到两个容易混淆的传递概念——值传递和引用传递。
像上述示例当中的做法,即直接传递一个变量名到另一个函数中,属于值传递。按照Go代码的执行策略,发生值传递时,将在另一个函数中自动生成一个值的副本。所以我们才会看到main()中的resultSlice和findPrimeNumber()中的result的内存地址是不同的,因为后者完全是前者的“替身”,我们在findPrimeNumber()函数中只是对替身做了改变,“真身”根本就没有收到影响!
与值传递相对的便是引用传递,这种方式在函数间传递的是指针,而指针恰恰是内存地址。这样的传值,无论发生在多少个函数之间,改变将始终作用于相同地址的数据上。
了解过值传递和引用传递后,便很清楚如何修改我们的代码了——只需将原有的值传递改为引用传递,即向findPrimeNumber()函数传递resultSlice的内存地址就行了。当然,还要适当修改findPrimeNumber()函数的定义和逻辑。修改后的完整代码如下:
go
复制代码func main() {
var resultSlice []int
findPrimeNumber(&resultSlice, 10)
fmt.Println(resultSlice)
}
func findPrimeNumber(result *[]int, max int) {
for i := 2; i < max; i++ {
//假定i为素数
flag := true
for j := 2; j < i; j++ {
if i%j == 0 {
//当i能被某个整数整除时,不是素数
flag = false
}
}
//如果依旧为true,则i为素数
if flag {
//将素数存放到result数组中
*result = append(*result, i)
}
}
}
再次运行程序,将输出:
[2 3 5 7]
除了将值传递改为引用传递外,还有一种“偷懒式”修改方法也可使程序正常输出——直接把resultSlice变量声明在函数外,改为全局变量,即可在所有函数中访问和修改这个变量了。
之所以称这种方法是“偷懒式”,是因为所有函数都能修改这个全局变量,数据存在一定的安全风险。若不慎错误地修改了变量的值,修复起来也会相对更困难一些了。
# 函数的延迟调用
接下来,我们保持findPrimeNumber()函数不变,在main()函数伊始添加两行神奇的代码:
go
复制代码func main() {
defer fmt.Print("素数")
defer fmt.Print("查找")
var resultSlice []int
findPrimeNumber(&resultSlice, 10)
fmt.Println(resultSlice)
}
很明显,main()函数开头的两行代码和普通的代码不同,前面有个“defer”。“defer”的作用是让整句代码延迟执行,且多个defer存在时,它们的顺序是反向的。
根据这一规律,我们便可推测上述代码运行的结果将是:
[2 3 5 7] 查找素数
defer的典型应用场景是执行一些收尾工作,通常是在常规逻辑执行结束后释放系统资源。如文件读写、网络IO等等。也用于程序在发生宕机时的恢复。
在这里,大家先对defer做到了解、会用即可。后续到了相应的章节处,我们还会和它再次相遇。
# 小结
🎉 恭喜,您完成了本次课程的学习!
📌 以下是本次课程的重点内容总结:
- 普通函数的定义和调用
- 函数的延迟调用
Go SDK中提供了大量实用的函数,我们可以随时调用它们完成项目所需要的功能。此外,我们还可以通过自定义函数,实现项目的独特性需求。
在自定义函数时,我们要掌握定义函数的格式以及调用函数的方法。无论函数是自用还是提供给团队中的其他成员,甚至是日后作为库发布,都要注意函数的命名,并为其添加必要的注释。
在调用函数时,要特别留意值传递和引用传递的区别。值传递会在被调用函数中自动生成一份传入参数的“副本”,改动不会影响“真身”;引用传递是在传递地址,被调用函数中对其的改变会对传入的参数产生影响。
➡️ 在下次课程中,我们会继续介绍Go语言中的函数,具体内容是:
- 递归算法