Go第二篇之基本语法总结

Go语言标识符

标识符以字母或下划线开头,大小写敏感
_是特殊标识符,用来忽略结果

Go语言变量的声明

变量(Variable)的功能是存储用户的数据。不同的逻辑有不同的对象类型,也就有不同的变量类型。经过半个多世纪的发展,编程语言已经形成一套固定的类型,这些类型在不同的编程语言中基本是相通的。常见变量的数据类型有:整型、浮点型、布尔型、结构体等。
Go 语言作为C语言家族的新派代表,在C语言的定义方法和类型上做了优化和调整,更加灵活易学。
Go 语言的每一个变量都拥有自己的类型,必须经过声明才能开始用。

由于 Go 是强类型(Strongly Typed)语言,因此不允许某一类型的变量赋值为其他类型的值,否则会报错

var a int
var b string
var c []float32
var d func() bool
var e struct{
x int
}

标准格式

Go 语言的变量声明格式为:

var 变量名 变量类型

变量声明以关键字 var 开头,后置变量类型,行尾无须分号

批量格式

觉得每行都用 var 声明变量比较烦琐?没关系,还有一种为懒人提供的定义变量的方法:

var (
a int
b string
c []float32
d func() bool
e struct {
x int
}
)

使用关键字var和括号,可以将一组变量定义放在一起。

Go语言变量的初始化

Go 语言在声明变量时,自动对变量对应的内存区域进行初始化操作。每个变量会初始化其类型的默认值,例

整型和浮点型变量的默认值为 0。
字符串变量的默认值为空字符串。
布尔型变量默认为 bool。
切片、函数、指针变量的默认为 nil

当然,依然可以在变量声明时赋予变量一个初始值

变量初始化的标准格式

var 变量名 类型 = 表达式

编译器推导类型的格式

在标准格式的基础上,将 int 省略后,编译器会尝试根据等号右边的表达式推导 hp 变量的类型。

var hp = 100

等号右边的部分在编译原理里被称做右值(rvalue)。

短变量声明并初始化

var 的变量声明还有一种更为精简的写法,例

hp := 100

这是 Go 语言的推导声明写法,编译器会自动根据右值类型推断出左值的对应类型。

注意:由于使用了:=,而不是赋值的=,因此推导声明写法的左值变量必须是没有定义过的变量。若定义过,将会发生编译错误

如果 hp 已经被声明过,但依然使用:=时编译器会报错

短变量声明的形式在开发中的例子较多,比如

conn, err := net.Dial("tcp","127.0.0.1:8080")

net.Dial 提供按指定协议和地址发起网络连接,这个函数有两个返回值,一个是连接对象,一个是 err 对象。如果是标准格式将会变成:

var conn net.Conn
var err error
conn, err = net.Dial("tcp", "127.0.0.1:8080")

因此,短变量声明并初始化的格式在开发中使用比较普遍。

注意:在多个短变量声明和赋值中,至少有一个新声明的变量出现在左值中,即便其他变量名可能是重复声明的,编译器也不会报错,代码如下:

conn, err := net.Dial("tcp", "127.0.0.1:8080")
conn2, err := net.Dial("tcp", "127.0.0.1:8080")

上面的代码片段,编译器不会报err重复定义

Go语言多个变量同时赋值

编程最简单的算法之一,莫过于变量交换。交换变量的常见算法需要一个中间变量进行变量的临时保存

到了 Go 语言时,内存不再是紧缺资源,而且写法可以更简单。使用 Go 的“多重赋值”特性,可以轻松完成变量交换的任务:

var a int = 100
var b int = 200
b, a = a, b
fmt.Println(a, b)

多重赋值时,变量的左值和右值按从左到右的顺序赋值。
多重赋值在 Go 语言的错误处理和函数返回值中会大量地使用。
例如,使用 Go 语言进行排序时就需要使用交换,代码如下

