Go语言实践[回顾]教程14--详解Go语言代码结构、包、作用域、变量、常量

  • Go语言的项目代码结构与包
  • Go语言的命名空间
  • Go语言的作用域
  • Go语言变量的作用域优先级
  • Go语言的变量
  • Go语言的常量


  变量的使用,就会涉及到作用域,继而涉及到命名空间,说到命名空间就要谈及代码结构。所以我们接下来先从 Go 语言的项目代码结构开始。

Go语言的项目代码结构与包

  Go 语言的代码结构,主要是以 包 的形式为组织单位进行的,也可以理解为一个包为一个完整且具体的功能模块代码。通常情况下,一个 Go 语言本地项目的文件夹结构如下:

go语言实战项目 go语言实践_go语言实战项目


  文件夹 hello 是项目文件夹,编译时不另外指定名字时,生成的可执行文件的文件名将与这个文件夹同名。

  main.go 是项目主源码文件(引导文件、入口文件),这个源码文件里有一个 main 函数(入口函数),一个项目必需有且只能有一个 main 函数,且这个源码文件内声明的包名必须是 main。虽然这个文件名可以不是 main,但是为了易读和好维护,最好还是将 main 函数所在的源码文件命名为 main.go 更好。

  main_conf.go 如果有需要,在项目文件夹根下还是可以放除 main 外其它功能源代码文件的,只是内部声明的包名必须是 main,因为同一个文件夹下的所有源码文件(不含子文件夹内的)必须属于同一个包。编译的时候,同一个文件夹内的源码文件会用包名进行合并处理(相互不需要使用 import 导入)。如果不是相同的包名,编译无法通过。main_conf 这个文件名我是随意起的,可以是任意合法的自定义文件名,可以有更多个文件。

  文件夹 out 是自定义文件夹,其里面的所有源码文件(不含子文件夹内的)声明的包名必需一致,且最好是与文件夹名相同,也就是包名最好声明为 out(易读,方便维护)。可以与文件夹名不相同,但各源码文件内声明的必须是同一个名字。如果这个文件夹内还有子文件夹,其里面还有源码文件,那子文件属于另一个独立的包,不会影响当前文件夹内的源码文件。

  out_set.go、out_drive.go 是包文件夹 out 内的源码文件,可以是更多个,名字根据自己需要取。它们共同组成了一个包(包名上段说过了),所有各源码文件内声明包名要一致。因编译器在编译时,会将这些同文件夹下同包名的源码文件合并成一个,所以编写代码时,这些文件虽是分开写的代码,但可以理解为等同于写在一个文件里的。也就是说,编译的时候,对源码文件来说,是以包为组织单位的。

  in 文件夹及里面的文件与上面介绍的 out 文件夹及其内部的文件作用是一样的,就不在赘述了。

  ,就是 Go 语言借助了目录树的组织形式,将源代码分成若干个文件集合(一个文件夹内的合并到一起),就形成了若干个包。一个项目里,必须有 main 包,这个名字是固定的。然后接下来根据需要自定义其他包(项目文件夹下建子文件夹,然后再其里面建源码文件),或者在项目文件夹下建其他源码文件都归属 main 包。为了逻辑更清晰,更易于维护,建议相对完整可独立的功能单独创建包。

  import,导入包的关键字。在源代码中导入包的语法有以下几种格式:

  单行导入:

import "fmt"         // 单行导入标准库提供的包,直接写包名
import "hello/out"   // 单行导入自定义的 out 包,需要写路径,从项目文件夹开始写,如失败检查环境变量 GOPATH 是否正确

  多行批量导入(推荐):

import (
	"fmt"
	"hello/out"
)

  导入并赋予别名:

import (
	f "fmt"         // 导入后使用包的别名 f,如 f.Println("OK") 就是调用 fmt 包的 Println 函数
	o "hello/out"   // 导入后使用包的别名 o,如 o.abc() 就是调用 out 包内的 abc 函数
)

  导入后与当前包合并:

import (
	. "hello/out"  // 导入后不需要包名,与当前已经合成一个包了。如 abc() 就是调用 out 包内的 abc 函数
)

  单行批量导入(已不推荐):

import ("fmt";"hello/out")

Go语言的命名空间

  命名空间,在网络上和专业书籍上有很多种解释,这里我就不写他们的答案了,可自行搜索查找。我个人理解,通俗点说,命名空间就是在整个代码结构中,给自定义标识符不允许重名划定的若干范围和层级。在同一个范围内,自定义的标识符是不允许重名的。

  Go 语言的命名空间,主要部分就是项目和包(因为有包名的声明,所以管这类叫显式命名空间),也就是整个项目内还是包内。然后还有一些隐式命名空间,就是有 {} 包裹的代码块,包括函数、if、for、switch等。它们之间的关系如下图所示:

go语言实战项目 go语言实践_Go语言_02

Go语言的作用域

  作用域,通常是指标识符(如变量)的最大有效命名空间,超出这个命名空间就无法使用了。根据上图命名空间的关系图,一个标识符最大的作用域是整个项目。作用域在层级上是有向下穿透特性的,也就是说在高级别命名空间声明的标识符在低级别的命名空间也是有效的(同名除外)。

  Go 语言的作用域有以下三种类型:

  ▲ 全局作用域:具有全局作用域的标识符,在项目任何地方都有效,在任何命名空间中都可见。包括 Go 语言内置的预声明标识符(如关键字、预置类型名、内置函数、导入后的标准库中的方法等),还有导入后的自定义包内以大写字母开头的标识符(如变量、常量、函数、自定义类型及结构字段等)。

  ▲ 包内公共作用域:在包各个源码文件中声明定义的,以小写字母开头的标识符(如变量、常量、函数、自定义类型及结构字段等)。在整个包内任何地方都有效,包括隐式命名空间(有变量重名问题下面阐述),但在其他包无效,不可见(如俗称的公共变量)。

  ▲ 隐式局部作用域:仅在声明定义时所在的隐式命名空间有效,其他命名空间都不可见(如俗称的局部变量)。

Go语言变量的作用域优先级

  Go 语言编译器在解析变量名时,是从代码嵌套最里层开始解析。如果一个变量在里层已经出现,然后在外层又出现了同名变量,那么在里层的变量不会受外层的同名变量影响,它们被看做是两个变量。这两个同名变量会在各自的命名空间有效,外层对内层的穿透特性将失效。实践一下,看如下代码:

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

var testVar = 0 // 声明一个公共变量并初始化为 0

func printTest() {
	fmt.Println("函数内打印", testVar)
}

// 主函数,程序入口
func main() {
	testVar := 1 // 声明一个局部变量并初始化为 1

	if testVar == 1 {
		testVar := 2 // 声明一个局部变量并初始化为 2

		fmt.Println("判断内打印", testVar)
	}
	fmt.Println("判断内打印", testVar)
	printTest()
}

  以上代码编译运行后打印输出结果如下:

判断内打印 2
判断内打印 1
函数内打印 0

  通过以上测试,证明三处打印的变量都不是同一个,三个地方声明定义的变量,各自保存着各自的初始化值,都没有被修改。且证明 if 判断的 {} 内也是一个命名空间,与 main 函数其他的代码并不处在同一级命名空间。三处都是对同名变量声明定义,所以最小级别命名空间的变量优先级最高,就保护了自身不被高级别命名空间的同名变量穿透进来的影响。这样变量就都在自己的作用域范围内保持着自己的数据。

  我们把上述代码修改一下,将判断内的声明变量变成给变量赋值,看看结果如何:

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

var testVar = 0 // 声明一个公共变量并初始化为 0

func printTest() {
	fmt.Println("函数内打印", testVar)
}

