- 示例工程
- 教程
【免费下载链接】learngo
❤️ 1000+ Hand-Crafted Go Examples, Exercises, and Quizzes. 🚀 Learn Go by fixing 1000+ tiny programs.
导读
string、byte、rune是 Go 字符串处理中最基础也最容易混淆的三个概念。本篇文章以开源仓库 learngo 第 19 章 19-strings-runes-bytes/exercises/README.md 中的三个递进练习为主线,逐一拆解题目要求、完整题解与底层原理。读完本文,你将掌握[]byte与string的相互转换、Printf对 rune 的各类格式动词,以及用unicode/utf8包正确统计、解码、切片多字节字符串的实战能力。
前置知识:string、byte 与 rune 三者的关系
在动手做练习前,先建立一个准确的模型——这三个类型并不神秘:
- string是不可变的字节序列,本质是一个只读的 byte slice,内部由「指向底层数组的指针 + 长度」组成的字符串头(StringHeader)描述,这一点可以在 05-internals/main.go 的
StringHeader结构体定义中看到; - byte是
uint8的别名,代表一个字节,取值 0~255; - rune是
int32的别名,代表一个 Unicode 码点(codepoint),用于表示一个字符。
UTF-8 编码下,一个字符占 1~4 个字节:ASCII 占 1 字节,拉丁扩展字符占 2 字节,中文(如「今天」)占 3 字节,emoji(如 🤓)占 4 字节。因此字符串的字节长度(len)不等于字符(rune)数量——这正是练习三的核心考点。
rune字面量(如'h')本身是「无类型」的,可以赋给任何整数类型,其默认类型才是rune,参考 01-bytes-runes-strings-basics/main.go:
var ( anInt int = 'h' // 104 anInt8 int8 = 'h' // 104 anInt16 int16 = 'h' anInt32 int32 = 'h' aRune = 'h' // 默认类型即 rune )练习一:Convert —— 打通 []byte 与 string 的转换
题目要求
题目位于 01-convert/main.go,任务分三步:
- 遍历
words字符串切片; - 在循环内把每个字符串转换为字节切片(
[]byte)并打印,同时把转换结果append到bwords; - 最后用
bwords把每个字节切片再转回字符串打印。
期望输出(题目注释中给出)是:先打印 4 组十进制字节数组,再打印还原后的 4 个原始字符串:
[103 111 112 104 101 114] [112 114 111 103 114 97 109 109 101 114] [103 111 32 108 97 110 103 117 97 103 101] [103 111 32 115 116 97 110 100 97 114 100 32 108 105 98 114 97 114 121] gopher programmer go language go standard library题目给了两行骨架:一个words切片和一行var bwords [][]byte,其中[][]byte即「字节切片的切片」。
完整题解
参考官方题解 01-convert/solution/main.go:
package main import "fmt" func main() { words := []string{ "gopher", "programmer", "go language", "go standard library", } var bwords [][]byte for _, w := range words { bw := []byte(w) // string -> []byte fmt.Println(bw) // 直接打印字节切片 bwords = append(bwords, bw) // 收集起来 } for _, w := range bwords { fmt.Println(string(w)) // []byte -> string } }原理纵深:双向转换与不可变性
[]byte(w)把字符串的每个字节原样拷贝成字节切片;string(w)则把字节切片重新编码回字符串,两者互为逆操作,参考 01-bytes-runes-strings-basics/main.go。- 转换后得到的
[]byte是可变的,你可以修改其中的任意字节;而 string 本身不可变——str[0] = 'N'会被编译器直接拒绝。这一「只读字节切片」的语义在 03-bytes-runes-strings-examples/main.go 中有明确演示。 fmt.Println对[]byte会以十进制逐字节打印(这就是期望输出第 1~4 行的来源),对 string 则直接打印文本。bwords中的每个元素都指向各自独立的字节切片,还原输出与原始words完全一致。
练习二:Print the Runes —— 用 Printf 动词看清字符的多种形态
题目要求
题目位于 02-print-the-runes/main.go,围绕常量word = "console"展开四个步骤:
- 遍历
"console",把每个 rune 分别按十进制、十六进制、二进制打印; - 手动把
"console"的 7 个字符以rune 字面量形式写入[]byte并打印; - 同样地,改用十进制数字作为字节切片元素;
- 改用十六进制数字作为字节切片元素。
完整题解
参考官方题解 02-print-the-runes/solution/main.go:
package main import "fmt" func main() { const word = "console" // 1) 逐个 rune,多进制打印 for _, w := range word { fmt.Printf("%c\n", w) fmt.Printf("\tdecimal: %[1]d\n", w) fmt.Printf("\thex : 0x%[1]x\n", w) fmt.Printf("\tbinary : 0b%08[1]b\n", w) } // 2) 用 rune 字面量手动拼出 "console" fmt.Printf("with runes : %s\n", string([]byte{'c', 'o', 'n', 's', 'o', 'l', 'e'})) // 3) 用十进制拼出 "console" fmt.Printf("with decimals : %s\n", string([]byte{99, 111, 110, 115, 111, 108, 101})) // 4) 用十六进制拼出 "console" fmt.Printf("with hexadecimals: %s\n", string([]byte{0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65})) }原理纵深:格式动词与「数字即字符」
%[1]d中的[1]表示「复用第一个参数」,这样一次调用就能用同一个 rune 打印出十进制;%x输出小写十六进制,%08b输出 8 位二进制并补零。同一个字符在这三种进制下本质是同一个整数,只是显示形态不同——例如'c'的十进制是99、十六进制是0x63、二进制是0b01100011。- 第 2~4 步用三种不同写法构造了完全等价的字节切片:rune 字面量、十进制数、十六进制数在 Go 中都是整型常量,
[]byte{...}会自动把它们转换为一组字节。三个string(...)转换后打印的都是console,直观印证了「字符就是数字」这一核心思想。 - 章节配套程序 02-bytes-runes-strings-charset-table/main.go 用同样的动词做了一张完整的字符集对照表(
literal / dec / hex / encoded),并注释了 Unicode 各字符块按 1~4 字节编码的起止范围,如 ASCII 为\u0001~\u007f、emoji 块为\U0001f600~\U0001f64f,可用来直观感受不同进制的对应关系。
练习三:Rune Manipulator —— 用 utf8 包安全处理多字节文本
题目要求
题目位于 03-rune-manipulator/main.go,提供了一组覆盖多种编码宽度的测试字符串:
words := []string{ "cool", // 纯 ASCII,1 字节/字符 "güzel", // 含 2 字节的 ü "jīntiān", // 含 2 字节的 ī、ā "今天", // 中文,3 字节/字符 "read 🤓", // 含 4 字节的 emoji }需要依次完成八项任务:
- 打印每个字符串的字节长度和rune 数量(提示:
len与utf8.RuneCountInString); - 以十六进制打印字符串的字节序列(提示:
% x动词); - 以十六进制打印字符串的每个 rune(提示:
for range); - 以 rune 字面量打印每个 rune(提示:
%q); - 打印第一个 rune 及其字节数(提示:
utf8.DecodeRuneInString); - 打印最后一个 rune(提示:
utf8.DecodeLastRuneInString); - 通过切片取出并打印前两个、后两个 rune;
- 将字符串转换为
[]rune后再取出前两个、后两个 rune。
完整题解
参考官方题解 03-rune-manipulator/solution/main.go:
package main import ( "fmt" "unicode/utf8" ) func main() { words := []string{"cool", "güzel", "jīntiān", "今天", "read 🤓"} for _, s := range words { fmt.Printf("%q\n", s) // 1) 字节长度 vs rune 数量 fmt.Printf("\thas %d bytes and %d runes\n", len(s), utf8.RuneCountInString(s)) // 2) 逐字节十六进制 fmt.Printf("\tbytes : % x\n", s) // 3) 逐 rune 十六进制 fmt.Print("\trunes :") for _, r := range s { fmt.Printf("% x", r) } fmt.Println() // 4) 逐 rune 字面量 fmt.Print("\trunes :") for _, r := range s { fmt.Printf("%q", r) } fmt.Println() // 5) 第一个 rune 及其字节数 r, size := utf8.DecodeRuneInString(s) fmt.Printf("\tfirst : %q (%d bytes)\n", r, size) // 6) 最后一个 rune r, size = utf8.DecodeLastRuneInString(s) fmt.Printf("\tlast : %q (%d bytes)\n", r, size) // 7) 按字节偏移切片前两个、后两个 rune _, first := utf8.DecodeRuneInString(s) _, second := utf8.DecodeRuneInString(s[first:]) fmt.Printf("\tfirst 2 : %q\n", s[:first+second]) _, last1 := utf8.DecodeLastRuneInString(s) _, last2 := utf8.DecodeLastRuneInString(s[:len(s)-last1]) fmt.Printf("\tlast 2 : %q\n", s[len(s)-last2-last1:]) // 8) 转成 []rune 后按索引切片 rs := []rune(s) fmt.Printf("\tfirst 2 : %q\n", string(rs[:2])) fmt.Printf("\tlast 2 : %q\n", string(rs[len(rs)-2:])) } }原理纵深:为什么不能直接按索引切多字节字符串
这是本练习最关键的实战知识点:
for range遍历的是 rune 而非字节:循环变量r的类型是rune,迭代起始索引是字节偏移(如"今天"的第二个 rune 从索引 3 开始),所以第 3、4 步能正确地逐字符输出。测验 questions/README.md 中确认了「for range遍历 rune」以及「UTF-8 字符串中 runes 的起止索引可能不同」这两个事实。len返回字节数:例如"güzel"是 6 字节、5 个 rune;"今天"是 6 字节、2 个 rune。这正是utf8.RuneCountInString(s)存在的意义。utf8.DecodeRuneInString与utf8.DecodeLastRuneInString分别从字符串开头和末尾解码出第一个/最后一个 rune,并返回它占用的字节数——这个size是后续安全切片的关键。- 字符串切片必须落在 rune 边界上:
s[:first+second]用前两个 rune 的字节数之和作为切割点,才能正确取出前两个字符;直接写s[:2]对"今天"会切出半个汉字。同样的思路在 04-rune-decoding/02/main.go 中被用于把第一个 rune 转大写并写回字节切片。 []rune(s)转换把每个字符均匀地放进 4 字节的 int32 槽位,此后可以像数组一样用rs[:2]、rs[len(rs)-2:]按字符索引切片,代价是内存更大(可参考 03-bytes-runes-strings-examples/main.go 中的对比说明)。第 7、8 步分别演示了「基于字节偏移」与「基于 rune 索引」两种取字符方案,实战中二者都要会用。
如何运行与验证
本仓库不依赖任何第三方库,纯标准库即可运行。在任一练习目录下执行:
go run main.go # 运行你的实现 go run solution/main.go # 运行官方题解对比输出以练习一为例,go run solution/main.go的输出应与题目注释中的EXPECTED OUTPUT完全一致;练习二、练习三则直接运行题解查看期望输出(题目注释提示「Run the solution to see the expected output」)。
自测:来自章节测验的高频考点
仓库在 questions/README.md 中配套了一组选择题,这里摘录最核心的几道,检验你是否真正理解:
string(103)与string(111)打印的是g o还是数字?——字符就是数字,打印的是g o。const word = "gökyüzü"转成[]byte后,utf8.RuneCount(bword)、len(word)、len(string(word[1]))分别是多少?——ö、ü各占 2 字节,答案是7 10 2(7 个 rune、10 个字节、word[1]单独构成 2 字节)。- 为什么不能修改 string 的字节?——string 是不可变字节切片,且字符串常被共享,两个原因同时成立。
"keeper"对应的字节切片是什么?——按码点k=>107, e=>101, p=>112, r=>114,正确选项是[]byte{107, 101, 101, 112, 101, 114}。
小结
三个练习构成了从「认识类型」到「安全处理多字节文本」的完整进阶路径:练习一打通 string 与[]byte的互转,练习二用Printf动词看清 rune 的数字本质,练习三则深入unicode/utf8包,解决多字节字符串的计数、解码与切片难题。配合章节内的理论示例(01-bytes-runes-strings-basics、03-bytes-runes-strings-examples、05-internals)与题解逐行对比,即可彻底吃透 Go 字符串处理的核心机制。
- 示例工程
- 教程
【免费下载链接】learngo
❤️ 1000+ Hand-Crafted Go Examples, Exercises, and Quizzes. 🚀 Learn Go by fixing 1000+ tiny programs.
相关推荐
用 5 道实战练习吃透 Go 的 switch 语句:learngo 12-switch 练习全解析
用 5 道实战练习吃透 Go 的 switch 语句:learngo 12 switch 练习全解析 switch 是 Go 中最实用的分支控制语句之一,它既能
示例工程教程Pyrite64对象脚本(C++)完整教程:P64_DATA宏、UUID命名空间与生命周期回调
Pyrite64对象脚本 C++ 完整教程:P64_DATA宏、UUID命名空间与生命周期回调 🎮 Pyrite64 是一款基于 libdragon 与 ti
示例工程教程learngo 常量实战指南:9 个练习吃透 Go 常量声明、无类型常量与 iota
learngo 常量实战指南:9 个练习吃透 Go 常量声明、无类型常量与 iota 在 Go 中,常量(constant)是编译期求值、不可修改的命名值,它消
示例工程教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考