go变量
1. 变量定义
在程序的运行过程中其值能够改变的量,就是变量,变量存于内存之中。
2. 变量类型
2.1 布尔型
布尔型的值只可以是常量 true 或者 false。
var b bool = true
2.2 数字类型
整型 int 和浮点型 float32、float64,Go 语言支持整型和浮点型数字,并且原生支持复数,其中位的运算采用补码。
2.2.1 整型
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)
2.2.2 浮点型
float32 IEEE-754 32位浮点型数
float64 IEEE-754 64位浮点型数
complex64 32 位实数和虚数
complex128 64 位实数和虚数
2.2.3 其他数字类型 byte 类似 uint8
rune 类似 int32
uint 32 或 64 位
int 与 uint 一样大小
uintptr 无符号整型,用于存放一个指针
2.3 字符串类型
字符串就是一串固定长度的字符连接起来的字符序列。Go的字符串是由单个字节连接起来的。Go语言的字符串的字节使用UTF-8编码标识Unicode文本。
var str string = "Hello GO!"
2.4 派生类型
2.4.1 指针类型(Pointer)
指针可以指向任何类型,通过*+type声明
var A *int
2.4.2 数组类型
数组是一个固定长度的类型的集合,长度是数组的len,类型是数组的element type。index从0开始,数组可以是多维的。
var A [5]int
2.4.3 结构化类型(struct)
一个struct由field组成,每一个field都由一个名字和类型组成。 field可以是具名和不具名的,必须是唯一的 。
type S struct{
x,y int
u float32
A *[]int
f func()
}
不具名成员,也叫作嵌套成员,结构体将拥有嵌套成员的方法集合,具体规则如下:
如果S包含匿名成员T,则S和*S都包含T的方法集合,S还包含T的方法集合
- 如果S包含匿名成员T,则S和S都包含T和*T方法集合
type A struct {
T1
*T2
}
不具名成员的类型名将作为成员的名字,所以如下声明是错误的,这样会造成成员的命名冲突:
struct{
T1
*T1
}
结构体的成员还可以包含一个可选的tag
struct {
T1 “任何字符串”
}
tag是结构体成员的属性,可以通过reflect查看。
2.4.4 Channel 类型
channel提供了一个用于goroutine进行同行的机制,类似于linux的pipe,但是并发安全的。
var a chan int //双向channel
var b chan<- int //只可以send的channel
var c <-chan int //只可以receive的channel
channel可以是有缓冲或者没有缓冲的,通过make创建
var a = make(chan int) //创建一个没有缓冲的channel
var b = make(chan int, 100) //创建一个有100个缓冲的channel
2.4.5 函数类型
一个函数类型表所有的拥有同样的参数类型和返回值的类型的函数。
函数的参数列表中,参数名字必须要么全部都有,要么全部都没有,不能只有部分参数没有名字。
返回值列表同参数列表,当返回值列表拥有名字的时候,这些名字和参数名一样可以在函数体中使用。
参数支持可变参数列表,使用…T表示,可变参数必须在参数列表的最后面。
函数可以有多个返回值。
func(a int, b int32) (ret bool)
2.4.6 切片类型
slice用于描述一个underlying array并且提供访问该数组元素的功能。
未初始化的slice是nil。
因为数组是值类型,当进行函数参数传递的时候传递的是数组的拷贝,一是浪费空间;二是无法在函数内部修改array,所以可以使用具有同样element type的slice来进行参数传递,因为slice指向的是数组,所以通过slice则可以修改array。
slice通过make创建,make([]T, length, capacity) ,其中第三个参数可以省略。
var numbers3 = [5]int{1, 2, 3, 4, 5}
var slice1 = numbers3[1:4]
2.4.7 接口类型(interface)
一个接口类型指出了一个接口的方法集合,任何实现该方法集合的类型都实现了这个接口,并且可以赋值给该接口类型的变量。
type ReadWriter interface {
Read(b Buffer) bool
Write(b Buffer) bool
Close()
}
2.4.8 Map 类型
map是一个未排序的k-v集合
map[key type]element type
其中key type必须可以进行==和!=操作,因此key type不能使function、map或者slice。如果key是一个借口类型,则接口指向的实际类型必须支持==和!=
var a map[string]int
var b map[*T]struct{x, y float64}
var c map[string]interface{}
make(map[key]element, capacity)可以创建一个map
3. 变量声明和赋值
3.1 var + 变量名 + 变量类型
var a int a = 10
3.2 var + 变量名
var a = 10
3.3 变量名:=
a := 10
注意:此方式只能被用在函数体内,而不可以用于全局变量的声明与赋值。
go常量
1. 常量定义
在程序运行过程中,其值无法改变的量,称为常量,存放于内存的数据区。
2. 变量类型
2.1 字面常量
所谓字面常量(literal),是指程序中硬编码的常量。
-12 // 整型的常量
3.1415926 // 浮点类型的常量
3.2+12i // 复数类型的常量
true // 布尔类型的常量
"foo" // 字符串常量
Go语言的字面常量更接近我们自然语言中的常量概念,它是无类型的。只要这个常量在相应类型的值域范围内,就可以作为该类型的常量。
2.2 常量定义
通过const关键字+常量名+类型进行定义。
const PI float64 = 3.1415926
const zero = 0.0 // 无类型浮点常量
const (
size int64 = 1024
eof = -1 // 无类型整型常量
)
const u, v float32 = 0, 3 // u = 0.0, v = 3.0,常量的多重赋值
const a, b, c = 3, 4, "foo" // a = 3, b = 4, c = "foo", 无类型整型和字符串常量
Go的常量定义可以限定常量类型,但不是必需的。如果定义常量时没有指定类型,那么它与字面常量一样,是无类型常量。常量定义的右值也可以是一个在编译期运算的常量表达式,比如:
const mask = 1 << 3
由于常量的赋值是一个编译期行为,所以右值不能出现任何需要运行期才能得出结果的表达式。
2.3 预定义常量
Go语言预定义了这些常量: true、 false和iota。
iota比较特殊,可以被认为是一个可被编译器修改的常量,在每一个const关键字出现时被重置为0,然后在下一个const出现之前,每出现一次iota,其所代表的数字会自动增1。
从以下的例子可以基本理解iota的用法:
const ( // iota被重设为0
c0 = iota // c0 == 0
c1 = iota // c1 == 1
c2 = iota // c2 == 2
)
const (
a = 1 << iota // a == 1 (iota在每个const开头被重设为0)
b = 1 << iota // b == 2
c = 1 << iota // c == 4
)
const (
u = iota * 42 // u == 0
v float64 = iota * 42 // v == 42.0
w = iota * 42 // w == 84
)
const x = iota // x == 0 (因为iota又被重设为0了)
const y = iota // y == 0 (同上)
如果两个const的赋值语句的表达式是一样的,那么可以省略后一个赋值表达式。因此,上 面的前两个const语句可简写为:
const ( // iota被重设为0
c0 = iota // c0 == 0
c1 // c1 == 1
c2 // c2 == 2
)
const (
a = 1 <<iota // a == 1 (iota在每个const开头被重设为0)
b // b == 2
c // c == 4
)
2.4 枚举
枚举指一系列相关的常量。
通过上一节的例子,我们看到可以在const后跟一对圆括号的方式定义一组常量,这种定义法在Go语言中通常用于定义枚举值。
注意:Go语言并不支持众多其他语言明确支持的enum关键字。
下面是一个常规的枚举表示法,其中定义了一系列整型常量:
const (
Sunday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
numberOfDays // 这个常量没有导出
)
注:同Go语言的其他符号(symbol)一样,以大写字母开头的常量在包外可见,小写字母开头的常量在包外不可见,所以以上例子中numberOfDays为包内私有,其他符号则可被其他包访问。
go格式输出和格式输入
fmt包实现了类似C语言printf和scanf的格式化I/O,对应函数为:Printf和Scanf。
1. 格式输出
1.1 通用
- %v 值的默认格式表示。当输出结构体时,扩展标志(%+v)会添加字段名
- %#v 值的Go语法表示
- %T 值的类型的Go语法表示
- %% 百分号
示例代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
// 接口类型将输出其内部包含的值
var i interface{} = struct {
name string
age int
}{"AAA", 20}
fmt.Printf("%v\n", i) // 只输出字段值
fmt.Printf("%+v\n", i) // 同时输出字段名
fmt.Printf("%#v\n", i) // Go 语法格式
// 输出类型
fmt.Printf("%T\n", i)
}
输出结果:
{AAA 20}
{name:AAA age:20}
struct { name string; age int }{name:"AAA", age:20}
struct { name string; age int }
1.2 布尔值
- %t 单词true或false
示例代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var a bool = true
fmt.Printf("%t", a)
}
输出结果:
true
1.3 整数
- %b 表示为二进制
- %c 该值对应的unicode码值
- %d 表示为十进制
- %o 表示为八进制
- %q 该值对应的单引号括起来的go语法字符字面值,必要时会采用安全的转义表示
- %x 表示为十六进制,使用a-f
- %X 表示为十六进制,使用A-F
- %U 表示为Unicode格式:U+1234,等价于"U+%04X"
代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
// 宽度、精度、索引
fmt.Printf("|%0+- #[1]*.[2]*[3]d|%0+- #[1]*.[2]*[4]d|\n", 8, 4, 32, 64)
}
运行结果:
|+0032 |+0064 |
1.4 浮点数、复数
- %b 无小数部分、二进制指数的科学计数法,如-123456p-78;参见strconv.FormatFloat
- %e 科学计数法,如-1234.456e+78
- %E 科学计数法,如-1234.456E+78
- %f 普通小数格式,有小数部分但无指数部分,如123.456
- %F 等价于%f
- %g 根据实际情况采用%e或%f格式(以获得更简洁、准确的输出)
- %G 根据实际情况采用%E或%F格式(以获得更简洁、准确的输出)
代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
// 浮点型精度
fmt.Printf("|%f|%8.4f|%8.f|%.4f|%.f|\n", 3.2, 3.2, 3.2, 3.2, 3.2)
fmt.Printf("|%.3f|%.3g|\n", 12.345678, 12.345678)
fmt.Printf("|%.2f|\n", 12.345678+12.345678i)
}
运行结果:
|3.200000| 3.2000| 3|3.2000|3|
|12.346|12.3|
|(12.35+12.35i)|
1.5 字符串和[]byte
- %s 直接输出字符串或者[]byte %q 该值对应的双引号括起来的go语法字符串字面值,必要时会采用安全的转义表示
- %x 每个字节用两字符十六进制数表示(使用a-f)
- %X 每个字节用两字符十六进制数表示(使用A-F)
代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
// 字符串精度
s := "你好世界!"
fmt.Printf("|%s|%8.2s|%8.s|%.2s|%.s|\n", s, s, s, s, s)
fmt.Printf("|%x|%8.2x|%8.x|%.2x|%.x|\n", s, s, s, s, s)
// 带引号字符串
s1 := "Hello 世界!"
s2 := "Hello\n世界!"
fmt.Printf("%q\n", s1) // 双引号
fmt.Printf("%#q\n", s1) // 反引号成功
fmt.Printf("%#q\n", s2) // 反引号失败
fmt.Printf("%+q\n", s2) // 仅包含 ASCII 字符
// Unicode
fmt.Printf("%U, %#U\n", '好', '好')
fmt.Printf("%U, %#U\n", '\n', '\n')
}
运行结果:
|你好世界!| 你好| |你好||
|e4bda0e5a5bde4b896e7958cefbc81| e4bd| |e4bd||
"Hello 世界!"
`Hello 世界!`
"Hello\n世界!"
"Hello\n\u4e16\u754c!"
U+597D, U+597D '好'
U+000A, U+000A
1.6 指针
- %p 表示为十六进制,并加上前导的0x
代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var a int = 10
fmt.Printf("%p\n", &a)
var b *int
fmt.Printf("%p\n", b)
}
运行结果:
0xc04204c080
0x0
2. 格式输入
2.1 格式
- %e %E %f %F %g %G 效果相同,用于读取浮点数或复数类型
- %s %v 用在字符串时会读取空白分隔的一个片段
2.2 示例
代码:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
a, b, c := "", 0, 0
fmt.Scanf("%s%c%d", &a, &b, &c)
fmt.Println(a, b, c)
}
结果:
abc 1
abc 32 1
go运算符
1. 算术运算符
下表列出了所有Go语言的算术运算符。
假定 A 值为 10,B 值为 20。
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var a int = 10
var b int = 20
fmt.Println(a + b)
fmt.Println(a - b)
fmt.Println(a * b)
fmt.Println(a / b)
fmt.Println(a % b)
a++
fmt.Println(a)
b--
fmt.Println(b)
}
结果:
30
-10
200
0
10
11
19
2. 关系运算符
下表列出了所有Go语言的关系运算符。
假定 A 值为 10,B 值为 20。
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var A = 10
var B = 20
fmt.Println(A == B)
fmt.Println(A != B)
fmt.Println(A > B)
fmt.Println(A < B)
fmt.Println(A >= B)
fmt.Println(A <= B)
}
结果:
false
true
false
true
false
true
3. 逻辑运算符
下表列出了所有Go语言的逻辑运算符。
假定 A 值为 true,B 值为 false。
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var A = true
var B = false
fmt.Println(A && B)
fmt.Println(A || B)
fmt.Println(!(A && B))
}
结果:
false
true
true
4. 位运算符
位运算符对整数在内存中的二进制位进行操作。
假定 A 为60,B 为13。
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var A = 60
var B = 13
fmt.Printf("%b\n", A&B)
fmt.Printf("%b\n", A|B)
fmt.Printf("%b\n", A^B)
fmt.Printf("%b\n", A<<2)
fmt.Printf("%b\n", A>>2)
}
结果:
1100
111101
110001
11110000
1111
5. 赋值运算符
下表列出了所有Go语言的赋值运算符。
假定 A 为21。
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var A = 21
var C = A
fmt.Println(C)
C += A
fmt.Println(C)
C -= A
fmt.Println(C)
C *= A
fmt.Println(C)
C /= A
fmt.Println(C)
C %= A
fmt.Println(C)
C = A
C <<= 2
fmt.Println(C)
C >>= 2
fmt.Println(C)
C &= 2
fmt.Println(C)
C ^= 2
fmt.Println(C)
C |= 2
fmt.Println(C)
}
结果:
21
42
21
441
21
0
84
21
0
2
2
6. 其他运算符
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
)
func main() {
var A = 21
var C = &A
fmt.Printf("%p\n", &A)
fmt.Printf("%d\n", *C)
}
结果:
0xc04204c080
21
go流程控制
程序设计语言](https://zhida.zhihu.com/search?content_id=100005559&content_type=Article&match_order=1&q=程序设计语言&zhida_source=entity)的流程控制语句,用于设定计算执行的次序,建立程序的逻辑结构。可以说,[流程控制语句是整个程序的骨架。
从根本上讲,流程控制只是为了控制程序语句的执行顺序,一般需要与各种条件配合,因此,在各种流程中,会加入条件判断语句。
流程控制语句一般起以下3个作用:
- 选择,即根据条件跳转到不同的执行序列;
- 循环,即根据条件反复执行某个序列,当然每一次循环执行的输入输出可能会发生变化;
- 跳转,即根据条件返回到某执行序列。
Go语言支持如下的几种流程控制语句:
- 条件语句,对应的关键字为if、 else和else if;
- 选择语句,对应的关键字为switch、case和select(将在介绍channel的时候细说);
- 循环语句,对应的关键字为for和range;
- 跳转语句,对应的关键字为goto。
在具体的应用场景中,为了满足更丰富的控制需求,Go语言还添加了如下关键字: break、continue和fallthrough。在实际的使用中,需要根据具体的逻辑目标、程序执行的时间和空间限制、代码的可读性、编译器的代码优化设定等多种因素,灵活组合。
1. 条件语句
if a < 5 {
return 0
} else {
return 1
}
关于条件语句,需要注意以下几点:
- 条件语句不需要使用括号将条件包含起来();
- 无论语句体内有几条语句,花括号{}都是必须存在的;
- 左花括号{必须与if或者else处于同一行;
- 在if之后,条件语句之前,可以添加变量初始化语句,使用;间隔;
- 在有返回值的函数中,不允许将“最终的”return语句包含在if…else…结构中,否则会编译失败:function ends without a return statement。失败的原因在于,Go编译器无法找到终止该函数的return语句。
2. 选择语句
根据传入条件的不同,选择语句会执行不同的语句。 下面的例子根据传入的整型变量i的不同而打印不同的内容:
switch i {
case 0:
fmt.Printf("0")
case 1:
fmt.Printf("1")
case 2:
fallthrough
case 3:
fmt.Printf("3")
case 4, 5, 6:
fmt.Printf("4, 5, 6")
default:
fmt.Printf("Default")
}
运行上面的案例,将会得到如下结果:
- i = 0时,输出0;
- i = 1时,输出1;
- i = 2时,输出3;
- i = 3时,输出3;
- i = 4时,输出4, 5, 6;
- i = 5时,输出4, 5, 6;
- i = 6时,输出4, 5, 6;
- i = 其他任意值时,输出Default
比较有意思的是,switch后面的表达式甚至不是必需的,比如下面的例子:
switch {
case 0 <= Num && Num <= 3:
fmt.Printf("0-3")
case 4 <= Num && Num <= 6:
fmt.Printf("4-6")
case 7 <= Num && Num <= 9:
fmt.Printf("7-9")
}
在使用switch结构时,我们需要注意以下几点:
- 左花括号{必须与switch处于同一行;
- 条件表达式不限制为常量或者整数;
- 单个case中,可以出现多个结果选项;
- 与C语言等规则相反,Go语言不需要用break来明确退出一个case;
- 只有在case中明确添加fallthrough关键字,才会继续执行紧跟的下一个case;
- 可以 不设定switch之 后的条 件表达式,在此种情况 下,整个switch结构与 多个if…else…的逻辑作用等同。
3. 循环语句
与多数语言不同的是,Go语言中的循环语句只支持for关键字,而不支持while和do-while结构。关键字for的基本使用方法与C和C++中非常接近:
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
可以看到比较大的一个不同在于for后面的条件表达式不需要用圆括号()包含起来。Go语言还进一步考虑到无限循环的场景,让开发者不用写无聊的for (;;) {} 和 do {} while(1);,而直接简化为如下的写法:
sum := 0
for {
sum++
if sum > 100 {
break
}
}
在条件表达式中也支持多重赋值,如下所示:
a := []int{1, 2, 3, 4, 5, 6}
for i, j := 0, len(a) – 1; i < j; i, j = i + 1, j – 1 {
a[i], a[j] = a[j], a[i]
}
使用循环语句时,需要注意的有以下几点。
- 左花括号{必须与for处于同一行。
- Go语言中的for循环](https://zhida.zhihu.com/search?content_id=100005559&content_type=Article&match_order=1&q=for循环&zhida_source=entity)与C语言一样,都允许在[循环条件中定义和初始化变量,唯一的区别是,Go语言不支持以逗号为间隔的多个赋值语句,必须使用平行赋值的方式来初始化多个变量。
- Go语言的for循环同样支持continue和break来控制循环,但是它提供了一个更高级的break,可以选择中断哪一个循环,如下例:
for j := 0; j < 5; j++ {
for i := 0; i < 10; i++ {
if i > 5 {
break JLoop
}
fmt.Println(i)
}
}
JLoop:
// ...
4. 跳转语句
goto语句被多数语言学者所反对,谆谆告诫不要使用。但对于Go语言这样一个惜关键字如金的语言来说,居然仍然支持goto关键字,无疑让某些人跌破眼镜。但队对于有编程经验的人来说,goto还是会在一些场合下被证明是最合适的。
goto语句的语义非常简单,就是跳转到本函数内的某个标签,如:
func myfunc() {
i := 0
HERE:
fmt.Println(i)
i++
if i < 10 {
goto HERE
}
}
go函数
函数构成代码执行的逻辑结构。
1. 函数定义
在Go语言中,函数的基本组成为:关键字func、函数名、参数列表、返回值、函数体和返回语句。
package mymath
import "errors"
func Add(a int, b int) (ret int, err error) {
if a < 0 || b < 0 { // 假设这个函数只支持两个非负数字的加法
err= errors.New("Should be non-negative numbers!")
return
}
return a + b, nil // 支持多重返回值
}
如果参数列表中若干个相邻的参数类型的相同,如上面例子中的a和b,则可以在参数列表中省略前面变量的类型声明,只保留最后一个类型声明,如下所示:
func Add(a, b int)(ret int, err error) {
// to do something
}
如果返回值列表中多个返回值的类型相同,也可以用同样的方式合并。如果函数只有一个返回值,也可以这么写:
func Add(a, b int) int {
// to do something
}
2. 函数调用
函数调用非常方便,只要事先导入了该函数所在的包,就可以直接按照如下所示的方式调用函数:
import "mymath"// 假设Add被放在一个叫mymath的包中
// to do something
c := mymath.Add(1, 2)
在Go语言中,函数支持多重返回值,利用函数的多重返回值和错误处理机制,我们可以很容易地写出优雅美观的Go代码。
Go语言中函数名字的大小写不仅仅是风格,更直接体现了该函数的可见性,这一点尤其需要注意。
规则:小写字母开头的函数只在本包内可见,大写字母开头的函数才能被其他包使用。
这个规则也适用于类型和变量的可见性。
3. 不定参数
Go语言标准库中的fmt.Println()等函数的实现严重依赖于语言的不定参数功能。
3.1 不定参数类型
不定参数是指函数传入的参数个数为不定数量。为了做到这点,首先需要将函数定义为接受不定参数类型:
func myfunc(args ...int) {
for _, arg := range args {
fmt.Println(arg)
}
}
这段代码的意思是,函数myfunc()接受不定数量的参数,这些参数的类型全部是int,所以它可以用如下方式调用:
myfunc(2, 3, 4)
myfunc(1, 3, 7, 13)
形如…type格式的类型只能作为函数的参数类型存在,并且必须是最后一个参数。它是一个语法糖](https://zhida.zhihu.com/search?content_id=100016040&content_type=Article&match_order=1&q=语法糖&zhida_source=entity)(syntactic sugar),即这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说,使用语法糖能够增加程序的[可读性,从而减少程序出错的机会。
从内部实现机理上来说,类型…type本质上是一个数组切片,也就是[]type,这也是为什么上面的参数args可以用for循环来获得每个传入的参数。
假如没有…type这样的语法糖,我们将不得不这么写:
func myfunc2(args []int) {
for _, arg := range args {
fmt.Println(arg)
}
}
从函数的实现角度来看,这没有任何影响,该怎么写就怎么写。但从调用方来说,情形则完全不同:
myfunc2([]int{1, 3, 7, 13})
你会发现,我们不得不加上]int{}来构造一个[数组切片实例。但是有了…type这个语法糖,我们就不用自己来处理了。
3.2 不定参数的传递
假设有另一个变参函数叫做myfunc3(args …int),下面的例子演示了如何向其传递变参:
func myfunc(args ...int) {
// 按原样传递
myfunc3(args...)
// 传递片段,实际上任意的int slice都可以传进去
myfunc3(args[1:]...)
}
3.3 任意类型的不定参数
之前的例子中将不定参数类型约束为int,如果你希望传任意类型,可以指定类型为interface{}。
下面是Go语言标准库中fmt.Printf()的函数原型:
func Printf(format string, args ...interface{}) {
// to do something
}
用interface{}传递任意类型数据是Go语言的惯例用法。使用interface{}仍然是类型安全的,这和 C/C++ 不太一样。
3.4 多返回值
Go语言的函数或者成员的方法可以有多个返回值,这个特性能够使我们写出比其他语言更优雅、更简洁的代码,比如File.Read()函数就可以同时返回读取的字节数和错误信息。如果读取文件成功,则返回值中的n为读取的字节数, err为nil,否则err为具体的出错信息:
func (file *File) Read(b []byte) (n int, err Error)
同样,从上面的方法原型可以看到,我们还可以给返回值命名,就像函数的输入参数一样。
返回值被命名之后,它们的值在函数开始的时候被自动初始化为空。在函数中执行不带任何参数的return语句时,会返回对应的返回值变量的值。
Go语言并不需要强制命名返回值,但是命名后的返回值可以让代码更清晰,可读性更强,同时也可以用于文档。
如果调用方调用了一个具有多返回值的方法,但是却不想关心其中的某个返回值,可以简单地用一个下划线“_”来跳过这个返回值,比如下面的代码表示调用者在读文件的时候不想关心Read()函数返回的错误码:
n, _ := f.Read(buf)
4. 匿名函数与闭包
匿名函数是指不需要定义函数名的一种函数实现方式。
4.1 匿名函数
在Go里面,函数可以像普通变量一样被传递或使用,这与C语言的回调函数比较类似。不同的是, Go语言支持随时在代码里定义匿名函数。
匿名函数由一个不带函数名的函数声明和函数体组成,如下所示:
func(a, b int, z float64) bool {
return a*b <int(z)
}
匿名函数可以直接赋值给一个变量或者直接执行:
f := func(x, y int) int {
return x + y
}
func(ch chan int) {
ch <- ACK
} (reply_chan)
花括号后直接跟参数列表表示函数调用。
4.2 闭包
Go的匿名函数是一个闭包,下面我们先来了解一下闭包的概念、价值和应用场景。
4.2.1 基本概念
闭包是可以包含自由(未绑定到特定对象)变量的代码块,这些变量不在这个代码块内或者任何全局上下文中定义,而是在定义代码块的环境中定义。要执行的代码块(由于自由变量包含在代码块中,所以这些自由变量以及它们引用的对象没有被释放)为自由变量提供绑定的计算环境(作用域)。
4.2.2 闭包的价值
闭包的价值在于可以作为函数对象或者匿名函数,对于类型系统](https://zhida.zhihu.com/search?content_id=100016040&content_type=Article&match_order=1&q=类型系统&zhida_source=entity)而言,这意味着不仅要表示数据还要表示代码。支持闭包的多数语言都将函数作为第一级对象,就是说这些函数可以存储到变量中作为[参数传递给其他函数,最重要的是能够被函数动态创建和返回。
4.2.3 Go语言中的闭包
Go语言中的闭包同样也会引用到函数外的变量。闭包的实现确保只要闭包还被使用,那么被闭包引用的变量会一直存在。
package main
import (
"fmt"
)
func main() {
var j int = 5
a := func()(func()) {
var i int = 10
return func() {
fmt.Printf("i, j: %d, %d\n", i, j)
}
}()
a()
j *= 2
a()
}
上述例子的执行结果是:
i, j: 10, 5
i, j: 10, 10
在上面的例子中,变量a指向的闭包函数引用了局部变量i和j, i的值被隔离,在闭包外不能被修改,改变j的值以后,再次调用a,发现结果是修改过的值。
在变量a指向的闭包函数中,只有内部的匿名函数才能访问变量i,而无法通过其他途径访问 到,因此保证了i的安全性。
go错误处理
错误处理是学习任何编程语言都需要考虑的一个重要话题。自C++语言以来,语言层面上会增加错误处理的支持,比如异常(exception)的概念和try-catch关键字的引入。
1. error接口
Go语言引入了一个关于错误处理的标准模式,即error接口,该接口的定义如下:
type error interface {
Error() string
}
对于大多数函数,如果要返回错误,大致上都可以定义为如下模式,将error作为多种返回值中的最后一个,但这并非是强制要求:
func Foo(param int)(n int, err error) {
// to do something
}
调用时的代码建议按如下方式处理错误情况:
n, err := Foo(0)
if err != nil {
// 错误处理
} else {
// 正常流程
}
下面我用Go库中的实际代码来示范如何使用自定义的error类型。 首先,定义一个用于承载错误信息的类型。因为Go语言中接口的灵活性,你根本不需要从error接口继承或者像Java一样需要使用implements来明确指定类型和接口之间的关系,具体代码如下:
type PathError struct {
Op string
Path string
Err error
}
如果这样的话,编译器又怎能知道PathError可以当一个error来传递呢?关键在于下面的代码实现了Error()方法:
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
之后就可以直接返回PathError变量了,比如在下面的代码中,当syscall.Stat()失败返回err时,将该err包装到一个PathError对象中返回:
func Stat(name string) (fi FileInfo, err error) {
var stat syscall.Stat_t
err = syscall.Stat(name, &stat)
if err != nil {
return nil, &PathError{"stat", name, err}
}
return fileInfoFromStat(&stat, name), nil
}
如果在处理错误时获取详细信息,而不仅仅满足于打印一句错误信息,那就需要用到类型转换知识了:
fi, err := os.Stat("a.txt")
if err != nil {
if e, ok := err.(*os.PathError); ok && e.Err != nil {
// 获取PathError类型变量e中的其他信息并处理
}
}
这就是Go中error类型的使用方法。与其他语言中的异常相比, Go的处理相对比较直观、简单。
2. defer
关键字defer是Go语言引入的一个非常有意思的特性,相信很多C++程序员都写过类似下面这样的代码:
class file_closer {
FILE _f;
public:
file_closer(FILE f) : _f(f) {}
~file_closer() {
if (f)
fclose(f);
}
};
然后在需要使用的地方这么写:
void f() {
FILE f = open_file("file.txt"); // 打开一个文件句柄
file_closer _closer(f);
// 对f句柄进行操作
}
为什么需要file_closer个包装类呢?因为如果没有这个类,代码中所有退出函数的环节,比如每一个可能抛出异常的地方,每一个return的位置,都需要关掉之前打开的文件句柄。即使你头脑清晰,想明白了每一个分支和可能出错的条件,在该关闭的地方都关闭了,怎么保证你的后继者也能做到同样水平?大量莫名其妙的问题就出现了。
在C/C++中还有另一种解决方案。开发者可以将需要释放的资源变量都声明在函数的开头部分,并在函数的末尾部分统一释放资源。函数需要退出时,就必须使用goto语句跳转到指定位置先完成资源清理工作,而不能调用return语句直接返回。
这种方案是可行的,也仍然在被使用着,但存在非常大的维护性问题。而Go语言使用defer关键字简简单单地解决了这个问题,比如以下的例子:
func CopyFile(dst, src string) (w int64, err error) {
srcFile, err := os.Open(src)
if err != nil {
return
}
defer srcFile.Close()
dstFile, err := os.Create(dstName)
if err != nil {
return
}
defer dstFile.Close()
return io.Copy(dstFile, srcFile)
}
即使其中的Copy()函数抛出异常,Go仍然会保证dstFile和srcFile会被正常关闭。如果觉得一句话干不完清理的工作,也可以使用在defer后加一个匿名函数的做法:
defer func() {
// 做你复杂的清理工作
} ()
另外,一个函数中可以存在多个defer语句,因此需要注意的是, defer语句的调用是遵照先进后出的原则,即最后一个defer语句将最先被执行。只不过,当你需要为defer语句到底哪个先执行这种细节而烦恼的时候,说明你的代码架构可能需要调整一下了。
3. panic()和recover()
Go语言引入了两个内置函数panic()和recover()以报告和处理运行时错误和程序中的错误场景:
func panic(interface{})
func recover() interface{}
当在一个函数执行过程中调用panic()函数时,正常的函数执行流程将立即终止,但函数中之前使用defer关键字延迟执行的语句将正常展开执行,之后该函数将返回到调用函数,并导致逐层向上执行panic流程,直至所属的goroutine中所有正在执行的函数被终止。错误信息将被报告,包括在调用panic()函数时传入的参数,这个过程称为错误处理流程。
从panic()的参数类型interface{}我们可以得知,该函数接收任意类型的数据,比如整型、字符串、对象等。调用方法很简单,下面为几个例子:
- panic(404)
- panic(“network broken”)
- panic(Error(“file not exists”))
recover()函数用于终止错误处理流程。一般情况下,recover()应该在一个使用defer关键字的函数中执行以有效截取错误处理流程。如果没有在发生异常的goroutine中明确调用恢复过程(使用recover关键字),会导致该goroutine所属的进程打印异常信息后直接退出。
以下为一个常见的场景。
我们对于foo()函数的执行要么心里没底感觉可能会触发错误处理,或者自己在其中明确加入了按特定条件触发错误处理的语句,那么可以用如下方式在调用代码中截取recover():
defer func() {
if r := recover(); r != nil {
log.Printf("Runtime error caught: %v", r)
}
}()
foo()
无论foo()中是否触发了错误处理流程,该匿名defer函数都将在函数退出时得到执行。假如foo()中触发了错误处理流程,recover()函数执行将使得该错误处理过程终止。如果错误处理流程被触发时,程序传给panic函数的参数不为nil,则该函数还会打印详细的错误信息。
go类型系统
接口在Go语言有着至关重要的地位。如果说goroutine和channel 是支撑起Go语言的并发模型](https://zhida.zhihu.com/search?content_id=100005503&content_type=Article&match_order=1&q=并发模型&zhida_source=entity)的基石,让Go语言在如今集群化与多核化的时代成为一道极为亮丽的风景,那么接口是Go语言整个[类型系统的基石,让Go语言在基础编程哲学的探索上达到前所未有的高度。
1. 非侵入式接口
在Go语言中,一个类只需要实现了接口要求的所有函数,我们就说这个类实现了该接口,例如:
type File struct {
// ...
}
func (f *File) Read(buf []byte) (n int, err error)
func (f *File) Write(buf []byte) (n int, err error)
func (f *File) Seek(off int64, whence int) (pos int64, err error)
func (f *File) Close() error
这里我们定义了一个File类,并实现有Read()、Write()、 Seek()、 Close()等方法。设想我们有如下接口:
type IFile interface {
Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
Seek(off int64, whence int) (pos int64, err error)
Close() error
}
type IReader interface {
Read(buf []byte) (n int, err error)
}
type IWriter interface {
Write(buf []byte) (n int, err error)
}
type ICloser interface {
Close() error
}
尽管File类并没有从这些接口继承,甚至可以不知道这些接口的存在,但是File类实现了这些接口,可以进行赋值:
var file1 IFile = new(File)
var file2 IReader = new(File)
var file3 IWriter = new(File)
var file4 ICloser = new(File)
Go语言的非侵入式接口,看似只是做了很小的调整,实则影响深远。
- 其一, Go语言的标准库,再也不需要绘制类库的继承树图。在Go中,类的继承树并无意义,你只需要知道这个类实现了哪些方法,每个方法是啥含义就足够了。
- 其二,实现类的时候,只需要关心自己应该提供哪些方法,不用再纠结接口需要拆得多细才合理。接口由使用方按需定义,而不用事前规划。
- 其三,不用为了实现一个接口而导入一个包,因为多引用一个外部的包,就意味着更多的耦合。接口由使用方按自身需求来定义,使用方无需关心是否有其他模块定义过类似的接口。
2. 接口赋值
接口赋值在Go语言中分为如下两种情况:
- 将对象实例赋值给接口;
- 将一个接口赋值给另一个接口。
先讨论将某种类型的对象实例赋值给接口,这要求该对象实例实现了接口要求的所有方法,例如之前我们作过一个Integer类型,如下:
type Integer int
func (a Integer) Less(b Integer) bool {
return a < b
}
func (a *Integer) Add(b Integer) {
*a += b
}
相应地,我们定义接口LessAdder,如下:
type LessAdder interface {
Less(b Integer) bool
Add(b Integer)
}
现在有个问题:假设我们定义一个Integer类型的对象实例,怎么将其赋值给LessAdder接口呢?应该用下面的语句(1),还是语句(2)呢?
var a Integer = 1
var b LessAdder = &a ... (1)
var b LessAdder = a ... (2)
答案是应该用语句(1)。原因在于,Go语言可以根据下面的函数:
func (a Integer) Less(b Integer) bool
自动生成一个新的Less()方法:
func (a *Integer) Less(b Integer) bool {
return (*a).Less(b)
}
这样,类型*Integer就既存在Less()方法,也存在Add()方法,满足LessAdder接口。而从另一方面来说,根据
func (a *Integer) Add(b Integer)
这个函数无法自动生成以下这个成员方法:
func (a Integer) Add(b Integer) {
(&a).Add(b)
}
因为(&a).Add()改变的只是函数参数a,对外部实际要操作的对象并无影响,这不符合用户的预期。所以,Go语言不会自动为其生成该函数。因此,类型Integer只存在Less()方法,缺少Add()方法,不满足LessAdder接口,故此上面的语句(2)不能赋值。
为了进一步证明以上的推理,我们不妨再定义一个Lesser接口,如下:
type Lesser interface {
Less(b Integer) bool
}
然后定义一个Integer类型的对象实例,将其赋值给Lesser接口:
var a Integer = 1
var b1 Lesser = &a ... (1)
var b2 Lesser = a ... (2)
正如我们所料的那样,语句(1)和语句(2)均可以编译通过。
我们再来讨论另一种情形:将一个接口赋值给另一个接口。在Go语言中,只要两个接口拥有相同的方法列表(次序不同不要紧),那么它们就是等同的,可以相互赋值。
下面我们来看一个示例,这是第一个接口:
package one
type ReadWriter interface {
Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
}
第二个接口位于另一个包中:
package two
type IStream interface {
Write(buf []byte) (n int, err error)
Read(buf []byte) (n int, err error)
}
这里我们定义了两个接口,一个叫one.ReadWriter,一个叫two.Istream,两者都定义了Read()、 Write()方法,只是定义次序相反。one.ReadWriter先定义了Read()再定义了Write(),而two.IStream反之。
在Go语言中,这两个接口实际上并无区别,因为:
- 任何实现了one.ReadWriter接口的类,均实现了two.IStream;
- 任何one.ReadWriter接口对象可赋值给two.IStream,反之亦然;
- 在任何地方使用one.ReadWriter接口与使用two.IStream并无差异。
以下这些代码可编译通过:
var file1 two.IStream = new(File)
var file2 one.ReadWriter = file1
var file3 two.IStream = file2
接口赋值并不要求两个接口必须等价。如果接口A的方法列表是接口B的方法列表的子集,那么接口B可以赋值给接口A。例如,假设我们有Writer接口:
type Writer interface {
Write(buf []byte) (n int, err error)
}
就可以将上面的one.ReadWriter和two.IStream接口的实例赋值给Writer接口:
var file1 two.IStream = new(File)
var file4 Writer = file1
但是反过来并不成立:
var file1 Writer = new(File)
var file5 two.IStream = file1 // 编译不能通过
这段代码无法编译通过,原因是显然的:file1并没有Read()方法。
3. 接口查询
有办法让上面的Writer接口转换为two.IStream接口么?有。那就是我们即将讨论的接口查询语法,代码如下:
var file1 Writer = ...
if file5, ok := file1.(two.IStream); ok {
...
}
这个if语句检查file1接口指向的对象实例是否实现了two.IStream接口,如果实现了,则执行特定的代码。
接口查询是否成功,要在运行期才能够确定。它不像接口赋值,编译器只需要通过静态类型检查即可判断赋值是否可行。
在Windows下做过开发的人,通常都接触过COM,知道COM也有一个接口查询(QueryInterface)。是的,Go语言的接口查询和COM的接口查询非常类似,都可以通过对象(组件)的某个接口来查询对象实现的其他接口。不过, Go语言的接口查询优雅得多。在Go语言中,对象是否满足某个接口,通过某个接口查询其他接口,这一切都是完全自动完成的。让语言内置接口查询,这是一件非常了不起的事情。在COM中实现接口查询的过程非常繁 复,但接口查询是COM体系的根本。
COM书对接口查询的介绍,往往从类似下面这样一段问话 开始,它在Go语言中同样适用:
>你会飞吗? // IFly
>不会。
>你会游泳吗? // ISwim
>会。
>你会叫吗? // IShout
>会。
> ...
随着问题的深入,你从开始对对象(组件)一无所知(在Go语言中是interface{},在COM中是IUnknown),到逐步深入了解。
但是你最终能够完全了解对象么?
- COM说:不能,你只能无限逼近,但永远不能完全了解一个组件。
- Go语言说:你能。
在Go语言中,你可以询问接口它指向的对象是否是某个类型,比如:
var file1 Writer = ...
if file6, ok := file1.(*File); ok {
...
}
这个if语句判断file1接口指向的对象实例是否是*File类型,如果是则执行特定代码。
查询接口所指向的对象是否为某个类型的这种用法可以认为只是接口查询的一个特例。接口是对一组类型的公共特性的抽象,所以查询接口与查询具体类型的区别好比是下面这两句问话的区别:
>你是医生吗?
>是。
>你是某某某?
>是。
第一句问话查的是一个群体,是查询接口;而第二句问话已经到了具体的个体,是查询具体类型。 在C++、 Java、C#等语言中,也有类似的动态查询能力,比如查询一个对象的类型是否继承自某个类型(基类查询),或者是否实现了某个接口(接口派生查询),但是它们的动态查询与Go的动态查询很不一样。
4. 类型查询
在Go语言中,还可以更加直截了当地询问接口指向的对象实例的类型,例如:
var v1 interface{} = ...
switch v := v1.(type) {
case int: // 现在v的类型是int
case string: // 现在v的类型是string
...
}
就像现实生活中物种多得数不清一样,语言中的类型也多得数不清,所以类型查询并不经常使用。它更多是个补充,需要配合接口查询使用,例如:
type Stringer interface {
String() string
}
func Println(args ...interface{}) {
for _, arg := range args {
switch v := v1.(type) {
case int: // 现在v的类型是int
case string: // 现在v的类型是string
default:
if v, ok := arg.(Stringer); ok { // 现在v的类型是Stringer
val := v.String()
// ...
} else {
// ...
}
}
}
}
当然, Go语言标准库的Println()比这个例子要复杂很多,我们这里只摘取其中的关键部分进行分析。
对于内置类型,Println()采用穷举法,将每个类型转换为字符串进行打印。对于更一般的情况,首先确定该类型是否实现了String()方法,如果实现了,则用String()方法将其转换为字符串进行打印。
否则,Println()利用反射功能来遍历对象的所有成员变量进行打印。
5. 接口组合
像之前介绍的类型组合一样,Go语言同样支持接口组合。我们已经介绍过Go语言包中io.Reader接口和io.Writer接口,接下来我们再介绍同样来自于io包的另一个接口io.ReadWriter:
// ReadWriter接口将基本的Read和Write方法组合起来
type ReadWriter interface {
Reader
Writer
}
这个接口组合了Reader和Writer两个接口,它完全等同于如下写法:
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}
因为这两种写法的表意完全相同:ReadWriter接口既能做Reader接口的所有事情,又能做Writer接口的所有事情。在Go语言包中,还有众多类似的组合接口,比如ReadWriteCloser、ReadWriteSeeker、ReadSeeker和WriteCloser等。
可以认为接口组合是类型匿名组合的一个特定场景,只不过接口只包含方法,而不包含任何成员变量。
6. Any类型
由于Go语言中任何对象实例都满足空接口interface{},所以interface{}看起来像是可以指向任何对象的Any类型,如下:
var v1 interface{} = 1 // 将int类型赋值给interface{}
var v2 interface{} = "abc" // 将string类型赋值给interface{}
var v3 interface{} = &v2 // 将*interface{}类型赋值给interface{}
var v4 interface{} = struct{ X int }{1}
var v5 interface{} = &struct{ X int }{1}
当函数可以接受任意的对象实例时,我们会将其声明为interface{},最典型的例子是标准库fmt中PrintXXX系列的函数,例如:
func Printf(fmt string, args ...interface{})
func Println(args ...interface{})
...
总体来说, interface{}类似于COM中的IUnknown,我们刚开始对其一无所知,但可以通过接口查询和类型查询逐步了解它。
go匿名组合(伪继承)
Go语言支持面向对象思想中的继承语法,但采用组合的文法,可以实现伪继承,称为匿名组合。
1. 非指针方式组合
1.1 基本语法
// 基类
type Base struct {
// 成员变量
}
func (b *Base) 函数名(参数列表) (返回值列表) {
// 函数体
}
// 派生类
type Derived struct {
Base
// 成员变量
}
func (b *Derived) 函数名(参数列表) (返回值列表) {
// 函数体
}
1.2 规则
- 在派生类没有改写基类的成员方法时,相应的成员方法被继承。
- 派生类可以直接调用基类的成员方法,譬如基类有个成员方法为Base.Func(),那么Derived.Func()等同于Derived.Base.Func()
- 倘若派生类的成员方法名与基类的成员方法名相同,那么基类方法将被覆盖或叫隐藏,譬如基类和派生类都有成员方法Func(),那么Derived.Func()将只能调用派生类的Func()方法,如果要调用基类版本,可以通过Derived.Base.Func()来调用。
1.3 示例
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import "fmt"
type Base struct {
}
func (b *Base) Func1() {
fmt.Println("Base.Func1() was invoked!")
}
func (b *Base) Func2() {
fmt.Println("Base.Func2() was invoked!")
}
type Derived struct {
Base
}
func (d *Derived) Func2() {
fmt.Println("Derived.Func2() was invoked!")
}
func (d *Derived) Func3() {
fmt.Println("Derived.Func3() was invoked!")
}
func main() {
d := &Derived{}
d.Func1()
d.Base.Func1()
d.Func2()
d.Base.Func2()
d.Func3()
}
结果:
Base.Func1() was invoked!
Base.Func1() was invoked!
Derived.Func2() was invoked!
Base.Func2() was invoked!
Derived.Func3() was invoked!
1.4 内存布局
- golang很清晰地展示类的内存布局是怎样的,Base的位置即基类成员展开的位置。
- golang还可以随心所欲地修改内存布局,Base的位置可以出现在派生类的任何位置。
示例:
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import "fmt"
type Base struct {
BaseName string
}
func (b *Base) PrintName() {
fmt.Println(b.BaseName)
}
type Derived struct {
DerivedName string
Base
}
func (d *Derived) PrintName() {
fmt.Println(d.DerivedName)
}
func main() {
d := &Derived{}
d.BaseName = "BaseStruct"
d.DerivedName = "DerivedStruct"
d.Base.PrintName()
d.PrintName()
}
结果:
BaseStruct
DerivedStruct
2. 指针方式组合
2.1 基本语法
// 基类
type Base struct {
// 成员变量
}
func (b *Base) 函数名(参数列表) (返回值列表) {
// 函数体
}
// 派生类
type Derived struct {
*Base
// 成员变量
}
func (b *Derived) 函数名(参数列表) (返回值列表) {
// 函数体
}
2.2 规则
- 基类采用指针方式的组合,依然具有派生的效果,只是派生类创建实例的时候需要外部提供一个基类实例的指针。
- 其他规则与非指针方式组合一致。
2.3 示例
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import (
"fmt"
"log"
"os"
)
type MyJob struct {
Command string
*log.Logger
}
func (job *MyJob) Start() {
job.Println("job started!") // job.Logger.Println
fmt.Println(job.Command)
job.Println("job finished!") // job.Logger.Println
}
func main() {
logFile, err := os.OpenFile("./job.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0)
if err != nil {
fmt.Println("%s", err.Error())
return
}
defer logFile.Close() //错误处理里面提到,在退出main函数之前调用
logger := log.New(logFile, "[info]", log.Ldate|log.Ltime|log.Llongfile)
job := MyJob{"programming", logger}
job.Start()
job.Println("test finished!") // job.Logger.Println
}
结果:
programming
同时生成了一个日志文件job.log,内容如下:
[info]2018/12/28 16:11:45 D:/Workshop/GoSpace/study/src/main/main.go:23: job started!
[info]2018/12/28 16:11:45 D:/Workshop/GoSpace/study/src/main/main.go:27: job finished!
[info]2018/12/28 16:11:45 D:/Workshop/GoSpace/study/src/main/main.go:42: test finished!
在经过合适的赋值后,MyJob类型的所有成员方法可以很方便地借用所有log.Logger提供的方法。这对于MyJob的实现者来说,根本就不用意识到log.Logger类型的存在,这就是匿名组合的一个魅力所在。
3. 总结
3.1 名字覆盖
上面说明了派生类成员方法名与基类成员方法名相同时基类方法将被覆盖的情况,这对于成员变量名来说,规则也是一致的。
package main
import "fmt"
type Base struct {
Name string
}
type Derived struct {
Base
Name string
}
func main() {
d := &Derived{}
d.Name = "Derived"
d.Base.Name = "Base"
fmt.Println(d.Name)
fmt.Println(d.Base.Name)
}
结果:
Derived
Base
3.2 名字冲突
匿名组合相当于以其类型名称(去掉包名部分)作为成员变量的名字。那么按此规则,类型中如果存在两个同名的成员,即使类型不同,但我们预期会收到编译错误。
package main
import "log"
type Logger struct {
Level int
}
type MyJob struct {
*Logger
Name string
*log.Logger // duplicate field Logger
}
func main() {
job := &MyJob{}
}
结果:
# main
main\main.go:20:2: duplicate field Logger
go接口
1. 概述
类型系统是指一个语言的类型体系结构。
一个典型的类型系统通常包含如下基本内容:
- 基础类型,如byte、 int、 bool、 float等;
- 复合类型,如数组、结构体、指针等;
- 可以指向任意对象的类型(Any类型);
- 值语义和引用语义;
- 面向对象,即所有具备面向对象特征(比如成员方法的类型;
- 接口。
类型系统描述的是这些内容在一个语言中如何被关联。
在传统的面相对象编程语言中,比如Java,存在两套完全独立的类型系统:
- 值类型系统,主要是基本类型,如byte、int、 boolean、 char、double等,这些类型基于值语义;
- 以Object类型为根的对象类型系统,这些类型可以定义成员变量](https://zhida.zhihu.com/search?content_id=100109587&content_type=Article&match_order=1&q=成员变量&zhida_source=entity)和成员方法,可以有[虚函数,基于引用语义,只允许在堆上创建(通过使用关键字new)。
Java语言中的Any类型就是整个对象类型系统的根——java.lang.Object类型,只有对象类型系统中的实例才可以被Any类型引用。值类型想要被Any类型引用,需要装箱 (boxing)过程,比如int类型需要装箱成为Integer类型。另外,只有对象类型系统中的类型才可以实现接口,具体方法是让该类型从要实现的接口继承。
相比之下, Go语言中的大多数类型都是值语义,并且都可以包含对应的操作方法。在需要的时候,你可以给任何类型(包括内置类型)“增加”新方法。而在实现某个接口时,无需从该接口继承(事实上,Go语言根本就不支持面向对象思想中的继承语法),只需要实现该接口 要求的所有方法即可。任何类型都可以被Any类型引用。Any类型就是空接口,即interface{}。
2. 为类型添加方法
在Go语言中,你可以给任意类型(包括内置类型,但不包括指针类型)添加相应的方法,例如:
type Integer int
func (a Integer) Less(b Integer) bool {
return a < b
}
在这个例子中,我们定义了一个新类型Integer,它和int没有本质不同,只是它为内置的int类型增加了个新方法Less()。
这样实现了Integer后,就可以让整型像一个普通的类一样使用:
func main() {
var a Integer = 1
if a.Less(2) {
fmt.Println(a, "Less 2")
}
}
在学其他面向对象语言](https://zhida.zhihu.com/search?content_id=100109587&content_type=Article&match_order=1&q=面向对象语言&zhida_source=entity)(C++)的时候,大家会觉得面向对象的概念很神秘,不知道那些继承和多态到底是怎么发生的,其实C++等语言中的面向对象都只是相当于在 C语言基础上添加的一个[语法糖。
上面的这个Integer例子如果不使用Go语言的面向对象特性,而使用之前我们介绍的面向过程方式实现的话,相应的实现细节将如下所示:
type Integer int
func Integer_Less(a Integer, b Integer) bool {
return a < b
}
func main() {
var a Integer = 1
if Integer_Less(a, 2) {
fmt.Println(a, "Less 2")
}
}
对比下面的两段代码:
func (a Integer) Less(b Integer) bool { // 面向对象
return a < b
}
func Integer_Less(a Integer, b Integer) bool { // 面向过程
return a < b
}
a.Less(2) // 面向对象的用法
Integer_Less(a, 2) // 面向过程的用法
可以看出,面向对象只是换了一种语法形式来表达。 C++语言的面向对象之所以让有些人迷惑的一大原因就在于其隐藏的this指针。一旦把隐藏的this指针](https://zhida.zhihu.com/search?content_id=100109587&content_type=Article&match_order=2&q=this指针&zhida_source=entity)显露出来,大家看到的就是一个[面向过程编程。
“在Go语言中没有隐藏的this指针”这句话的含义是:
- 方法施加的目标(也就是“对象”)显式传递,没有被隐藏起来;
- 方法施加的目标(也就是“对象”)不需要非得是指针,也不用非得叫this。
对比Java语言的代码:
class Integer {
private int val;
public boolean Less(Integer b) {
return this.val< b.val;
}
}
对于这段Java代码,初学者可能会比较难以理解其背后的机制,以及this到底从何而来。这主要是因为Integer类的Less()方法隐藏了第一个参数Integer*this。如果将其翻译成C代码,会更清晰:
struct Integer {
int val;
};
bool Integer_Less(Integer* this, Integer* b) {
return this->val < b->val;
}
Go语言中的面向对象最为直观,也无需支付额外的成本。如果要求对象必须以指针传递,这有时会是个额外成本,因为对象有时很小(比如4字节),用指针传递并不划算。只有在你需要修改对象的时候,才必须用指针。它不是Go语言的约束,而是一种自然约束。
func (a *Integer) Add(b Integer) {
*a += b
}
这里为Integer类型增加了Add()方法。由于Add()方法需要修改对象的值,所以需要用指针引用。调用如下:
func main() {
var a Integer = 1
a.Add(2)
fmt.Println("a =", a)
}
运行该程序,得到的结果是:
a=3
如果你实现成员方法时传入的不是指针而是值(即传入 Integer,而非*Integer),如下所示:
func (a Integer) Add(b Integer) {
a += b
}
那么运行程序得到的结果是
a=1
也就是维持原来的值,究其原因,是因为Go语言和C语言一样,类型都是基于值传递的。要想修改变量的值,只能传递指针。
3. 值语义和引用语义
值语义和引用语义的差别在于赋值,比如下面的例子:
b = a
b.Modify()
如果b的修改不会影响a的值,那么此类型属于值类型。如果会影响a的值,那么此类型是引用类型。
Go语言中的大多数类型都基于值语义,包括:
- 基本类型,如byte、 int、 bool、 float32、 float64和string等;
- 复合类型,如数组(array)、结构体(struct)和指针(pointer)等。
Go语言中类型的值语义表现得非常彻底。我们之所以这么说,是因为数组。如果大家之前学过C语言,就会知道C语言中的数组比较特别,通过函数传递一个数组的时 候基于引用语义,但是在结构体中定义数组变量的时候基于值语义(表现在为结构体赋值的时候,该数组会被完整地复制)。
Go语言中的数组和基本类型没有区别,是很纯粹的值类型,例如:
var a = [3]int{1, 2, 3}
var b = a
b[1]++
fmt.Println(a, b)
该程序的运行结果如下:
[1 2 3] [1 3 3]
这表明b=a赋值语句是数组内容的完整复制。要想表达引用,需要用指针:
var a = [3]int{1, 2, 3}
var b = &a
b[1]++
fmt.Println(a, *b)
该程序的运行结果如下:
[1 3 3] [1 3 3]
这表明b=&a赋值语句是数组内容的引用。变量b的类型不是[3]int,而是*[3]int类型。
Go语言中有4个类型比较特别,看起来像引用类型,如下所示。
- 数组切片:指向数组(array)的一个区间。
- map:极其常见的数据结构,提供键值查询能力。
- channel:执行体(goroutine)间的通信设施。
- 接口(interface):对一组满足某个契约的类型的抽象。
但是这并不影响我们将Go语言类型看做值语义。下面我们来看看这4个类型。数组切片本质上是一个区间,你可以大致将[]T表示为:
type slice struct {
first *T
len int
cap int
}
因为数组切片内部是指向数组的指针,所以可以改变所指向的数组元素并不奇怪。数组切片类型本身的赋值仍然是值语义。
map本质上是一个字典指针,你可以大致将map[K]V表示为:
type Map_K_V struct {
// ...
}
type map[K]V struct {
impl *Map_K_V
}
基于指针,我们完全可以自定义一个引用类型,如:
type IntegerRef struct {
impl *int
}
channel和map类似,本质上是一个指针。将它们设计为引用类型而不是统一的值类型的原因是,完整复制一个channel或map并不是常规需求。同样,接口具备引用语义,是因为内部维持了两个指针,示意为:
type interface struct {
data *void
itab *Itab
}
4. 结构体
Go语言的结构体(struct)和其他语言的类(class)有同等的地位,但Go语言放弃了包括继承在内的大量面向对象特性,只保留了组合(composition)这个最基础的特性。
组合甚至不能算面向对象特性,因为在C语言这样的面向过程的编程语言中,也有结构体,也有组合。组合只是形成复合类型的基础。
上面我们说到,所有的Go语言类型(指针类型除外)都可以有自己的方法。在这个背景下,Go语言的结构体只是很普通的复合类型,比如,我们要定义一个矩形类型:
type Rect struct {
x, y float64
width, height float64
}
然后我们定义成员方法Area()来计算矩形的面积:
func (r *Rect) Area() float64 {
return r.width * r.height
}
可以看出, Go语言中结构体的使用方式与C语言并没有明显不同。
go并发编程
1. 协程
执行体](https://zhida.zhihu.com/search?content_id=100131911&content_type=Article&match_order=1&q=执行体&zhida_source=entity)是个抽象的概念,在操作系统层面有多个概念与之对应,比如操作系统自己掌管的进程(process)、进程内的线程(thread)以及进程内的协程(coroutine,也叫轻量级线程)。与传统的系统级线程和进程相比,协程的最大优势在于其“轻量级”,可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万个。这也是协程也叫[轻量级线程的原因。
Go 语言在语言级别支持轻量级线程,叫goroutine。 Go语言标准库提供的所有系统调用操作(当然也包括所有同步IO操作),都会出让CPU给其他goroutine。这让事情变得非常简单,让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖于CPU的核心数量。
2. goroutine
goroutine是Go语言中的轻量级线程实现,由Go运行时(runtime)管理。你将会发现,它的使用非常简单。
假设我们需要实现一个函数Add(),它把两个参数相加,并将结果打印到屏幕上,具体代码如下:
func Add(x, y int) {
z := x + y
fmt.Println(z)
}
那么,如何让这个函数并发执行呢?具体代码如下:
go Add(1, 1)
其中,“go”这个单词是关键。与普通的函数调用相比,这也是唯一的区别。,go是Go语言中最重要的关键字,这一点从Go语言本身的命名即可看出。
在一个函数调用前加上go关键字,这次调用就会在一个新的goroutine中并发执行。当被调用的函数返回时,这个goroutine也自动结束了。需要注意的是,如果这个函数有返回值,那么这个返回值会被丢弃。
package main
import "fmt"
func Add(x, y int) {
z := x + y
fmt.Println(z)
}
func main() {
for i := 0; i < 10; i++ {
go Add(i, i)
}
}
在上面的代码里,我们在一个for循环中调用了10次Add()函数,它们是并发执行的。可是当你编译执行了上面的代码,就会发现一些奇怪的现象:
“什么?!屏幕上什么都没有,程序没有正常工作!”
是什么原因呢?明明调用了10次Add(),应该有10次屏幕输出才对。要解释这个现象,就涉及Go语言的程序执行机制了。
Go程序从初始化main package并执行main()函数开始,当main()函数返回时,程序退出,且程序并不等待其他goroutine(非主goroutine)结束。
对于上面的例子,主函数启动了10个goroutine,然后返回,这时程序就退出了,而被启动的执行Add(i, i)的goroutine没有来得及执行,所以程序没有任何输出。
OK,问题找到了,怎么解决呢?提到这一点,估计写过多线程程序的读者就已经恍然大悟,并且摩拳擦掌地准备使用类似WaitForSingleObject之类的调用,或者写个自己很拿手的忙等待或者稍微先进一些的sleep循环等待来等待所有线程执行完毕。
在Go语言中有自己推荐的方式,它要比这些方法都优雅得多。要让主函数等待所有goroutine退出后再返回,如何知道goroutine都退出了呢?这就引出了多个goroutine之间通信的问题。
3. 并发通信
从上面的例子中可以看到,关键字go的引入使得在Go语言中并发编程变得简单而优雅,但我们同时也应该意识到并发编程的原生复杂性,并时刻对并发中容易出现的问题保持警惕。
并发编程的难度在于协调,而协调就要通过交流。从这个角度看来,并发单元间的通信是最大的问题。在工程上,有两种最常见的并发通信模型:共享数据和消息。
共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,比如内存数据块](https://zhida.zhihu.com/search?content_id=100131911&content_type=Article&match_order=1&q=数据块&zhida_source=entity)、磁盘文件、网络数据等。在实际[工程应用中最常见的无疑是内存了,也就是常说的共享内存。
先看看我们在C语言中通常是怎么处理线程间数据共享的:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
void *count();
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;
main()
{
int rc1, rc2;
pthread_t thread1, thread2;
/* 创建线程,每个线程独立执行函数functionC */
if((rc1 = pthread_create(&thread1, NULL, &add, NULL)))
{
printf("Thread creation failed: %d\n", rc1);
}
if((rc2 = pthread_create(&thread2, NULL, &add, NULL)))
{
printf("Thread creation failed: %d\n", rc2);
}
/* 等待所有线程执行完毕 */
pthread_join( thread1, NULL);
pthread_join( thread2, NULL);
exit(0);
}
void *count()
{
pthread_mutex_lock( &mutex1 );
counter++;
printf("Counter value: %d\n",counter);
pthread_mutex_unlock( &mutex1 );
}
现在我们尝试将这段C语言代码直接翻译为Go语言代码:
package main
import "fmt"
import "sync"
import "runtime"
var counter int = 0
func Count(lock *sync.Mutex) {
lock.Lock()
counter++
fmt.Println(z)
lock.Unlock()
}
func main() {
lock := &sync.Mutex{}
for i := 0; i < 10; i++ {
go Count(lock)
}
for {
lock.Lock()
c := counter
lock.Unlock()
runtime.Gosched()
if c >= 10 {
break
}
}
}
此时这个例子终于可以正常工作了。
在上面的例子中,我们在10个goroutine中共享了变量counter。每个goroutine执行完成后,将counter的值加1。因为10个goroutine是并发执行的,所以我们还引入了锁,也就是代码中的lock变量。每次对n的操作,都要先将锁锁住,操作完成后,再将锁打开。在主函数中,使用for循环来不断检查counter的值(同样需要加锁)。当其值达到10时,说明所有goroutine都执行完毕了,这时主函数返回,程序退出。
事情好像开始变得糟糕了。实现一个如此简单的功能,却写出如此臃肿而且难以理解的代码。Go语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方式来解决。
Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。
消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通信,它们不会共享内存。
“不要通过共享内存来通信,而应该通过通信来共享内存。”
Go语言提供的消息通信机制被称为channel,后面将详细介绍channel。
5. 多核并行化
在执行一些昂贵的计算任务时,我们希望能够尽量利用现代服务器普遍具备的多核特性来尽量将任务并行化,从而达到降低总计算时间的目的。此时我们需要了解CPU核心的数量,并针对性地分解计算任务到多个goroutine中去并行运行。
下面我们来模拟一个完全可以并行的计算任务:计算N个整型数的总和。我们可以将所有整型数分成M份, M即CPU的个数。让每个CPU开始计算分给它的那份计算任务,最后将每个CPU的计算结果再做一次累加,这样就可以得到所有N个整型数的总和:
type Vector []float64
// 分配给每个CPU的计算任务
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // 发信号告诉任务管理者我已经计算完成了
}
const NCPU = 16 // 假设总共有16核
func (v Vector) DoAll(u Vector) {
c := make(chan int, NCPU) // 用于接收每个CPU的任务完成信号
for i := 0; i < NCPU; i++ {
go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
}
// 等待所有CPU的任务完成
for i := 0; i < NCPU; i++ {
<-c // 获取到一个数据,表示一个CPU计算完成了
}
// 到这里表示所有计算已经结束
}
这两个函数看起来设计非常合理。
DoAll()会根据CPU核心的数目对任务进行分割,然后开辟多个goroutine来并行执行这些计算任务。 是否可以将总的计算时间降到接近原来的1/N呢?答案是不一定。如果掐秒表(正常点的话,应该用7.8节中介绍的Benchmark方法),会发现总的执行时间没有明显缩短。再去观察CPU运行状态,你会发现尽管我们有16个CPU核心,但在计算过程中其实只有一个CPU核心处于繁忙状态,这是会让很多Go语言初学者迷惑的问题。
虽然我们确实创建了多个goroutine,并且从运行状态看这些goroutine](https://zhida.zhihu.com/search?content_id=100131911&content_type=Article&match_order=21&q=goroutine&zhida_source=entity)也都在并行运行,但实际上所有这些goroutine都运行在同一个CPU核心上,在一个goroutine得到时间片执行的时候,其他goroutine都会处于等待状态。从这一点可以看出,虽然goroutine简化了我们写并行代码的过程,但实际上整体运行效率并不真正高于[单线程程序。
6. 出让时间片
我们可以在每个goroutine中控制何时主动出让时间片给其他goroutine,这可以使用runtime包中的Gosched()函数实现。
实际上,如果要比较精细地控制goroutine的行为,就必须比较深入地了解Go语言开发包中runtime包所提供的具体功能。
7. 同步
我们之前倡导用通信来共享数据,而不是通过共享数据来进行通信,但考虑到即使成功地用channel来作为通信手段,还是避免不了多个goroutine之间共享数据的问题, Go语言的设计者虽然对channel有极高的期望,但也提供了妥善的资源锁方案。
7.1 同步锁
Go语言包中的sync包提供了两种锁类型:sync.Mutex和sync.RWMutex。
- Mutex是最简单的一种锁类型,同时也比较暴力,当一个goroutine获得了Mutex后,其他goroutine就只能乖乖等到这个goroutine释放该Mutex。
- RWMutex相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个goroutine可同时获取读锁(调用RLock()方法;而写锁(调用Lock()方法)会阻止任何其他goroutine(无论读和写)进来,整个锁相当于由该goroutine独占。
从RWMutex的实现看,RWMutex类型其实组合了Mutex:
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount int32
readerWait int32
}
对于这两种锁类型,任何一个Lock()或RLock()均需要保证对应有Unlock()或RUnlock()调用与之对应,否则可能导致等待该锁的所有goroutine处于饥饿状态,甚至可能导致死锁。
锁的典型使用模式如下:
var l sync.Mutex
func foo() {
l.Lock()
defer l.Unlock()
//...
}
7.2 全局唯一性操作
对于从全局的角度只需要运行一次的代码,比如全局初始化操作,Go语言提供了一个Once类型来保证全局的唯一性操作,具体代码如下:
var a string
var once sync.Once
func setup() {
a = "hello, world"
}
func doprint() {
once.Do(setup)
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
如果这段代码没有引入Once,setup()将会被每一个goroutine先调用一次,这至少对于这个例子是多余的。在现实中,我们也经常会遇到这样的情况。 Go语言标准库为我们引入了Once类型以解决这个问题。 once的Do()方法可以保证在全局范围内只调用指定的函数一次(这里指setup()函数),而且所有其他goroutine在调用到此语句时,将会先被阻塞,直至全局唯一的once.Do()调用结束后才继续。
这个机制比较轻巧地解决了使用其他语言时开发者不得不自行设计和实现这种Once效果的难题,也是Go语言为并发性编程做了尽量多考虑的一种体现。 如果没有once.Do(),我们很可能只能添加一个全局的bool变量,在函数setup()的最后一行将该bool变量设置为true。在对setup()的所有调用之前,需要先判断该bool变量是否已经被设置为true,如果该值仍然是false,则调用一次setup(),否则应跳过该语句。
实现代码如下所示:
var done bool = false
func setup() {
a = "hello, world"
done = true
}
func doprint() {
if !done {
setup()
}
print(a)
}
这段代码初看起来比较合理,但是细看还是会有问题,因为setup()并不是一个原子性操作,这种写法可能导致setup()函数被多次调用,从而无法达到全局只执行一次的目标。这个问题的复杂性也更加体现了Once类型的价值。
为了更好地控制并行中的原子性操作,sync包中还包含一个atomic子包,它提供了对于一些基础数据类型的原子操作函数,比如下面这个函数:
func CompareAndSwapUint64(val *uint64, old, new uint64) (swapped bool)
就提供了比较和交换两个uint64类型数据的操作。这让开发者无需再为这样的操作专门添加Lock操作。
go channel
channel是Go语言在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或多个goroutine之间传递消息。
channel是进程内的通信方式,因此通过channel传递对象的过程和调用函数时的参数传递](https://zhida.zhihu.com/search?content_id=100132023&content_type=Article&match_order=1&q=参数传递&zhida_source=entity)行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用分布式系统的方法来解决,比如使用Socket或者HTTP等[通信协议。Go语言对于网络方面也有非常完善的支持。
channel是类型相关的。也就是说,一个channel只能传递一种类型的值,这个类型需要在声明channel时指定。如果对Unix管道](https://zhida.zhihu.com/search?content_id=100132023&content_type=Article&match_order=1&q=Unix管道&zhida_source=entity)有所了解的话,就不难理解channel,可以将其认为是一种[类型安全的管道。
在了解channel的语法前,我们先看下用channel的方式重写上面的例子是什么样子的,以此对channel先有一个直感的认识。
/**
* @version: v1.0
* @author: Kandy.Ye
* @contact: Kandy.Ye@foxmail.com
* @file: main.go
* @time: 2018/3/7 23:45
**/
package main
import "fmt"
func Count(ch chan int) {
ch <- 1
fmt.Println("Counting")
}
func main() {
chs := make([]chan int, 10)
for i := 0; i < 10; i++ {
chs[i] = make(chan int)
go Count(chs[i])
}
for _, ch := range chs {
<-ch
}
}
运行结果:
Counting
Counting
Counting
Counting
Counting
Counting
在这个例子中,我们定义了一个包含10个channel的数组(名为chs),并把数组中的每个channel分配给10个不同的goroutine。在每个goroutine的Add()函数完成后,我们通过ch <- 1语句向对应的channel中写入一个数据。在这个channel被读取前,这个操作是阻塞的。在所有的goroutine启动完成后,我们通过<-ch语句从10个channel中依次读取数据。在对应的channel写入数据前,这个操作也是阻塞的。这样,我们就用channel实现了类似锁的功能,进而保证了所有goroutine完成后主函数才返回。
我们在使用Go语言开发时,经常会遇到需要实现条件等待的场景,这也是channel可以发挥作用的地方。对channel的熟练使用,才能真正理解和掌握Go语言并发编程。
1. 基本语法
一般channel的声明形式为:
var chanName chan ElementType
与一般的变量声明不同的地方仅仅是在类型之前加了chan关键字。 ElementType指定这个channel所能传递的元素类型。举个例子,我们声明一个传递类型为int的channel:
var ch chan int
或者,我们声明一个map,元素是bool型的channel:
var m map[string] chan bool
上面的语句都是合法的。
定义一个channel也很简单,直接使用内置的函数make()即可:
ch := make(chan int)
这就声明并初始化了一个int型的名为ch的channel。
在channel的用法中,最常见的包括写入和读出。将一个数据写入(发送)至channel的语法很直观,如下:
ch <- value
向channel写入数据通常会导致程序阻塞,直到有其他goroutine从这个channel中读取数据。从 channel中读取数据的语法是:
value := <-ch
如果channel之前没有写入数据,那么从channel中读取数据也会导致程序阻塞,直到channel中被写入数据为止。我们之后还会提到如何控制channel只接受写或者只允许读取,即单向channel。
2. select
早在Unix时代,select机制就已经被引入。通过调用select()函数来监控一系列的文件句柄,一旦其中一个文件句柄发生了IO动作,该select()调用就会被返回。后来该机制也被用于实现高并发的Socket服务器程序。
Go语言直接在语言级别支持select关键字,用于处理异步IO问题。
select的用法与switch语言非常类似,由select开始一个新的选择块,每个选择条件由case语句来描述。与switch语句可以选择任何可使用相等比较的条件相比, select有比较多的限制,其中最大的一条限制就是每个case语句里必须是一个IO操作,大致的结构如下:
select {
case <-chan1:
// 如果chan1成功读到数据,则进行该case处理语句
case chan2 <- 1:
// 如果成功向chan2写入数据,则进行该case处理语句
default:
// 如果上面都没有成功,则进入default处理流程
}
可以看出, select不像switch,后面并不带判断条件,而是直接去查看case语句。每个case语句都必须是一个面向channel的操作。比如上面的例子中,第一个case试图从chan1读取一个数据并直接忽略读到的数据,而第二个case则是试图向chan2中写入一个整型数1,如果这两者都没有成功,则到达default语句。
基于此功能,我们可以实现一个有趣的程序:
ch := make(chan int, 1)
for {
select {
case ch <- 0:
case ch <- 1:
}
i := <-ch
fmt.Println("Value received:", i)
}
这个程序实现了一个随机向ch中写入一个0或者1 的过程,这是个死循环。
3. 缓冲机制
之前的示例都是不带缓冲的channel,这种做法对于传递单个数据的场景可以接受,但对于需要持续传输大量数据的场景就有些不合适了。接下来我们介绍如何给channel带上缓冲,从而达到消息队列的效果。
要创建一个带缓冲的channel,其实也非常容易:
c := make(chan int, 1024)
在调用make()时将缓冲区大小作为第二个参数传入即可,比如上面这个例子就创建了一个大小为1024的int类型channel,即使没有读取方,写入方也可以一直往channel里写入,在缓冲区被填完之前都不会阻塞。
从带缓冲的channel中读取数据可以使用与常规非缓冲channel完全一致的方法,但我们也可以使用range关键来实现更为简便的循环读取:
for i := range c {
fmt.Println("Received:", i)
}
4. 超时机制
在之前对channel的介绍中,我们完全没有提到错误处理的问题,而这个问题显然是不能被忽略的。在并发编程的通信过程中,最需要处理的就是超时问题,即向channel写数据时发现channel已满,或者从channel试图读取数据时发现channel为空。如果不正确处理这些情况,很可能会导致整个goroutine锁死。
虽然goroutine是Go语言引入的新概念,但通信锁死问题已经存在很长时间,在之前的C/C++开发中也存在。操作系统在提供此类系统级](https://zhida.zhihu.com/search?content_id=100132023&content_type=Article&match_order=1&q=系统级&zhida_source=entity)通信函数时也会考虑入超时场景,因此这些方法通常都会带一个独立的超时参数。超过设定的时间时,仍然没有处理完任务,则该方法会立即终止并返回对应的超时信息。[超时机制本身虽然也会带来一些问题,比如在运行比较快的机器或者高速的网络上运行正常的程序,到了慢速的机器或者网络上运行就会出问题,从而出现结果不一致的现象,但从根本上来说,解决死锁问题的价值要远大于所带来的问题。
使用channel时需要小心,比如对于以下这个用法:
i := <-ch
不出问题的话一切都正常运行。但如果出现了一个错误情况,即永远都没有人往ch里写数据,那么上述这个读取动作也将永远无法从ch中读取到数据, 导致的结果就是整个goroutine永远阻塞并没有挽回的机会。如果channel只是被同一个开发者使用,那样出问题的可能性还低一些。但如果一旦对外公开,就必须考虑到最差的情况并对程序进行保护。
Go语言没有提供直接的超时处理机制,但我们可以利用select机制。虽然select机制不是专为超时而设计的,却能很方便地解决超时问题。因为select的特点是只要其中一个case已经完成,程序就会继续往下执行,而不会考虑其他case的情况。
基于此特性,我们来为channel实现超时机制:
// 首先,我们实现并执行一个匿名的超时等待函数
timeout := make(chan bool, 1)
go func() {
time.Sleep(1e9) // 等待1秒钟
timeout <- true
}()
// 然后我们把timeout这个channel利用起来
select {
case <-ch:
// 从ch中读取到数据
case <-timeout:
// 一直没有从ch中读取到数据,但从timeout中读取到了数据
}
这样使用select机制可以避免永久等待的问题,因为程序会在timeout中获取到一个数据后继续执行,无论对ch的读取是否还处于等待状态,从而达成1秒超时的效果。
这种写法看起来是一个小技巧,但却是在Go语言开发中避免channel通信超时的最有效方法。在实际的开发过程中,这种写法也需要被合理利用起来,从而有效地提高代码质量。
5. channel的传递
需要注意的是,在Go语言中channel本身也是一个原生类型,与map之类的类型地位一样,因此channel本身在定义后也可以通过channel来传递。
我们可以使用这个特性来实现Linux上非常常见的管道(pipe)特性。管道也是使用非常广泛的一种设计模式,比如在处理数据时,我们可以采用管道设计,这样可以比较容易以插件的方式增加数据的处理流程。
下面我们利用channel可被传递的特性来实现我们的管道。为了简化表达,我们假设在管道中传递的数据只是一个整型数,在实际的应用场景中这通常会是一个数据块。
首先限定基本的数据结构:
type PipeData struct {
value int
handler func(int) int
next chan int
}
然后我们写一个常规的处理函数。我们只要定义一系列PipeData的数据结构并一起传递给这个函数,就可以达到流式处理数据的目的:
func handle(queue chan *PipeData) {
for data := range queue {
data.next <- data.handler(data.value)
}
}
这里我们只给出了大概的样子,同理,利用channel的这个可传递特性,我们可以实现非常强大、灵活的系统架构](https://zhida.zhihu.com/search?content_id=100132023&content_type=Article&match_order=1&q=系统架构&zhida_source=entity)。与Go语言接口的[非侵入式类似,channel的这些特性也可以大大降低开发者的心智成本,用一些比较简单却实用的方式来达成在其他语言中需要使用众多技巧才能达成的效果。
6. 单向channel
顾名思义,单向channel只能用于发送或者接收数据。 channel本身必然是同时支持读写的,否则根本没法用。假如一个channel真的只能读,那么肯定只会是空的,因为你没机会往里面写数据。同理,如果一个channel只允许写,即使写进去了,也没有丝毫意义,因为没有机会读取里面的数据。所谓的单向channel概念,其实只是对channel的一种使用限制。
我们在将一个channel变量传递到一个函数时,可以通过将其指定为单向channel变量,从而限制 该函数中可 以对此 channel的操作, 比如只能往 这个 channel写,或者只 能从这个channel读。
单向channel变量的声明非常简单,如下:
var ch1 chan int // ch1是一个正常的channel,不是单向的
var ch2 chan<- float64// ch2是单向channel,只用于写float64数据
var ch3 <-chan int // ch3是单向channel,只用于读取int数据
那么单向channel如何初始化呢?之前我们已经提到过, channel是一个原生类型,因此不仅支持被传递,还支持类型转换。只有在介绍了单向channel的概念后,读者才会明白类型转换对于channel的意义:就是在单向channel和双向channel之间进行转换。示例如下:
ch4 := make(chan int)
ch5 := <-chan int(ch4) // ch5就是一个单向的读取channel
ch6 := chan<- int(ch4) // ch6 是一个单向的写入channel
基于ch4,我们通过类型转换初始化了两个单向channel:单向读的ch5和单向写的ch6。
为什么要做这样的限制呢?从设计的角度考虑,所有的代码应该都遵循“最小权限原则”,从而避免没必要地使用泛滥问题,进而导致程序失控](https://zhida.zhihu.com/search?content_id=100132023&content_type=Article&match_order=1&q=程序失控&zhida_source=entity)。写过C++程序的读者肯定就会联想起const指针的用法。非const指针具备[const指针的所有功能,将一个指针设定为const就是明确告诉函数实现者不要试图对该指针进行修改。单向channel也是起到这样的一种契约作用。
下面我们来看一下单向channel的用法:
func Parse(ch <-chan int) {
for value := range ch {
fmt.Println("Parsing value", value)
}
}
除非这个函数的实现者无耻地使用了类型转换,否则这个函数就不会因为各种原因而对ch进行写,避免在ch中出现非期望的数据,从而很好地实践最小权限原则。
7. 关闭channel
关闭channel非常简单,直接使用Go语言内置的close()函数即可:
close(ch)
在介绍了如何关闭channel之后,我们就多了一个问题:如何判断一个channel是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:
x, ok := <-ch
这个用法与map中的按键获取value的过程比较类似,只需要看第二个bool返回值即可,如果返回值是false则表示ch已经被关闭。
go RPC编程
RPC(Remote ProcedureCall,远程过程调用)是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络细节的应用程序通信协议。
RPC协议构建于TCP或UDP,或者是HTTP之上,允许直接调用另一台计算机上的程序,而无需额外地为这个调用过程编写网络通信相关代码,使得开发包括网络分布式程序在内的应用程序更加容易。
RPC 采用客户端—服务器(Client/Server)的工作模式。请求程序](https://zhida.zhihu.com/search?content_id=100244232&content_type=Article&match_order=1&q=请求程序&zhida_source=entity)就是一个客户端(Client),而服务提供程序就是一个服务器(Server)。当执行一个[远程过程调用时,客户端程序首先发送一个带有参数的调用信息到服务端,然后等待服务端响应。在服务端,服务进程保持睡眠状态直到客户端的调用信息到达为止。当一个调用信息到达时,服务端获得进程参数,计算出结果,并向客户端发送应答信息,然后等待下一个调用。最后,客户端接收来自服务端的应答信息,获得进程结果,然后调用执行并继续进行。
1. Go语言中的RPC支持与处理
在Go中,标准库提供的net/rpc包实现了RPC协议需要的相关细节,我们可以很方便地使用该包编写 RPC的服务端和客户端程序,这使得用Go语言开发的多个进程之间的通信变得非常简单。
net/rpc包允许 RPC 客户端程序通过网络或是其他 I/O 连接调用一个远端对象的公开方法(必须是大写字母开头、可外部调用的)。在RPC服务端,可将一个对象注册为可访问的服务,之后该对象的公开方法就能够以远程的方式提供访问。一个RPC服务端可以注册多个不同类型的对象,但不允许注册同一类型的多个对象。
一个对象中只有满足如下这些条件的方法,才能被RPC服务端设置为可供远程访问:
- 必须是在对象外部可公开调用的方法(首字母大写);
- 必须有两个参数,且参数的类型都必须是包外部可以访问的类型或者是Go内建支持的类型;
- 第二个参数必须是一个指针;
- 方法必须返回一个error类型的值。
以上4个条件,可以简单地用如下一行代码表示:
func (t *T) MethodName(argType T1, replyType *T2) error
在上面这行代码中,类型T、T1和T2默认会使用Go 内置的encoding/gob包进行编码解码。
关于encoding/gob包的内容,稍后我们将会对其进行介绍。该方法(MethodName)的第一个参数表示由 RPC 客户端传入的参数,第二个参数表示要返 回给RPC客户端的结果,该方法最后返回一个error 类型的值。
RPC服务端可以通过调用rpc.ServeConn处理单个连接请求。多数情况下,通过TCP或是HTTP在某个网络地址上进行监听来创建该服务是个不错的选择。 在RPC客户端,Go的net/rpc包提供了便利的rpc.Dial()和rpc.DialHTTP()方法来与指定的RPC服务端建立连接。在建立连接之后,Go的net/rpc包允许我们使用同步或者异步的方式接收RPC服务端的处理结果。调用RPC客户端的Call()方法则进行同步处理,这时候客户端程序按顺序执行,只有接收完 RPC 服务端的处理结果之后才可以继续执行后面的程序。当调用RPC客户端的Go()方法时,则可以进行异步处理,RPC客户端程序无需等待服务端的结果即可执行后面的程序,而当接收到RPC服务端的处理结果时,再对其进行相应的处理。
无论是调用RPC客户端的Call()或者是Go()方法,都必须指定要调用的服务及其方法名称,以及一个客户端传入参数的引用,还有一个用于接收处理结果参数的指针。
如果没有明确指定RPC传输过程中使用何种编码解码器,默认将使用 Go 标准库提供的encoding/gob 包进行数据传输。
接下来,我们来看一组RPC服务端和客户端交互的示例程序。
package server
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
type Arith int
func (t *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}
func (t *Arith) Divide(args *Args, quo *Quotient) error {
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}
注册服务对象并开启该 RPC 服务的代码如下:
arith := new(Arith)
rpc.Register(arith)
rpc.HandleHTTP()
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
go http.Serve(l, nil)
此时,RPC服务端注册了一个Arith类型的对象及其公开方法Arith.Multiply()和Arith.Divide()供 RPC 客户端调用。RPC在调用服务端提供的方法之前,必须先与RPC服务端建立连接,如下列代码所示:
client, err := rpc.DialHTTP("tcp", serverAddress + ":1234")
if err != nil {
log.Fatal("dialing:", err)
}
在建立连接之后,RPC客户端可以调用服务端提供的方法。首先,我们来看同步调用程序顺序执行的方式:
args := &server.Args{7,8}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d*%d=%d", args.A, args.B, reply)
此外,还可以以异步方式进行调用,具体代码如下:
quotient := new(Quotient)
divCall := client.Go("Arith.Divide", args, "ient, nil)
replyCall := <-divCall.Done
2. Gob简介
Gob是Go的一个序列化数据结构](https://zhida.zhihu.com/search?content_id=100244232&content_type=Article&match_order=1&q=数据结构&zhida_source=entity)的编码解码工具,在Go标准库中内置encoding/gob包以供使用。一个数据结构使用Gob进行序列化之后,能够用于网络传输。与JSON或XML这种基于文本描述的数据交换语言不同,Gob是[二进制编码的数据流,并且Gob流是可以自解释的,它在保证高效率的同时,也具备完整的表达能力。
作为针对Go的数据结构进行编码和解码的专用序列化方法,这意味着Gob无法跨语言使用。在Go的net/rpc包中,传输数据所需要用到的编码解码器,默认就是Gob。由于Gob仅局限于使用Go语言开发的程序,这意味着我们只能用Go的RPC实现进程间通信](https://zhida.zhihu.com/search?content_id=100244232&content_type=Article&match_order=1&q=进程间通信&zhida_source=entity)。然而,大多数时候,我们用Go编写的RPC服务端(或客户端),可能更希望它是通用的,与语言无关的,无论是Python、Java或其他[编程语言实现的 RPC 客户端,均可与之通信。
3. 设计优雅的RPC接口
Go的net/rpc很灵活,它在数据传输前后实现了编码解码器的接口定义。这意味着,开发者可以自定义数据的传输方式以及RPC服务端和客户端之间的交互行为。
RPC提供的编码解码器接口如下:
type ClientCodec interface {
WriteRequest(*Request, interface{}) error
ReadResponseHeader(*Response) error
ReadResponseBody(interface{}) error
Close() error
}
type ServerCodec interface {
ReadRequestHeader(*Request) error
ReadRequestBody(interface{}) error
WriteResponse(*Response, interface{}) error
Close() error
}
接口ClientCodec定义了 RPC 客户端如何在一个 RPC 会话中发送请求和读取响应。客户端程序通过 WriteRequest()方法将一个请求写入到RPC连接中,并通过 ReadResponseHeader()和ReadResponseBody()读取服务端的响应信息。当整个过程执行完毕后,再通过Close()方法来关闭该连接。
接口ServerCodec定义了RPC服务端如何在一个 RPC 会话中接收请求并发送响应。服务端程序通过 ReadRequestHeader()和ReadRequestBody()方法从一个 RPC 连接中读取请求信息,然后再通过WriteResponse() 方法向该连接中的RPC客户端发送响应。当完成该过程后,通过Close()方法来关闭连接。
通过实现上述接口,我们可以自定义数据传输前后的编码解码方式,而不仅仅局限于Gob。
同样,可以自定义RPC服务端和客户端的交互行为。实际上, Go标准库提供的net/rpc/json包,就是一套实现了rpc.ClientCodec和rpc.ServerCodec接口的 JSON-RPC 模块。
go JSON处理
JSON(JavaScript Object Notation)是一种比XML更轻量级的数据交换格式](https://zhida.zhihu.com/search?content_id=100306371&content_type=Article&match_order=1&q=数据交换格式&zhida_source=entity),在易于人们阅读和编写的同时,也易于程序解析和生成。尽管JSON是JavaScript的一个子集,但JSON采用完全独立于编程语言的文本格式,且表现为键/值对集合的文本描述形式(类似一些编程语言中的字典结构),这使它成为较为理想的、[跨平台、跨语言的数据交换语言。
开发者可以用JSON传输简单的字符串、数字、布尔值](https://zhida.zhihu.com/search?content_id=100306371&content_type=Article&match_order=1&q=布尔值&zhida_source=entity),也可以传输一个数组,或者一个更复杂的复合结构。在 Web 开发领域中, JSON被广泛应用于 Web 服务端程序和客户端之间的数据通信,但也不仅仅局限于此,其应用范围非常广阔,比如作为Web Services API输出的标准格式,又或是用作程序网络通信中的[远程过程调用(RPC)等。
关于JSON的更多信息,请访问JSON官方网站http://json.org/ 查阅。
Go语言内建对JSON的支持。使用Go语言内置的encoding/json 标准库,开发者可以轻松使用Go程序生成和解析JSON格式的数据。在Go语言实现JSON的编码和解码时,遵循RFC4627协议标准。
1. 编码为JSON格式
使用json.Marshal()函数可以对一组数据进行JSON格式的编码。
json.Marshal()函数的声明如下:
func Marshal(v interface{}) ([]byte, error)
假如有如下一个Book类型的结构体:
type Book struct {
Title string
Authors []string
Publisher string
IsPublished bool
Price float
}
并且有如下一个Book类型的实例对象:
gobook := Book{
"Go区块链编程",
["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
"XuDaoli"],
"ituring.com.cn",
true,
9.99
}
然后,我们可以使用json.Marshal()函数将gobook实例生成一段JSON格式的文本:
b, err := json.Marshal(gobook)
如果编码成功, err 将赋于零值 nil,变量b 将会是一个进行JSON格式化之后的[]byte类型:
b == []byte(`{
"Title": "Go区块链编程",
"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
"XuDaoli"],
"Publisher": "ituring.com.cn",
"IsPublished": true,
"Price": 9.99
}`)
当我们调用json.Marshal(gobook)语句时,会递归遍历](https://zhida.zhihu.com/search?content_id=100306371&content_type=Article&match_order=1&q=递归遍历&zhida_source=entity)gobook对象,如果发现gobook这个[数据结构实现了json.Marshaler接口且包含有效的值,Marshal()就会调用其MarshalJSON()方法将该数据结构生成 JSON 格式的文本。
Go语言的大多数数据类型都可以转化为有效的JSON文本,但channel、complex和函数这几种类型除外。
如果转化前的数据结构中出现指针,那么将会转化指针所指向的值,如果指针指向的是零值,那么null将作为转化后的结果输出。
在Go中, JSON转化前后的数据类型映射如下。
- 布尔值转化为JSON后还是布尔类型。
- 浮点数和整型会被转化为JSON里边的常规数字。
- 字符串将以UTF-8编码转化输出为Unicode字符集的字符串,特殊字符比如<将会被转义为 。
- 数组和切片会转化为JSON里边的数组,但[]byte类型的值将会被转化为 Base64 编码后的字符串, slice类型的零值会被转化为 null。
- 结构体会转化为JSON对象,并且只有结构体里边以大写字母开头的可被导出的字段才会被转化输出,而这些可导出的字段会作为JSON对象的字符串索引。
- 转化一个map类型的数据结构时,该数据的类型必须是 map[string]T(T可以是encoding/json 包支持的任意数据类型)。
2. 解码JSON数据
可以使用json.Unmarshal()函数将JSON格式的文本解码为Go里边预期的数据结构。
json.Unmarshal()函数的原型如下:
func Unmarshal(data []byte, v interface{}) error
该函数的第一个参数是输入,即JSON格式的文本(比特序列),第二个参数表示目标输出容器,用于存放解码后的值。要解码一段JSON数据,首先需要在Go中创建一个目标类型的实例对象,用于存放解码后的值:
var book Book
然后调用 json.Unmarshal() 函数,将 []byte 类型的JSON数据作为第一个参数传入,将 book 实例变量的指针作为第二个参数传入:
err := json.Unmarshal(b, &book)
如果 b 是一个有效的JSON数据并能和book结构对应起来,那么JSON解码后的值将会一一存放到book结构体中。解码成功后的 book 数据如下:
book := Book{
"Go区块链编程",
["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
"XuDaoli"],
"ituring.com.cn",
true,
9.99
}
我们不禁好奇,Go是如何将JSON数据解码后的值一一准确无误地关联到一个数据结构中的相应字段呢?
实际上, json.Unmarshal()函数会根据一个约定的顺序查找目标结构中的字段,如果找到一个即发生匹配。假设一个JSON对象有个名为"Foo"的索引,要将"Foo"所对应的值填充到目标结构体的目标字段上, json.Unmarshal()将会遵循如下顺序进行查找匹配:
- 一个包含Foo标签的字段;
- 一个名为Foo的字段;
- 一个名为Foo或者Foo或者除了首字母其他字母不区分大小写的名为Foo的字段。
这些字段在类型声明中必须都是以大写字母开头、可被导出的字段。但是当JSON数据里边的结构和Go里边的目标类型的结构对不上时,会发生什么呢?示例代码如下:
b := []byte(`{"Title": "Go区块链编程", "Sales": 1000000}`)
var gobook Book
err := json.Unmarshal(b, &gobook)
如果JSON中的字段在Go目标类型中不存在,json.Unmarshal()函数在解码过程中会丢弃该字段。在上面的示例代码中,由于Sales字段并没有在Book类型中定义,所以会被忽略,只有Title这个字段的值才会被填充到gobook.Title中。
这个特性让我们可以从同一段JSON数据中筛选指定的值填充到多个Go语言类型中。当然,前提是已知JSON数据的字段结构。这也同样意味着,目标类型中不可被导出的私有字段(非首字母大写)将不会受到解码转化的影响。但如果JSON的数据结构是未知的,应该如何处理呢?
3. 解码未知结构的JSON数据
我们已经知道,Go语言支持接口。在Go语言里,接口是一组预定义方法的组合,任何一个类型均可通过实现接口预定义的方法来实现,且无需显示声明,所以没有任何方法的空接口可以代表任何类型。换句话说,每一个类型其实都至少实现了一个空接口。
Go内建这样灵活的类型系统,向我们传达了一个很有价值的信息:空接口是通用类型。如果要解码一段未知结构的JSON,只需将这段JSON数据解码输出到一个空接口即可。在解码JSON数据的过程中,JSON数据里边的元素类型将做如下转换:
- JSON中的布尔值将会转换为Go中的bool类型;
- 数值会被转换为Go中的float64类型;
- 字符串转换后还是string类型;
- JSON数组会转换为[]interface{}类型;
- JSON对象会转换为map[string]interface{}类型;
- null值会转换为nil。
在Go的标准库encoding/json包中,允许使用map[string]interface{}和[]interface{}类型的值来分别存放未知结构的JSON对象或数组,示例代码如下:
b := []byte(`{
"Title": "Go区块链编程",
"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
"XuDaoli"],
"Publisher": "ituring.com.cn",
"IsPublished": true,
"Price": 9.99,
"Sales": 1000000
}`)
var r interface{}
err := json.Unmarshal(b, &r)
在上述代码中,r被定义为一个空接口。json.Unmarshal() 函数将一个JSON对象解码到空接口r中,最终r将会是一个键值对的map[string]interface{} 结构:
map[string]interface{}{
"Title": "Go区块链编程",
"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
"XuDaoli"],
"Publisher": "ituring.com.cn",
"IsPublished": true,
"Price": 9.99,
"Sales": 1000000
}
要访问解码后的数据结构,需要先判断目标结构是否为预期的数据类型:
gobook, ok := r.(map[string]interface{})
然后,我们可以通过for循环搭配range语句一一访问解码后的目标数据:
if ok {
for k, v := range gobook {
switch v2 := v.(type) {
case string:
fmt.Println(k, "is string", v2)
case int:
fmt.Println(k, "is int", v2)
case bool:
fmt.Println(k, "is bool", v2)
case []interface{}:
fmt.Println(k, "is an array:")
for i, iv := range v2 {
fmt.Println(i, iv)
}
default:
fmt.Println(k, "is another type not handle yet")
}
}
}
虽然有些烦琐,但的确是一种解码未知结构的JSON数据的安全方式。
4. JSON的流式读写
Go内建的encoding/json包还提供Decoder和Encoder两个类型,用于支持JSON数据的流式读写,并提供NewDecoder()和NewEncoder()两个函数来便于具体实现:
func NewDecoder(r io.Reader) *Decoder
func NewEncoder(w io.Writer) *Encoder
代码清单5-6从标准输入流中读取JSON数据,然后将其解码,但只保留Title字段(书名),再写入到标准输出流中。
package main
import (
"encoding/json"
"log"
"os"
)
func main() {
dec := json.NewDecoder(os.Stdin)
enc := json.NewEncoder(os.Stdout)
for {
var v map[string]interface{}
if err := dec.Decode(&v); err != nil {
log.Println(err)
return
}
for k := range v {
if k != "Title" {
v[k] = nil, false
}
}
if err := enc.Encode(&v); err != nil {
log.Println(err)
}
}
}
使用Decoder 和Encoder对数据流进行处理可以应用得更为广泛些,比如读写HTTP连接、WebSocket或文件等,Go的标准库net/rpc/jsonrpc就是一个应用了Decoder和Encoder的实际例子。