← Backend / Go

基础语法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. 逐行理解容易混淆的地方

  1. type Task struct 创建一种结构体类型;Task{Title: cleaned} 创建一个值,未填写的 Done 得到零值 false。
  2. tasks *[]Task 是“指向切片变量的指针”。append 可能分配新底层数组,因此 addTask 要把新切片写回调用方的切片变量。另一种同样清楚的设计是返回 ([]Task, error) 再由调用方赋值。
  3. completeTask(tasks []Task, ...) 接收切片描述符的副本,但它仍指向同一底层数组,所以修改 tasks[number-1].Done 会影响调用方看到的元素。
  4. number 从 1 开始,而切片索引从 0 开始;检查范围后才访问 number-1,避免越界 panic。
  5. scanner.Scan() 每次读一行,false 可能表示输入结束或出错,所以再检查 scanner.Err()。
  6. strings.SplitN(line, " ", 2) 最多分成两段,使 add 学习 Go 基础 的完整标题仍留在第二段;随后 TrimSpace 去掉两端空白。
  7. switch 命中一个 case 后默认结束,不需要每段都写 break;continue 则进入外层 for 的下一轮。
  8. 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. 自己扩展与答案提示

  1. 增加 delete 编号:范围检查后用 append(tasks[:index], tasks[index+1:]...) 构造新切片,再写回 tasks;注意原底层数组可能仍保留对象引用,复杂元素要考虑清理。
  2. 增加 count:循环统计 !task.Done 的元素数,先用普通 for,再考虑抽成函数。
  3. 增加 JSON 保存:定义导出字段和 json 标签,使用 json.Marshal/Unmarshal,先写到新建学习目录;读取后仍需逐项校验标题。
学习提示:先能不用看答案写出内存版,再尝试文件持久化和 HTTP 服务。每次只增加一个功能,并为成功与失败各写一条测试。