type IntSlice []int
func (p IntSlice) Len() int { return len(p) }
func (p IntSlice) Less(i, j int) bool { return p[i] < p[j] }
func (p IntSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

代码说明如下

第 1 行,将 []int 声明为 IntSlice 类型。
第 3 行,为这个类型编写一个 Len 方法,提供切片的长度。
第 4 行,根据提供的 i、j 元素索引,获取元素后进行比较,返回比较结果。
第 5 行,根据提供的 i、j 元素索引,交换两个元素的值。

Go语言匿名变量

在使用多重赋值时,如果不需要在左值中接收变量,可以使用匿名变量(anonymous variable)匿名变量的表现是一个下画线_,使用匿名变量时,只需要在变量声明的地方使用下画线替换即可。例

func GetData() (int, int) {
return 100, 200
}
a, _ := GetData()
_, b := GetData()

匿名变量不占用命名空间,不会分配内存。匿名变量与匿名变量之间也不会因为多次声明而无法使用
提示:在 Lua 等编程语言里,匿名变量也被叫做哑元变量

Go语言整型(整数类型)

Go 语言中有丰富的数据类型,除了基本的整型、浮点型、布尔型、字符串外,还有切片、结构体、函数、map、通道(channel)等。Go 语言的基本类型和其他语言大同小异,切片类型有着指针的便利性,但比指针更为安全,很多高级语言都配有切片进行安全和高效率的内存操作。
本节我们将介绍最基本的整型。
整型分为以下两个大类:

按长度分为:int8、int16、int32、int64
还有对应的无符号整型:uint8、uint16、uint32、uint64

其中,uint8 就是我们熟知的 byte 型,int16 对应C语言中的 short 型,int64 对应C语言中的 long 型。

自动匹配平台的 int 和 uint

Go 语言也有自动匹配特定平台整型长度的类型—— int 和 uint。
可以跨平台的编程语言可以运行在多种平台上。平台的字节长度是有差异的。64 位平台现今已经较为普及,但 8 位、16 位、32 位的操作系统依旧存在。16 位平台上依然可以使用 64 位的变量,但运行性能和内存性能上较差。同理,在 64 位平台上大量使用 8 位、16 位等与平台位数不等长的变量时,编译器也是尽量将内存对齐以获得最好的性能。
不能正确匹配平台字节长度的程序就类似于用轿车运一头牛和用一辆卡车运送一头牛的情形一样。
在使用 int 和 uint 类型时,不能假定它是 32 位或 64 位的整型,而是考虑 int 和 uint 可能在不同平台上的差异。

哪些情况下使用 int 和 uint

逻辑对整型范围没有特殊需求。例如,对象的长度使用内建 len() 函数返回,这个长度可以根据不同平台的字节长度进行变化。实际使用中,切片或 map 的元素数量等都可以用 int 来表示。
反之,在二进制传输、读写文件的结构描述时,为了保持文件的结构不会受到不同编译目标平台字节长度的影响,不要使用 int 和 uint。

Go语言浮点类型(小数类型)

Go语言支持两种浮点型数:float32 和 float64。这两种浮点型数据格式遵循 IEEE 754 标准

float32 的浮点数的最大范围约为 3.4e38,可以使用常量定义:math.MaxFloat32。
float64 的浮点数的最大范围约为 1.8e308,可以使用一个常量定义:math.MaxFloat64。

打印浮点数时,可以使用 fmt 包配合动词%f

Go语言bool类型(布尔类型)

布尔型数据在 Go 语言中以 bool 类型进行声明,布尔型数据只有 true(真)和 false(假)两个值。
Go 语言中不允许将整型强制转换为布尔型

var n bool
fmt.Println(int(n) * 2)

编译错误,输出如下:

cannot convert n (type bool) to type int

布尔型无法参与数值运算,也无法与其他类型进行转换。

Go语言字符串

字符串在 Go 语言中以原生数据类型出现,使用字符串就像使用其他原生数据类型(int、bool、float32、float64 等)一样。
提示:在 C++、C# 语言中,字符串以类的方式进行封装。
C# 语言中在使用泛型匹配约束类型时,字符串是以 Class 的方式存在,而不是 String,因为并没有“字符串”这种原生数据类型。
在 C++ 语言中使用模板匹配类型时,为了使字符串与其他原生数据类型一样支持赋值操作,需要对字符串类进行操作符重载。
字符串的值为双引号中的内容,可以在 Go 语言的源码中直接添加非 ASCII 码字符,代码如下:

str := "hello world"
ch := "中文"

字符串转义符

Go 语言的字符串常见转义符包含回车、换行、单双引号、制表符等,如下表所示。
Go语言的常见转义符

转移符 含  义
\r 回车符(返回行首)
\n 换行符(直接跳到下一行的同列位置)
\t 制表符
\' 单引号
\" 双引号
\\ 反斜杠


在 Go 语言源码中使用转义符代码如下:

package main
import (
"fmt"
)
func main() {
fmt.Println("str := \"c:\\Go\\bin\\go.exe\"")
}

代码运行结果:

str := "c:\Go\bin\go.exe"

这段代码中将双引号和反斜杠“\”进行转义。

字符串实现基于 UTF-8 编码

Go 语言里的字符串的内部实现使用 UTF-8 编码。通过 rune 类型,可以方便地对每个 UTF-8 字符进行访问。当然,Go 语言也支持按传统的 ASCII 码方式进行逐字符访问。
提示:Python 语言的 2.0 版本不是基于 UTF-8 编码设计,到了 3.0 版才改为 UTF-8 编码设计。因此,使用 2.0 版本时,在编码上会出现很多混乱情况。
同样,C/C++语言的 std::string 在使用 UTF-8 时,经常因为没有方便的 UTF-8 配套封装让编写极为困难。

定义多行字符串

在源码中,将字符串的值以双引号书写的方式是字符串的常见表达方式,被称为字符串字面量(string literal)。这种双引号字面量不能跨行。如果需要在源码中嵌入一个多行字符串时,就必须使用`字符,代码如下

const str = ` 第一行
第二行
第三行
\r\n
`
fmt.Println(str)

`叫反引号,就是键盘上 1 键左边的键,两个反引号间的字符串将被原样赋值到 str 变量中。
在这种方式下,反引号间换行将被作为字符串中的换行,但是所有的转义字符均无效,文本将会原样输出。

Go语言计算字符串长度——len()和RuneCountInString()

Go 语言的内建函数 len(),可以用来获取切片、字符串、通道(channel)等的长度

len() 函数的返回值的类型为 int,表示字符串的 ASCII 字符个数或字节长度

这里的差异是由于 Go 语言的字符串都以 UTF-8 格式保存,每个中文占用 3 个字节,因此使用 len() 获得两个中文文字对应的 6 个字节
如果希望按习惯上的字符个数来计算,就需要使用 Go 语言中 UTF-8 包提供的 RuneCountInString() 函数,统计 Uncode 字符数量。
下面的代码展示如何计算UTF-8的字符个数。

fmt.Println(utf8.RuneCountInString("中国"))

一般游戏中在登录时都需要输入名字,而名字一般有长度限制。考虑到国人习惯使用中文做名字,就需要检测字符串 UTF-8 格式的长度。

总结

    • ASCII 字符串长度使用 len() 函数。
    • Unicode 字符串长度使用 utf8.RuneCountInString() 函数。

Go语言遍历字符串——获取每一个字符串元素

遍历每一个ASCII字符

遍历 ASCII 字符使用 for 的数值循环进行遍历,直接取每个字符串的下标获取 ASCII 字符,如下面的例子所示。

theme := "狙击 start"
for i := 0; i < len(theme); i++ {
fmt.Printf("ascii: %c %d\n", theme[i], theme[i])
}

程序输出

ascii: ?  231
ascii:     139
ascii:     153
ascii: ?  229
ascii:     135
ascii: ?  187
ascii:    32
ascii: s  115
ascii: t  116
ascii: a  97
ascii: r  114
ascii: t  116

这种模式下取到的汉字“惨不忍睹”。由于没有使用 Unicode,汉字被显示为乱码。

按Unicode字符遍历字符串

同样的内容:

theme := "狙击 start"
for _, s := range theme {
fmt.Printf("Unicode: %c %d\n", s, s)
}

程序输出如下:

Unicode: 狙  29401
Unicode: 击  20987
Unicode:    32
Unicode: s  115
Unicode: t  116
Unicode: a  97
Unicode: r  114
Unicode: t  116

总结

ASCII 字符串遍历直接使用下标
Unicode 字符串遍历用 for range

Go语言字符串截取(获取字符串的某一段字符)

获取字符串的某一段字符是开发中常见的操作,我们一般将字符串中的某一段字符称做子串(substring)

字符串索引比较常用的有如下几种方法:

strings.Index:正向搜索子字符串。
strings.LastIndex:反向搜索子字符串。
搜索的起始位置可以通过切片偏移制作。

Go语言修改字符串

Go 语言的字符串无法直接修改每一个字符元素,只能通过重新构造新的字符串并赋值给原来的字符串变量实现

package main

import "fmt"

func main() {

	angel := "Heros nerver die"
	angleBytes := []byte(angel)
	for i := 5; i <= 10; i++ {
		angleBytes[i] = ' '
	}

	fmt.Println(string(angleBytes))
}
// Heros      r die

代码说明如下:

在第 3 行中,将字符串转为字符串数组。
第 5~7 行利用循环,将 never 单词替换为空格。
最后打印结果。

感觉我们通过代码达成了修改字符串的过程,但真实的情况是:Go 语言中的字符串和其他高级语言(Java、C#)一样,默认是不可变的(immutable)
字符串不可变有很多好处,如天生线程安全,大家使用的都是只读对象,无须加锁;再者,方便内存共享,而不必使用写时复制(Copy On Write)等技术;字符串 hash 值也只需要制作一份。
所以说,代码中实际修改的是 []byte,[]byte 在 Go 语言中是可变的,本身就是一个切片
在完成了对 []byte 操作后,在第 9 行,使用 string() 将 []byte 转为字符串时,重新创造了一个新的字符串。

总结

Go 语言的字符串是不可变的
修改字符串时,可以将字符串转换为 []byte 进行修改
[]byte 和 string 可以通过强制类型转换互转

Go语言字符串拼接

连接字符串这么简单,还需要学吗?确实,Go 语言和大多数其他语言一样,使用+对字符串进行连接操作,非常直观。
但问题来了,好的事物并非完美,简单的东西未必高效。除了加号连接字符串,Go 语言中也有类似于 StringBuilder 的机制来进行高效的字符串连接,例如:

hammer := "吃我一锤"
sickle := "死吧"
// 声明字节缓冲
var stringBuilder bytes.Buffer
// 把字符串写入缓冲
stringBuilder.WriteString(hammer)
stringBuilder.WriteString(sickle)
// 将缓冲以字符串形式输出
fmt.Println(stringBuilder.String())

Go语言字符类型(byte和rune)

bytes.Buffer 是可以缓冲并可以往里面写入各种字节数组的。字符串也是一种字节数组,使用 WriteString() 方法进行写入。
将需要连接的字符串,通过调用 WriteString() 方法,写入 stringBuilder 中,然后再通过 stringBuilder.String() 方法将缓冲转换为字符串。

Go语言fmt.Sprintf(格式化输出)

格式化在逻辑中非常常用。使用格式化函数,要注意写法:

fmt.Sprintf(格式化样式, 参数列表…)

格式化样式:字符串形式,格式化动词以%开头
参数列表:多个参数以逗号分隔,个数必须与格式化样式中的个数一一对应,否则运行时会报错

在 Go 语言中,格式化的命名延续C语言风格:

var progress = 2
var target = 8
// 两参数格式化
title := fmt.Sprintf("已采集%d个药草, 还需要%d个完成任务", progress, target)
fmt.Println(title)
pi := 3.14159
// 按数值本身的格式输出
variant := fmt.Sprintf("%v %v %v", "月球基地", pi, true)
fmt.Println(variant)
// 匿名结构体声明, 并赋予初值
profile := &struct {
Name string
HP int
}{
Name: "rat",
HP: 150,
}
fmt.Printf("使用'%%+v' %+v\n", profile)
fmt.Printf("使用'%%#v' %#v\n", profile)
fmt.Printf("使用'%%T' %T\n", profile)

代码输出如下:

已采集2个药草, 还需要8个完成任务
"月球基地" 3.14159 true
使用'%+v' &{Name:rat HP:150}
使用'%#v' &struct { Name string; HP int }{Name:"rat", HP:150}
使用'%T' *struct { Name string; HP int }C语言中, 使用%d代表整型参数

下表中标出了常用的一些格式化样式中的动词及功能。

 

表:字符串格式化时常用动词及功能
动  词 功  能
%v 按值的本来值输出
%+v 在 %v 基础上,对结构体字段名和值进行展开
%#v 输出 Go 语言语法格式的值
%T 输出 Go 语言语法格式的类型和值
%% 输出 % 本体
%b 整型以二进制方式显示
%o 整型以八进制方式显示
%d 整型以十进制方式显示
%x 整型以十六进制方式显示
%X 整型以十六进制、字母大写方式显示
%U Unicode 字符
%f 浮点数
%p 指针,十六进制方式显示

 

字符串中的每一个元素叫做“字符”,在遍历或者单个获取字符串元素时可以获得字符
Go 语言的字符有以下两种

一种是 uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符
另一种是 rune 类型,代表一个 UTF-8 字符。当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型实际是一个 int32

使用 fmt.Printf 中的%T动词可以输出变量的实际类型,使用这个方法可以查看 byte 和 rune 的本来类型

var a byte = 'a'
fmt.Printf("%d %T\n", a, a)
var b rune = '你'
fmt.Printf("%d %T\n", b, b)

例子输出结果:

97 uint8
20320 int32

可以发现,byte 类型的 a 变量,实际类型是 uint8,其值为 'a',对应的 ASCII 编码为 97。
rune 类型的 b 变量的实际类型是 int32,对应的 Unicode 码就是 20320。
Go 使用了特殊的 rune 类型来处理 Unicode,让基于 Unicode 的文本处理更为方便,也可以使用 byte 型进行默认字符串处理,性能和扩展性都有照顾

UTF-8 和 Unicode 有何区别

Unicode 是字符集,ASCII 也是一种字符集。
字符集为每个字符分配一个唯一的 ID,我们使用到的所有字符在 Unicode 字符集中都有唯一的一个 ID 对应,例如上面例子中的 a 在 Unicode 与 ASCII 中的编码都是 97。“你”在 Unicode 中的编码为 20320,但是在不同国家的字符集中,“你”的 ID 会不同。而无论任何情况下,Unicode 中的字符的 ID 都是不会变化的。
UTF-8 是编码规则,将 Unicode 中字符的 ID 以某种方式进行编码。UTF-8 的是一种变长编码规则,从 1 到 4 个字节不等。编码规则如下

0xxxxxx 表示文字符号 0~127,兼容 ASCII 字符集
从 128 到 0x10ffff 表示其他字符

根据这个规则,拉丁文语系的字符编码一般情况下,每个字符依然占用一个字节,而中文每个字符占用 3 个字节
广义的 Unicode 指一个标准,定义字符集及编码规则,即 Unicode 字符集和 UTF-8、UTF-16 编码等

Go语言数据类型转换

Go 语言使用类型前置加括号的方式进行类型转换,一般格式如下:

T(表达式)

其中,T 代表要转换的类型。表达式包括变量、复杂算子和函数返回值等。
类型转换时,需要考虑两种类型的关系和范围,是否会发生数值截断等,参见下面代码:

package main
import (
"fmt"
"math"
)
func main() {
// 输出各数值范围
fmt.Println("int8 range:", math.MinInt8, math.MaxInt8)
fmt.Println("int16 range:", math.MinInt16, math.MaxInt16)
fmt.Println("int32 range:", math.MinInt32, math.MaxInt32)
fmt.Println("int64 range:", math.MinInt64, math.MaxInt64)
// 初始化一个32位整型值
var a int32 = 1047483647
// 输出变量的十六进制形式和十进制值
fmt.Printf("int32: 0x%x %d\n", a, a)
// 将a变量数值转换为十六进制, 发生数值截断
b := int16(a)
// 输出变量的十六进制形式和十进制值
fmt.Printf("int16: 0x%x %d\n", b, b)
// 将常量保存为float32类型
var c float32 = math.Pi
// 转换为int类型, 浮点发生精度丢失
fmt.Println(int(c))
}

代码说明如下:

  • 第 11~14 行,输出常见整型类型的数值范围。
  • 第 17 行,声明 int32 类型的 a 变量并初始化。
  • 第 19 行,使用 fmt.Printf 的%x动词将数值以十六进制格式输出。这一行输出 a 在转换前的 32 位的值。
  • 第 22 行,将 a 的值转换为 int16 类型,也就是从 32 位有符号整型转换为 16 位有符号整型。由于 16 位变量没有 32 位变量的数值范围大,因此数值会进行截断。
  • 第 24 行,输出转换后的 a 变量值,也就是 b 的值。同样以十六进制和十进制两种方式进行打印。
  • 第 27 行,math.Pi 是 math 包的常量,默认没有类型,会在引用到的地方自动根据实际类型进行推导。这里 math.Pi 被存到 c 中,类型为 float32。
  • 第 29 行,将 float32 转换为 int 类型并输出。

代码输出如下:

int8 range: -128 127
int16 range: -32768 32767
int32 range: -2147483648 2147483647
int64 range: -9223372036854775808 9223372036854775807
int32: 0x3e6f54ff 1047483647
int16: 0x54ff 21759
3

根据输出结果,16 位有符号整型的范围是 -32768~32767,而 a 变量的 1047483647 不在这个范围内。1047483647 对应的十六进制为 0x3e6f54ff,转为 16 位变量后,长度缩短一半,也就是在十六进制上砍掉一半,变成 0x54ff,对应的十进制值为 21759。
浮点数在转换为整型时,会将小数部分去掉,只保留整数部分。
整型截断在类型转换中发生的较为隐性,有些即为难追查的问题,很小一部分是由整型截断造成

Go语言指针

指针(pointer)概念在 Go 语言中被拆分为两个核心概念:

  • 类型指针,允许对这个指针类型的数据进行修改。传递数据使用指针,而无须拷贝数据。类型指针不能进行偏移和运算。
  • 切片,由指向起始元素的原始指针、元素数量和容量组成。

受益于这样的约束和拆分,Go 语言的指针类型变量拥有指针的高效访问,但又不会发生指针偏移,从而避免非法修改关键性数据问题。同时,垃圾回收也比较容易对不会发生偏移的指针进行检索和回收。
切片比原始指针具备更强大的特性,更为安全。切片发生越界时,运行时会报出宕机,并打出堆栈,而原始指针只会崩溃。

认识指针地址和指针类型

每个变量在运行时都拥有一个地址,这个地址代表变量在内存中的位置。Go 语言中使用&作符放在变量前面对变量进行“取地址”操作。
格式如下:

ptr := &v    // v的类型为T

其中 v 代表被取地址的变量,被取地址的 v 使用 ptr 变量进行接收,ptr 的类型就为*T,称做 T 的指针类型。*代表指针。
指针实际用法,通过下面的例子了解:

package main
 
import (
"fmt"
)
 
func main() {
var cat int = 1
var str string = "banana"
fmt.Printf("%p %p", &cat, &str)
}

运行结果:

0xc042052088 0xc0420461b0

代码说明如下:

  • 第 8 行,声明整型 cat 变量。
  • 第 9 行,声明字符串 str 变量。
  • 第 10 行,使用 fmt.Printf 的动词%p输出 cat 和 str 变量取地址后的指针值,指针值带有0x的十六进制前缀。

输出值在每次运行是不同的,代表 cat 和 str 两个变量在运行时的地址。
在 32 位平台上,将是 32 位地址;64 位平台上是 64 位地址。
提示:变量、指针和地址三者的关系是:每个变量都拥有地址,指针的值就是地址。

从指针获取指针指向的值

在对普通变量使用&操作符取地址获得这个变量的指针后,可以对指针使用*操作,也就是指针取值,代码如下

package main
 
import (
"fmt"
)
 
func main() { 
// 准备一个字符串类型
var house = "Malibu Point 10880, 90265"
// 对字符串取地址, ptr类型为*string
ptr := &house
// 打印ptr的类型
fmt.Printf("ptr type: %T\n", ptr)
// 打印ptr的指针地址
fmt.Printf("address: %p\n", ptr)
// 对指针进行取值操作
value := *ptr
// 取值后的类型
fmt.Printf("value type: %T\n", value)
// 指针取值后就是指向变量的值
fmt.Printf("value: %s\n", value)
 
}

运行结果:

ptr type: *string
address: 0xc0420401b0
value type: string
value: Malibu Point 10880, 90265

代码说明如下:

  • 第 10 行,准备一个字符串并赋值。
  • 第 13 行,对字符串取地址,将指针保存到 ptr 中。
  • 第 16 行,打印 ptr 变量的类型,类型为 *string。
  • 第 19 行,打印 ptr 的指针地址,每次运行都会发生变化。
  • 第 22 行,对 ptr 指针变量进行取值操作,value 变量类型为 string。
  • 第 25 行,打印取值后 value 的类型。
  • 第 28 行,打印 value 的值。

取地址操作符&和取值操作符*是一对互补操作符,&取出地址,*根据地址取出地址指向的值。
变量、指针地址、指针变量、取地址、取值的相互关系和特性如下:

  • 对变量进行取地址(&)操作,可以获得这个变量的指针变量。
  • 指针变量的值是指针地址。
  • 对指针变量进行取值(*)操作,可以获得指针变量指向的原变量的值。

使用指针修改值

通过指针不仅可以取值,也可以修改值。
前面已经使用多重赋值的方法进行数值交换,使用指针同样可以进行数值交换,代码如下

package main
import "fmt"
// 交换函数
func swap(a, b *int) {
// 取a指针的值, 赋给临时变量t
t := *a
// 取b指针的值, 赋给a指针指向的变量
*a = *b
// 将a指针的值赋给b指针指向的变量
*b = t
}
func main() {
// 准备两个变量, 赋值1和2
x, y := 1, 2
// 交换变量值
swap(&x, &y)
// 输出变量值
fmt.Println(x, y)
} 

运行结果:
2 1
代码说明如下:

  • 第 6 行,定义一个交换函数,参数为 a、b,类型都为 *int,都是指针类型。
  • 第 9 行,将 a 指针取值,把值(int类型)赋给 t 变量,t 此时也是 int 类型。
  • 第 12 行,取 b 指针值,赋给 a 变量指向的变量。注意,此时*a的意思不是取 a 指针的值,而是“a指向的变量”。
  • 第 15 行,将 t 的值赋给 b 指向的变量。
  • 第 21 行,准备 x、y 两个变量,赋值 1 和 2,类型为 int。
  • 第 24 行,取出 x 和 y 的地址作为参数传给 swap() 函数进行调用。
  • 第 27 行,交换完毕时,输出 x 和 y 的值。

*操作符作为右值时,意义是取指针的值;作为左值时,也就是放在赋值操作符的左边时,表示 a 指向的变量。其实归纳起来,*操作符的根本意义就是操作指针指向的变量。当操作在右值时,就是取指向变量的值;当操作在左值时,就是将值设置给指向的变量。
如果在 swap() 函数中交换操作的是指针值,会发生什么情况?可以参考下面代码:

package main
 
import "fmt"
 
func swap(a, b *int) {
b, a = a, b
}
 
func main() {
x, y := 1, 2
swap(&x, &y)
fmt.Println(x, y)
}

运行结果:
1 2

结果表明,交换是不成功的。上面代码中的 swap() 函数交换的是 a 和 b 的地址,在交换完毕后,a 和 b 的变量值确实被交换。但和 a、b 关联的两个变量并没有实际关联。这就像写有两座房子的卡片放在桌上一字摊开,交换两座房子的卡片后并不会对两座房子有任何影响。

示例:使用指针变量获取命令行的输入信息

Go 语言的 flag 包中,定义的指令以指针类型返回。通过学习 flag 包,可以深入了解指针变量在设计上的方便之处。
下面的代码通过提前定义一些命令行指令和对应变量,在运行时,输入对应参数的命令行参数后,经过 flag 包的解析后即可通过定义的变量获取命令行的数据。
获取命令行输入:

package main
 
// 导入系统包
import (
"flag"
"fmt"
)
 
// 定义命令行参数
var mode = flag.String("mode", "", "process mode")
 
func main() {
 
// 解析命令行参数
flag.Parse()
 
// 输出命令行参数
fmt.Println(*mode)
}

将这段代码命名为main.go,然后使用如下命令行运行:

$ go run flagparse.go --mode=fast

命令行输出结果如下:
fast

代码说明如下:

  • 第 10 行,通过 flag.String,定义一个 mode 变量,这个变量的类型是 *string。后面 3 个参数分别如下:
    • 参数名称:在给应用输入参数时,使用这个名称。
    • 参数值的默认值:与 flag 所使用的函数创建变量类型对应,String 对应字符串、Int 对应整型、Bool 对应布尔型等。
    • 参数说明:使用 -help 时,会出现在说明中。
  • 第 15 行,解析命令行参数,并将结果写入创建的指令变量中,这个例子中就是 mode 变量。
  • 第 18 行,打印 mode 指针所指向的变量。

由于之前使用 flag.String 已经注册了一个 mode 的命令行参数,flag 底层知道怎么解析命令行,并且将值赋给 mode*string 指针。在 Parse 调用完毕后,无须从 flag 获取值,而是通过自己注册的 mode 这个指针,获取到最终的值。代码运行流程如下图所示。


图:命令行参数与变量的关系

创建指针的另一种方法——new() 函数

Go 语言还提供了另外一种方法来创建指针变量,格式如下:

new(类型)

一般这样写

str := new(string)
*str = "ninja"
 
fmt.Println(*str)

new() 函数可以创建一个对应类型的指针,创建过程会分配内存。被创建的指针指向的值为默认值。

Go语言变量生命期

讨论变量生命期之前,先来了解下计算机组成里两个非常重要的概念:堆和栈。

什么是栈

栈(Stack)是一种拥有特殊规则的线性表数据结构。

1) 概念

栈只允许往线性表的一端放入数据,之后在这一端取出数据,按照后进先出(LIFO,Last InFirst Out)的顺序,如下图所示。


图:栈的操作及扩展

往栈中放入元素的过程叫做入栈。入栈会增加栈的元素数量,最后放入的元素总是位于栈的顶部,最先放入的元素总是位于栈的底部。
从栈中取出元素时,只能从栈顶部取出。取出元素后,栈的数量会变少。最先放入的元素总是最后被取出,最后放入的元素总是最先被取出。不允许从栈底获取数据,也不允许对栈成员(除栈顶外的成员)进行任何查看和修改操作。
栈的原理类似于将书籍一本一本地堆起来。书按顺序一本一本从顶部放入,要取书时只能从顶部一本一本取出。

2) 变量和栈有什么关系

