#【仓颉语言入门 · 第29课】实战一:带文件持久化的命令行小工具

项目实战模块开张。前 29 课学的零件一直散在小例子里:类在第 15、16 课,集合在第 8 课,读键盘在第 3 课,文件读写在第 22 课,异常在第 10 课,多文件工程在第 21 课。今天第一次把它们组装成一个真正天天能用的程序——命令行待办清单:启动时自动读文件,敲键盘增、查、勾、删,关掉程序再打开,数据还在。

本文所有代码与输出均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(系列导航)

整套路线共 7 个模块、30 课:

模块课次内容
一、环境与入门01~05环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环
二、常用类型与数据组织06~10字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理
三、函数与函数式11~14函数、Lambda 与高阶函数、闭包、迭代器与惰性序列
四、面向对象与类型系统15~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程的创建与等待、线程同步、并发实战
七、项目实战29~30命令行小工具(本文)、GeoJSON 数据处理程序
  1. 环境搭建与第一个仓颉程序
  2. 变量、常量与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO
  23. JSON 处理
  24. 网络编程入门
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. 线程同步:互斥锁、原子类型与条件变量
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具(本文)
  30. 实战二:GeoJSON 数据处理程序

一、要做一个什么东西

一个叫 todo 的命令行待办清单,运行后进入一个循环菜单:

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
  • 选 1:输入一行标题,程序分配一个自增编号,存下来;
  • 选 2:列出全部任务,完成的打 [x],没完成的打 [ ],末尾给统计;
  • 选 3:输入编号,把对应任务勾掉;
  • 选 4:输入编号,删除对应任务;
  • 选 0:退出。

和前 28 课例子最大的不同有两个:

  1. 程序会"记住"事情——任务写在一个文本文件 tasks.txt 里,每次改动立即落盘,关掉再开数据还在;
  2. 代码拆三个文件,各司其职,这是第 21 课后第一次真正用上多文件工程。

数据文件长什么样

不搞复杂格式,每行一条任务,字段用 | 分隔(和第 22 课订单文件同款思路,但这里多一个自增编号和完成标记):

1|0|写仓颉作业
2|1|跑步30分钟

三段依次是:编号 | 是否完成(1 完成 / 0 未完成) | 标题。这个格式用记事本就能打开看、打开改,不需要任何第三方库——第 23 课的 JSON 方案依赖 stdx 扩展库,本课追求"建完工程零配置直接跑",故意选纯文本。

工程结构

todo/
├── cjpm.toml
├── tasks.txt              # 运行后自动生成,第一次运行前不存在
└── src/
    ├── task.cj            # 数据层:一条任务长什么样
    ├── task_store.cj      # 仓库层:任务列表的增删查 + 文件读写
    └── main.cj            # 表现层:菜单循环和键盘交互

三个文件都在 src/ 下、首行都写 package todo,属于同一个包(第 21 课 3.1 节):互相引用不需要 import。需要 import 的只有标准库。


二、第一步:建工程

在 CIDE 终端(或任意终端)里:

cjpm init --name todo

生成的 cjpm.toml 关键字段如下(cjpm init 已自动填好,不用改):

[package]
  cjc-version = "1.2.0"
  name = "todo"
  version = "1.0.0"
  output-type = "executable"

name = "todo" 决定了包名:接下来三个源文件第一行都必须写 package todo,写成别的会报 package name wrong。把 src/main.cj 骨架清空,我们分三个文件从零写。


三、数据层:task.cj

一条任务只需要三个字段:编号(身份,永不改变)、标题、是否完成。

src/task.cj:

package todo

// 一条待办任务:编号、标题、是否完成
public class Task {
    public let id: Int64
    public let title: String
    public var done: Bool

    public init(id: Int64, title: String, done!: Bool = false) {
        this.id = id
        this.title = title
        this.done = done
    }
}

