Go 基本数据类型:数值、布尔、字符与字符串
面向有 Java 经验的开发者,基于 Go 1.26。
Go 的基本数据类型并不难记,真正容易出错的是边界:int 在当前机器上占多少位,1.0 为什么是 float64,'中' 保存了什么,len("中国") 为什么是 6,金额又为什么不能直接用浮点数判断相等。这些问题单独看都不大,一旦进入协议、数据库或并发服务,就可能变成很难排查的 bug。
因此,这一章不会从一张需要背诵的类型表开始。我们先看 Go 的类型和值模型,再逐项讨论取值范围、字面量、运算、转换和工程用法。遇到 JSON 精度丢失、整数溢出或 Unicode 截断时,也可以直接回到对应小节查边界。
目录
1. Go 有哪些基本类型
Go 规范把预声明的基本类型分为三组:
布尔类型
bool数值类型
// 有符号整数
int
int8
int16
int32
int64
// 无符号整数
uint
uint8
uint16
uint32
uint64
uintptr
// 浮点数
float32
float64
// 复数
complex64
complex128另外有两个别名:
byte // uint8 的别名
rune // int32 的别名字符串类型
string有些教程会把“字符型”单列为基本类型,这种说法不够准确。Go 没有独立的 char 类型:
byte是uint8的别名,适合表达一个原始字节;rune是int32的别名,适合表达一个 Unicode 码点;- 单引号写出来的是 rune literal,也就是一个整数常量。
指针、数组、slice、map、struct、function、interface、channel 属于复合类型或其他类型类别,会在后续章节展开。
2. 预声明类型、别名与定义类型
2.1 预声明不等于关键字
int、string、bool 等名字是在 universe block 中预声明的标识符,不是语法关键字。理论上可以遮蔽:
func f() {
string := "不推荐"
fmt.Println(string)
}这样写会遮蔽预声明的 string,当前作用域内便无法再正常使用这个类型。代码虽然可以解释,阅读成本却没有必要,工程中不要用基本类型名作为变量名。
2.2 byte 和 rune 是别名
规范中的关系是:
type byte = uint8
type rune = int32别名两边是同一个类型:
var b byte = 65
var u uint8 = b // 不需要转换
var r rune = '中'
var i int32 = r // 不需要转换选哪个名字取决于语义:
- 处理文件、网络、哈希、二进制协议:
byte; - 处理 Unicode 码点:
rune; - 单纯 8 位或 32 位整数:
uint8、int32。
类型名本身就在传递语义。虽然底层表示相同,[]byte 比 []uint8 更容易让读者看出“这是一段原始数据”。
2.3 定义类型不是别名
type UserID int64
type OrderID int64UserID、OrderID 和 int64 是三个不同的定义类型:
var uid UserID = 10
var oid OrderID = 10
// uid = oid // 编译错误
uid = UserID(oid) // 显式转换
raw := int64(uid)定义类型可以增加方法:
type UserID int64
func (id UserID) Valid() bool {
return id > 0
}它的目的与 Java 使用 value object 包装 long 相近,但 Go 的定义类型通常不需要额外的对象分配。编译器会直接阻止调用方把订单 ID 误传成用户 ID。
2.4 自定义别名
type ID = int64ID 与 int64 完全相同,不能在 ID 上定义只属于它的新方法。别名主要用于 API 迁移、兼容旧名字,不适合用来建立新的领域边界。
2.5 底层类型
每个定义类型都有 underlying type:
type UserID int64 // 底层类型 int64
type Name string // 底层类型 string
type Enabled bool // 底层类型 bool底层类型决定允许的运算和显式转换,也决定泛型中 ~int64 这类约束的含义。
3. 字面量、常量与默认类型
3.1 字面量最初可能没有具体类型
42
3.14
true
'中'
"hello"
1 + 2i这些字面量在常量表达式中通常是无类型常量。它们不是一出现就立刻塞进某种固定宽度的运行期存储。
const Big = 1 << 100 // 合法,常量可以拥有很高精度只有进入需要具体类型的上下文时,才检查能否表示:
var x uint64 = 1 << 63 // 合法
// var y uint64 = 1 << 100 // 编译错误:超出 uint643.2 默认类型
没有其他类型信息时,无类型常量使用默认类型:
| 常量类别 | 默认类型 |
|---|---|
| 布尔常量 | bool |
| rune 常量 | rune,即 int32 |
| 整数常量 | int |
| 浮点常量 | float64 |
| 复数常量 | complex128 |
| 字符串常量 | string |
i := 42 // int
f := 3.14 // float64
c := 1 + 2i // complex128
b := true // bool
r := '中' // rune / int32
s := "hello" // string用 %T 可以确认:
fmt.Printf("%T %T %T %T %T %T\n", i, f, c, b, r, s)3.3 上下文可以改变落地类型
var a int8 = 42
var b float32 = 3.14
var c complex64 = 1 + 2i常量值能被目标类型精确或按规则表示时,赋值合法。变量之间则没有这种宽松的隐式转换:
var x int8 = 42
// var y int64 = x // 编译错误
var y int64 = int64(x)4. 整数类型
4.1 固定宽度整数
| 类型 | 位数 | 最小值 | 最大值 |
|---|---|---|---|
int8 | 8 | -128 | 127 |
uint8 / byte | 8 | 0 | 255 |
int16 | 16 | -32,768 | 32,767 |
uint16 | 16 | 0 | 65,535 |
int32 / rune | 32 | -2,147,483,648 | 2,147,483,647 |
uint32 | 32 | 0 | 4,294,967,295 |
int64 | 64 | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
uint64 | 64 | 0 | 18,446,744,073,709,551,615 |
对于 n 位整数:
无符号:0 到 2ⁿ - 1
有符号:-2ⁿ⁻¹ 到 2ⁿ⁻¹ - 1Go 的 n 位整数使用补码运算。不能简单理解成“有符号整数拿出一位不用来存数据”。最高位参与补码表示,因此 int8 有 128 个负数、127 个正数和一个零。
4.2 int 和 uint
int
uint它们在规范中是 32 位或 64 位,并且宽度相同,取决于目标架构,不是操作系统界面显示“64 位”就绝对可以推断所有构建目标都是 64 位。
查看当前编译目标:
fmt.Println(strconv.IntSize) // 32 或 64
fmt.Println(math.MinInt)
fmt.Println(math.MaxInt)
fmt.Println(math.MaxUint)一般业务计数、slice 索引、len 结果和循环变量优先使用 int:
for i := 0; i < len(items); i++ {
// i 和 len(items) 都是 int
}这些场景使用 int64 反而会产生大量转换。
固定宽度场景使用明确类型:
- 网络协议字段;
- 文件格式;
- 数据库列和跨语言接口;
- 哈希或位运算算法;
- 必须跨架构保持布局的数据。
4.3 uintptr
uintptr 是足以保存指针地址“无解释位模式”的无符号整数。它主要服务于 unsafe、系统调用和底层运行时:
var address uintptr它不是普通指针:
- GC 不会把
uintptr当作对象引用; - 它不会阻止对象被回收;
- 即使将来 GC 移动物体,也不会自动更新它;
- 从指针转成
uintptr后长期保存再转回通常不安全。
业务 ID、文件大小、内存长度都不该因为“看起来很大”而使用 uintptr。
4.4 用标准常量表示范围
不要用浮点函数计算整数上限:
// 不推荐:math.Pow 返回 float64
limit := math.Pow(2, 63) - 1浮点数在这个数量级不能逐个表示相邻整数,2^63 - 1 可能已经被舍入。
直接使用:
fmt.Println(math.MaxInt64)
fmt.Println(math.MinInt64)
fmt.Println(math.MaxUint64)
fmt.Println(math.MaxInt)或者用常量表达式:
const MaxInt64 = 1<<63 - 1
const MinInt64 = -1 << 635. 整数的字面量与运算
5.1 不同进制
decimal := 42
binary := 0b0010_1010
octal := 0o52
hex := 0x2A
fmt.Println(decimal == binary, binary == octal, octal == hex) // true true true下划线用于提高可读性:
million := 1_000_000
mask := 0xFF_FF_00_00下划线有语法位置限制,不能随意放在小数点或数字开头结尾。
旧式前导零八进制仍可用:
legacy := 052新代码优先 0o52,意图更清楚,也避免把 010 误以为十进制 10。
5.2 算术运算
a + b
a - b
a * b
a / b
a % b整数除法截断小数部分,结果向零靠近:
fmt.Println(7 / 3) // 2
fmt.Println(-7 / 3) // -2
fmt.Println(7 / -3) // -2
fmt.Println(-7 / -3) // 2取余满足:
x = q*y + r
|r| < |y|除数为零会在运行期 panic;常量除以零会在编译期报错。
5.3 位运算
x & y // 与
x | y // 或
x ^ y // 异或
x &^ y // 位清除:x AND NOT y
x << n // 左移
x >> n // 右移权限标志:
type Permission uint8
const (
PermissionRead Permission = 1 << iota
PermissionWrite
PermissionDelete
)
permissions := PermissionRead | PermissionWrite
if permissions&PermissionWrite != 0 {
fmt.Println("可以写")
}
permissions &^= PermissionWrite // 清除写权限移位计数必须是整数或无类型整数,不能为负。移位后的结果是否能放入目标类型,要看表达式最终上下文。
5.4 位算法用 math/bits
标准库提供:
bits.OnesCount64(x)
bits.LeadingZeros64(x)
bits.TrailingZeros64(x)
bits.RotateLeft64(x, n)
bits.Reverse64(x)
bits.Add64(x, y, carry)
bits.Mul64(x, y)这些函数通常比手写循环更清楚,也更容易被编译器映射成硬件指令。
6. 整数溢出与窄化转换
6.1 常量溢出在编译期检查
const n = 256
// var b uint8 = n // 编译错误
// var b = uint8(256) // 编译错误无类型常量本身可以很大,但一旦要求转换或赋给固定类型,就必须能够表示。
6.2 运行期整数可以溢出
无符号整数按 2ⁿ 取模:
var u uint8 = 255
u++
fmt.Println(u) // 0有符号整数也允许溢出,结果按补码表示确定,不会自动 panic:
var i int8 = 127
i++
fmt.Println(i) // -128这与 Java 整数溢出“不抛异常”的体验相似。不同之处是 Go 还提供无符号整数,并且编译器不能假定有符号溢出永远不会发生。
6.3 窄化转换保留低位
x := 256
b := uint8(x)
fmt.Println(b) // 0转换过程会先扩展到隐含的无限精度有符号值,再截断到目标宽度;不断重复符号扩展或截断,直到得到目标类型。
另一个容易意外的例子:
var i int8 = -1
u := uint8(i)
fmt.Println(u) // 255转换没有错误返回值。若输入来自用户、数据库或网络,转换前必须检查范围:
func ToUint8(n int) (uint8, error) {
if n < 0 || n > math.MaxUint8 {
return 0, fmt.Errorf("%d 超出 uint8 范围", n)
}
return uint8(n), nil
}6.4 检测加法溢出
无符号加法:
func AddUint64(a, b uint64) (uint64, bool) {
sum := a + b
overflow := sum < a
return sum, overflow
}也可以使用:
sum, carry := bits.Add64(a, b, 0)
if carry != 0 {
// 溢出
}有符号整数要根据符号和边界判断:
func AddInt64(a, b int64) (int64, error) {
if b > 0 && a > math.MaxInt64-b {
return 0, errors.New("int64 正溢出")
}
if b < 0 && a < math.MinInt64-b {
return 0, errors.New("int64 负溢出")
}
return a + b, nil
}6.5 循环不要无意识地使用无符号数
for i := uint(10); i >= 0; i-- {
// 永远不会因为 i < 0 结束;i 从 0 减 1 后绕回最大值
}长度和索引用 int 不只是为了少转换,也避免无符号下溢让边界判断失效。
7. 浮点类型
7.1 两种浮点类型
| 类型 | IEEE 754 格式 | 有效十进制数字约数 | 最大有限值 |
|---|---|---|---|
float32 | binary32 | 约 6~9 位 | math.MaxFloat32,约 3.4e38 |
float64 | binary64 | 约 15~17 位 | math.MaxFloat64,约 1.8e308 |
最小正非零值:
math.SmallestNonzeroFloat32
math.SmallestNonzeroFloat64它们是最小正 subnormal 值,不是最小负数。浮点负数范围关于零大致对称。
7.2 默认是 float64
f := 1.25
fmt.Printf("%T\n", f) // float64显式使用 float32:
var f32 float32 = 1.25float32 更省空间,但计算精度更低。除非协议、GPU、海量数组或外部 API 明确要求,普通计算通常使用 float64。
7.3 浮点字面量
a := 1.25
b := .25
c := 1.
d := 1.25e3
e := 0x1.8p1 // 十六进制浮点,等于 3.0e 形式的指数以 10 为底;十六进制浮点的 p 指数以 2 为底。
7.4 二进制浮点不能精确表示多数十进制小数
x := 0.1
y := 0.2
fmt.Printf("%.20f\n", x+y)
// 0.30000000000000004441 附近这不是 Go 特有问题,而是 IEEE 754 二进制浮点的性质。
不要把格式化为两位小数误认为内部值已经变精确:
fmt.Printf("%.2f\n", x+y) // 看起来是 0.30格式化只改变显示,不改变存储值。
7.5 浮点相等
来自同一确定计算路径的值有时可以直接比较;测量、累计或不同计算路径得到的浮点数,通常需要容差:
func AlmostEqual(a, b, absTol, relTol float64) bool {
diff := math.Abs(a - b)
if diff <= absTol {
return true
}
scale := max(math.Abs(a), math.Abs(b))
return diff <= relTol*scale
}只用固定 epsilon:
math.Abs(a-b) < 1e-9在数量级变化很大时可能不可靠。容差必须来自业务误差模型,不存在适合所有程序的万能值。
7.6 NaN、正负无穷和负零
nan := math.NaN()
posInf := math.Inf(1)
negInf := math.Inf(-1)
negZero := math.Copysign(0, -1)判断:
math.IsNaN(nan)
math.IsInf(posInf, 1)
math.IsInf(negInf, -1)
math.Signbit(negZero)NaN 与任何值比较都不相等,也包括它自己:
fmt.Println(nan == nan) // false因此不能写:
if value == math.NaN() {
// 永远不会进入
}必须使用 math.IsNaN。
cmp.Compare 为排序提供了稳定的 NaN 规则;普通 <、== 仍遵守 IEEE 行为。语法虽然允许浮点数作为 map key,但 NaN 键无法再用“相等的 NaN”正常取回,这样的接口通常得不偿失。
7.7 float 转 int
f := 12.9
i := int(f)
fmt.Println(i) // 12,直接丢弃小数部分,向零截断负数:
fmt.Println(int(-12.9)) // -12若非常量浮点值超出目标整数范围,结果依赖具体实现。转换前应检查:
func Float64ToInt64(f float64) (int64, error) {
if math.IsNaN(f) || math.IsInf(f, 0) {
return 0, errors.New("不是有限数")
}
if f < math.MinInt64 || f >= -float64(math.MinInt64) {
return 0, errors.New("超出 int64 范围")
}
return int64(f), nil
}float64(math.MaxInt64) 会舍入到 2^63,所以上界判断不能直接写成 f > float64(math.MaxInt64)。涉及边界的转换应当配测试;如果数据在业务上本来就是整数,更稳妥的办法是不要先让它经过浮点类型。
7.8 舍入函数
math.Floor(1.8) // 1
math.Ceil(1.2) // 2
math.Trunc(-1.8) // -1
math.Round(1.5) // 2,半数远离零
math.RoundToEven(2.5) // 2,银行家舍入选择函数之前,先把业务舍入规则写清楚。金融结算不能靠“看起来像四舍五入”来决定结果。
7.9 序列化特殊值
JSON 不支持 NaN 和正负无穷:
_, err := json.Marshal(math.NaN())
// err 为 *json.UnsupportedValueError数值进入 JSON、数据库或 RPC 边界之前,应先确认它是有限值。
8. 复数类型
8.1 complex64 与 complex128
var c64 complex64 // 实部和虚部各为 float32
var c128 complex128 // 实部和虚部各为 float64默认复数字面量类型是 complex128:
c := 3 + 4i
fmt.Printf("%T %v\n", c, c) // complex128 (3+4i)8.2 构造与拆分
c := complex(3.0, 4.0)
fmt.Println(real(c)) // 3
fmt.Println(imag(c)) // 4complex 参数的浮点类型决定结果:
var r32, i32 float32 = 3, 4
c32 := complex(r32, i32) // complex648.3 运算
a := 1 + 2i
b := 3 - 4i
fmt.Println(a + b)
fmt.Println(a - b)
fmt.Println(a * b)
fmt.Println(a / b)math/cmplx 提供:
cmplx.Abs(3 + 4i) // 5
cmplx.Conj(1 + 2i) // 1 - 2i
cmplx.Phase(c)
cmplx.Polar(c)
cmplx.Sqrt(c)
cmplx.Exp(c)8.4 比较限制
复数支持 == 和 !=,不支持 <、<=、>、>=:
fmt.Println((1 + 2i) == (1 + 2i))
// fmt.Println((1 + 2i) < (2 + 3i)) // 编译错误排序必须先定义业务规则,例如按模、实部或相位排序。复数分量也可能包含 NaN,届时相等比较同样会表现出 NaN 的特殊性。
9. 布尔类型
9.1 值与零值
var enabled bool // false
enabled = true
enabled = false只有两个预声明布尔常量:
true
false9.2 没有 truthy / falsy
Go 不允许把整数、字符串、指针当布尔条件:
count := 1
// if count { } // 编译错误
if count != 0 { } // 正确
name := "go"
if name != "" { } // 正确也不能在 bool 和整数之间转换:
// b := bool(1) // 编译错误
// n := int(true)这减少了 0、空字符串、nil 混入条件判断的歧义。
9.3 运算
!a
a && b
a || b
a == b
a != b&& 和 || 短路:
if user != nil && user.Enabled {
// user 为 nil 时不会访问 user.Enabled
}表达式从左到右求值。可以利用短路保证安全,但不要在条件右侧塞入难以理解的副作用。
9.4 布尔命名
优先让名字读起来像判断:
isReady
hasPermission
canRetry
enabled避免:
flag
status
value当 bool 已无法表达状态时,不要继续叠加 isX 字段。比如任务可能是 pending、running、failed、done,更适合定义枚举类型。
10. byte、rune 与 Unicode
10.1 单引号是 rune literal
a := 'a'
zhong := '中'
fmt.Printf("%T %d %c %U\n", a, a, a, a)
fmt.Printf("%T %d %c %U\n", zhong, zhong, zhong, zhong)默认类型:
rune,也就是 int32'中' 的数值是 Unicode 码点 U+4E2D,即十进制 20013。它不是“UTF-8 编码值”。这个码点编码成 UTF-8 后才是三个字节:
E4 B8 AD码点和编码是两个层次:
字符抽象:Unicode code point U+4E2D
UTF-8 编码:E4 B8 AD
Go 表达码点:rune(0x4E2D)
Go 表达编码字节:[]byte{0xE4, 0xB8, 0xAD}10.2 byte
var b byte = 'A'
fmt.Println(b) // 65
fmt.Printf("%c\n", b) // AASCII 范围的码点与单字节 UTF-8 值相同,所以 'A' 可以放进 byte:
var a byte = 'A'中文码点超出 uint8:
// var z byte = '中' // 编译错误:溢出10.3 rune 的合法范围
Unicode 最大码点:
utf8.MaxRune // U+10FFFFrune 本身是 int32,能表示的整数范围比合法 Unicode 码点更大:
r := rune(0x7FFFFFFF)
fmt.Println(utf8.ValidRune(r)) // false把非法 rune 转为 string 时会得到 Unicode replacement character U+FFFD:
fmt.Printf("%q\n", string(r))10.4 一个“用户看到的字符”可能有多个 rune
例如带组合音标的字符、国旗、家庭 emoji,可能由多个 Unicode 码点组成。于是存在三个不同的长度:
字节数
rune / code point 数
grapheme cluster / 用户感知字符数Go 标准库的 len 给字节数,utf8.RuneCountInString 给 rune 数;界面光标、文本宽度和“最多 20 个字符”这类产品要求,往往需要专门的 Unicode grapheme 库。
10.5 Java char 不能直接类比 rune
Java char 是一个 UTF-16 code unit,占 16 位;超出 BMP 的码点需要 surrogate pair。Go rune 是 32 位整数,可容纳一个合法 Unicode 码点。
因此:
Java char ≠ Go rune
Java code point(int)更接近 Go rune11. 字符串
11.1 string 是不可变字节序列
var name string = "枫枫知道"规范对 string 的定义是“不可变的字节序列”。它不保证内容一定是 UTF-8:
s := string([]byte{0xff, 0xfe})
fmt.Println(utf8.ValidString(s)) // falseGo 源文件必须是 UTF-8;普通字符串字面量中直接写入的 Unicode 文本会形成 UTF-8 字节,但运行期 string 可以保存任意字节。
11.2 不可修改
s := "hello"
// s[0] = 'H' // 编译错误要修改,转换为 []byte 或 []rune:
bytes := []byte(s)
bytes[0] = 'H'
s = string(bytes)字符串变量可以重新赋值:
s = "world"“不可变”指原字符串值中的字节不能原地改变,不是变量永远不能指向新字符串。
11.3 len 返回字节数
s := "中国"
fmt.Println(len(s)) // 6
fmt.Println(utf8.RuneCountInString(s)) // 2因为每个中文码点在 UTF-8 中占 3 字节。
11.4 字符串比较
a == b
a != b
a < b
a <= b
a > b
a >= b排序按字节字典序进行,不是人类语言的排序规则:
fmt.Println("Z" < "a") // true大小写无关比较:
strings.EqualFold(a, b)本地化排序、拼音排序、重音处理需要 Unicode collation 能力,不能依赖 <。
11.5 string 可以作为 map key
counts := map[string]int{
"go": 1,
}string 可比较,因此适合作为 map key。键比较按字节进行,视觉上相同但 Unicode 规范化形式不同的字符串可能是两个不同键。
如果业务要求用户名或搜索键规范化,需要在边界统一处理,而不是假定所有输入天然同形同码。
12. 转义字符与原始字符串
12.1 解释型字符串
双引号字符串会解释反斜杠转义:
fmt.Println("枫枫\t知道") // 水平制表符
fmt.Println("枫枫\n知道") // 换行 LF
fmt.Println("\"枫枫\"知道") // 双引号
fmt.Println("枫枫\r知道") // 回车 CR,回到当前行行首
fmt.Println("C:\\pprof\\main.exe") // 反斜杠原教程把 \n 叫“回车”,不够准确:
\n:line feed,LF,换行;\r:carriage return,CR,回到行首;- Windows 文本行常用
\r\n; - Unix 通常使用
\n。
终端如何显示裸 \r 取决于环境。它常用于进度条覆盖当前行,不适合当通用换行。
12.2 常用转义
| 写法 | 含义 |
|---|---|
\a | 响铃 |
\b | 退格 |
\f | 换页 |
\n | 换行 LF |
\r | 回车 CR |
\t | 水平制表符 |
\v | 垂直制表符 |
\\ | 反斜杠 |
\" | 双引号 |
\' | 单引号,主要用于 rune literal |
\xNN | 两位十六进制字节 |
\uNNNN | 4 位十六进制 Unicode 码点 |
\UNNNNNNNN | 8 位十六进制 Unicode 码点 |
fmt.Println("\x41") // A
fmt.Println("\u4E2D") // 中
fmt.Println("\U0001F600") // 😀\xNN 写入一个字节,可能构造出非法 UTF-8;\u 和 \U 表达合法 Unicode 码点并以 UTF-8 放入字符串。
12.3 原始字符串
反引号中的 raw string literal 基本按原样保存:
sql := `SELECT id, name
FROM users
WHERE enabled = true`其中 \n 不会变成换行:
path := `C:\pprof\main.exe`
text := `hello\nworld`text 中是反斜杠和字母 n。
限制:
- 不能直接包含反引号;
- 源码中的实际换行会成为字符串内容;
- 为代码缩进加入的空格和 tab 也会进入内容。
原始字符串适合 SQL、正则表达式、JSON 示例和多行模板,但不是“自动去缩进”的文本块。
13. 字符串的遍历、截取与转换
13.1 按索引访问得到 byte
s := "中A"
fmt.Printf("%T %x\n", s[0], s[0]) // uint8 e4s[0] 是“中”的第一个 UTF-8 字节,不是 rune '中'。
逐字节遍历:
for i := 0; i < len(s); i++ {
fmt.Printf("byte[%d]=%02x\n", i, s[i])
}13.2 range 解码 UTF-8
for byteIndex, r := range s {
fmt.Printf("byteIndex=%d rune=%c code=%U\n", byteIndex, r, r)
}第一项是 rune 起点的字节偏移,不是第几个 rune:
byteIndex=0 rune=中
byteIndex=3 rune=A遇到非法 UTF-8,range 产生 utf8.RuneError(U+FFFD),并前进 1 字节。
13.3 截取字符串按字节
s := "中国"
first := s[:3] // "中"错误边界会产生非法 UTF-8:
broken := s[:2]
fmt.Println(utf8.ValidString(broken)) // false按 rune 截取:
runes := []rune(s)
firstRune := string(runes[:1])这会分配并解码整个字符串。大文本或高频路径可以用 range 找到目标字节边界,只截取需要的部分。
按 rune 截取仍可能切开一个 grapheme cluster,例如 emoji 组合。产品说的“字符”需要先定义清楚。
13.4 string 与 []byte
s := "hello"
b := []byte(s)
s2 := string(b)语义上它们是独立值,正常转换通常会复制数据。编译器在能证明安全时可能消除部分复制,但代码不能依赖这种实现优化。
修改 b 不应影响 s:
b[0] = 'H'
fmt.Println(s) // hello
fmt.Println(string(b)) // Hello不要为了“零拷贝”随意使用 unsafe.String 或 unsafe.Slice。一旦底层字节被修改或生命周期结束,字符串不可变性和内存安全都会被破坏。
13.5 string 与 []rune
runes := []rune("中国")
fmt.Println(runes) // [20013 22269]
fmt.Println(string(runes)) // 中国适合需要按码点修改:
runes[0] = '美'
result := string(runes)如果只是检查、查找或替换,优先使用 strings、unicode、unicode/utf8 的现成函数,避免不必要地转完整 rune slice。
13.6 拼接
少量固定片段:
fullName := firstName + " " + lastName循环或大量片段:
var b strings.Builder
b.Grow(128)
for _, part := range parts {
b.WriteString(part)
}
result := b.String()二进制和字符串混合处理可以使用 bytes.Buffer。不要在没有 benchmark 的情况下为了几次拼接引入复杂池化。
13.7 substring 可能保留大底层数据
字符串切片通常复用原字符串数据:
small := huge[:10]如果 small 长期存活,可能让整个 huge 的底层数据无法回收。需要主动复制时:
small = strings.Clone(huge[:10])这类问题只在“大字符串切出极小片段并长期保存”时值得关注。普通局部切片不要过早优化。
14. 零值
14.1 基本类型零值
| 类型 | 零值 |
|---|---|
| 所有整数 | 0 |
| 所有浮点数 | 0.0 |
| 所有复数 | 0 + 0i |
bool | false |
string | "" |
var i int
var f float64
var c complex128
var b bool
var s string
fmt.Printf("%#v\n", i) // 0
fmt.Printf("%#v\n", f) // 0
fmt.Printf("%#v\n", c) // (0+0i)
fmt.Printf("%#v\n", b) // false
fmt.Printf("%#v\n", s) // ""14.2 零值不等于“未初始化”
Go 不允许读取一块未初始化的局部基本类型内存。变量声明后总有确定值:
var count int
fmt.Println(count) // 合法,0这与 Java 字段的默认值相似,但 Java 局部变量必须在读取前显式赋值;Go 局部变量也自动拥有零值。
14.3 零值可能是合法业务值
0、false、"" 既可能表示“未提供”,也可能是真实值。不要只凭零值猜字段是否出现:
type UpdateUser struct {
Age int
Enabled bool
}无法区分:
客户端没有提供 Enabled
客户端明确提供 Enabled=false可选字段常用指针:
type UpdateUser struct {
Age *int
Enabled *bool
}Go 1.26 可以方便地构造:
req := UpdateUser{
Age: new(18),
Enabled: new(false),
}也可以定义显式 optional 类型,避免业务层到处解引用指针。
15. 类型转换与可赋值性
15.1 Go 没有数字隐式提升
var a int32 = 10
var b int64 = 20
// c := a + b // 编译错误
c := int64(a) + b即使当前平台 int 和 int64 都是 64 位,它们仍是不同类型:
var i int = 10
var j int64 = 20
// j = i
j = int64(i)这比 Java 的 numeric promotion 更严格,代价是转换多一点,收益是跨架构和有符号/无符号混用更显式。
15.2 转换语法
T(value)例子:
f := float64(10)
i := int(3.9)
s := string('中')
runes := []rune("中国")
bytes := []byte("hello")转换不是 Java cast 的完整等价物。Go 只允许规范规定的类型转换,不能把任意对象强制转换成无关类型。
15.3 int 转 string 不是十进制格式化
fmt.Println(string(65)) // "A"整数转 string 把整数解释为 Unicode 码点,不是得到 "65"。
十进制文本:
strconv.Itoa(65)
strconv.FormatInt(65, 10)
fmt.Sprintf("%d", 65)静态检查工具会特别提示可疑的 string(intValue)。
15.4 定义类型之间需要转换
type Celsius float64
type Fahrenheit float64
var c Celsius = 100
// var f Fahrenheit = c
var f Fahrenheit = Fahrenheit(c*9/5 + 32)显式转换只改变类型和按规则得到的表示,不自动应用领域语义。Fahrenheit(c) 只是数值类型转换,不会自动完成摄氏到华氏公式。
15.5 字符串转换的特殊规则
string([]byte{0xE4, 0xB8, 0xAD}) // "中"
string([]rune{'中', '国'}) // "中国"
string(0x4E2D) // "中"整数值不是合法 Unicode 码点时,结果为 replacement rune:
fmt.Printf("%q\n", string(-1)) // "�"16. 格式化与解析
16.1 输出格式
整数:
fmt.Printf("%d\n", n) // 十进制
fmt.Printf("%b\n", n) // 二进制
fmt.Printf("%o\n", n) // 八进制
fmt.Printf("%x\n", n) // 十六进制
fmt.Printf("%c\n", n) // Unicode 字符
fmt.Printf("%U\n", n) // U+XXXX浮点:
fmt.Printf("%f\n", f)
fmt.Printf("%.2f\n", f)
fmt.Printf("%e\n", f)
fmt.Printf("%g\n", f)类型和 Go 表示:
fmt.Printf("%T\n", value)
fmt.Printf("%#v\n", value)16.2 strconv 比 fmt.Sscan 更适合单值解析
n, err := strconv.Atoi("42")
f, err := strconv.ParseFloat("3.14", 64)
b, err := strconv.ParseBool("true")带明确位宽:
n, err := strconv.ParseInt("-128", 10, 8)
u, err := strconv.ParseUint("255", 10, 8)ParseInt 返回 int64,即使指定 bitSize 8:
n8 := int8(n)解析成功后转换是安全的,因为 ParseInt(..., 8) 已经做过范围检查。
16.3 自动识别进制
n, err := strconv.ParseInt("0xff", 0, 64)base 为 0 时识别:
0b:二进制;0或0o:八进制;0x:十六进制;- 其他:十进制。
若输入协议只允许十进制,应明确传 10,不要因为方便而接受额外格式。
16.4 解析错误
strconv 返回 *strconv.NumError:
value, err := strconv.ParseInt(input, 10, 64)
if err != nil {
var numErr *strconv.NumError
if errors.As(err, &numErr) {
switch {
case errors.Is(numErr.Err, strconv.ErrSyntax):
return fmt.Errorf("%q 不是合法整数", input)
case errors.Is(numErr.Err, strconv.ErrRange):
return fmt.Errorf("%q 超出 int64 范围", input)
}
}
return err
}不要只返回“参数错误”,应告诉调用方是格式错误还是范围错误,但日志中要避免回显敏感输入。
16.5 格式化为字符串
strconv.Itoa(42)
strconv.FormatInt(-42, 10)
strconv.FormatUint(255, 16)
strconv.FormatBool(true)
strconv.FormatFloat(3.14, 'g', -1, 64)
strconv.Quote("hello\n")FormatFloat 参数:
FormatFloat(f, fmt, prec, bitSize)常见的 'g', -1, 64 会选择能往返还原 float64 的紧凑表示。
批量追加到 []byte:
buf = strconv.AppendInt(buf, n, 10)
buf = strconv.AppendFloat(buf, f, 'g', -1, 64)热路径中它通常比 fmt.Sprintf 少分配,但应以 benchmark 为准。
17. 大小、对齐与内存布局
17.1 固定宽度类型的大小
int8 / uint8 1 byte
int16 / uint16 2 bytes
int32 / uint32 4 bytes
int64 / uint64 8 bytes
float32 4 bytes
float64 8 bytes
complex64 8 bytes
complex128 16 bytesbool、int、uint、uintptr、string 描述符以及 struct 布局应通过目标架构和实现分析,不要拿经验值写协议。
17.2 unsafe.Sizeof
fmt.Println(unsafe.Sizeof(int(0)))
fmt.Println(unsafe.Sizeof(float64(0)))
fmt.Println(unsafe.Sizeof(""))unsafe.Sizeof 返回变量本身的大小,不包含它引用的外部数据:
s := "一个很长的字符串"
fmt.Println(unsafe.Sizeof(s))结果只是 string 值内部描述信息的大小,不随字符串内容长度变化。
slice、map、interface 同理:只统计描述符本身,不递归统计底层数组或对象。
17.3 struct padding
type A struct {
Flag bool
ID int64
Code byte
}字段之间可能为了对齐插入 padding,因此 struct 大小不等于字段大小简单相加。调整字段顺序可能减少内存:
type B struct {
ID int64
Flag bool
Code byte
}只有在大量对象且 profile 证明内存布局重要时再优化。随意重排公开 struct 字段也会影响可读性、反射输出和位置式复合字面量用户。
17.4 不要用内存布局做文件协议
下面的思路不可移植:
把 struct 的内存字节直接写入文件或网络问题包括:
- 字节序;
- padding;
int宽度;- 指针和 string 描述符;
- Go 版本和架构差异;
- 对齐要求。
使用 encoding/binary、JSON、Protobuf 等明确协议,并为每个字段选择固定宽度类型。
18. 工程边界:金额、时间、ID 与 JSON
18.1 金额
不推荐:
price := 19.99 // float64若只处理固定最小货币单位:
type Cents int64
price := Cents(1999)涉及汇率、不同小数位、税务舍入时,需要明确的十进制定点模型或经过评估的 decimal 库。核心不是“绝不能用 float”,而是金额规则不能由 IEEE 754 的偶然舍入决定。
18.2 时间和持续时间
时间点使用:
time.Time持续时间使用:
time.Duration // 底层是 int64,单位为纳秒timeout := 5 * time.Second不要用裸 int64 同时表达 Unix 秒、Unix 毫秒和纳秒。类型相同不代表单位相同。
跨协议时在字段名或类型中明确单位:
type UnixMillis int6418.3 ID
数据库自增 ID 常用 int64,但领域层可定义:
type UserID int64
type OrderID int64分布式 ID 若可能使用 uint64 全范围,注意:
- 数据库驱动是否支持;
- JSON/JavaScript 是否能精确表示;
- 日志和监控是否按有符号 64 位解析;
- 跨语言 schema 是否一致。
很多系统把大 ID 作为十进制字符串传给前端,以绕过 JavaScript Number 的精度上限。
18.4 JSON number 的精度陷阱
明确目标 struct 时,按字段类型解析:
type Payload struct {
ID int64 `json:"id"`
}解码到 any 时,JSON number 默认变成 float64:
var value any
_ = json.Unmarshal([]byte(`9007199254740993`), &value)
fmt.Printf("%T %v\n", value, value)大整数可能已经丢失精度。需要保留数字文本:
dec := json.NewDecoder(reader)
dec.UseNumber()
var value any
if err := dec.Decode(&value); err != nil {
return err
}之后对 json.Number 调用 Int64、Float64 或按业务使用十进制库。
18.5 NaN 与 Inf 不能直接进 JSON
json.Marshal(math.NaN()) // 错误
json.Marshal(math.Inf(1)) // 错误API schema 应决定特殊值表示为:
- 拒绝请求;
null;- 明确字符串;
- 独立状态字段。
不要悄悄把它改成 0。
18.6 string 不保证 UTF-8,但外部协议往往要求
Go string 可保存任意字节;JSON、数据库文本列、HTTP header 和日志系统通常要求特定编码。
边界检查:
if !utf8.ValidString(input) {
return errors.New("文本不是合法 UTF-8")
}encoding/json 的具体无效 UTF-8 处理还会受到所用 JSON 语义版本和选项影响。安全敏感接口不要依赖“库会替我修复”,应先定义协议要求。
19. 泛型中的基本类型
19.1 comparable
所有基本类型都可比较,因此满足 comparable:
func Contains[T comparable](values []T, target T) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}浮点和复数虽然语法上 comparable,但 NaN 不等于自身。泛型算法若默认“每个值都等于自己”,需要额外说明或排除 NaN。
19.2 cmp.Ordered
标准库:
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}cmp.Ordered 包含:
- 所有整数及其底层类型;
float32、float64及其底层类型;- string 及其底层类型。
不包含:
bool;complex64、complex128。
19.3 ~ 的意义
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}~int64 表示底层类型是 int64 的所有类型,包括:
int64
UserID
OrderID若只写 int64,自定义定义类型不会满足约束。
19.4 一个整数求和约束
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
func Sum[T Integer](values []T) T {
var total T
for _, value := range values {
total += value
}
return total
}这个函数仍然会按 T 的宽度溢出。泛型消除了重复代码,不会自动提供大整数或溢出保护。
20. 与 Java 的差异
| Java | Go | 关键差异 |
|---|---|---|
byte | int8 | Java byte 有符号,Go byte 是 uint8 |
| 无内建无符号整数或使用辅助 API | uint8~uint64 | Go 原生支持无符号运算 |
short | int16 | 都是 16 位有符号 |
int 固定 32 位 | int 为 32 或 64 位 | 跨协议不能用 Go int |
long | int64 | 都是 64 位有符号 |
char,UTF-16 code unit | rune,Unicode code point | 不是一一对应 |
float | float32 | IEEE 754 binary32 |
double | float64 | Go 浮点字面量默认 float64 |
| 无基本复数类型 | complex64/128 | Go 内建复数 |
boolean | bool | Go 不允许与整数互转 |
String | string | 都不可变;Go 索引得到 UTF-8 byte |
BigInteger | math/big.Int | Go 基本整数仍是固定宽度 |
BigDecimal | 无标准库直接等价 | 需定点设计、big.Rat 或第三方 decimal |
| primitive / wrapper | 没有自动装箱模型 | interface 可保存值,但模型不同 |
| 隐式 numeric promotion | 大多要求显式转换 | Go 不自动混合 int32/int64 |
| 显式 narrowing cast 仍可能截断 | 数值转换会截断 | 两边都需要在转换前手工检查范围 |
| 字段有默认值,局部变量需赋值 | 所有变量都有零值 | Go 局部变量也有零值 |
Java 开发者尤其容易踩这四个坑:
- 把 Go
byte当成 Javabyte,忘记前者无符号; - 把 Go
rune当成 Javachar,忽略 UTF-16 surrogate; - 以为 Go
int固定 32 位; - 期待不同整数类型像 Java 一样自动提升。
21. 常见误区
整数
- 用
math.Pow计算整数最大值。 - 认为
int永远是int64。 - 把
uintptr当“大号 uint”用于业务。 - 认为运行期整数溢出会 panic。
- 未检查范围就窄化为
uint8、int32。 - 用无符号循环变量判断
i >= 0。 - 混用
int和int64后到处随意转换。 - 用主机内存布局定义网络协议。
浮点和复数
- 用 float64 表示需要精确结算的金额。
- 认为
%.2f会让内部值变精确。 - 所有场景都用固定
1e-9判断相等。 - 用
value == math.NaN()判断 NaN。 - 未校验 NaN/Inf 就写 JSON。
- float 转 int 前不检查有限性和范围。
- 试图用
<比较复数。
byte、rune 和 string
- 说 rune 保存“UTF-8 码值”。
- 认为
byte可以保存任意“字符”。 - 认为
len(string)返回字符数。 - 认为
range的索引是第几个 rune。 - 按任意字节位置截断 UTF-8。
- 认为 rune 数等于用户看到的字符数。
- 把
string(65)当成十进制"65"。 - 假定所有 Go string 都是合法 UTF-8。
- 用普通字节序比较实现自然语言排序。
- 长期保存巨大字符串的微小切片却不复制。
类型设计
- 用别名代替领域定义类型,失去编译期隔离。
- 认为显式转换会自动完成单位换算。
- 用零值同时表达“未提供”和真实的 false/0。
- 认为泛型求和会自动防止溢出。
- 遮蔽
string、int、bool等预声明名字。
22. 速查表
默认类型
i := 1 // int
f := 1.0 // float64
c := 1 + 2i // complex128
r := '中' // rune / int32
b := true // bool
s := "go" // string范围
math.MinInt
math.MaxInt
math.MinInt8
math.MaxInt8
math.MinInt16
math.MaxInt16
math.MinInt32
math.MaxInt32
math.MinInt64
math.MaxInt64
math.MaxUint
math.MaxUint8
math.MaxUint16
math.MaxUint32
math.MaxUint64
math.MaxFloat32
math.MaxFloat64
math.SmallestNonzeroFloat32
math.SmallestNonzeroFloat64当前 int 位宽
strconv.IntSize进制
binary := 0b1010
octal := 0o12
decimal := 10
hex := 0x0A类型转换
int64(i)
float64(i)
int(f)
string(r)
[]byte(s)
[]rune(s)
string(bytes)
string(runes)字符串长度
len(s) // 字节数
utf8.RuneCountInString(s) // rune 数
utf8.ValidString(s) // 是否为合法 UTF-8数字解析
n, err := strconv.Atoi(text)
i, err := strconv.ParseInt(text, 10, 64)
u, err := strconv.ParseUint(text, 10, 64)
f, err := strconv.ParseFloat(text, 64)
b, err := strconv.ParseBool(text)数字格式化
strconv.Itoa(n)
strconv.FormatInt(i, 10)
strconv.FormatUint(u, 10)
strconv.FormatFloat(f, 'g', -1, 64)
strconv.FormatBool(b)特殊浮点
math.IsNaN(f)
math.IsInf(f, 0)
math.Inf(1)
math.Inf(-1)
math.NaN()
math.Signbit(f)常用字符串工具
strings.Contains(s, sub)
strings.HasPrefix(s, prefix)
strings.HasSuffix(s, suffix)
strings.TrimSpace(s)
strings.Fields(s)
strings.Split(s, sep)
strings.Join(parts, sep)
strings.EqualFold(a, b)
strings.Clone(s)23. 可运行示例
本节把最容易“看懂定义、写错边界”的三类问题放进可执行程序:UTF-8 索引、整数溢出和 JSON 数字精度。源码统一位于 examples/ch03。
23.1 同时观察字节、rune 与字节索引
同一个字符串有三种计数视角。 len(text)、utf8.RuneCountInString 和 range text 各自在数什么?
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
text := "Go语言"
// len(string) 返回 UTF-8 编码后的字节数,不是“字符数”。
fmt.Printf("字节数=%d rune数=%d\n", len(text), utf8.RuneCountInString(text))
// range string 的索引是每个 rune 的起始字节偏移,值才是解码后的 Unicode 码点。
for byteIndex, r := range text {
fmt.Printf("byte=%d rune=%q code=%U\n", byteIndex, r, r)
}
}运行:
go run ./examples/ch03/unicode预期输出:
字节数=8 rune数=4
byte=0 rune='G' code=U+0047
byte=1 rune='o' code=U+006F
byte=2 rune='语' code=U+8BED
byte=5 rune='言' code=U+8A00对照输出看清字节与码点。
- Go string 保存字节序列;
len得到 8,因为两个 ASCII 字母各占 1 字节,两个汉字各占 3 字节。 RuneCountInString解码 UTF-8 后得到 4 个码点。- range 的 index 是当前 rune 在原字符串中的字节起点,所以索引从 2 跳到 5。
- rune 是 Unicode 码点,不等于用户眼中的“一个完整字符”;组合音标和 emoji 仍可能由多个 rune 构成。
换成组合字符再观察。 把文本改为 "e\u0301",比较字节数、rune 数和肉眼看到的字符数;再加入 text[2],观察它得到的是汉字 UTF-8 编码的第一个字节。
23.2 显式检查无符号整数溢出
溢出不会主动报错。 固定宽度整数相加超出范围时,怎样避免悄悄回绕?
package main
import (
"fmt"
"math"
"math/bits"
)
// addChecked 返回结果和是否溢出。
// 无符号加法溢出会按 2^64 回绕,语言不会自动报错;carry 才是边界信号。
func addChecked(left, right uint64) (sum uint64, overflow bool) {
sum, carry := bits.Add64(left, right, 0)
return sum, carry != 0
}
func main() {
sum, overflow := addChecked(40, 2)
fmt.Printf("40 + 2 = %d, overflow=%t\n", sum, overflow)
wrapped, overflow := addChecked(math.MaxUint64, 1)
fmt.Printf("MaxUint64 + 1 = %d, overflow=%t\n", wrapped, overflow)
}运行:
go run ./examples/ch03/checked-overflow预期输出:
40 + 2 = 42, overflow=false
MaxUint64 + 1 = 0, overflow=true进位值给出了判断依据。
uint64运算按模 $2^{64}$ 进行,最大值加一会得到 0,不会自动产生 error 或 panic。bits.Add64同时返回低 64 位结果和进位carry,进位非零就说明发生溢出。- 包装函数把底层的
uint64 carry转成业务更容易理解的bool。 - 若溢出不能接受,调用方必须在使用
sum前处理标志;打印标志但继续入账仍然是 bug。
把边界向前移动一格。 测试 math.MaxUint64-1 分别加 1 和 2;再用 bits.Sub64 写一个检查无符号减法下溢的函数。
23.3 保住 JSON 整数和金额精度
一次 JSON 解码会暴露两类精度问题。 为什么解码到 any 的大整数会变化?金额为什么更适合用最小货币单位保存?
package main
import (
"encoding/json"
"fmt"
"strings"
)
func main() {
const document = `{"order_id":9007199254740993,"amount_cents":1999}`
// 解码到 any 时,默认数字会进入 float64;超过 2^53 后,某些整数无法精确表示。
var loose map[string]any
if err := json.Unmarshal([]byte(document), &loose); err != nil {
panic(err)
}
fmt.Printf("默认 float64 订单号: %.0f\n", loose["order_id"].(float64))
// UseNumber 先保留 JSON 数字的十进制文本,再由业务代码决定转成 int64、big.Int 等类型。
decoder := json.NewDecoder(strings.NewReader(document))
decoder.UseNumber()
var exact map[string]json.Number
if err := decoder.Decode(&exact); err != nil {
panic(err)
}
orderID, err := exact["order_id"].Int64()
if err != nil {
panic(err)
}
amountCents, err := exact["amount_cents"].Int64()
if err != nil {
panic(err)
}
// 金额用“分”的整数保存,避免把 19.99 放进二进制浮点后参与精确结算。
fmt.Printf("精确订单号: %d\n", orderID)
fmt.Printf("金额: %d.%02d 元\n", amountCents/100, amountCents%100)
}运行:
go run ./examples/ch03/json-precision预期输出:
默认 float64 订单号: 9007199254740992
精确订单号: 9007199254740993
金额: 19.99 元示例分别守住整数和金额边界。
- JSON 解码到
map[string]any时,数字默认成为float64;2^53+1已不能被它精确表示。 Decoder.UseNumber让数字先以json.Number保存原始十进制文本,再显式调用Int64。- 转换仍可能失败或越界,所以
Int64的 error 不能忽略。 - 示例把 19.99 元存成 1999 分的整数,结算加减保持精确;展示时再拆分整数部分和小数部分。
继续把输入推到范围之外。 把订单号改成超过 int64 最大值的数字,观察 Int64 错误;再定义带 int64 字段的结构体直接解码,比较它与 UseNumber 的适用边界。
24. 官方资料
- Go Language Specification:Types and values
- Go Language Specification:Constants
- Go Language Specification:Conversions
- Go Language Specification:Integer overflow
math包math/bits包math/cmplx包math/big包strconv包unicode包unicode/utf8包strings包cmp包unsafe包- Strings, bytes, runes and characters in Go
- Constants
- Text normalization in Go
入门文章适合快速认识类型名,整数溢出、浮点边界以及字符串与 Unicode 的规则,则应回到语言规范和标准库文档确认。看到“rune 就是多字节字符”或“字符保存的是 UTF-8 码值”这类表述时尤其要留意:码点、编码和用户感知字符是三个不同的概念。