栈可用于内存分配,栈的分配和回收速度非常快。下面代码展示栈在内存分配上的作用,代码如下:

func calc(a, b int) int {
var c int
c = a * b
var x int
x = c * 10
return x
}

代码说明如下:

  • 第 1 行,传入 a、b 两个整型参数。
  • 第 2 行,声明 c 整型变量,运行时,c 会分配一段内存用以存储 c 的数值。
  • 第 3 行,将 a 和 b 相乘后赋予 c。
  • 第 5 行,声明 x 整型变量,x 也会被分配一段内存。
  • 第 6 行,让 c 乘以 10 后存储到 x 变量中。
  • 第 8 行,返回 x 的值。

上面的代码在没有任何优化情况下,会进行 c 和 x 变量的分配过程。Go 语言默认情况下会将 c 和 x 分配在栈上,这两个变量在 calc() 函数退出时就不再使用,函数结束时,保存 c 和 x 的栈内存再出栈释放内存,整个分配内存的过程通过栈的分配和回收都会非常迅速。

什么是堆

堆在内存分配中类似于往一个房间里摆放各种家具,家具的尺寸有大有小。分配内存时,需要找一块足够装下家具的空间再摆放家具。经过反复摆放和腾空家具后,房间里的空间会变得乱七八糟,此时再往空间里摆放家具会存在虽然有足够的空间,但各空间分布在不同的区域,无法有一段连续的空间来摆放家具的问题。此时,内存分配器就需要对这些空间进行调整优化,如下图所示。


