mirror of
https://github.com/chai2010/advanced-go-programming-book.git
synced 2025-05-23 20:02:22 +00:00
commit
2da13f2e69
@ -21,7 +21,7 @@ var d = [...]int{1, 2, 4: 5, 6} // 定义长度为6的int型数组, 元素为 1,
|
||||
|
||||
第二种方式定义数组,可以在定义的时候顺序指定全部元素的初始化值,数组的长度根据初始化元素的数目自动计算。
|
||||
|
||||
第三种方式是以索引的方式来初始化数组的元素,因此元素的初始化值出现顺序比较随意。这种初始化方式和`map[int]Type`类型的初始化语法类似。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用0值初始化。
|
||||
第三种方式是以索引的方式来初始化数组的元素,因此元素的初始化值出现顺序比较随意。这种初始化方式和 `map[int]Type` 类型的初始化语法类似。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用零值初始化。
|
||||
|
||||
第四种方式是混合了第二种和第三种的初始化方式,前面两个元素采用顺序初始化,第三第四个元素零值初始化,第五个元素通过索引初始化,最后一个元素跟在前面的第五个元素之后采用顺序初始化。
|
||||
|
||||
@ -31,7 +31,6 @@ var d = [...]int{1, 2, 4: 5, 6} // 定义长度为6的int型数组, 元素为 1,
|
||||
|
||||
*图 1-7 数组布局*
|
||||
|
||||
|
||||
Go 语言中数组是值语义。一个数组变量即表示整个数组,它并不是隐式的指向第一个元素的指针(比如 C 语言的数组),而是一个完整的值。当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。
|
||||
|
||||
```go
|
||||
@ -165,7 +164,6 @@ type StringHeader struct {
|
||||
|
||||
*图 1-8 字符串布局*
|
||||
|
||||
|
||||
分析可以发现,“Hello, world”字符串底层数据和以下数组是完全一致的:
|
||||
|
||||
```go
|
||||
@ -375,7 +373,6 @@ type SliceHeader struct {
|
||||
|
||||
*图 1-10 切片布局*
|
||||
|
||||
|
||||
让我们看看切片有哪些定义方式:
|
||||
|
||||
```go
|
||||
@ -412,7 +409,6 @@ var (
|
||||
|
||||
如前所说,切片是一种简化版的动态数组,这是切片类型的灵魂。除了构造切片和遍历切片之外,添加切片元素、删除切片元素都是切片处理中经常遇到的问题。
|
||||
|
||||
|
||||
**添加切片元素**
|
||||
|
||||
内置的泛型函数 `append` 可以在切片的尾部追加 `N` 个元素:
|
||||
@ -421,7 +417,7 @@ var (
|
||||
var a []int
|
||||
a = append(a, 1) // 追加 1 个元素
|
||||
a = append(a, 1, 2, 3) // 追加多个元素, 手写解包方式
|
||||
a = append(a, []int{1,2,3}...) // 追加一个切片, 切片需要解包
|
||||
a = append(a, []int{1,2,3}...) // 追加 1 个切片, 切片需要解包
|
||||
```
|
||||
|
||||
不过要注意的是,在容量不足的情况下,`append` 的操作会导致重新分配内存,可能导致巨大的内存分配和复制数据代价。即使容量足够,依然需要用 `append` 函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。
|
||||
@ -520,7 +516,6 @@ a = a[:i+copy(a[i:], a[i+N:])] // 删除中间N个元素
|
||||
|
||||
比如下面的 `TrimSpace` 函数用于删除 `[]byte` 中的空格。函数实现利用了 0 长切片的特性,实现高效而且简洁。
|
||||
|
||||
|
||||
```go
|
||||
func TrimSpace(s []byte) []byte {
|
||||
b := s[:0]
|
||||
@ -549,7 +544,6 @@ func Filter(s []byte, fn func(x byte) bool) []byte {
|
||||
|
||||
切片高效操作的要点是要降低内存分配的次数,尽量保证 `append` 操作不会超出 `cap` 的容量,降低触发内存分配的次数和每次分配内存大小。
|
||||
|
||||
|
||||
**避免切片内存泄漏**
|
||||
|
||||
如前面所说,切片操作并不会复制底层的数据。底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟自动内存回收器对底层数组的回收。
|
||||
@ -592,7 +586,6 @@ a = a[:len(a)-1] // 从切片删除最后一个元素
|
||||
|
||||
当然,如果切片存在的周期很短的话,可以不用刻意处理这个问题。因为如果切片本身已经可以被 GC 回收的话,切片对应的每个元素自然也就是可以被回收的了。
|
||||
|
||||
|
||||
**切片类型强制转换**
|
||||
|
||||
为了安全,当两个切片类型 `[]T` 和 `[]Y` 的底层原始切片类型不同时,Go 语言是无法直接转换类型的。不过安全都是有一定代价的,有时候这种转换是有它的价值的——可以简化编码或者是提升代码的性能。比如在 64 位系统上,需要对一个 `[]float64` 切片进行高速排序,我们可以将它强制转为 `[]int` 整数切片,然后以整数的方式进行排序(因为 `float64` 遵循 IEEE754 浮点数标准特性,当浮点数有序时对应的整数也必然是有序的)。
|
||||
@ -631,4 +624,3 @@ func SortFloat64FastV2(a []float64) {
|
||||
第二种转换操作是分别取到两个不同类型的切片头信息指针,任何类型的切片头部信息底层都是对应 `reflect.SliceHeader` 结构,然后通过更新结构体方式来更新切片信息,从而实现 `a` 对应的 `[]float64` 切片到 `c` 对应的 `[]int` 类型切片的转换。
|
||||
|
||||
通过基准测试,我们可以发现用 `sort.Ints` 对转换后的 `[]int` 排序的性能要比用 `sort.Float64s` 排序的性能好一点。不过需要注意的是,这个方法可行的前提是要保证 `[]float64` 中没有 NaN 和 Inf 等非规范的浮点数(因为浮点数中 NaN 不可排序,正 0 和负 0 相等,但是整数中没有这类情形)。
|
||||
|
||||
|
@ -2,13 +2,12 @@
|
||||
|
||||
函数对应操作序列,是程序的基本组成元素。Go 语言中的函数有具名和匿名之分:具名函数一般对应于包级的函数,是匿名函数的一种特例,当匿名函数引用了外部作用域中的变量时就成了闭包函数,闭包函数是函数式编程语言的核心。方法是绑定到一个具体类型的特殊函数,Go 语言中的方法是依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的。Go 语言通过隐式接口机制实现了鸭子面向对象模型。
|
||||
|
||||
Go语言程序的初始化和执行总是从`main.main`函数开始的。但是如果`main`包导入了其它的包,则会按照顺序将它们包含进`main`包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量,再调用包里的`init`函数,如果一个包有多个`init`函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个`init`则是以出现的顺序依次调用(`init`不是普通函数,可以定义有多个,所以也不能被其它函数调用)。最后,当`main`包的所有包级常量、变量被创建和初始化完成,并且`init`函数被执行后,才会进入`main.main`函数,程序开始正常执行。下图是Go程序函数启动顺序的示意图:
|
||||
Go 语言程序的初始化和执行总是从 `main.main` 函数开始的。但是如果 `main` 包导入了其它的包,则会按照顺序将它们包含进 `main` 包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量,再调用包里的 `init` 函数,如果一个包有多个 `init` 函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个 `init` 则是以出现的顺序依次调用(`init` 不是普通函数,可以定义有多个,所以也不能被其它函数调用)。最后,当 `main` 包的所有包级常量、变量被创建和初始化完成,并且 `init` 函数被执行后,才会进入 `main.main` 函数,程序开始正常执行。下图是 Go 程序函数启动顺序的示意图:
|
||||
|
||||

|
||||
|
||||
*图 1-11 包初始化流程*
|
||||
|
||||
|
||||
要注意的是,在 `main.main` 函数执行之前所有代码都运行在同一个goroutine,也就是程序的主系统线程中。因此,如果某个 `init` 函数内部用 go 关键字启动了新的 goroutine 的话,新的 goroutine 只有在进入 `main.main` 函数之后才可能被执行到。
|
||||
|
||||
## 1.4.1 函数
|
||||
@ -143,7 +142,7 @@ func twice(x IntSliceHeader) {
|
||||
|
||||
因为切片中的底层数组部分是通过隐式指针传递(指针本身依然是传值的,但是指针指向的却是同一份的数据),所以被调用函数是可以通过指针修改掉调用参数切片中的数据。除了数据之外,切片结构还包含了切片长度和切片容量信息,这2个信息也是传值的。如果被调用函数中修改了 `Len` 或 `Cap` 信息的话,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是为何内置的 `append` 必须要返回一个切片的原因。
|
||||
|
||||
Go语言中,函数还可以直接或间接地调用自己,也就是支持递归调用。Go语言函数的递归调用深度逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为Go语言运行时会根据需要动态地调整函数栈的大小。每个goroutine刚启动时只会分配很小的栈(4或8KB,具体依赖实现),根据需要动态调整栈的大小,栈最大可以达到GB级(依赖具体实现,在目前的实现中,32位体系结构为250MB,64位体系结构为1GB)。在Go1.4以前,Go的动态栈采用的是分段式的动态栈,通俗地说就是采用一个链表来实现动态栈,每个链表的节点内存位置不会发生变化。但是链表实现的动态栈对某些导致跨越链表不同节点的热点调用的性能影响较大,因为相邻的链表节点它们在内存位置一般不是相邻的,这会增加CPU高速缓存命中失败的几率。为了解决热点调用的CPU缓存命中率问题,Go1.4之后改用连续的动态栈实现,也就是采用一个类似动态数组的结构来表示栈。不过连续动态栈也带来了新的问题:当连续栈动态增长时,需要将之前的数据移动到新的内存空间,这会导致之前栈中全部变量的地址发生变化。虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白Go语言中指针不再是固定不变的了(因此不能随意将指针保持到数值变量中,Go语言的地址也不能随意保存到不在GC控制的环境中,因此使用CGO时不能在C语言中长期持有Go语言对象的地址)。
|
||||
Go语言中,函数还可以直接或间接地调用自己,也就是支持递归调用。Go 语言函数的递归调用深度逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为 Go 语言运行时会根据需要动态地调整函数栈的大小。每个 goroutine 刚启动时只会分配很小的栈(4 或 8KB,具体依赖实现),根据需要动态调整栈的大小,栈最大可以达到 GB 级(依赖具体实现,在目前的实现中,32 位体系结构为 250MB,64 位体系结构为 1GB)。在 Go1.4 以前,Go 的动态栈采用的是分段式的动态栈,通俗地说就是采用一个链表来实现动态栈,每个链表的节点内存位置不会发生变化。但是链表实现的动态栈对某些导致跨越链表不同节点的热点调用的性能影响较大,因为相邻的链表节点它们在内存位置一般不是相邻的,这会增加 CPU 高速缓存命中失败的几率。为了解决热点调用的 CPU 缓存命中率问题,Go1.4 之后改用连续的动态栈实现,也就是采用一个类似动态数组的结构来表示栈。不过连续动态栈也带来了新的问题:当连续栈动态增长时,需要将之前的数据移动到新的内存空间,这会导致之前栈中全部变量的地址发生变化。虽然 Go 语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白 Go 语言中指针不再是固定不变的了(因此不能随意将指针保持到数值变量中,Go 语言的地址也不能随意保存到不在 GC 控制的环境中,因此使用 CGO 时不能在 C 语言中长期持有 Go 语言对象的地址)。
|
||||
|
||||
因为,Go 语言函数的栈会自动调整大小,所以普通 Go 程序员已经很少需要关心栈的运行机制的。在 Go 语言规范中甚至故意没有讲到栈和堆的概念。我们无法知道函数参数或局部变量到底是保存在栈中还是堆中,我们只需要知道它们能够正常工作就可以了。看看下面这个例子:
|
||||
|
||||
@ -162,7 +161,7 @@ func g() int {
|
||||
|
||||
## 1.4.2 方法
|
||||
|
||||
方法一般是面向对象编程(OOP)的一个特性,在C++语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。面向对象编程(OOP)进入主流开发领域一般认为是从C++开始的,C++就是在兼容C语言的基础之上支持了class等面向对象的特性。然后Java编程则号称是纯粹的面向对象语言,因为Java中函数是不能独立存在的,每个函数都必然是属于某个类的。
|
||||
方法一般是面向对象编程(OOP)的一个特性,在 C++ 语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是 Go 语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。面向对象编程进入主流开发领域一般认为是从 C++ 开始的,C++ 就是在兼容 C 语言的基础之上支持了 class 等面向对象的特性。然后 Java 编程则号称是纯粹的面向对象语言,因为 Java 中函数是不能独立存在的,每个函数都必然是属于某个类的。
|
||||
|
||||
面向对象编程更多的只是一种思想,很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。Go 语言的祖先 C 语言虽然不是一个支持面向对象的语言,但是 C 语言的标准库中的 File 相关的函数也用到了的面向对象编程的思想。下面我们实现一组 C 语言风格的 File 函数:
|
||||
|
||||
@ -320,11 +319,11 @@ func (p *Cache) Lookup(key string) string {
|
||||
|
||||
`Cache`结构体类型通过嵌入一个匿名的 `sync.Mutex` 来继承它的 `Lock` 和 `Unlock` 方法. 但是在调用 `p.Lock()` 和 `p.Unlock()` 时, `p` 并不是 `Lock` 和 `Unlock` 方法的真正接收者, 而是会将它们展开为 `p.Mutex.Lock()` 和 `p.Mutex.Unlock()` 调用. 这种展开是编译期完成的, 并没有运行时代价.
|
||||
|
||||
在传统的面向对象语言(eg.C++或Java)的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的`this`可能不是基类类型对应的对象,这个特性会导致基类方法运行的不确定性。而在Go语言通过嵌入匿名的成员来“继承”的基类方法,`this`就是实现该方法的类型的对象,Go语言中方法是编译时静态绑定的。如果需要虚函数的多态特性,我们需要借助Go语言接口来实现。
|
||||
在传统的面向对象语言(eg.C++ 或 Java)的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的 `this` 可能不是基类类型对应的对象,这个特性会导致基类方法运行的不确定性。而在 Go 语言通过嵌入匿名的成员来“继承”的基类方法,`this` 就是实现该方法的类型的对象,Go 语言中方法是编译时静态绑定的。如果需要虚函数的多态特性,我们需要借助 Go 语言接口来实现。
|
||||
|
||||
## 1.4.3 接口
|
||||
|
||||
Go语言之父Rob Pike曾说过一句名言:那些试图避免白痴行为的语言最终自己变成了白痴语言(Languages that try to disallow idiocy become themselves idiotic)。一般静态编程语言都有着严格的类型系统,这使得编译器可以深入检查程序员有没有作出什么出格的举动。但是,过于严格的类型系统却会使得编程太过繁琐,让程序员把大好的青春都浪费在了和编译器的斗争中。Go语言试图让程序员能在安全和灵活的编程之间取得一个平衡。它在提供严格的类型检查的同时,通过接口类型实现了对鸭子类型的支持,使得安全动态的编程变得相对容易。
|
||||
Go 语言之父 *Rob Pike* 曾说过一句名言:那些试图避免白痴行为的语言最终自己变成了白痴语言(Languages that try to disallow idiocy become themselves idiotic)。一般静态编程语言都有着严格的类型系统,这使得编译器可以深入检查程序员有没有作出什么出格的举动。但是,过于严格的类型系统却会使得编程太过繁琐,让程序员把大好的青春都浪费在了和编译器的斗争中。Go 语言试图让程序员能在安全和灵活的编程之间取得一个平衡。它在提供严格的类型检查的同时,通过接口类型实现了对鸭子类型的支持,使得安全动态的编程变得相对容易。
|
||||
|
||||
Go 的接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让对象更加灵活和更具有适应能力。很多面向对象的语言都有相似的接口概念,但 Go 语言中接口类型的独特之处在于它是满足隐式实现的鸭子类型。所谓鸭子类型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子。Go 语言中的面向对象就是如此,如果一个对象只要看起来像是某种接口类型的实现,那么它就可以作为该接口类型使用。这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义;当我们使用的类型来自于不受我们控制的包时这种设计尤其灵活有用。Go 语言的接口类型是延迟绑定,可以实现类似虚函数的多态功能。
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
在早期,CPU 都是以单核的形式顺序执行机器指令。Go 语言的祖先 C 语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个 CPU 在顺序执行程序的指令。
|
||||
|
||||
随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的CPU频率基本被锁定在了3GHZ附近。单核CPU的发展的停滞,给多核CPU的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。
|
||||
随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的 CPU 频率基本被锁定在了 3Ghz 附近。单核 CPU 的发展的停滞,给多核 CPU 的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go 语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。
|
||||
|
||||
常见的并行编程有多种模型,主要有多线程、消息传递等。从理论上来看,多线程和基于消息的并发编程是等价的。由于多线程并发模型可以自然对应到多核的处理器,主流的操作系统因此也都提供了系统级的多线程支持,同时从概念上讲多线程似乎也更直观,因此多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对基于消息的并发编程模型支持则相比较少,Erlang 语言是支持基于消息传递并发编程模型的代表者,它的并发体之间不共享内存。Go 语言是基于消息并发模型的集大成者,它将基于 CSP 模型的并发编程内置到了语言中,通过一个 go 关键字就可以轻易地启动一个 Goroutine,与 Erlang 不同的是 Go 语言的 Goroutine 之间是共享内存的。
|
||||
|
||||
@ -209,7 +209,6 @@ func main() {
|
||||
|
||||
因此,如果在一个 Goroutine 中顺序执行 `a = 1; b = 2;` 两个语句,虽然在当前的 Goroutine 中可以认为 `a = 1;` 语句先于 `b = 2;` 语句执行,但是在另一个 Goroutine 中 `b = 2;` 语句可能会先于 `a = 1;` 语句执行,甚至在另一个 Goroutine 中无法看到它们的变化(可能始终在寄存器中)。也就是说在另一个 Goroutine 看来, `a = 1; b = 2;`两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。比如下面这个程序:
|
||||
|
||||
|
||||
```go
|
||||
func main() {
|
||||
go println("你好, 世界")
|
||||
@ -269,8 +268,7 @@ Go程序的初始化和执行总是从`main.main`函数开始的。但是如果`
|
||||
|
||||
## 1.5.5 Goroutine的创建
|
||||
|
||||
`go`语句会在当前Goroutine对应函数返回前创建新的Goroutine. 例如:
|
||||
|
||||
`go` 语句会在当前 Goroutine 对应函数返回前创建新的 Goroutine。例如:
|
||||
|
||||
```go
|
||||
var a string
|
||||
@ -289,8 +287,7 @@ func hello() {
|
||||
|
||||
## 1.5.6 基于 Channel 的通信
|
||||
|
||||
Channel通信是在Goroutine之间进行同步的主要方法。在无缓存的Channel上的每一次发送操作都有与其对应的接收操作相配对,发送和接收操作通常发生在不同的Goroutine上(在同一个Goroutine上执行2个操作很容易导致死锁)。**无缓存的Channel上的发送操作总在对应的接收操作完成前发生.**
|
||||
|
||||
Channel 通信是在 Goroutine 之间进行同步的主要方法。在无缓存的 Channel 上的每一次发送操作都有与其对应的接收操作相配对,发送和接收操作通常发生在不同的 Goroutine 上(在同一个 Goroutine 上执行两个操作很容易导致死锁)。**无缓存的 Channel 上的发送操作总在对应的接收操作完成前发生.**
|
||||
|
||||
```go
|
||||
var done = make(chan bool)
|
||||
|
@ -1,4 +1,3 @@
|
||||
## 1.8 补充说明
|
||||
|
||||
本书定位是Go语言进阶图书,因此读者需要有一定的Go语言基础。如果对Go语言不太了解,作者推荐通过以下资料开始学习Go语言。首先是安装Go语言环境,然后通过`go tool tour`命令打开“A Tour of Go”教程学习。在学习“A Tour of Go”教程的同时,可以阅读Go语言官方团队出版的[《The Go Programming Language》](http://www.gopl.io/)教程。[《The Go Programming Language》](http://www.gopl.io/)在国内Go语言社区被称为Go语言圣经,它将带你系统地学习Go语言。在学习的同时可以尝试用Go语言解决一些小问题,如果遇到要查阅API的时候可以通过godoc命令打开自带的文档查询。Go语言本身不仅仅包含了所有的文档,也包含了所有标准库的实现代码,这是第一手的最权威的Go语言资料。我们认为此时你应该已经可以熟练使用Go语言了。
|
||||
|
||||
本书定位是 Go 语言进阶图书,因此读者需要有一定的 Go 语言基础。如果对 Go 语言不太了解,作者推荐通过以下资料开始学习 Go 语言。首先是安装 Go 语言环境,然后通过 `go tool tour` 命令打开“A Tour of Go”教程学习。在学习“A Tour of Go”教程的同时,可以阅读 Go 语言官方团队出版的[《The Go Programming Language》](http://www.gopl.io/)教程。[《The Go Programming Language》](http://www.gopl.io/)在国内 Go 语言社区被称为 Go 语言圣经,它将带你系统地学习 Go 语言。在学习的同时可以尝试用 Go 语言解决一些小问题,如果遇到要查阅 API 的时候可以通过 `godoc` 命令打开自带的文档查询。Go 语言本身不仅仅包含了所有的文档,也包含了所有标准库的实现代码,这是第一手的最权威的 Go 语言资料。我们认为此时你应该已经可以熟练使用 Go 语言了。
|
||||
|
Loading…
x
Reference in New Issue
Block a user