云原生系列Go语言篇-代码块,遮蔽和控制结构

Coding Alan 2年前 (2022-12-28) 1548次浏览 0个评论 扫描二维码

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

前面我们已经讲解了变量、常量和内置类型,下一步要学习程序逻辑和组织方式了。我们会先讲解代码块,以及代码块如何控制某个标识符的可用性。然后我们一起学习Go语言的控制结构:ifforswitch。最后我们会讨论goto,以及使用它的场景。

代码块

Go允许在多处声明变量。可以在函数外声明、声明函数参数也可以在函数内声明本地变量。

注:至此,我们只写过main函数,下一章就会开始写一些带参函数。

每个产生声明的地方称为代码块。函数外声明的变量、常量、类型位于包代码块中。我们已经使用过import 语句来在程序中使用打印和数学函数(在模块、包和导入中还会做进一步讨论)。它们为其它包定义有效名称,可在包含import 语句的文件 中使用。这些名称位于文件代码块中。在函数顶层定义的所有变量(包含函数的参数)都处于这一代码块中。在函数内,每组花括号 ({})定义一个新的代码块,我们稍后会看到Go的控制结构中会定义自己的代码块。

可在内层代码块中访问所有外层代码块中定义的标识符。这就带来了一个问题:在内层代码块中定义相同名称的标识符会怎么样呢?这时就会遮蔽外层所定义的标识符。

变量遮蔽

在讲解什么是遮蔽之前,我们先看一段代码(例 4-1)。可在The Go Playground中运行这段代码。

例 4-1 变量遮蔽

在运行代码之前,猜一猜会打印出什么:

  • 什么都不打印,代码无法编译
  • 第一行10,第二行5,第三行5
  • 第一行10,第二行5,第三行10

实际输出是:

遮蔽的变量与外层代码块变量名称相同。只要遮蔽变量存在,就无法访问到被遮蔽的变量。

本例中,我们肯定不希望在if语句中新建一个变量x。而是希望将5赋值给函数顶部声明的xif语句中的第一个fmt.Println,我们可以访问到函数顶部所声明的x。但在接下来的一行中,我们在if语句体代码块内新声明了一个x,这产生了遮蔽。第二个fmt.Println访问变量x时,得到的是遮蔽的变量,值为5。if语句的闭合花括号结束了遮蔽x的代码块,所以在第三个fmt.Println中访问变量x时,得到的又是函数顶部所声明的变量,值为10。注意这个x并未消失也没有被重赋值,只是在内部代码块中遮蔽后无法访问到它而已。

上一章中我们提到有些场景下应避免使用:=,因为那样会不清楚使用的是哪个变量。背后的原因就是使用:=会很容易不小心形成遮蔽。别忘了,我们可以使用:=一次性创建并对多个变量赋值。:=同时左侧的变量不需要全部是新变量。只要:=左侧有一个变量是新变量就合法。我们再来看另一个示例程序(例4-2),读者也在The Go Playground中运行。

例4-2 多赋值语句产生的遮蔽

运行代码得到如下结果:

虽然外部代码块定义了x,还是会在if语句内被遮蔽。原因在于:=只会重用当前代码块中声明的变量。在使用:=时,确保在左侧不使用外部作用域的变量,除非你就是想进行遮蔽。

还应注意不要遮蔽了导入的包。我们会在模块、包和导入一章中讨论包的导入,但实际上我们已经导入过fmt包用于打印程序的输出。参见例4-3的main函数中声明名为fmt的变量后的效果。读者可以在The Go Playground中运行。

例4-3 包名的遮蔽

运行以上代码会报如下错误:

注意问题不在于我们将变量命名为fmt,而在于尝试访问的方法本地变量fmt并没有。在声明了本地变量fmt后,就会遮蔽文件代码块中的fmt包,在main中就无法再使用fmt包了。

检测遮蔽的变量

既然遮蔽会产生不易察觉的bug,那么最好是能保障在程序中不存在遮蔽的变量。go vetgolangci-lint中都没有检测遮蔽的工具,但可以通过在本机上安装shadowlinter工具来在构建过程中完成检测:

