Go 代码块与作用域,变量遮蔽问题详解

2023-10-14 23:44

本文主要是介绍Go 代码块与作用域,变量遮蔽问题详解,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

Go 代码块与作用域详解

文章目录

  • Go 代码块与作用域详解
    • 一、引入
    • 二、代码块 (Block)
      • 2.1 代码块介绍
      • 2.2 显式代码块
      • 2.3 隐式代码块
      • 2.4 空代码块
      • 2.5 支持嵌套代码块
    • 三、作用域 (Scope)
      • 3.1 作用域介绍
      • 3.2 作用域划定原则
      • 3.3 标识符的作用域范围
        • 3.3.1 预定义标识符作用域
        • 3.3.2 包代码块级作用域
        • 3.3.3 文件代码块作用域(包的导入作用域)
        • 3.3.4 函数体的作用域
        • 3.3.5 流程控制作用域
    • 四、避免变量遮蔽的原则
      • 4.1 变量遮蔽的根本原因
      • 4.2 变量遮蔽问题分析
        • 4.2.1 第一个问题:遮蔽预定义标识符
        • 4.2.2 第二个问题:遮蔽包代码块中的变量
        • 4.2.3 第三个问题:遮蔽外层显式代码块中的变量
    • 五、利用工具检测变量遮蔽问题

一、引入

首先我们从一个 Go 变量遮蔽(Variable Shadowing)的问题说起。

什么是变量遮蔽呢?

变量遮蔽(Variable Shadowing)是指在程序中一个作用域内的变量名(或标识符)隐藏(遮蔽)了外部作用域中相同名称的变量。这会导致在遮蔽内部作用域内,无法直接访问外部作用域的变量,因为编译器或解释器将优先选择内部作用域的变量,而不是外部的。

我们来看下面这段示例代码:

package mainimport "fmt"var x = 10 // 包级作用域的变量func main() {x := 5 // 函数内的局部变量,遮蔽了包级作用域的 xfmt.Println(x) // 输出:5
}func anotherFunction() {fmt.Println(x) // 在这个函数中,外部包级作用域的 x 是可见的,输出:10
}

你可以看到,在这段代码中,函数main内部有一个局部变量 x,它遮蔽了包级作用域的 x。因此,在main函数内部,通过变量 x 访问的是局部变量,而不是外部包级作用域的变量。然而,在anotherFunction中,没有局部变量 x,因此外部包级作用域的 x 是可见的。

二、代码块 (Block)

2.1 代码块介绍

在Go语言中,代码块是包裹在一对大括号{} 包围的声明和语句序列。

2.2 显式代码块

这些代码块是你在代码中明确可见的,由一对大括号 {} 包围。比如函数的函数体、for循环的循环体、以及其他控制结构内部的代码块。这些代码块明确定义了它们的作用域,包括变量的可见性:

func Foo() {// 这里是显式代码块,包裹在函数的函数体内// ...for {// 这里是显式代码块,包裹在for循环体内// 该代码块也是嵌套在函数体显式代码块内部的代码块// ...}if true {// 这里是显式代码块,包裹在if语句的true分支内// 该代码块也是嵌套在函数体显式代码块内部的代码块// ...}
}

2.3 隐式代码块

隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。

虽然隐式代码块身着“隐身衣”,但我们也不是没有方法来识别它,因为 Go 语言规范对现存的几类隐式代码块做了明确的定义,我们可以看下这张图:

WechatIMG221

我们按代码块范围从大到小,逐一说明:

  • 宇宙(Universe)代码块:它囊括的范围最大,所有 Go 源码都在这个隐式代码块中,你也可以将该隐式代码块想象为在所有 Go 代码的最外层加一对大括号,就像图中最外层的那对大括号那样。
  • 包代码块:在宇宙代码块内部嵌套了包代码块(Package Block),每个 Go 包都对应一个隐式包代码块,每个包代码块包含了该包中的所有 Go 源码,不管这些代码分布在这个包里的多少个的源文件中。
  • 文件代码块:在包代码块的内部嵌套着若干文件代码块(File Block),每个 Go 源文件都对应着一个文件代码块,也就是说一个 Go 包如果有多个源文件,那么就会有多个对应的文件代码块。
  • 再下一个级别的隐式代码块就在控制语句层面了,包括 ifforswitch。我们可以把每个控制语句都视为在它自己的隐式代码块里。不过你要注意,这里的控制语句隐式代码块与控制语句使用大括号包裹的显式代码块并不是一个代码块。你再看一下前面的图,switch 控制语句的隐式代码块的位置是在它显式代码块的外面的。
  • 最后,位于最内层的隐式代码块是 switchselect 语句的每个 case/default 子句中,虽然没有大括号包裹,但实质上,每个子句都自成一个代码块。

