Go语言和其他编程语言一样,一个大的程序是由很多小的基础构件组成的。变量保存值,简单的加法和减法运算被组合成较复杂的表达式。基础类型被聚合为数组或结构体等更复杂的数据结构。然后使用 iffor 之类的控制语句来组织和控制表达式的执行流程。然后多个语句被组织到一个个函数中,以便代码的隔离和复用。函数以源文件和包的方式被组织。

在本章中,我们将深入讨论在 Go语言中变量和常量的命名、声明以及赋值方式,学习不同声明方式的区别和使用方法,以及变量和常量的特点。通过简单学习基础的变量和常量的结构来进入 Go语言的世界。此外,关于一些非常细节或者简单的声明要注意的点,在本文中不会显示。

Go语言是什么类型的语言?

Go 语言是一个什么类型的语言?强/弱类型、动态/静态检查类型。

首先需要明确的是,什么是强/弱类型?什么是动态/静态类型?

  • 强类型:强类型的编程语言在编译期间会有严格的类型限制,也就是编译器会在编译期间发现变量赋值、返回值和函数调用时的类型错误。
  • 弱类型:弱类型的编程语言在出现类型错误时可能会在运行时进行隐式类型转化,这可能会造成运行错误。
  • 动态检查类型:静态类型检查是基于对源代码的分析来确定运行程序类型安全的过程,如果我们的代码能够通过静态类型检查,那么当前程序在一定程度上可以满足类型安全的要求,它能够减少程序在运行时的类型检查,也可以被看作是一种代码优化的方式。
  • 静态检查类型:动态类型检查是在运行时确定程序类型安全的过程,它需要编程语言在编译时为所有的对象加入类型标签等信息,运行时可以使用这些存储的类型信息来实现动态派发、向下转型、反射以及其他特性。

因此,Go 语言是静态强类型语言,同时 Go 语言也是编译型语言。

如何命名?

命名规则如下:

  • 以字母或下划线开头(Go语言中多不以_开头);
  • 后面可以是任意数量的字符、数字和下划线;
  • 区分大小写;
  • 不能是关键字(关键字具备特定含义);

关键字如下:

break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var
  • 可以是保留字,但是建议不使用保留字做为变量名;

保留字如下:

true false iota nil int
int8 int16 int32 int64 unit
unit8 unit16 unit32 unitptr float32
float64 complex128 complex64 bool byte
rune string error make len
cap new append copy close
deletecomplex real imag panic recover
  • 在同一范围内不允许出现同名变量
  • Go语言要求变量声明后至少使用一次(赋值不属于使用)

Go语言为开发者提供了简单的基础语法,开发者在短期内即可完全掌握这些语法并编写可用于生成环境的代码。本部分将详述在Go基础语法层面有哪些高质量 Go 代码的惯用法和有效实践,内容涵盖变量声明、无类型常量的作用、枚举常量的定义、零值可用类型的意义等。

使用一致的变量声明形式

和Python、Ruby等动态脚本语言不同,Go语言沿袭了静态编译型语言的传统:使用变量之前需要先进行变量的声明

1
2
3
4
5
6
7
8
var a int32
var s string = "hello"
var i = 13
n := 17
var (
crlf = []byte("\r\n")
colonSpace = []byte(": ")
)

Go语言有两类变量:

  • 包级变量(package variable):在package级别可见的变量。如果是导出变量,则该包级变量也可以被视为全局变量。
  • 局部变量(local variable):函数或方法体内声明的变量,仅在函数或方法体内可见。

下面来分别说明实现这两类变量在声明形式选择上保持一致性的一些最佳实践。

包级变量的声明方式

包级变量只能使用带有 var 关键字的变量声明形式,但在形式细节上仍然有一定的灵活度。我们从生命变量时是否延迟初始化这个角度对包级变量进行一次分类。

声明并同时显式初始化

源码示例:

1
2
3
4
5
6
// $GOROOT/src/io/pipe.go
var ErrClosedPipe = errors.New("io: read/write on closed pipe")

