0%

《lua 程序设计》读书笔记(4):输入输出 & 补充知识

由于 Lua 强调可移植性和嵌入型,所以 Lua 本身并没有提供太多与外部交互的机制。在真实的 Lua 程序中,从图形、数据库到网络访问等大多数 I/O 操作,要么由宿主程序实现,要么通过不包括在发行版中的外部库实现。Lua 本身只提供了 ISO C 语言标准库支持的功能,即基本的文件操作。

这篇文章介绍 Lua 中的 I/O 机制,同时补充一些 Lua 编程的基础知识。

输入输出

简单的 I/O 模型

对于文件操作,I/O 库提供了两种不同的模型。简单模型虚拟了一个当前输入流(current input stream)和一个当前输出流(current output stream),其中 I/O 操作是通过这些流实现的。I/O 库把当前输入流初始化为进程的标准输入(stdin),把当前输出流初始化为进程的标准输出(stdout)。

  • io.inputio.output 可以用于改变当前的输入输出流
  • io.write 可以读取任意的字符串(或者数字)并将其写入当前输出流。该函数可以接收多个参数。作为原则,应该在 用后即弃 的代码中使用函数 print,当需要完全控制输出时,则应该使用函数 io.write。另外 io.write 在将数值转换为字符串时遵循一般的转换规则:如果想要完全控制这种转换,应该使用 string.format
  • io.read 可以从当前输入流中读取字符串,其参数决定了要读取的数据。除了读取模式外,该函数还可以用一个数字 n 作为其参数,此时 read 会从输入流中读取 n 个字符。io.read(0) 是一个特例,它通常用来测试是否到达了文件尾,如果仍然有数据可供读取,它会返回一个空字符串,否则返回 nil。
    • "a":读取整个文件(从当前文件位置开始)
    • "l":读取下一行(丢弃换行符),如果达到文件尾时,该函数返回 nil。它也是函数 read 的默认参数
    • "L":读取下一行(保留换行符)
    • "n":读取一个数值
    • num:以字符串读取 num 个字符
    • 调用 read 时可以指定多个选项,函数会根据每个参数返回相应的结果
  • 要逐行遍历一个文件,使用 io.lines 迭代器会更简单
1
2
3
4
5
6
7
8
9
10
11
12
local lines = {}

io.input("1.txt")
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

完整 I/O 模型

简单 I/O 模型对简单的需求还算适用,但是对于诸如同时读写多个文件等更高级的文件操作来说就不够了。对于这些文件操作,需要用到完整 I/O 模型。

  • io.open 用来打开一个文件,该函数类似于 C 中的 fopen()。该函数第一个参数是待打开文件名,另一个参数则是一个模式字符串。其返回一个对应的文件流。当发生错误时,该函数会在返回 nil 的同时返回一条错误信息和系统错误码。检查错误的一种典型方法是使用函数 assert
  • 打开文件后,使用方法 readwrite 从流中读取/写入。它们与函数 read/write 类似,但需要使用冒号运算符将它们当作流对象的方法来调用
  • I/O 库提供了三个预定义的 C 语言流的句柄:io.stdin、io.stdout、io.stderr。
  • 函数 io.inputio.output 允许混用完整 I/O 模型和简单 I/O 模型。无参数调用 io.input() 可以获得当前输入流,调用 io.input(handle) 则可以设置当前输入流,类似的调用同样适用于函数 io.output。如下代码临时改变当前输入流
  • 函数 io.lines 返回一个可以从流中不断读取内容的迭代器。当不带参数调用时,io.lines 从当前输入流读取,当传入文件名时,则从对应的文件读取。也可以把 lines 作为句柄的一个方法。从 Lua5.2 开始,函数 io.lines 可以接收和函数 io.read 一样的参数
1
2
3
4
5
6
7
> local temp = io.input()
> io.input("1.txt")
file (0x55fc5bd21790)
> io.input():close()
true
> io.input(temp)
file (closed)

