字符串

字符串表示文本。Lua 中的字符串,可以包含单个的字母,或一整本书。在 Lua 中,处理有着 10 万或 100 万字符的字符串的程序,并不罕见。

Lua 中的字符串,是一些字节序列。Lua 内核,the Lua core,并不考虑这些字节如何编码文本。Lua 采用八位编码,Lua is eight-bit clean,其字符串可以包含任何数字编码的字节,包括嵌入的零。这意味着,我们可以将任何二进制数据,存储到字符串中。我们还可以任何表示形式(UTF-8、UTF-16 等),存储 Unicode 的字符串;不过,正如我们将要讨论的,有几个很好的理由,让我们尽可能使用 UTF-8。Lua 自带的标准字符串库,假定使用单字节字符,但他可以相当合理地处理 UTF-8 字符串。此外,从 5.3 版开始,Lua 自带了一个小型库,来帮助使用 UTF-8 编码。

Lua 中的字符串,是一些不可更改的值,immutable values。我们不能像在 C 语言中那样,更改字符串中的某个字符;相反,我们要创建一个新的字符串,并进行所需的修改,如下面的示例:

> a = "one string"
> b = string.gsub(a, "one", "another")
> a
one string
> b
another string

Lua 中的字符串,与所有其他 Lua 对象(表、函数等)一样,都属于自动内存管理对象。这意味着,我们不必担心字符串的内存分配和取消分配,Lua 会为我们处理。

我们可以使用长度运算符(用 # 表示),来获取字符串的长度:

> #a
10
> #b
14
>
> #"goodbye"
7

该运算符始终是以字节为单位,计算长度,而不同于某些编码中的字符。

我们可以使用连接运算符 ..(两个点),将两个字符串连接起来。如果操作数是数字,Lua 会将其转换为字符串:

> "Hello ".."World"
Hello World
> "Result is "..3
Result is 3

(有些语言会使用加号表示连接,但 3 + 53 .. 5 是不同的)。

请记住,Lua 中的字符串是不可变值。连接操作符总是会创建出一个新字符串,而不会对操作数进行任何修改:

> a = "Hello"
> a .. " World"
Hello World
> a
Hello

字面的字符串

Literal strings

我们可以用成对的单引号或双引号,对字面字符串进行定界,delimit literal strings by single or double matching quotes:

> a = "a line"
> b = 'another line'

他们是等价的;唯一的区别是,在一种引号内,我们可以使用不带转义的另一种引号。

> a = "It's a line"
> a
It's a line
> b = 'He said, "We can win"'
> b
He said, "We can win"

作为一种风格,大多数程序员,总是会为同类字符串,使用同一种引号,字符串的 “类别”,the "kinds" of strings,取决于程序。例如,处理 XML 的库,可能会为 XML 代码片段,保留单引号字符串,因为这些片段通常包含双引号。

Lua 中的字符串,可以包含以下的类似 C 语言的转义序列:

转义意义
\a铃声
\b退格
\f换页(form feed)
\n换行
\r回车(carriage return)
\t水平制表位(horizontal tab)
\v垂直制表位(vertical tab)
\\反斜杠(backslash)
\"双引号
\'单引号

下面的例子,说明了他们的用途:

> print("one line\nnext line\n\"in quotes\", 'in quotes'")
one line
next line
"in quotes", 'in quotes'
> print('a backslash inside quotes: \'\\\'')
a backslash inside quotes: '\'
> print("a simple way: '\\'")
a simple way: '\'

经由转义序列 \ddd\xhh,我们还可以通过其数值,指定出字面字符串中的字符,其中 ddd 是最多三个十进制位的序列,hh 是两个十六进制位的序列。举一个有点刻意人为的例子,"ALO/n123/""\x41LO\10/04923" 这两个字面值,在使用 ASCII 码的系统中具有相同的值:0x41(十进制 65),他是 A 的 ASCII 码,10 是换行的代码,49 是数字 1 的代码。(在这个例子中,我们必须将 49 写成三个数字,即 \049,因为它后面还有另一个数字;否则 Lua 会将转义字符读成 \492)。我们也可以将同样的字符串,写成 "x41\x4c\x4f\x0a\x31\x32\x33\x22", 用十六进制代码来表示每个字符。

> "ALO\n123\""
ALO
123"
> '\x41LO\10\04923"'
ALO
123"
>
> '\x41\x4c\x4f\x0a\x31\x33\x22'
ALO
13"

自 Lua 5.3 起,我们还可以使用转义序列 \u{h...h},来指定 UTF-8 字符;我们可以在括号内,写入任意数量的十六进制数字:

> "\u{3b1} \u{3b2} \u{3b3}"
α β γ

(以上示例假定使用 UTF-8 终端)。

长字符串

Long strings

我们也可以通过成对的双方括号,来给字面字符串定界,就像我们之前给长注释定界一样。这种括号形式的字面字符串,可以长达数行,并且不会解释转义序列。此外,如果字符串的第一个字符是换行符,其会忽略该字符串的第一个字符。在写下包含了大量代码的字符串时,这种形式尤其方便,例如下面的示例:

> page = [[
>> <html>
>> <head>
>>      <title>An HTML Page</title>
>> </head>
>> <body>
>>      <a href="https://www.lua.org">Lua</a>
>> </body>
>> </html>
>> ]]
> page
<html>
<head>
        <title>An HTML Page</title>
</head>
<body>
        <a href="https://www.lua.org">Lua</a>
</body>
</html>

有时,我们可能需要将一段包含 a = b[c[i]],这样内容的代码括起来(请注意这段代码中的 ]]),或者可能需要将一些已经注释掉的代码括起来。为了处理这种情况,我们可以在两个方括号之间,添加任意数量的等号,如 [===[。更改后,字面字符串就只会在下一个其间的等号个数相同的括号处结束(在我们的示例中为 ]===])。扫描程序,the scanner,会忽略等号个数不同的任何一对括号。通过选择适当的等号个数,我们可以括住任何的字面字符串,而无需对其进行任何修改。

