
【仓颉语言入门 · 第22课】文件与目录 IO让程序的数据持久化前 21 课的数据都活在内存里程序一关就没了。本课带你掌握std.fs 文件系统库读写文本文件、遍历目录、处理路径拼接最后把第 21 课的订单数据保存到文件重启程序还能读出来。本文所有代码均在仓颉 SDK 1.2.0 下逐行实测编译运行。目录系列导航整套路线共7 个模块、30 课模块课次内容一、环境与入门0105环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环二、常用类型与数据组织0610字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理三、函数与函数式1114函数、Lambda 与高阶函数、闭包、迭代器与惰性序列四、面向对象与类型系统1520struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展五、工程化与标准库2125cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试六、并发编程2628线程、Channel 通道与同步原语、并发实战七、项目实战2930命令行小工具、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) // 写入字符串需转 ArrayUInt8 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)是静态方法直接返回ArrayUInt8无需手动 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)返回ArrayFileInfoFileInfo提供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.pathFileInfo的完整路径属性继续深入子目录而不是直接用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: ArrayOrder, 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): ArrayOrder { let path Path(filePath) let result ArrayListOrder() 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.cjpackage 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)七、常见问题 FAQQ1OpenMode.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 字符串转换。Q6Directory.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 仓颉 SDK1.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