『GoLang』语法基础
标识符
- 字母或下划线开头
- 之后只能出现数字、字母、下划线
- 大小写敏感
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 |
此外,还有大约30多个预定义的名字,比如
int
和true
等,主要对应内建的常量、类型和函数
- 内建常量:
true false iota nil
- 内建类型:
int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64 uintptr
float32 float64 complex128 complex64
bool byte rune string error
- 内建函数:
make len cap new append copy close delete
complex real imag
panic recover
这些内部预先定义的名字并不是关键字,你可以再定义中重新使用它们。在一些特殊的场景中重新定义它们也是有意义的,但是也要注意避免过度而引起语义混乱。
标识符一般使用驼峰命名法:
- 变量名:
xxxYyyZzz
- 常量名:
XXX_YYY_ZZZ
- 函数名:
XxxYyyZzz
- 方法名:
XxxYyyZzz
- 包名:一般是小写字母组成
【注】有些有特殊含义的缩写单词要字母全大写,如
ID
,HTTP
等
基本类型
整型
类型 | 符号 | 长度范围 |
---|---|---|
uint8 | 无符号 | 8位整型 (0 到 255) |
uint16 | 无符号 | 16位整型 (0 到 65535) |
uint32 | 无符号 | 32位整型 (0 到 4294967295) |
uint64 | 无符号 | 64位整型 (0 到 18446744073709551615) |
int8 | 有符号 | 8位整型 (-128 到 127) |
int16 | 有符号 | 16位整型 (-32768 到 32767) |
int32 | 有符号 | 32位整型 (-2147483648 到 2147483647) |
int64 | 有符号 | 64位整型 (-9223372036854775808 到 9223372036854775807) |
Unicode
字符rune
类型是和int32
等价的类型(别名),通常用于表示一个Unicode码点。这两个名称可以互换使用。同样byte
也是uint8
类型的等价类型(别名),byte
类型一般用于强调数值是一个原始的数据而不是一个小的整数。
有符号整数采用补码表示
Go 也有基于架构的类型,例如:int
、uint
和 uintptr
,这些类型的长度都是根据运行程序所在的操作系统类型所决定的。
不管它们的具体大小,
int
、uint
和uintptr
是不同类型的兄弟类型。其中int
和int32
也是不同的类型,即使int
的大小也是32 bit,在需要将int
当作int32
类型的地方需要一个显式的类型转换操作,反之亦然。
浮点型
主要是为了表示小数,也可细分为float32
和float64
两种。浮点数能够表示的范围可以从很小到很巨大,这个极限值范围可以在math
包中获取,math.MaxFloat32
表示float32
的最大值,大约是3.4e38
,math.MaxFloat64
大约是1.8e308
,两个类型最小的非负值大约是1.4e-45
和4.9e-324
。
float32
大约可以提供6位有效数字的精度,作为对比,float64
可以提供16位有效数字的精度。通常情况应该优先选择float64
,因为float32
的精确度较低,在累积计算时误差扩散很快,而且float32
能精确表达的最小正整数并不大,浮点数和整数的底层解释方式完全不同。
不要对浮点型进行
==
判断,因为本身就是近似存储
小数点前面或后面的零都可以被省略(例如.707
或1.
)。很小或很大的数最好用科学计数法书写,通过e
或E
来指定指数部分:
const Avogadro = 6.02214129e23 // 阿伏伽德罗常数
const Planck = 6.62606957E-34 // 普朗克常数
如果一个函数返回的浮点数结果可能失败,最好的做法是用单独的标志报告失败,像这样:
func compute() (value float64, ok bool) {
// ...
if failed {
return 0, false
}
return result, true
}
复数
Go语言提供了两种精度的复数类型:complex64
和complex128
,分别对应float32
和float64
两种浮点数精度。内置的complex
函数用于构建复数,内建的real
和imag
函数分别返回复数的实部和虚部:
var x complex128 = complex(1, 2) // 1+2i
var y complex128 = complex(3, 4) // 3+4i
fmt.Println(x*y) // (-5+10i)
fmt.Println(real(x*y)) // -5
fmt.Println(imag(x*y)) // 10
如果一个浮点数面值或一个十进制整数面值后面跟着一个i
,例如3.141592i
或2i
,它将构成一个复数的虚部,复数的实部是0
:
fmt.Println(1i * 1i) // (-1+0i), i^2 = -1
在常量算术规则下,一个复数常量可以加到另一个普通数值常量(整数或浮点数、实部或虚部),我们可以用自然的方式书写复数,就像1+2i
或与之等价的写法2i+1
。上面x
和y
的声明语句还可以简化:
x := 1 + 2i
y := 3 + 4i
复数也可以用==
和!=
进行相等比较。只有两个复数的实部和虚部都相等的时候它们才是相等的。
浮点数的相等比较是危险的,需要特别小心处理精度问题。想想Java的BigInteger!
布尔型
一个布尔类型的值只有两种:true
和false
。if
和for
语句的条件部分都是布尔类型的值,并且==
和<
等比较操作也会产生布尔型的值。一元操作符!
对应逻辑非操作,因此!true
的值为false
。
布尔值并不会隐式转换为数字值0或1,反之亦然。必须使用一个显式的if
语句辅助转换:
i := 0
if b {
i = 1
}
如果需要经常做类似的转换, 包装成一个函数会更方便:
// btoi returns 1 if b is true and 0 if false.
func btoi(b bool) int {
if b {
return 1
}
return 0
}
数字到布尔型的逆转换则非常简单, 不过为了保持对称, 我们也可以包装一个函数:
// itob reports whether i is non-zero.
func itob(i int) bool { return i != 0 }
字符串
字符串是 UTF-8 字符的一个序列(当字符为 ASCII 码时则占用 1 个字节,其它字符根据需要占用 2-4 个字节)。UTF-8 是被广泛使用的编码格式,是文本文件的标准编码,其它包括 XML 和 JSON 在内,也都使用该编码。由于该编码对占用字节长度的不定性,Go 中的字符串里面的字符也可能根据需要占用 1 至 4 个字节,这与其它语言如 C++、Java 或者 Python 不同(Java 始终使用 2 个字节)。Go 这样做的好处是不仅减少了内存和硬盘空间占用,同时也不用像其它语言那样需要对使用 UTF-8 字符集的文本进行编码和解码。
字符串是一种值类型,且值不可变,即创建某个文本后你无法再次修改这个文本的内容;更深入地讲,字符串是字节的定长数组。
Go 支持以下 2 种形式的字面值:
-
解释字符串:
该类字符串使用双引号括起来,其中的相关的转义字符将被替换,这些转义字符包括:
\n
:换行符\r
:回车符\t
:tab 键\u
或\U
:Unicode 字符\\
:反斜杠自身
-
非解释字符串:
该类字符串使用反引号括起来,支持换行,并且禁止转义,例如:
`This is a raw string \n` 中的 `\n\` 会被原样输出。
和 C/C++不一样,Go 中的字符串是根据长度限定,而非特殊字符
\0
。
内置的len
函数可以返回一个字符串中的字节数目(不是rune
字符数目),索引操作s[i]
返回第i
个字节的字节值,i
必须满足0 ≤ i< len(s)
条件约束。
s := "hello, world"
fmt.Println(len(s)) // "12"
fmt.Println(s[0], s[7]) // "104 119" ('h' and 'w')
如果试图访问超出字符串索引范围的字节将会导致panic
异常:
s := "hello, world"
c := s[len(s)] // panic: index out of range
第
i
个字节并不一定是字符串的第i
个字符,因为对于非ASCII
字符的UTF8
编码会要两个或多个字节。
+
操作符可以连接2个不同的字符串:
s := "hello, world"
fmt.Println("goodbye" + s[5:]) // "goodbye, world"
只能连接字符串,不能连接字符串和其他类型,比如字符串和数字,是不能编译的
另外,如果拼接字符串涉及到换行,
+
必须放在一行的结尾,不然Go编译器自动会添加分号报错
字符串可以用==
和<
进行比较;比较通过逐个字节比较完成的,因此比较的结果是字符串自然编码的顺序。
字符串的值是不可变的:一个字符串包含的字节序列永远不会被改变,当然我们也可以给一个字符串变量分配一个新字符串值。可以像下面这样将一个字符串追加到另一个字符串:
s := "left foot"
t := s
s += ", right foot"
这并不会导致原始的字符串值被改变,但是变量s
将因为+=
语句持有一个新的字符串值,但是t
依然是包含原先的字符串值。
fmt.Println(s) // "left foot, right foot"
fmt.Println(t) // "left foot"
因为字符串是不可修改的,因此尝试修改字符串内部数据的操作也是被禁止的:
s[0] = 'L' // compile error: cannot assign to s[0]
不变性意味如果两个字符串共享相同的底层数据的话也是安全的,这使得复制任何长度的字符串代价是低廉的。同样,一个字符串s
和对应的子字符串切片s[7:]
的操作也可以安全地共享相同的内存,因此字符串切片操作代价也是低廉的。在这两种情况下都没有必要分配新的内存。
获取字符串中某个字节的地址的行为是非法的,例如:
&str[i]
。
指针
一个指针变量可以指向任何一个值的内存地址。指针变量在 32 位计算机上占用 4B 内存,在 64 位计算机占用 8B内存,并且与它所指向的值的大小无关,因为指针变量只是地址的值而已。可以声明指针指向任何类型的值来表明它的原始性或结构性,也可以在指针类型前面加上*
号(前缀)来获取指针所指向的内容。
在Go语言中,指针类型表示指向给定类型(称为指针的基础类型)的变量的所有指针的集合。 符号 *
可以放在一个类型前,如 *T
,那么它将以类型T
为基础,生成指针类型*T
。未初始化指针的值为nil
。例如:
type Point3D struct{ x, y, z float64 }
var pointer *Point3D
var i *[4]int
上面定义了两个指针类型变量。它们的值为nil,这时对它们的反向引用是不合法的,并且会使程序崩溃。
package main
func main() {
var p *int = nil
*p = 0
}
// in Windows: stops only with: <exit code="-1073741819" msg="process crashed"/>
// runtime error: invalid memory address or nil pointer dereference
虽然Go 语言和 C、C++ 这些语言一样,都有指针的概念,但是指针运算在语法上是不允许的。这样做的目的是保证内存安全。从这一点看,Go 语言的指针基本就是一种引用。
指针的一个高级应用是可以传递一个变量的引用(如函数的参数),这样不会传递变量的副本。当调用函数时,如果参数为基础类型,传进去的是值,也就是另外复制了一份参数到当前的函数调用栈。参数为引用类型时,传进去的基本都是引用。而指针传递的成本很低,只占用 4B或 8B内存。
如果代码在运行中需要占用大量的内存,或很多变量,或者两者都有,这时使用指针会减少内存占用和提高运行效率。被指向的变量保存在内存中,直到没有任何指针指向它们。所以从它们被创建开始就具有相互独立的生命周期。
内存管理中的内存区域一般包括堆内存(heap)和栈内存(stack), 栈内存主要用来存储当前调用栈用到的简单类型数据,如string
,bool
,int
,float
等。这些类型基本上较少占用内存,容易回收,因此可以直接复制,进行垃圾回收时也比较容易做针对性的优化。 而复杂的复合类型占用的内存往往相对较大,存储在堆内存中,垃圾回收频率相对较低,代价也较大,因此传引用或指针可以避免进行成本较高的复制操作,并且节省内存,提高程序运行效率。
指针的使用方法:
- 定义指针变量;
- 为指针变量赋值;
- 访问指针变量中指向地址的值;
- 在指针类型前面加上
*
号来获取指针所指向的内容。
package main
import "fmt"
func main() {
var a, b int = 20, 30 // 声明实际变量
var ptra *int // 声明指针变量
var ptrb *int = &b
ptra = &a // 指针变量的存储地址
fmt.Printf("a 变量的地址是: %x\n", &a) // a 变量的地址是: c00000a0c8
fmt.Printf("b 变量的地址是: %x\n", &b) // b 变量的地址是: c00000a0e0
// 指针变量的存储地址
fmt.Printf("ptra 变量的存储地址: %x\n", ptra) // ptra 变量的存储地址: c00000a0c8
fmt.Printf("ptrb 变量的存储地址: %x\n", ptrb) // ptrb 变量的存储地址: c00000a0e0
// 使用指针访问值
fmt.Printf("*ptra 变量的值: %d\n", *ptra) // *ptra 变量的值: 20
fmt.Printf("*ptrb 变量的值: %d\n", *ptrb) // *ptrb 变量的值: 30
}
type关键字
一个类型声明语句创建了一个新的类型名称,和现有类型具有相同的底层结构。新命名的类型提供了一个方法,用来分隔不同概念的类型,这样即使它们底层类型相同也是不兼容的。
type 新类型 底层类型 // 自定义类型,本质2个类型
type 别名 = 已有类型 // 类型别名,本质一个类型
类型声明语句一般出现在包一级,因此如果新创建的类型名字的首字符大写,则在外部包也可以使用。
为了说明类型声明,我们将不同温度单位分别定义为不同的类型:
// Package tempconv performs Celsius and Fahrenheit temperature computations.
package tempconv
import "fmt"
type Celsius float64 // 摄氏温度
type Fahrenheit float64 // 华氏温度
const (
AbsoluteZeroC Celsius = -273.15 // 绝对零度
FreezingC Celsius = 0 // 结冰点温度
BoilingC Celsius = 100 // 沸水温度
)
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }
我们在这个包声明了两种类型:Celsius和Fahrenheit分别对应不同的温度单位。它们虽然有着相同的底层类型float64,但是它们是不同的数据类型,因此它们不可以被相互比较或混在一个表达式运算。
刻意区分类型,可以避免一些像无意中使用不同单位的温度混合计算导致的错误;因此需要一个类似
Celsius(t)
或Fahrenheit(t)
形式的显式转型操作才能将float64转为对应的类型。
Celsius(t)
和Fahrenheit(t)
是类型转换操作,它们并不是函数调用。类型转换不会改变值本身,但是会使它们的语义发生变化。另一方面,CToF
和FToC
两个函数则是对不同温度单位下的温度进行换算,它们会返回不同的值。
对于每一个类型T,都有一个对应的类型转换操作T(x)
,用于将x
转为T
类型(如果T
是指针类型,可能会需要用小括弧包装T
,比如 (*int)(0)
)。只有当两个类型的底层基础类型相同时,才允许这种转型操作,或者是两者都是指向相同底层结构的指针类型,这些转换只改变类型而不会影响值本身。如果x
是可以赋值给T
类型的值,那么x
必然也可以被转为T
类型,但是一般没有这个必要。
在任何情况下,运行时不会发生转换失败的错误( 错误只会发生在编译阶段)
底层数据类型决定了内部结构和表达方式,也决定是否可以像底层类型一样对内置运算符的支持。这意味着,Celsius
和Fahrenheit
类型的算术运算行为和底层的float64
类型是一样的,正如我们所期望的那样。
fmt.Printf("%g\n", BoilingC-FreezingC) // "100" °C
boilingF := CToF(BoilingC)
fmt.Printf("%g\n", boilingF-CToF(FreezingC)) // "180" °F
fmt.Printf("%g\n", boilingF-FreezingC) // compile error: type mismatch
变量声明
基本用法
var
声明语句可以创建一个特定类型的变量,然后给变量附加一个名字,并且设置变量的初始值。变量声明的一般语法如下:
var 变量名字 类型 = 表达式
其中 “类型” 或 “= 表达式” 两个部分可以省略其中的一个
如果省略的是类型信息,那么将根据初始化表达式来推导变量的类型信息。如果初始化表达式被省略,那么将用零值初始化该变量。
数值类型变量对应的零值是
0
,布尔类型变量对应的零值是false
,字符串类型对应的零值是空字符串
,接口或引用类型(包括slice
、map
、chan
和函数
)变量对应的零值是nil
。数组或结构体等聚合类型对应的零值是每个元素或字段都是对应该类型的零值。
也可以在一个声明语句中同时声明一组变量,或用一组初始化表达式声明并初始化一组变量。如果省略每个变量的类型,将可以声明多个类型不同的变量(类型由初始化表达式推导):
var i, j, k int // int, int, int
var b, f, s = true, 2.3, "four" // bool, float64, string
初始化表达式可以是字面量或任意的表达式。在包级别声明的变量会在main入口函数执行前完成初始化,局部变量将在声明语句被执行到的时候完成初始化。
一组变量也可以通过调用一个函数,由函数返回的多个返回值初始化:
var f, err = os.Open(name) // os.Open returns a file and an error
类似Python返回的元组
还可以这样声明变量:
var (
a int
b bool
str string
)
这种因式分解关键字的写法一般用于声明全局变量。
简短变量声明
在函数内部,有一种称为简短变量声明语句的形式可用于声明和初始化局部变量。它的形式为:
名字:= 表达式
,变量的类型根据表达式来自动推导。
anim := gif.GIF{LoopCount: nframes}
freq := rand.Float64() * 3.0
t := 0.0
因为简洁和灵活的特点,简短变量声明被广泛用于大部分的局部变量的声明和初始化。var形式的声明语句往往是用于需要显式指定变量类型地方,或者因为变量稍后会被重新赋值而初值无关紧要的地方。
i := 100 // an int
var boiling float64 = 100 // a float64
var names []string
var err error
var p Point
和var
形式声明语句一样,简短变量声明语句也可以用来声明和初始化一组变量:
i, j := 0, 1
这种同时声明多个变量的方式应该限制只在可以提高代码可读性的地方使用,比如
for
语句的循环的初始化语句部分
请记住:=
是一个变量声明语句,而=
是一个变量赋值操作
和普通var
形式的变量声明语句一样,简短变量声明语句也可以用函数的返回值来声明和初始化变量,像下面的os.Open
函数调用将返回两个值:
f, err := os.Open(name)
if err != nil {
return err
}
// ...use f...
f.Close()
这里有一个比较微妙的地方:简短变量声明左边的变量可能并不是全部都是刚刚声明的。如果有一些已经在
相同的词法域
声明过了,那么简短变量声明语句对这些已经声明过的变量就只有赋值行为了。在下面的代码中,第一个语句声明了in和err两个变量。在第二个语句只声明了out一个变量,
然后对已经声明的err进行了赋值操作。in, err := os.Open(infile) // ... out, err := os.Create(outfile)
简短变量声明语句中必须至少要声明一个新的变量,下面的代码将不能编译通过:
f, err := os.Open(infile) // ... f, err := os.Create(outfile) // compile error: no new variables
解决的方法是第二个简短变量声明语句改用普通的多重赋值语言(就是换成
=
号)简短变量声明语句只有对已经在同级词法域声明过的变量才和赋值操作语句等价,如果变量是在外部词法域声明的,那么简短变量声明语句将会在当前词法域重新声明一个新的变量。这里我的理解是,命名空间不同的位置可以出现同名标识符。
声明指针
一个变量对应一个保存了变量对应类型值的内存空间。普通变量在声明语句创建时被绑定到一个变量名,比如叫x
的变量,但是还有很多变量始终以表达式方式引入,例如x[i]
或x.f
变量。所有这些表达式一般都是读取一个变量的值,除非它们是出现在赋值语句的左边,这种时候是给对应变量赋予一个新的值。
一个指针的值是另一个变量的地址。一个指针对应变量在内存中的存储位置。并不是每一个值都会有一个内存地址,但是对于每一个变量必然有对应的内存地址。通过指针,我们可以直接读或更新对应变量的值,而不需要知道该变量的名字(如果变量有名字的话)。
如果用var x int
声明语句声明一个x
变量,那么&x
表达式(取x
变量的内存地址)将产生一个指向该整数变量的指针,指针对应的数据类型是 *int
,指针被称之为“指向int
类型的指针”。如果指针名字为p
,那么可以说“p
指针指向变量x
”,或者说“p
指针保存了x
变量的内存地址”。同时 *p
表达式对应p
指针指向的变量的值。一般*p
表达式读取指针指向的变量的值,这里为int
类型的值,同时因为*p
对应一个变量,所以该表达式也可以出现在赋值语句的左边,表示更新指针所指向的变量的值。
x := 1
p := &x // p, of type *int, points to x
fmt.Println(*p) // "1"
*p = 2 // equivalent to x = 2
fmt.Println(x) // "2"
对于聚合类型每个成员,比如结构体的每个字段、或者是数组的每个元素,也都是对应一个变量,因此可以被取地址。
任何类型的指针的零值都是nil
。如果 p != nil
测试为真,那么p
是指向某个有效变量。指针之间也是可以进行相等测试的,只有当它们指向同一个变量或全部是nil
时才相等。
var x, y int
fmt.Println(&x == &x, &x == &y, &x == nil) // "true false false"
var a, b *int
fmt.Println(a,b) //<nil> <nil>
这里注意,
var a, b *int
声明后a
和b
都是指针,这个不同于C/C++语言
在Go语言中,返回函数中局部变量的地址也是安全的。例如下面的代码,调用f
函数时创建局部变量v
,在局部变量地址被返回之后依然有效,因为指针p
依然引用这个变量。
var p = f()
func f() *int {
v := 1
return &v
}
每次调用f
函数都将返回不同的结果:
fmt.Println(f() == f()) // "false"
因为指针包含了一个变量的地址,因此如果将指针作为参数调用函数,那将可以在函数中通过该指针来更新变量的值。例如下面这个例子就是通过指针来更新变量的值,然后返回更新后的值,可用在一个表达式中:
func incr(p *int) int {
*p++ // 非常重要:只是增加p指向的变量的值,并不改变p指针!!!
return *p
}
v := 1
incr(&v) // side effect: v is now 2
fmt.Println(incr(&v)) // "3" (and v is 3)
每次我们对一个变量取地址,或者复制指针,我们都是为原变量创建了新的别名。例如, *p
就是是 变量v
的别名。指针特别有价值的地方在于我们可以不用名字而访问一个变量,但是这是一把双剑:要找到一个变量的所有访问者并不容易,我们必须知道变量全部的别名(这是Go语言的垃圾回收器所做的工作)。不仅仅是指针会创建别名,很多其他引用类型也会创建别名,例如slice
、map
和chan
,甚至结构体
、数组
和接口
都会创建所引用变量的别名。
所谓别名,是因为这些都是指向相同内存地址的标识符。
new函数声明
另一个创建变量的方法是调用用内建的new
函数。表达式new(T)
将创建一个T
类型的匿名变量,初始化为T
类型的零值,然后返回变量地址,返回的指针类型为 *T
。
p := new(int) // p, *int 类型, 指向匿名的 int 变量
fmt.Println(*p) // "0"
*p = 2 // 设置 int 匿名变量的值为 2
fmt.Println(*p) // "2"
用
new
创建变量和普通变量声明语句方式创建变量没有什么区别,除了不需要声明一个临时变量的名字外,我们还可以在表达式中使用new(T)
。换言之,new
函数类似是一种语法糖,而不是一个新的基础概念。
每次调用new
函数都是返回一个新的变量的地址,因此下面两个地址是不同的:
p := new(int)
q := new(int)
fmt.Println(p == q) // "false"
当然也有特殊情况:如果两个类型都是空的,也就是说类型的大小是\(0\),例如
struct{}
和[0]int
, 有可能有相同的地址。这种要谨慎使用,因为Go语言的垃圾回收机制会有不同的行为。
new
函数使用相对比较少,因为对结构体来说,可以直接用字面量语法创建新变量的方法会更灵活。
由于new
只是一个预定义的函数,它并不是一个关键字,因此我们可以将new
名字重新定义为别的类型。
变量的生命周期
变量的生命周期指的是在程序运行期间变量有效存在的时间间隔。
-
对于在包一级声明的变量来说,它们的生命周期和整个程序的运行周期是一致的。
-
而相比之下,局部变量的生命周期则是动态的:从每次创建一个新变量的声明语句开始,直到该变量不再被引用为止,然后变量的存储空间可能被回收。函数的参数变量和返回值变量都是局部变量。它们在函数每次被调用的时候创建。
函数的右小括弧也可以另起一行缩进,同时为了防止编译器在行尾自动插入分号而导致的编译错误,可以在末尾的参数变量后面显式插入逗号:
for t := 0.0; t < cycles*2*math.Pi; t += res { x := math.Sin(t) y := math.Sin(t*freq + phase) img.SetColorIndex( size+int(x*size+0.5), size+int(y*size+0.5), blackIndex, // 最后插入的逗号不会导致编译错误,这是Go编译器的一个特性 ) // 小括弧另起一行缩进,和大括弧的风格保存一致 }
上面代码在每次循环的开始会创建临时变量
t
,然后在每次循环迭代中创建临时变量x
和y
。
Go语言的自动圾收集器是如何知道一个变量是何时可以被回收的呢?基本的实现思路是,从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或引用的访问路径遍历,是否可以找到该变量。如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响程序后续的计算结果。
因为一个变量的有效周期只取决于是否可达,因此一个循环迭代内部的局部变量的生命周期可能超出其局部作用域。同时,局部变量可能在函数返回之后依然存在。
编译器会自动选择在栈上还是在堆上分配局部变量的存储空间,但可能令人惊讶的是,这个选择并不是由用var
还是new
声明变量的方式决定的。
var global *int
func f() {
var x int
x = 1
global = &x
}
func g() {
y := new(int)
*y = 1
}
f
函数里的x
变量必须在堆上分配,因为它在函数退出后依然可以通过包一级的global
变量找到,虽然它是在函数内部定义的;用Go语言的术语说,这个x
局部变量从函数f
中逃逸了。相反,当
g
函数返回时,变量*y
将是不可达的,也就是说可以马上被回收的。因此,*y
并没有从函数g
中逃逸,编译器可以选择在栈上分配*y
的存储空间(也可以选择在堆上分配,然后由Go语言的GC回收这个变量的内存空间),虽然这里用的是new
方式。
其实在任何时候,你并不需为了编写正确的代码而要考虑变量的逃逸行为,要记住的是,逃逸的变量需要额外分配内存,同时对性能的优化可能会产生细微的影响。
Go语言的自动垃圾收集器对编写正确的代码是一个巨大的帮助,但也并不是说你完全不用考虑内存了。你虽然不需要显式地分配和释放内存,但是要编写高效的程序你依然需要了解变量的生命周期。例如,如果将指向短生命周期对象的指针保存到具有长生命周期的对象中,特别是保存到全局变量时,会阻止对短生命周期对象的垃圾回收(从而可能影响程序的性能)。
常量
常量使用关键字 const
定义,用于存储不会改变的数据。
存储在常量中的数据类型只可以是布尔型、数字型(整数型、浮点型和复数)和字符串型。
常量的定义格式:const identifier [type] = value
,例如:
const Pi = 3.14159
在 Go 语言中,你可以省略类型说明符 [type]
,因为编译器可以根据变量的值来推断其类型。
- 显式类型定义:
const b string = "abc"
- 隐式类型定义:
const b = "abc"
常量的值必须是能够在编译时就能够确定的;你可以在其赋值表达式中涉及计算过程,但是所有用于计算的值必须在编译期间就能获得。
- 正确的做法:
const c1 = 2/3
- 错误的做法:
const c2 = getNumber()
// 引发构建错误:getNumber() used as value
因为在编译期间自定义函数均属于未知,因此无法用于常量的赋值,但内置函数可以使用,如:
len()
。
数字型的常量是没有大小和符号的,并且可以使用任何精度而不会导致溢出:
const Ln2 = 0.693147180559945309417232121458\
176568075500134360255254120680009
const Log2E = 1/Ln2 // this is a precise reciprocal
const Billion = 1e9 // float constant
const hardEight = (1 << 100) >> 97
根据上面的例子我们可以看到,反斜杠
\
可以在常量表达式中作为多行的连接符使用。
一个常量的声明也可以包含一个类型和一个值,但是如果没有显式指明类型,那么将从右边的表达式推断类型。
如果是批量声明的常量,除了第一个外其它的常量右边的初始化表达式都可以省略,如果省略初始化表达式则表示使用前面常量的初始化表达式写法,对应的常量类型也一样的。例如:
const (
a = 1
b
c = 2
d
)
fmt.Println(a, b, c, d) // "1 1 2 2"
如果只是简单地复制右边的常量表达式,其实并没有太实用的价值。但是它可以带来其它的特性,那就是iota
常量生成器语法。
iota常量生成器
iota
是特殊的常量,可以被编译器自动修改的常量,每当定义一个const
,iota
的初始值为0
,每当定义一个常量,就会自动累加1
。在下一个const
里再初次定义iota
时会清零,看下面代码示例就懂了。
const (
a = iota // 0
b = 4 // iota=1
c = iota // 2
d = iota + 2 //iota=3, 3 + 5 = 5
e = 34 // iota=4
f = 25 // iota=5
g = iota // 6
)
const (
h = 5 // iota=0
i = iota // 1
j = 6 // iota=2
k = iota // 3
)
注意,即使,
const
中第一个定义的常量没用iota
,iota
也会在计数。简单地讲,每遇到一次
const
关键字,iota
就重置为0
。
赋值
和其他语言一样,值放在等号右边,接收的变量放在等号左边
x = 1 // 命名变量的赋值
*p = true // 通过指针间接赋值
person.name = "bob" // 结构体字段赋值
count[x] = count[x] * scale // 数组、slice或map的元素赋值
也有二元运算符,如:
count[x] *= scale
特别地,Go要提一下一元运算符。数值变量也可以支持++
递增和--
递减语句(自增和自减是语句,而不是表达式,因此 x = i++
之类的表达式是错误的):
v := 1
v++ // 等价方式 v = v + 1;v 变成 2
v-- // 等价方式 v = v - 1;v 变成 1
元组赋值
元组赋值是另一种形式的赋值语句,它允许同时更新多个变量的值。在赋值之前,赋值语句右边的所有表达式将会先进行求值,然后再统一更新左边对应变量的值。这对于处理有些同时出现在元组赋值语句左右两边的变量很有帮助,例如我们可以这样交换两个变量的值:
x, y = y, x
a[i], a[j] = a[j], a[i]
或者是计算两个整数值的的最大公约数:
func gcd(x, y int) int {
for y != 0 {
x, y = y, x%y
}
return x
}
或者是计算斐波纳契数列(Fibonacci)的第N个数:
func fib(n int) int {
x, y := 0, 1
for i := 0; i < n; i++ {
x, y = y, x + y
}
return x
}
元组赋值也可以使一系列琐碎赋值更加紧凑,特别是在for循环的初始化部分:
i, j, k = 2, 3, 5
但如果表达式太复杂的话,应该尽量避免过度使用元组赋值;因为每个变量单独赋值语句的写法可读性会更好。有些表达式会产生多个值,比如调用一个有多个返回值的函数。当这样一个函数调用出现在元组赋值右边的表达式中时(右边不能再有其它表达式),左边变量的数目必须和右边一致。
f, err = os.Open("foo.txt") // function call returns two values
和变量声明一样,我们可以用下划线空白标识符 _
来丢弃不需要的值:
_, err = io.Copy(dst, src) // 丢弃字节数
_, ok = x.(T) // 只检测类型,忽略具体值
可赋值性
赋值语句是显式的赋值形式,但是程序中还有很多地方会发生隐式的赋值行为:函数调用会隐式地将调用参数的值赋值给函数的参数变量,一个返回语句将隐式地将返回操作的值赋值给结果变量,一个复合类型的字面量也会产生赋值行为。
不管是隐式还是显式地赋值,在赋值语句左边的变量和右边最终的求到的值必须有相同的数据类型。更直白地说,只有右边的值对于左边的变量是可赋值的,赋值语句才是允许的。
nil
可以赋值给任何指针或引用类型的变量
对于两个值是否可以用 ==
或 !=
进行相等比较的能力也和可赋值能力有关系:对于任何类型的值的相等比较,第二个值必须是对第一个值类型对应的变量是可赋值的,反之依然。
值类型和引用类型
所有像 int
、float
、bool
和 string
这些基本类型都属于值类型,使用这些类型的变量直接指向存在内存中的值。另外,像数组
和struct
这些复合类型也是值类型。
当使用等号 =
将一个变量的值赋值给另一个变量时,如:j = i
,实际上是在内存中将 i
的值进行了拷贝。
package main
import (
"fmt"
)
func main() {
i := 7
j := i
fmt.Println(i, &i) // 7 0xc00000a0c8
fmt.Println(j, &j) // 7 0xc00000a0e0
}
在 Go 语言中,指针
属于引用类型,其它的引用类型还包括 slices
,maps
和 channel
。被引用的变量会存储在堆中,以便进行垃圾回收,且比栈拥有更大的内存空间。
一个引用类型的变量 r1
存储的是 r1
的值所在的内存地址(数字),或内存地址中第一个字所在的位置:
同一个引用类型的指针指向的多个字可以是在连续的内存地址中(内存布局是连续的),这也是计算效率最高的一种存储形式;也可以将这些字分散存放在内存中,每个字都指示了下一个字所在的内存地址。
当使用赋值语句 r2 = r1
时,只有引用(地址)被复制。
如果 r1
的值被改变了,那么这个值的所有引用都会指向被修改后的内容,在这个例子中,r2
也会受到影响。
运算符
算数运算符
运算符 | 描述 | 实例 |
---|---|---|
+ | 相加 | A + B 输出结果 30 |
- | 相减 | A - B 输出结果 -10 |
* | 相乘 | A * B 输出结果 200 |
/ | 相除 | B / A 输出结果 2 |
% | 求余 | B % A 输出结果 0 |
++ | 自增 | A++ 输出结果 11 |
-- | 自减 | A-- 输出结果 9 |
【注】
/
两边都是整数则为整除,有一边是浮点数就是正常除法%
只针对整数运算++
/--
不是表达式,不能赋值,它只是一行可执行的代码
关系运算符
运算符 | 描述 | 实例 |
---|---|---|
== | 检查两个值是否相等,如果相等返回 True 否则返回 False。 | (A == B) 为 False |
!= | 检查两个值是否不相等,如果不相等返回 True 否则返回 False。 | (A != B) 为 True |
> | 检查左边值是否大于右边值,如果是返回 True 否则返回 False。 | (A > B) 为 False |
< | 检查左边值是否小于右边值,如果是返回 True 否则返回 False。 | (A < B) 为 True |
>= | 检查左边值是否大于等于右边值,如果是返回 True 否则返回 False。 | (A >= B) 为 False |
<= | 检查左边值是否小于等于右边值,如果是返回 True 否则返回 False。 | (A <= B) 为 True |
逻辑运算符
运算符 | 描述 | 实例 |
---|---|---|
&& | 逻辑 AND 运算符。 如果两边的操作数都是 True,则条件 True,否则为 False。 | (A && B) 为 False |
|| | 逻辑 OR 运算符。 如果两边的操作数有一个 True,则条件 True,否则为 False。 | (A || B) 为 True |
! | 逻辑 NOT 运算符。 如果条件为 True,则逻辑 NOT 条件 False,否则为 True。 | !(A && B) 为 True |
注意,Go语言中逻辑运算是短路运算。
位运算符
p | q | p & q | p | q | p ^ q |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
0 | 1 | 0 | 1 | 1 |
1 | 1 | 1 | 1 | 0 |
1 | 0 | 0 | 1 | 1 |
^
可以是单目运算符,^p
表示按位取反Go语言中,负数是以补码形式存在的
特别地,有一种位清空运算符
&^
,作用如下:
1 &^ 1 = 0
1 &^ 0 = 1
0 &^ 1 = 0
0 &^ 0 = 0
特点是:如果右侧是0,则左侧保持不变;如果右侧是1,则左侧清零。功能和
a&(^b)
相同。例如:
5 &^ 3 = 4
转成二进制看即101 &^ 011 = 100
复合赋值运算符
如+=
、-=
等等
指针和地址运算符
运算符 | 描述 | 实例 |
---|---|---|
& | 返回变量存储地址 | &a; 将给出变量的实际地址 |
* | 取出指针指向的值 | *a; 返回指针指向的值 |
func main() {
a := 1
b := &a
fmt.Println(*b) // 1
fmt.Println(&b) // 0xc0000ca018
}
作用域
一个声明语句将程序中的实体和一个名字关联,比如一个函数或一个变量。声明语句的作用域是指源代码中可以有效使用这个名字的范围。
不要将作用域和生命周期混为一谈。声明语句的作用域对应的是一个源代码的文本区域;它是一个编译时的属性。一个变量的生命周期是指程序运行时变量存在的有效时间段,在此时间区域内它可以被程序的其他部分引用;是一个运行时的概念。
语法块是由花括弧所包含的一系列语句,就像函数体或循环体花括弧对应的语法块那样。语法块内部声明的名字是无法被外部语法块访问的。语法决定了内部声明的名字的作用域范围。
声明语句对应的词法域决定了作用域范围的大小。对于内置的类型、函数和常量,比如int
、len
和true
等是在全局作用域的,因此可以在整个程序中直接使用。任何在函数外部(也就是包级语法域)声明的名字可以在同一个包的任何源文件中访问的。对于导入的包,例如tempconv
导入的fmt
包,则是对应源文件级的作用域,因此只能在当前的文件中访问导入的fmt
包,当前包的其它源文件无法访问在当前源文件导入的包。还有许多声明语句,比如tempconv.CToF
函数中的变量c
,则是局部作用域的,它只能在函数内部(甚至只能是局部的某些部分)访问。
控制流标号,就是break
、continue
或goto
语句后面跟着的那种标号,则是函数级的作用域。
一个程序可能包含多个同名的声明,只要它们在不同的词法域就没有关系。例如,你可以声明一个局部变量,和包级的变量同名。或者你可以将一个函数参数的名字声明为new
,虽然内置的new是全局作用域的。但是物极必反,如果滥用不同词法域可重名的特性的话,可能导致程序很难阅读。
内部声明屏蔽了外部同名的声明,让外部的声明的名字无法被访问
并不是所有的词法域都显式地对应到由花括弧包含的语句;还有一些隐含的规则。for
语句创建了两个词法域:花括号包含的是显式的部分是for
的循环体部分词法域,另外一个隐式的部分则是循环的初始化部分,比如用于迭代变量i
的初始化。隐式的词法域部分的作用域还包含条件测试部分和循环后的迭代部分( i++
),当然也包含循环体词法域。
下面的例子同样有三个不同的x
变量,每个声明在不同的词法域,一个在函数体词法域,一个在for
隐式的初始化词法域,一个在for
循环体词法域;只有两个块是显式创建的:
func main() {
x := "hello"
for _, x := range x {
x := x + 'A' - 'a'
fmt.Printf("%c", x) // "HELLO" (one letter per iteration)
}
}
特别的,下面这种代码的bug检测器可能失效
var cwd string
func init() {
cwd, err := os.Getwd() // NOTE: wrong!
if err != nil {
log.Fatalf("os.Getwd failed: %v", err)
}
log.Printf("Working directory = %s", cwd)
}
init()
函数中的cwd
是简短声明了一个新变量,和外面的包级变量cwd
不是一个东西,偏偏函数内部还使用了内部的cwd
,这种同名的变量会造成误解,其实外面的cwd
声明后并未使用。
注释
- 单行注释:
//...
- 多行注释:
/* ... */
包注释
- 每个包都应该有一个包注释,一个位于package子句之前行注释
- 包注释应该包含下面基本信息
// @Title 文件名称
// @Description 文件描述
// @Author 作者名称 (时间 格式是2019/3/26 19:53)
// @Update 修改者名称 (时间 格式是2019/3/26 19:53)
结构(接口)注释
每个自定义的结构体或者接口都应该有注释说明,该注释对结构进行简要介绍,放在结构体定义的前一行,格式为: 结构体名, 结构体说明
。同时结构体内的每个成员变量都要有说明,该说明放在成员变量的后面(注意对齐),实例如下:
// User , 用户对象,定义了用户的基础信息
type User struct{
Username string // 用户名
Email string // 邮箱
}
函数(方法)注释
- 每个函数,或者方法(结构体或者接口下的函数称为方法)都应该有注释说明
- 函数的注释应该包括三个方面
- 注释在函数(方法)定义语句上面
// @title 函数名称
// @description 函数的详细描述
// @auth 作者 时间(2019/6/18 10:57 )
// @param 输入参数名 参数类型 "解释"
// @return 返回参数名 参数类型 "解释"
代码逻辑注释
对于一些关键位置的代码逻辑,或者局部较为复杂的逻辑,需要有相应的逻辑说明,方便其他开发者阅读该段代码,实例如下:
// 从 Redis 中批量读取属性,对于没有读取到的 id , 记录到一个数组里面,准备从 DB 中读取
xxxxx
xxxxxxx
xxxxxxx
注释风格
统一使用中文注释,对于中英文字符之间严格使用空格分隔, 这个不仅仅是中文和英文之间,英文和中文标点之间也都要使用空格分隔,例如:
// 从 Redis 中批量读取属性,对于没有读取到的 id , 记录到一个数组里面,准备从 DB 中读取
个性签名:我与春风皆过客,你携秋水揽星河
---------------------------------------------------------如果觉得这篇文章对你有小小的帮助的话,记得在右下角点个“推荐”哦!
转载请保留原文链接及作者。