1. 简介
当你刚开始处理文件时,你想要的是简单的幸福:“这里有个文件,我想复制它。”就在那一刻,你了解到文件涉及流 (streams)、缓冲区 (buffers)、分块读取循环 (chunked read loops)、EOF 等等,这些词汇在万圣节用来吓唬初学者非常方便。但事实是,有时你并不需要整个动物园:如果文件很小,你可以诚实地将其作为字节数组加载到内存中,并同样诚实地将其写入另一个文件——没有解释,没有编码,没有复杂的循环。
正是为了这些场景,Kotlin/JVM 提供了一对非常方便的函数:File.readBytes() 和 File.writeBytes(bytes)。它们实现了名字所承诺的功能:以“原样”读取和写入字节。从本质上讲,这是从“我有一个二进制文件”到“我有一个它的副本”的最短路径。
为什么这些看起来像是 File 的“方法”
当你第一次看到 File("a.bin").readBytes() 这样的代码时,很容易认为:“哦,所以 File 类有一个 readBytes() 方法。”然后会出现第二个问题:“为什么 Java 的 File 类突然有了这些 Kotlin 方法?”
答案非常符合 Kotlin 的优雅风格:它们是 扩展函数 (extension functions)。
Kotlin 允许你向类型“添加”函数,即使你没有它们的源代码。从外部看,它们调用的方式就像普通方法一样,但实际上编译器将其重写为对普通函数的调用。这就是扩展的核心思想:“像调用成员一样调用它,但我们并没有修改这个类。”
从心理上讲,这很方便:file.readBytes() 的读法更像是一句人类语言,而不是“调用某个包里的某个工具”。
最小示例:读取字节并打印大小
首先,让我们做最简单、最直接的示例:完整读取一个文件并打印我们获取了多少字节。在这里,我们甚至没有进行任何复制——只是验证 ByteArray 确实已获取。
import java.io.File
fun main() {
val file = File("in.bin")
val bytes: ByteArray = file.readBytes()
println("bytesCount=${bytes.size}") // 字节数=...
}
注意:bytes.size 是 ByteArray 中元素的数量,即字节的数量。这些不是“字符”,不是“行”,也不是“字母”。它是“文件中存储了多少个数据单元”。
2. 整文件二进制复制:“读取 → 写入”
现在我们来做这部分的核心内容:“读取 → 写入”。重要的一点:我们 不尝试解释 文件里有什么。我们不把字节变成文本再转回来。我们像搬砖一样移动它们:“从盒子 A 取出砖块,放进盒子 B。”
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("copy.bin")
val data = src.readBytes()
dst.writeBytes(data)
println("copied: ${src.length()} -> ${dst.length()} bytes")
// 已复制: 123 -> 123 字节
}
在这里,我们使用 length() 作为磁盘文件大小(以字节为单位)的快速指标。这并不能 100% 证明内容完全一致,但作为“初步检查”非常有效。
3. 将复制功能提取到 copyBinaryWhole(...)
如果你把“复制”逻辑直接写在 main 函数里,那么过几天你的 main 看起来就会像一张假期待办事项清单:每件事都很重要,但根本读不进去。所以让我们把复制功能提取到一个函数中。
让我们为我们的学习控制台应用制作一个工具(让它成为一个小型工具 FileTool——我们将在关于文件的讲座中逐步完善它)。
import java.io.File
fun copyBinaryWhole(src: File, dst: File) {
val data = src.readBytes()
dst.parentFile?.mkdirs() // 以防路径为 "out/dir/copy.bin"
dst.writeBytes(data)
}
fun main() {
copyBinaryWhole(File("in.bin"), File("out/copy.bin"))
println("Done") // 完成
}
注意 dst.parentFile?.mkdirs()。 这是一点面向未来的预防措施:如果路径是“嵌套”的,目录可能还不存在。我们这里不会深入探讨复杂的 I/O 和异常处理(那是另一个大课题),但我们已经开始习惯编写不会“因为琐事”而崩溃的代码。
4. 结果验证:“看起来相似” vs “完全一样”
验证结果不是偏执——这是正常的工程实践。尤其是对于文件:复制过程可能会中断、文件可能被锁定、磁盘空间可能耗尽,或者你可能会不小心搞混路径(然后愉快地覆盖了错误的文件)。
记住两个级别的验证很有帮助:
| 检查 | 证明了什么 | 优点 | 缺点 |
|---|---|---|---|
|
“大小匹配” | 速度快,无需加载到内存 | 内容可能不同 |
|
“逐字节相同” | 可靠 | 两个文件最终都会进入内存 |
基于大小的检查
让我们从简单的开始:复制后比较大小。
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("copy.bin")
dst.writeBytes(src.readBytes())
val ok = src.length() == dst.length()
println("sameSize=$ok") // sameSize=true
}
如果 sameSize 为 false,显然复制失败了。如果为 true,这是一个好迹象——但不是保证。
基于内容的检查:contentEquals
要按内容比较两个字节数组,必须比较 内容,而不是“对象引用”。 Kotlin 明确警告说,对于数组,不能使用 == 进行“内容比较”,因为数组是对象,== 对它们的作用不如你所愿。这就是 contentEquals() 的用途。
import java.io.File
fun main() {
val a: ByteArray = File("a.bin").readBytes()
val b: ByteArray = File("b.bin").readBytes()
println(a.contentEquals(b)) // true 或 false
}
核心思想:ByteArray 是一个数组,它以“特殊方式”进行比较。这与普通数组的规则完全相同;只是现在因为它处理的是二进制数据,所以显得格外有用。
5. 为什么 a == b 不适用于 ByteArray
非常典型的初学者情况:“在 Kotlin 中我习惯了 == 是值比较。为什么这里不行?”Kotlin 回答:“因为数组很特殊。”对于许多类型,== 检查结构相等性,但 数组不行——数组有专门的函数 contentEquals/contentDeepEquals。
类比一下,对于数组来说,a == b 就像是在问:“这是同一个包装盒吗?”(同一个对象),而 contentEquals 则是:“包装盒里的东西是否完全一样,且顺序一致?”对于文件和字节,我们几乎总是关心后者。
6. “整文件”方法的局限性:内存不是无限的
这听起来很显而易见,但很有用:RAM 是有限的。readBytes() 会将整个文件读取到一个 ByteArray 中,这意味着该数组必须存放在某处。如果你用这种方式读取一个 2-3 GB 的文件,程序很快就会遇到 OutOfMemoryError(而且这种相遇通常是不愉快的)。
所以“读取全部”是一个有舒适区的工具:适用于小型和中型文件。在下一次讲座中,我们将转向流和“分块”读取,但即使现在,你也可以添加一个简单的防范措施:在读取之前检查大小。
import java.io.File
private const val MAX_BYTES: Long = 10L * 1024 * 1024 // 10 MB
fun main() {
val file = File("in.bin")
if (file.length() > MAX_BYTES) {
println("File is too large: ${file.length()} bytes") // 文件过大: ... 字节
return
}
val bytes = file.readBytes()
println("Loaded ${bytes.size} bytes") // 已加载 ... 字节
}
这不是一个“理想的策略”,但它是一个诚实且清晰的屏障:你明确告诉代码你愿意完整加载多大的文件。
7. 流程:完整复制 + 验证
为了让这些步骤在脑海中不混乱,让我们把这个过程画成一个小流程图。是的,程序员喜欢图表,因为它们让你思考一次后,就可以假装这显而易见很久了。
flowchart TD
A[有一个源文件] --> B{大小是否可接受?}
B -->|否| X[通知并退出]
B -->|是| C["src.readBytes()"]
C --> D["dst.writeBytes(data)"]
D --> E{验证结果}
E -->|大小| F[src.length == dst.length]
E -->|内容| G["data.contentEquals(dst.readBytes())"]
8. FileTool:交互式二进制复制
现在我们将整合一个小的“实用”代码片段,执行复制并提供验证选项。我们不会深入探讨命令行参数(那会在课程的另一天讲解),所以我们将通过 readln() 实现一个简单的交互式场景。
验证模式选择模型
我们需要表示“按大小验证 / 按内容验证 / 不验证”的选择。对于本次讲座,字符串就足够了,但为了避免到处传播字符串魔法,我们将制作一个小的标准化函数。
fun normalizeMode(input: String): String =
input.trim().lowercase()
我们已经在之前的字符串主题中学过了 trim() 和 lowercase(),所以这里的一切看起来应该很熟悉。
复制 + 大小验证
首先,实现不需要将两个文件同时放在内存中的选项。
import java.io.File
fun copyWholeWithSizeCheck(src: File, dst: File): Boolean {
dst.writeBytes(src.readBytes())
return src.length() == dst.length()
}
这里使用 Boolean 很方便:该函数以简单值报告“成功/失败”。我们不会深入研究错误设计和异常——目前的目标是一个清晰的基本场景。
复制 + 内容验证
现在是“硬”验证:通过 contentEquals 进行逐字节比较,它专为数组的内容比较而设计。
import java.io.File
fun copyWholeWithContentCheck(src: File, dst: File): Boolean {
val data = src.readBytes()
dst.writeBytes(data)
val copied = dst.readBytes()
return data.contentEquals(copied)
}
是的,这会“读取两次”:一次是从 src,第二次是从 dst。对于学习示例来说这没问题,但请记住:在大文件上这样做可能会很昂贵。
组装 main:一个场景,三种模式
现在让我们把它组装到一个 main 函数中。它会很简短,但“充满活力”:它会要求输入路径和验证模式。
import java.io.File
fun main() {
print("Source file path: ")
val src = File(readln().trim())
print("Destination file path: ")
val dst = File(readln().trim())
print("Verify mode (none/size/content): ")
val mode = readln().trim().lowercase()
val ok = when (mode) {
"size" -> copyWholeWithSizeCheck(src, dst)
"content" -> copyWholeWithContentCheck(src, dst)
else -> { dst.writeBytes(src.readBytes()); true }
}
println("OK=$ok") // OK=true (如果一切正常)
}
这里 when 被用作表达式来生成 ok。你已经在课程的前面部分学习过 when,所以这应该读起来很顺畅。
细节:为什么在 none 模式下我们仍然返回 true
我们 main 中的 none/else 分支执行了复制并说“看起来不错”。这是一个特意简化的选项:我们没有验证结果,所以我们“不知道”,但对于“直接复制”的场景,这正是用户想要的。
如果你想要一个更诚实的契约,你可以不返回 Boolean,而是例如返回一个状态字符串(“COPIED_WITHOUT_VERIFICATION”)。但那开始引导我们设计操作结果,而今天我们的目标是掌握 readBytes/writeBytes 并理解方法的局限性。
9. 使用 readBytes() / writeBytes() 时的常见错误
错误 #1:出于习惯将二进制文件作为文本读取。
在经历了多天的 readText() 和字符串处理后,你的手会自动伸向将所有内容读取为“字符串”,然后你会对那些“乱码字符”感到奇怪。预先问自己一个问题很重要:“这是别人用眼睛读的文本,还是字节(图像/存档/可执行文件)?”对于二进制数据,文本方法几乎总是错误的工具。
错误 #2:假设 src.length() == dst.length() 就能保证内容相同。
大小相同是个好消息,但不是证明。两个不同的文件可能具有相同的长度。如果你需要“逐字节完全相同”,则必须通过 contentEquals() 比较内容(并记住对于数组,不能依赖 ==)。
错误 #3:通过 == 比较 ByteArray 并得到奇怪的结果。
这是一个经典陷阱:“但在 Kotlin 中 == 是比较值的!”数组在 Kotlin 中是一个特例;有专门的函数用于内容比较。
错误 #4:盲目地一次性读取巨大的文件。
readBytes() 很方便,但它不是魔法:它会将整个文件加载到内存中。对于大文件,这会成为内存和性能风险。即使在这个阶段,习惯于通过 length() 检查大小并设置限制也是有用的,对于大文件,请使用流式方法(我们稍后会根据课程计划进行讲解)。
错误 #5:忘记了目标目录。
有时你会写到一个路径如 out/copy.bin,但 out 文件夹还不存在。writeBytes() 不会“凭空创建周围的环境”,它只是写入文件。所以要么提前创建目录,要么在写入之前至少执行 dst.parentFile?.mkdirs(),这样你就不会在平地上遇到错误。
GO TO FULL VERSION