图:堆的分配及空间


堆分配内存和栈分配内存相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。

变量逃逸(Escape Analysis)——自动决定变量分配方式,提高运行效率

堆和栈各有优缺点,该怎么在编程中处理这个问题呢?在 C/C++ 语言中,需要开发者自己学习如何进行内存分配,选用怎样的内存分配方式来适应不同的算法需求。比如,函数局部变量尽量使用栈;全局变量、结构体成员使用堆分配等。程序员不得不花费很多年的时间在不同的项目中学习、记忆这些概念并加以实践和使用
Go 语言将这个过程整合到编译器中,命名为“变量逃逸分析”。这个技术由编译器分析代码的特征和代码生命期,决定应该如何堆还是栈进行内存分配,即使程序员使用 Go 语言完成了整个工程后也不会感受到这个过程。

1) 逃逸分析

使用下面的代码来展现 Go 语言如何通过命令行分析变量逃逸,代码如下

package main
import "fmt"
// 本函数测试入口参数和返回值情况
func dummy(b int) int {
// 声明一个c赋值进入参数并返回
var c int
c = b
return c
}
// 空函数, 什么也不做
func void() {
}
func main() {
// 声明a变量并打印
var a int
// 调用void()函数
void()
// 打印a变量的值和dummy()函数返回
fmt.Println(a, dummy(0))
}