// $GOROOT/src/io/io.go
var EOF = errors.New("EOF")
var ErrShortWrite = errors.New("short write")

从上面的源码可以看出,对于声明变量的同时进行显式初始化的这类包级别变量,实践中常常会用到的格式是:

1
var variableName = InitExpression

Go 编译器会自动根据等号右侧的 InitExpression 表达式求值的类型确定左侧所声明变量的类型。

如果 InitExpression 采用的是不带有类型信息的常量表达式,如下面的语句:

1
2
var a = 17
var f = 3.14

则包级变量会被设置为常量表达式的默认类型:

  • 以整型值初始化的变量a,Go编译器会将之设置为默认类型int
  • 而以浮点值初始化的变量f,Go编译器会将之设置为默认类型float64

如果不接受默认类型,而是要显式为包级变量a和f指定类型,那么有以下两种声明方式:

1
2
3
4
5
6
7
// 第一种
var a int32 = 17
var f float32 = 3.14

// 第二种
var a = int32(17)
var f = float32(3.14)

从声明一致性的角度出发,Go语言官方更推荐后者,这样就统一了接受默认类型和显式指定类型两种声明形式。尤其是在将这些变量放在一个var块中声明时,我们更青睐这样的形式:

1
2
3
4
var (
a = 17
f = float32(3.14)
)

而不是下面这种看起来不一致的声明形式:

1
2
3
4
var (
a = 17
f float32 = 3.14
)

声明但延迟初始化

对于声明时并不显式初始化的包级变量,我们使用最基本的声明形式:

1
2
var a int32
var f float64

虽然没有显式初始化,但 Go语言会让这些变量拥有初始的“零值”。如果是自定义的类型,保证其零值可用是非常必要的。

声明聚类与就近原则

Go语言提供 var 块用于将多个变量声明语句放在一起,并且在语法上不会限制放置在 var 块中的声明类型。

但是我们一般将同一类的变量声明放在一个 var 块中,将不同类的声明放在不同的 var 块中;或者将延迟初始化的变量声明放在一个 var 块中,而将声明并显式初始化的变量放在另一个 var 块中,可以称之为“声明聚类”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// $GOROOT/src/net/http/server.go
var (
bufioReaderPool sync.Pool
bufioWriter2kPool sync.Pool
bufioWriter4kPool sync.Pool
)

var copyBufPool = sync.Pool {
New: func() interface{} {
b := make([]byte, 32*1024)
return &b
},
}
...

// $GOROOT/src/net/net.go
var (
aLongTimeAgo = time.Unix(1, 0)
noDeadline = time.Time{}
noCancel = (chan struct{})(nil)
)

var threadLimit chan struct{}
...

关于声明包级变量,或许大家还会有一个新的问题:是否应当将包级变量的声明全部集中放在源文件头部呢?

使用静态编程语言的开发人员都知道,变量声明最佳实践中还有一条:就近原则,即尽可能在靠近第一次使用变量的位置声明该变量。就近原则实际上是变量的作用域最小化的一种实现手段。

局部变量的声明方式

与包级变量相比,局部变量多了一种短变量声明形式,这也是局部变量采用最多的一种声明形式。

对于延迟初始化的局部变量声明,采用带有 var 关键字的声明形式

和全局变量类似,看个例子就行了:

1
2
3
4
5
6
7
8
9
10
11
func Foo() {
var err error
defer func() {
if err != nil {
...
}
}()

err = Bar()
...
}

对于声明且显式初始化的局部变量,建议使用短变量生命形式

短变量声明形式是局部变量最常用的声明形式,它遍布Go标准库代码。对于接受默认类型的变量,可以使用下面的形式:

1
2
3
a := 17
f := 3.14
s := "hello, gopher!"

同样,Go 编译器会根据右边的数据类型自动推测左边变量的类型,如果没有明显声明,则使用默认类型。

