02-变量、常量和字符串:len("Hello, 世界") 为什么不是 9?

02-变量、常量和字符串:len("Hello, 世界") 为什么不是 9?

本系列《从零开始学 Go:语言、并发与底层原理》是整个微服务课程的语言前置篇。这一篇回答一个"看起来简单、答错一大片"的问题:len("Hello, 世界") 为什么返回 13 而不是 9? 顺路把程序里最基础的三样东西装进你脑子:变量怎么定义、常量怎么编号、每一种数据住在哪种"格子"里。它是语法地基里最厚的一块------后面所有章节的数据,都从这篇的格子出发。

一、一个用户信息页,数据往哪放

第 01 篇的程序只会打印一句固定的话。现在升级一点:一个最简单的用户信息页,要展示用户名、年龄、是不是会员、一句签名。这些数据从哪来、放哪、怎么算,就是我们今天的主角:

flowchart LR A["用户名 是一串文字"] --> E["放进名字里"] B["年龄 是一个整数"] --> E C["是否会员 是一个布尔"] --> E D["签名 是多行文字"] --> E E --> F["在需要的地方读出来、拼成一行"]

图里的"放进名字里",就是变量------给一块存储起名字,用名字读写里面的值。但 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、作用域:名字从哪到哪

作用域回答"一个名字从定义处开始,到哪里还看得见"。规则可以收成一句:名字在包住它的那对花括号里有效,出了括号就消失;用的时候从里往外找,最近的一层有就用这层的。

flowchart TB P["包级变量:本包各个函数都能读"] --> F["函数里的变量:只有这个函数能读"] F --> B["函数里再套的花括号:出了这对括号就没有了"]

三层是包含关系。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。

flowchart LR B["8 个比特"] --> U["无符号 uint8:8 位都记大小,0 到 255"] B --> S["有符号 int8:最高位记正负,-128 到 127"]
有符号 无符号 字节数 范围
int8 uint8 1 -128127 / 0255
int16 uint16 2 -3276832767 / 065535
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 大约到 103810^{38} 1038,float64 大约到 1030810^{308} 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−910^{-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=101\times8+2=10 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,相当于乘 2n2^n 2n
>> 右移 全体右移,相当于除以 2n2^n 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=137 + 6 = 13 7+6=13------所以 len 是 13。按字符 数是 7+2=97 + 2 = 9 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+⋯+n1 + 2 + \cdots + n 1+2+⋯+n。Builder 则把各段写进自己的一块缓冲区 (可继续往后写的内存),中间不产生临时字符串,最后 String() 一次性复制出结果:

flowchart LR A1[&#34;变量 a、b&#34;] --> A2[&#34;加号先算出中间串 abcd&#34;] --> A3[&#34;再和 c 算出 abcdef&#34;] B1[&#34;Builder 缓冲区&#34;] --> B2[&#34;依次写入 ab、cd、ef&#34;] --> B3[&#34;String() 复制出 abcdef&#34;]

上一条路:两次加法、两份新字符串,第一份是中间产物。下一条路:写入发生在缓冲区里,对外交出的只有最后那次复制。

三种怎么选:一两处固定文字用 + (常量拼接在编译时就完成了,运行时零成本);字段多、只拼一次用 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 把这种对不齐提前查出来。

七、串起来看一遍

把"一个名字从诞生到被打印"的全程串成一条线:

sequenceDiagram participant 你 as 你 participant 编译器 as 编译器 participant 程序 as 程序 你->>编译器: var age = 18(登记名字与类型) 编译器->>编译器: 从右边的 18 推断类型是 int 你->>编译器: city := &#34;Beijing&#34;(函数内新建) 编译器->>编译器: 检查作用域:city 是否已在当前层定义 你->>程序: 运行,读 age 得 18 程序->>程序: len(&#34;Hello, 世界&#34;) 按字节数:7 + 3×2 = 13 程序->>程序: s[0]='h' 被编译器拒绝:字符串不可变 你->>程序: strconv.Atoi(&#34;十二&#34;),err 不是 nil 程序->>程序: 返回零值 0 + 错误,不能当成功用

浓缩成六句话:

  1. 变量是带名字的存储:三种写法里 := 只能写在函数里,它表示"新建"而不是"修改";声明就有零值,函数里的变量定义了必须用。
  2. 作用域跟着花括号走:内层 := 同名会遮蔽 外层变量;err 是重灾区,想改外面的就用 =。
  3. const 的值编译期定死;iota 是 const 块里的行号计数器,省略行沿用的是表达式,中断后再写 iota 拿到的是行号。
  4. 整数按字节数和符号分档,溢出不报错;0.1+0.2 != 0.3 是 IEEE 754 二进制存储的必然;金额用分,不用 float。
  5. len 数的是字节 :中文 3 字节,所以 "Hello, 世界" 是 13 不是 9;字符串不可变,改它的函数都返回新串。
  6. 位运算用 1<<n、|、&、&^ 管理权限位;+/Sprintf/Builder 三种拼接按场景选,循环里用 Builder。

下一篇预告 :程序现在只会一条路走到黑。下一章让代码学会"看情况"和"重复做"------if 为什么要求左花括号紧跟条件、for range 的循环变量为什么是副本,以及 switch 为什么默认不穿透。有了它,你就能写真正"有逻辑"的程序了。

动手建议

  1. 跑通 (对应二、三、四节):在 learngo 下建 ch02-var、ch02-iota、ch02-str 三个目录,分别跑通三种变量写法、星期一到星期日的 iota、以及 "Hello, 世界" 的 len 和 RuneCountInString 对照。目录约定见第 01 篇:一个目录一个 main。
  2. 制造失败 (对应 2.1、6.1 节):把 city := "Beijing" 写到 main 外面,撞 non-declaration statement outside function body;在 ch02-str 里加 s[0] = 'h',撞 cannot assign to s[0]。改回原样,确认恢复。
  3. 制造异常现象 (对应 2.4、4.2 节):写一个 count := 1 后进 if 里 count := 100 的程序,看离开 if 后打印的仍是 1,体会同名遮蔽;再打印 0.1+0.2 == 0.3,看到 false 后按 4.2 节的"差足够小"改写。每个实验都对应正文的一节,先有体感,再看原理。
相关推荐
SamDeepThinking1 小时前
CyclicBarrier的底层实现
java·后端·面试
god_cvz1 小时前
第二章:6、对话记忆与引用标注
后端·ai编程
拖孩1 小时前
全程 AI 写的小程序,刚接上会员虚拟支付,上线第二天就有人真金白银买单了|双签名、定价、iOS 的坑我全踩了一遍
前端·后端·微信小程序
我是人✓1 小时前
SpringMVC异常处理与拦截器实战揭秘
java·开发语言·后端
仟里码1 小时前
九:goroutine(1.什么是协程)
后端
不是万能的豆学长1 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
阿狗童鞋1 小时前
SpringSecurity权限认证实战指南
java·后端·spring
韩振方1 小时前
Kubernetes Service 让会更换的 Pod 有稳定访问入口
后端
用户125758524361 小时前
Golang后台管理框架推荐:XYGo Admin与devinggo怎么选?
后端·go·ai编程