> code = [===[
>> a = b[c[i]]
>> ]===]
> code
a = b[c[i]]

>

这一功能同样适用于注释。例如,如果我们用 --[=[ 开始一条长注释,他就会一直延伸到下一个 ]=]。通过这种方法,我们可以轻松地注释掉一段,包含已注释部分的代码。

长字符串是在代码中,包含字面文本的理想格式,但我们不应将其用于非文本的字面值。虽然 Lua 中的字面字符串,可以包含任意字节,但使用这一功能并不是一个好主意(例如,咱们的文本编辑器,就可能会出现问题);此外,像 "\r\n" 这样的行结束序列,在读取时可能会被规范化为 "\n"。相反,最好使用十进制,或十六进制数字转义序列,对任意二进制数据进行编码,如 "\x13\x01\xA1\xBB"。不过,这对于长字符串来说是个问题,因为他们会造成相当长的行。针对这种情况,从 5.2 版开始,Lua 提供了转义序列 \z:他可以跳过字符串中所有后续的空格字符,直到第一个非空格字符。下一个示例说明了其用法:

> data = "\x00\x01\x02\x03\x04\x05\x06\x07\z
>> \x08\x09\x0A\x0B\x0C\x0D\x0E\x0F"
> data


>

第一行末尾的转义字符 \z,会跳过后面的行尾和第二行的缩进,因此在生成的字符串中,字节 \x08 会直接跟在 \x07 后面。

类型强制转换

Coercions

Lua 提供了运行时的数字和字符串之间的自动转换。任何应用到字符串的数字运算,都会尝试将字符串转换为数字。Lua 不仅在算术运算符中使用这种强制转换,而且在其他需要使用数字的地方,也使用这种强制转换,例如 math.sin 的参数。

相反,每当 Lua 在需要字符串的地方,发现一个数字时,他就会将该数字,转换为字符串:

> print(10 .. 20)
1020

(当我们要在数字后面写连接运算符时,必须用空格隔开,否则 Lua 会认为,第一个点是小数点)。

