Lua 第7部分 输入输出
由于 Lua 语言强调可移植性和嵌入性 , 所以 Lua 语言本身并没有提供太多与外部交互的机制 。 在真实的 Lua 程序中,从图形、数据库到网络的访问等大多数 I/O 操作,要么由宿主程序实现,要么通过不包括在发行版中的外部库实现。 单就 Lua 语言而言,只提供了 ISO C语言标准支持的功能, 即基本的文件操作等。 在这一章中,我们将会学习标准库如何支持这些功能。
7.1 简单 I/O 模型
对于文件操作来说, I/O 库提供了两种不同的模型 。 简单模型虚拟了一个当前输入流和一个当前输出流,其 I/O 操作是通过这些流实现的 。 I/O 库把当前输入流初始化为进程的标准输入( C 语言中的 stdin ),将当前输出流初始化为进程的标准输出( C 语言中的 stdout ) 。 因此 ,当执行类似于 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 语言中编写过滤器( filter )的一种简单技巧就是将整个文件读取到一个字符串中 , 然后对字符串进行处理,最后输出结果为:
t = io.read("a") -- 读取整个文件
t = string.gsub(t, "bad", "good") -- 进行处理
io.write(t) -- 输出结果
举一个更加具体的例子,以下是一段将某个文件的内容使用 MIME 可打印字符引用编码 进行编码的代码。 这种编码方式将所有非 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 字符(从 128 到 255 ),并调用指定的函数完成替换。
调用 io.read (”l ”) 会返回当前输入流的下一行,不包括换行符在内 ; 调用 io.read (” L ”)与之类似,但会保留换行符(如果文件中存在)。 当到达文件末尾时 ,由于已经没有内容可以返回,该函数会返回 nil。 选项 ” l ” 是函数read 的默认参数。 我通常只在逐行处理数据的算法中使用该参数,其他情况则更倾向于使用选项 ” a ” 一次性地读取整个文件,或者像后续介绍的按块读取。
作为面向行的输入的一个简单例子,以下的程序会在将当前输入复制到当前输出中的同时对每行进行编号 :
for count = 1, math.huge dolocal line = io.read("L")if line == nil then break endio.write(string.format("%6d ", count), line)
end
不过,如果要逐行迭代一个文件,那么使用 io.lines 迭代器会更简单:
local count = 0
for line in io.lines() docount = count + 1 io.write(string.format("%6d ", count), line, "\n")
end
另一个面向行的输入的例子参见示例 7.1 ,其中给出了一个对文件中的行进行排序的完整程序。
示例 7.1 对文件进行排序的程序
local lines = {}-- 将所有行读取到表"lines"中
for line in io.lines() dolines[#lines + 1] = line
end-- 排序
table.sort(lines)-- 输出所有的行
for _, l in ipairs(lines) doio.write(l, "\n")
end
调用 io.read ("n") 会从当前输入流中读取一个数值,这也是函数 read 返回值为数值(整型或者浮点型,与 Lua 语法扫描器的规则一致) 而非字符串的唯一情况。 如果在跳过了空格后,函数 io.read 仍然不能从当前位置读取到数值(由于错误的格式问题或到了文件末尾),则返回 nil。
除了上述这些基本的读取模式外,在调用函数 read 时还可以用一个数字 n 作为其参数 :在这种情况下,函数 read 会从输入流中读取 n 个字符。 如果无法读取到任何字符(处于文件末尾)则返回 nil ;否则,则返回一个由流中最多 n 个字符组成的字符串 。 作为这种读取模式的示例,以下的代码展示了将文件从 stdin 复制到 stdout 的高效方法:
while true do local block = io.read(2^13) -- 块大小是8KBif not block then break endio.write(block)
end
io.read(0) 是一个特例,它常用于测试是否到达了文件末尾 。 如果仍然有数据可供读取,它会返回一个空字符串;否则,则返回 nil 。
调用函数 read 时可以指定多个选项,函数会根据每个参数返回相应的结果。 假设有一个每行由 3 个数字组成的文件:
如果想打印每一行的最大值,那么可以通过调用函数 read 来一次性地同时读取每行中的3个数字:
6.1 -3.23 15e12
4.3 234 1000001
... while true dolocal n1, n2, n3 = io.read("n", "n", "n")if not n1 then break endprint(math.max(n1,n2,n3))
end
7.2 完整 I/O 模型
简单 I/O 模型对简单的需求而言还算适用,但对于诸如同时读写多个文件等更高级的文件操作来说就不够了 。 对于这些文件操作,我们需要用到完整 I/O 模型 。
可以使用函数 io.open 来打开一个文件,该函数仿造了 C 语言中的函数 fopen 。 这个函数有两个参数,一个参数是待打开文件的文件名,另一个参数是一个模式 ( mode )字符串 。模式字符串包括表示只读的 r、 表示只写的 w (也可以用来删除文件中原有的内容)、表示追加的 a, 以及另外一个可选的表示打开二进制文件的 b 。 函数 io.open 返回对应文件的流。 当发生错误时,该函数会在返回 nil 的同时返回一条错误信息及一个系统相关的错误码 :
> print(io.open("non-existent-file", "r"))
nil non-existent-file: No such file or directory 2
>
> print(io.open("/etcpasswd", "w"))
nil /etcpasswd: Permission denied 13
检查错误的一种典型方法是使用函数 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") -- 打开一个新的当前输入流
-- 对新的输入流进行某些操作
io.input():close() -- 关闭当前流
io.input(temp) -- 恢复此前的当前输入流
注意, io.read(args) 实际上是 io.input (): read(args)的简写,即函数 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) doio.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 一样,该函数运行一条系统命令,但该函数还可以重定向命令的输入/输出,从而使得程序可以向命令中写入或从命令的输出中读取。 例如,下列代码使用当前目录中的所有内容构建了一个表:
-- 对于POSIX系统而言,使用'ls'而非'dir'
local f = io.popen("dir /B", "r")
local dir = {}
for entry in f:lines() dodir[#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 库。