Go语言的100个错误使用场景(11-20)|项目组织和数据类型

前言

大家好,这里是白泽。 《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)|代码和项目组织》

2. Code and project organization

🌟 章节概述:

  • 主流的代码组织方式
  • 高效的抽象:接口和范型
  • 构建项目的最佳实践

2.11 没有使用函数式选项模式(#11)

当设计一个 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 := 0config := httplib.Config{Port: &port,}httplib.NewServer("localhost", config)
}

这种方式允许用户将可选的配置参数通过 Config 存放,然后在 NewServer 方法的内部读取 Config 结构的字段去初始化,但是有两个问题:

  1. 随着可选参数的增多,NewServer 内的初始化逻辑将无限扩大。
  2. 如果用户 Config 整个都选择默认参数,则必须传入一个空的 Config{},使得用户需要对 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 = &portreturn 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 {// ...}
}

这种写法下仍然有两个问题:

  1. 当可选参数希望使用默认值的时候,需要传递 nil(虽然已经比示例2进步了)。
server, err := httplib.NewServer("localhost", nil)
  1. 使用建造者模式链式创建过程中,只允许返回一个参数。一旦过程中发生错误,为了确保链式调用继续,即使需要错误处理也只能内聚的具体一个个方法内部,并不能将 err 传递出来。从而只能在 Builder 方法内验证可能发生的错误,使得 err 的处理成本大大提高。
cfg, err := builder.Foo("foo").Bar("bar").Build()

🌟 推荐方法(函数式配置选项模式),核心思想如下:

  1. 用一个不对外倒出的结构存放配置:options
  2. 每一个 option 是一个函数返回同一个结构:Option

代码展示:

package httplib
​
type options struct {port *int
}type Option func(options *options) errorfunc WithPort(port int) Option {return func(options *options) error {if port < 0 {return errors.New("port should be positive")}options.port = &portreturn nil}
}

WithPort 接受一个 port 参数代表端口号,返回一个给 options 设置端口号的函数。这种形式的函数本质是一个匿名的闭包,持有外部的 options 配置集合。