对于不接受默认类型的变量,依然可以使用短变量声明形式,只是在:=右侧要进行显式转型:

1
2
3
a := int32(17)
f := float32(3.14)
s := []byte("hello, gopher!")

尽量在分支控制时应用短变量声明形式

这应该是Go中短变量声明形式应用最广泛的场景了。在编写Go代码时,我们很少单独声明在分支控制语句中使用的变量,而是通过短变量声明形式将其与iffor等融合在一起。

由于良好的函数/方法设计讲究的是“单一职责”,因此每个函数/方法规模都不大,很少需要应用var块来聚类声明局部变量。当然,如果你在声明局部变量时遇到适合聚类的应用场景,你也应该毫不犹豫地使用var块来声明多个局部变量。

1
2
3
4
5
6
7
8
9
10
11
12
// $GOROOT/src/net/dial.go
func (r *Resolver) resolveAddrList(ctx context.Context, op, network,
addr string, hint Addr) (addrList, error) {
...
var (
tcp *TCPAddr
udp *UDPAddr
ip *IPAddr
wildcard bool
)
...
}

小结

要想做好代码中变量声明的一致性,需要明确要声明的变量是包级变量还是局部变量、是否要延迟初始化、是否接受默认类型、是否为分支控制变量,并结合聚类和就近原则。

image-20241024163145530

使用无类型常量简化代码

常量是现代编程语言中最常见的语法元素。在类型系统十分严格的Go语言中,常量还兼具特殊的作用。

Go常量溯源

在看 Go语言的常量之前,我们先来回顾一下 C语言中的常量是什么样的。

在 C语言中,字面量(literal)担负着常量的角色(针对整型值,还可以使用枚举常量)。可以使用整型、浮点型、字符串型、字符型字面值来满足不同场合下对常量的需求:

1
2
3
4
5
0x12345678
10086
3.1415926
"Hello, Gopher"
'a'

为了不让这些魔数(magic number)充斥于源码各处,早期C语言的常用实践是使用宏(macro)定义记号来指代这些字面值:

1
2
3
4
5
#define MAX_LEN 0x12345678
#define CMCC_SERVICE_PHONE_NUMBER 10086
#define PI 3.1415926
#define WELCOME_TO_GO "Hello, Gopher"
#define A_CHAR 'a'

这种定义“具名字面值”的实践也被称为宏定义常量。虽然后续的 C标准中提供了 const 关键字来定义在程序运行过程中不可改变的变量(又称“只读变量”),但使用宏定义常量的习惯依然被沿袭下来,并且依旧是 C编码中的主流风格。

宏定义的常量有着诸多不足,比如:

  • 仅是预编译阶段进行替换的字面值,继承了宏替换的复杂性和易错性;
  • 是类型不安全的;
  • 无法在调试时通过宏名字输出常量的值。

而 C语言中 const 修饰的标识符本质上还是变量,和其他变量一样,编译器不能像对待真正的常量那样对其进行代码优化,也无法将其作为数组声明时的初始长度。

Go语言是站在 C语言等编程语言的肩膀之上诞生的,它原生提供常量定义的关键字constGo语言中的const整合了 C语言中宏定义常量、const只读变量和枚举常量三种形式,并消除了每种形式的不足,使得Go常量成为类型安全且对编译器优化友好的语法元素。Go中所有与常量有关的声明都通过const来进行。

1
2
3
4
5
6
7
8
// $GOROOT/src/os/file.go
const (
O_RDONLY int = syscall.O_RDONLY
O_WRONLY int = syscall.O_WRONLY
O_RDWR int = syscall.O_RDWR
O_APPEND int = syscall.O_APPEND
...
)

上面对常量的声明方式仅仅是Go标准库中的少数个例,绝大多数情况下,Go常量在声明时并不显式指定类型,也就是说使用的是无类型常量(untyped constant)。比如:

1
2
3
4
5
6
// $GOROOT/src/io/io.go
const (
SeekStart = 0
SeekCurrent = 1
SeekEnd = 2
)