其他文件操作

  • io.tmpfile 返回一个操作临时文件的句柄,该句柄是以读/写模式打开的。当进程结束后,该临时文件会被自动移除
  • flush 将所有缓冲数据写入文件。可以通过 io.flush() 刷新当前输出流,也可以通过 f:flush() 刷新流 f
  • setvbuf 用于设置流的缓冲模式,其第一个参数是一个字符串:no 表示无缓冲,full 表示在缓冲区满时或者显式地刷新文件时才写入数据,line 表示输出一直被缓冲直到遇到换行符或者从一些特定文件(例如终端设备)中读取到数据。对于后两个选项,其支持第二个参数,用于指定缓冲区大小。通常 stderr 是不被缓冲的,而标准输出是行缓冲的
  • seek 用来获取和设置文件当前位置,其常用形式为 f:seek(whence, offset)。whence 的取值如下:
    • set:表示相对文件开头的偏移
    • cur:表示相对文件当前位置的偏移
    • end:表示相对于文件尾部的偏移
    • 不管 whence 的取值如何,该函数均以字节为单位,返回当前新位置在流中相对于文件开头的偏移
    • whence 默认值为 curoffset 默认值为 0,因此 file:seek() 会返回当前位置且不改变当前位置
  • os.rename 用于文件重命名,os.remove 用于移除(删除)文件。这两个函数处理的是真实文件而非流,所以它们位于 OS 库而非 IO 库中
  • 上述所有函数在遇到错误时,均会返回 nil 外加一条错误信息和一个错误码

其他系统调用

  • os.exit 用于终止程序的执行。该函数有两个可选参数:第一个参数表示返回状态,其值可以是一个数值(0 表示执行成功)或者一个布尔值(true 表示执行成功)。该函数的第二个参数也是可选的,当值为 true 时会关闭 Lua 状态并调用析构器释放所占用的内存(这种方式是非必须的,因为通常 OS 会在进程退出时释放其所占用的资源)
  • os.getenv 用于获取某个环境变量,输入参数是环境变量的名称,返回值为保存了该环境变量所对应值的字符串
  • os.execute 用于运行系统命令,等价于 C 中的函数 system。参数为待执行命令的字符串,返回值为命令运行结束后的状态
  • io.popenos.execute 一样,运行一条系统命令,但该函数还可以重定向命令的输入/输出,从而使得程序可以向命令中写入或从命令的输出中读取

如果要使用操作系统的其他扩展功能,最好选择是使用第三方库,比如用于基本目录操作和文件属性操作的 LuaFileSymstem,或者提供了 POSIX.1 标准支持的 luaposix 库。

补充知识

局部变量和代码块

Lua 语言中的变量默认情况下是全局变量,所有的局部变量在使用之前必须声明。与全局变量不同,局部变量的生效范围仅限于声明它的代码块。一个代码块是一个控制结构的主体,或者是一个函数的主体,或者是一个代码段(即变量被声明时所在的文件或字符串)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
x = 10
local i = 1

while i <= x do
local x = i * 2
print(x)
i = i + 1
end

if i > 20 then
local x
x = 20
print(x + 2)
else
print(x)
end

print(x)

需要注意,在交互模式下,每一行代码就是一个代码段(除非不是一条完整的命令)。解决该问题的一种方法就是显式地声明整个代码块,即将它放入一对 do-end 中。当需要更好的控制某些局部变量的生效范围时,do 程序块也同样有用:

1
2
3
4
>
> local i = 1
> print(i)
nil

尽可能使用局部变量是一种良好的编程风格,它有以下好处:

  • 可以避免由于不必要的命名造成全局变量的混乱
  • 可以避免同一程序中不同代码部分中的命名冲突
  • 访问局部变量比访问全局变量更快
  • 局部变量会随着其作用域结束而消失,从而使得 GC 能够将其释放

局部变量的声明可以包含初始值,其赋值规则与常见的多重赋值一样。如果一个声明中没有赋初值,则变量被初始化为 nil。Lua 中有一种常见用法:

1
local foo = foo

该代码声明了局部变量 foo,然后用全局变量 foo 对其赋予初值(局部变量 foo 只有在声明之后才能被访问)。该用法在需要提高对 foo 的访问速度时很有用。另外当其他代码段修改了全局变量 foo 的值,而本代码段又保留了 foo 的原始值,这个用法也很有用。

我们很少在不赋初值的情况下声明变量,在需要时才声明变量可以避免漏掉初始化这个变量。另外,通过缩小变量的作用域有助于提高代码的可读性。

控制结构

Lua 提供了一组精简且常用的控制结构。所有的控制结构语法上都有一个显式地终结符:end 用于终结 if、for、while 结构;until 用于终结 repeat 结构。控制结构的条件表达式的结果可以是任意值,Lua 将所有不是 false 和 nil 的值当做真。