两个细节:

  1. id、title 用 let——编号和标题在本课的生命周期里不可变;done 用 var,因为"勾完成"就是要改它。
  2. 构造器第三参写成 done!: Bool = false。注意参数名后面那个 !:仓颉的默认参数必须用命名参数形式声明(第 16 课 1.3 节),调用时才能省略它。这样新建任务写 Task(1, "写作业") 就表示未完成;从文件读历史任务时再写 Task(2, "跑步", done: true)。

🚫 小提示:写成 done: Bool = false(漏了 !)编译不过,实测报错:error: expected ',' or ')', found '='。


四、仓库层(上):内存里的增删查

所有任务放在哪?放一个 TaskStore(任务仓库)对象里:内部攥一个 ArrayList<Task> 和一个"下一个编号"nextId,对外提供"添加、完成、删除、取全部、数数"五个操作。文件读写的事先按下不表,下一节再加。

src/task_store.cj(先写内存部分):

package todo

import std.collection.ArrayList

// 任务仓库:内存里用 ArrayList 保管全部任务
public class TaskStore {
    private let filePath: String
    private let tasks: ArrayList<Task> = ArrayList<Task>()
    private var nextId: Int64 = 1

    public init(filePath: String) {
        this.filePath = filePath
        this.load()
    }

    // 添加一条任务,返回它的编号
    public func add(title: String): Int64 {
        let id = this.nextId
        this.tasks.add(Task(id, title))
        this.nextId += 1
        this.save()
        return id
    }

    // 把指定编号标记为完成,找不到返回 false
    public func complete(id: Int64): Bool {
        for (t in this.tasks) {
            if (t.id == id) {
                t.done = true
                this.save()
                return true
            }
        }
        return false
    }

    // 删除指定编号,找不到返回 false
    public func remove(id: Int64): Bool {
        for (i in 0..this.tasks.size) {
            if (this.tasks[i].id == id) {
                this.tasks.remove(i..(i + 1))
                this.save()
                return true
            }
        }
        return false
    }

    public func getAll(): Array<Task> {
        return this.tasks.toArray()
    }

    public func count(): Int64 {
        return this.tasks.size
    }

    public func completedCount(): Int64 {
        var n: Int64 = 0
        for (t in this.tasks) {
            if (t.done) {
                n += 1
            }
        }
        return n
    }
}

几个要点:

  • 编号由仓库统一发。add 时把 nextId 的当前值发出去,再自增 1——调用方永远不能自己指定编号,保证编号不重复。
  • 删除用 remove(i..(i + 1))。ArrayList 的 remove 收的是 Range(第 27 课手写阻塞队列时用过):i..(i+1) 表示从 i 到 i+1(不含 i+1),正好删掉下标 i 这一个元素。不要写 remove(i),参数类型不对。
  • getAll() 返回 toArray() 得到的 Array<Task>:数组适合"只读遍历展示",ArrayList 适合"随时增删",两个类型各管一段。
  • complete / remove 返回 Bool 表示"找没找到",界面层据此打印"已完成"还是"找不到编号"。

现在构造器里调用了还不存在的 load()、每个操作里调用了还不存在的 save()——下一节补齐,先别编译。


五、仓库层(下):save 和 load

在同一个 task_store.cj 里补上落盘和加载。第四节的 import 只有 ArrayList,现在把文件头的 import 换成:

import std.fs.{Path, File, OpenMode, exists}
import std.collection.ArrayList
import std.convert.*