无类型常量是Go语言在语法设计方面的一个“微创新”,也是“追求简单”设计哲学的又一体现,它可以让你的Go代码更加简洁。

有类型常量带来的烦恼

Go是对类型安全要求十分严格的编程语言。Go要求,两个类型即便拥有相同的底层类型(underlying type),也仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算:

1
2
3
4
5
6
7
type myInt int

func main() {
var a int = 5
var b myInt = 6
fmt.Println(a + b) // 编译器会给出错误提示:invalid operation: a + b (mismatched types int and myInt)
}

我们看到,Go 在处理不同类型的变量间的运算时不支持隐式的类型转换。Go 的设计者认为,隐式转换带来的便利性不足以抵消其带来的诸多问题。要解决上面的编译错误,必须进行显式类型转换:

1
2
3
4
5
6
7
type myInt int

func main() {
var a int = 5
var b myInt = 6
fmt.Println(a + int(b)) // 输出:11
}

而将有类型常量与变量混合在一起进行运算求值时也要遵循这一要求,即如果有类型常量与变量的类型不同,那么混合运算的求值操作会报错:

1
2
3
4
5
6
7
8
type myInt int
const n myInt = 13
const m int = n + 5 // 编译器错误提示:cannot use n + 5 (type myInt) as type int in const initializer

func main() {
var a int = 5
fmt.Println(a + n) // 编译器错误提示:invalid operation: a + n (mismatched types int and myInt)
}

唯有进行显式类型转换才能让上面的代码正常工作:

1
2
3
4
5
6
7
8
type myInt int
const n myInt = 13
const m int = int(n) + 5

func main() {
var a int = 5
fmt.Println(a + int(n)) // 输出:18
}

有类型常量给代码简化带来了麻烦,但这也是Go语言对类型安全严格要求的结果。

无类型常量消除烦恼,简化代码

1
2
3
4
5
6
7
8
9
10
11
12
13
type myInt int
type myFloat float32
type myString string

func main() {
var j myInt = 5
var f myFloat = 3.1415926
var str myString = "Hello, Gopher"

fmt.Println(j) // 输出:5
fmt.Println(f) // 输出:3.1415926
fmt.Println(str) // 输出:Hello, Gopher
}

可以看到这三个字面值无须显式类型转换就可以直接赋值给对应的三个自定义类型的变量,这等价于下面的代码:

1
2
3
var j myInt = myInt(5)
var f myFloat = myFloat(3.1415926)
var str myString = myString("Hello, Gopher")

但显然之前的无须显式类型转换的代码更为简洁。

Go的无类型常量恰恰就拥有像字面值这样的特性,该特性使得无类型常量在参与变量赋值和计算过程时无须显式类型转换,从而达到简化代码的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const (
a = 5
pi = 3.1415926
s = "Hello, Gopher"
c = 'a'
b = false
)

type myInt int
type myFloat float32
type myString string

func main() {
var j myInt = a
var f myFloat = pi
var str myString = s
var e float64 = a + pi

fmt.Println(j) // 输出:5
fmt.Println(f) // 输出:3.1415926
fmt.Println(str) // 输出:Hello, Gopher
fmt.Printf("%T, %v\n", e, e) // float64, 8.1415926
}

无类型常量使得Go在处理表达式混合数据类型运算时具有较大的灵活性,代码编写也有所简化,我们无须再在求值表达式中做任何显式类型转换了。

除此之外,无类型常量也拥有自己的默认类型:无类型的布尔型常量、整数常量、字符常量、浮点数常量、复数常量、字符串常量对应的默认类型分别为bool、int、int32(rune)、float64、complex128和string。当常量被赋值给无类型变量、接口变量时,常量的默认类型对于确定无类型变量的类型及接口对应的动态类型是至关重要的。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const (
a = 5
s = "Hello, Gopher"
)

func main() {
n := a
var i interface{} = a

fmt.Printf("%T\n", n) // 输出:int
fmt.Printf("%T\n", i) // 输出:int
i = s
fmt.Printf("%T\n", i) // 输出:string
}