代码说明如下:

  • 第 6 行,dummy() 函数拥有一个参数,返回一个整型值,测试函数参数和返回值分析情况。
  • 第 9 行,声明 c 变量,这里演示函数临时变量通过函数返回值返回后的情况。
  • 第 16 行,这是一个空函数,测试没有任何参数函数的分析情况。
  • 第 23 行,在 main() 中声明 a 变量,测试 main() 中变量的分析情况。
  • 第 26 行,调用 void() 函数,没有返回值,测试 void() 调用后的分析情况。
  • 第 29 行,打印 a 和 dummy(0) 的返回值,测试函数返回值没有变量接收时的分析情况。

接着使用如下命令行运行上面的代码:

$ go run -gcflags "-m -l" main.go

使用 go run 运行程序时,-gcflags 参数是编译参数。其中 -m 表示进行内存分配分析,-l 表示避免程序内联,也就是避免进行程序优化。

运行结果如下:

# command-line-arguments
./main.go:29:13: a escapes to heap
./main.go:29:22: dummy(0) escapes to heap
./main.go:29:13: main ... argument does not escape
0 0

程序运行结果分析如下:

  • 输出第 2 行告知“main 的第 29 行的变量 a 逃逸到堆”。
  • 第 3 行告知“dummy(0)调用逃逸到堆”。由于 dummy() 函数会返回一个整型值,这个值被 fmt.Println 使用后还是会在其声明后继续在 main() 函数中存在。
  • 第 4 行,这句提示是默认的,可以忽略。