filePath 字段已在第四节的类骨架里声明。现在往类里补两个方法:

    // 落盘格式(每行一条):编号|是否完成(1/0)|标题
    public func save(): Unit {
        let file = File(Path(this.filePath), OpenMode.Write)
        for (t in this.tasks) {
            let mark = if (t.done) {
                "1"
            } else {
                "0"
            }
            file.write("${t.id}|${mark}|${t.title}\n".toArray())
        }
        file.close()
    }

    // 从文件加载;文件不存在视为首次运行,坏行直接跳过
    func load(): Unit {
        let path = Path(this.filePath)
        if (!exists(path)) {
            return
        }
        let content = String.fromUtf8(File.readFrom(path))
        let lines = content.split("\n")
        var maxId: Int64 = 0
        for (line in lines) {
            if (line == "") {
                continue
            }
            let parts = line.split("|")
            if (parts.size < 3) {
                continue
            }
            try {
                let id = Int64.parse(parts[0])
                let done = parts[1] == "1"
                var title = parts[2]
                // 标题里万一含有 |,把后面的段拼回来
                for (i in 3..parts.size) {
                    title = "${title}|${parts[i]}"
                }
                this.tasks.add(Task(id, title, done: done))
                if (id > maxId) {
                    maxId = id
                }
            } catch (e: Exception) {
                continue
            }
        }
        this.nextId = maxId + 1
    }

逐条解释:

save(覆盖写整个文件)

  • File(Path(p), OpenMode.Write):文件不存在就创建,存在就整个截断重写(第 22 课 3.1 节)。我们不做增量追加——内存里的列表才是"唯一真相",每次把它整个重写一遍,文件必然和内存一致,也不会产生重复行。
  • 写文本要先 .toArray() 转成 Array<UInt8>,写完记得 close()。

load(启动时把文件灌回内存)

  1. exists(path) 为 false 说明第一次运行——什么也不做,nextId 保持 1、列表保持空即可;
  2. 仓颉没有逐行读文件的 API,老套路(第 22 课 3.4 节):File.readFrom 读全部字节 → String.fromUtf8 转字符串 → split("\n") 切行。文件末尾那个换行切出一个空字符串,用 if (line == "") continue 跳过;
  3. 每行再 split("|"),不足三段是坏行,跳过;
  4. Int64.parse(parts[0]) 可能因为编号不是数字而抛 IllegalArgumentException(第 10 课)。整段包在 try-catch 里,任何一行坏掉只跳过这一行,不拖垮整个程序启动;
  5. 关键收尾:this.nextId = maxId + 1。扫描时记下见过的最大编号,下次添加任务从它后面发号——这样即使重启过,编号也不会和旧任务撞车。

💡 标题里如果手滑含了 |,直接取 parts[2] 会被截断。加载循环用 for (i in 3..parts.size) 把多余的段用 | 拼回来兜底;正规入口(第七节)也会拒绝包含 | 的标题,双保险。


六、表现层:菜单主循环

src/main.cj 的开头部分:一个 while 循环 + 字符串 match 分发,就是整个程序的骨架。

package todo

import std.env.*
import std.convert.*
import std.unicode.*

main(): Int64 {
    let store = TaskStore("tasks.txt")
    println("=== 待办清单 ===")
    var running = true
    while (running) {
        println("")
        println("1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出")
        print("请选择:")
        let choice = getStdIn().readln().getOrThrow().trim()
        match (choice) {
            case "1" => addFlow(store)
            case "2" => listFlow(store)
            case "3" => completeFlow(store)
            case "4" => removeFlow(store)
            case "0" =>
                running = false
                println("数据已保存,再见!")
            case _ =>
                println("无效选项,请重新输入")
        }
    }
    return 0
}

三个 import 各有来历,漏一个都会报错:

  • getStdIn() 在 std.env.*(第 3 课),不是直觉上的 console 包;
  • Int64.parse 在 std.convert.*(第 3 课);
  • .trim() 是 std.unicode.* 提供的字符串扩展(第 6 课),漏 import 会报 'trim' is not a member of struct 'String'。

循环本身没有任何新语法:var running = true 配 while (running),选 0 时置 false 退出;菜单是字符串,所以 match 的每个 case 直接写字面量(第 4 课),case _ 兜住所有乱输。print 和 println 的区别是前者不换行,用来让光标停在"请选择:"后面等输入。


七、表现层:四个交互流程

main.cj 里再补上五个自由函数(和 main 同包同文件):