使用 itoa 实现枚举常量

C家族的主流编程语言(如C++、Java等)都提供定义枚举常量的语法。比如在C语言中,枚举是一个具名的整型常数的集合。下面是使用枚举定义的Weekday类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// C语法
enum Weekday {
SUNDAY,
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY
};

int main() {
enum Weekday d = SATURDAY;
printf("%d\n", d); // 6
}

C语言针对枚举类型提供了很多语法上的便利,比如:如果没有显式给枚举常量赋初始值,那么枚举类型的第一个常量的值为0,后续常量的值依次加1。

与使用define宏定义的常量相比,C编译器可以对专用的枚举类型进行严格的类型检查,使得程序更为安全。

枚举的存在代表了一类现实需求:有限数量标识符构成的集合,且多数情况下并不关心集合中标识符实际对应的值;注重类型安全。

与其他C家族主流语言(如C++、Java)不同,Go语言没有提供定义枚举常量的语法。我们通常使用常量语法定义枚举常量,比如要在Go中定义上面的Weekday类型,可以这样写:

1
2
3
4
5
6
7
8
9
const (
Sunday = 0
Monday = 1
Tuesday = 2
Wednesday = 3
Thursday = 4
Friday = 5
Saturday = 6
)

如果仅仅能支持到这种程度,那么Go就算不上是“站在巨人的肩膀上”了。Go的const语法提供了“隐式重复前一个“非空表达式”的机制,来看下面的代码:

1
2
3
4
5
const (
Apple, Banana = 11, 22
Strawberry, Grape
Pear, Watermelon
)

常量定义的后两行没有显式给予初始赋值,Go编译器将为其隐式使用第一行的表达式,这样上述定义等价于:

1
2
3
4
5
const (
Apple, Banana = 11, 22
Strawberry, Grape = 11, 22
Pear, Watermelon = 11, 22
)

不过这显然仍无法满足枚举的要求,Go在这个机制的基础上又提供了神器iota。有了iota,我们就可以定义满足各种场景的枚举常量了。

iota是 Go语言的一个预定义标识符,它表示的是const声明块(包括单行声明)中每个常量所处位置在块中的偏移值(从零开始)。同时,每一行中的iota自身也是一个无类型常量,可以像无类型常量那样自动参与不同类型的求值过程,而无须对其进行显式类型转换操作。下面是Go标准库中sync/mutex.go中的一段枚举常量的定义:

1
2
3
4
5
6
7
8
// $GOROOT/src/sync/mutex.go (go 1.12.7)
const (
mutexLocked = 1 << iota //1
mutexWoken //2
mutexStarving //4
mutexWaiterShift = iota //3
starvationThresholdNs = 1e6 //1e6
)

iota 的本质:它仅代表常量声明的索引,所以它会表示出以下特征

  • 单个 const 声明块中从 0 开始取值;
  • 单个 const 声明块中,每增加一行声明,iota 的取值增 1,即便声明中没有使用 iota 也是如此;
  • 单行声明语句中,即便出现多个 iota,iota 的取值也保持不变。

iota的加入让Go在枚举常量定义上的表达力大增,主要体现在如下几方面:

  1. iota预定义标识符能够以更为灵活的形式为枚举常量赋初值;
  2. Go的枚举常量不限于整型值,也可以定义浮点型的枚举常量;
  3. iota使得维护枚举常量列表更容易;
  4. 使用有类型枚举常量保证类型安全;

尽量定义零值可用的类型

保持零值可用。——Go谚语

在Go语言中,零值不仅在变量初始化阶段避免了变量值不确定可能带来的潜在问题,而且定义零值可用的类型也是Go语言积极倡导的最佳实践之一,就像上面那句Go谚语所说的那样。

Go 语言中的零值

在使用 C语言进行开发时,我们不难发现一个问题,在声明一个变量但没有显式初始化时,它的值是不确定的。即未被显式初始化且具有自动存储持续时间的对象,其值是不确定的。

