大家好,这里是白泽。 《Go语言的100个错误以及如何避免》 是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“Go: Simple to learn but hard to master”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。
我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第二篇文章,对应书中第11-20个错误场景。
当然,如果您是一位 Go 学习的新手,您可以在我开源的学习仓库中,找到针对 《Go 程序设计语言》 英文书籍的配套笔记,期待您的 star。
公众号【白泽talk】,聊天交流群:622383022,原书电子版可以加群获取。
前文链接:
《Go语言的100个错误使用场景(1-10)|代码和项目组织》
章节概述:
- 主流的代码组织方式
- 高效的抽象:接口和范型
- 构建项目的最佳实践
当设计一个 API 的时候,如何处理可选的配置项输入项是一个问题。下面先展示一些不好的例子。假设场景是创建一个 HTTP Server 服务,需要输入 IP 地址,端口等信息,但同时需要提供默认值,当不传入的时候也可以工作。
反例1(直接包含所有,配置参数):
func NewServer(addr string, port int) (*http.Server, error) {
// ...
}
直接在参数列表罗列各种参数,然后在内部依次处理,但是使用时必须传入所有参数。
反例2(Config 存放可选参数):
package httplib
type Config struct {
// 使用引用类型则未传入int则是nil,否则会和0值混淆
Port *int
}
func NewServer(addr string, cfg Confg) {
}
--------------------------------------------------
func main() {
port := 0
config := httplib.Config{
Port: &port,
}
httplib.NewServer("localhost", config)
}
这种方式允许用户将可选的配置参数通过 Config 存放,然后在 NewServer 方法的内部读取 Config 结构的字段去初始化,但是有两个问题:
httplib.NewServer("localhost", httplib.Config{})
反例3(建造者模式):
package httplib
type Config struct {
Port int
}
type ConfigBuilder sruct {
port *int
}
func (b *ConfigBuilder) Port(port int) *ConfigBuilder {
b.port = &port
return b
}
func (b *ConfigBuilder) Build() (Config, error) {
cfg := Config{}
if b.port == nil {
cfg.Port = defaultHTTPPort
} else {
if *b.port == 0 {
cfg.Port = randomPort()
} else if *b.port < 0 {
return Config{}, errors.New("port should be positive")
} else {
cfg.Port = *b.port
}
}
reutrn cfg, nil
}
func NewServer(addr string, config Config) (http.Server, error) {
// ...
}
----------------------------------------------
// 用法
func main() {
builder := httplib.ConfigBuilder{}
builder.Port(8080)
cfg, err := builder.Build()
if err != nil {
// ...
}
server, err := httplib.NewServer("localhost", cfg)
if err != nil {
// ...
}
}
这种写法下仍然有两个问题:
server, err := httplib.NewServer("localhost", nil)
cfg, err := builder.Foo("foo").Bar("bar").Build()
推荐方法(函数式配置选项模式),核心思想如下:
代码展示:
package httplib
type options struct {
port *int
}
type Option func(options *options) error
func WithPort(port int) Option {
return func(options *options) error {
if port < 0 {
return errors.New("port should be positive")
}
options.port = &port
return nil
}
}
WithPort 接受一个 port 参数代表端口号,返回一个给 options 设置端口号的函数。这种形式的函数本质是一个匿名的闭包,持有外部的 options 配置集合。
func NewServer(addr string, opts ...Option) (*http.Server, error) {
// 初始化配置集合 options
var options options
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
// 针对配置字段内容,添加验证需要的逻辑
var port int
if options.port == nil {
port = defaultHTTPPort
} else {
if *options.port == 0 {
port = randomPort()
} else {
port = *options.port
}
}
}
---------------------------------------------
// 用法
int main() {
server, err := httplib.NewServer("localhost", httplib.WithPort(8080), httplib.WithTimeout(time.second))
}
在 NewServer 内通过循环将 Option 的配置通过函数调用应用到 options 集合中,然后在编写针对 options 配置字段的验证逻辑,因为所有可选的 Option 都是外部传入的,NewServer 内需要为其进行二次校验。
这种方式也是 Go 的地道用法,在很多开源项目如 gRPC 中都大量使用。
Go 语言是一个自由的语言,并不强制要求你选择某一种组织项目的模板,但是你需要为此行动。一个常见的模板展示:
/cmd # 主要的源代码位置,foo应用的入口文件位于 /cmd/foo/main.go
/internal # 内部使用的代码,不希望被导出使用
/pkg # 公共的代码,希望被导出
/test # 额外的外部测试代码和测试数据,Go的单测应该和源代码在同一个package内,但是集成测试等代码需要在这个目录
/configs # 配置文件
/docs # 设计文档和用户手册
/examples # 项目的使用示例代码
/api # API 文件,例如 Swagger,PB等
/web # Web应用拥有的资源文件,如静态文件等
/build # 打包和持续集成文件
/scripts # 各种脚本
/vendor # 当前项目的依赖文件
没有 src/
目录因为它太泛用了,从而将其细分成了上述的各个目录。但这只是一个参考。
package
的组织方式:
/net
/http
client.go
...
/smtp
auth.go
...
addrselect.go
...
这是 Go 标准库中 net 包的组织结构,虽然 /http
位于 /net
之后,但是 net/http
这个包只能访问 net 包中被导出的内容(大写开头),使用子目录这种组织结构是为了使相关功能的包聚集在一起管理。
选择根据上下文进行组织项目还是分层组织项目都可以,只要你可以确保项目清晰:
最佳实践:
一种常见的不好的实践:创建共享的包如 utils,common & base。
代码展示:
package util
func NewStringSet(...string) map[string]struct{} {
// ...
}
func SortStringSet(map[string]struct{}) []string {
// ...
}
-----------------------------------------
// 用法
func main() {
set := util.NewStringSet("a", "b", "c")
fmt.Println(util.SortStringSet(set))
}
工具包内的两个函数实现了创建 string 集合和针对 key 进行排序输出的函数,但是此处包命名为 util 则没有任何意义,完全可以替换成 common,shared…
代替方案:
package stringset
type Set map[string]struct{}
func New(...string) Set {...}
func (s Set) Sort() []string {...}
-----------------------------------------
// 用法
set := stringset.New("a", "b", "c")
fmt.Println(set.Sort())
用 stringset 代替 util 这个包的名称,使其更具表达性。同时将方法的前缀去除,用一个结构 Set 去接收 Sort 方法,将所有逻辑内聚在一个用途明确的 stringset 包中。调用侧使用也收到了明确约束,更加方便。
示例代码:
package redis
type Client struct {...}
func NewClient() *Client {...}
func (c *Client) Get(key string) (string, error) {...}
----------------------------------------------------
// 冲突的场景
func main() {
redis := redis.NewClient()
v, err := redis.Get("Foo")
}
在这种场景下,虽然 redis 变量现在是可以工作的,但它本质是变量,会被修改。但是 redis 包将无法再在代码中访问。
func main() {
redisClient := redis.NewClient()
v, err := redisClient.Get("Foo")
}
修改变量名称,避免冲突。
import redisapi "mylib/redis"
func main() {
redis := redis.NewClient()
v, err := redis.Get("Foo")
}
通过给 import 的 redis 包起别名的方式,避免与变量名的冲突,这是一种更推荐的做法。
Tips:变量名的创建要避免与内置关键字或者函数同名
文档对于项目的开发者和使用者都十分重要,这里给出几个法则:
// Customer is a customer representation
type Customer struct
// ID returns the customer identifier
func (c Customer) ID() string {...}
.
结尾。并且针对于描述对象的功能,而不是如何实现。确保提供足够的描述信息,使得用户无需阅读代码即可使用。// ComputePath returns the fastset path between two points
// Deprecated: This function uses a deprecated way to compute
// the fastest Path. Use ComputeFastestPath instead. func ComputePath () {}
func ComputePath() {}
// Package math provides basic constants and mathmatical functions
//
// This package ...
package math
第一行需要简洁,因为在文档中会展现:
Linter 是一个自动化代码分析工具,可以帮助我们分析代码,找到潜在的错误。所以 Code Linter 也是持续集成中必不可少的一环。
go vet: Go 内置的静态代码检查工具。
go vet ./...
Linters: 可以是外部工具,如 Golint、GolangCI-Lint、Staticcheck 等,它们通过外部安装,并提供更多的规则和功能。
通常情况下,建议同时使用 go vet
和 linters 以确保代码的质量和一致性。
示例代码:
func main() {
unusedVariable := 42 // 未使用的变量
}
运行 go vet ./...
:
baize@baizedeMacBook-Air mistakes % go vet ./...
# mistake
vet: ./main.go:4:2: unusedVariable declared and not used
章节概述:
- 基本类型涉及的常见错误
- 掌握 slice 和 map 的基本概念,避免使用时产生 bug
- 值的比较
在 Go 当中,以0字面量开头的数值表示8进制,因此:
sum := 100 + 010 // 结果为108
但是8进制也有其发挥作用的场景,如赋予文件对应 Linux 系统的权限:
file, err := os.OpenFile("foo", os.O_RDONLY, 0644)
// 0644可以替换成0o644或者0O644
Go 语言中的不同进制表示:
Go 语言中支持用下划线作为数值的分隔符,提高可读性:
1_000_000_000 // 一百万
0b00_00_01 // 二进制也是可以的
常见的整型类型:
整型溢出场景:
var counter int32 = math.MaxInt32
counter++
fmt.Printf("counter=%d\n", counter)
整个程序编译和运行不会报错,但是:
counter = -2147483648 // counter++ 导致int32溢出
计算机存储有符号整数用二进制表示:
针对有符号整数,第一位为符号位,0表示正数,1表示负数,全0表示0(约定)。
比如上述 int32 有符号整型最大值+1(左侧图片),得到右侧图片(是一个负数)。
计算时使用补码:正数的补码等于原码,负数的补码等于原码除去符号位外,所有位数取反,最后整体+1。
举例 int8 计算 8 + (-8) = 0 用补码的表示:
00001000 + 10001000 // 原码
00001000 + 11111000 = 1|00000000 // 补码,左侧0溢出,因为只有8位,得到0
如果希望手动检测整型溢出,这是一些模板代码:
func Inc32(counter int32) int32 {
if counter == math.MaxInt32 {
panic("int32 overflow")
}
return counter+1
}
func addInt(a, b int) int {
if a > math.MaxInt-b {
panic("int overflow")
}
return a + b
}
func MultiplInt(a, b int) int {
if a == 0 || b == 0 {
return 0
}
result := a * b
if a == 1 || b == 1 {
return result
}
if a == math.MinInt || b == math.MinInt {
panic("integer overflow")
}
if result/b != a {
panic("integer overflow")
}
return result
}
浮点数在计算机中的存储通常遵循 IEEE 754 标准,该标准定义了单精度和双精度浮点数的表示方式。
单精度浮点数(32位):
单精度浮点数的存储结构如下:
SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
具体数值表示为:(-1)^S * 1.M * 2^(E-127)
双精度浮点数(64位):
双精度浮点数的存储结构如下:
SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM
具体数值表示为:(-1)^S * 1.M * 2^(E-1023)
需要注意的是,由于二进制浮点数的特性,某些十进制小数可能无法精确表示,会有舍入误差。在编程中,特别是涉及金融等需要高精度的领域,需要小心处理浮点数的精度问题。
举例 1.0001 在单精度和双精度下的存储:
单精度(32位):
0 01111111 00010010000111101011100
具体数值表示为:(-1)^0 * 1.00010010000111101011100 * 2^(0-127) ≈ 1.0001
双精度(64位):
0 01111111111 0001001000011110101110000101000111101011100010100011111
具体数值表示为:(-1)^0 * 1.0001001000011110101110000101000111101011100010100011111 * 2^(0-1023) ≈ 1.0001
浮点数的使用准则:
Go 的切片本质上是一个结构体,包含一个指向数组的指针,以及两个变量记录长度和容量。
切片创建于扩容场景分析:
s := make([]int, 3, 6) // 创建长度为3容量为6的int类型的切片
s[1] = 1 // 赋值s[1]=1
访问切片大于长度范围的位置将触发 panic:
panic: runtime error: index out of range [4] with length 3
在 len 小于 cap 的时候,可以直接向切片添加元素:
s = append(s, 2)
此时切片的 len 自动增加到4,因为容量足够,不会触发切片的扩容,但如果添加的元素数量超过 cap 的限制,则会触发切片的扩容:
s = append(s, 3, 4, 5)
fmt.Println(s) // 得到:[0 1 0 2 3 4 5]
当添加5的时候,原底层数组的容量达到上限6,触发2倍扩容(创建新数组),拷贝原切片内容到新数组,并追加5。
大致的 Go 切片扩容规则:容量1024以下双倍扩容,以上扩容25%
原底层数组因为丢失了引用,如果在堆内存内,会被后续的 Go GC 回收(GC 相关场景将在全书后期讲解)。
切片截取场景分析:
s1 := make([]int, 3, 6) // 长度为3,容量为6
s2 := s1[1:3] // 从s1的索引1-3截取,左闭右开,此时s2的长度为2,容量是5
此时如果更新 s1[1] 或者 s2[0] 为1,则由于共享底层数组的原因,导致另一方可以读取到变更内容。
如果追加内容,则底层共享的数组追加2。此时 s2 的 len 变为3,但是 s1 的 len 依旧为3。
并且此时打印两个切片得到的内容如下:
s1 = [0 1 0], s2 = [1 0 2]
如果继续向 s2 追加元素直到 s2 的长度超过容量,则会触发扩容,创建新底层数组(二倍):
s2 = append(s2, 3)
s2 = append(s2, 4)
s2 = append(s2, 5)
已完成全书学习进度20/100,再接再厉。