Go基础系列:函数(1)

时间:2021-02-23 22:17:51

Go中函数特性简介

对Go中的函数特性做一个总结。懂则看,不懂则算。

  1. Go中有3种函数:普通函数、匿名函数(没有名称的函数)、方法(定义在struct上的函数)。
  2. Go编译时不在乎函数的定义位置,但建议init()定义在最前面(如果有的话),main函数定义在init()之后,然后再根据函数名的字母顺序或者根据调用顺序放置各函数的位置。
  3. 函数的参数、返回值以及它们的类型,结合起来成为函数的签名(signature)。
  4. 函数调用的时候,如果有参数传递给函数,则先拷贝参数的副本,再将副本传递给函数。
    • 由于引用类型(slice、map、interface、channel)自身就是指针,所以这些类型的值拷贝给函数参数,函数内部的参数仍然指向它们的底层数据结构。
  5. 函数参数可以没有名称,例如func myfunc(int,int)
  6. Go中的函数可以作为一种type类型,例如type myfunc func(int,int) int
    • 实际上,在Go中,函数本身就是一种类型,它的signature就是所谓的type,例如func(int,int) int。所以,当函数ab()赋值给一个变量ref_abref_ab := ab,不能再将其它函数类型的函数cd()赋值给变量ref_ab
  7. Go中作用域是词法作用域,意味着函数的定义位置决定了它能看见的变量。
  8. Go中不允许函数重载(overload),也就是说不允许函数同名。
  9. Go中的函数不能嵌套函数,但可以嵌套匿名函数。
  10. Go实现了一级函数(first-class functions),Go中的函数是高阶函数(high-order functions)。这意味着:
    • 函数是一个值,可以将函数赋值给变量,使得这个变量也成为函数
    • 函数可以作为参数传递给另一个函数
    • 函数的返回值可以是一个函数
    • 这些特性使得函数变得无比的灵活,例如回调函数、闭包等等功能都依赖于这些特性。
  11. Go中的函数不支持泛型(目前不支持),但如果需要泛型的情况,大多数时候都可以通过接口、type switch、reflection的方式来解决。但使用这些技术使得代码变得更复杂,性能更低。

参数和返回值

函数可以有0或多个参数,0或多个返回值,参数和返回值都需要指定数据类型,返回值通过return关键字来指定。

return可以有参数,也可以没有参数,这些返回值可以有名称,也可以没有名称。Go中的函数可以有多个返回值。

  • (1).当返回值有多个时,这些返回值必须使用括号包围,逗号分隔
  • (2).return关键字中指定了参数时,返回值可以不用名称。如果return省略参数,则返回值部分必须带名称
  • (3).当返回值有名称时,必须使用括号包围,逗号分隔,即使只有一个返回值
  • (4).但即使返回值命名了,return中也可以强制指定其它返回值的名称,也就是说return的优先级更高
  • (5).命名的返回值是预先声明好的,在函数内部可以直接使用,无需再次声明。命名返回值的名称不能和函数参数名称相同,否则报错提示变量重复定义
  • (6).return中可以有表达式,但不能出现赋值表达式,这和其它语言可能有所不同。例如return a+b是正确的,但return c=a+b是错误的

例如:

// 单个返回值
func func_a() int{
return a
} // 只要命名了返回值,必须括号包围
func func_b() (a int){
// 变量a int已存在,无需再次声明
a = 10
return
// 等价于:return a
} // 多个返回值,且在return中指定返回的内容
func func_c() (int,int){
return a,b
} // 多个返回值
func func_d() (a,b int){
return
// 等价于:return a,b
} // return覆盖命名返回值
func func_e() (a,b int){
return x,y
}

Go中经常会使用其中一个返回值作为函数是否执行成功、是否有错误信息的判断条件。例如return value,existsreturn value,okreturn value,err等。

当函数的返回值过多时,例如有4个以上的返回值,应该将这些返回值收集到容器中,然后以返回容器的方式去返回。例如,同类型的返回值可以放进slice中,不同类型的返回值可以放进map中。

但函数有多个返回值时,如果其中某个或某几个返回值不想使用,可以通过下划线_这个blank identifier来丢弃这些返回值。例如下面的func_a函数两个返回值,调用该函数时,丢弃了第二个返回值b,只保留了第一个返回值a赋值给了变量a

func func_a() (a,b int){
return
} func main() {
a,_ := func_a()
}

按值传参

Go中是通过传值的方式传参的,意味着传递给函数的是拷贝后的副本,所以函数内部访问、修改的也是这个副本。

