云原生系列Go语言篇-函数

Coding Alan 2年前 (2023-01-06) 1320次浏览 0个评论 扫描二维码

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

学到现在,我们的程序都局限于main函数中的寥寥数行。是时候搞点更大的动作了。本章中我们会学习如何在Go语言中编写函数以及函数那些有趣的功能。

声明及调用函数

读者只要使用过C、Python、Ruby或JavaScript等编程语言,那么对Go函数的基础也不会陌生。(Go中还有方法,在类型、方法和接口一章中进行讲解。)和控制结构一样,Go也为函数添加了一些特性。其中一些是改进,另一些是我们应当避免的试验。在本章中均会进行讨论。

我们已经见过如何声明和使用函数。我们编写的每个程序都有一个main函数,它是Go程序的入口,我们也调用过fmt.Println函数来在屏幕上显示输出。因main函数不接收参数也没有返回值,我们来看一个有参数和返回值的函数:

我们来看看这个示例代码中的新内容。函数声明包含4部分:关键字func、函数名、入参和返回类型。入参数位于括号中,由逗号分隔,参数名居前、类型居后。Go是一种有类型语言,因此必须指定参数的类型。返回类型位于参数的闭合括号和函数体的起始花括号之间。

和其它语言一样,Go的函数返回值有一个return关键字。如果函数有返回值,则必须存在return。如果函数无返回值,则函数结尾无需使用return语句。无返回值函数仅在最后一行之前退出时使用return关键字。

main函数没有入参或返回值。函数没有参数时,使用一对空括号()。函数无返回值时,参数闭合括号和函数体的起始花括号之间什么都不用写:

函数调用对于有过开发经验的人应该很熟悉了。在:=的右侧,我们调用了div函数,参数为5和2。左侧我们将其返回值赋给变量result

小贴士:在多个入参的类型相同时,可以这样写:

模拟命名参数和可选参数

在我们讲解Go语言中函数的独有特性时,先来讲Go中所不具备的两特性:命名参数和可选参数。除了下一节中会提到的特例,我们必须为函数传递所有参数。如果希望模拟命名参数和可选参数,定义一个包含所需参数的结构体,然后将结构体传递给函数。例5-1中为演示这一模式的代码段。

例5-1 使用结构体模拟命名参数

在实际编码中,缺少命名参数和可选参数并不是局限。函数的参数应当精简,命名参数和可选参数多用于有大量入参的函数。如果读者发现处于这种状况,很可能是函数过于复杂了。

可变参数和切片

我们一直在使用fmt.Println打印结果,读者可能注意到了它所接收的参数数量不限。那是怎么实现的呢?和很多编程语言一样,Go也支持可变参数。可变参数应为函数的最后一个参数或是唯一参数。通过在类型前加三个点() 来声明。在函数内所创建的变量为所指定类型的切片。用法和其它切片一样。我们编写一个程序来演示,将可变数量的参数与基数相加并返回最终的int切片。可在The Go Playground中运行这段程序。首先我们会编写一个可变参数的函数:

我们来用不同方式进行调用:

可以看到对可变参数可传任意数量的值,也可以不传。因可变参数会转化为切片,可直接传递切片。但是必须要在变量或切片字面量之后加三个点()。如若不加,就会报编译错误。

构建、运行程序后,得到的结果如下:

多返回值

Go和其它语言中的第一个差别就是Go中允许有多个返回值。我们来为前面的除法程序添加一个功能。让这个函数同时返回商和余数。更新后的函数如下:

此处做了一些修改来支持多返回值。在Go函数返回多个值时,返回值类型放在括号中,用逗号分隔。同时,如果函数返回多个值,必须要全数返回,使用逗号分隔。在返回值两边不要加括号,会报编译错误。

这里还有一个没有学过的知识:创建和返回error。如果希望学习有关错误的知识,请参见错误处理一章。现在读者只需要知道在函数出错时可使用多返回值来返回error。如果函数成功执行,返回的错误值使用nil。按照习惯,error通常是函数的最后一个或唯一返回值。

调用修改后的函数如下:

我们在基础类型和变量声明一章的var对照:=中讨论过一次赋多值。这里我们就用到这一特性将函数的结果返回给三个变量。在:=的右侧,我们使用参数5和2调用了divAndRemainder函数。在其左侧,我们将返回值赋给了变量resultremaindererr。通过比较errnil来查看是否有报错。

