外部世界

由于其在可移植性,与可嵌入性方面的强调,Lua 本身,并没有提供太多与外部世界通信的设施。真实 Lua 程序中的大多数 I/O,从图形到数据库及网络的访问,要么由主机应用程序完成,要么通过主发行版中未包含的一些外部库完成。 纯粹的 Lua,仅提供 ISO C 标准提供的功能,即基本文件操作,加上一些额外功能。在本章中,我们将了解标准库,如何涵盖这些功能。

简单 I/O 模型

I/O 库提供了两种不同的文件操作模型。其中简单模型假设了,当前输入流,current input stream,和 当前输出流,current out stream,且其 I/O 操作是对这两种流进行操作。该库将当前输入流,初始化为进程的标准输入 (stdin),将当前输出流,初始化为进程的标准输出 (stdout)。因此,当我们执行 io.read() 之类的操作时,我们会从标准输入中,读取一行。

我们可以使用 io.inputio.output 函数,更改这些当前流。像 io.input(filename) 这样的调用,会以读取模式,在文件上的打开流,并将其设置为当前输入流。从此时起,所有输入,都将来自该文件,直到再次调用 io.input。函数 io.output,则对输出执行类似的工作。如果出现错误,两个函数都会抛出错误。如果咱们想要直接处理错误,就应使用 完整 I/O 模型,the complete I/O model。

由于相较 readwrite 要简单一些,因此我们首先看他。函数 io.write 只是接受任意数量的字符串(或数字),并将他们写入当前输出流。因为我们可以使用多个参数来调用他,所以我们应该避免像 io.write(a..b..c); 这样的调用。调用 io.write(a, b, c),会以更少资源,达到相同的效果,因为他避免了连接运算。

通常,咱们应仅将 print,用于快速而肮脏的程序或调试,quick-and-dirty programs or debugging;当咱们需要完全控制输出时,请始终使用 io.write。与 print 不同,write 不会向输出,添加额外字符,例如制表符或换行符。此外,io.write 允许咱们重定向输出,而 print 则始终使用标准输出,the standard output。最后, print 会自动将 tostring,应用于其参数;这对于调试来说很方便,但这也可能隐藏一些微妙的错误,subtle bugs。

函数 io.write,会按照通常的转换规则,将数字转换为字符串;为了完全控制这种转换,我们应该使用 string.format

> io.write("sin(3) = ", math.sin(3), "\n")
sin(3) = 0.14112000805987
file (0x7f386f1505c0)
> io.write(string.format("sin(3) = %.4f\n", math.sin(3)))
sin(3) = 0.1411
file (0x7f386f1505c0)

函数 io.read,会从当前输入流读取字符串。他的参数,控制着读取的内容:注 1

参数读取内容
"a"读取整个文件。
"l"读取下一行(丢弃新行字符,dropping the newline)。
"L"读取下一行(保留新行字符,keeping the newline)。
"n"读取一个数字。
numnum 个字符,作为字符串读取。

注 1:在 Lua 5.2 及之前版本中,所有字符串选项前面,都应该有一个星号,an asterisk, *。 Lua 5.3 仍然接受星号,以实现兼容性。

io.read("a") 这个调用,会从当前位置开始,读取整个当前输入文件。如果我们位于文件末尾,或者文件为空,则该调用会返回一个空字符串。

因为 Lua 可以有效地处理长字符串,故以 Lua 编写过滤器的一种简单技巧,便是将整个文件读入字符串,处理该字符串,然后将字符串写入输出:

> io.input("data")
file (0x564a2d55ffe0)
> io.output("new-data")
file (0x564a2d5a1510)
> t = io.read("a")
> t = string.gsub(t, "the", "that")
> io.write(t)
file (0x564a2d5a1510)
> io.close()

作为更具体的示例,以下代码块,是使用 MIME 扩起来的可打印 编码,the MIME quoted-printable encoding,对文件内容进行编码的完整程序。这种编码将每个非 ASCII 字节,编码为 =xx,其中 xx 是字节的十六进制值。为了保持编码的一致性,他还必须对等号进行编码:

> io.input("data")
> t = io.read("a")
> t = string.gsub(t, "([\128-\255=])", function (c) return string.format("=%02X", string.byte(c)) end)
> io.write(t)