func NewServer(addr string, opts ...Option) (*http.Server, error) {// 初始化配置集合 optionsvar options optionsfor _, opt := range opts {err := opt(&options)if err != nil {return nil, err}}// 针对配置字段内容,添加验证需要的逻辑var port intif 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 中都大量使用。

2.12 项目缺乏组织(#12)

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/httpclient.go.../smtpauth.go...addrselect.go...

这是 Go 标准库中 net 包的组织结构,虽然 /http 位于 /net 之后,但是 net/http 这个包只能访问 net 包中被导出的内容(大写开头),使用子目录这种组织结构是为了使相关功能的包聚集在一起管理。

🌟 选择根据上下文进行组织项目还是分层组织项目都可以,只要你可以确保项目清晰:

  • 按上下文:将代码分成 customer,constract 等等模块。
  • 六边形架构(DDD):按功能进行分层。

🌟 最佳实践:

  1. 避免为时过早的 package 创建,允许演化,而不是一直遵守一开始的强制规划。
  2. 避免产生大量细粒度的 package,只包含个别文件。当然过大也是一个问题。
  3. 包的命名需要根据它提供的功能出发,用一个小写单词表示。
  4. 最小化包需要导出的内容,减少耦合,不确定就先不导出。
  5. 代码库的编码风格一致性。

2.13 创建公共设施包(#13)

一种常见的不好的实践:创建共享的包如 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 包中。调用侧使用也收到了明确约束,更加方便。

2.14 忽略包名的冲突(#14)

示例代码:

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:变量名的创建要避免与内置关键字或者函数同名

2.15 代码文档缺失(#15)

文档对于项目的开发者和使用者都十分重要,这里给出几个法则:

  1. 为每一个导出的对象都配备文档(通过注释)
// Customer is a customer representation
type Customer struct// ID returns the customer identifier
func (c Customer) ID() string {...}
  1. 注释需要是一个完整的句子,以.结尾。并且针对于描述对象的功能,而不是如何实现。确保提供足够的描述信息,使得用户无需阅读代码即可使用。
  2. 针对废弃的 API 使用注释:
// 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() {}
  1. 为 package 添加文档:
// Package math provides basic constants and mathmatical functions
//
// This package ...
package math

第一行需要简洁,因为在文档中会展现:

image-20240130150051245

2.16 不使用 code-linter(#16)

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

3. Data types

🌟 章节概述:

  • 基本类型涉及的常见错误
  • 掌握 slice 和 map 的基本概念,避免使用时产生 bug
  • 值的比较

3.1 八进制产生的混乱局面(#17)

在 Go 当中,以0字面量开头的数值表示8进制,因此:

sum := 100 + 010 // 结果为108

但是8进制也有其发挥作用的场景,如赋予文件对应 Linux 系统的权限:

file, err := os.OpenFile("foo", os.O_RDONLY, 0644)
// 0644可以替换成0o644或者0O644

Go 语言中的不同进制表示:

  • 二进制:使用 0b 或者 0B 为前缀
  • 十六进制:使用0x 或者 0X 为前缀
  • 虚数:使用 i 为后缀

Go 语言中支持用下划线作为数值的分隔符,提高可读性:

1_000_000_000 // 一百万
0b00_00_01 // 二进制也是可以的

3.2 忽略整型溢出(#18)

常见的整型类型:

image-20240131112800057

整型溢出场景:

var counter int32 = math.MaxInt32
counter++
fmt.Printf("counter=%d\n", counter)

整个程序编译和运行不会报错,但是:

counter = -2147483648 // counter++ 导致int32溢出

计算机存储有符号整数用二进制表示:

image-20240131114037048

针对有符号整数,第一位为符号位,0表示正数,1表示负数,全0表示0(约定)。

比如上述 int32 有符号整型最大值+1(左侧图片),得到右侧图片(是一个负数)。

计算时使用补码:正数的补码等于原码,负数的补码等于原码除去符号位外,所有位数取反,最后整体+1。

举例 int8 计算 8 + (-8) = 0 用补码的表示:

00001000 + 10001000 // 原码
00001000 + 11111000 = 100000000 // 补码,左侧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 * bif 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
}

3.3 不理解浮点数(#19)

浮点数在计算机中的存储通常遵循 IEEE 754 标准,该标准定义了单精度和双精度浮点数的表示方式。

  1. 单精度浮点数(32位):

    • 符号位:1位
    • 指数位:8位
    • 尾数位:23位

    单精度浮点数的存储结构如下:

    SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
    
    • S:符号位,表示正负。
    • E:指数位,以偏移值(127)存储,范围为-126到+127。
    • M:尾数位,23位精度。

    具体数值表示为:(-1)^S * 1.M * 2^(E-127)

  2. 双精度浮点数(64位):

    • 符号位:1位
    • 指数位:11位
    • 尾数位:52位

    双精度浮点数的存储结构如下:

    SEEEEEEE EEEEMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM MMMMMMMM
    
    • S:符号位,表示正负。
    • E:指数位,以偏移值(1023)存储,范围为-1022到+1023。
    • M:尾数位,52位精度。

    具体数值表示为:(-1)^S * 1.M * 2^(E-1023)

需要注意的是,由于二进制浮点数的特性,某些十进制小数可能无法精确表示,会有舍入误差。在编程中,特别是涉及金融等需要高精度的领域,需要小心处理浮点数的精度问题。

🌟 举例 1.0001 在单精度和双精度下的存储:

  1. 单精度(32位):

    0 01111111 00010010000111101011100
    
    • 符号位:0(正数)
    • 指数位:01111111(127的二进制表示,表示偏移值为0)
    • 尾数位:00010010000111101011100(23位精度)

    具体数值表示为:(-1)^0 * 1.00010010000111101011100 * 2^(0-127) ≈ 1.0001

  2. 双精度(64位):

    0 01111111111 0001001000011110101110000101000111101011100010100011111
    
    • 符号位:0(正数)
    • 指数位:01111111111(1023的二进制表示,表示偏移值为0)
    • 尾数位:0001001000011110101110000101000111101011100010100011111(52位精度)

    具体数值表示为:(-1)^0 * 1.0001001000011110101110000101000111101011100010100011111 * 2^(0-1023) ≈ 1.0001

🌟 浮点数的使用准则:

  1. 比较大小需要在合理精度范围内
  2. 运算时注意相似精度优先运算,减少精度波动

3.4 不理解切片长度和容量(#20)

Go 的切片本质上是一个结构体,包含一个指向数组的指针,以及两个变量记录长度和容量。

🌟 切片创建于扩容场景分析:

s := make([]int, 3, 6) // 创建长度为3容量为6的int类型的切片
s[1] = 1 // 赋值s[1]=1

image-20240131123853071

访问切片大于长度范围的位置将触发 panic:

panic: runtime error: index out of range [4] with length 3

在 len 小于 cap 的时候,可以直接向切片添加元素:

s = append(s, 2)

image-20240131124253144

此时切片的 len 自动增加到4,因为容量足够,不会触发切片的扩容,但如果添加的元素数量超过 cap 的限制,则会触发切片的扩容:

s = append(s, 3, 4, 5)
fmt.Println(s) // 得到:[0 1 0 2 3 4 5]

image-20240131124546956

当添加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

image-20240131125818202

此时如果更新 s1[1] 或者 s2[0] 为1,则由于共享底层数组的原因,导致另一方可以读取到变更内容。

如果追加内容,则底层共享的数组追加2。此时 s2 的 len 变为3,但是 s1 的 len 依旧为3。

image-20240131130436982

并且此时打印两个切片得到的内容如下:

s1 = [0 1 0], s2 = [1 0 2]

如果继续向 s2 追加元素直到 s2 的长度超过容量,则会触发扩容,创建新底层数组(二倍):

s2 = append(s2, 3)
s2 = append(s2, 4)
s2 = append(s2, 5)

image-20240131130649349

小结

已完成全书学习进度20/100,再接再厉。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/449526.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

嵌入式系统学习(一)

嵌入式现状&#xff08;UP经历&#xff09;&#xff1a; 大厂的招聘要求&#xff1a; 技术栈总结&#xff1a; 产品拆解网站&#xff1a; 52audio 方案查询网站iotku,我爱方案网&#xff0c; 主要元器件类型&#xff1a;

冰冻天气恰逢春运,“观冰精灵”化身电力供应守护者

据中国路网&#xff0c;截至2月1日14时&#xff0c;受降雪及路面结冰影响&#xff0c;河北、山西、内蒙古、黑龙江、江苏、安徽、河南、山东、西藏、陕西、宁夏、甘肃、新疆共封闭路段66个&#xff08;涉及44条高速公路、5条普通国道、5条普通省道&#xff09;&#xff0c;关闭…

C语言问题汇总

指针 #include <stdio.h>int main(void){int a[4] {1,2,3,4};int *p &a1;int *p1 a1;printf("%#x,%#x",p[-1],*p1);} 以上代码中存在错误。 int *p &a1; 错误1&#xff1a;取a数组的地址&#xff0c;然后1&#xff0c;即指针跳过int [4]大小的字节…

数据图表方案,企业视频生产数据可视化

在信息爆炸的时代&#xff0c;如何将复杂的数据转化为直观、生动的视觉信息&#xff0c;是企业在数字化转型中面临的挑战。美摄科技凭借其独特的数据图表方案&#xff0c;为企业在数据可视化领域打开了一扇全新的大门。 一、数据图表方案的优势 1、高效便捷&#xff1a;利用数…

计算机网络第4章(网络层)

4.1、网络层概述 简介 网络层的主要任务是实现网络互连&#xff0c;进而实现数据包在各网络之间的传输 这些异构型网络N1~N7如果只是需要各自内部通信&#xff0c;他们只要实现各自的物理层和数据链路层即可 但是如果要将这些异构型网络互连起来&#xff0c;形成一个更大的互…

【七】【C++】模版初阶

泛型编程 C中的泛型编程是一种编程范式&#xff0c;它强调代码的重用性和类型独立性。通过泛型编程&#xff0c;你可以编写与特定数据类型无关的代码&#xff0c;使得相同的代码可以用于多种数据类型。 利用重载实现泛型编程 /*利用重载实现泛型编程*/ #include<iostream&…

部署实战--修改jar中的文件并重新打包成jar文件

一.jar文件 JAR 文件就是 Java Archive &#xff08; Java 档案文件&#xff09;&#xff0c;它是 Java 的一种文档格式JAR 文件与 ZIP 文件唯一的区别就是在 JAR 文件的内容中&#xff0c;多出了一个META-INF/MANIFEST.MF 文件META-INF/MANIFEST.MF 文件在生成 JAR 文件的时候…

STM32--USART串口(3)数据包

一、前言 在实际的工程中肯会有同时发送多种数据的情况&#xff0c;比如要不停的发送x、y、z分别对应三种不同的数据。xyzxyzxyz&#xff0c;但接收方可能是从中间某个地方开始接收的&#xff0c;这就导致数据错位。所以我们就需要将数据进行分割&#xff0c;打包成一个一个的…

spring问题点

1.事务 1.1.事务传播 同一个类中 事务A调非事务B B抛异常 AB事务生效&#xff08;具有传播性&#xff09; 同一个类中 事务A调非事务B A抛异常 AB事务生效 也就是主方法加了事务注解 则方法内调用的其他本类方法无需加事务注解&#xff0c; 发生异常时可以保证事务的回滚 最常…

LabVIEW核能设施监测

LabVIEW核能设施监测 在核能领域&#xff0c;确保设施运行的安全性和效率至关重要。LabVIEW通过与硬件的紧密集成&#xff0c;为高温气冷堆燃料装卸计数系统以及脉冲堆辐射剂量监测与数据管理系统提供了解决方案。这些系统不仅提高了监测和管理的精确度&#xff0c;也保证了核…

Java SWT Composite 绘画

Java SWT Composite 绘画 1 Java SWT2 Java 图形框架 AWT、Swing、SWT、JavaFX2.1 Java AWT (Abstract Window Toolkit)2.2 Java Swing2.3 Java SWT (Standard Widget Toolkit)2.4 Java JavaFX 3 比较和总结 1 Java SWT Java SWT&#xff08;Standard Widget Toolkit&#xff…

【毕业快刊】录用率98%!IF将破7,中科院2区,2个月录用,6天见刊,36天检索!

计算机类 ● 高分快刊 今天带来Elsevier旗下快刊解读&#xff0c;影响因子将破7&#xff0c;2023中科院分区上涨至中科院2区&#xff0c;期刊实力强劲&#xff0c;审稿快&#xff0c;实为毕业投稿首选&#xff0c;如有投稿意向可重点关注&#xff0c;具体详情见下文&#xff1…