if then else:if 语句先测试其条件,并根据条件是否满足执行对应的 then 部分或者 else 部分。else 部分是可选的。如果要编写嵌套的 if 语句,可以使用 elseif。由于 Lua 不支持 switch,因此 elseif 语句比较常见。

1
2
3
4
5
6
7
if op == "+" then
r = a + b
elseif op == "-" then
r = a - b
else
error("invalid operation")
end

当条件为真时,while 循环会重复执行器循环体:

1
2
3
4
5
local i = 1
while a[i] do
print(a[i])
i = i + 1
end

repeat-until 语句会重复执行其循环体直到条件为真时结束。由于条件测试在循环体之后执行,因此循环体至少执行一次:

1
2
3
4
local line
repeat
line = io.read()
until line ~= ""

在 Lua 中,循环体内声明的局部变量的作用域包括测试条件

for 语句有两种形式:数值型(numerical) for泛型(generic)for。数值型 for 的语法如下:

1
2
3
for var = exp1, exp2, exp3 do
something
end

在该循环中,var 的值从 exp1 变化到 exp2 之前的每次循环都会执行 something,并在每次循环结束后将步长 exp3 增加到 var 上。exp3 是可选的,如果不存在,默认为 1。如果不想设置上限,可以使用产量 math.huge

在循环开始前,三个表达式都会运行一次,其次,控制变量是被 for 语句自动声明的局部变量,其作用范围仅限于循环体内。如果需要在循环结束后使用控制变量的值,则必须将其保存到另一个变量中。最后不要改变控制变量的值,随意改变控制变量的值可能产生不可预知的结果。如果需要在循环结束前停止 for 循环,可以使用 break 语句。

泛型 for 遍历迭代函数返回的所有值。使用恰当的迭代器可以在保证代码可读性的情况下遍历几乎所有数据结构。泛型 for 可以使用多个变量,这些变量在每次循环时都会更新。当第一个变量变为 nil 时,循环终止。与数值型 for 一样,控制变量是循环体中的局部变量,也不应该在循环中改变其值。

break、continue 和 goto

可以使用 break 语句结束循环,该语句会中断包含它的内层循环。该语句不能在循环外使用。

return 语句用于返回函数执行结果。所有函数最后都有一个隐含 return。按照语法,return 语句只能是代码块中的最后一句。也就是说,它只能是代码块的最后一句,或者是 end、else 和 until 之前的最后一句。为了在代码块中间使用 return,必须显式地使用 do-end 代码块。

1
2
3
4
5
6
7
8
function foo()
do
return
end
local i = 1
end

foo()

goto 语句用于将当前程序跳转到相应的标签处继续执行。尽管一直以来备受争议,但是 goto 有强大的功能,只要足够细心,就可以利用它来提高代码质量。Lua 中的 goto 后面紧跟标签名,标签名可以是任意有效的标识符。标签前后紧跟两个冒号,形如 ::name::。在使用 goto 跳转时,Lua 设置了一些限制条件:

  • 标签遵循常见的可见性规则,因此不能直接调转到一个代码块中的标签,因为这些标签对外不可见
  • goto 不能跳转到函数外(上一条规则已经排除了跳转进一个函数的可能性)
  • 不能跳转到局部变量的作用域

continue 语句仅仅相当于一个跳转到位于循环体最后位置处标签的 goto 语句:

1
2
3
4
5
6
7
8
9
10
while some_condition do
if cond1 then
goto continue
end

local var = something
-- some code here

::continue::
end

有一点需要注意,局部变量的作用域终止于声明变量的代码中的最后一个有效语句处,而标签则被认为是无效语句。因此在上面的示例中,goto 语句并没有跳转到局部变量 var 的作用域内,因为 continue 标签出现在该代码块的最后一个有效语句之后。

goto 语句在编写状态机时也很有用,如下代码用于判断输入是否包含偶数个 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
::s1:: do
local c = io.read(1)
if c == '0' then
goto s2
elseif c == nil then
print 'ok'
return
else
goto s1
end
end

::s2:: do
local c = io.read(1)
if c == '0' then
goto s1
elseif c == nil then
print 'not ok'
return
else
goto s2
end
end

goto s1