// 主函数,程序入口
func main() {
	testVar := 1 // 声明一个局部变量并初始化为 1

	if testVar == 1 {
		testVar = 2 // 这里改成了给同名变量赋值 2

		fmt.Println("判断内打印", testVar)
	}
	fmt.Println("判断内打印", testVar)
	printTest()
}

  只是修改了第19行代码,把 := 改成了 = ,就是把声明改成了赋值,看这段代码的编译运行结果:

判断内打印 2
判断内打印 2
函数内打印 0

  从结果可以看出,第16行初始化的 1 ,已经被第19行给修改了。这证明了函数内的局部变量穿透了 if 的 {} 的隐式命名空间,因为在没有遇到同名变量时,第16行声明的变量 testVar 的作用域是包括它下一级命名空间的。

Go语言的变量

  用一个标识符绑定一块内存空间,通过这个标识符来对该空间进行操作。若这块内存保存的内容是可以修改的,那么这就是变量。变量可以保存的数据类型是在变量声明的时候指定的。

  ▲ 变量声明有以下几种方式:

var a int          // 完整声明,声明一个 int 类型变量 a
var b int = 58     // 完整声明,声明一个 int 类型变量 b 并初始化值为 58,
var c = 58         // 简式声明,声明一个 int 类型变量 c 并初始化值为 58,数据类型是编译器自动推导出来的
d := "OK"          // 简式声明,仅适用局部变量,声明一个字符串变量 d 并初始化值为“OK”,数据类型是编译器自动推导出来的
e, f := 58, "OK"   // 简式声明,仅适用局部变量,同时声明并初始化多个变量,变量顺序与值的顺序一一对应

var (              // 批量多行声明,每个变量占一行,其他规则同上
    g string
    h int
    i = 58
)

  ▲ 局部变量自己的作用域代码执行结束后,变量就无效了。Go语言有垃圾回收机制,编程者不用考虑垃圾回收问题。如果是被多次调用的代码块(如函数),其内部的局部变量在每次调用时都会重新初始化。

  ▲ 变量的命名规则遵循前面标识符的命名规则,包内的公共变量是否可以被其它包使用,是利用首字母大小写来决定的。首字母大写才允许公开,其他包才可以调用。

Go语言的常量

  用一个标识符绑定一块内存空间,通过这个标识符来对该空间进行操作。若这块内存保存的内容是不允许修改的,那么这就是常量。常量可以保存的数据类型是在常量声明的时候指定的,且常量声明时必须赋值。Go 语言的常量有布尔、字符串、数值三种类型。

  ▲ 常量声明有以下几种方式:

const chang1 int = 20    // 完整声明,常量 chang1 的值是 20,类型是数值型
const chang2 = 20        // 简式声明,常量 chang2 的值是 20,类型推导出数值型
const chang3 = iota      // 简式声明,iota 是常量数索引,单行声明始终是 0,常量 chang3 的值是 0,类型推导出数值型
const chang4 = iota      // 简式声明,iota 是常量数索引,单行声明始终是 0,常量 chang4 的值是 0,类型推导出数值型

const (                  // 批量声明常量,iota 值在第一个常量时是 0,依次加 1
	chang5 = 23          // 常量 chang5 的值是数值 23
	chang6 = "OK"        // 常量 chang6 的值是字符串 “OK”
	chang7 = iota        // 常量 chang7 的值是数值 2
	chang8 = iota * 2    // 常量 chang8 的值是数值 6
)

  ▲ 常量的地址是不允许访问的,所以常量不可以引用。

  ▲ 常量的命名规则遵循前面标识符的命名规则,包内的常量是否可以被其它包使用,是利用首字母大小写来决定的。首字母大写才允许公开,其他包才可以调用,这与变量规则相同。

  ▲ iota 是批量声明变量时,变量的顺序号(索引),从 0 开始。可以利用这个特性声明类似 枚举 的变量。例如:

const (
    con1 = iota    // 值是 0
    con2 = iota    // 值是 1
    con3 = iota    // 值是 2
)

  ▲ 常量赋值可以是算式,但是不能有变量,可以有之前声明过的常量和 iota。