- 示例工程
- 教程
【免费下载链接】learngo
❤️ 1000+ Hand-Crafted Go Examples, Exercises, and Quizzes. 🚀 Learn Go by fixing 1000+ tiny programs.
导读
本篇围绕 learngo 课程仓库 23-input-scanning/exercises/README.md 展开,系统讲解如何用 Go 标准库中的bufio.Scanner读取标准输入流、用map做集合与计数,并以此为工具完成 6 个循序渐进的实战练习:从逐行转大写、统计唯一单词、清洗标点、实现 grep 克隆、检测重复输入,到最后从零复刻一个完整的日志解析器。读完本篇,你将掌握Scanner的分词原理、SplitFunc的用法、正则清洗文本、命令行参数解析,以及 map 作为集合/计数器的典型模式,并能独立写出可处理真实文本输入的 Go 命令行程序。
一、练习总览:从输入流到输出的一整套阶梯
这一章的练习全部围绕一个共同主题:把一段标准输入(通常是文本文件)读进程序,用bufio.Scanner逐行或逐词扫描,再用map完成统计、去重或判断。六个练习难度逐级递增,恰好覆盖了输入扫描的两大核心知识点:
| 练习 | 目录 | 核心技能点 |
|---|---|---|
| 1. Uppercaser | 01-uppercaser | 逐行扫描 +strings.ToUpper |
| 2. Unique Words | 02-unique-words | in.Split(bufio.ScanWords)+ map 计数 |
| 3. Unique Words 2 | 03-unique-words-2 | 正则[^A-Za-z]+清洗 + 大小写归一 |
| 4. Grep Clone | 04-grep | 命令行参数 +strings.Contains过滤 |
| 5. Quit | 05-quit | map 作为集合判断重复 |
| 6. Log Parser(从零实现) | 06-log-parser | 综合:Scanner + map + 错误处理 + 格式化输出 |
每个练习目录都采用"题目 + 参考答案"的结构:main.go是带完整题目描述与预期输出的空壳,solution/main.go是可直接运行的答案,部分练习还附带了用于喂给程序的shakespeare.txt。下面的实现细节均以仓库中 solution/main.go 等参考答案文件为准。
二、先看懂工具:Scanner 的两种扫描模式
所有练习都建立在bufio.Scanner之上。基础用法见 01-scanning/main.go:
in := bufio.NewScanner(os.Stdin) var lines int for in.Scan() { lines++ in.Text() // 获取当前行文本;in.Bytes() 可获取原始字节 } fmt.Printf("There are %d line(s)\n", lines) if err := in.Err(); err != nil { fmt.Println("ERROR:", err) }这段代码揭示了 Scanner 的三个要点:
NewScanner包装一个io.Reader,这里传入os.Stdin,因此程序天然支持go run main.go < 文件的重定向输入方式;for in.Scan()循环是扫描器的驱动核心——每次调用Scan()推进到下一个 token,循环结束后可用in.Err()检查是否有错误(该源码中甚至注释展示了如何模拟错误:os.Stdin.Close());- 默认按行拆分,即默认的
SplitFunc是bufio.ScanLines。
当需要按"词"而不是按"行"扫描时,只需显式设置拆分函数:
in.Split(bufio.ScanWords)bufio包内置了ScanLines、ScanWords、ScanRunes、ScanBytes四种拆分策略,ScanWords会把连续的非空白字符序列识别为一个词。这正是 Unique Words 系列练习的关键一步——它让 map 的键直接就是一个个单词。
三、练习 1:Uppercaser——逐行转换并打印
题目要求(见 01-uppercaser/main.go):把shakespeare.txt喂给程序,用 Scanner 扫描输入,将每一行转为大写后打印。
参考答案(01-uppercaser/solution/main.go):
func main() { in := bufio.NewScanner(os.Stdin) for in.Scan() { fmt.Println(strings.ToUpper(in.Text())) } }运行方式:
go run main.go < shakespeare.txt这个练习虽然只有几行,却完整演示了"扫描—取文本—转换—输出"的最小管道。strings.ToUpper(in.Text())接收当前行的字符串并返回全大写版本,配合fmt.Println逐行输出,整个程序不依赖任何第三方库。
四、练习 2:Unique Words——用 map 统计唯一单词
题目要求(见 02-unique-words/main.go):扫描输入中的每个单词,用 map 统计唯一词数量,最终输出There are 99 words, 70 of them are unique.。
参考答案(02-unique-words/solution/main.go):
func main() { in := bufio.NewScanner(os.Stdin) in.Split(bufio.ScanWords) total, words := 0, make(map[string]int) for in.Scan() { total++ words[in.Text()]++ } fmt.Printf("There are %d words, %d of them are unique.\n", total, len(words)) }技术要点拆解:
total统计总词数:每扫描到一个 token 就total++,等价于len(words)再加重复词的出现次数;words[in.Text()]++是经典的"map 作为计数器"模式:Go 中访问不存在的 map 键会返回零值,因此首次遇到某词时words[词]为 0,自增后变成 1,之后再遇到同一个词就继续累加;len(words)即唯一词数量,因为 map 的每个键只存在一份。
这里可以看到in.Split(bufio.ScanWords)与默认逐行模式的关键区别:若沿用逐行扫描,整行文本会被当作一个"词"计入 map,统计结果将完全错误。
五、练习 3:Unique Words 2——用正则清洗标点与数字
题目要求(见 03-unique-words-2/main.go):在上一题基础上,将单词加入 map之前先移除其中的标点符号和数字;同时注意此时shakespeare.txt中同时含有大写和小写字母,因此还需要统一大小写。预期输出为There are 100 words, 69 of them are unique.。
题目注释直接给出了所需的正则模式:
// [^A-Za-z]+ // Matches to any character but upper case and lower case letters参考答案(03-unique-words-2/solution/main.go):
func main() { in := bufio.NewScanner(os.Stdin) in.Split(bufio.ScanWords) rx := regexp.MustCompile(`[^A-Za-z]+`) total, words := 0, make(map[string]int) for in.Scan() { total++ word := rx.ReplaceAllString(in.Text(), "") word = strings.ToLower(word) words[word]++ } fmt.Printf("There are %d words, %d of them are unique.\n", total, len(words)) }三个新增知识点:
[^A-Za-z]+是反向字符类:[^...]表示"匹配任何一个不在括号内的字符",A-Za-z限定为英文字母,末尾的+表示连续匹配一个或多个。因此该模式能一次性吃掉一串连续的标点或数字;regexp.MustCompile在包级表达式固定的情况下比regexp.Compile更简洁——它在编译失败时直接 panic,适用于模式是编译期常量的场景;rx.ReplaceAllString(in.Text(), "")把匹配到的所有"非字母片段"替换为空串,随后strings.ToLower归一化大小写,使Come与come归并为同一个键,这正是唯一词数从 70 降到 69 的原因。
六、练习 4:Grep Clone——用命令行参数过滤行
题目要求(见 04-grep/main.go):实现一个 grep 克隆。grep 是用于在纯文本数据中搜索匹配特定模式的行的命令行工具。具体要求:从命令行参数接受模式,只打印包含该模式的行;若未提供模式则打印所有行。预期运行与输出:
go run main.go come < shakespeare.txt come night come romeo come thou day in night come gentle night come loving black-browed night参考答案(04-grep/solution/main.go):
func main() { in := bufio.NewScanner(os.Stdin) var pattern string if args := os.Args[1:]; len(args) == 1 { pattern = args[0] } for in.Scan() { s := in.Text() if strings.Contains(s, pattern) { fmt.Println(s) } } }实现技巧分析:
os.Args[1:]取出所有命令行参数,os.Args[0]是程序名本身,因此从下标 1 开始;if args := os.Args[1:]; len(args) == 1使用 if 初始化语句,把"取参数"与"校验参数个数"合并成一步,是 Go 惯用的短路写法——恰好一个参数才设置 pattern,否则 pattern 保持空串;strings.Contains(s, pattern):当 pattern 为空串时,任何字符串都包含空串,于是所有行都被打印,自然满足了"无模式则全量输出"的要求;- 扫描模式这里保持默认的逐行,因为 grep 的语义是"按行匹配"。
七、练习 5:Quit——用 map 做集合判断重复输入
题目要求(见 05-quit/main.go):创建一个程序,当用户连续两次输入同一个词时退出。
参考答案(05-quit/solution/main.go):
func main() { in := bufio.NewScanner(os.Stdin) words := make(map[string]bool) for in.Scan() { w := strings.ToLower(in.Text()) if words[w] { fmt.Println("TWICE!") return } words[in.Text()] = true } }这个练习展示了map 作为集合(Set)的惯用法:
map[string]bool的值不需要计数,只关心"这个键存不存在"——这是 Go 中模拟集合的标准做法;if words[w]是判断技巧:访问不存在的键返回false(bool 零值),访问已存在的键返回true,因此一行即可完成"是否见过"的判断;- 注意这里的细微差异:判断时用
strings.ToLower(in.Text())归一化,写入时却用原始in.Text()。这表示大小写不同的输入仍会被视为"同一个词"从而触发退出(比如输入Hello后再输入hello会输出TWICE!并退出),这是刻意设计的容错行为; - 触发重复后
return直接结束main,程序退出。
八、练习 6:从零实现 Log Parser——综合大考
题目要求(见 06-log-parser/main.go):你已观看过讲解视频,现在不参考讲解和已有源码,独立实现同样的日志解析器。题目给出了完整的多组预期输出,覆盖正常与三类错误场景:
go run main.go < log.txt DOMAIN VISITS --------------------------------------------- blog.golang.org 30 golang.org 10 learngoprogramming.com 20 TOTAL 60go run main.go < log_err_missing.txt wrong input: [golang.org] (line #3)go run main.go < log_err_negative.txt wrong input: "-100" (line #3)go run main.go < log_err_str.txt wrong input: "FOUR" (line #3)练习目录内提供了四个输入文件——log.txt(正常数据)、log_err_missing.txt(某行缺少访问次数)、log_err_negative.txt(出现负数)、log_err_str.txt(次数是字符串而非数字)。
这个练习是对整章能力的综合检验,期望你独立完成以下步骤:
- 逐行扫描输入,用
in.Split(bufio.ScanWords)或strings.Fields拆分"域名 + 访问次数"; - 用 map 汇总每个域名的总访问量;
- 校验数据:行内字段缺失、次数为负数、次数无法转换为整数时,都要按格式输出
wrong input: "..." (line #N)并退出; - 格式化输出:用
fmt.Printf对齐 DOMAIN 与 VISITS 两列,并在末尾打印 TOTAL。
作为对照,仓库 03-project-log-parser/main.go 提供了完整的讲师版本实现,其中用到了strconv.Atoi解析数字、fmt.Printf的宽度对齐(如%-*s)等技巧;后续章节 24-structs/05-project-log-parser-structs、25-functions、26-pointers 还会用结构体、函数与指针反复重构这个解析器。如果在此卡壳,建议先自己完成再对照阅读,这正是该练习"从零实现"的设计初衷。
九、关键模式速查与自测清单
六个练习沉淀下来四组可复用的 Go 惯用模式:
| 模式 | 关键代码 | 出自练习 |
|---|---|---|
| 逐行扫描 | in := bufio.NewScanner(os.Stdin); for in.Scan() | 1、4、5、6 |
| 按词扫描 | in.Split(bufio.ScanWords) | 2、3 |
| map 计数 | words[word]+++len(words) | 2、3、6 |
| map 集合 | map[string]bool+if words[w] | 5 |
| 文本清洗 | regexp.MustCompile(\[^A-Za-z]+`)+ReplaceAllString` | 3 |
| 参数解析 | if args := os.Args[1:]; len(args) == 1 | 4 |
动手自测时可以依次追问自己:Scan()返回false时如何区分"输入结束"与"出错"(答案是in.Err())?为什么 Unique Words 2 必须同时做正则清洗与ToLower?如果 Grep Clone 需要忽略大小写,strings.Contains该如何改造(提示:双方都先ToLower)?Log Parser 中某行只有域名没有数字时,ScanWords模式下该行会扫描出几个 token?
十、延伸阅读
- 扫描器基础讲解:23-input-scanning/01-scanning/main.go(含错误检查示范)
- map 作为集合:23-input-scanning/02-map-as-sets/main.go
- 日志解析器完整实现:23-input-scanning/03-project-log-parser/main.go
- 本章问答自测:23-input-scanning/questions/README.md
- 后续重构路线:结构体版 24-structs/05-project-log-parser-structs/main.go、函数版 25-functions/03-refactor-to-funcs、指针版 26-pointers/04-log-parser-pointers
完成这六个练习后,你不仅掌握了bufio.Scanner与map的组合用法,也具备了"接收标准输入 → 清洗与校验 → 统计与过滤 → 格式化输出"这条完整的命令行文本处理流水线能力,可以直接迁移到真实的日志分析、词频统计等场景中。
- 示例工程
- 教程
【免费下载链接】learngo
❤️ 1000+ Hand-Crafted Go Examples, Exercises, and Quizzes. 🚀 Learn Go by fixing 1000+ tiny programs.
相关推荐
SwifterSwift中15个超好用的String扩展:从trimmed修剪到emoji检测一网打尽
SwifterSwift中15个超好用的String扩展:从trimmed修剪到emoji检测一网打尽 SwifterSwift 是一个拥有 500+ 原生 S
移动开发learngo 实战训练:用三个练习掌握 Go 的包、作用域与导入重命名
learngo 实战训练:用三个练习掌握 Go 的包、作用域与导入重命名 本文基于 03 packages and scopes/exercises/READM
示例工程教程掌握 Go 短变量声明:基于 learngo 仓库的 6 个实战练习详解
掌握 Go 短变量声明:基于 learngo 仓库的 6 个实战练习详解 Go 的短变量声明( := )是日常编码中使用频率最高的语法之一,也是许多初学者最容易
示例工程教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考