许多人认为,在 Lua 的设计中,这些自动强制转换机制,并不是个好主意。一般来说,最好不要指望他们。他们在某些地方很有用,但会增加语言,以及用到类型强制转换程序的复杂性。

为了体现这种 “次等地位,second-class",Lua 5.3 并没有实现强制转换和整数的完全整合,而是采用了更简单、更快速的实现方式。算术运算的规则是,只有当两个操作数都是整数时,运算结果才是整数;字符串不是整数,因此对字符串的任何算术运算,都将作为浮点运算处理:

> "10" + 1          --> 11.0

注意:在 Lua 5.4.6 版本中,这一点上已有了不同行为:

> "10" + 1
11

要明确地将字符串转换为数字,我们可以使用函数 tonumber,如果字符串未表示适当的数字,函数 tonumber 将返回 nil。否则,他会按照 Lua 扫描器的相同规则,返回整数或浮点数:

> tonumber("  -3 ")
-3
> tonumber(" 10e4 ")
100000.0
> tonumber("10e")
nil
> tonumber("0x1.3p-4")
0.07421875

默认情况下,tonumber 采用十进制表示法,但我们可以指定 2 到 36 之间的任意基数,进行转换:

> tonumber("100101", 2)
37
> tonumber("fff", 16)
4095
> tonumber("-ZZ", 36)
-1295
> tonumber("987", 8)
nil

最后一行中,那个字符串并不代表所给定基数的正确数字,因此 tonumber 返回了 nil

要将数字转换为字符串,我们可以调用函数 tostring

> tostring(10) == "10"
true

这些转换总是有效的。但请记住,我们无法对格式施加控制(例如,结果字符串中的小数位数)。要想完全控制,我们应该使用 string.format,下一节我们就会看到他。

与算术运算符不同,秩(顺序,order)运算符,就从不强制转换参数。此外,2 < 15 显然为真,但 "2" < "15" 则为假(按字母顺序排列)。为了避免出现不一致的结果,当我们在顺序比较中,混合使用字符串和数字(如 2 < "15")时,Lua 会抛出错误。

字符串库

原始的 Lua 解释器,处理字符串的能力非常有限。程序可以创建字符串字面值、连接字符串、比较字符串和获取字符串长度。但是,程序不能提取子串,或检查其内容。在 Lua 中,操作字符串的全部功能,都来自字符串库 string

如前所述,字符串库假定了字符为单字节。对于好几种编码(如 ASCII 或 ISO-8859-1),这种等价性都是正确的,但在任何的 Unicode 编码中,其都会被打破。尽管如此,正如我们将要看到的,字符串库的一些部分,对 UTF-8 非常有用。

字符串库中的一些函数,非常简单:string.len(s) 返回字符串 s 的长度;相当于 #s。函数 string.rep(s, n) 返回字符串 s 重复 n 次的结果;我们可以用 string.rep("a", 2^20) 创建一个 1 MB 的字符串(比如,用于测试)。函数 string.reverse 可以反转字符串。调用 string.lower(s) 可以返回将大写字母,转换为小写字母后 s 的副本;字符串中的所有其他字符不变。函数 string.upper,则会将字符串转换为大写字母。

> string.rep("abc", 3)
abcabcabc
> string.reverse("civic,deed,did,gag,level,madam,noon,peep,refer,rotator")
rotatorreferpeepnoonmadamlevelgagdiddeedcivic
> string.reverse("A Long Line!")
!eniL gnoL A
> string.lower("A Long Line!")
a long line!
> string.upper("A Long Line!")
A LONG LINE!

注意:为何上面第二个示例中,逗号丢失了?

典型的用法是,在要比较两个字符串,而不考虑大小写时,我们可以这样写:

> a = "A long line!"
> b = "A Long String!"
> string.lower(a) < string.lower(b)
true

请记住,Lua 中的字符串,是不可变的。与 Lua 中其他函数一样,string.sub 不会改变字符串的值,而是返回一个新的字符串。如果我们想要修改变量的值,就必须为其赋值:

