Ruby 零基础教程

Ruby 文件读取

与文件进行交互是许多编程任务的基础环节。虽然 Ruby 程序通常在内存中操作数据,但将信息持久地存储到文件系统或从中检索信息的能力也很重要。这使得应用程序能够保存用户偏好、记录日志事件、加载配置、处理数据集或与其他程序交换数据。

在本章中,你将学习如何打开文件、以各种方式读取其内容以及安全地管理文件资源。我们将超越短暂的内存数据,开始与持久化存储的外部世界打交道。

1. 理解 Ruby 中的文件 I/O

输入/输出 (I/O) 指的是计算机系统与外部世界之间的通信。在 Ruby 中,当我们谈论文件 I/O 时,通常是指从磁盘上的文件读取 (Reading) 数据,或者将数据写入 (Writing) 磁盘。

1.1 为什么需要文件?

文件提供了一种持久化存储数据的方式。这意味着即使你的程序运行结束或计算机关机,数据依然存在。想象一下你正在构建:

  • 一个简单的笔记应用: 你需要将用户的笔记保存到文件中,以便他们以后可以检索。
  • 一个游戏: 游戏进度、高分榜或自定义关卡都可以保存在文件中。
  • 一个数据处理脚本: 它可能需要从一个大型 CSV 文件中读取数据,进行处理,然后将结果写入另一个文件。

Ruby 提供了一个内置的 File 类,它提供了一套丰富的与文件系统交互的方法。File 类继承自 IO 类,这意味着它获得了许多强大的输入/输出能力。

2. 安全地打开和关闭文件

在读取或写入文件之前,你必须先打开 (open) 它。打开文件会在你的 Ruby 程序和磁盘上的文件之间建立连接。一旦你用完了文件,同样重要的是要关闭 (close) 它,以释放系统资源并确保所有的更改(如果是写入操作)都被安全保存。

2.1 File.open 方法

打开文件的主要方法是 File.open。它至少接收两个参数:文件的路径以及你想要打开它的模式 (mode)

对于读取操作,最常见的模式是 "r" (Read)。

# 示例:尝试打开一个文件进行读取
# 这种方法需要手动关闭文件,通常【不推荐】使用。
# 我们很快会解释原因。
file_handle = File.open("my_data.txt", "r") # 以只读模式打开 'my_data.txt'

# ... 在这里执行对 file_handle 的操作 ...

file_handle.close # 必须手动关闭文件

2.2 为什么关闭文件如此重要?

当一个文件被打开时,操作系统会分配资源(如文件描述符)来管理这个连接。如果你打开了大量文件却忘记关闭它们,你可能会耗尽这些资源,导致程序报错甚至系统不稳定。对于写入操作(我们将在下一章介绍),未能正确关闭文件还可能意味着你的部分数据根本没有被真正写入磁盘。

2.3 使用代码块自动关闭文件

Ruby 的 File.open 方法有一个非常强大的特性:它可以接收一个代码块 (block)。当带有代码块调用 File.open 时,它会将打开的文件对象传递(yield)给代码块。

最关键的是,一旦代码块执行完毕,Ruby 会自动为你关闭文件,无论代码是正常结束还是中间发生了异常错误。这是处理文件操作最安全、最受推荐的方式。

# 提前创建一个示例文件用于演示
File.open("sample_read.txt", "w") do |file| # 'w' 模式用于写入
  file.puts "这是第一行。"
  file.puts "这是第二行。"
  file.puts "这是第三行。"
end

# 推荐做法:使用带代码块的 File.open 进行读取
File.open("sample_read.txt", "r") do |file|
  # 在这个代码块内部,'file' 是一个已打开的文件对象
  puts "文件已成功打开!"
  # 我们将在接下来的小节学习如何从 'file' 中读取数据
end
# 代码块结束,文件已经被安全且自动地关闭了。
puts "代码块结束后,文件已自动关闭。"

在这个例子中,file 对象仅在 do...end 代码块内部可用。一旦代码块结束,系统资源就被释放。这完美地防止了“忘记关闭文件”这种低级错误的发生。

3. 一次性读取全部文件内容

Ruby 提供了简单直接的方法将文件的全部内容一次性读入程序的内存中。这对于较小的文件非常方便,但对于非常大的文件则需要谨慎使用,因为它会消耗大量的 RAM(内存)。

3.1 File.read(path)

File.read 类方法是一种将整个文件内容读入单个字符串 (String) 的快捷方式。它会自动在底层为你打开、读取并关闭文件。

# 准备一个 poem.txt 文件
File.open("poem.txt", "w") do |f|
  f.puts "白日依山尽,"
  f.puts "黄河入海流。"
  f.puts "欲穷千里目,"
  f.puts "更上一层楼。"
end

# 一次性读取 "poem.txt" 的全部内容
file_content = File.read("poem.txt")

puts "--- 完整的文件内容 ---"
puts file_content

在这个例子中,poem.txt 的所有文本被加载到了 file_content 字符串变量中。每一行,包括其末尾的换行符 (\n),都成为了这个巨大字符串的一部分。

3.2 File.readlines(path)

如果你更希望将文件的每一行作为一个独立的元素来处理,File.readlines 是你的最佳选择。它会读取整个文件,并返回一个字符串数组 (Array of Strings),其中每个字符串对应文件中的一行(包含每行末尾的换行符)。

# 将文件内容读取为按行分割的数组
lines_array = File.readlines("poem.txt")

puts "\n--- 作为行数组读取的内容 ---"
lines_array.each_with_index do |line, index|
  # 使用 .chomp 移除每行末尾的换行符,让输出更干净
  puts "第 #{index + 1} 行: #{line.chomp}"
end