2.4 空代码块

如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块

空代码块在Go语言中是有效的,并且在某些情况下可以有一定的用途,尤其是在控制结构中,如if语句、for循环或switch语句的特定分支。它们充当了占位符,允许你将来添加代码而不需要改变代码的结构。

以下是一个示例,演示了空代码块的使用:

func main() {x := 10if x > 5 {// 非空代码块fmt.Println("x 大于 5")} else {// 空代码块,什么都不做}for i := 0; i < 5; i++ {// 空代码块,什么都不做}
}

2.5 支持嵌套代码块

Go 代码块支持嵌套,我们可以在一个代码块中嵌入多个层次的代码块,如下面示例代码所示:

func foo() { //代码块1{ // 代码块2{ // 代码块3{ // 代码块4}}}
}

三、作用域 (Scope)

3.1 作用域介绍

作用域的概念是针对标识符的,不局限于变量。每个标识符都有自己的作用域,而一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域。

显然,作用域是一个编译期的概念,也就是说,编译器在编译过程中会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会给出编译错误的报错。

3.2 作用域划定原则

我们可以使用代码块的概念来划定每个标识符的作用域。一般划定**原则就是声明于外层代码块中的标识符,其作用域包括所有内层代码块。**而且,这一原则同时适于显式代码块与隐式代码块。

3.3 标识符的作用域范围

3.3.1 预定义标识符作用域

首先,我们来看看位于最外层的宇宙隐式代码块的标识符。这一区域是 Go 语言预定义标识符的自留地。你可以看看下面这张表是Go 语言当前版本定义里的所有预定义标识符:

WechatIMG222

由于这些预定义标识符位于包代码块的外层,所以它们的作用域是范围最大的,对于开发者而言,它们的作用域就是源代码中的任何位置。不过,这些预定义标识符不是关键字,我们同样可以在内层代码块中声明同名的标识符。

3.3.2 包代码块级作用域

包顶层声明中的常量、类型、变量或函数(不包括方法)对应的标识符的作用域是包代码块。

不过,对于作用域为包代码块的标识符,我需要你知道一个特殊情况。那就是当一个包 A 导入另外一个包 B 后,包 A 仅可以使用被导入包包 B 中的导出标识符(Exported Identifier)。

按照 Go 语言定义,一个标识符要成为导出标识符需同时具备两个条件:一是这个标识符声明在包代码块中,或者它是一个字段名或方法名;二是它名字第一个字符是一个大写的 Unicode 字符。这两个条件缺一不可。

// 包 A
package Aimport "B"func SomeFunction() {// 可以访问包 B 中的导出标识符B.ExportFunction()
}// 这里无法访问包 B 中的非导出标识符
3.3.3 文件代码块作用域(包的导入作用域)

在Go语言中,除了大多数在包顶层声明的标识符具有包代码块范围的作用域外,还有一个特殊情况,即导入的包名。导入的包名的作用域是文件代码块范围,这意味着它在包含它的源代码文件中可见,但对其他源文件不可见。

考虑以下示例,其中一个包A有两个源文件,它们都依赖包B中的标识符:

// 文件1:source1.gopackage Aimport "B"func FunctionInSource1() {B.SomeFunctionFromB() // 可以使用导入的包名 B
}
// 文件2:source2.gopackage Aimport "B"func FunctionInSource2() {B.AnotherFunctionFromB() // 可以使用导入的包名 B
}

在这个示例中,两个源文件都导入了包B,但每个文件内的包名 B文件级别可见。这意味着FunctionInSource1FunctionInSource2函数都可以访问B包中的导出标识符(以大写字母开头的标识符),但对于其他包和源文件而言,它们不可见。

3.3.4 函数体的作用域

函数体内的标识符的作用域被限制在函数的开始和结束之间。这意味着函数体内的局部变量只能在函数体内部访问。