上面例子中变量 c 是整型,其值通过 dummy() 的返回值“逃出”了 dummy() 函数。c 变量值被复制并作为 dummy() 函数返回值返回,即使 c 变量在 dummy() 函数中分配的内存被释放,也不会影响 main() 中使用 dummy() 返回的值。c 变量使用栈分配不会影响结果。

2) 取地址发生逃逸

下面的例子使用结构体做数据,了解在堆上分配的情况,代码如下:

package main
import "fmt"
// 声明空结构体测试结构体逃逸情况
type Data struct {
}
func dummy() *Data {
// 实例化c为Data类型
var c Data
//返回函数局部变量地址
return &c
}
func main() {
fmt.Println(dummy())
}

代码说明如下:

  • 第 6 行,声明一个空的结构体做结构体逃逸分析
  • 第 9 行,将 dummy() 函数的返回值修改为 *Data 指针类型
  • 第 12 行,将 c 变量声明为 Data 类型,此时 c 的结构体为值类型
  • 第 15 行,取函数局部变量 c 的地址并返回。Go 语言的特性允许这样做
  • 第 20 行,打印 dummy() 函数的返回值

执行逃逸分析:
$ go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:15:9: &c escapes to heap
./main.go:12:6: moved to heap: c
./main.go:20:19: dummy() escapes to heap
./main.go:20:13: main ... argument does not escape
&{}
注意第 4 行出现了新的提示:将 c 移到堆中。这句话表示,Go 编译器已经确认如果将 c 变量分配在栈上是无法保证程序最终结果的。如果坚持这样做,dummy() 的返回值将是 Data 结构的一个不可预知的内存地址。这种情况一般是 C/C++ 语言中容易犯错的地方:引用了一个函数局部变量的地址。
Go 语言最终选择将 c 的 Data 结构分配在堆上。然后由垃圾回收器去回收 c 的内存。

