【仓颉语言入门 · 第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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程、Channel 通道与同步原语、并发实战
七、项目实战29~30命令行小工具、GeoJSON 数据处理实战
  1. 环境搭建与第一个仓颉程序
  2. 变量与常量:let / var 与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构:if 与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO(本文)
  23. JSON 处理(结合 stdx 扩展库)
  24. 网络编程入门
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. Channel 通道与同步原语
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具
  30. 实战二: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 节)。


八、课后练习

  1. 写一个程序,创建 notes/ 目录,在里面生成 note1.txt、note2.txt、note3.txt 三个文件,内容分别是"笔记一"、“笔记二”、“笔记三”。
  2. 在第 1 题的基础上,遍历 notes/ 目录,打印每个文件的文件名和内容。
  3. 写一个日志记录器函数 log(message: String),每次调用把消息追加到 app.log 文件,格式:[时间] 消息内容。
  4. 把第 21 课的订单系统改造成:启动时从 data/orders.txt 加载订单,用户输入命令(如 pay NO.1001)修改订单状态后保存回文件。
  5. 挑战:写一个递归函数 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
Logo

一站式 AI 云服务平台

更多推荐