GO:1.4 程序实体

Go 语言中的程序实体包括变量、常量、函数、结构体和接口。 Go 语言是静态类型的编程语言,所以我们在声明变量或常量的时候,都需要指定它们的类型,或者给予足够的信息,这样才可以让 Go 语言能够推导出它们的类型。

1、变量

在 Go 语言中,变量的类型可以是其预定义的那些类型,也可以是程序自定义的函数、结构体或接口。常量的合法类型不多,只能是那些 Go 语言预定义的基本类型。它的声明方式也更简单一些。


1、声明变量有几种方式?

1、先声明后赋值
一个很简单的命令源码文件,命名为 demo7.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"flag"
"fmt"
)

func main() {
var name string // 先声明
flag.StringVar(&name, "name", "everyone", "The greeting object.") // 后赋值
flag.Parse()
fmt.Printf("Hello, %v!\n", name)
}

2、使用var声明变量的同时赋值

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"flag"
"fmt"
)

func main() {
var name = flag.String("name", "everyone", "The greeting object.")//声明的同时赋值
flag.Parse()
fmt.Printf("Hello, %v!\n", *name)。
}

flag.String函数返回的结果值的类型是string而不是string。类型string代表的是字符串的指针类型,而不是字符串类型。因此,这里的变量name代表的是一个指向字符串值的指针。

注:

  • 1、声明变量name的同时,还为它赋了值,而这时声明中并没有显式指定name的类型。之前的变量声明语句是var name string。这里利用了 Go 语言自身的类型推断,而省去了对该变量的类型的声明。
  • 2、类型推断是一种编程语言在编译期自动解释表达式类型的能力。
  • 3、类型推断只能用于对变量或常量的初始化。

3、使用”:=”短变量声明的同时赋值

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"flag"
"fmt"
)

func main() {
name := flag.String("name", "everyone", "The greeting object.")//声明的同时赋值
flag.Parse()
fmt.Printf("Hello, %v!\n", *name)。
}

注:

  • 1、短变量声明,实际上就是 Go 语言的类型推断再加上一点点语法糖,只能在函数体内部使用短变量声明
  • 在编写if、for或switch语句的时候,我们经常把它安插在初始化子句中,并用来声明一些临时的变量。而相比之下,第一种方式更加通用,它可以被用在任何地方。
    app1

2、Go 语言的类型推断可以带来哪些好处?

在写代码时,我们通过使用 Go 语言的类型推断,而节省下来的键盘敲击次数几乎可以忽略不计。但它真正的好处,往往会体现在我们写代码之后的那些事情上,比如代码重构

通过调用一个函数在声明name变量的同时为它赋值,这个函数是由我们自己定义的函数getTheFlag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"flag"
"fmt"
)

func main() {
var name = getTheFlag()
//不显式地指定变量name的类型,使得它可以被赋予任何类型的值。也就是说,变量name的类型可以在其初始化时,由其他程序动态地确定。
flag.Parse()
fmt.Printf("Hello, %v!\n", *name)
}

func getTheFlag() *string {
return flag.String("name", "everyone", "The greeting object.")
}

我们可以用getTheFlag函数包裹(或者说包装)那个对flag.String函数的调用,并把其结果直接作为getTheFlag函数的结果,结果的类型是*string。

这样一来,var name =右边的表达式,可以变为针对getTheFlag函数的调用表达式了。这实际上是对“声明并赋值name变量的那行代码”的重构。

1
2
重构:
我们通常把不改变某个程序与外界的任何交互方式和规则,而只改变其内部实现”的代码修改方式,叫做对该程序的重构。重构的对象可以是一行代码、一个函数、一个功能模块,甚至一个软件系统。

你会发现,你可以随意改变getTheFlag函数的内部实现,及其返回结果的类型,而不用修改main函数中的任何代码。

注:

  • 我们不显式地指定变量name的类型,使得它可以被赋予任何类型的值。也就是说,变量name的类型可以在其初始化时,由其他程序动态地确定。
  • 在你改变getTheFlag函数的结果类型之后,Go 语言的编译器会在你再次构建该程序的时候,自动地更新变量name的类型。
  • 通过这种类型推断,你可以体验到动态类型编程语言所带来的一部分优势,即程序灵活性的明显提升。但在那些编程语言中,这种提升可以说是用程序的可维护性和运行效率换来的。
  • Go 语言是静态类型的,所以一旦在初始化变量时确定了它的类型,之后就不可能再改变。这就避免了在后面维护程序时的一些问题。另外,请记住,这种类型的确定是在编译期完成的,因此不会对程序的运行效率产生任何影响。