func exampleFunction() {var localVar = 42fmt.Println(localVar) // 可以访问局部变量 localVar
}fmt.Println(localVar) // 这里无法访问局部变量 localVar
3.3.5 流程控制作用域

流程控制结构,如if语句、for循环和switch语句,也会引入新的作用域。在这些结构中声明的局部变量的作用域限制在结构内部,不会泄漏到外部。

if x := 10; x > 5 {// x 只能在 if 语句块内访问fmt.Println(x)
}fmt.Println(x) // 这里无法访问 x

在上面的示例中,变量 x 在if语句内部有一个新的局部作用域,因此它只在if语句块内可见。

四、避免变量遮蔽的原则

4.1 变量遮蔽的根本原因

变量是标识符的一种,通过以上我们知道,一个变量的作用域起始于其声明所在的代码块,并且可以一直扩展到嵌入到该代码块中的所有内层代码块,而正是这样的作用域规则,成为了滋生“变量遮蔽问题”的土壤。

变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量,参与此层代码块内的相关计算,我们也就说内层变量遮蔽了外层同名变量。现在,我们先来看一下这个示例代码,它就存在着多种变量遮蔽的问题:

... ...var a int = 2020func checkYear() error {err := errors.New("wrong year")switch a, err := getYear(); a {case 2020:fmt.Println("it is", a, err)case 2021:fmt.Println("it is", a)err = nil}fmt.Println("after check, it is", a)return err}type new intfunc getYear() (new, error) {var b int16 = 2021return new(b), nil}func main() {err := checkYear()if err != nil {fmt.Println("call checkYear error:", err)return}fmt.Println("call checkYear ok")}

这个变量遮蔽的例子还是有点复杂的,我们首先运行一下这个例子:

$go run complex.go
it is 2021
after check, it is 2020
call checkYear error: wrong year

我们可以看到,第 20 行定义的 getYear 函数返回了正确的年份 (2021),但是 checkYear 在结尾却输出“after check, it is 2020”,并且返回的 err 并非为 nil,这显然是变量遮蔽的“锅”!

根据我们前面给出的变量遮蔽的根本原因,看看上面这段代码究竟有几处变量遮蔽问题(包括标识符遮蔽问题)。

4.2 变量遮蔽问题分析

4.2.1 第一个问题:遮蔽预定义标识符

面对上面代码,我们一眼就看到了位于第 18 行的 new,这本是 Go 语言的一个预定义标识符,但上面示例代码呢,却用 new 这个名字定义了一个新类型,于是 new 这个标识符就被遮蔽了。如果这个时候你在 main 函数下方放上下面代码:

p := new(int)
*p = 11

你就会收到 Go 编译器的错误提示:“type int is not an expression”,如果没有意识到 new 被遮蔽掉,这个提示就会让你不知所措。不过,在上面示例代码中,遮蔽 new 并不是示例未按预期输出结果的真实原因,我们还得继续往下看。

4.2.2 第二个问题:遮蔽包代码块中的变量

你看,位于第 7 行的 switch 语句在它自身的隐式代码块中,通过短变量声明形式重新声明了一个变量 a,这个变量 a 就遮蔽了外层包代码块中的包级变量 a,这就是打印“after check, it is 2020”的原因。包级变量 a 没有如预期那样被 getYear 的返回值赋值为正确的年份 2021,2021 被赋值给了遮蔽它的 switch 语句隐式代码块中的那个新声明的 a。

4.2.3 第三个问题:遮蔽外层显式代码块中的变量

同样还是第 7 行的 switch 语句,除了声明一个新的变量 a 之外,它还声明了一个名为 err 的变量,这个变量就遮蔽了第 4 行 checkYear 函数在显式代码块中声明的 err 变量,这导致第 12 行的 nil 赋值动作作用到了 switch 隐式代码块中的 err 变量上,而不是外层 checkYear 声明的本地变量 err 变量上,后者并非 nil,这样 checkYear 虽然从 getYear 得到了正确的年份值,但却返回了一个错误给 main 函数,这直接导致了 main 函数打印了错误:“call checkYear error: wrong year”。

通过这个示例,我们也可以看到,短变量声明与控制语句的结合十分容易导致变量遮蔽问题,并且很不容易识别,因此在日常 go 代码开发中你要尤其注意两者结合使用的地方。

