【仓颉语言入门 · 第22课】
【仓颉语言入门 · 第22课】文件与目录 IO:让程序的数据持久化
前 21 课的数据都活在内存里,程序一关就没了。本课带你掌握 std.fs 文件系统库:读写文本文件、遍历目录、处理路径拼接,最后把第 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 | 线程、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
- 环境搭建与第一个仓颉程序
- 变量与常量:let / var 与基本数据类型
- 运算符与标准输入输出
- 分支结构:if 与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织
- 文件与目录 IO(本文)
- JSON 处理(结合 stdx 扩展库)
- 网络编程入门
- 单元测试
- 并发基础:线程的创建与等待
- Channel 通道与同步原语
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具
- 实战二:GeoJSON 数据处理程序
一、为什么需要文件 IO
回顾第 21 课的订单系统:
main(): Int64 {
let orders = [
Order("NO.1001", 1250),
Order("NO.1002", 800)
]
// 处理订单...
return 0
}
问题很明显:订单数据硬编码在代码里,每次改数据都要重新编译。真实场景应该是:
- 启动时从文件加载订单列表;
- 用户新增/修改订单后保存回文件;
- 下次启动时读取最新的数据。
文件 IO 就是让程序能读写磁盘上的文件,实现数据持久化。
仓颉标准库提供 std.fs 模块处理文件系统操作,核心类型有三个:
| 类型 | 作用 | 常用场景 |
|---|---|---|
Path | 路径抽象(不含文件内容) | 拼接路径、判断文件是否存在 |
File | 文件读写 | 读文本、写文本、追加内容 |
Directory | 目录操作 | 创建目录、遍历子文件、删除目录 |
二、Path:处理文件路径
2.1 创建 Path 对象
import std.fs.Path
main(): Int64 {
// 从字符串创建路径
let p1 = Path("data/orders.txt")
// 拼接路径(自动处理分隔符)
let p2 = Path("data").join("2024").join("orders.txt")
println("p1 = ${p1}")
println("p2 = ${p2}")
return 0
}
运行输出(Windows 下):
p1 = data/orders.txt
p2 = data\2024\orders.txt
注意:Path 构造函数保留原始分隔符(/ 不会转成 \),但 join() 会用系统分隔符拼接。
2.2 判断路径是否存在
判断存在用的是 std.fs 的包级函数 exists()(不是 Path 的成员方法):
import std.fs.{Path, exists}
main(): Int64 {
let filePath = Path("test.txt")
let dirPath = Path("data")
println("test.txt 存在? ${exists(filePath)}")
println("data 目录存在? ${exists(dirPath)}")
return 0
}
如果 test.txt 和 data 都不存在,输出:
test.txt 存在? false
data 目录存在? false
注意:要区分"是文件"还是"是目录",需要用 FileInfo(见 4.2 节)。
2.3 获取文件名、父目录、扩展名
import std.fs.Path
main(): Int64 {
let p = Path("data/2024/orders.txt")
println("文件名:${p.fileName}") // orders.txt
println("父目录:${p.parent}") // data/2024
println("扩展名:${p.extensionName}") // txt
println("不含扩展名的文件名:${p.fileNameWithoutExtension}") // orders
println("是否绝对路径:${p.isAbsolute()}") // false
println("是否相对路径:${p.isRelative()}") // true
return 0
}
运行输出:
文件名:orders.txt
父目录:data/2024
扩展名:txt
不含扩展名的文件名:orders
是否绝对路径:false
是否相对路径:true
注意:fileName、parent、extensionName 是属性(无括号),isAbsolute()、isRelative() 是函数(有括号)。parent 返回空字符串表示没有父目录。
三、File:读写文本文件
3.1 写入文本(覆盖模式)
import std.fs.{Path, File, OpenMode}
main(): Int64 {
let path = Path("hello.txt")
let content = "你好,仓颉!\n这是第二行。\n"
// 以写模式打开(不存在则创建,存在则截断为0字节)
let file = File(path, OpenMode.Write)
// 写入字符串(需转 Array<UInt8>)
file.write(content.toArray())
// 关闭文件(释放资源)
file.close()
println("写入完成:${path}")
return 0
}
运行后会在当前目录生成 hello.txt,内容:
你好,仓颉!
这是第二行。
注意:File.create() 也能创建文件,但如果文件已存在会抛异常。覆盖写已有文件请用 File(path, OpenMode.Write)。
3.2 读取文本
import std.fs.{Path, File, exists}
main(): Int64 {
let path = Path("hello.txt")
if (!exists(path)) {
println("文件不存在")
return 1
}
// 读取全部字节并转字符串
let bytes = File.readFrom(path)
let content = String.fromUtf8(bytes)
println("文件内容:")
println(content)
return 0
}
运行输出:
文件内容:
你好,仓颉!
这是第二行。
注意:File.readFrom(path) 是静态方法,直接返回 Array<UInt8>,无需手动 open/close。也可以用 File(path, OpenMode.Read) + file.read(buffer) 分块读取大文件。
3.3 追加内容(不清空原文件)
import std.fs.{Path, File, OpenMode}
main(): Int64 {
let path = Path("log.txt")
// 追加模式打开(不存在则创建,存在则在末尾追加)
let file = File(path, OpenMode.Append)
file.write("[2024-10-01 10:00] 程序启动\n".toArray())
file.write("[2024-10-01 10:05] 处理订单\n".toArray())
file.close()
println("日志已追加")
return 0
}
运行两次后,log.txt 内容:
[2024-10-01 10:00] 程序启动
[2024-10-01 10:05] 处理订单
[2024-10-01 10:00] 程序启动
[2024-10-01 10:05] 处理订单
3.4 逐行读取
仓颉没有内置的 readLine(),逐行读取需要先读全部内容,再按行分割:
import std.fs.{Path, File}
main(): Int64 {
let path = Path("data.txt")
// 读全部内容
let bytes = File.readFrom(path)
let content = String.fromUtf8(bytes)
// 按行分割
let lines = content.split("\n")
var lineNum = 1
for (line in lines) {
if (line == "") {
continue // 跳过空行
}
println("第 ${lineNum} 行:${line}")
lineNum += 1
}
return 0
}
假设 data.txt 内容:
苹果
香蕉
橙子
运行输出:
第 1 行:苹果
第 2 行:香蕉
第 3 行:橙子
四、Directory:目录操作
4.1 创建目录
import std.fs.{Path, Directory, exists}
main(): Int64 {
let dirPath = Path("output/2024/logs")
// 递归创建目录(包括所有父目录)
if (!exists(dirPath)) {
Directory.create(dirPath, recursive: true)
}
println("目录已创建:${dirPath}")
return 0
}
运行后会创建 output/2024/logs/ 三层目录。
注意:Directory.create 默认不递归,传 recursive: true 才能一次创建多层;如果目录已存在会抛异常,通常先判断 exists()。
4.2 遍历目录下的文件
Directory.readFrom(path) 返回 Array<FileInfo>,FileInfo 提供 name、isRegular()、isDirectory() 等:
import std.fs.{Path, Directory, exists}
main(): Int64 {
let dirPath = Path("data")
if (!exists(dirPath)) {
println("目录不存在")
return 1
}
// 获取目录下的所有条目(文件 + 子目录)
let entries = Directory.readFrom(dirPath)
for (entry in entries) {
if (entry.isRegular()) {
println("文件:${entry.name}")
} else if (entry.isDirectory()) {
println("目录:${entry.name}")
}
}
return 0
}
假设 data/ 目录结构:
data/
├── orders.txt
├── users.txt
└── backup/
└── old.txt
运行输出:
文件:orders.txt
文件:users.txt
目录:backup
注意:Directory.readFrom() 只列出直接子项,不会递归进子目录。要递归遍历,见 4.4 节。
4.3 删除文件或目录
删除用的是 std.fs 的包级函数 remove(path, recursive:):
import std.fs.{Path, remove, exists}
main(): Int64 {
let filePath = Path("temp.txt")
let dirPath = Path("temp_dir")
// 删除文件
if (exists(filePath)) {
remove(filePath, recursive: false)
println("文件已删除")
}
// 删除空目录(如果目录非空会报错)
if (exists(dirPath)) {
remove(dirPath, recursive: false)
println("目录已删除")
}
return 0
}
注意:recursive: false 时,目录非空会抛异常;传 recursive: true 可递归删除非空目录(谨慎使用)。也可以用 removeIfExists(path, recursive:),它返回 Bool,且路径不存在时不抛异常。
4.4 递归遍历目录
import std.fs.{Path, Directory, exists}
// 递归打印目录树
func printTree(dir: Path, indent: String): Unit {
let entries = Directory.readFrom(dir)
for (entry in entries) {
println("${indent}${entry.name}")
if (entry.isDirectory()) {
printTree(entry.path, indent + " ")
}
}
}
main(): Int64 {
let root = Path("data")
if (!exists(root)) {
println("目录不存在")
return 1
}
println(root.fileName)
printTree(root, " ")
return 0
}
假设 data/ 结构:
data/
├── orders.txt
└── backup/
├── 2023.txt
└── 2024/
└── old.txt
运行输出:
data
orders.txt
backup
2023.txt
2024
old.txt
注意:递归遍历中通过 entry.path(FileInfo 的完整路径属性)继续深入子目录,而不是直接用 entry(类型是 FileInfo,不是 Path)。
五、实战:把订单数据保存到文件
我们把第 21 课的订单系统改造成从文件加载、保存到文件。
5.1 数据文件格式
用简单的文本格式(每行一个订单):
NO.1001|1250|已支付
NO.1002|800|待支付
NO.1003|99|已支付
字段用 | 分隔:订单号|金额(分)|状态。
5.2 代码实现
目录结构:
ordersys/
├── cjpm.toml
└── src/
├── main.cj
├── models/
│ └── order.cj
└── storage/
└── file_store.cj
src/models/order.cj(和第 21 课相同):
package ordersys.models
public class Order {
public let id: String
private var paid: Bool = false
private let amountFen: Int64
public init(id: String, amountFen: Int64) {
this.id = id
this.amountFen = amountFen
}
public func pay(): Unit {
this.paid = true
}
public func isPaid(): Bool {
return this.paid
}
public func getAmountFen(): Int64 {
return this.amountFen
}
}
src/storage/file_store.cj(新增,负责文件读写):
package ordersys.storage
import std.fs.{Path, File, Directory, OpenMode, exists}
import std.collection.ArrayList
import std.convert.*
import ordersys.models.Order
// 把订单列表保存到文件
public func saveOrders(orders: Array<Order>, filePath: String): Unit {
let path = Path(filePath)
// 确保父目录存在
let parent = path.parent
if (parent.toString() != "" && !exists(parent)) {
Directory.create(parent, recursive: true)
}
// OpenMode.Write:不存在则创建,存在则截断覆盖
let file = File(path, OpenMode.Write)
for (order in orders) {
let status = if (order.isPaid()) { "已支付" } else { "待支付" }
let line = "${order.id}|${order.getAmountFen()}|${status}\n"
file.write(line.toArray())
}
file.close()
}
// 从文件加载订单列表
public func loadOrders(filePath: String): Array<Order> {
let path = Path(filePath)
let result = ArrayList<Order>()
if (!exists(path)) {
println("文件不存在,返回空列表:${filePath}")
return result.toArray()
}
// 读取全部内容
let bytes = File.readFrom(path)
let content = String.fromUtf8(bytes)
// 按行分割
let lines = content.split("\n")
for (line in lines) {
if (line == "") {
continue
}
// 解析行:NO.1001|1250|已支付
let parts = line.split("|")
if (parts.size < 3) {
continue // 跳过格式错误的行
}
let id = parts[0]
let amountFen = Int64.parse(parts[1])
let paid = parts[2] == "已支付"
let order = Order(id, amountFen)
if (paid) {
order.pay()
}
result.add(order)
}
return result.toArray()
}
src/main.cj:
package ordersys
import ordersys.models.Order
import ordersys.storage.{saveOrders, loadOrders}
main(): Int64 {
let filePath = "data/orders.txt"
// 从文件加载订单
var orders = loadOrders(filePath)
if (orders.size == 0) {
println("首次运行,创建测试数据...")
orders = [
Order("NO.1001", 1250),
Order("NO.1002", 800),
Order("NO.1003", 99)
]
}
// 显示订单列表
println("=== 订单列表 ===")
for (o in orders) {
let status = if (o.isPaid()) { "已支付" } else { "待支付" }
println("订单(${o.id}) ${o.getAmountFen()} 分 [${status}]")
}
// 模拟支付第一个订单
if (orders.size > 0) {
orders[0].pay()
println("\n订单 ${orders[0].id} 已支付")
}
// 保存回文件
saveOrders(orders, filePath)
println("\n数据已保存到 ${filePath}")
return 0
}
5.3 运行效果
第一次运行(文件不存在):
文件不存在,返回空列表:data/orders.txt
首次运行,创建测试数据...
=== 订单列表 ===
订单(NO.1001) 1250 分 [待支付]
订单(NO.1002) 800 分 [待支付]
订单(NO.1003) 99 分 [待支付]
订单 NO.1001 已支付
数据已保存到 data/orders.txt
同时生成 data/orders.txt 文件,内容:
NO.1001|1250|已支付
NO.1002|800|待支付
NO.1003|99|待支付
第二次运行(从文件读取):
=== 订单列表 ===
订单(NO.1001) 1250 分 [已支付]
订单(NO.1002) 800 分 [待支付]
订单(NO.1003) 99 分 [待支付]
订单 NO.1001 已支付
数据已保存到 data/orders.txt
订单 NO.1001 的支付状态被记住了!
六、常用 API 速查
| 功能 | API | 示例 |
|---|---|---|
| 创建路径 | Path(str) | Path("data/orders.txt") |
| 拼接路径 | path.join(sub) | Path("data").join("orders.txt") |
| 判断存在 | exists(path) | if (exists(path)) { ... } |
| 判断是文件 | entry.isRegular() | if (entry.isRegular()) { ... } |
| 判断是目录 | entry.isDirectory() | if (entry.isDirectory()) { ... } |
| 写文件(覆盖) | File(path, OpenMode.Write) | File(Path("a.txt"), OpenMode.Write) |
| 写文件(追加) | File(path, OpenMode.Append) | File(Path("log.txt"), OpenMode.Append) |
| 静态读文件 | File.readFrom(path) | File.readFrom(Path("a.txt")) |
| 静态写文件 | File.writeTo(path, bytes) | File.writeTo(path, str.toArray()) |
| 逐行读 | 先 readFrom 再 split("\n") | 见 3.4 节 |
| 写字符串 | file.write(bytes) | file.write("hello\n".toArray()) |
| 关闭文件 | file.close() | file.close() |
| 创建目录 | Directory.create(path, recursive: true) | Directory.create(p, recursive: true) |
| 列出目录内容 | Directory.readFrom(path) | Directory.readFrom(Path("data")) |
| 删除文件/目录 | remove(path, recursive: Bool) | remove(path, recursive: false) |
| 安全删除 | removeIfExists(path, recursive:) | removeIfExists(path, recursive: false) |
七、常见问题 FAQ
Q1:OpenMode.Write 和 OpenMode.Append 有什么区别?
OpenMode.Write:覆盖模式,如果文件已存在会清空内容;OpenMode.Append:追加模式,在文件末尾追加内容,不会清空已有数据。
另外注意:File.create(path) 只能创建新文件,文件已存在时会抛异常。
Q2:忘记 file.close() 会怎样?
文件句柄会一直占用,直到程序退出。如果程序长时间运行且频繁打开文件不关闭,会导致"文件句柄耗尽"错误。建议每次打开文件后都记得关闭。
Q3:路径用 / 还是 \?
仓颉的 Path 会自动处理分隔符,推荐用 /(跨平台兼容),Windows 下也能正常工作。
Q4:读写文件出错了怎么办?
文件操作会抛出异常(如文件不存在、权限不足)。可以用 try-catch 捕获(第 10 课讲过):
try {
let bytes = File.readFrom(Path("data.txt"))
println(String.fromUtf8(bytes))
} catch (e: Exception) {
println("读取文件失败:${e.message}")
}
Q5:文本文件和二进制文件有什么区别?
本课讲的都是文本文件(能用记事本打开的 .txt、.csv)。如果是图片、视频等二进制文件,同样用 File.readFrom() 读字节数组、file.write(bytes) 写字节,只是不做 UTF-8 字符串转换。
Q6:Directory.readFrom() 会递归子目录吗?
不会。它只列出直接子项。要递归遍历,需要自己写递归函数(见 4.4 节)。
八、课后练习
- 写一个程序,创建
notes/目录,在里面生成note1.txt、note2.txt、note3.txt三个文件,内容分别是"笔记一"、“笔记二”、“笔记三”。 - 在第 1 题的基础上,遍历
notes/目录,打印每个文件的文件名和内容。 - 写一个日志记录器函数
log(message: String),每次调用把消息追加到app.log文件,格式:[时间] 消息内容。 - 把第 21 课的订单系统改造成:启动时从
data/orders.txt加载订单,用户输入命令(如pay NO.1001)修改订单状态后保存回文件。 - 挑战:写一个递归函数
countFiles(dir: Path): Int64,统计目录下(包括所有子目录)有多少个文件(不含目录)。
下节预告
文本文件虽然能用,但格式太简陋(NO.1001|1250|已支付),不适合复杂数据。第 23 课 JSON 处理 将讲解:如何用 stdx.json 库把订单对象序列化成 JSON、从 JSON 反序列化成对象,让数据格式更通用、更易读。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
📥 工具下载
本系列全程使用的仓颉 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)