02-变量、常量和字符串:len("Hello, 世界") 为什么不是 9?
本系列《从零开始学 Go:语言、并发与底层原理》是整个微服务课程的语言前置篇。这一篇回答一个"看起来简单、答错一大片"的问题:
len("Hello, 世界")为什么返回 13 而不是 9? 顺路把程序里最基础的三样东西装进你脑子:变量怎么定义、常量怎么编号、每一种数据住在哪种"格子"里。它是语法地基里最厚的一块------后面所有章节的数据,都从这篇的格子出发。
一、一个用户信息页,数据往哪放
第 01 篇的程序只会打印一句固定的话。现在升级一点:一个最简单的用户信息页,要展示用户名、年龄、是不是会员、一句签名。这些数据从哪来、放哪、怎么算,就是我们今天的主角:
图里的"放进名字里",就是变量------给一块存储起名字,用名字读写里面的值。但 Go 的变量有自己的一套规矩:名字能起在哪儿、没填值的时候装什么、两个同名名字谁挡着谁。这些规矩不搞清楚,写出来的代码编译器会一路报错。我们一节一节过。
二、变量:给数据起名字
变量是一块带名字的存储位置 。Go 是静态类型语言(第 01 篇说的第二件事),落到变量上就是三条铁律:先定义再使用、定义时类型必须确定、类型定下不能改。可以看成先登记,再放值:登记表上写下名字和类型,没登记的名字编译器直接拒绝。
2.1、三种写法,:= 为什么不能写在函数外面
登记有三种姿势,差别在"类型谁来写、能不能写在函数外面":
go
package main
import "fmt"
func main() {
// 第一种:先登记类型,值稍后放。这一刻还没有值,类型必须自己写。
var name string
name = "Bobby"
// 第二种:登记时顺带放值。编译器从右边的 18 推断出 age 是 int。
var age = 18
// 第三种:短变量声明,:= 表示「这里新建一个变量」。
// 类型和 var 全省了,只能写在函数里面。
city := "Beijing"
fmt.Println(name, age, city)
}
放在 ch02-var 目录执行 go run .,输出:
text
Bobby 18 Beijing
三种写法背后的机制值得说透。第一种是声明与赋值分离 ,适合值一会儿才有、先占位置的场景;第二种把推断交给编译器,函数内外都能用;第三种 := 最省字,但有两条边界:只能写在函数里,而且它的含义是"新建",不是"修改"。
:= 为什么不能写在函数外面?因为包这一层(函数外面)只接受声明 :var、const、func、type。:= 是一条语句 ------它意味着"执行到这里,创建变量并赋值",而语句必须活在函数体内,Go 不允许文件从上往下直接执行。把 city := "Beijing" 挪到 main 外面,报错就是第 01 篇见过的那句:
text
non-declaration statement outside function body
同一对花括号里,同一个名字也不能定义两次。再写 city := "Shanghai" 会报 no new variables on left side of :=。改值用 =:
go
city = "Shanghai" // 这是赋值,不是再定义
判断一行是"新建"还是"修改"只有一个依据:左边有没有 :。这个区别后面讲遮蔽时会再放大一次。
2.2、零值:声明完就有的默认值
声明了没赋值,Go 不会给你留下一块残留数据,而是放进一个确定的默认值 ,叫零值。C 语言里未赋值的局部变量可能是内存残渣,读出来无法预期;Go 把这件事定死:声明完成的那一刻,值就已经是零值。
| 类型 | 零值 | 打印时看到什么 |
|---|---|---|
int 等整数 |
0 |
0 |
float32/64 |
0 |
0 |
string |
"" |
两个引号之间是空的,没有字符 |
bool |
false |
false |
go
package main
import "fmt"
func main() {
var age int
var name string
var ok bool
// %d 按整数、%s 按字符串、%t 按布尔打印。单引号是为了把空字符串显出来。
fmt.Printf("age=%d, name='%s', ok=%t\n", age, name, ok)
}
输出:
text
age=0, name='', ok=false
name 是空字符串------不是空格,也不是 null。零值让"先声明、后赋值"成为安全的写法:声明完立刻去读,读到的一定是这张表里的值。
但注意另一条相反的规定:函数里的局部变量,定义了就必须用。声明了却从头到尾没读没写,编译失败:
go
func main() {
city := "Beijing"
}
报错:
text
city declared and not used
这是语法层面的强制,防止函数里堆着没用的名字。把 city 传给 Println 就消除。包级变量不套这条------写在函数外面的变量允许先定义着不用,编译器只给提示不给错误。
2.3、作用域:名字从哪到哪
作用域回答"一个名字从定义处开始,到哪里还看得见"。规则可以收成一句:名字在包住它的那对花括号里有效,出了括号就消失;用的时候从里往外找,最近的一层有就用这层的。
三层是包含关系。if 自己带一对花括号,所以 if 里定义的名字出了 if 就没有了。看一个"外面进得了、里面出不去"的例子:
go
package main
import "fmt"
var name = "Bobby" // 包级,A 和 main 都能读
func A() {
fmt.Println(name) // 往外一层层找,找到包级的 name
}
func main() {
mainName := "main" // 只活在 main 的花括号里
fmt.Println(mainName)
A()
}
输出:
text
main
Bobby
如果 A 里写 fmt.Println(mainName),报错 undefined: mainName------main 和 A 是两对互不包含的花括号,谁也看不见谁的局部变量。
if 分支和 else 分支各自定义同名变量,看起来"不论走哪个分支外面都有它",实际两个名字都死在各自的花括号里,出了 if 谁都不存在:
go
x := 1
if x > 0 {
n := "if 分支"
fmt.Println(n)
} else {
n := "else 分支"
fmt.Println(n)
}
// 这里写 fmt.Println(n) 会报 undefined: n
Go 的名字跟着花括号 走,不跟着"哪个分支执行过"走。出了分支还要用的名字,先在分支外面声明,分支里只用 = 赋值。这一条习惯后面会反复救你。
2.4、同名遮蔽::= 什么时候"挡"住外面的变量
这是本章最阴的坑,值得单独一节。看代码:
go
package main
import "fmt"
func main() {
count := 1
fmt.Println("进入 if 之前:", count)
if true {
count := 100 // := 是新建,这个 count 只活在 if 里
fmt.Println("if 里面:", count)
}
fmt.Println("离开 if 之后:", count)
}
输出:
text
进入 if 之前: 1
if 里面: 100
离开 if 之后: 1
中间打印 100,外面还是 1。内层用 := 定义了一个和外层同名的变量,内层从此看见的是自己这个新的,外层那个还在,只是被挡住了------这叫同名遮蔽(shadowing)。查找规则是"从里往外、最近优先",内层新名字正好卡在查找路径的第一站。
要改外层的值,就别用 :=。内层没有同名新变量时,= 会顺着往外找到原来那个:
go
var name = "Bobby"
func main() {
mName := "外面"
if true {
mName = "imooc" // 找到外层的 mName 改掉
name = "改包级" // 找到包级的 name 改掉
}
fmt.Println(mName, name)
}
输出 imooc 改包级,两行相同------改的是同一对变量。
最常踩进遮蔽的是 err。外面已经有一个 err,if 里写 data, err := doSomething():因为左边有新名字 data,整句按新建处理,err 也被当成内层新变量,外面的 err 不会被这次调用改到。这是 := 的一个隐蔽规则:左边只要有一个新名字,:= 就成立,其余旧名字也一起变成内层新变量 。想改外面那个,得写成 data, err = doSomething(),并且 data 也要事先声明过。第 05 篇专门讲错误处理时会再次用到这个分辨。
2.5、匿名变量:下划线
函数经常一次交回两个结果(完整写法第 05 篇展开)。比如一个除法函数同时交回商和余数:
go
package main
import "fmt"
// divide 交出两个 int:商和余数。
func divide(a, b int) (int, int) {
return a / b, a % b
}
func main() {
quotient, remainder := divide(17, 5)
fmt.Println(quotient, remainder) // 3 2
q, _ := divide(17, 5) // 只要商,余数丢掉
fmt.Println(q) // 3
_, r := divide(17, 5) // 只要余数
fmt.Println(r) // 2
}
下划线 _ 叫匿名变量,表示"这个结果我收下了,随即丢掉"。它只在赋值的左边 占一个位置------不是一块能再读的存储,没有地址,不能出现在 fmt.Println(_) 里。四种接法对个账:
| 左边怎么写 | 结果 |
|---|---|
quotient, remainder := divide(17, 5),两个都用 |
编译通过 |
接住 remainder 却从不用 |
remainder declared and not used |
q, _ := divide(17, 5) |
编译通过,余数被丢掉 |
q := divide(17, 5) |
编译失败,个数对不上:assignment mismatch |
返回值个数必须接满,一个都不能少------这是 Go 多返回值的硬规矩。第 03 篇循环、第 05 篇错误处理里,_ 会反复出场。
2.6、包级变量:写在函数外面的 var
函数外面也可以定义变量,叫包级变量 (也常叫全局变量,但它属于这一个包,别的包能不能看见要看首字母大小写,那是第 07 篇的事)。它用 var 写,本包的各个函数都能读;几个放一起时写成 var ( ),免得每一行都再写一个 var:
go
package main
import "fmt"
// 包级变量。Name、Age、OK 在 main 里可以直接用。
var (
Name = "Bobby"
Age = 18
OK = true
)
func main() {
fmt.Println(Name, Age, OK) // Bobby 18 true
}
包级变量和局部变量有三处不同,2.2 节已经碰到第一处:局部变量定义了必须用,包级变量允许先定义着 ------编译器只给提示不给错误。另外两处:包级变量用 var(函数外面写 := 报 2.1 节那个错);它的优点是本包的函数都能读到同一份值,缺点是任何一个函数都能把它改掉 ,改动从函数签名上看不出来,查错时要全包搜索。所以能放在函数参数里传递的数据,就不要先做成包级变量;常量如果永远不会改,用下一节的 const,不要做成一个"大家约定不去改"的包级变量。
三、常量与 iota
变量会变,有些东西天生不该变:圆周率、错误码、星期。定义时就给定、之后不能再赋值的名字叫常量,关键字 const。值在编译时就要能确定,运行中不能执行语句去改它。给它赋值会被编译器拒绝:
go
const PI = 3.1415926
// PI = 3 // 报错:cannot assign to PI
常量允许的类型是布尔、数值和字符串。切片、map、结构体的内容要到运行时才能准备好,不能当常量。定义时类型可写可不写:const PI float64 = 3.141592 是显式,const PI = 3.141592 由右边推断。和局部变量不同,常量定义了不用,编译也能通过。
命名上,本课自己写的常量用全大写加下划线(MY_NAME、ERR_OK),普通变量用小写驼峰。标准库里的常量多是驼峰(如 http.StatusOK),调用时按库里的名字写,不要改。
3.1、省略时沿用上一行
一组常量放进 const ( ) 里,某一行如果既不写类型也不写值,就沿用上一行的类型和值:
go
const (
x int = 16
y
s = "abc"
z
m
)
func main() {
fmt.Println(x, y) // 16 16
fmt.Println(s, z, m) // abc abc abc
}
y 沿用上一行 x,得到 int 和 16。s 重新给了值,从它起沿用被打断;z、m 都沿用 s。沿用的是紧挨着的上一行,不是分组第一行。这条规则是理解下一节 iota 的钥匙。
3.2、iota:流水号怎么自动递增
一组错误码手写成 Err1 = 1、Err2 = 2,插一个中间项,后面的编号全部要手改。iota 是编译期的常量计数器,表示**"这是这个 const 括号里的第几行"**:第一行是 0,每往下一行加 1:
go
const (
Sunday = iota // 第 0 行,0
Monday // 沿用 iota,第 1 行,1
Tuesday
Wednesday
Thursday
Friday
Saturday
)
func main() {
fmt.Println(Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
}
输出 0 1 2 3 4 5 6。想从 1 开始,第一行写 Sunday = iota + 1,后面省略的行沿用表达式 iota + 1,得到 1 到 7。
这里藏着一个必须分清的机制:3.1 节的"省略"沿用的是一个已经算死的值 ,iota 的"省略"沿用的是一个随行号变化的表达式。所以中间插入一行,后面的编号跟着行号自动后移:
go
const (
Err1 = iota + 1
Err2
Err2b // 插入这一行
Err3
)
输出 1 2 3 4------Err3 从 3 变成 4,变的是行号,不是我们手写了 4。
再看一个最容易答错的问题:中间有一行写死别的值,后面再写 iota,得到什么?
go
const (
A = iota // 第 0 行 → 0
B // 第 1 行 → 1
C = 100 // 这一行值是 100,但行号已经走到 2
D = iota // 第 3 行 → 3,不是 101
)
func main() {
fmt.Println(A, B, C, D) // 0 1 100 3
}
iota 永远给当前行号,不关心这一行最后把值写成了什么 ------哪怕 C = "pause" 是字符串,计数器照样走掉,D 仍是 3。每出现一个新的 const 关键字,计数从 0 重新开始,不会接着上一组的数字往下加:
go
const (
X = iota
Y
)
const (
P = iota // 新的一组,从 0 重新数
)
func main() {
fmt.Println(X, Y, P) // 0 1 0
}
P 是 0,因为它在新的一组里排第 0 行。iota 只适合"一组整数依次递增"的场景;编号有固定含义、不能因为插行就变的时候,把数字写死。它的代价也在这里:读代码的人要知道省略的那一行沿用的是一个表达式 、不是上一行的结果,而且中间夹一个别的值之后,下一个 iota 是行号。
四、类型:每种数据住哪种格子
基本类型是这一章直接写在变量上的类型,分五族:布尔、整数、浮点数、字符、字符串。选错格子,轻则浪费空间,重则数值悄悄溢出。
4.1、整数档位与溢出
整数按两件事分档:占几个字节、有没有符号 。1 字节是 8 比特,比特是一位二进制,只能是 0 或 1。uint8 的 u 是 unsigned,无符号;int8 有符号,正负都能放。8 个比特全用来记大小,范围是 0 到 255;最高的 1 位分给正负号,int8 就是 -128 到 127。
| 有符号 | 无符号 | 字节数 | 范围 |
|---|---|---|---|
int8 |
uint8 |
1 | -128 |
int16 |
uint16 |
2 | -32768 |
int32 |
uint32 |
4 | 约 ±21 亿 / 0~42 亿 |
int64 |
uint64 |
8 | 约 ±922 京 / 0~1844 京 |
int 和 uint 不带数字,宽度跟着编译目标机器走------64 位机器上是 64 位。宽度和 int64 相同,类型仍然不是 int64,两者不能直接赋值,第 4.5 节要转一次。
int 和 uint 不带数字,宽度跟着编译目标机器走------64 位机器上是 64 位。宽度和 int64 相同,类型仍然不是 int64,两者不能直接赋值,第 4.5 节要转一次。
拿不准用哪一档时用 int(64 位机器上范围够普通计数用,标准库很多参数也是 int,少做转换)。三种情况再改用带宽度的类型:要和固定宽度的存储对齐 (协议里写明这个字段 4 个字节,就用 int32);范围很小、数量很多的字段 (年龄用 uint8 省空间);明确需要 64 位且要和 int64 参数互相传递 (直接声明 int64,不要依赖"int 在这台机器上碰巧也是 64 位")。
选档位看数据上限。年龄不会是负数、到不了 255 以上,用 uint8 最省;计数器没把握,用 int。档位定错最可怕的不是报错,而是溢出不报错:
go
var small uint8 = 255
small = small + 1
fmt.Println(small) // 0
255 的二进制是 8 个 1,加 1 进位成 9 位,uint8 只留 8 位,最高位被丢掉,剩下的 8 个 0 就是 0。Go 的整数溢出不报错、不中断,静悄悄绕回范围内------这是选择宽度时必须看数据上限的原因。
4.2、浮点数与 0.1 + 0.2
浮点类型只有 float32 和 float64,没有 float 这个类型------var price float 报 undefined: float。小数常量默认按 float64 推断。两个类型能表达的范围差得很远:float32 约 7 位十进制有效数字,float64 约 15 位 ;有效数字是从第一个非零数字开始、能可靠保留的那些位,所以把很长的圆周率放进 float32,后面几位会保不住(打印出来是 3.1415927)。数量级上 float32 大约到 1038,float64 大约到 10308,比 int64 的上限还大------但那是近似值,不是每一位都精确的整数,需要精确整数时仍然用整数类型。默认用 float64;只有要存很多个、并且 7 位有效数字够用时,才用 float32。
经典问题来了:
go
fmt.Println(0.1 + 0.2) // 0.30000000000000004
fmt.Println(0.1+0.2 == 0.3) // false
为什么不是 0.3?因为浮点数按 IEEE 754 这个二进制 标准来存,而 0.1 写成二进制是一段无限循环小数,有限的存储位数只能保留一个最接近的近似值,0.2 同理。两个近似值相加,比 0.3 的近似值多出一个极小的尾数。这不是 Go 的 bug,所有用这套标准的语言都这样 。所以判断两个浮点数是否"相等",不能写 ==,要写"差足够小":
go
sum := 0.1 + 0.2
fmt.Println(math.Abs(sum-0.3) < 1e-9) // true
1e-9 是 10−9。界限按业务能接受的误差定,不是所有题都用这个数。更进一步的纪律:金额不要用 float64 累加 。19.99 元加减几次可能变成 19.989999999999998,本课把金额换成整数、以分为单位(19.99 元写成 1999),第 02 篇电商系列的商品价格就是这么存的。
4.3、布尔
bool 只有 true 和 false 两个值,零值是 false。它有两条硬规矩:
- 不能和整数互转 :
int(ok)、bool(1)都编译失败; - 没有"非零即真" :
if 1 {}编译失败,必须写成if n != 0 {}------条件本身必须是布尔。
这逼着条件永远是显式判断。别的语言里 if list 表示"列表不空",Go 里得写 if len(list) > 0。初看啰嗦,换来的是读代码时没有歧义。
4.4、byte 与 rune:字符怎么存
Go 没有 char 类型。一个字符用整数存:单引号里是字符,双引号里是字符串------'a' 是字符,"a" 是长度 1 的字符串,两者类型不同,不能直接赋值。
存字符有两个格子。byte 是 uint8 的别名 (别名是同一个类型的另一个名字,可以直接互相赋值),范围 0 到 255,装得下 ASCII 里的英文字母、数字和标点。中文的 Unicode 编号远超 255,rune 是 int32 的别名,用来存码点------码点就是字符在 Unicode 里的整数编号:
go
var c1 byte = 'a'
var c2 byte = 97 // 97 和 'a' 是同一个编号,字形一样
fmt.Printf("c1=%c,编号=%d\n", c1, c1) // c1=a,编号=97
c1 = c1 + 1
fmt.Printf("加 1 之后=%c\n", c1) // 加 1 之后=b
var c3 rune = '慕'
fmt.Printf("%c\n", c3) // 慕
'a' 加 1 得到 'b',因为参与运算的是编号:97 + 1 = 98,98 对应 b。直接 Println 打出整数,%c 才按字形打。把 '慕' 写进 byte 会编译失败,报 overflows------它的码点超出了 0 到 255。
4.5、类型转换与 strconv
不同类型不能直接赋值,数字一族之间用 目标类型(值) 显式转换,得到一个新值,原变量不变:
go
var a int8 = 12
b := uint8(a) // 12
c := int32(3.14) // 3,小数部分被丢掉,这是转换的结果不是算错
var n int = 12
wide := int64(n) // int 和 int64 宽度相同也要转,类型不同
把 var wide int64 = n 直接写,报 cannot use n (variable of type int) as int64。浮点转整数丢小数、超出范围丢高位,转换前先看目标装不装得下。
字符的转换就是"取编号、还字形":
go
var ch rune = 'A'
n := int(ch) // 65,取编号
next := rune(n + 1) // 编号 66 变回字形 B
字符串和数字之间不能 用 T(值) 那套------int("12") 不是这种转换,要用标准库 strconv(string conversion)。从字符串转出去可能失败 ,失败时不中断程序,而是返回 error 和零值,所以必须先看 error:
go
n, err := strconv.Atoi("123")
fmt.Println(n, err) // 123 <nil>
n2, err2 := strconv.Atoi("十二")
fmt.Println(n2, err2) // 0 strconv.Atoi: parsing "十二": invalid syntax
s := strconv.Itoa(42)
fmt.Println(s) // 42,已经是字符串
Atoi 字符串转整数,Itoa 整数转字符串(任意整数都写得出来,所以不返回 error)。注意第二行:失败时整数是零值 0,不能把 0 当成"解析出了 0" ,必须先确认 err 是 nil。nil 表示"没有错误",不是空字符串。
其余方向的速查表:
| 方向 | 十进制整数优先 | 需要进制或小数位数时 |
|---|---|---|
| 字符串转整数 | Atoi |
ParseInt(串, 进制, 位数) |
| 整数转字符串 | Itoa |
FormatInt(数, 进制) |
| 字符串转浮点数 | ParseFloat |
第二参数选 32 或 64 |
| 浮点数转字符串 | FormatFloat |
格式 'f',写明小数位数 |
| 布尔 | ParseBool / FormatBool |
失败时布尔是零值,必须看 error |
ParseBool 接受 "1" 和 "0",写成 "12" 会失败并返回 false + 错误------把 false 当业务结论前,先看错误。
其余方向的函数名叫 Parse 加类型,凡是从字符串转出都返回 error。ParseFloat 的第二个参数是比特数,64 表示按 float64 的范围认这串文字、返回类型仍是 float64;ParseInt 有三个参数------字符串、进制、比特数,ParseInt("12", 8, 64) 按八进制解析,得到 1×8+2=10;ParseBool 接受 "true"、"false"、"1"、"0"。反过来把值写成字符串,函数名是 Format 加类型且不返回 error:FormatFloat(3.1415926, 'f', 2, 64) 按普通小数保留两位得到 "3.14",FormatInt(-42, 16) 按十六进制写出 -2a------格式 'f' 是普通小数、第三个参数是小数的位数。一个完整的往返示例:
go
f, err := strconv.ParseFloat("3.1415", 64)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(f) // 3.1415
n, err := strconv.ParseInt("-42", 10, 64)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(n) // -42
fmt.Println(strconv.FormatFloat(f, 'f', 2, 64)) // 3.14
fmt.Println(strconv.FormatInt(n, 16)) // -2a
只做十进制整数互转时,Atoi 和 Itoa 参数最少、优先用;需要进制或小数位数时再查这张表。第 11 篇讲服务端常用标准库时会把这一族和 time、strings 放一起用。
五、运算符
运算符写在值之间算出一个新结果,分算术、关系、逻辑、位、赋值五族,最后谈优先级。
5.1、算术与赋值
+ - * / % 加、减、乘、除、取余。两个要点:整数相除结果还是整数 ,17 / 5 是 3 不是 3.4,要小数先把两边转浮点,float64(17) / float64(5) 才是 3.4;字符串可以用 + 拼接,不能用 - ,"Hello" - "H" 报 operator - not defined。
i++、i-- 是语句不是表达式:只能单独一行、符号在变量后面,++i 和 fmt.Println(i++) 都编译失败。赋值运算符 c += 5 就是 c = c + 5,减乘除移位同理。
5.2、关系、逻辑与短路
| 符号 | 含义 |
|---|---|
== != |
相等 / 不相等 |
< > <= >= |
小于 / 大于 / 小于等于 / 大于等于 |
&& |
与:两个都为真才是真 |
| ` | |
! |
非:真假互换 |
&& 和 || 会短路:左边已经决定结果,右边不再执行。用带打印的函数证明:
go
func right() bool {
fmt.Println("右边执行了")
return true
}
fmt.Println(false && right()) // 只打印 false,右边没执行
fmt.Println(true || right()) // 只打印 true,右边没执行
fmt.Println(true && right()) // 先打印"右边执行了",再打印 true
短路不只是省一次调用,它保护危险操作:第 05 篇会写 p != nil && p.Age >= 18------左边已经是假,右边就不会去读空指针里的字段。所以两个条件的顺序不能随便对调:对调之后,右边可能在左边还没挡住它的时候就执行了。
5.3、位运算与权限位
逻辑运算得到真或假;位运算把两个整数拆成二进制,按每一个比特 算出一个新整数。符号只差一个字符:& 不是 &&,| 不是 ||。
| 运算符 | 名字 | 每一个比特怎么算 |
|---|---|---|
& |
按位与 | 两个都是 1,结果才是 1 |
| ` | ` | 按位或 |
^ |
按位异或 | 两个不同,结果才是 1 |
&^ |
位清除 | 右边是 1 时,结果对应位变 0 |
<< |
左移 | 全体左移右边补 0,相当于乘 2n |
>> |
右移 | 全体右移,相当于除以 2n |
用 60(二进制 0011 1100)和 13(0000 1101)逐位对账:
text
60 0011 1100
13 0000 1101
按位与 0000 1100 = 8 + 4 = 12
按位或 0011 1101 = 61
按位异或 0011 0001 = 49
位清除 0011 0000 = 48
业务代码里最常见的用途是权限位 :一个整数的每个比特代表一种权限,1 << n 造出第 n 位,| 叠加、& 查询、&^= 收回:
go
const (
PermRead = 1 << 0 // 0001
PermWrite = 1 << 1 // 0010
PermDelete = 1 << 2 // 0100
)
func main() {
perm := PermRead | PermWrite // 0011
fmt.Println("可写:", perm&PermWrite != 0) // true
perm |= PermDelete // 0111
perm &^= PermWrite // 0101,写被收回
fmt.Println("可写:", perm&PermWrite != 0) // false
}
一个整数装下很多开关,判断和修改都是一次整数运算。代价是读的人必须知道每一位代表什么------开关只有两三个时,分几个 bool 更清楚。标准库里打开文件的标志 os.O_CREATE|os.O_WRONLY|os.O_APPEND 也是用 | 把几个开关合成一个整数,用到文件时再查。还要分清同一个 & 的两个身份:两个整数中间是位运算,变量前面是取地址 。a & b 是按位与,p := &a 是问 a 住在哪,p 的类型是 *int(指针第 05 篇展开),*p 再顺地址把值读出来。
5.4、优先级
一行多个运算符,谁先算由优先级决定,数字越大越先:
| 优先级 | 运算符 |
|---|---|
| 5 | * / % << >> & &^ |
| 4 | + - ` |
| 3 | == != < <= > >= |
| 2 | && |
| 1 | ` |
!、取地址的 &、读指针的 * 比表里这些都先算。表不用背 :拿不准就加括号,读的人也不必回忆级别。1 + 2*3 是 7,(1+2)*3 是 9,括号把先算谁直接写在脸上。
六、字符串:核心问题的答案
字符串是一串不变的字节,编码用 UTF-8。UTF-8 是变长编码:英文、数字、标点通常 1 个字节,一个中文通常 3 个字节。今天标题里的谜底,就藏在这句话里。
6.1、len 为什么是 13 而不是 9
数一数 "Hello, 世界"。Hello, 是 H、e、l、l、o、逗号、空格,7 个 ASCII 字符,7 个字节;世 和 界 各占 3 个字节,共 6 个字节。 7+6=13------所以 len 是 13。按字符 数是 7+2=9:
text
字节下标 0 1 2 3 4 5 6 7 8 9 10 11 12
字节 H e l l o , 空 世 世 世 界 界 界
字符 H e l l o , 空 世 界
go
s := "Hello, 世界"
fmt.Println("len =", len(s)) // 13
fmt.Println("字符数 =", utf8.RuneCountInString(s)) // 9
fmt.Println(s[0]) // 72,'H' 的 ASCII 编号
len 数的是字节,不是字符个数 ------这就是副标题的答案。要数字符个数,用 unicode/utf8.RuneCountInString,它按 UTF-8 的字符边界来数。下标 s[7] 取到的只是 世 的第一个字节,不是"世"这个字;真正按字遍历靠第 03 篇的 for range。
第二件事:字符串创建后不能改 。s[0] = 'h' 编译失败:
text
cannot assign to s[0] (neither addressable nor a map index expression)
要一份不同的内容,就拼一份新字符串,原串保持不动。这条"不可变"的好处有两个:第一,一份字符串可以被多个变量同时引用,谁也改不了它 ,就不存在"改到一半被别的 goroutine 看见"的竞态------并发安全是设计上白送的;第二,很多字符处理函数可以放心共享同一份底层数据、只在需要时才复制。这两条的好处要到第 17 篇看底层结构时才会完整兑现,现在先记住:所有"改字符串"的函数,返回的都是新字符串。
6.2、strings 包与字节下标
strings 是处理字符串的标准库。这些函数都不修改原串 ,替换、改大小写、去空白返回的全是新字符串。查找返回的位置是字节下标 ,不是"第几个汉字";找不到时 Index 返回 -1:
go
s := "Hello, 世界"
fmt.Println(strings.Contains(s, "世界")) // true
fmt.Println(strings.Index(s, "世界")) // 7,不是"第 8 个字符"
s2 := strings.Replace(s, "世界", "Go", 1) // 1 表示只换从左数第一个
fmt.Println(s) // Hello, 世界 原串没变
fmt.Println(s2) // Hello, Go
Replace 的最后一个参数是换几个:1 换第一个,2 换前两个,写 -1 表示全换。常用函数合一张表:
| 函数 | 作用 |
|---|---|
Contains / Count |
是否包含 / 出现次数 |
Index |
子串的字节下标,找不到返回 -1 |
HasPrefix / HasSuffix |
是否以某段开头 / 结尾 |
Split / Join |
按分隔符切开 / 用分隔符接回 |
Replace |
替换,最后一个参数控制换几个,-1 全换 |
ToUpper / ToLower |
转大写 / 转小写,返回新串 |
Trim / TrimSpace |
去掉两侧字符 / 去掉两侧空白(空格、换行、制表符) |
Split 切出的是一组子串(打印成 [a b c]),这种一组值叫切片,第 04 篇专门讲。Trim 的第二个参数是一组要删的字符,只有两侧连续出现的会被删,中间的不会动。这些函数记不全没关系,编辑器里输入 strings.,gopls 会列出名字。
6.3、转义符与原始字符串
双引号字符串里,有些字符没法直接敲:再写一个双引号,编译器以为字符串提前结束;想换行,按回车又把源码拆成两行。反斜杠 \ 加一个字符组成转义符,用两个字符表示一个特殊字符:
| 转义符 | 表示 |
|---|---|
\n |
换行 |
\t |
制表符 |
\r |
回车 |
\" |
一个双引号 |
\\ |
一个反斜杠 |
\' |
一个单引号(rune 字面量里) |
go
fmt.Println("第一行\n第二行") // 一次 Println 打出两行
fmt.Println("Windows 路径:C:\\Users\\bobby") // 每个要显示的反斜杠都写成 \\
另一种是反引号 (键盘 1 左边那个,不是单引号)包起来的原始字符串:内容原样保留,转义符失效,换行可以直接写:
go
s := `Windows 路径:C:\Users\bobby
这里直接换行了
\n 在这里不是换行,就是两个字符`
sql := `SELECT id, name, price
FROM goods
WHERE price > 100`
它适合三种内容:满是反斜杠的路径或正则、多行的 SQL(第 02 篇电商系列会大量出现)、满是双引号的 JSON。代价是里面不能再出现反引号,而且源码的缩进会原样进到字符串里,所以多行内容从行首写起。
6.4、拼接三选一
把几段文字接成一段,有三种写法,结果相同、代价不同:
go
name := "Bobby"
age := 18
s1 := "name=" + name + ", age=18" // 加号
s2 := fmt.Sprintf("name=%s, age=%d", name, age) // 模板填值
var b strings.Builder // 缓冲区
b.WriteString("name=")
b.WriteString(name)
b.WriteString(", age=")
b.WriteString(strconv.Itoa(age)) // 整数要先转字符串
s3 := b.String()
三行都输出 name=Bobby, age=18,差别在运行时造出几份字符串。+ 每算一次就产生一份新字符串 :a + b + c 先算出 "abcd" 这一份中间结果,再和 c 拼成 "abcdef",中间那份用完就被丢弃。循环里反复 +,第 n 次拼接要复制前 n-1 次的全部字符,复制总量约 1+2+⋯+n。Builder 则把各段写进自己的一块缓冲区 (可继续往后写的内存),中间不产生临时字符串,最后 String() 一次性复制出结果:
上一条路:两次加法、两份新字符串,第一份是中间产物。下一条路:写入发生在缓冲区里,对外交出的只有最后那次复制。
三种怎么选:一两处固定文字用 + (常量拼接在编译时就完成了,运行时零成本);字段多、只拼一次用 Sprintf (整数直接进 %d,不用先 Itoa);循环里不断追加用 Builder 。Sprintf 的代价是每次要解析模板里的占位符,热循环里反复解析不如 Builder。
6.5、比较与格式化
相等用 ==、不等用 !=,比的是内容 ,区分大小写:"hello" != "Hello"。大小用 >、<,从左往右逐字符比,比的是码点,不是拼音也不是笔画:
go
fmt.Println("Hello" > "Bobby") // true:'H' 编号 72,'B' 编号 66
fmt.Println('H', 'B') // 72 66,就是上面决定大小的两个编号
需要不区分大小写时,先 ToLower 再 ==。
输出也有讲究。Println 打完自动换行;Print 不换行,两次 Print 会接在同一行;Printf 按模板填值、自己不换行,模板末尾要写 \n;Sprintf 不打印、返回拼好的字符串。常用占位符:
| 占位符 | 作用 |
|---|---|
%v / %#v |
默认格式 / Go 字面量样子 |
%T |
只打印类型 |
%d %b %x |
十进制 / 二进制 / 十六进制 |
%c |
把整数当字符 |
%s %t |
字符串 / 布尔 |
%f / %.2f |
浮点 / 保留两位小数 |
%% |
百分号本身 |
%#v 和 %T 是调试好搭档------同时看见值和类型。占位符个数和参数个数对不齐时,程序仍能编译,运行时会打出带 %! 的提示,第 07 篇用 go vet 把这种对不齐提前查出来。
七、串起来看一遍
把"一个名字从诞生到被打印"的全程串成一条线:
浓缩成六句话:
- 变量是带名字的存储:三种写法里
:=只能写在函数里,它表示"新建"而不是"修改";声明就有零值,函数里的变量定义了必须用。 - 作用域跟着花括号走:内层
:=同名会遮蔽 外层变量;err是重灾区,想改外面的就用=。 const的值编译期定死;iota 是 const 块里的行号计数器,省略行沿用的是表达式,中断后再写 iota 拿到的是行号。- 整数按字节数和符号分档,溢出不报错;
0.1+0.2 != 0.3是 IEEE 754 二进制存储的必然;金额用分,不用 float。 len数的是字节 :中文 3 字节,所以"Hello, 世界"是 13 不是 9;字符串不可变,改它的函数都返回新串。- 位运算用
1<<n、|、&、&^管理权限位;+/Sprintf/Builder三种拼接按场景选,循环里用 Builder。
下一篇预告 :程序现在只会一条路走到黑。下一章让代码学会"看情况"和"重复做"------if 为什么要求左花括号紧跟条件、for range 的循环变量为什么是副本,以及 switch 为什么默认不穿透。有了它,你就能写真正"有逻辑"的程序了。
动手建议
- 跑通 (对应二、三、四节):在
learngo下建ch02-var、ch02-iota、ch02-str三个目录,分别跑通三种变量写法、星期一到星期日的 iota、以及"Hello, 世界"的len和RuneCountInString对照。目录约定见第 01 篇:一个目录一个main。 - 制造失败 (对应 2.1、6.1 节):把
city := "Beijing"写到main外面,撞non-declaration statement outside function body;在ch02-str里加s[0] = 'h',撞cannot assign to s[0]。改回原样,确认恢复。 - 制造异常现象 (对应 2.4、4.2 节):写一个
count := 1后进if里count := 100的程序,看离开if后打印的仍是 1,体会同名遮蔽;再打印0.1+0.2 == 0.3,看到false后按 4.2 节的"差足够小"改写。每个实验都对应正文的一节,先有体感,再看原理。