func addFlow(store: TaskStore): Unit {
    print("任务标题:")
    let title = getStdIn().readln().getOrThrow().trim()
    if (title.size == 0) {
        println("标题不能为空")
        return
    }
    let id = store.add(title)
    println("已添加 #${id}:${title}")
}

func listFlow(store: TaskStore): Unit {
    let tasks = store.getAll()
    if (tasks.size == 0) {
        println("(还没有任务,选 1 添加一个吧)")
        return
    }
    println("---- 待办清单 ----")
    for (t in tasks) {
        let mark = if (t.done) {
            "x"
        } else {
            " "
        }
        println("[${mark}] #${t.id} ${t.title}")
    }
    println("共 ${store.count()} 项,已完成 ${store.completedCount()} 项")
}

func readId(prompt: String): Int64 {
    print(prompt)
    let text = getStdIn().readln().getOrThrow().trim()
    return Int64.parse(text)
}

func completeFlow(store: TaskStore): Unit {
    try {
        let id = readId("要完成的任务编号:")
        if (store.complete(id)) {
            println("任务 #${id} 已完成")
        } else {
            println("找不到编号 #${id}")
        }
    } catch (e: Exception) {
        println("请输入正确的数字编号")
    }
}

func removeFlow(store: TaskStore): Unit {
    try {
        let id = readId("要删除的任务编号:")
        if (store.remove(id)) {
            println("任务 #${id} 已删除")
        } else {
            println("找不到编号 #${id}")
        }
    } catch (e: Exception) {
        println("请输入正确的数字编号")
    }
}

设计上有两个可以迁移到任何交互程序的手法:

  1. 共用一个 readId:"完成"和"删除"都要读一个数字编号,把"打印提示 → 读一行 → parse 成 Int64"抽成一个函数,parse 失败抛的异常由调用方的 try-catch 接住,界面提示语各自定制。
  2. 不合法输入绝不崩:标题为空直接拦下;数字格式错误由 IllegalArgumentException 兜;编号格式对但不存在由仓库返回 false 兜。下一节实测这三条防线。

八、第一次联跑

三个文件写完,cjpm run。程序是交互式的,测试时可以把键盘输入预先写进一个 UTF-8 文本文件(每行对应一次回车),用输入重定向喂给程序。第一轮依次输入:添加"写仓颉作业"、添加"跑步30分钟"、查看、勾掉 #2、再查看、退出——输入文件内容为:

1
写仓颉作业
1
跑步30分钟
2
3
2
2
0

程序完整输出(逐字实测。注意 print 不换行,所以"请选择:"会和后面的提示连在同一行,真实终端里你的键盘输入就接在它后面):

=== 待办清单 ===

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:任务标题:已添加 #1:写仓颉作业

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:任务标题:已添加 #2:跑步30分钟

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:---- 待办清单 ----
[ ] #1 写仓颉作业
[ ] #2 跑步30分钟
共 2 项,已完成 0 项

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:要完成的任务编号:任务 #2 已完成

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:---- 待办清单 ----
[ ] #1 写仓颉作业
[x] #2 跑步30分钟
共 2 项,已完成 1 项

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:数据已保存,再见!

与此同时,工程目录下生成了 tasks.txt,内容是:

1|0|写仓颉作业
2|1|跑步30分钟

九、第二次启动:数据真的还在

不删 tasks.txt,再跑一次,这次只输入"查看、退出"(2 回车、0 回车):

=== 待办清单 ===

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:---- 待办清单 ----
[ ] #1 写仓颉作业
[x] #2 跑步30分钟
共 2 项,已完成 1 项

1. 添加任务  2. 查看清单  3. 完成任务  4. 删除任务  0. 退出
请选择:数据已保存,再见!

#2 那个 [x] 是从文件里读回来的——"关掉再开数据还在"达成。此时如果再选 1 添加任务,新编号会是 #3 而不是 #1,这就是 load 里 nextId = maxId + 1 的作用。

顺手做个破坏实验

用记事本把 tasks.txt 改成下面这样(故意塞两行坏数据),再启动选 2:

2|1|跑步30分钟
这是一行坏数据
abc|0|编号损坏
3|0|复习第29课

程序不报错、不崩溃,列表里只有两条好数据,两行坏数据被安静跳过:

---- 待办清单 ----
[x] #2 跑步30分钟
[ ] #3 复习第29课
共 2 项,已完成 1 项

而且因为保存时整文件重写,你做任意一次合法操作后,坏行会被自动"清洗"出文件。


十、健壮性四道防线实测

把各种乱来的输入集中喂一轮,每条都有明确去向(均实测):

乱来的输入触发的防线程序反应
菜单敲 9match 的 case _无效选项,请重新输入,继续循环
编号处敲 abcInt64.parse 抛异常 → try-catch请输入正确的数字编号,程序不崩
编号敲 99(格式对、不存在)仓库返回 false找不到编号 #99
添加时空着直接回车title.size == 0 判断标题不能为空
tasks.txt 里有坏行load 的段数检查 + try-catch跳过坏行,好行照常加载

这张表就是一个入门级交互程序的"容错清单":菜单越界、类型错误、数据不存在、空输入、外部文件损坏——五类常见意外,每一类都在自己的边界上被拦下,没有一个能让程序死掉。


十一、这个小项目的五条工程经验

  1. 分层:task.cj 只描述数据,task_store.cj 管"数据怎么存"(内存和文件都在这一层),main.cj 管"屏幕和键盘"。改存储格式只动仓库层,换成交互界面只动表现层——这就是第 17 课接口思想之外,更朴素的"职责分离"。
  2. 改动即落盘:add / complete / remove 内部都调 save(),任何一步之后程序被杀、断电,最多损失最后一次未完成的键盘输入,不会丢已确认的数据。代价是每次操作重写整个文件——对几百条任务的小工具完全可以接受。
  3. 编号是身份,只发不复用:nextId 从文件里的最大编号恢复。删掉 #2 后新来的任务也绝不再叫 #2,避免"这个 #2 到底是哪条"的错乱。
  4. 外部数据一律不可信:文件可能被手改、被截断、被塞垃圾。加载时每行都验证、坏行跳过;parse 放在 try-catch 里。
  5. 同包多文件不用互相 import:三个文件都 package todo 即共享作用域(第 21 课);只有标准库才需要 import,且每个文件各 import 各的。

十二、CIDE 实操

  1. 终端执行 cjpm init --name todo,按本节结构新建 src/task.cj、src/task_store.cj,改写 src/main.cj;
  2. cjpm run,亲手走一遍:加三条、勾一条、删一条、退出;
  3. 左侧文件树打开自动生成的 tasks.txt,对照屏幕列表逐行看;
  4. 再 cjpm run 一次,确认数据原样回来;
  5. 故意往 tasks.txt 里敲一行乱码保存,重启程序,观察坏行被跳过。

十三、常用 API 速查

功能写法所在包 / 备注
读一行键盘输入getStdIn().readln().getOrThrow()std.env.*;返回值取 .trim() 去首尾空白
去首尾空白s.trim()std.unicode.* 扩展,漏 import 报 not a member
字符串转整数Int64.parse(s)std.convert.*;失败抛 IllegalArgumentException
不换行输出print(s)std.core 内置;换行版 println(s)
覆盖写文件File(Path(p), OpenMode.Write) + write(s.toArray()) + close()std.fs;不存在则创建,存在则清空
读整个文件String.fromUtf8(File.readFrom(p))静态方法,返回 Array<UInt8>
判断文件存在exists(Path(p))std.fs
切分字符串s.split("|") / s.split("\n")String 自带;结果 .size、[i]
动态数组ArrayList<T>()std.collection.ArrayList;add / toArray / size / [i]
按下标删一个list.remove(i..(i + 1))参数是 Range,不是整数
默认参数func f(x!: Int64 = 0)参数名后必须有 !,调用处可 f(x: 3)
菜单循环while (running) { match (readln()) { case _ => } }选 0 置 false 退出