Go 语言的类型推断可以带来哪些好处?
Go 语言的类型推断可以明显提升程序的灵活性,使得代码重构变得更加容易,同时又不会给代码的维护带来额外负担(实际上,它恰恰可以避免散弹式的代码修改),更不会损失程序的运行效率。


2、变量的重声明是什么意思?

1、变量的重声明:对已经声明过的变量再次声明。

2、这涉及了短变量声明。通过使用它,我们可以对同一个代码块中的变量进行重声明。

3、变量重声明的前提条件如下。

  • 1、由于变量的类型在其初始化时就已经确定了,所以对它再次声明时赋予的类型必须与其原本的类型相同,否则会产生编译错误。
  • 2、变量的重声明只可能发生在某一个代码块中。如果与当前的变量重名的是外层代码块中的变量,那么就是另外一种含义了
  • 3、变量的重声明只有在使用短变量声明时才会发生,否则也无法通过编译。如果要在此处声明全新的变量,那么就应该使用包含关键字var的声明语句,但是这时就不能与同一个代码块中的任何变量有重名了。
  • 4、被“声明并赋值”的变量必须是多个,并且其中至少有一个是新的变量。这时我们才可以说对其中的旧变量进行了重声明。

如:

1
2
var err error
n, err := io.WriteString(os.Stdout, "Hello, everyone!\n")

使用短变量声明,对新变量n和旧变量err进行了“声明并赋值”,这时也是对err的重声明。

3、如果一个变量与其外层代码块中的变量重名会出现什么状况?

1、作用域:

  • 1、程序实体的访问权限有三种:包级私有的、模块级私有的和公开的。这其实就是 Go 语言在语言层面,依据代码块对程序实体作用域进行的定义。
  • 2、包级私有和模块级私有访问权限对应的都是代码包代码块,公开的访问权限对应的是全域代码块。
  • 3、一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。对“高内聚,低耦合”这种程序设计思想的实践,就是从这里开始的。

2、如果一个变量与其外层代码块中的变量重名会出现什么状况?
如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

var block = "package"

func main() {
block := "function"
{
block := "inner"
fmt.Printf("The block is %s.\n", block)
}
fmt.Printf("The block is %s.\n", block)
}

这个命令源码文件中有四个代码块,它们是:

  • 全域代码块、
  • main包代表的代码块、
  • main函数代表的代码块,
  • 在main函数中的一个用花括号包起来的代码块。

在后三个代码块(main包代表的代码块、main函数代表的代码块、在main函数中的一个用花括号包起来的代码块)中分别声明了一个名为block的变量,并分别把字符串值”package”、”function”和”inner”赋给了它们。此外,我在后两个代码块的最后分别尝试用fmt.Printf函数打印出“The block is %s.”。这里的“%s”只是为了占位,程序会用block变量的实际值替换掉。

那么,该源码文件中的代码能通过编译吗?
能通过编译。运行后打印出的内容是:

1
2
The block is inner.
The block is function.

  • 对于同一个代码块而言,用短变量声明对已有变量进行重声明可以通过编译
  • 对于不同的代码块来说,其中的变量重名没什么大不了,照样可以通过编译。即使这些代码块有直接的嵌套关系也是如此,就像上述代码中的main包代码块、main函数代码块和那个最内层的代码块那样。

3、这其实有一个很有画面感的查找过程。这个查找过程不只针对于变量,还适用于任何程序实体。如下面所示。

  • 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。
  • 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块(父级代码块)开始,一层一层地查找。
  • 一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么 Go 语言的编译器就会报错了。
  • 如果我们在当前源码文件中导入了其他代码包,那么引用其中的程序实体时,是需要以限定符为前缀的。所以程序在找代表变量未加限定符的名字(即标识符)的时候,是不会去被导入的代码包中查找的。

但有个特殊情况,如果我们把代码包导入语句写成import . “XXX”的形式(注意中间的那个“.”),那么就会让这个“XXX”包中公开的程序实体,被当前源码文件中的代码,视为当前代码包中的程序实体。 比如,如果有代码包导入语句import . fmt,那么我们在当前源码文件中引用fmt.Printf函数的时候直接用Printf就可以了。在这个特殊情况下,程序在查找当前源码文件后会先去查用这种方式导入的那些代码包。

4、从作用域的角度也可以说,虽然通过var block = “package”声明的变量作用域是整个main代码包,但是在main函数中,它却被那两个同名的变量“屏蔽”了。
相似的,虽然main函数首先声明的block的作用域,是整个main函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的block也不可能被该块之外的代码引用到,这也是打印内容的第二行是“The block is function.”的另一半原因。

4、不同代码块中的重名变量与变量重声明中的变量区别到底在哪儿?

