提纲
提纲
CH1
CH2 程序结构
CH2.1.命名
这一章主要讲了 Go 语言中的命名规范, 它是大小写敏感的, 以及避免使用关键字和预定义名字作为变量名
Go 中的大写字母开头的名字有着特殊含义
, 这种名字是导出的
,也就是说可以被外部包访问,例如 math.Pi
中的 Pi
就是一个导出的名字
以及 Go 推荐简短的驼峰式命名
CH2.2.声明
这一节讲了 Go 语言中的声明, 讲述了如下四种类型的声明的基本语法
- 变量声明
var
- 常量声明
const
- 类型声明
type
- 函数声明
func
CH2.3.变量
var 声明语句可以创建一个特定类型的变量, 然后给变量附加一个名字, 并且设置变量的初始值
变量声明的一般语法如下: var 变量名字 类型 = 表达式
可以省略 类型
或者 =表达式
- 如果省略的是类型信息, 那么将根据初始化表达式来推导变量的类型信息
- 如果初始化表达式被省略, 那么将用零值初始化该变量
- 数值类型变量对应的零值是0
- 布尔类型变量对应的零值是false
- 字符串类型对应的零值是空字符串
- 接口或引用类型(包括slice, 指针, map, chan和函数)变量对应的零值是nil
- 数组或结构体等聚合类型对应的零值是每个元素或字段都是对应该类型的零值
零值初始化机制可以确保每个声明的变量总是有一个良好定义的值, 因此 在Go语言中不存在未初始化的变量
以及支持同时声明一组变量, 比如
var i, j, k int // int, int, int
var b, f, s = true, 2.3, "four" // bool, float64, string
CH2.3.1 简短变量声明
在函数内部, 有一种称为简短变量声明语句的形式可用于声明和初始化局部变量, 它以 名字 := 表达式
形式声明变量, 变量的类型根据表达式来自动推导
对于已声明的变量, 单独使用 :=
会报错 "no new variables on left side of :="
, 此时可以使用 = 赋值
不过如果 :=
左侧有未声明的变量, 则可以这样用, 此时对于已声明的部分 :=
等效与 =
赋值
CH2.3.2 指针
一个指针的值是另一个变量的地址; 一个指针对应变量在内存中的存储位置; 通过指针, 我们可以直接读或更新对应变量的值, 而不需要知道该变量的名字(如果变量有名字的话)
如果用 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"
2.3.3 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 函数类似是一种语法糖, 而不是一个新的基础概念
2.3.4 变量的生命周期
变量的生命周期指的是在程序运行期间变量有效存在的时间段
对于在包一级声明的变量来说, 它们的生命周期和整个程序的运行周期是一致的
而相比之下, 局部变量的生命周期则是动态的: 每次从创建一个新变量的声明语句开始, 直到该变量不再被引用为止, 然后变量的存储空间可能被回收; 函数的参数变量和返回值变量都是局部变量, 它们在函数每次被调用的时候创建
从每个包级的变量和每个当前运行函数的每一个局部变量开始, 通过指针或引用的访问路径遍历, 是否可以找到该变量; 如果不存在这样的访问路径, 那么说明该变量是不可达的, 也就是说它是否存在并不会影响程序后续的计算结果
例如:
func example() {
// 创建一个整数变量a
a := 10
// 创建一个指向整数的指针变量p
p := &a
// 创建一个整数变量b
b := 20
// 将p重新指向b
p = &b
}
在p = &b这一行执行后,整个程序中没有任何变量或者指针指向它, 变量a就变成了不可达状态, 就会被Go语言的垃圾回收器回收
然后说明了编译器会自动选择在栈上还是在堆上分配局部变量的存储空间
堆
和 栈
是计算机内存管理中的两个常用术语, 用于描述内存中不同类型的分配方式
堆(Heep)
: 是一种动态的数据结构, 通常用于存储程序运行时动态分配的内存;堆上的内存可以在任意时刻分配和释放, 并没有固定的大小限制;
在堆上分配内存需要手动管理, 通常通过内存分配函数(如
malloc
或new
)来分配内存, 并通过相应的内存释放函数(如free
或delete
)来释放内存;堆上的变量的生命周期通常是由程序员显式地控制的
栈(Stack)
: 是一种线性的数据结构, 通常用于存储函数的局部变量和函数调用的状态当一个函数被调用时, 该函数的局部变量被存储在栈上, 函数的参数和返回地址也被压入栈中
当函数执行完毕时, 栈上的这些数据会被弹出, 栈的空间会被释放
栈的内存分配和释放都是由编译器自动管理的, 分配和释放操作都非常高效, 但栈的大小通常是固定的, 因此栈上的变量的生命周期也是固定的
Go 语言的自动垃圾收集器对编写正确的代码是一个巨大的帮助, 但也并不是说你完全不用考虑内存了;
你虽然不需要显式地分配和释放内存, 但是要编写高效的程序你依然需要了解变量的生命周期; 例如, 如果将指向短生命周期对象的指针保存到具有长生命周期的对象中, 特别是保存到全局变量时, 会阻止对短生命周期对象的垃圾回收(从而可能影响程序的性能)
CH2.4.赋值
可以使用 =
以及 *=
+=
来更新变量的值
自增和自减是语句, 而不是表达式, 因此
x = i++
之类的表达式是错误的
CH2.4.1 元组赋值
支持 x, y = y, x
和变量声明一样, 我们可以用下划线空白标识符_
来丢弃不需要的值:
_, err = io.Copy(dst, src) // 丢弃字节数
_, ok = x.(T) // 只检测类型, 忽略具体值
2.4.2 可赋值性
赋值语句是显式的赋值形式, 但是程序中还有很多地方会发生隐式的赋值行为
函数调用会隐式地将调用参数的值赋值给函数的参数变量
一个返回语句会隐式地将返回操作的值赋值给结果变量
一个复合类型的字面量(§4.2)也会产生赋值行为
medals := []string{"gold", "silver", "bronze"}
一些规则:
- 只有右边的值对于左边的变量是可赋值的, 赋值语句才是允许的
- 类型必须完全匹配
- nil 可以赋值给任何指针或引用类型的变量
- 常量(§3.6)则有更灵活的赋值规则, 因为这样可以避免不必要的显式的类型转换
CH2.5 类型
这一节讲了 Go 语言中的类型(type) 概念, 可以使用 type 类型名字 底层类型
来定义一个 type
然后举了摄氏度华氏度温度转换的例子, 定义了基于 float64 的摄氏度与华氏度类型并编写了二者间的转换函数
从这个示例也引出了当底层类型一致时, 类型之间是可以相互转换的
以及一些其他基本类型强制转换的原则, 例如
- 将一个浮点数转为整数将丢弃小数部分
- 将一个字符串转为
[]byte
类型的slice将拷贝一个字符串数据的副本
又举了如下例子
var c Celsius
var f Fahrenheit
fmt.Println(c == Celsius(f)) // "true"!
来演示底层类型相同时的强制类型转换, 不改变值只改变类型
对于其中的 func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
Sprintf
名字来源于 "String print format", 意思是将格式化的输出打印为字符串返回一个 string 类型的值
在 Go 语言中, 函数和方法是两个不同的概念
函数是一段独立的代码, 它可以接收一些参数, 执行一些操作, 然后返回一个或多个结果
函数不依赖于任何特定的类型或值
例如
func Add(a, b int) int
是一个函数, 它接收两个整数, 返回它们的和方法则是与特定类型关联的函数
方法的定义方式是在函数名前添加一个参数,这个参数定义了这个方法所属的类型
例如
func (c Celsius) String() string
是一个方法, 它属于 Celsius 类型当你在某个类型上定义了方法后, 你就可以在这个类型的值上调用这个方法。例如对于
c Celsius
调用c.String()
来获取c
的字符串表示
许多类型都会定义一个 String 方法, 因为当使用 fmt 包的打印方法时,将会优先使用该类型对应的 String 方法返回的结果打印
CH2.6 包和文件
这一节讲了Go 语言中的包和其他语言的库或模块的概念类似, 目的都是为了支持模块化, 封装, 单独编译和代码重用
一个包目录下会有多个 .go
源文件, 这些文件中大写字母开头的包级别的类型和常量在同一个包的其他源文件也是可以直接访问的
举了之前提到的摄氏度华氏度温度转换的例子, 把类型定义和转换函数分别放到了两个 go 文件中以展示这些包级别的名字对包内其他源文件也是可见的
此外还用到上一节中提到的每个 type 可以有一个 String 方法用于在使用 fmt 包的打印方法打印 type 实例时输出对应处理后的字符串
练习2.1
练习 2.1 练习了包级别名字包内可见, 仿照摄氏度华氏度定义和转换函数编写开尔文温度的定义和转换函数即可
CH2.6.1.导入包
这一节主要就是讲了从包外导入包可以 import 模块名/包路径
, 比如
import (
"GoLearning/Chapter/ch2/ch2_6_package_and_file/ex2_2/lengthconv"
"GoLearning/Chapter/ch2/ch2_6_package_and_file/ex2_2/tempconv"
"GoLearning/Chapter/ch2/ch2_6_package_and_file/ex2_2/weightconv"
"fmt"
"os"
"strconv"
)
以及可以在引入包的时候加个别名, 例如:
import alname "GoLearning/Chapter/ch2/ch2_6_package_and_file/ex2_2/lengthconv"
练习2.2
在原本的温度转换的基础上多写两个长度和重量转换的包, 然后写一个main 函数处理输入并用 switch case 来判断单位以及转换函数即可
新知识也就是 switch case 的语法:
switch s {
case "a":
// do something
case "b":
// do something
default:
// do something
}
除此以外还用到了命令行接收的 string 类型的参数可以使用 strconv.ParseFloat(number, 64)
来转换为 float64 类型, 用法类似于前面用到的 strconv.Atoi
用于转换整数
number := os.Args[1]
f, err := strconv.ParseFloat(number, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
CH2.6.2.包的初始化
这一节讲了 Go 语言中 package 中初始化的顺序:
- 首先是解决包级变量的依赖顺序,然后按照包级变量声明出现的顺序依次初始化
- 如果包中含有多个
.go
源文件,它们将按照发给编译器的顺序进行初始化,Go语言的构建工具首先会将.go
文件根据文件名排序(字典序),然后依次调用编译器编译 - 每个包在解决依赖的前提下,以导入声明的顺序初始化,每个包只会被初始化一次, 也就是说当有导入其他包的时候会先初始化其他包
- 每个Go语言的包都可以包含一个或多个init函数。这些函数在包初始化时会自动被调用,无需手动调用。init函数常常被用于执行一些初始化的工作,例如初始化包级别的变量。
- 初始化包级别(全局)的变量后会调用init函数(示例中的
pc[256]
即是先初始化为全0,然后调用init
函数将其初始化为一个表) - 初始化工作是自下而上进行的,main包最后被初始化, 这样可以确保在main函数执行之前,所有依赖的包都已经完成初始化工作了
然后举了一个求数字汉明权重的例子, 演示了先初始化全局变量然后调用 init 函数初始化变量的过程
练习2.3
练习 2.3 让重写PopCount函数,用一个循环代替单一的表达式。比较两个版本的性能
这里自定义了一个 popcount 包及其 PopCount
函数, 然后再 main package 中分别引入这两个自定义的包
这里使用了
import (
ex2_3_popcount "GoLearning/Chapter/ch2/ch2_6_package_and_file/ex2_3/popcount"
popcount "GoLearning/Chapter/ch2/ch2_6_package_and_file/popcount"
"fmt"
"time"
)
需要注意的是: 和一个包里 .go
源程序按照字典序初始化不同, 这里引入的两个包都要先于 main
初始化, 但是二者之间没有依赖关系, 无法确定谁先谁后; 这是因为Go语言的设计者选择让包的初始化并行进行,以提高程序启动的速度。因此,如果两个包没有依赖关系,它们可能会在不同的Goroutine中并行初始化,其完成的顺序取决于运行时的调度情况。
这里引入循环会增加额外的开销, 因此单一表达式的版本性能更好; 循环带来的开销可以是:
- 循环控制语句的开销:每次循环都需要进行条件检查,以确定是否继续执行循环
- 变量更新的开销:在循环中,通常会有一些变量需要在每次循环时更新(例如循环计数器)
- 函数调用的开销:如果在循环中调用了函数,那么每次函数调用都会有一定的开销,包括参数传递、返回值处理、栈帧管理等
练习2.4
用移位算法重写PopCount函数,每次测试最右边的1bit,然后统计总数。比较和查表算法的性能差异
延续 2.3 的性能验证策略, 再写个 64 次移位取最低位相加运算, 性能肯定远不如直接查表
// 用移位算法重写 PopCount 函数,每次测试最右边的1bit,然后统计总数
func PopCount(x uint64) int {
var count int
for i := 0; i < 64; i++ {
count += int(x & 1)
x >>= 1
}
return count
}
PS: 我发现练习的概括是没有必要写的, 要说的都写在文档里了, 不需要再总结了, 所以后续不写练习的提纲了
CH2.7.作用域
这一节讲述了作用域相关的知识; 首先指出, 虽然一个变量所在作用域执行完后他的生命周期基本也就结束了, 但是前者是个编译时的属性, 后者是个运行时的概念, 因此不能混为一谈
接下具体讲述了一些作用域的实例, 例如花括号包围的部分, 条件判断,循环语句覆盖的部分(if, for, switch) 都可以划定作用域范围
对于导入的包, 只能在导入的当前文件使用
像控制流符号, break, continue, goto 这样则是函数级的作用域, 不能 goto 到另一个函数里定义的控制流符号
接下来举了一些 "坏风格代码" 来讲述作用域范围, 循环和条件判断都有两个词法域, 一个在初始化, 条件那段隐式作用域, 一个在括弧内的显示作用域
所以这就引出了后续部分的内容, go 的短变量声明兼顾声明定义, 因此如果没理清作用域很可能导致内部定义的局部变量覆盖了本想赋值的外部变量, 因此对于多层嵌套的程序最好先声明后定义, 减少使用简短变量声明导致的隐形危害