> a = "A long line!"
> a = string.sub(a, 2, -2)
> a
 long line

函数 string.charstring.byte,在字符与其内部的数字表示之间,进行转换。函数 string.char 取得零或多个的整数,将每个整数,转换为字符,然后返回一个连接了所有这些字符的字符串。调用 string.byte(s, i),会返回字符串 s 中第 i 个字符的内部数字表示;第二个参数是可选参数;调用 string.byte(s),会返回字符串 s 中,第一个(或单个)字符的内部数字表示:

> string.char(97)
a
> i = 99; print(string.char(i, i+1, i+2))
cde
> string.byte("abc")
97
> string.byte("abc", 2)
98
> string.byte("abc", -1)
99

注意:可以看出,Lua 中,字符串第一个字符的索引是 1,这不同于其他语言的字符串第一个字符索引为 0,这也是上一示例中,输出为 " long line" 的原因。

在最后一行中,我们使用负的索引,来访问字符串中最后一个字符。

string.byte(s, i, j) 这样的调用,会返回多个值,其中包含了索引 ij(含)之间,所有字符的数字表示:

> string.byte("This is a line.", 6, -2)
105     115     32      97      32      108     105     110     101

一种很好的习惯用法,是 {string.byte(s,1,-1)},他会创建出一个包含于 s 中,所有字符代码的列表(这种习惯用法,仅适用于长度略小于 1 MB 的字符串)。Lua 限制了栈的大小,Lua limits its stack size,继而限制了函数的最大返回值数目。默认的栈限制,是一百万个条目)。

函数 string.format,是格式化字符串,以及将数字转换为字符串的强大工具。他会返回第一个参数(即所谓的 格式字串,format string)的副本,字符串中的每个 指令,directive,都由其对应参数的格式化版本所替换。格式字符串中的指令规则,与 C 语言函数 printf 类似。指令由一个百分号,和一个字母组成,指明如何格式化参数:d 表示十进制整数,x 表示十六进制数,f 表示浮点数,s 表示字符串,以及一些其他指令。

> string.format("x = %d y = %d", 10, 20)
x = 10 y = 20
> string.format("x = %x", 200)
x = c8
> string.format("x = 0x%X", 200)
x = 0xC8
> string.format("x = %f", 200)
x = 200.000000
> tag, title = "h1", "a little"
> string.format("<%s>%s</%s>", tag, title, tag)
<h1>a little</h1>

在格式指令的百分号和字母之间,可以包含控制格式化细节的一些其他选项,例如,浮点数的小数位数:

> string.format("pi = %.4f", math.pi)
pi = 3.1416
> d = 5; m = 11; y = 1990
> string.format("%02d/%02d/%04d", d, m, y)
05/11/1990

在第一个示例中,%.4f 表示小数点后,有四位数的浮点数。在第二个示例中,%02d 表示有着由零填充,且至少有两位数的十进制数;而无零填充的 %2d 指令,则将使用空白作为填充。有关这些指令的完整描述,请参阅 C 语言函数 printf 的文档,因为 Lua 调用了标准 C 库,来完成这里的繁重工作。

注意:若具体数字的位数,超出了格式指令中指定的位数,则会原样输出。

> string.format("%2d", y)
1990

使用冒号操作符,咱们便可将 string 库中的所有函数,作为字符串的方法来调用。例如,我们可以将调用 string.sub(s, i, j) 改写为 s:sub(i,j);将 string.upper(s) 改写为 s:upper()。(我们将在第 21 章,面向对象编程,Object-Oriented Programming 中,详细讨论冒号操作符)。

字符串库还包括了几个,基于模式匹配,based on pattern matching,的函数。函数 string.find 会搜索给定字符串中的模式:

> string.find("hello world", "wor")
7       9
> string.find("hello world", "war")
nil

其会返回字符串中,模式的初始位置和最终位置,如果找不到模式,则返回 nil。函数 string.gsub(全局替换,Global SUBstitution),会用另一个字符串,替换字符串中出现的所有模式:

> string.gsub("hello world", 'l', ".")
he..o wor.d     3
> string.gsub("hello world", 'll', "..")
he..o world     1
> string.gsub("hello world", 'a', ".")
hello world     0

作为第二个结果,他还返回了替换次数。

在第 10 章 模式匹配,Pattern Matching 中,咱们还将进一步讨论,这些函数以及模式匹配的所有内容。

关于 Unicode

从 5.3 版开始,Lua 包含了一个支持对以 UTF-8 编码的 Unicode 字符串,进行操作的小型库(utf8)。而甚至在该库出现之前,Lua 就已为 UTF-8 字符串,提供了适度支持。

UTF-8 是万维网 Web 上,主要的 Unicode 编码。由于与 ASCII 兼容,UTF-8 也是 Lua 的理想编码。这种兼容性,足以确保好几种适用于 ASCII 字符串的字符串操作技巧,也适用于 UTF-8,而无需进行任何修改。

UTF-8 使用可变数量的字节,来表示每个 Unicode 字符。例如,UTF-8 会用一个字节 65,来表示 A;用两个字节 215-144,来表示希伯来文字符 Aleph,该字符在 Unicode 中的编码为 1488。UTF-8 会像 ASCII 码那样,表示 ASCII 码范围内的所有字符,即用小于 128 的单字节表示。他使用了字节序列,sequences of bytes,来表示所有其他字符,其中第一个字节的范围是 [194,244],而延续字节的范围是 [128,191]。更具体地说,两个字节序列的起始字节范围,是 [194,223];三个字节序列的起始字节范围,则是 [224,239];四个字节序列的起始字节范围,又是 [240,244]。这些范围都不会重叠。这一特性确保了任何字符的代码序列,都不会作为其他字符代码序列的一部分出现。特别是,小于 128 的字节,永远不会出现在多字节序列中;他总是表示,其对应的 ASCII 字符。

Lua 中的一些功能,对 UTF-8 字符串 “恰到好处,just work”。由于 Lua 是 8 位纯净字符串, 8-bit clean,因此他可以像对待其他字符串一样,读取、写入和存储 UTF-8 字符串。字面字符串可以包含 UTF-8 数据。(当然,咱们可能需要在支持 UTF-8 的编辑器中,将源代码编辑为 UTF-8 文件)。对于 UTF-8 字符串,连接操作可以正常工作。字符串排序运算符(小于、小于等于等),会按照 UTF-8 字符串在 Unicode 中的字符编码顺序,对其进行比较。

Lua 的操作系统库和 I/O 库,是底层系统的主要接口,因此他们对 UTF-8 字符串的支持,取决于底层系统。例如,在 Linux 上,我们可以对文件名使用 UTF-8,但 Windows 使用的却是 UTF-16。因此,要在 Windows 上处理 Unicode 文件名,我们需要额外的库,或修改标准 Lua 库。

现在咱们来看看,string 库中的函数,是如何处理 UTF-8 字符串的。函数 reverseupperlowerbytechar,对 UTF-8 字符串不起作用,因为他们都假定了某个字符,等同于一个字节。函数 string.formatstring.rep,在处理 UTF-8 字符串时没有问题,但格式化选项 "%c" 除外,因为他假定了一个字符等于一个字节。函数 string.lenstring.sub 可以正确处理 UTF-8 字符串,因为其索引指的是字节数(而不是字符数)。这正是我们所需要的。

现在我们来看看这个新 utf8 库。函数 utf8.len 会返回给定字符串中,UTF-8 字符(编码点,codepoints)的数量。此外,他还会对字符串加以验证:如果发现任何无效字节序列,则返回 nil,及第一个无效字节的位置:

> s = "这是一个测试"
> utf8.len(s)
6

(当然,要运行这些示例,我们需要一个能理解 UTF-8 的终端。)

函数 utf8.charutf8.codepoint,相当于 UTF-8 世界中的 string.char 和 string.byte

> utf8.char(27979, 35797)
测试
> utf8.codepoint("测试", 1, -1)
27979   35797
> utf8.char(0x6D4B)
测