puts "\n--- 查看原始数组的内部结构 ---"
p lines_array # 可以看到数组元素末尾都带有 \n 换行符
内存警告 :File.readFile.readlines 都会将整个文件加载到内存中。对于配置文件、短日志或小数据集,这完全没问题。但是,对于非常大的文件(例如几 GB 大小),这种方法会迅速耗尽可用内存,导致程序变慢甚至崩溃。在处理大文件时,我们必须采用下一节介绍的“按行读取”策略。

4. 逐行读取文件(高效处理大文件)

对于大文件,不将其全部加载到内存中,而是逐行处理是至关重要的。Ruby 提供了能高效实现这一目标的方法,非常适合解析巨大的日志文件或处理海量数据集。

4.1 File.foreach(path)

File.foreach 方法是逐行处理大文件的理想选择。它打开文件,每次只读取一行,并将这一行传递给代码块。这意味着在任何给定时刻,内存中只有一行数据,因此它的内存效率极高。它同样会自动处理文件的打开和关闭。

# 创建一个大型日志文件用于演示
File.open("large_log.txt", "w") do |f|
  f.puts "INFO: 用户 'Alice' 登录成功"
  f.puts "DEBUG: 加载偏好设置"
  f.puts "WARN: 来自 IP 192.168.1.100 的 'Bob' 登录失败"
  f.puts "INFO: 报表 A 数据处理完毕"
  f.puts "ERROR: 数据库连接丢失!"
  f.puts "INFO: 用户 'Alice' 登出"
end

puts "--- 在 large_log.txt 中搜索异常 ---"

# 使用 File.foreach 在不加载整个文件的情况下处理每一行
File.foreach("large_log.txt") do |line|
  if line.include?("ERROR") 
    puts "发现错误: #{line.chomp}"
  elsif line.include?("WARN")
    puts "发现警告: #{line.chomp}"
  end
end

这个例子模拟了处理服务器日志文件。File.foreach 读取每一行,代码块检查它是否包含 "ERROR" 或 "WARN"。无论日志文件有几百万行,这种方法都不会撑爆你的内存。

4.2 IO#each_line (配合 File.open 使用)

当你使用带代码块的 File.open 打开文件时,传递给代码块的 file 对象(它是 IO 类的子类实例)拥有一个 each_line 方法。它的行为与 File.foreach 非常相似。区别在于,each_line 是在一个已经打开的文件对象上调用的,这让你在 File.open 代码块内拥有更多控制权。

puts "\n--- 使用 each_line 逐行处理 poem.txt ---"
File.open("poem.txt", "r") do |file|
  file.each_line do |line|
    # 示例:将每一行转换为拼音或大写(此处演示大写)
    puts "转换: #{line.chomp}" 
  end
end

无论是 File.foreach 还是 IO#each_line,都是逐行迭代文件内容的绝佳选择。

5. 读取特定的数据块

有时你需要更细粒度地控制从文件中读取多少数据。除了读取整行,你可能只想读取固定数量的字符(字节),甚至一次只读一个字符。

5.1 IO#gets (读取下一行)

在打开的文件对象上调用 gets 方法时,它会从文件中读取下一行(包含行尾的换行符)。如果到达文件末尾,它将返回 nil

puts "\n--- 使用 gets 逐行手动读取 ---"
File.open("poem.txt", "r") do |file|
  first_line = file.gets 
  puts "读取的第一行: #{first_line.chomp}"
  
  second_line = file.gets 
  puts "读取的第二行: #{second_line.chomp}"
  
  puts "使用循环读取剩余的行:"
  while (line = file.gets) # 当 file.gets 读到文件末尾返回 nil 时,循环结束
    puts "- #{line.chomp}"
  end
end

当你需要根据条件决定是否读取下一行,或者需要手动控制读取节奏时,gets 非常有用。

5.2 IO#read(length) (读取指定字节)

在打开的文件对象上调用 read(length) 会尝试从文件当前位置读取 length 个字节(对于普通文本文件,通常可以理解为字符)。如果省略 length,它将读取从当前位置直到文件末尾的所有剩余内容。

puts "\n--- 读取特定的字节块 ---"
File.open("poem.txt", "r") do |file|
  chunk1 = file.read(6) # 读取前 6 个字节 (注意:中文字符可能占 3 个字节)
  puts "前 6 个字节: '#{chunk1}'"
  
  # 读取剩余的内容
  rest_of_file = file.read
  puts "文件的剩余部分:\n#{rest_of_file}"
end

当处理二进制文件、固定格式的文件或需要严格控制内存占用的网络流时,这个方法非常强大。

6. 重要的文件状态校验

在尝试打开并读取文件之前,进行一些检查以确保文件确实存在,并且你的程序拥有读取权限,是一个非常良好的编程习惯。这有助于创建更健壮、能优雅处理错误的应用程序。

6.1 File.exist?(path)

这个类方法检查给定路径的文件或目录是否存在。存在返回 true,否则返回 false

puts "poem.txt 存在吗? #{File.exist?("poem.txt")}" 
puts "不存在的文件存在吗? #{File.exist?("non_existent.txt")}"

6.2 File.readable?(path)

这个类方法检查当前用户(或运行程序的权限)是否拥有该文件的读取权限

puts "poem.txt 可读吗? #{File.readable?("poem.txt")}"
# 在某些系统目录下的文件可能不可读
# puts File.readable?("/etc/shadow") # 通常为 false

将这些检查结合起来,可以让你的文件读取操作极其安全:

filename = "config.ini"

if File.exist?(filename)
  if File.readable?(filename)
    puts "\n'#{filename}' 存在且可读。正在读取内容..."
    # 执行读取操作
  else
    puts "\n错误:'#{filename}' 存在,但当前用户没有读取权限。"
  end
else
  puts "\n错误:找不到文件 '#{filename}'。"
end