1、为了方便描述,我就把不同代码块中的重名变量叫做“可重名变量”吧。注意,在同一个代码块中不允许出现重名的变量,这违背了 Go 语言的语法。

区别:

  • 变量重声明中的变量一定是在某一个代码块内的。注意,这里的“某一个代码块内”并不包含它的任何子代码块,否则就变成了“多个代码块之间”。而可重名变量指的正是在多个代码块之间由相同的标识符代表的变量。
  • 变量重声明是对同一个变量的多次声明,这里的变量只有一个。而可重名变量中涉及的变量肯定是有多个的。
  • 不论对变量重声明多少次,其类型必须始终一致,具体遵从它第一次被声明时给定的类型。而可重名变量之间不存在类似的限制,它们的类型可以是任意的。
  • 如果可重名变量所在的代码块之间,存在直接或间接的嵌套关系,那么它们之间一定会存在“屏蔽”的现象。但是这种现象绝对不会在变量重声明的场景下出现。

app2.png

2、不同类型的可重名变量例子

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])
}

运行结果:

1
The element is "one".

上述代码中,有两个都叫做container的变量,分别位于main包代码块和main函数代码块。

main包代码块中的变量是切片(slice)类型的,另一个是字典(map)类型的。在main函数的最后,试图打印出container变量的值中索引为1的那个元素。

如果container的类型不是数组、切片或字典类型,那么索引表达式就会引发编译错误。

这正是利用 Go 语言语法,帮我们约束程序的一个例子;但是当我们想知道 container 确切类型的时候,利用索引表达式的方式就不够了。

当可重名变量的值被转换成某个接口类型值,或者它们的类型本身就是接口类型的时候,严格的类型检查就很有必要了。

5、怎样判断一个变量的类型?

看如下例子:

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])
}

怎样在打印其中元素之前,正确判断变量container的类型?
使用“类型断言”表达式

1
value, ok := interface{}(container).([]string)

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])

value, ok := interface{}(container).([]string)
fmt.Printf("The value is %v.\n", value)
fmt.Printf("The ok is %v.\n", ok)
}

运行结果:

1
2
3
The element is "one".
The value is [].
The ok is false.

1、这里有一条赋值语句。在赋值符号的右边,是一个类型断言表达式。

它包括了用来把container变量的值转换为空接口值的interface{}(container)。以及一个用于判断前者的类型是否为切片类型 []string 的 .([]string)。

这个表达式的结果可以被赋给两个变量,在这里由value和ok代表。变量ok是布尔(bool)类型的,它将代表类型判断的结果,true或false。

如果是true,那么被判断的值将会被自动转换为[]string类型的值,并赋给变量value,否则value将被赋予nil(即“空”)。这里的ok也可以没有。也就是说,类型断言表达式的结果,可以只被赋给一个变量,在这里是value。但是这样的话,当判断为否时就会引发异常。

这种异常在 Go 语言中被叫做panic,我把它翻译为运行时恐慌。因为它是一种在 Go 程序运行期间才会被抛出的异常,而“恐慌”二字是英文 Panic 的中文直译。除非显式地“恢复”这种“恐慌”,否则它会使 Go 程序崩溃并停止。所以,在一般情况下,我们还是应该使用带ok变量的写法。

如果将类型断言改为:

1
value, ok := interface{}(container).(map[int]string)

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])

value, ok := interface{}(container).(map[int]string)
fmt.Printf("The value is %v.\n", value)
fmt.Printf("The ok is %v.\n", ok)
}

运行结果:

1
2
3
The element is "one".
The value is map[0:zero 1:one 2:two].
The ok is true.

1、类型断言表达式的语法形式是x.(T)。其中的x代表要被判断类型的值。这个值当下的类型必须是接口类型的,不过具体是哪个接口类型其实是无所谓的。

2、所以,当这里的container变量类型不是任何的接口类型时,我们就需要先把它转成某个接口类型的值。如果container是某个接口类型的,那么这个类型断言表达式就可以是container.([]string)。

3、在 Go 语言中,interface{}代表空接口,任何类型都是它的实现类型。这里的具体语法是interface{}(x),例如前面展示的interface{}(container)。

4、你可能会对这里的{}产生疑惑,为什么在关键字interface的右边还要加上这个东西?

  • 一对不包裹任何东西的花括号,除了可以代表空的代码块之外,还可以用于表示不包含任何内容的数据结构(或者说数据类型)。
  • 比如struct{},它就代表了不包含任何字段和方法的、空的结构体类型。而空接口interface{}则代表了不包含任何方法定义的、空的接口类型。
  • 当然了,对于一些集合类的数据类型来说,{}还可以用来表示其值不包含任何元素,比如空的切片值[]string{},以及空的字典值map[int]string{}。
    app3.png

