【仓颉语言入门 · 第29课】
#【仓颉语言入门 · 第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~20 | struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展 |
| 五、工程化与标准库 | 21~25 | cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试 |
| 六、并发编程 | 26~28 | 线程的创建与等待、线程同步、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具(本文)、GeoJSON 数据处理程序 |
- 环境搭建与第一个仓颉程序
- 变量、常量与基本数据类型
- 运算符与标准输入输出
- 分支结构与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织
- 文件与目录 IO
- JSON 处理
- 网络编程入门
- 单元测试
- 并发基础:线程的创建与等待
- 线程同步:互斥锁、原子类型与条件变量
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具(本文)
- 实战二: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 --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
}
}
两个细节:
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(任务仓库)对象里:内部攥一个 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(启动时把文件灌回内存)
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 改成下面这样(故意塞两行坏数据),再启动选 2:
2|1|跑步30分钟
这是一行坏数据
abc|0|编号损坏
3|0|复习第29课
程序不报错、不崩溃,列表里只有两条好数据,两行坏数据被安静跳过:
---- 待办清单 ----
[x] #2 跑步30分钟
[ ] #3 复习第29课
共 2 项,已完成 1 项
而且因为保存时整文件重写,你做任意一次合法操作后,坏行会被自动"清洗"出文件。
十、健壮性四道防线实测
把各种乱来的输入集中喂一轮,每条都有明确去向(均实测):
| 乱来的输入 | 触发的防线 | 程序反应 |
|---|---|---|
菜单敲 9 | match 的 case _ | 无效选项,请重新输入,继续循环 |
编号处敲 abc | Int64.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.cj; cjpm 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)) | 静态方法,返回 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("|"))。
十五、课后练习
- (必做)增加"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。确认程序不崩溃、只列出两条好数据;再执行任意一次合法操作后重新打开文件,确认坏行已被重写清洗掉。 -
(选做)给任务加一个优先级字段(整数 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
更多推荐




所有评论(0)