由于 Lua 语言强调可移植性和嵌入性,所以 Lua 语言本身并没有提供太多与外部交互的机制。在真实的 Lua 程序中,从图形、数据库到网络的访问等大多数 I/O 操作,要么由宿主程序实现,要么通过不包括在发行版中的外部库实现。单就 Lua 语言而言,只提供了 ISO C 语言标准支持的功能,即基本的文件操作等。在这一章中,我们将学习标准库如何支持这些功能。
所谓输入流就是指从文件流入程序,而输出流则是从程序流入文件。
7.1 简单模型
对于文件操作来说,I/O 库提供了两种不同的模型。简单模型虚拟了一个 当前输入流 和一个 当前输出流,其 I/O 操作时通过这些流实现的。 I/O 库把当前输入流初始化为进程的标准输入,将当前输出流初始化为进程的标准输出。因此,当执行类似于io.read()这样的语句时,就可以从标准输入中读取一行。
函数io.input和函数io.output可以用于改变当前的输入输出流。调用io.input(filename)会以只读模式打开指定文件,并将文件设置为当前输入流。之后,所有的输入都将来自该文件,除非再次调用io.input。对于输出而言,函数io.output的逻辑与之类似。如果出现错误,这两个函数都会抛出异常。如果想直接处理这些异常,则必须使用完整 I/O 模型。
由于函数 write 比 read 简单,我们先来看函数 write。函数io.write可以读取任意数量的字符串(或者数字)并将其写入当前输出流。由于调用该函数时可以使用多个参数,因此应该避免使用io.write(a .. b .. c),应该调用io.write(a, b, c),后者可以用更少的资源达到同样的效果,并且可以避免更多的连接动作。
作为原则,应该只在 “用后即弃” 的代码或者调试代码中使用函数print;当需要完全控制输出时,应该使用函数io.write。与函数print不同,函数io.write不会在最终的输出结果中添加诸如制表符或换行符这样的额外内容。此外,函数io.write允许对输出进行重定向,而函数print只能使用标准输出。最后,函数print可以自动为其参数调用tostring,这一点对于调试而言非常便利,但也容易导致一些诡异的 Bug。
函数io.write在数值转换为字符串时遵循一般的转换规则;如果想完全地控制这种转换,则应该使用函数string.format:
> io.write("sin(3) = ", math.sin(3), "\n")
sin(3) = 0.14112000805987
> io.write(string.format("sin(3) = %.4f\n", math.sin(3)))
sin(3) = 0.1411
>
函数io.read可以从当前输入流中读取字符串,其参数决定了要读取的数据:
| 参数 | 含义 |
|---|---|
| "a" | 读取整个文件 |
| "l" | 读取下一行(丢弃换行符) |
| "L" | 读取下一行(保留换行符) |
| "n" | 读取一个值 |
| num | 以字符串读取 num 个字符 |
调用io.read("a")可从当前位置开始读取当前输入文件的全部内容。如果当前位置处于文件的末尾或文件为空,那么该函数返回一个空字符串。
因为 Lua 语言可以高效地处理长字符串,所以在 Lua 语言中编写过滤器地一种简单技巧就是将整个文件读取到一个字符串中,然后对字符串进行处理,最后输出结果为:
t = io.read("a")
t = string.gsub(t, "bad", "good")
io.write(t)
举一个更具体地例子,以下是一段将某个文件的内容使用 MINE 可打印字符引用编码进行编码的代码。这种编码方式将所有非 ASCII 字符编码为 =xx,其中 xx 是这个字符的十六进制。为保证编码的一致性,等号也会被编码:
t = io.read("all")
t = string.gsub(t, "([\128-\255=])", function(c)
return string.format("=%02X" string.byte(c))
end)
io.write(t)
函数string.gsub会匹配所有的等号及非 ASCII 字符(从1218-255),并调用指定的函数完成替换。
调用io.read("l")会返回当前输入流的下一行,不包括换行符在内;调用io.read("L")与之类似,但会保留换行符(如果文件中存在)。当到达文件末尾时,由于已经没有内容可以返回,该函数会返回 nil。选项 "l" 是函数read的默认参数。我通常只在逐行处理数据的算法中使用该参数,其他情况更倾向于使用选项 "a" 一次性地读取整个文件,或者像后续介绍的按块读取。
作为面向行的输入的一个简单例子,以下的程序会在将当前输入复制到当前输出中的同时对每行进行编号:
for count = 1, math.huge do
local line = io.read("L")
if line == nil then break end
io.write(string.format("%6d ", count), line)
end
不过,如果要逐行迭代一个文件,那么使用io.lines迭代器会更简单:
local count = 0
for line in io.lines() do
count = count + 1
io.write(string.format("%6d ", count), line, "\n")
end
另一个面向行的输入的例子参见示例 7.1,其中给出了一个对文件中的行进行排序的完整程序。
示例 7.1 对文件进行排序的程序
local lines = {}
for line in io.lines() do
lines[#lines + 1] = line
end
table.sort(lines)
for _, l in ipairs(lines) do
io.write(l, "\n")
end
调用io.read("n")会从当前输入流中读取一个数值,这也是函数read返回值为数值而非字符串的唯一情况。如果在跳过了空格后,函数io.read仍然不能从当前位置读取到数值,则返回 nil。
除了上述这些基本的读取模式外,在调用函数read时,还可以用一个数字 n 作为其参数:在这种情况下,函数read会从输入流中读取 n 个字符。如果无法读取到任何字符则返回nil;否则,则返回一个由流中最多 n 个字符组成的字符串。作为这种读取模式的示例,以下的代码展示了将文件从 stdin 复制到 stdout 的高效方法:
while true do
local block = io.read(2^13) --块大小是8KB
if not block then break end
io.write(block)
end
io.read(0)是一个特例,它常用于测试是否到达了文件末尾。如果仍然由数据可供读取,它会返回一个空字符串;否则,返回nil。
调用函数read时可以指定多个选项,函数会根据每个参数返回相应的结果。假设有一个每行由 3 个数字组成的文件:
6.0 -3.23 15e12
4.3 234 1000001
...
如果想打印每一行的最大值,那么可以通过调用函数read来一次性地同时读取每行中的 3 个数字:
while true do
local n1, n2, n3 = io.read("n", "n", "n")
if not n1 then break end
print(math.max(n1, n2, n3))
end
7.2 完整 I/O 模型
简单 I/O 模型对简单的需求而言还算适用,但对于诸如同时读写多个文件等高级的文件操作来说就不够了。对于这些文件操作,我们需要用到完整 I/O 模型。
可以使用函数io.open 来打开一个文件,该函数仿造了 C 语言中的函数fopen。这个函数有两个参数,一个参数时待打开的文件名,另一个参数是模式字符串。模式字符串包括表示只读的 r、表示只写的 w、表示追加的 a,以及另外一个可选的表示打开二进制文件的 b。函数 io.open 返回对应文件的流。当发生错误时,该函数会在返回 nil 的同时返回一条错误信息和一个系统相关的错误码:
> print(io.open("non-existent-file", "r"))
nil non-existent-file: No such file or directory 2
>
检查错误的一种典型方法是使用函数 assert:
local f = assert(io.open(filename, mode))
如果函数io.open执行失败,错误信息会作为函数assert的第二个参数被传入,之后函数assert会将错误信息展示出来。
在打开文件后,可以使用方法read和write从流中读取和向流中写入。它们与函数read和write相似,但需要使用冒号运算符将它们当作流对象的方法来调用。例如,可以使用如下的代码打开一个文件并读取其中所有内容:
local f = assert(io.open(filename, "r"))
local t = f : read("a")
f : close()
I/O 库提供了三个预定义的 C 语言流的句柄:io.stdin、io.stdout 和 io.stderr。例如,可以使用如下的代码将信息直接写到标准错误流中:
io.stderr:write(message)
函数io.input和io.output允许混用完整 I/O 模型和简单 I/O 模型。调用无参数的io.input()可以获得当前输入流,调用io.input(handle)可以设置当前输入流(类似的调用同样适用于函数 io.output)。例如,如果想要临时改变当前输入流,可以像这样:
local temp = io.input()
io.input("newinput")
-- do something
io.input() : close()
io.input(temp)
注意,io.read(args)实际上是io.input() : read(arg)的简写,即函数read是用在当前输入流上的。同样,io.write(args)是io.output() : write(args) 的简写。
除了函数io.read外,还可以用函数io.lines从流中读取内容。正如之前的示例中展示的那样,函数io.lines返回一个可以从流中不断读取内容的迭代器。给函数io.lines提供一个文件名,它就会以只读的方式打开对应文件的输入流,并在到达文件末尾关闭该输入流。若调用时不带参数,函数io.lines就从当前输入流读取。我们也可以把函数lines当作句柄的一个方法。此外,从 Lua 5.2 开始,函数 io.lines 可以接收和函数 io.read 一样的参数。例如,下面的代码会以在 8KB 为块迭代,将当前输入流中的内容复制到当前输出流中:
for block in io.input() : lines(2^13) do
io.write(block)
end
7.3 其他文件操作
函数io.tmpfile返回一个操作临时文件的句柄,该句柄是以读/写模式打开的。当程序运行结束以后,该临时文件会被自动移除(删除)。
函数flush将所有缓冲数据写入文件。与函数write一样,我们也可以把它当作io.flush()使用,以刷新当前输出流;或者把它当作方法f : flush()使用,以刷新流 f。
函数setvbuf用于设置流的缓冲模式。该函数的第一个参数是一个字符串:"no" 表示无缓冲,"full" 表示在缓冲区满时或者显式地刷新文件时才写入数据,"Line" 表示输出一直被缓冲直到遇到换行符或从一些特定文件中读取到了数据。对于后两个选项,函数setvbuf支持可选地第二参数,用于指定缓冲区大小。
在大多数系统中,标准错误流(io.stderr)是不缓冲的,而标准输出流(io.stdout)按行缓冲。因此,当向标准输出中写入了不完整的行(比如进度条)时,可能需要刷新这个输出流才能看到输出结果。
函数seek用来获取和设置文件的当前位置,常常使用f : seek(whence, offset)的形式来调用,其中参数 whence 时一个指定如何使用偏移的字符串,当参数 whence 取值为 "set" 时,表示相对于文件开头的偏移;取值为 "cur" 时,表示相对于文件当前位置的偏移;取值为 "end" 时,表示相对于文件尾部的偏移。不管 whence 的取值是什么,该函数都会以字节为单位,返回当前新位置在流中相对于文件开头的偏移。
whence 的默认值是 "cur",offset 的默认值是 0。因此,调用函数file : seek()会返回当前的位置且不改变当前位置;调用函数file : seek("set")会将位置重置到文件开头并返回 0。调用函数file : seek("end")会将当前位置重置到文件结尾并返回文件的大小。下面的函数演示了如何在不修改当前位置的情况下获取文件大小:
function fsize(file)
local current = file : seek()
local size = file : seek("end")
file : seek("set", current)
return size
end
此外,函数os.rename用于文件重命名,函数os.remove用于移除文件。需要注意的是,由于这两个函数处理的是真实文件而非流,所以它们位于 os 库而非 io 库中。
上述所有的函数在遇到错误时,均会返回 nil 外加一条错误信息和一个错误码。
7.4 其他系统调用
函数os.exit用于终止程序的执行。该函数的第一个参数是可选的,表示该程序的返回状态,其值可以为一个数值(0 表示执行成功)或者一个布尔值(true 表示执行成功);该函数的第二个参数也是可选的,当值为 true 时会关闭 Lua 状态并调用所有析构器释放所占用的所有内存(这种终止方式通常是非必要的,因为大多数操作系统会在进程退出时释放其占用的所有资源)。
函数os.getenv用于获取某个环境变量,该函数的输入参数是环境变量的名称,返回值为保存了该环境变量对应值的字符串:
print(os.getenv("HOME")) --> /home/lua
对于未定义的环境变量,该函数返回 nil。
7.4.1 运行系统命令
函数os.execute用于运行系统命令,它等价于 C 语言中的函数 system。该函数的参数为表示待执行命令的字符串,返回值为命令结束后的状态。其中,第一个返回值是一个布尔类型,当为 true 时表示程序成功运行完成;第二个返回值是一个字符串,当为 "exit" 时表示程序正常运行结束,当为 "signal" 时表示因信号而中断;第三个返回值是返回状态(若该程序正常终结)或者终结该程序的信号代码。例如,在 POSIX 和 Windows 中都可以使用如下的函数创建新目录:
function createDir(dirname)
os.execute("mkdir ".. dirname)
end
另一个非常有用的函数是io.popen。同函数os.execute一样,该函数运行一条系统命令,但该函数还可以重定向命令的输入/输出,从而使得程序可以向命令中写入或从命令的输出中读取。例如,下列代码使用当前目录中的所有内容构建了一个表:
local f = io.popen("dir /B", "r")
local dir = {}
for entry in f : lines() do
dir[#dir + 1] = entry
end
其中,函数io.popen的第二个参数 "r" 表示从命令的执行结果中读取。由于该函数的默认行为就是这样,所以在上例中的参数实际是可选的。
下面的示例用于发送一封邮件:
local subject = "some news"
local address = "someone@somewhere.org"
local cmd = string.format("mail -s '%s' '%s' ", subject, address)
local f = io.popen(cmd, "w")
f : write([[
Nothing important to say.
-- me
]])
f : close()
注意,该脚本只能在安装了相应工具包的 POSIX 系统中运行。上例中函数io.popen的第二个参数是 "w",表示向该命令中写入。
正如我们在上面的两个例子中看到的一样,函数os.execute和io.popen都是功能非常强大的函数,但它们也同样是非常依赖于操作系统的。
如果要使用操作系统的其他扩展功能,最好的选择是使用第三方库,比如用于基本目录操作和文件属性操作的 LuaFileSystem,或者提供了 POSIX.1 标准支持的 luaposix 库。
7.5 练习
- 练习 7.1:请编写一个程序,该程序读取一个文本文件然后将每行的内容按照字母表顺序排序后重写该文件。如果在调用时不带参数,则从标准输入读取并向标准输出写入;如果在调用时传入一个文件名作为参数。则从该文件中读取并向标准输出写入;如果在调用时传入两个文件名作为参数,则从第一个文件读取并将结果写入到第二个文件中。
我不确定题目描述的意思是一行一个数据重排序,还是指每一行都有若干数据需要单独排序,此处我是按后者理解,如果难的都能做,简单的也一样能做。
---@param file1 string
---@param file2 string
function FileSort(file1, file2)
if(file1 ~= nil) then --如果第一个参数不为空
if(file2 ~= nil) then --如果第一第二个参数都不为空
local f1 = io.open(file1, "r") --以读方式打开文件1
local f2 = io.open(file2, "r") --先以写方式打开文件2 查看文件2是否存在
if f2 ~= nil then
io.write(file2 .. " exists, are you sure to overwrite it? y/n\n")
if io.read() ~= 'y' then
f1:close()
f2:close()
return
end
end
f2:close()
f2 = io.open(file2, "w")
while true do
local str = f1 : read("l") --读取一行数据作为字符串存储起来
local t = {}
local startPos = 1 --提取字串的参数初始化
local endPos = 1
if str == nil then --读取结束时退出
break
end
str = string.gsub(str, "%s", ",") --将空格替换为逗号,以便于进行字符串处理
while true do
local endPos = string.find(str, ",", startPos) --从起始位置开始找到下一个逗号的位置
if startPos > #str then --如果提取字串的初始位置已经超出字符串的长度,说明提取已经结束
break
end
if (endPos == nil) and (startPos <= #str) then --如果已经读取到最后一个数字或只有一个数字时,后面已经没有逗号,因此直接把结束位置设置为字符串的末尾
endPos = #str + 1
end
local num = tonumber(string.sub(str, startPos, endPos - 1)) --将提取出来的数据转成数值型
table.insert(t, num)
startPos = endPos + 1 --起始位置移动到上一个逗号的后面
end
table.sort(t)
for _, v in ipairs(t) do
f2:write(v, " ")
end
f2:write("\n")
end
f1:close()
f2:close() --将两个文件关闭
io.write("Data from " .. file1 .. " to " .. file2 .. "\n")
else
local f1 = io.open(file1, "r")
while true do
local str = f1 : read("l")
local t = {}
local startPos = 1
local endPos = 1
if str == nil then
break
end
str = string.gsub(str, "%s", ",")
while true do
local endPos = string.find(str, ",", startPos)
if startPos > #str then
break
end
if (endPos == nil) and (startPos <= #str) then
endPos = #str + 1
end
local num = tonumber(string.sub(str, startPos, endPos - 1))
table.insert(t, num)
startPos = endPos + 1
end
table.sort(t)
for _,v in ipairs(t) do
io.write(v, " ")
end
print("\n")
end
f1:close()
io.write("Data from " .. file1 .. " to cmd\n")
end
else
io.write("enter 'n' to exit!!!!!\n")
while true do
local str = io.read()
local t = {}
local startPos = 1
local endPos = 1
if str == 'n' then
break
end
str = string.gsub(str, "%s", ",")
while true do
local endPos = string.find(str, ",", startPos)
if startPos > #str then
break
end
if (endPos == nil) and (startPos <= #str) then
endPos = #str + 1
end
local num = tonumber(string.sub(str, startPos, endPos - 1))
table.insert(t, num)
startPos = endPos + 1
end
table.sort(t)
for _,v in ipairs(t) do
io.write(v, " ")
end
print("\n")
end
io.write("Data from cmd to cmd\n")
end
end
- 练习 7.2:请改写上面的程序,使得当指定的输出文件已经存在时,要求用户进行确认。
在上一题中已经写入
- 练习 7.3:对比使用下列几种不同的方式把标准输入流复制到标准输出流中的 Lua 程序的性能表现:
按字节
按行
按块(每个块大小 8KB)
一次性读取整个文件
对于最后一种情况,输入文件最大支持多大?
性能从高到低:按块 --> 一次性读取 --> 按行 --> 按字节
输入文件最大支持231 - 1
- 练习 7.4:请编写一个程序,该程序输出一个文本文件的最后一行。当文件较大且可以使用 seek 时,请尝试避免读取整个文件。
基本的思路应该是先把位置置为末尾,然后依次往前找,直到找到一个换行符为止,再输出这一行。但是要考虑到一些临界条件,比如说文件内容只有一行或者为空,这样就需要记录下起始位置,如果最后找到的位置是文件的开头,那么就直接输出一行。
---@param file string
function PrintLastLine(file)
local f = io.open(file, "r")
local head = f : seek("set") --文件的起始位置
local startPos = f : seek("end") --文件的末尾位置,从此处开始往前寻找
local check = "" --用于确认startPos处的字符是否为换行符
while true do
check = f : read(1)
if startPos == head then
break
end
if check == "\n" then
startPos = startPos + 1 --检测到换行符需要把指针置到下一个字符
break
end
startPos = startPos - 1 --调用read()时会改变指针的位置,所以获取位置会出现一些奇怪的问题,这里我手动调整位置
f : seek("set", startPos)
end
f:seek("set", startPos)
print(f:read("l"))
end
- 练习 7.5:请将上面的程序修改得更加通用,使其可以输出一个文本文件的最后 n 行。同时,当文件较大且可以使用 seek 时,请尝试避免读取整个文件。
初步的想法是拿 n 当计数器,每检测到一个 '\n' 就减少一次,就可以得到最后 n 行的数据。
---@param file string
---@param n number
function PrintSomeLines(file, n)
local count = n
local f = io.open(file, "r")
local head = f : seek("set")
local startPos = f : seek("end")
local check = ""
while true do
check = f : read(1)
if startPos == head then
if count > 1 then
print("文本中只有 " .. (n - count + 1) .. " 行数据")
return
end
break
end
if check == "\n" then
if count < 0 then
startPos = startPos + 1
break
end
count = count - 1
end
startPos = startPos - 1
f : seek("set", startPos)
end
f:seek("set", startPos)
print(f:read("a"))
end
- 练习 7.6:使用函数
os.execute和io.popen,分别编写用于创建目录、删除目录和输出目录内容的函数。
function CreateDir(dirname)
os.execute("mkdir ".. dirname)
end
function DelDir(dirname)
os.execute("rmdir /s/q " .. dirname)
end
function PrintDir(filename)
local f = io.popen("dir " .. filename, "r")
print(f:read("a"))
end
- 练习 7.7:你能否使用函数
os.execute来改变 Lua 脚本的当前目录?为什么?
可以,当脚本被执行时,代码已经被加载进 Lua 虚拟机的栈中,此后就不再需要源文件关联,因此源文件被移走并不会影响后续代码的执行。在这个前提下,Lua 代码甚至可以在执行过程中动态地改变自己的行为,例如从移动自己变成复制自己,只需要对栈中的内容进行相关的操作就可以。
后续如果能看到更底层的代码再做更详细的解释。