【仓颉语言入门 · 第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.cjsrc/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