五、利用工具检测变量遮蔽问题

依靠肉眼识别变量遮蔽问题终归不是长久之计,所以Go 官方提供了 go vet 工具可以用于对 Go 源码做一系列静态检查,在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:

go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest

安装成功后,我们就可以通过 go vet 扫描代码并检查这里面有没有变量遮蔽的问题了。我们检查一下前面的示例代码,看看效果怎么样。执行检查的命令如下:

$go vet -vettool=$(which shadow) -strict complex.go 
./complex.go:13:12: declaration of "err" shadows declaration at line 11

这篇关于Go 代码块与作用域,变量遮蔽问题详解的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/213958

相关文章

解决IDEA报错:编码GBK的不可映射字符问题

《解决IDEA报错:编码GBK的不可映射字符问题》:本文主要介绍解决IDEA报错:编码GBK的不可映射字符问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录IDEA报错:编码GBK的不可映射字符终端软件问题描述原因分析解决方案方法1:将命令改为方法2:右下jav

MySQL中的分组和多表连接详解

《MySQL中的分组和多表连接详解》:本文主要介绍MySQL中的分组和多表连接的相关操作,本文通过实例代码给大家介绍的非常详细,感兴趣的朋友一起看看吧... 目录mysql中的分组和多表连接一、MySQL的分组(group javascriptby )二、多表连接(表连接会产生大量的数据垃圾)MySQL中的

Java 实用工具类Spring 的 AnnotationUtils详解

《Java实用工具类Spring的AnnotationUtils详解》Spring框架提供了一个强大的注解工具类org.springframework.core.annotation.Annot... 目录前言一、AnnotationUtils 的常用方法二、常见应用场景三、与 JDK 原生注解 API 的

redis中使用lua脚本的原理与基本使用详解

《redis中使用lua脚本的原理与基本使用详解》在Redis中使用Lua脚本可以实现原子性操作、减少网络开销以及提高执行效率,下面小编就来和大家详细介绍一下在redis中使用lua脚本的原理... 目录Redis 执行 Lua 脚本的原理基本使用方法使用EVAL命令执行 Lua 脚本使用EVALSHA命令

MyBatis模糊查询报错:ParserException: not supported.pos 问题解决

《MyBatis模糊查询报错:ParserException:notsupported.pos问题解决》本文主要介绍了MyBatis模糊查询报错:ParserException:notsuppo... 目录问题描述问题根源错误SQL解析逻辑深层原因分析三种解决方案方案一:使用CONCAT函数(推荐)方案二:

SpringBoot3.4配置校验新特性的用法详解

《SpringBoot3.4配置校验新特性的用法详解》SpringBoot3.4对配置校验支持进行了全面升级,这篇文章为大家详细介绍了一下它们的具体使用,文中的示例代码讲解详细,感兴趣的小伙伴可以参考... 目录基本用法示例定义配置类配置 application.yml注入使用嵌套对象与集合元素深度校验开发

Python中的Walrus运算符分析示例详解

《Python中的Walrus运算符分析示例详解》Python中的Walrus运算符(:=)是Python3.8引入的一个新特性,允许在表达式中同时赋值和返回值,它的核心作用是减少重复计算,提升代码简... 目录1. 在循环中避免重复计算2. 在条件判断中同时赋值变量3. 在列表推导式或字典推导式中简化逻辑

Redis 热 key 和大 key 问题小结

《Redis热key和大key问题小结》:本文主要介绍Redis热key和大key问题小结,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录一、什么是 Redis 热 key?热 key(Hot Key)定义: 热 key 常见表现:热 key 的风险:二、

Java Stream流使用案例深入详解

《JavaStream流使用案例深入详解》:本文主要介绍JavaStream流使用案例详解,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录前言1. Lambda1.1 语法1.2 没参数只有一条语句或者多条语句1.3 一个参数只有一条语句或者多

SpringBoot整合mybatisPlus实现批量插入并获取ID详解

《SpringBoot整合mybatisPlus实现批量插入并获取ID详解》这篇文章主要为大家详细介绍了SpringBoot如何整合mybatisPlus实现批量插入并获取ID,文中的示例代码讲解详细... 目录【1】saveBATch(一万条数据总耗时:2478ms)【2】集合方式foreach(一万条数