如果使用Makefile进行构建,可在vet任务下添加shadow

在对前述代码运行make vet时,会检测到所遮蔽的变量:

全局代码块

其实还有一个比较奇怪的代码块:全局代码块。Go是一个只有25个关键字的小型语言。有意思的是其内置类型(如intstring)、常量(如truefalse)和函数(如makeclose)并没有位列其中。nil也一样,那它们是放在什么位置呢?

Go没有将它们看成关键字,而是看成预定义标识符,在全局代码块中定义,也就是包含所有其它代码块的代码块。

因为这些名称在全局代码块中定义,也就意味着可以在其它作用域中被遮蔽。可以在The Go Playground中查看例4-4的代码。

例4-4 遮蔽true

运行得到的结果如下:

必须要当心不要重新定义全局代码块中标识符。如果不小心这么干了,会产生非常奇怪的效果。幸运的话会报编译错误。但如果没有,会很难在源代码中追踪到问题所在。

你可能会觉得这么具有毁灭性的问题lint工具一定可以捕获到。但事实是不能。即使是用shadow也无法监测到全局代码块标识符的遮蔽。

if

Go语言中的if语句和其它大部分编程语言中的if并没有什么差别。因为我们太熟悉它了,在前面的示例代码中我们已经用过了。例4-5为一个更完整的示例。

例4-5 ifelse

注:如果运行这段代码,会发现对n的赋值一直都是1。这是因为math/rand中的随机数种子被硬编码了。在模块、包和导入重载包名一节中,我们会在演示如何处理包冲突时学习一种生成随机数种子的方式。

Go中与其它编程语言if语句最明显的差别在于条件不放在括号中。并且Go为if语句添加了一项功能可更好地管理变量。

在讨论变量遮蔽时提到过,ifelse语句代码块中声明的变量仅存在于该代码块。这并不奇怪,大部分编程语言都是这样。Go增加的功能是在条件的作用域中声明变量,可同时用于ifelse代码块。我们使用这一作用域重写前面的例子,见例4-6。

例4-6 将变量限定在if语句作用域内

这个特殊作用域非常方便。所创建的变量只在需要用到的地方可用。在if/else语句完结后,n就取消了定义。可以在The Go Playground中自行测试例4-7中的代码。

例4-7 超出作用域

运行这段代码会报编译错误:

注:技术上讲,可以在if语句的比较前插入简单语句。这包含无返回值的函数调用或对已有变量赋值。但别这么干。只利用这一特性定义在if/else语句作用域内的新变量,否则会产生困扰。

同时注意和其它代码块一样,if语句中定义的变量会遮蔽外层代码块的同名变量。

for的4种用法

和C系列的其它编程语言一样,Go通过for语句来实现循环。但Go与其它语言所不同之处在于for是其唯一的循环关键字。Go通过4种格式的for关键字用法实现这一点:

  • 完整类C的for
  • 仅包含条件的for
  • 无限循环for
  • for-range

完整for语句

我们首先来看的是完整的for声明,使用过C、Java或JavaScript的读者一定很熟悉了,参见例4-8。

例4-8 完整for语句

毫不意外地这段程序会打印出0到9。

if语句一样,在for语句后无需使用括号。其它部分都是我们所熟悉的。共3个由分号分隔的部分。第一部分是在循环开启前初始化一个或多个变量。在初始化的部分有两个重要细节。第一,我们必须使用:=来初始化变量,此处var不合法。第二,和if语句的变量声明一样,些处可能会遮蔽变量。

第二部分是比较。这部分的运行结果必须是bool类型。在循环体运行前会对其进行检查,时间为初始化后、循环结束前。如果表达式的结果是true,则会执行循环体。

标准for语句的最后一部分是递增。通常使用的类似i++,但也可以使用其它赋值语句。在每次迭代检测条件前执行。

仅包含条件的for语句

Go允许在for语句中不编写初始化及递增语句。这样的for语句和C、Java、JavaScript、Python、Ruby等语言中的while语句类似。可参见例4-9。

例4-9 仅包含条件的for语句