其中的函数 string.gsub,将匹配所有非 ASCII 字节(从 128255 的代码),加上等号,并调用给定函数来提供替换。 (我们将在第 10 章 ”模式匹配” 中详细讨论模式匹配。)

调用 io.read("l"),会返回当前输入流中的下一行,不带换行符;调用 io.read("L") 类似,但他会保留换行符(如文件中存在)。当我们到达文件末尾时,调用会返回 nil,因为没有下一行要返回。选项 “l”read 函数的默认选项。通常,仅在算法自然地逐行处理数据时,我(作者)才使用此选项;否则,我喜欢使用选项 “a” 立即读取整个文件,或者如我们稍后将看到的,分块读取。

作为基于行输入的运用简单示例,以下程序,会将其当前输入,复制到当前输出,并对每行进行编号:

> io.input("data")
file (0x5650b9a53fe0)
> 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
     1 Dozens were killed and many more injured in a blast at the Al-Maghazi refugee camp in the central Gaza Strip late Saturday night, according to hospital officials.
     2
     3 The explosion in the camp killed 52 people, said Mohammad al Hajj, the director of communications at the nearby Al-Aqsa Martyr’s hospital in Deir Al-Balah. He told CNN that the explosion was the result of an Israeli airstrike.
     4
     5 One resident of the camp told CNN: “We were sitting in our homes, suddenly we heard a very, very powerful sound of an explosion. It shook the whole area, all of it.”
     6
     7 The Israel Defense Forces (IDF) says it is looking into the circumstances around the explosion.
     8
     9 Dr. Khalil Al-Daqran, the head of nursing at the Al-Aqsa Martyr’s hospital told CNN he had seen at least 33 bodies from what he also claimed was an Israeli airstrike.

但是,io.lines 迭代器实现了使用更简单的代码,来逐行迭代整个文件:

local count = 0

for line in io.lines() do
    count = count + 1
    io.write(string.format("%6d ", count), line, "\n")
end

注意:原文的代码如下,若在 Lua 交互模式下,因为变量作用域的缘故,而报出了在 nil 上执行算术运算的错误。

> local count = 0
> for line in io.lines() do
>> count = count + 1
>> io.write(string.format("%6d ", count), line, "\n")
>> end
stdin:2: attempt to perform arithmetic on a nil value (global 'count')
stack traceback:
        stdin:2: in main chunk
        [C]: in ?

作为基于行输入的另一示例,下图 7.1 “对文件进行排序的程序” 给出了对文件行进行排序的一个完整程序。

图 7.1,一个对文件加以排序的程序

local lines = {}