十四、常见问题 FAQ

Q1:为什么不用第 23 课的 JSON 存?

JSON 结构化、自带类型,适合字段多、嵌套深的数据;但第 23 课的 JSON 能力在 stdx 扩展库里,需要额外下载配置。本课只有三个扁平字段,纯文本 编号|完成|标题 零依赖、人眼可读、记事本可改,工程上叫"按数据复杂度选技术",不是什么都要用最重的方案。

Q2:为什么每操作一下就写文件,退出时统一存不是更省事?

退出时才存,一旦程序崩溃、电脑断电、用户直接关窗口,本次所有改动全丢。改动即落盘把"丢数据"的窗口缩到一次键盘输入以内。等以后任务多到每次重写文件有明显卡顿,再改成"标记脏数据 + 退出时/定时刷盘"也不迟。

Q3:删掉 #2 之后,为什么新任务不是 #2?

编号是任务的身份,不是列表里的座位号。复用编号会让"曾经的 #2"和"新来的 #2"在文件、日志、你的记忆里混淆。仓库从文件恢复 nextId = maxId + 1,保证编号单调发、永不回头。

Q4:输出里"请选择:"为什么和"任务标题:"挤在同一行?

print 输出后不换行(就是为了让你在终端里看到 请选择:▏ 在冒号后打字)。用输入重定向跑批时没有键盘回显,两个提示符就挨在一起了;你在真实终端里手动运行时,中间夹着你敲的字符,观感完全正常。

Q5:三个文件为什么不用互相 import?

src/ 下所有首行写 package todo 的文件天然属于同一个包、共享作用域(第 21 课 3.1 节),main.cj 里直接用 TaskStore、Task 即可。只有 std.* 标准库才需要 import,且 import 只对当前文件生效,所以三个文件各自 import 自己用到的包。

Q6:标题里非要输入 | 会怎样?

当前版本的添加流程没有拦截它,落盘后这一行会多出一个分隔段;加载端有"把第三段之后拼回来"的兜底,数据不会丢。课后练习可以自己在添加时加上拒绝逻辑(判断 title.contains("|"))。


十五、课后练习

  1. (必做)增加"5. 清空已完成"菜单项。在 TaskStore 里加 clearCompleted(): Int64(返回清掉的条数),边遍历边删除时下标不能无脑 += 1——删元素后后面的元素会前移,只有没删时才 i += 1;一条没删就不用写文件。预置三条任务(两条已完成)时选 5,期望输出:
已清空 2 项已完成任务
  1. (必做)增加"6. 修改标题"。先把 Task.title 从 let 改成 var(否则编译报错),仓库里加 rename(id: Int64, newTitle: String): Bool,界面流程读编号、再读新标题(空标题同样拦截)。把 #1 从"买菜"改成"写代码",期望输出:
任务 #1 已改名为:写代码
  1. (必做)破坏实验:在程序未运行时用记事本把 tasks.txt 改成"两行好数据 + 一行只有两个字段的坏行 + 一行编号是字母的坏行",启动后选 2。确认程序不崩溃、只列出两条好数据;再执行任意一次合法操作后重新打开文件,确认坏行已被重写清洗掉。

  2. (选做)给任务加一个优先级字段(整数 1~3),文件格式扩成四段 编号|完成|优先级|标题,列表里用 (高) (中) (低) 标注。注意 load 的段数下限和标题下标都要跟着挪一位,坏行容错仍要保留。


下节预告

最后一课,我们换一个更"像工作"的题材:第 30 课 实战二:GeoJSON 数据处理程序——读取真实的地理数据文件,用枚举和集合做结构化解析、统计与转换,再把结果写成新文件。它会把泛型、集合、文件 IO、错误处理串成一条数据处理流水线,为整个 30 课系列收尾。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):
在这里插入图片描述

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
  • 打开页面后进入 发行版(Releases),两种包任选其一:
    • 安装版:下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable):下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
Logo

一站式 AI 云服务平台

更多推荐