无限循环for语句

第三种for语句把条件语句也拿掉了。这是一种无限循环的for语句。对于老程序员,首先学习的可能是无限打印HELLO的BASIC循环:

例4-10中展示了该程序的Go语言版本。读者可以在本地或The Go Playground上进行调试。

例4-10 怀旧版无限循环

运行这段程序会得到曾经铺满Commodore 64和Apple ][的相同内容:

按下Ctrl-C可以停止程序。

注:如果在The Go Playground中运行这段程序,会发现几秒后程序自己会停止。因为资源是共享的,所以它不会允许程序运行过长时间。

break和continue

那么如何在不使用键盘或者关机的情况下退出无限for循环呢?这时就可以使用break语句了。它会立刻退出循环,这点与其它语言中的break一样。当然,可在任意一个for语句中使用break,不限于无限for循环。

注:Go语言中没有与Java、C和JavaScript对应的do关键字。如果想要至少迭代一次,最简洁的方式是使用无限for循环并以if语句结尾。比如Java的do/while循环:

改写成Go之后是这样:

注意条件语句有一个前导!,来对Java代码中的条件取反。Go代码中指定的是如何退出循环,而Java中指定的是如何保留在循环中。

Go还有一个关键字continue,它会跳过for循环体中剩下的部分直接进入下一次迭代。从技术上来说不太需要continue语句。可能会写出例4-11这样的代码。

例4-11 让人困扰的代码

但这样不够地道。Go鼓励使用简短的if语句体,缩进尽可能少。嵌套的代码非常难以跟踪。使用continue语句会更便于理解。例4-12为使用continue语句重写后的上例。

例4-12 使用continue让代码更清晰

可以看到,我们将if/else语句链改成了一系列if语句,其中借助了continue减少了缩进。这改善了条件的布局,进而让代码更易于阅读和理解。

for-range语句

第4种for语句用于遍历Go语言中一些内置类型的元素。它就是for-range循环,可类比其它语言中的迭代器。本节中我们会学习如何对字符串、数组、切片和字典使用for-range。在并发一章中讲到通道时,我们会再次对其使用for-range循环。

注:仅能使用for-range循环遍历内置复合类型以及基于这些类型的自定义类型。

首先,我们对切片使用for-range循环。可在The Go Playground中测试例4-13中的代码。

例4-13 for-range循环

运行以上代码会输出:

for-range循环比较有意思的地方是它有两个变量。第一个变量是位于所迭代数据结构中的位置,第二个变量是该位置上的值。这两个变量使用什么名称取决于所遍历的内容。在遍历数组、切片或字符串时,通常对索引使用i。而在遍历字典时,一般使用k(用于表示key)。
第二个变量通常使用v(表示value),但有时也会根据迭代值的类型来取名。当然,其实可以选择任意你喜欢的名称。如果在循环体中只有几条语句,可以使用单个字母的变量。对于更长的(或嵌套)循环,应当使用更具描述性的名称。

那如果不需要使用for-range循环中的键呢?我们知道Go语言要求使用所有声明过的变量,这条规则对于for循环中的变量也不例外。如果不需要使用某个变量,可以使用下划线(_)作为变量名。这告诉Go语言忽略该值。我们编写一段不打印位置的切片遍历。可在The Go Playground中运行例4-14中的代码。

例4-14 忽略for-range循环中的键

运行这段代码会输出如下内容:

小贴士:这里我们在希望忽略返回值使用了下划线来隐藏值。在函数模块、包和导入中我们还会用到下划线的语法。

但如果希望保留键、忽略值呢?这里可以直接省略第二个变量。下面是合法的Go代码:

只遍历键最常见的情况下将字典用作集合。这时,值就不太重要了。但在遍历数组或切片时也可以省略掉值。这种情况很少见,因为通常遍历线性数据结构都是为了获取值。如果读者对数组或切片做了这种操作,很大可能是选择了错误的数据结构,应当考虑重构。

注:在并发一章中学习通道时,我们会碰到for-range循环每次迭代仅返回单个值的情况。

遍历字典

在使用for-range循环遍历字典时会有一些有趣的状况。可以在The Go Playground中运行例4-15的代码。

例4-15 字典遍历的顺序不同

运行这段程序的输出会不一样。比如:

这里键和值的顺序会发生变化,有时可能一样。这实际上是一种安全特性。早期的Go版本中,在对字典插入相同项后遍历字典键的排序通常(也不一定)是相同的。这带来了两个问题:

  • 人们在编写代码时会假定顺序固定,偶尔可能会出问题。
  • 如果字典总是将子项哈希为同样的值,并且有人知道服务端的字典中存储的是用户数据,那么就可以通过对将键哈希为相同桶的特殊处理过的数据,来发起称为Hash DoS的攻击,从而拖慢服务端。

为避免这两个问题,Go团队对字典的实现做了两大修改。其一是将字典的哈希算法修改为包含每次创建字典变量时生成的随机数。其二是在for-range迭代字典时每次排序都发生一些变化。这两大修改会让黑客很难实施Hash DoS攻击。

注:有一种例外情况。为易于调试和记录字典,格式化函数(如fmt.Println)在输出字典时会按照键的升序。

遍历字符串

前面已经提到,也可对字符串进行for-range遍历。我们来看下具体情况。可在本机或The Go Playground上运行例4-16的代码。

例4-16 遍历字符串

hello的遍历结果毫无意外:

第一列为索引,第二列是字典的数值形式,第三列中将数值转换为了字符串。

apple_π!的输出就会有些不一样了:

有两点需要注意。第一是首列跳过了数字7。第二是位置6处的值是960。这么大的值一个字节是装不下的。在复合类型一章中,我们说过字符串是由字节组成的。发生了什么呢?

在对字符串执行for-range循环时出现了一种特殊行为。它遍历的不是字节,而是符文。当for-range循环在字符串中遇到多字节符文时,会将UTF-8的形式转换为32位数字并将其赋给值。偏移量按照符文的字节数进行递增。如果for-range循环碰到的字节不是有效UTF-8值,会返回Unicode替换字符(十六进制值0xfffd)。

小贴士:使用for-range循环按顺序访问字符串中的符文。键是从字符串开始计算的字节数,但值的类型是符文。

for-range的值是一个拷贝

for-range循环每次对复合类型进行迭代时,会将复合类型的值拷贝到值对应的变量中。修改值变量不会修改复合类型中的值。例4-17中的程序快速展示了这一点。可在The Go Playground中运行查看。

例4-17 修改值不会改变原值

运行以上代码得到的输出如下:

这一行为的含义很微妙。在并发一章中讨论协程时,会发现在for-range中启动协程时,要格外当心索引和值传入协程的方式,否则会得到错误的结果。

同其它三种for语句一样,也可以对for-range循环使用breakcontinue

为语句打标签

默认,breakcontinue关键字应用于直接包含它们的for循环。如果存在嵌套for循环而你又希望退出或跳到外层循环呢?下面来看一个例子。我们修改前面的字符串迭代程序,在循环至“l”时停止循环。可在The Go Playground中运行例4-18的代码。

例4-18 标签

注意这里的标签outergo fmt缩进至与外层函数同级。标签的缩进总是与代码块的花括号同级。这样更容易发现。运行以上程序输出如下:

带标签的嵌套for循环比较少见。最常的用法是实现类似下面伪代码的算法:

选择正确的语句

我们已经讲解了所有形式的for语句,读者可能会想什么时候使用哪种格式呢?大部分时候使用的是for-rangefor-range循环是遍历字符串的最佳方式,因为它返回的是符文而不是字节。我们也看到for-range循环可很好地遍历切片和字典,在并发一章中我们还会发现for-range循环处理通道也很顺理成章。

小贴士:在处理内置复合类型实例时尽量使用for-range循环。它可以省掉其它for循环处理数组、切片或字典时所需使用的代码。

那么在什么时候使用完整的for循环呢?最好是用在遍历复合类型而又不是从头到尾遍历时。虽然在for-range内可以使用一些ifcontinuebreak的组合,标准的for循环可更清楚地表明迭代的首末。对比下面两段代码,两者都是遍历数组的第二个元素到倒数第二个元素。首先是for-range循环:

使用标准for循环同样的代码如下:

标准for循环的代码更简短也更容易理解。

警告:这种方式无法跳过字符串的开头。别忘了标准for循环无法正确处理多字节字符。如果希望对字符串跳过相应数量的符文,要使用for-range来相应地处理符文。

另两种for语句使用频率较低。仅包含条件语句的for循环和其所对应的while循环一样,用于遍历计算值。

无限for循环用于一些其它场景。一般都会包含break,因为很少会有无限循环。真实的程序应当优雅地处理迭代和操作无法完成时的失败。前面演示过,无限for循环可结合if语句来模拟其它语言中的do语句。无限for循环也可用于实现某些版本的迭代器模式,在标准库一章中讲到io及其小伙伴们的时候会有相关讲解。

switch

与很多C系列语言一样,Go也有switch语句。这些语言的大部分开发者都会避免使用switch语句,因为所判断的值存在局限还有就是其默认的穿透行为。在Go的做法不一样,它充分地使用了switch语句。

注:有些读者可能比较熟悉Go语言了,本章中讨论的是表达式switch语句。我们会在类型、方法和接口一章中讨论类型switch语句。

乍一看,Go中的switch语句与C/C++、Java或JavaScript并没有什么不同,但其实是有些小惊喜的。我们来看一段示例switch语句。读者可以在The Go Playground中运行例4-19中的代码。

例4-19 switch语句

运行以上代码得到的输出如下:

我们来讲解switch语句的特性并说明输了内容。和if语句一样,switch后进行值的比较时无需使用括号。同样和if一样,我们所声明的变量作用域为所有switch语句的分支。这里size的作用域是switch语句的所有分支。

所有的case语句(以及可选的default语句)都在一对大括号中。但是case语句的内容不需要使用大括号包裹。在case(或default)语句中可以有多行代码,它们都处于同一代码块。

case 5:中,我们声明了一个新变量wordLen。因为这是一个新代码块,可以在其中声明新变量。和其它代码块一样,case语句中声明的变量仅在该代码块中可见。

如果读者习惯了在switchcase语句结束时添加break的话,会惊喜地发现不需要了。默认,Go语言中的switch语句不会穿透。这与Ruby或Pascal保持了一致。

这又抛出了一个问题:如果分支语句不穿透,多个值需要触发相同逻辑时该怎么办呢?在Go语言中,我们使用逗号分隔多个匹配,参见上面使用的1, 2, 3, 4或6, 7, 8, 9。这也正是acow的输出相同的原因。

这又带来了另一个问题:如果不穿透,空分支(参见示例中的6, 7, 8, 9)该如何处理呢?Go中,空分支表示什么也不发生。这也是正是参数为octopusgopher时没有输出的原因。

小贴士:为保持完整性,Go有一个fallthrough关键字,可以让分支延续至下一个分支。在使用它来实现算法时请三思。如果用到了fallthrough,请尝试重构逻辑移除分支间的依赖。

在我们的示例代码我们判断是的整型,但不限于此。可以判断任何能使用==进行比较的类型,这包括除切片、字典、通道、函数和结构体外的所有内置类型以及不包含这些类型字段的类型。

虽然不需要在每个case语句的最后添加break,但在希望更早退出case时还是可以使用它。但是用到了break语句可能意味着逻辑过于复杂。考虑重构代码并移除它。

switch语句的case中还有一种使用break的场景。如果switch位于for循环内的话,而此时又希望退出for循环,为for语句添加标签,然后在break后添加该标签。如果不使用标签,Go会认为我们退出的是当前case分支。下面看一个示例,可在The Go Playground中运行例4-20的代码。

例4-20 遗漏标签的情况

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

这和计划不一样。我们本来是要在到7的时候退出for循环。这时就要像跳到外部嵌套for一样使用标签来实现了。首先,对for语句打标签:

接着使用标签进行中断:

可在The Go Playground中看到发生的变化。再次运行得到的输出和预期一样:

空switch

switch语句还有一种更强大的使用方式。就像Go中允许省掉for语句的部分声明一样,在编写switch语句时可以不指定比较的值。这称为空switch。常规的switch仅能进行值的相等比较。而空switch允许我们对每个case使用任意的布尔比较。可在The Go Playground中测试例4-21的代码。

例4-21 空switch

运行这段程序得到的输出如下:

和常规的switch语句一样,可以选择在空switch中包含一段变量声明。但与常规switch不同的是,逻辑测试部分可以写在各分支上。空switch很棒,但不要过度使用。如果在编写空switch时发现所有的分支都是对同一变量进行相等比较时:

应当将其替换为表达式switch语句:

选择if还是switch

在功能上,if/else语句和空switch语句并没有太大分别。两者都可以进行一系列的对比。那么使用时候使用switch、什么时候使用if/else语句呢?switch语句,包括空switch,表示在每个分支中存在与值的关系或比较。为清楚地演示,我们将if部分中的随机数分类使用switch编写,见例4-22。

例4-22 使用空switch重写if/else

大部分人都会认为它的可读性更强。值的比较自成一行,所有的比较都位于左侧。比较的整齐排列会阅读和修改都更容易。

当然Go不会阻止我们在空switch的分支中进行无关联的比较。但这种写法就不地道了。如果读者发现自己在这么干时,请使用一组if/else语句(或是考虑重构代码)。

小贴士:在有多个关联分支时优先使用空switch语句。使用switch会让比较可见性更强并强化了它们是相关联的。

goto-跳转

Go语言中还有第四种控制语句,但大概率你不会用到它。自Edgar Dijkstra于1968写了Go To Statement Considered Harmful一文之后,goto语句就成了编程家庭中的老鼠屎了。理由也很充分。曾经goto很危险的原因是它可以跳转到程序的任意一处,可以跳入或跳出循环,跳过函数定义或时跳到if语句的中间。这就让人们很难理解使用了goto的程序。

大部分现代编程语言都没有包含goto。但Go中却包含goto语句。还是应当避免使用它,但它也有用武之地,并且Go对其添加了限制让其更好地服务于结构化编程。

在Go语言中,goto语句指定了打标签的代码行进行跳转。但并不是什么地方都能跳。Go禁止跳转跳过变量声明或是跳到内部或平行的代码块。

例4-23中展示了两种非法的goto语句。读者可在The Go Playground中尝试运行。

例4-23 Go语言中goto是有规则的

运行以上代码会得到如下报错:

那么goto有什么用途呢?大多数时候不应该使用。使用打标签的breakcontinue可跳出深度嵌套的循环或跳过迭代。例4-24中的程序合法地使用了goto,演示了其极少使用场景之一。

例4-24 使用goto的一种原因

这是一个人造的示例,但展示了goto是如何让程序更清晰的。在这个简单的例子中,函数中的部分逻辑我们不希望运行,但又希望运行函数结尾部分的代码。也可以不使用goto。可以配置一个布尔标记或是复制这段代码,但这两种方式都有缺点。在逻辑控制流中散放布尔标记和goto语句的功能可能一样,但更冗长。复制代码问题就更大了,代码会更难维护。这些场景不多见,但如果没找到重构逻辑的方式,使用goto其实是可以改善代码的。

如果想看真实示例的话,可以标准库strconv包atof.go文件中的floatBits方法。整段代码比较长,该方法的结尾处为:

在这些代码行之前,有多个条件检测。有些需要运行overflow标签后的代码,而有些条件要求跳过这段代码,直接进入out。根据条件的不同,goto语句会跳到overflowout。读者也许能找到一些避免使用goto语句的方式,但那样的代码都会更难理解。

小贴士:应当尽量避免使用goto。但在一些会让代码更易读的极少场景中,这是一种选择。

小结

本章讲解了大量编写地道Go代码的话题。我们学习了代码块、遮蔽和控制结构,以及如何正确地使用它们。现在我们可以在主函数中编写简单的Go程序了。是时候迈向更大的程序了,使用函数来组织代码。

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

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

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

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