-- 将文件中的行,读入到表 `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 返回数字(整数或浮点数,遵循 Lua 扫描器的相同规则),而不是字符串的唯一情况。如果在跳过空格后,io.read 在当前文件位置找不到数字(由于格式错误或文件结尾),则返回 nil

除了基本的读取模式之外,咱们还可以使用数字 n 作为参数,来调用 read:在这种情况下,他会尝试从输入流中,读取 n 个字符。如果无法读取任何字符(文件结尾),则调用返回 nil;否则,他会从流中返回最多包含 n 个字符的字符串。作为此读取模式的示例,以下程序是将文件从 stdin,复制到 stdout 的有效方法:

while true do
    local block = io.read(2^13)         -- 块大小为 8k
    if not block then break end
    io.write(block)
end

作为一种特殊情况,io.read(0) 用作文件结尾的测试:如果有更多内容要读取,则返回空字符串,否则返回 nil

我们可以使用多个选项,来调用 read;对于每个参数,该函数将返回相应的结果。假设我们有一个文件,每行包含三个数字:

6.0     -3.23   15e12
4.3     234     1000001
89      95      78
...

现在我们要打印出每行的最大值。我们可以通过一次 read 调用,读取每行的所有三个数字:

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

输出为:

15000000000000.0
1000001
95

完整 I/O 模型

简单的 I/O 模型,对于简单的事情来说很方便,但对于更高级的文件操作(例如同时读取或写入多个文件)来说,还不够。对于这些操作,我们需要完整模型。

要打开文件,咱们要使用模仿 C 函数 fopenio.open 函数。他以要打开的文件名,和 模式,mode 字符串作为参数。此模式字符串,可以包含用于读取的 r、用于写入的 w(这也会删除文件以前的任何内容),或用于追加的 a,以及用于打开二进制文件的可选项 b。函数 open 返回一个文件上的新流。如果发生错误,open 会返回 nil,加上错误消息,以及与系统相关的错误编号:

> print(io.open("data-num", "r"))
nil     data-num: No such file or directory     2
> print(io.open("data-number", "w"))
nil     data-number: Permission denied  13

一种检查错误的典型习惯用法,便是使用函数 assert

> f = assert(io.open("data", "r"))
> f
file (0x563c8dffcfe0)
>
> f = assert(io.open("data-number", "r"))
> f
file (0x563c8e03f270)
> f = assert(io.open("data-number", "w"))
stdin:1: data-number: Permission denied
stack traceback:
        [C]: in function 'assert'
        stdin:1: in main chunk
        [C]: in ?

如果 open 失败了,那么错误信息,就将作为 assert 的第二个参数,然后显示错误消息。

打开文件后,我们就可以使用 readwrite 方法,从其读取或写入到所产生的流。他们与函数 readwrite 类似,但我们使用冒号操作符,将他们作为流对象上的方法来调用。例如,要打开一个文件并全部读取,我们可以使用如下的代码片段:

local f = assert(io.open("data", "r"))

local t = f:read("a")
f:close()

(我们将在第 21 章 面向对象编程 中,详细讨论冒号运算符。)

I/O 库为三个预定义的 C 语言(文件)流提供了句柄,分别名为 io.stdinio.stdoutio.stderr。例如,我们可以直接向错误流,发送信息,代码如下:

io.stderr:write(message)

函数 io.inputio.output,允许咱们混合使用完整模型与简单模型。我们通过调用 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.lines,代替 io.read 从流中读取数据。正如我们在前面的示例中看到的,io.lines 提供了一个迭代器,可以重复从流中读取数据。在给定了某个文件名时,io.lines 将以读取模式,在文件上打开一个流,并在文件结束后关闭该流。如果调用时没有参数,io.lines 将从当前输入流中,读取数据。我们还可以将 lines 作为句柄上的方法使用,as a method over handles。此外,自 Lua 5.2 版起,io.lines 也接受与 io.read 相同的选项。例如,接下来的代码片段,会将当前输入,复制到当前输出,迭代 8 KB 的数据块:

for block in io.input():lines(2^13) do
    io.write(block)
end

其他文件操作

函数 io.tmpfile 会返回一个,以读写模式,read/write mode,打开的临时文件流。程序结束时,该文件将自动删除。

函数 flush 会执行所有待处理的写入文件操作。与函数 write 一样,我们可以函数 io.flush() 的形式,调用他来刷新当前输出流,或以方法 f:flush() 的形式,调用他来刷新流 f

setvbuf 方法,用于设置数据流的缓冲模式。他的第一个参数是个字符串: "no" 表示不缓冲;"full" 表示只有当缓冲区满,或我们显式刷新文件时,才写出流数据;"line" 表示在输出换行符,或有来自特殊文件(如终端设备)的输入前,输出会被缓冲。对于后两个选项,setvbuf 接受可选的第二个参数,即缓冲区大小,the buffer size。

在大多数系统中,标准错误流(io.stderr)是不缓冲的,而标准输出流(io.stdout)在行模式下,in line mode,是缓冲的。因此,在我们向标准输出,写入不完整的行(如进度指示器),就可能需要刷新流,才能看到输出。

seek 方法,可以获取及设置文件流的当前位置。他的一般形式是 f:seek(whence,offset),其中 whence 参数是个字符串,用于指定如何解释偏移量。其有效值为,"set",用于相对于文件开头的偏移量;"cur",用于相对于文件当前位置的偏移量;"end",用于相对于文件结尾的偏移量。而与 whence 的值无关,调用会返回数据流的新当前位置,从文件开头开始以字节为单位计算。

whence 的默认值是 "cur",偏移量的默认值是零。因此,调用 file:seek(),就会在不会改变当前流位置下,返回当前流位置;调用 file:seek("set") 会将位置,重置为文件开头(并返回零);调用 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 与错误信息,以及错误代码。

其他系统调用

函数 os.exit 会终止程序的执行。他的第一个可选参数,是程序的返回状态。其可以是一个数字(0 表示执行成功)或一个布尔值(true 表示执行成功)。当可选的第二个参数为 true 时,会关闭 Lua 状态,调用所有终结器,all finalizers,并释放该状态使用的所有内存。(通常这种终结,this finalization,是不必要的,因为大多数操作系统,都会在进程退出时,释放进程使用的所有资源。)

函数 os.getenv,会获取环境变量的值。他取变量的名称,并返回一个包含其值的字符串:

> print(os.getenv("HOME"))
C:\tools\msys64\home\Lenny.Peng

对于未定义的变量,该调用会返回 nil

运行系统命令

函数 os.execute 运行一条系统命令;他等同于 C 语言函数 system。他取一个包含命令的字符串,并返回命令结束的信息,information regarding how the command terminated。第一个结果是布尔值:true 表示程序无错误退出。第二个结果是一个字符串:如果程序正常结束,则返回 "exit";如果程序被信号中断,则返回 "signal"。第三个结果是返回状态,the return status(在程序正常终止时),或终止程序的信号编号,the number of the signal that terminated the program。举例来说,在 POSIX 和 Windows 中,我们都可以使用下面的函数,来创建新目录:

function createDir (dirname)
    os.execute("mkdir " .. dirname)
end

另一个相当有用的函数,是 io.popen注 2os.execute 类似,他运行一条系统命令,但同时也将命令的输出(或输入),连接到一个新的本地流,并返回该流,这样我们的脚本就可以,从命令中读取数据(或向命令写入数据)。例如,下面的脚本,会用当前目录中的条目,建立了一个表:

注 2:该函数并非在所有 Lua 安装中都可用,因为相应的功能并非 ISO C 的一部分。尽管不是 C 中的标准,但由于其通用性和在主要操作系统中的存在,我们将其包含在标准库中。

-- 对于 Windows,请使用 'dir /B' 代替 'ls -1'
local f = io.popen("ls -1", "r")
-- 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@example.com"

local cmd = string.format("mail -s '%s' '%s'", subject, address)
local f = io.popen(cmd, "w")

f:write([[
Nothing important to sys.
-- me
]])
f:close()

(此脚本仅适用于安装了相应软件包(bsd-mailx)的 POSIX 系统。)现在,io.popen 的第二个参数是 "w",表示我们打算写入该命令。

从以上两个示例可以看出,os.executeio.popen,都是功能强大的函数,但他们也是系统高度依赖的。

对于扩展的操作系统访问,咱们最好使用某个外部 Lua 库,诸如 LuaFileSystem(用于目录和文件属性的基本操作)或 luaposix(提供 POSIX.1 标准的大部分功能)。

练习

练习 7.1:请编写一个读取文本文件,并按字母顺序排序其中的行后,重写该文件。当不带参数调用时,他应该从标准输入读取,并写入标准输出。当使用文件名参数调用时,他应该从该文件读取,并写入标准输出。当使用两个文件名参数调用时,他应该从第一个文件读取,并写入第二个文件。

练习 7.2:更改上一程序,使其在用户提供其输出文件的某个既有文件名时,要求用户确认。

练习 7.3:比较以下列方式,将标准输入流复制到标准输出流的 Lua 程序性能:

  • 逐个字节,byte by byte;

  • 逐行,line by line;

  • 以 8KB 块方式,in chunks of 8 kB;

  • 一次性整个文件方式,the whole file at once。

对于最后一个选项,输入文件可以有多大?

练习 7.4:请编写一个程序,打印出文本文件的最后一行。当文件较大且可寻时,when the file is large and seekable,要尽量避免读取整个文件。

练习 7.5:将上一程序通用化,以便打印文本文件的最后 n 行。同样,当文件较大且可寻时,要尽量避免读取整个文件。

练习 7.6:请使用 os.executeio.popen,编写出创建目录、删除目录和收集目录中项目的函数。

练习 7.7:你能使用 os.execute,改变咱们 Lua 脚本的当前目录吗?为什么?

Last change: 2024-02-02, commit: 04e47ad