Go 语言的选择却恰恰相反,当通过声明或调用new为变量分配存储空间,或者通过复合文字字面量或调用make创建新值,且不提供显式初始化时,Go会为变量或值提供默认值

Go语言中的每个原生类型都有其默认值,这个默认值就是这个类型的零值。下面是Go规范定义的内置原生类型的默认值(零值):

  • 整型类型:0
  • 浮点类型:0.0
  • 布尔类型:false
  • 字符串类型:””
  • 指针、interface、切片(slice)、channel、map、function:nil
  • 另外,Go的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。

零值可用

当我们申明了一个变量,但是未对其进行显示初始化时,go语言编译器就会把该变量置为默认零值,且可以直接使用该变量。 例如:

1
2
3
4
var a []int              
fmt.Println(a)
a = append(a, 6)
fmt.Println(a)

image.png

但是不能直接对切片进行赋值操作这样会出现错误,同样的还有map

零值可用的好处

  • 开箱即用:Go语言零值让程序变得简单了,有些场景我们不需要初始化变量就可以直接进行使用。 例如上面的slice、map还有基础类型。
  • 方法的归纳:利用零值可用的特性,我们可以通过定义一个空的结构体,配合空结构体方法接收者属性,将一些方法组合起来,在业务代码中便于后续的拓展和维护。
  • 标准库中可以不显示初始化:在GO标准库和运行时代码中,典型的零值可用:sync.Mutexbytes.Buffer

零值可用的类型要注意尽量避免值复制。

使用复合字面值作为初值构造器

在上一条中,我们了解到零值可用对于编写出符合Go惯用法的代码是大有裨益的。

但有些时候,零值并非最好的选择,我们有必要为变量赋予适当的初值以保证其后续以正确的状态参与业务流程计算,尤其是Go语言中的一些复合类型的变量。

Go语言中的复合类型包括结构体、数组、切片和map。对于复合类型变量,最常见的值构造方式就是对其内部元素进行逐个赋值,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var s myStruct
s.name = "tony"
s.age = 23

var a [5]int
a[0] = 13
a[1] = 14
...
a[4] = 17

sl := make([]int, 5, 5)
sl[0] = 23
sl[1] = 24
...
sl[4] = 27

m := make(map[int]string)
m[1] = "hello"
m[2] = "gopher"
m[3] = "!"

但这样的值构造方式让代码显得有些烦琐,尤其是在构造组成较为复杂的复合类型变量的初值时。Go提供的复合字面值(composite literal)语法可以作为复合类型变量的初值构造器。上述代码可以使用复合字面值改写成下面这样:

1
2
3
4
s := myStruct{"tony", 23}
a := [5]int{13, 14, 15, 16, 17}
sl := []int{23, 24, 25, 26, 27}
m := map[int]string {1:"hello", 2:"gopher", 3:"!"}

显然,最初的代码得到了大幅简化。

复合字面值由两部分组成:一部分是类型,比如上述示例代码中赋值操作符右侧的myStruct[5]int[]intmap[int]string;另一部分是由大括号{}包裹的字面值。这里的字面值形式仅仅是Go复合字面值作为值构造器的基本用法。

总结

在 Go 语言中,变量和常量是非常重要的概念。变量用于存储可以在程序运行过程中被修改的数据,而常量用于存储在程序的整个生命周期中都不会改变的数据。通过不同的声明方式,可以方便地声明和初始化变量和常量。在使用变量和常量时,需要注意它们的作用域和特点,以确保程序的正确性和可读性。

参考资料

难得有一两天没什么笔试和面试的事情,还是要好好整理一下最近学习的内容,但是翻过来覆过去又好像一直都是这些东西。但是这些内容又是常看常新,还是边复习就内容边学习新内容吧。如果时间合适的话,还是想最近一段时间学一学关于测试开发相关的东西。

加油,祝我面试顺利!!!