6、类型转换规则中有哪些值得注意的地方?

1、类型转换表达式的语法形式:T(x)

其中的x可以是一个变量,也可以是一个代表值的字面量(比如1.23和struct{}{}),还可以是一个表达式。注意,如果是表达式,那么该表达式的结果只能是一个值,而不能是多个值。

在这个上下文中,x可以被叫做源值,它的类型就是源类型,而那个T代表的类型就是目标类型。如果从源类型到目标类型的转换是不合法的,那么就会引发一个编译错误。

2、三个注意点

  • 1、对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。

比如,之所以uint8(255)可以把无类型的常量255转换为uint8类型的值,是因为255在[0, 255]的范围内。

但需要特别注意的是,源整数类型的可表示范围较大,而目标类型的可表示范围较小的情况,比如把值的类型从int16转换为int8。请看下面这段代码:

1
2
var srcInt = int16(-255)
dstInt := int8(srcInt)//1

变量srcInt的值是int16类型的-255,而变量dstInt的值是由前者转换而来的,类型是int8。int16类型的可表示范围可比int8类型大了不少。

问题是,dstInt的值是多少?
首先你要知道,整数在 Go 语言以及计算机中都是以补码的形式存储的。这主要是为了简化计算机对整数的运算过程。补码其实就是原码各位求反再加 1。

比如,int16类型的值-255的补码是1111111100000001。如果我们把该值转换为int8类型的值,那么 Go 语言会把在较高位置(或者说最左边位置)上的 8 位二进制数直接截掉,从而得到00000001。又由于其最左边一位是0,表示它是个正整数,以及正整数的补码就等于其原码,所以dstInt的值就是1

一定要记住,当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可。

类似的快刀斩乱麻规则还有:当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉。

  • 2、虽然直接把一个整数值转换为一个string类型的值是可行的,但值得关注的是,被转换的整数值应该可以代表一个有效的 Unicode 代码点,否则转换的结果将会是”�”(仅由高亮的问号组成的字符串值)。
    字符’�’的 Unicode 代码点是U+FFFD。它是 Unicode 标准中定义的 Replacement Character,专用于替换那些未知的、不被认可的以及无法展示的字符。
    如:
    1
    string(-1)  //�"

由于-1肯定无法代表一个有效的 Unicode 代码点,所以得到的总会是”�”。

  • 3、string类型与各种切片类型之间的互转
    一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串会被拆分成零散、独立的字节。

除了与 ASCII 编码兼容的那部分字符集,以 UTF-8 编码的某个单一字节是无法代表一个字符的。

1
string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好

比如,UTF-8 编码的三个字节\xe4、\xbd和\xa0合在一起才能代表字符’你’,而\xe5、\xa5和\xbd合在一起才能代表字符’好’。

一个值在从string类型向[]rune类型转换时代表着字符串会被拆分成一个个 Unicode 字符。

1
string([]rune{'\u4F60', '\u597D'}) // 你好

7、什么是别名类型?什么是潜在类型?

我们可以用关键字type声明自定义的各种类型。这些类型必须在 Go 语言基本类型和高级类型的范畴之内。在它们当中,有一种被叫做“别名类型”的类型。我们可以像下面这样声明它:

1
type MyString = string

这条声明语句表示,MyString是string类型的别名类型。顾名思义,别名类型与其源类型的区别只是在名称上,它们是完全相同的。

源类型与别名类型是一对概念,是两个对立的称呼。别名类型主要是为了代码重构而存在的。

Go 语言内建的基本类型中就存在两个别名类型。byte是uint8的别名类型,而rune是int32的别名类型。

一定要注意,如果我这样声明:

1
type MyString string  // 注意,这里没有等号。

MyString2和string就是两个不同的类型了。这里的MyString2是一个新的类型,不同于其他任何类型。

这种方式也可以被叫做对类型的再定义。我们刚刚把string类型再定义成了另外一个类型MyString2。
app4.png

对于这里的类型再定义来说,string可以被称为MyString2的潜在类型。潜在类型的含义是,某个类型在本质上是哪个类型。

潜在类型相同的不同类型的值之间是可以进行类型转换的。因此,MyString2类型的值与string类型的值可以使用类型转换表达式进行互转

但对于集合类的类型[]MyString2与[]string来说这样做却是不合法的,因为[]MyString2与[]string的潜在类型不同,分别是[]MyString2和[]string。

另外,即使两个不同类型的潜在类型相同,它们的值之间也不能进行判等或比较,它们的变量之间也不能赋值。