多返回值是多个值

熟悉Python的读者可能会多返回值看成Python函数所返回的元组,它在元组赋给多个变量时会自行解构。例5-2为在Python中所运行的示例代码。

例5-2 Python的多返回值是解构的元组

Go语言中并不是这样。必须对函数所返回的每个值赋变量。倘若尝试将多个返回值赋给同一个变量,会报编译错误。

忽略返回值

万一调用了函数又不想要使用所有的返回值呢?在在基础类型和变量声明一章的未使用变量中我们讲过,Go是不允许声明变量而不使用的。如果函数返回了多个值,而又不需要读取其中的一个或多个值时,将不需要使用的值赋给_。例如,如果不需要读取余数,可以在赋值时写作result, _, err := divAndRemainder(5, 2)

Go竟然还允许我们隐式地忽略掉函数的所有返回值。在写作divAndRemainder(5,2时,会丢弃掉其所有的返回值。其实从最早的示例我们就这么干了:fmt.Println会返回两个值,但地道的写法都会忽略掉它们。不过在其它的场景中,通常都应显式地使用下划线来忽略返回值。

小贴士:在无需读取函数的某个返回值时使用_

命名返回值

Go的函数不仅可以返回多个值,还可以为这些返回值指定名称。我们使用命名返回值再次重写divAndRemainder函数:

在对返回值提供名称时,实际上是在预先声明函数中存储返回值的变量。写法是在括号中使用逗号分隔。命名返回值即使只有一个也必须使用括号包裹。命名返回值在创建时会初始化为其零值。也就是说在显式使用或赋值前就可以返回它们。

另一个值得注意的事是:命名返回值的作用域为函数本地,并不会作用于函数外部的变量。将其返回值赋值给其它名称的变量毫无问题:

小贴士:如果只希望为部分返回值命名,可以对不希望命名的返回值使用_作为其名称。

虽然命名返回值有时会让代码保持清晰,但在一些极端情况下也会带来问题。首先就是代码遮蔽了。和其它变量一样,可能会遮蔽命名返回值。确保对其赋返回值而不会出现遮蔽。

命名返回值的另一个问题是不一定会返回。我们再次修改divAndRemainder。可在The Go Playground中运行这段代码:

注意我们对resultremainder进行了赋值,然后又直接返回了不同的值。在运行这段代码前,试着猜下对函数传5和2时会发生什么。结果可能会让你意外:

返回的是return语句的值,虽然并没有将它们周一给你命名返回参数。这是因为Go编译器为返回参数插入了赋值代码。命名返回参数提供一种声明想用于存储返回值的变量的方式,但并不要求你使用它们。

一些开发者喜欢使用命名返回参数,原因在于其文档属性。但我个人觉得其价值有限。遮蔽会带来困扰,和直接忽略一样。有一种场景命名返回参数很重要。我们会在本章稍后讲到defer时讨论。

空返回-请勿使用!

如果使用命名返回值,请格外注意Go的一大缺陷:空返回(有时也称为裸返回)。如果使用了命名返回值,可以只写return而不用指定返回值。这时会返回最后一次赋给命名参数的值。我们最后一次重写divAndRemainder函数,这次使用空返回:

使用空返回值对函数做出了一些修改。在输入无效时,立即返回。这时因为没有对resultremainder赋值,返回的是它们的零值。如果命名返回值返回的是零值,请确保符合逻辑。同时应注意我们在函数的最后也要添加return。虽然使用的是空返回语句,但函数还是有返回值的。不写return会报编译错误。

一开始你可能会觉得空返回很方便,因为可以少打一些代码。但大部分Go开发者都认为空返回不好,因为不易于理解数据流。好软件应该整洁易读,所做的事很清楚。使用空返回,读代码时需要回到程序中查找返回参数的最后一次赋值,才知道返回的是什么。

警告:如果函数有返回值,请勿使用空返回。很难确定实际的返回值是什么。

函数也是值

和众多其它编程语言一样,Go中的函数也是值。函数的类型由func关键字和参数及返回值类型定义。这种组合也称作函数的签名。只要函数的参数及返回值的类型和数量一致,就符合这一类型签名。

将函数作为值可以实现一些智能的功能,比如把函数作为字典的值构建原始计算器。我们一起看如何实现。代码位于The Go Playground中。首先,我们要设置具有相同签名的一组函数:

接着创建一个字典关联数学运算符和这些函数:

最后通过一些表达式来测试这个计算器:

我们使用了标准库中的strconv.Atoi函数,将string转化为int。这一函数返回的第二值是error。和之前一样,我们会检测函数所返回的错误并进行相应处理。

这里使用op作为opMap字典的键,将键所关联的值赋给变量opFuncopFunc的类型为func(int, int) int。如果所提供的键在字典中没有对应的函数,会打印错误消息并跳过这一循环。然后会将此前处理的变量p1p2变量传给opFunc完成调用。调用函数变量和直接调用函数是一样的。

运行这段程序会发现简化的计算器可正常工作:

注:别写不严谨的程序。上例中的核心逻辑比较短。for循环的22行代码中,有6行实现实际的算法,此外的16行都是做错误检查和数据校验。读者可能想跳过入参校验和错误检查,但这样的代码是不稳定、不易维护的。错误处理是专业开发者和业余码农的分水岭。

函数类型声明

正如我们可以使用type来定义struct,也可以使用它来定义函数类型(我们会在类型、方法和接口一章中深入讲解类型声明)。

然后可以将opMap的声明重写成这样:

完全无需修改函数。所有具有两个int类型参数和一个int类型返回值的函数都符合这一类型,可赋值给该字典。

声明函数类型有什么优势呢?一个用途是文档记录。在多次引用时有名称会更方便。在类型、方法和接口一章的函数类型是接口的桥梁中会学习其另一用途。

匿名函数

不仅可以将函数赋值给变量,还可以在函数定义一些新函数赋值给变量。

这些内层函数称为匿名函数,它们没有名称。也不一定也将它们赋值给变量。可以在行内编写并直接调用。以下是可在The Go Playground中运行的简单示例:

可使用func关键字直接接入参、返回值和起始花括号声明匿名函数。在func和入参间加函数名会报编译错误。

和其它函数一样,通过括号来调用匿名函数。本例中,我们传递的是for循环的变量i。将它赋给了匿名函数的入参j

运行程序的输出如下:

通常我们不会这么干。声明匿名函数又直接调用,完全可以去除匿名函数、直接调用代码。但有两种场景声明匿名函数又不赋值给变量是有用的:defer语句和启动协程。 一会儿我们就会聊到defer。协程会在并发一章中讲解。

闭包

在函数内声明函数很特别,它属于闭包。这是一个计算机科学领域的词,表示这些在函数内声明的函数可以访问、修改函数外声明的变量。

内层函数和闭包看上去可能并没有什么意思。在一个大函数内创建一堆迷你函数有什么用呢?Go又为什么有这个功能?

闭包的一个功能是限定函数的作用域。如果另一个函数中调用某个函数,而又调用了多次,可以使用内层函数来“隐藏”被调用函数。这会减少包级别的声明次数,也就更容易找到未使用的名称。

闭包在传递给其它函数或通过函数返回时才见其用武之处。这样可以拿出函数内的变量并在函数外使用这些值。

传递函数参数

因为函数也值,我们可以通过其参数和返回值指定函数类型,并为参数传递给其它函数。如果读者还不习惯把函数看成数据的话,可以需要花一点时间思考创建引用本地变量的闭包然后将这一闭包传给其它函数的意义。这是一种非常有用的模式,在标准库中多次出现。

比如切片的排序。标准库的sort包中有一个sort.Slice函数。它接收任意切片以及一个对所传入切片排序的函数。我们通过对有两个字段的结构体切片排序来进行理解。

注:Go语言早期并没有泛型,所以sort.Slice内部通过一些“魔法”让其可用于任意类型的切片。在恶龙三剑客:反射、Unsafe 和 Cgo一章中会讲到这种魔法。

我们来看如何使用闭包对相同数据做不同排序。可在The Go Playground中运行这段代码。首先我们会定义一个简单类型、该类型值的切片,并打印出切片:

接着,我们通过姓对切片排序,打印出结果:

传递给sort.Slice的闭包有两个参数,ij,但在闭包内,我们可以访问people,因而可以通过LastName字段进行排序。使用计算机科学的词汇来说,people被闭包所捕获。下面我们再使用Age字段排序:

运行这段代码得到的输出如下:

people切片在通过sort.Slice调用时发生了变化。在Go是值传递一节会简单讲解,在下一章会详细讨论。

小贴士:将函数作为参数传递给其它函数通常用于对相同数据执行不同操作。

函数返回函数

不仅可以使用闭包将一些函数状态传递给其它函数,函数也可以返回函数。我们通过编写一个返回乘法函数的函数来进行演示。可在The Go Playground中运行这段代码。以下是返回闭包的函数:

使用方式如下:

运行程序得到的结果如下:

学习闭包的使用,读者可能会想Go开发者会经常使用它吗?其实闭包非常有用。我们学习了如何使用它对切片排序。闭包还可用于sort.Search对切片进行高效搜索。而对返回闭包,在标准库一章中讲到中间件时我们会学习如何使用这一模式来为web服务端构建中间件。Go借助defer关键字来使用闭包实现资源清理。

注:如果读者接触过使用Haskell这种函数式编程语言的开发者,可能会听到高阶函数这个词。这是一种函数的入参或返回值也是函数的花式说法。作为Go开发者,你跟他们一样酷。

defer

程序经常会创建临时资源,比如文件或网络连接,需要及时清理。不论函数有多少个退出点或是函数是否成功调用,都需要进行这种清理。Go语言中,清理的代码放在defer关键字所关联的函数中。

我们来看如何使用defer释放资源。我们无法在The Go Playground中打开文件,读者可以在GitHub上的simple_cat目录下找到这段代码:

这个例子中引入了一些在后绪章节中讲解的新特性。读者可以提前阅读相关章节作进一步了解。

首先,我们通过检查os.Args的长度确保命令行中传入了文件名,os.Argsos包中一个切片,包含启动程序名称及所传入的参数。如果未传参数,使用log包中的log函数打印消息并退出程序。接着,通过os包中的Open函数获取一个文件只读句柄。Open返回的第二个参数是错误。如果在打开文件时出错,会打印错误消息、退出程序。前面说过在错误处理一章中会讨论错误。

在获取到了有效的文件句柄后,我们需要在使用完后进行关闭,不论函数时如何退出的。要确保清理代码运行,使用defer关键字接一个函数或方法调用。本例中我们使用了文件变量的Close方法(在类型、方法和接口一章中会学习方法)。通常函数会立即调用,但defer会将其调用延迟至外部函数退出之时。

我们通过向文件变量的Read方法传递一个字节切片读取文件句柄。在标准库一章的io及其小伙伴们一节中会详细讲解如何使用这个方法,Read的返回值是读取到切片中的字节数和一个错误。如果发生报错,我们会查看是否文件结束标记。如果文件的结尾,会使用break退出for循环。对于其它错误,会使用log.Fatal报告并立即退出。在Go是值传递一节中会再讨论到切片和函数参数,在下一章中会进行深入地讨论。

simple_cat目录中构建、运行程序会得到如下结果:

有关defer还有几点应该了解。首先在Go的函数中可以defer多个闭包。它们按后进先出的顺序执行,最后注册的defer最先运行。

defer闭包中的代码在返回语句之后运行。前面也说到可以在defer之后添加有入参的函数。正如defer不会立即运行那样,传入其闭包的参数也只会在闭包运行时才会进行运算。

注:可以对defer添加带返回值的函数,但无法读取这些值。

读者可能会想defer的函数是否是查看或修改外层函数的返回值呢?其实是有的,这也是命名返回值最好的理由。这会允许我们根据错误执行操作。在错误处理一章中讨论错误时,会讲到使用defer为函数所返回错误添加上下文信息的模式。下面使用命名返回值和defer处理数据库事务的清理:

这里不会讲解Go对数据库的支持,但标准库的database/sql包中对数据库提供了深度的支持。上面的函数中,我们创建了一个事务处理一系列数据库插入。如果其中有一个失败了,就需要回滚(不对数据做修改)。如果全部都成功,则需要提交事务(在数据库中存储修改)。我们使用闭包配合defer检测err是否有赋值。如果没有就运行tx.Commit(),它也有可能返回错误。如果返回错误,err的值会发生修改。只要有数据库操作返回了错误,就会调用tx.Rollback()

注:Go语言开发新手在为defer指定闭包时容易忘记写最后的括号。不写的话会报编译错误,逐渐就会习惯了。设想添加括号可以为闭包指定传入参数值会有助于记忆。
Go中常见的模式是让分配了资源的函数同时返回一个清理资源的闭包。在GitHub的simple_cat_cancel目录中,对简单cat程序进行了重写实现该功能。首先我们编写一个打开文件并返回闭包的帮助函数:

我们的函数返回了一个文件、一个函数和一个错误。其中的*表示它是一个文件指针。会在下一章中讨论指针。

main中,我们使用该getFile函数:

因为Go语言不允许存在未使用的变量,函数返回closer就意味着如果不调用该函数程序就不会编译。这会提醒我们使用defer。前面已经讲过,延迟调用的closer之后要放上括号。

注:有些开发者习惯了在其它语言中使用函数内代码块控制资源的清理,比如Java、Javascript和Python中的try/catch/finally代码块(Python 中的关键字是 except)或Ruby中的begin/rescue/ensure代码块,可能会觉得defer很奇怪。

这些资源清理代码块的缺点是它们在函数中增加了一层缩进,这会让代码更难阅读。嵌套代码更难理解不是一家之言。在Empirical Software Engineering 2017年的一篇研究论文中,Vard Antinyan、Miroslaw Staron, and Anna Sandberg发现:“在计划的11种代码特征中,只有两种会显著导致复杂性的上升:嵌套深度和缺乏结构。”

有关更易阅读和理解程序的研究不是新课题。可以看到几十年前的论文,其中包含Richard Miara、Joyce Musseman、Juan Navarro和Ben Shneiderman于1983年所著论文,他们尝试找到缩进的正确数量(研究结果是两到四个空格)。

Go为值传递

读者可能听到人们说Go是一种值传递的语言,并会想是什么意思。它表示在为函数提供变量作为其参数时,Go总是会生成一个变量的拷贝。我们来看个示例。可在The Go Playground中运行这段代码。首先,定义一个简单的结构体:

接下来编写接收一个int、一个string和一个person类型并修改其值的函数:

main函数中调用该函数,查看修改是否生效:

就像函数名称所表明的那样,运行这段代码后会发现传入函数的参数值不会发生改变:

这里特意使用了person结构来表明它不仅适用于内置类型。有Java、JavaScript、Python或Ruby编程经验的读者会觉得struct这种行为很怪异。因为这些语言中在将对象以参数传入函数时是可以修改对象中的字段的。出现差别的原因会在讨论指针时进行讲解。

字典和切片又会有些不同。我们来看看在函数中对它做出修改会发生什么。可在The Go Playground中运行这段代码。这里编写一个修改字典参数的函数和一个修改切片参数的函数:

然后在main中调用这两个函数:

运行这段代码,会发现颇有些意思:

对于字典,很容易解释发生了什么:任何对字典参数的修改都会反映到传入函数的变量中。而对于切片,则更为复杂。可以修改切片中的元素,但不能加长切片。不管是直接传入函数的字典和变量还是结构体中的字典切片都是如此。

这个程序引出了一个问题:字典和切片为什么和其它类型不一样呢?这是因为字典及切片都是通过指针来实现的。我们会在下一章中详细讲解。

小贴士:Go语言中每种类型都是值类型。只是有时候这个值是指针。

值传递是Go中对常量提供有限支持只是小问题的一个原因。因为变量通过值传递,可以保证调用函数不会修改所传入的变量(切片或字典变量除外)。总的来说这是一件好事,函数不修改其入参而是返回新的计算值会让程序中的数据流更容易理解。

虽然这种方式易于理解,但有时需要向函数传递一些可变参数。那该怎么办呢?这正是该使用指针的时候了。

小结

本章中,我们学习了Go语言中的函数,它与其它函数中函数的相似点以及独有的特性。下一章中我们会学习指针,读者会发现它并不像很多Go语言新手所担心的那样可怕,同时学习如何利用指针编写高效的程序。

喜欢 (0)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址