例如:

a,b := 10,20
min(a,b)
func min(x,y int) int{}

上面调用min()时,是将a和b的值拷贝一份,然后将拷贝的副本赋值给变量x,y的,所以min()函数内部,访问、修改的一直是a、b的副本,和原始的数据对象a、b没有任何关系。

如果想要修改外部数据(即上面的a、b),需要传递指针。

例如,下面两个函数,func_value()是传值函数,func_ptr()是传指针函数,它们都修改同一个变量的值。

package main

import "fmt"

func main() {
a := 10
func_value(a)
fmt.Println(a) // 输出的值仍然是10 b := &a
func_ptr(b)
fmt.Println(*b) // 输出修改后的值:11
} func func_value(x int) int{
x = x + 1
return x
} func func_ptr(x *int) int{
*x = *x + 1
return *x
}

map、slice、interface、channel这些数据类型本身就是指针类型的,所以就算是拷贝传值也是拷贝的指针,拷贝后的参数仍然指向底层数据结构,所以修改它们可能会影响外部数据结构的值。

另外注意,赋值操作b = a+1这种类型的赋值也是拷贝赋值。换句话说,现在底层已经有两个数据对象,一个是a,一个是b。但a = a+1这种类型的赋值虽然本质上是拷贝赋值,但因为a的指针指向特性,使得结果上看是原地修改数据对象而非生成新数据对象。

变长参数"..."(variadic)

有时候参数过多,或者想要让函数处理任意多个的参数,可以在函数定义语句的参数部分使用ARGS...TYPE的方式。这时会将...代表的参数全部保存到一个名为ARGS的slice中,注意这些参数的数据类型都是TYPE。

...在Go中称为variadic,在使用...的时候(如传递、赋值),可以将它看作是一个slice,下面的几个例子可以说明它的用法。

例如:func myfunc(a,b int,args...int) int {}。除了前两个参数a和b外,其它的参数全都保存到名为args的slice中,且这些参数全都是int类型。所以,在函数内部就已经有了一个args = []int{....}的数据结构。

例如,下面的例子中,min()函数要从所有参数中找出最小的值。为了实验效果,特地将前两个参数a和b独立到slice的外面。min()函数内部同时会输出保存到args中的参数值。

package main

import "fmt"

func main() {
a,b,c,d,e,f := 10,20,30,40,50,60
fmt.Println(min(a,b,c,d,e,f))
} func min(a,b int,args...int) int{
// 输出args中保存的参数
// 等价于 args := []int{30,40,50,60}
for index,value := range args {
fmt.Printf("%s%d%s %d\n","args[",index,"]:",value)
} // 取出a、b中较小者
min_value := a
if a>b {
min_value = b
}
// 取出所有参数中最小值
for _,value := range args{
if min_value > value {
min_value = value
}
}
return min_value
}

但上面代码中调用函数时传递参数的方式显然比较笨重。如果要传递的参数过多(要比较的值很多),可以先将这些参数保存到一个slice中,再传递slice给min()函数。传递slice给函数的时候,使用SLICE...的方式即可。

func main() {
s1 := []int{30,40,50,60,70}
fmt.Println(min(10,20,s1...))
}

上面的赋值方式已经能说明能使用slice来理解...的行为。另外,下面的例子也能很好的解释:

// 声明f1()
func f1(s...string){
// 调用f2()和f3()
f2(s...)
f3(s)
} // 声明f2()和f3()
func f2(s...string){}
func f3(s []string){}

如果各参数的类型不同,又想定义成变长参数,该如何?第一种方式,可以使用struct,第二种方式可以使用接口。接口暂且不说,如果使用struct,大概如下:

type args struct {
arg1 string
arg2 int
arg3 type3
}

然后可以将args传递给函数:f(a,b int,args{}),如果args结构中需要初始化,则f(a,b int,args{arg1:"hello",arg2:22})

内置函数

在builtin包中有一些内置函数,这些内置函数额外的导入包就能使用。

有以下内置函数:

$ go doc builtin | grep func
func close(c chan<- Type)
func delete(m map[Type]Type1, key Type)
func panic(v interface{})
func print(args ...Type)
func println(args ...Type)
func recover() interface{}
func complex(r, i FloatType) ComplexType
func imag(c ComplexType) FloatType
func real(c ComplexType) FloatType
func append(slice []Type, elems ...Type) []Type
func make(t Type, size ...IntegerType) Type
func new(Type) *Type
func cap(v Type) int
func copy(dst, src []Type) int
func len(v Type) int
  • close用于关闭channel
  • delete用于删除map中的元素
  • copy用于拷贝slice
  • append用于追加slice
  • cap用于获取slice的容量
  • len用于获取
    • slice的长度
    • map的元素个数
    • array的元素个数
    • 指向array的指针时,获取array的长度
    • string的字节数
    • channel的channel buffer中的未读队列长度
  • printprintln:底层的输出函数,用来调试用。在实际程序中,应该使用fmt中的print类函数
  • compleximagreal:操作复数(虚数)
  • panicrecover:处理错误
  • newmake:分配内存并初始化
    • new适用于为值类(value type)的数据类型(如array,int等)和struct类型的对象分配内存并初始化,并返回它们的指针给变量。如v := new(int)
    • make适用于为内置的引用类的类型(如slice、map、channel等)分配内存并初始化底层数据结构,并返回它们的指针给变量,同时可能会做一些额外的操作

注意,地址和指针是不同的。地址就是数据对象在内存中的地址,指针则是占用一个机器字长(32位机器是4字节,64位机器是8字节)的数据,这个数据中存储的是它所指向数据对象的地址。

a -> AAAA
b -> Pointer -> BBBB

new()和make()构造数据对象赋值给变量的都是指向数据对象的指针。

递归函数

函数内部调用函数自身的函数称为递归函数。

使用递归函数最重要的三点:

  1. 必须先定义函数的退出条件,退出条件基本上都使用退出点来定义,退出点常常也称为递归的基点,是递归函数的最后一次递归点,或者说没有东西可递归时就是退出点。
  2. 递归函数很可能会产生一大堆的goroutine(其它编程语言则是出现一大堆的线程、进程),也很可能会出现栈空间内存溢出问题。在其它编程语言可能只能设置最大递归深度或改写递归函数来解决这个问题,在Go中可以使用channel+goroutine设计的"lazy evaluation"来解决。
  3. 递归函数通常可以使用level级数的方式进行改写,使其不再是递归函数,这样就不会有第2点的问题。

例如,递归最常见的示例,求一个给定整数的阶乘。因为阶乘的公式为n*(n-1)*...*3*2*1,它在参数为1的时候退出函数,也就是说它的递归基点是1,所以对是否为基点进行判断,然后再写递归表达式。

package main

import "fmt"

func main() {
fmt.Println(a(5))
} func a(n int) int{
// 判断退出点
if n == 1 {
return 1
}
// 递归表达式
return n * a(n-1)
}

它的调用过程大概是这样的:

Go基础系列:函数(1)

再比如斐波那契数列,它的计算公式为f(n)=f(n-1)+f(n-2)f(2)=f(1)=1。它在参数为1和2的时候退出函数,所以它的退出点为1和2。

package main

import "fmt"

func main() {
fmt.Println(f(3))
} func f(n int) int{
// 退出点判断
if n == 1 || n == 2 {
return 1
}
// 递归表达式
return f(n-1)+f(n-2)
}

如何递归一个目录?它的递归基点是文件,只要是文件就返回,只要是目录就进入。所以,伪代码如下:

func recur(dir FILE) FILE{
// 退出点判断
if (dir is a file){
return dir
} // 当前目录的文件列表
file_slice := filelist() // 遍历所有文件
for _,file := range file_slice {
return recur(file)
}
}

匿名函数

匿名函数是没有名称的函数。一般匿名函数嵌套在函数内部,或者赋值给一个变量,或者作为一个表达式。

定义的方式:

// 声明匿名函数
func(args){
...CODE...
} // 声明匿名函数并直接执行
func(args){
...CODE...
}(parameters)

下面的示例中,先定义了匿名函数,将其赋值给了一个变量,然后在需要的地方再去调用执行它。

package main

import "fmt"

func main() {
// 匿名函数赋值给变量
a := func() {
fmt.Println("hello world")
}
// 调用匿名函数
a()
fmt.Printf("%T\n", a) // a的type类型:func()
fmt.Println(a) // 函数的地址
}

如果给匿名函数的定义语句后面加上(),表示声明这个匿名函数的同时并执行:

func main() {
msg := "Hello World"
func(m string) {
fmt.Println(m)
}(msg)
}

其中func(c string)表示匿名函数的参数,func(m string){}(msg)msg表示传递msg变量给匿名函数,并执行。

func type

可以将func作为一种type,以后可以直接使用这个type来定义函数。

package main

import "fmt"

type add func(a,b int) int

func main() {
var a add = func(a,b int) int{
return a+b
}
s := a(3,5)
fmt.Println(s)
}