3) 原则

在使用 Go 语言进行编程时,Go 语言的设计者不希望开发者将精力放在内存应该分配在栈还是堆上的问题。编译器会自动帮助开发者完成这个纠结的选择。但变量逃逸分析也是需要了解的一个编译器技术,这个技术不仅用于 Go 语言,在 Java 等语言的编译器优化上也使用了类似的技术。
编译器觉得变量应该分配在堆和栈上的原则是:

    • 变量是否被取地址。
    • 变量是否发生逃逸。

Go语言常量

相对于变量,常量是恒定不变的值,例如圆周率。
可以在编译时,对常量表达式进行计算求值,并在运行期使用该计算结果,计算结果无法被修改。
常量表示起来非常简单,如下面的代码:

const pi = 3.141592
const e = 2.718281

常量的声明和变量声明非常类似,只是把 var 换成了 const。

多个变量可以一起声明,类似的,常量也是可以多个一起声明的,如下面的代码:

const (
pi = 3.141592
e = 2.718281
)

常量因为在编译期确定,所以可以用于数组声明,如下面的代码:

const size = 4
var arr [size]int

Go语言模拟枚举

Go 语言中现阶段没有枚举,可以使用 const 常量配合 iota 模拟枚举,请看下面的代码:
type Weapon int
const (
Arrow Weapon = iota // 开始生成枚举值, 默认为0
Shuriken
SniperRifle
Rifle
Blower
)
// 输出所有枚举值
fmt.Println(Arrow, Shuriken, SniperRifle, Rifle, Blower)
// 使用枚举类型并赋初值
var weapon Weapon = Blower
fmt.Println(weapon)
代码输出如下:
1 2 3 4
4
代码说明如下:
第 1 行中将 int 定义为 Weapon 类型,就像枚举类型其实本质是一个 int 一样。当然,某些情况下,如果需要 int32 和 int64 的枚举,也是可以的。
第4行中,将 Array 常量的类型标识为 Weapon,这样标识后,const 下方的常量可以是默认类型的,默认时,默认使用前面指定的类型作为常量类型。该行使用 iota 进行常量值自动生成。iota 起始值为 0,一般情况下也是建议枚举从 0 开始,让每个枚举类型都有一个空值,方便业务和逻辑的灵活使用。
一个 const 声明内的每一行常量声明,将会自动套用前面的 iota 格式,并自动增加。这种模式有点类似于电子表格中的单元格自动填充。只需要建立好单元格之间的变化关系,拖动右下方的小点就可以自动生成单元格的值。
当然,iota 不仅只生成每次增加 1 的枚举值。我们还可以利用 iota 来做一些强大的枚举常量值生成器。下面的代码可以方便生成标志位常量:
const (
FlagNone = 1 << iota
FlagRed
FlagGreen
FlagBlue
)
fmt.Printf("%d %d %d\n", FlagRed, FlagGreen, FlagBlue)
fmt.Printf("%b %b %b\n", FlagRed, FlagGreen, FlagBlue)
代码输出如下:
2 4 8
10 100 1000
在代码中编写一些标志位时,我们往往手动编写常量值,如果常量值特别多时,很容易重复或者写错。因此,使用 ioto 自动生成较为方便。
代码说明如下:
  • 第 2 行中 iota 使用了一个移位操作,每次将上一次的值左移一位,以做出每一位的常量值。
  • 第 8 行,将 3 个枚举按照常量输出,分别输出 2、4、8,都是将 1 每次左移一位的结果。
  • 第 9 行,将枚举值按二进制格式输出,可以清晰地看到每一位的变化。

将枚举值转换为字符串

枚举在 C# 语言中是一个独立的类型,可以通过枚举值获取值对应的字符串。例如,C# 中 Week 枚举值 Monday 为 1,那么可以通过 Week.Monday.ToString() 函数获得 Monday 字符串。
Go 语言中也可以实现这一功能,见下面的例子。
转换字符串
package main
import "fmt"
// 声明芯片类型
type ChipType int
const (
None ChipType = iota
CPU // 中央处理器
GPU // 图形处理器
)
func (c ChipType) String() string {
switch c {
case None:
return "None"
case CPU:
return "CPU"
case GPU:
return "GPU"
}
return "N/A"
}
func main() {
// 输出CPU的值并以整型格式显示
fmt.Printf("%s %d", CPU, CPU)
}
运行结果:
CPU 1
代码说明如下:
  • 第 6 行,将 int 声明为 ChipType 芯片类型。
  • 第 9 行,将 const 里定义的一句常量值设为 ChipType 类型,且从 0 开始,每行值加 1。
  • 第 14 行,定义 ChipType 类型的方法 String(),返回字符串。
  • 第 15~22 行,使用 switch 语句判断当前的 ChitType 类型的值,返回对应的字符串。
  • 第 30 行,输出 CPU 的值并按整型格式输出。
使用 String() 方法的 ChipType 在使用上和普通的常量没有区别。当这个类型需要显示为字符串时,Go 语言会自动寻找 String() 方法并进行调用。
注意:本节内容涉及 Go 语言新版本的功能。内容上会涉及后续章节讲解的类型定义及结构体嵌入等特性。另外,本节内容适用于对 Go 语言很熟悉且正在关注工程升级、代码重构等问题的读者阅读。

