
#【仓颉语言入门 · 第29课】实战一带文件持久化的命令行小工具项目实战模块开张。前 29 课学的零件一直散在小例子里类在第 15、16 课集合在第 8 课读键盘在第 3 课文件读写在第 22 课异常在第 10 课多文件工程在第 21 课。今天第一次把它们组装成一个真正天天能用的程序——命令行待办清单启动时自动读文件敲键盘增、查、勾、删关掉程序再打开数据还在。本文所有代码与输出均在仓颉 SDK 1.2.0 下逐行实测编译运行。目录系列导航整套路线共7 个模块、30 课模块课次内容一、环境与入门0105环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环二、常用类型与数据组织0610字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理三、函数与函数式1114函数、Lambda 与高阶函数、闭包、迭代器与惰性序列四、面向对象与类型系统1520struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展五、工程化与标准库2125cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试六、并发编程2628线程的创建与等待、线程同步、并发实战七、项目实战2930命令行小工具本文、GeoJSON 数据处理程序环境搭建与第一个仓颉程序变量、常量与基本数据类型运算符与标准输入输出分支结构与 match 表达式循环结构while / for / Range字符串详解与字符串插值数组 Array 与区间 Range集合框架ArrayList、HashMap、HashSet可空类型?与 Option错误处理异常机制与 Result函数定义、参数与返回值Lambda 与高阶函数闭包、作用域与函数类型迭代器 Iterator 与 Sequence结构体 struct 与类 class构造函数、属性与方法接口 interface 与实现枚举 enum、代数数据类型与 match 模式匹配泛型编程扩展、类型别名与可见性控制cjpm 包管理与多文件项目组织文件与目录 IOJSON 处理网络编程入门单元测试并发基础线程的创建与等待线程同步互斥锁、原子类型与条件变量并发实战多线程任务处理实战一带文件持久化的命令行小工具本文实战二GeoJSON 数据处理程序一、要做一个什么东西一个叫todo的命令行待办清单运行后进入一个循环菜单1. 添加任务 2. 查看清单 3. 完成任务 4. 删除任务 0. 退出选1输入一行标题程序分配一个自增编号存下来选2列出全部任务完成的打[x]没完成的打[ ]末尾给统计选3输入编号把对应任务勾掉选4输入编号删除对应任务选0退出。和前 28 课例子最大的不同有两个程序会记住事情——任务写在一个文本文件tasks.txt里每次改动立即落盘关掉再开数据还在代码拆三个文件各司其职这是第 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--nametodo生成的cjpm.toml关键字段如下cjpm init已自动填好不用改[package] cjc-version 1.2.0 name todo version 1.0.0 output-type executablename todo决定了包名接下来三个源文件第一行都必须写package todo写成别的会报 package name wrong。把src/main.cj骨架清空我们分三个文件从零写。三、数据层task.cj一条任务只需要三个字段编号身份永不改变、标题、是否完成。src/task.cjpackage 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 } }两个细节id、title用let——编号和标题在本课的生命周期里不可变done用var因为勾完成就是要改它。构造器第三参写成done!: Bool false。注意参数名后面那个!仓颉的默认参数必须用命名参数形式声明第 16 课 1.3 节调用时才能省略它。这样新建任务写Task(1, 写作业)就表示未完成从文件读历史任务时再写Task(2, 跑步, done: true)。 小提示写成done: Bool false漏了!编译不过实测报错error: expected , or ), found 。四、仓库层上内存里的增删查所有任务放在哪放一个TaskStore任务仓库对象里内部攥一个ArrayListTask和一个下一个编号nextId对外提供添加、完成、删除、取全部、数数五个操作。文件读写的事先按下不表下一节再加。src/task_store.cj先写内存部分package todo import std.collection.ArrayList // 任务仓库内存里用 ArrayList 保管全部任务 public class TaskStore { private let filePath: String private let tasks: ArrayListTask ArrayListTask() 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(): ArrayTask { 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..(i1)表示从 i 到 i1不含 i1正好删掉下标 i 这一个元素。不要写remove(i)参数类型不对。getAll()返回toArray()得到的ArrayTask数组适合只读遍历展示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()转成ArrayUInt8写完记得close()。load启动时把文件灌回内存exists(path)为 false 说明第一次运行——什么也不做nextId保持 1、列表保持空即可仓颉没有逐行读文件的 API老套路第 22 课 3.4 节File.readFrom读全部字节 →String.fromUtf8转字符串 →split(\n)切行。文件末尾那个换行切出一个空字符串用if (line ) continue跳过每行再split(|)不足三段是坏行跳过Int64.parse(parts[0])可能因为编号不是数字而抛IllegalArgumentException第 10 课。整段包在try-catch里任何一行坏掉只跳过这一行不拖垮整个程序启动关键收尾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(请输入正确的数字编号) } }设计上有两个可以迁移到任何交互程序的手法共用一个readId完成和删除都要读一个数字编号把打印提示 → 读一行 → parse 成 Int64抽成一个函数parse 失败抛的异常由调用方的 try-catch 接住界面提示语各自定制。不合法输入绝不崩标题为空直接拦下数字格式错误由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改成下面这样故意塞两行坏数据再启动选 22|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跳过坏行好行照常加载这张表就是一个入门级交互程序的容错清单菜单越界、类型错误、数据不存在、空输入、外部文件损坏——五类常见意外每一类都在自己的边界上被拦下没有一个能让程序死掉。十一、这个小项目的五条工程经验分层task.cj只描述数据task_store.cj管数据怎么存内存和文件都在这一层main.cj管屏幕和键盘。改存储格式只动仓库层换成交互界面只动表现层——这就是第 17 课接口思想之外更朴素的职责分离。改动即落盘add / complete / remove 内部都调save()任何一步之后程序被杀、断电最多损失最后一次未完成的键盘输入不会丢已确认的数据。代价是每次操作重写整个文件——对几百条任务的小工具完全可以接受。编号是身份只发不复用nextId从文件里的最大编号恢复。删掉 #2 后新来的任务也绝不再叫 #2避免这个 #2 到底是哪条的错乱。外部数据一律不可信文件可能被手改、被截断、被塞垃圾。加载时每行都验证、坏行跳过parse放在 try-catch 里。同包多文件不用互相 import三个文件都package todo即共享作用域第 21 课只有标准库才需要 import且每个文件各 import 各的。十二、CIDE 实操终端执行cjpm init --name todo按本节结构新建src/task.cj、src/task_store.cj改写src/main.cjcjpm run亲手走一遍加三条、勾一条、删一条、退出左侧文件树打开自动生成的tasks.txt对照屏幕列表逐行看再cjpm run一次确认数据原样回来故意往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))静态方法返回ArrayUInt8判断文件存在exists(Path(p))std.fs切分字符串s.split(|)/s.split(\n)String 自带结果.size、[i]动态数组ArrayListT()std.collection.ArrayListadd/toArray/size/[i]按下标删一个list.remove(i..(i 1))参数是 Range不是整数默认参数func f(x!: Int64 0)参数名后必须有!调用处可f(x: 3)菜单循环while (running) { match (readln()) { case _ } }选 0 置 false 退出十四、常见问题 FAQQ1为什么不用第 23 课的 JSON 存JSON 结构化、自带类型适合字段多、嵌套深的数据但第 23 课的 JSON 能力在 stdx 扩展库里需要额外下载配置。本课只有三个扁平字段纯文本编号|完成|标题零依赖、人眼可读、记事本可改工程上叫按数据复杂度选技术不是什么都要用最重的方案。Q2为什么每操作一下就写文件退出时统一存不是更省事退出时才存一旦程序崩溃、电脑断电、用户直接关窗口本次所有改动全丢。改动即落盘把丢数据的窗口缩到一次键盘输入以内。等以后任务多到每次重写文件有明显卡顿再改成标记脏数据 退出时/定时刷盘也不迟。Q3删掉 #2 之后为什么新任务不是 #2编号是任务的身份不是列表里的座位号。复用编号会让曾经的 #2和新来的 #2在文件、日志、你的记忆里混淆。仓库从文件恢复nextId maxId 1保证编号单调发、永不回头。Q4输出里请选择为什么和任务标题挤在同一行print输出后不换行就是为了让你在终端里看到请选择▏在冒号后打字。用输入重定向跑批时没有键盘回显两个提示符就挨在一起了你在真实终端里手动运行时中间夹着你敲的字符观感完全正常。Q5三个文件为什么不用互相 importsrc/下所有首行写package todo的文件天然属于同一个包、共享作用域第 21 课 3.1 节main.cj里直接用TaskStore、Task即可。只有std.*标准库才需要 import且 import 只对当前文件生效所以三个文件各自 import 自己用到的包。Q6标题里非要输入|会怎样当前版本的添加流程没有拦截它落盘后这一行会多出一个分隔段加载端有把第三段之后拼回来的兜底数据不会丢。课后练习可以自己在添加时加上拒绝逻辑判断title.contains(|)。十五、课后练习必做增加5. 清空已完成菜单项。在TaskStore里加clearCompleted(): Int64返回清掉的条数边遍历边删除时下标不能无脑 1——删元素后后面的元素会前移只有没删时才i 1一条没删就不用写文件。预置三条任务两条已完成时选 5期望输出已清空 2 项已完成任务必做增加6. 修改标题。先把Task.title从let改成var否则编译报错仓库里加rename(id: Int64, newTitle: String): Bool界面流程读编号、再读新标题空标题同样拦截。把 #1 从买菜改成写代码期望输出任务 #1 已改名为写代码必做破坏实验在程序未运行时用记事本把tasks.txt改成两行好数据 一行只有两个字段的坏行 一行编号是字母的坏行启动后选 2。确认程序不崩溃、只列出两条好数据再执行任意一次合法操作后重新打开文件确认坏行已被重写清洗掉。选做给任务加一个优先级字段整数 13文件格式扩成四段编号|完成|优先级|标题列表里用(高)(中)(低)标注。注意 load 的段数下限和标题下标都要跟着挪一位坏行容错仍要保留。下节预告最后一课我们换一个更像工作的题材第 30 课实战二GeoJSON 数据处理程序——读取真实的地理数据文件用枚举和集合做结构化解析、统计与转换再把结果写成新文件。它会把泛型、集合、文件 IO、错误处理串成一条数据处理流水线为整个 30 课系列收尾。系列说明本系列基于 Windows 平台 CIDE 仓颉 SDK1.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