基础语法05_待办清单练习与答案
从零实现待办清单,综合练习 Go 基础语法并编写测试。
Go 基础语法 05:待办清单练习与答案
学习目标:把包、变量、结构体、切片、循环、函数、错误和标准输入组合成可运行程序。只用 Go 标准库;先自行实现,再对照完整答案。
1. 项目与需求
在新建的学习目录执行 go mod init example.com/todolearn。程序支持 add 标题、list、done 编号、quit 四个命令;编号从 1 开始,错误输入给出提示而不让程序崩溃。数据只存在内存中,退出后消失,方便先集中练语法。
把完整答案保存为 main.go,执行 go run .。同目录的其他 .go 文件若也有 main(),编译会报重复定义;每个练习最好单独建目录。
2. 先建立数据与函数边界
Task 存标题和完成状态;addTask 校验标题后把元素加到切片;completeTask 用编号找到元素;renderTasks 负责显示。交互循环只解析输入并调用这些函数。这样可以用测试直接检查规则,而不必模拟整个终端。
3. 完整可运行答案
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type Task struct {
Title string
Done bool
}
func addTask(tasks *[]Task, title string) error {
cleaned := strings.TrimSpace(title)
if cleaned == "" {
return fmt.Errorf("任务标题不能为空")
}
*tasks = append(*tasks, Task{Title: cleaned})
return nil
}
func completeTask(tasks []Task, number int) error {
if number < 1 || number > len(tasks) {
return fmt.Errorf("任务编号不存在")
}
tasks[number-1].Done = true
return nil
}
func renderTasks(tasks []Task) string {
if len(tasks) == 0 {
return "还没有任务"
}
var output strings.Builder
for index, task := range tasks {
mark := " "
if task.Done {
mark = "✓"
}
fmt.Fprintf(&output, "%d. [%s] %s\n", index+1, mark, task.Title)
}
return strings.TrimSuffix(output.String(), "\n")
}
func main() {
tasks := []Task{}
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("命令:add 标题 | list | done 编号 | quit")
for {
fmt.Print("> ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
fmt.Println("读取失败:", err)
}
fmt.Println("再见")
return
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
command := parts[0]
argument := ""
if len(parts) == 2 {
argument = strings.TrimSpace(parts[1])
}
switch command {
case "add":
if err := addTask(&tasks, argument); err != nil {
fmt.Println("输入错误:", err)
} else {
fmt.Println("已添加")
}
case "list":
fmt.Println(renderTasks(tasks))
case "done":
number, err := strconv.Atoi(argument)
if err != nil {
fmt.Println("输入错误: 任务编号必须是整数")
continue
}
if err := completeTask(tasks, number); err != nil {
fmt.Println("输入错误:", err)
} else {
fmt.Println("已完成")
}
case "quit":
fmt.Println("再见")
return
default:
fmt.Println("未知命令")
}
}
}
4. 逐行理解容易混淆的地方
type Task struct创建一种结构体类型;Task{Title: cleaned}创建一个值,未填写的Done得到零值false。tasks *[]Task是“指向切片变量的指针”。append可能分配新底层数组,因此addTask要把新切片写回调用方的切片变量。另一种同样清楚的设计是返回([]Task, error)再由调用方赋值。completeTask(tasks []Task, ...)接收切片描述符的副本,但它仍指向同一底层数组,所以修改tasks[number-1].Done会影响调用方看到的元素。number从 1 开始,而切片索引从 0 开始;检查范围后才访问number-1,避免越界 panic。scanner.Scan()每次读一行,false可能表示输入结束或出错,所以再检查scanner.Err()。strings.SplitN(line, " ", 2)最多分成两段,使add 学习 Go 基础的完整标题仍留在第二段;随后TrimSpace去掉两端空白。switch命中一个case后默认结束,不需要每段都写break;continue则进入外层for的下一轮。strings.Builder减少循环中反复拼接字符串;小输出也可用fmt.Sprintf,此处用它练习io.Writer风格 API。
5. 一次预期运行
命令:add 标题 | list | done 编号 | quit
> list
还没有任务
> add 学习 Go
已添加
> add
输入错误: 任务标题不能为空
> list
1. [ ] 学习 Go
> done 1
已完成
> list
1. [✓] 学习 Go
> quit
再见
若出现 undefined,检查是否把整个程序保存到同一个 main.go;若出现 imported and not used,检查导入包是否对应实际使用。遇到 index out of range,优先检查用户编号是否在访问切片前完成范围校验。
6. 直接测试规则
同目录新建 main_test.go,写入以下代码,运行 go test ./...。测试文件与 main.go 同属 package main,所以能直接调用未导出函数。
package main
import "testing"
func TestAddAndComplete(t *testing.T) {
tasks := []Task{}
if err := addTask(&tasks, " 学习 Go "); err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].Title != "学习 Go" {
t.Fatalf("unexpected tasks: %#v", tasks)
}
if err := completeTask(tasks, 1); err != nil {
t.Fatal(err)
}
if !tasks[0].Done {
t.Fatal("task should be done")
}
}
func TestRejectsBlankTitle(t *testing.T) {
tasks := []Task{}
if err := addTask(&tasks, " "); err == nil {
t.Fatal("expected an error")
}
}
7. 自己扩展与答案提示
- 增加
delete 编号:范围检查后用append(tasks[:index], tasks[index+1:]...)构造新切片,再写回tasks;注意原底层数组可能仍保留对象引用,复杂元素要考虑清理。 - 增加
count:循环统计!task.Done的元素数,先用普通for,再考虑抽成函数。 - 增加 JSON 保存:定义导出字段和
json标签,使用json.Marshal/Unmarshal,先写到新建学习目录;读取后仍需逐项校验标题。
学习提示:先能不用看答案写出内存版,再尝试文件持久化和 HTTP 服务。每次只增加一个功能,并为成功与失败各写一条测试。