请注意最后一行中的索引。utf8 库中的大多数函数,都使用字节索引。例如,调用 string.codepoint(s, i, j) 会将 ij 视为字符串 s 中的字节位置。如果打算使用字符索引,那么函数 utf8.offset,则会将字符位置转换为字节位置:

> s
这是一个测试
> utf8.offset(s, 3)
7
> utf8.codepoint(s, 1, -1)
36825   26159   19968   20010   27979   35797
> utf8.codepoint(s, utf8.offset(s, 3))
19968

注意:以上示例(运行于 Linux 平台,在 Windows 平台上,由于 CP936 编码的问题,Lua 的输出会始终是乱码),表明 Lua 5.3.3 中,utf8 库以及使用了基于字符的索引。

UTF-8 涉及到的两种编码:编码点,code point <--> 字节,bytes 编码

在本例中,咱们使用了 utf8.offset,来获区字符串中第 3 个字符的字节索引,然后将该索引提供给 codepoint

与字符串库一样,utf8.offset 的字符索引,可以是负数,在这种情况下,计数将从字符串的末尾开始:

> s
这是一个测试
> utf8.offset(s, -2)
13
> string.sub(s, utf8.offset(s, -2))
测试

utf8 库的最后一个函数,是 utf8.codes。他允许我们遍历 UTF-8 字符串中的字符:

> s
这是一个测试
> for i, c in utf8.codes(s) do print(i, c) end
1       36825
4       26159
7       19968
10      20010
13      27979
16      35797

此结构遍历了给定字符串中的所有字符,将其字节位置和数字代码,分配给两个局部变量。在咱们的示例中,循环体只打印这些变量的值。(我们将在第 18 章 迭代器与泛型 for中,详细讨论迭代器)。

遗憾的是,Lua 所能提供的并不多。Unicode 有太多的特殊性,too many peculiarities。实际上,几乎不可能从特定语言中,归纳出任何概念。甚至什么是字符的概念,也很模糊,因为 Unicode 编码的字符,和字素,graphemes,之间,并无一一对应的关系。例如,常见的词素 é,可以用一个编码点表示("\u{E9}"),也可以用两个编码点表示,即一个 e 后面加一个变音符号("e\u{301}")。其他一些看似基本的概念,比如什么是字母,在不同的语言中,也会发生变化。由于这种复杂性,完全支持 Unicode 需要一些庞大的表,huge tables,这与 Lua 的小尺寸不相容。因此,对于更复杂的问题,最好的办法,是使用外部库。

练习

练习 4.1:如何在 Lua 程序中以字符串形式嵌入以下 XML 片段?

<![CDATA[
Hello world
]]>

请给出至少两种方式。

练习 4.2:假设需要在 Lua 中,将任意字节的长序列,写成字面字符串。你会使用什么格式?请考虑可读性、最大行长及大小等问题。

练习 4.3:请编写一个函数,将一个字符串插入另一个字符串的指定位置:

> insert("hello world", 1, "start: ")       --> start: hello world
> insert("hello world", 7, "small ")        --> hello small world

练习 4.4:请针对 UTF-8 字符串,重做前面的练习:

> insert("这是一个练习", 7, "!")           --> 这是一个练习!

(请注意,现在的位置,是以编码点,codepoints,为单位计算的。)

练习 4.5:请编写一个从字符串中,删除某个片段的函数;片段应由其初始位置和长度给出:

> remove("hello world", 7, 4)               --> hello d

练习 4.6:请针对 UTF-8 字符串,重做前面的练习:

> remove("这是一个练习", 3, 1)              --> 这是个练习

(这里,初始位置和长度,都应以编码点为单位。)

练习 4.7:编写一个函数,检查给定字符串,是否为回文字符串,a palindrome:

> ispali("step on no pets")             --> true
> ispali("banana")                      --> false

练习 4.8:重做前面的练习,忽略空格和标点符号的不同。

练习 4.9:针对 UTF-8 字符串重做之前的练习。

Last change: 2023-11-02, commit: a3d2bc9