Go语言类型别名

类型别名是 Go 1.9 版本添加的新功能。主要用于代码升级、迁移中类型的兼容性问题。在 C/C++ 语言中,代码重构升级可以使用宏快速定义新的一段代码。Go 语言中没有选择加入宏,而是将解决重构中最麻烦的类型名变更问题。

在 Go 1.9 版本之前的内建类型定义的代码是这样写的:
type byte uint8
type rune int32
而在 Go 1.9 版本之后变为:
type byte = uint8
type rune = int32
这个修改就是配合类型别名而进行的修改。

区分类型别名与类型定义

类型别名的写法为:

type TypeAlias = Type

类型别名规定:TypeAlias 只是 Type 的别名,本质上 TypeAlias 与 Type 是同一个类型。就像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字都指的是他本人。

类型别名与类型定义表面上看只有一个等号的差异,那么它们之间实际的区别有哪些呢?下面通过一段代码来理解
package main
import (
"fmt"
)
// 将NewInt定义为int类型
type NewInt int
// 将int取一个别名叫IntAlias
type IntAlias = int
func main() {
// 将a声明为NewInt类型
var a NewInt
// 查看a的类型名
fmt.Printf("a type: %T\n", a)
// 将a2声明为IntAlias类型
var a2 IntAlias
// 查看a2的类型名
fmt.Printf("a2 type: %T\n", a2)
}
代码运行结果:
a type: main.NewInt
a2 type: int
代码说明如下:
  • 第 8 行,将 NewInt 定义为 int 类型,这是常见定义类型的方法,通过 type 关键字的定义,NewInt 会形成一种新的类型。NewInt 本身依然具备int的特性。
  • 第 11 行,将 IntAlias 设置为 int 的一个别名,使用 IntAlias 与 int 等效。
  • 第 16 行,将 a 声明为 NewInt 类型,此时若打印,则 a 的值为 0。
  • 第 18 行,使用%T格式化参数,显示 a 变量本身的类型。
  • 第 21 行,将 a2 声明为 IntAlias 类型,此时打印 a2 的值为 0。
  • 第 23 行,显示 a2 变量的类型。
结果显示a的类型是 main.NewInt,表示 main 包下定义的 NewInt 类型。a2 类型是 int。IntAlias 类型只会在代码中存在,编译完成时,不会有 IntAlias 类型。

非本地类型不能定义方法

能够随意地为各种类型起名字,是否意味着可以在自己包里为这些类型任意添加方法?参见下面的代码演示:
package main
import (
"time"
)
// 定义time.Duration的别名为MyDuration
type MyDuration = time.Duration
// 为MyDuration添加一个函数
func (m MyDuration) EasySet(a string) {
}
func main() {
}
代码说明如下:
  • 第 8 行,使用类型别名为 time.Duration 设定一个别名叫 MyDuration。
  • 第 11 行,为这个别名添加一个方法。
编译上面代码报错,信息如下:

cannot define new methods on non-local type time.Duration

编译器提示:不能在一个非本地的类型 time.Duration 上定义新方法。非本地方法指的就是使用 time.Duration 的代码所在的包,也就是 main 包。因为 time.Duration 是在 time 包中定义的,在 main 包中使用。time.Duration 包与 main 包不在同一个包中,因此不能为不在一个包中的类型定义方法。

解决这个问题有下面两种方法:
  • 将第 8 行修改为 type MyDuration time.Duration,也就是将 MyDuration 从别名改为类型。
  • 将 MyDuration 的别名定义放在 time 包中。

在结构体成员嵌入时使用别名

当类型别名作为结构体嵌入的成员时会发生什么情况?请参考下面的代码
package main
import (
"fmt"
"reflect"
)
// 定义商标结构
type Brand struct {
}
// 为商标结构添加Show()方法
func (t Brand) Show() {
}
// 为Brand定义一个别名FakeBrand
type FakeBrand = Brand
// 定义车辆结构
type Vehicle struct {
// 嵌入两个结构
FakeBrand
Brand
}
func main() {
// 声明变量a为车辆类型
var a Vehicle
// 指定调用FakeBrand的Show
a.FakeBrand.Show()
// 取a的类型反射对象
ta := reflect.TypeOf(a)
// 遍历a的所有成员
for i := 0; i < ta.NumField(); i++ {
// a的成员信息
f := ta.Field(i)
// 打印成员的字段名和类型
fmt.Printf("FieldName: %v, FieldType: %v\n", f.Name, f.Type.
Name())
}
}
代码输出如下:
FieldName: FakeBrand, FieldType: Brand
FieldName: Brand, FieldType: Brand

代码说明如下:
  • 第 9 行,定义商标结构。
  • 第 13 行,为商标结构添加 Show() 方法。
  • 第 17 行,为 Brand 定义一个别名 FakeBrand。
  • 第 20~25 行,定义车辆结构 Vehicle,嵌入 FakeBrand 和 Brand 结构。
  • 第 30 行,将 Vechicle 实例化为 a。
  • 第 33 行,显式调用 Vehicle 中 FakeBrand 的 Show() 方法。
  • 第 36 行,使用反射取变量 a 的反射类型对象,以查看其成员类型。
  • 第 39~42 行,遍历 a 的结构体成员。
  • 第 45 行,打印 Vehicle 类型所有成员的信息。

这个例子中,FakeBrand 是 Brand 的一个别名。在 Vehicle 中嵌入 FakeBrand 和 Brand 并不意味着嵌入两个 Brand。FakeBrand 的类型会以名字的方式保留在 Vehicle 的成员中。

如果尝试将第 33 行改为:

a.Show()

编译器将发生报错:

ambiguous selector a.Show

在调用 Show() 方法时,因为两个类型都有 Show() 方法,会发生歧义,证明 FakeBrand 的本质确实是 Brand 类型。
posted @ 2019-01-14 21:09  丶无根生  阅读(310)  评论(0编辑  收藏  举报