4 字符串

字符串用于表示文本。Lua 语言中的字符串是一串字节组成的序列,Lua 核心并不关心这些字节究竟以何种方式编码文本。在 Lua 语言中,字符使用 8 个比特位来存储。Lua 语言中的字符串可以存储包括空字符在内的所有数值编码,这意味着我们可以在字符串中存储任意的二进制数据。同样,我们可以使用任意一种编码方法(UTF-8、UTF-16 等)来存储 Unicode 字符串,最好在一切可能的情况下优先使用 UTF-8 编码。Lua 的字符串标准库默认处理 8 个比特位的字符,但是也同样可以优雅地处理 UTF-8 字符串。此外,从 Lua 5.3 开始还提供了一个帮助使用 UTF-8 编码的函数库。
Lua 语言中的字符串是不可变值。我们不能像在 C 语言中那样直接改变某个字符串中的某个字符,但是我们可以通过创建一个新的字符串的方式来达到修改的目的,例如:

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

像 Lua 语言中的其他对象(表、函数等)一样,Lua 语言中的字符串也是自动内存管理的对象之一。这意味着 Lua 语言会负责字符串的分配和释放,开发人员无需关注。
可以使用长度操作符#获取字符串长度:

> a = "hello"
> print(#a)
5
> print(#"good bye")
8
>

该操作符返回字符串占用的字节数,在某些编码中,这个值可能与字符串中字符的个数不同。
我们可以使用连接操作符..来进行字符串连接。如果操作数中存在数值,那么 Lua 语言会先把数值转换成字符串:

> print("Hello " .. "World")
Hello World
> print("result is " .. 3)
result is 3
>

在某些语言中,字符串连接使用的是加号,实际上 3 + 5 和 3 .. 5 是不同的。
应该注意,在 Lua 语言中,字符串是不可变量。字符串连接总是创建一个新的字符串,而不会改变原来作为操作数的字符串:

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

4.1 字符串常量

我们可以使用一对双引号或单引号来声明字符串常量:

a = "one line"
b = 'another line'

使用双引号和单引号声明字符串是等价的。它们两者唯一的区别在于,使用双引号声明的字符串中出现单引号时,单引号不需要转义,反过来依然成立。
从代码风格上看,大多数程序员会使用相同的方式来声明“同一类”字符串,要么都用双引号,要么都用单引号,根据实际的用法决定。比如说,由于 XML 文本中一般会有双引号,所以一个操作 XML 的库可能就会使用单引号来声明 XML 片段。
Lua 语言中的字符串支持下列 C 语言风格的转义字符

转移字符 含义
\a 响铃 (bell)
\b 退格(back space)
\f 换页(form feed)
\n 换行(newline)
\r 回车(carriage return)
\t 水平制表符(horizontal tab)
\v 垂直制表符(vertical tab)
\ 反斜杠(backslash)
" 双引号(double quote)
' 单引号(single quote)

示例:

> 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是由最多 3 个十进制数字组成的序列,hh是由两个且必须是两个十六进制数字组成的序列。举一个稍微有点刻意的例子,在一个使用 ASCII 编码的系统中,“AL0\n123""和'\x41L0\10\04923"'实际上是一样的:0x41在 ASCII 编码中对应 A,10对应换行符,49对应数字 1 (在这个例子中,由于转义序列之后紧跟了其他的数字,所以 49 必须写成 \049,即用0来补足三位数字,否则,Lua 语言会将其错误地解析为\492)。我们还可以把上述字符串写成'\x41\x4c\x4f\x0a\x31\x32\x33\x22',即使用十六进制来表示字符串中的每一个字符。
从 Lua 5.3 开始,也可以使用转义序列\u{h...h}来声明 UTF-8 字符,花括号中可以支持任意有效的十六进制:

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

4.2 长字符串/多行字符串

像长注释/多行注释一样,可以使用一对双方括号来声明长字符串/多行字符串常量。被方括号括起来的内容可以包括很多行,并且内容中的转义序列不会被转义。此外,如果多行字符串中的第一个字符是换行符,那么这个换行符会被忽略。多行字符串在声明包含大段代码的字符串时非常方便,例如:

> page = [[
>>     
>>     
>>         An HTML Page
>>     
>>     
>>         Lua
>>     
>>     
>> ]]
>
> io.write(page)
    
    
        An HTML Page
    
    
        Lua
    
    
>

有时字符串中可能出现类似 a = b[c[i]] 这样的内容(注意最后的]]),或者,字符串中可能有被注释掉的代码。为了应对这种状况,可以在两个左方括号之间加上任意数量的等号,例如:[===[。这样,字符串只有在遇到了包含相同数量等号的两个右方括号时才会结束(即]===])。Lua 语言的语法扫描器会忽略所含等号数量不同的方括号。通过选择恰当数量的等号,就可以在无须修改原字符串的情况下声明任意的字符串常量了。
对于注释而言,这种机制同样有效。例如我们可以使用--[==[和--]==]来进行长注释,从而降低了对内部已经包含注释的代码进行注释的难度。
当代码中需要使用常量文本时,使用长字符串是一种理想的选择。但是,对于非文本的常量我们不应一样的该滥用长字符串。虽然 Lua 语言中的字符串常量可以包含任意字节,但是滥用这个特性并不明智(例如,可能导致某些文本编辑器出现异常)。同时,像 "\r\n" 一样的 EOF 序列在被读取的时候可能会被归一化成 "\n" 。作为替代方案,最好就是把这些可能引起歧义的二进制数据用十进制数值或十六进制的数值转义序列进行表示,例如 "\x13\x01\xA1\xBB"。不过,由于这种转义表示形成的字符串往往很长,所以对于长字符串来说仍可能是个问题。针对这种情况,从 Lua 5.2 开始引入了转义序列 \z,该转义符的使用方法:

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

第一行最后的 \z 会跳过其后的EOF和第二行的制表符,因此在最终得到的字符串中,\x08实际上是紧跟着\x07的,这里补充一个效果:

> data = "1234567\z
          89101112131415"
> print(data)
123456789101112131415
>

4.3 强制类型转换

Lua 语言在运行时提供了数值与字符串之间的自动转换(conversion)。针对字符串的所有算术操作都会尝试将字符串转换为数值。Lua 语言不仅仅在算术操作时进行这种强制类型转换(coercion),还会在任何需要数值的情况下进行,例如函数math.sin()的参数。
相反,当 Lua 语言发现在需要字符串的地方出现了数值时,它就会把数值转换为字符串:

print(10 .. 20) --> 1020

当在数值后紧接着使用字符串连接时,必须使用空格将它们分开,否则 Lua 语言会把第一个点当成小数点。
很多人认为自动强制类型转换算不上是 Lua 语言中的一项好设计。作为原则之一,建议最好不要完全寄希望于自动强制类型转换。虽然在某些场景下这种机制很便利,但同时也给语言和使用这种机制的程序带来了复杂性。
作为这种“二类状态”的表现之一,Lua 5.3 没有强制类型转换与整型的集成,而是采用了另一种更简单和快速的实现方式:算术运算规则就是只有在两个操作数都是整型值时结果才是整型。因此,由于字符串不是整型值,所以任何有字符串参与的算术运算都会被当作浮点处理:

"10" + 1 --> 11.0

如果显示地将一个字符串转换成数值那么可以使用函数tonumber()。当这个字符串的内容不能表示为有效数字时该函数返回 nil;否则,该函数就按照 Lua 语法扫描器的规则返回对应的整型值或浮点类型值:

print(tonumber(" -3 "))
print(tonumber(" 10e4 "))
print(tonumber(" 10e "))
print(tonumber(" 0x1.3p-4 "))

-- 结果
-3
100000.0
nil
0.07421875

默认情况下,函数tonumber()使用的是十进制,但是也可以指明使用二进制到三十六进制之间的任意进制:

print(tonumber(" 100101 ", 2))
print(tonumber(" fff ", 16))
print(tonumber(" -ZZ ", 36))
print(tonumber(" 987 ", 8))

--结果
37
4095
-1295
nil

在最后一行中,对于指定的进制而言,传入的字符串是一个无效值,因此函数tonumber()返回 nil。
调用函数tostring()可以将数值转换成字符串:

> print(tostring(10) == "10")
true
>

上述这种转换总是有效,但我们需要记住,使用这种转换时并不能控制输出字符串的格式(例如,结果中十进制数字的个数)。我们会在下一节中看到,可以通过函数string.format()来全面控制输出字符串格式。
与算术操作不同,比较操作符不会对操作数进行强制类型转换。请注意,“0” 和 0 是不同的。此外,2 < 15 明显为真,但 "2" < "15" 却为假(字母顺序)。为了避免出现不一致的结果,当比较操作符中混用了字符串和数值时,Lua 语言会抛出异常。


4.4 字符串标准库

Lua 语言解释器本身处理字符串的能力是十分有限的。一个程序能够创建字符串、连接字符串、比较字符串和获取字符串长度,但是它并不能提取字符串的字串或检视字符串的内容。Lua 语言处理字符串的完整能力来自其字符串标准库。
正如之前提到的,字符串标准库默认处理的是 8 bit 字符。这对于某些编码方式(例如 ASCII 或 ISO-8859-1)适用,但对于所有的 Unicode 编码来说都不适用。不过尽管如此,我们接下来会看到,字符串标准库中的某些功能对 UTF-8 编码来说还是非常有用的,
字符串标准库中的一些函数非常简单:函数string.len(s)返回字符串 s 长度,等价于 #s。函数string.rep(s, n)与之相反,该函数会将小写字母转换成大写字母。

> = string.rep("abc",3)
abcabcabc
> = string.reverse("A Long Line!")
!eniL gnoL A
> = string.lower("A Long Line!")
a long line!
> = string.upper("A Long Line!")
A LONG LINE!
>

作为一种典型的应用,我们可以使用如下代码在忽略大小写差异的原则下比较两个字符串:

string.lower(a) < string.lower(b)

函数string.sub(s, i, j)从字符串 s 中提取第 i 个到第 j 个字符(包括第 i 个和第 j 个字符,字符串的第一个字符索引为 1)。该函数也支持负数索引,负数索引从字符串的结尾开始计数:索引 -1 代表字符串的最后一个字符,索引 -2 代表倒数第二个字符,以此类推。这样,对字符串 s 调用函数 string.sub(s, 1, j)得到的是字符串 s 中长度为 j 的前缀,调用string.sub(s, j, -1)得到的是字符串 s 从第 j 个字符开始的后缀, 调用string.sub(s, 2, -2)返回的是去掉字符串 s 中第一个和最后一个字符后的结果:

> s = "[in brackets]"
> = string.sub(s, 2, -2)
in brackets
> = string.sub(s, 1, 1)
[
> = string.sub(s, -1, -1)
]
>

请注意,Lua 语言中的字符串是不可变的。和 Lua 语言中的所有其他函数一样,函数string.sub()并不会改变原有字符串的值,它只会返回一个新字符串。一种常见的误解是以为string.sub(s, 2, -2)返回的是修改后的 s。如果需要修改原字符串,那么必须把新的值赋给它:

s = string.sub(s, 2, -2)

函数string.char()string.byte()用于转换字符以及其内部数值表示。函数string.char()接收零个或多个整数作为参数,然后将每个整数转换成对应的字符,最后返回这些字符连接而成的字符串。函数string.byte(s, i)返回字符串 s 中第 i 个字符的内部数值表示,该函数的第二个参数是可选的。调用string.byte(s)返回字符串 s 中的第一个字符(如果字符串只由一个字符组成,那么久返回这个字符)的内部数值表示。在下例中,假定字符是用 ASCII 表示的:

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

在最后一行中,使用了负数索引来访问字符串中的最后一个字符。
调用string.byte(s, i, j)返回索引 i 到 j 之间(包括 i 和 j)的所有字符的数值表示:

> print(string.byte("abc", 1, 2))
97      98
>

一种常见的写法是{string.byte(s, 1, -1)},该表达式会创建一个由字符串 s 中的所有字符代码组成的表(由于 Lua 语言限制了栈大小,所以也限制了一个函数的返回值的最大个数,默认为一百万个。因此,这个技巧不能用于大小超过 1MB 的字符串)。
函数string.format()是用于进行字符串格式化和将数值输出为字符串的强大工具,该函数会返回第一个参数(也就是所谓的格式化字符串)的副本,其中的每一个指示符都会被替换为使用对应格式进行格式化后的对应参数。格式化字符串中的指示符与 C 语言中函数printf()的规则类似,一个指示符由一个百分号和一个代表格式化方式的字母组成。具体的参数和含义可以参考第三章数值 附1。

> string.format("x = %d y = %d", 10, 20)
> = 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 title"
> = string.format("<%s>%s",tag, title, tag)

a title

>

在百分号和字母之间可以包含用于控制格式细节的其他选项。例如,可以指定一个浮点数中小数点的位数:

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

在上例中,%.4f 表示小数点后保留 4 位小数;%02d 表示一个十进制数至少由两个数字组成,不足两个数字的用 0 补齐,而 %2d 则表示用空格来补齐。关于这些指示符的完整描述可以参阅 C 语言 printf()函数相关文档,因为 Lua 语言是通过调用 C 语言标准库来完成实际工作的。
可以使用冒号操作符像调用字符串的一个方法那样调用字符串标准库中的所有函数。例如,string.sub(s, i, j)可以重写为 s:sub(i, j)string.upper(s)可以重写为 s:upper()
字符串标准库还包括了几个基于模式匹配的函数。函数string.find()用于在指定的字符串中进行模式搜索:

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

如果该函数在指定的字符串中找到了匹配的模式,则返回模式的开始和结束位置,否则返回 nil。函数string.gsub()则把所有匹配的模式用另一个字符串替换:

> = 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 章中继续学习上面提到的所有函数和关于模式匹配的所有知识。


4.5 Unicode 编码

从 Lua 5.3 开始,Lua 语言引入了一个用于操作 UTF-8 编码的 Unicode 字符串的标准库。当然,在引入这个标准库之前,Lua 语言也提供了对 UTF-8 字符串额合理支持。
UTF-8 是 Web 环境中用于 Unicode 的主要编码之一。由于 UTF-8 编码与 ASCII 编码部分兼容,所以 UTF-8 对于 Lua 语言来说也是一种理想的编码方式。这种兼容性保证了用于 ASCII 字符串的一些字符串操作技巧无须修改就可以用于 UTF-8 字符串。
UTF-8 使用变长的多个字节来编码一个 Unicode 字符。例如,UTF-8 编码使用一个字节的 65 来代表 A,使用两个字节的 215-144 代表希伯来语字符 Aleph(其在 Unicode中的编码是 1488)。UTF-8 使用一个字节表示所有 ASCII 范围内的字符(小于128)。对于其他字符,则使用字节序列表示,其中第一个字节的范围是 [194, 244],而后续的字节范围是 [128, 191]。更准确的说,对于两个字节组成的序列来说,第一个字节的范围是 [194, 223];对于三个字节组成的序列来说,第一个字节的范围是 [224, 239];对于四个字节组成的序列来说,第一个字节的范围是[240, 244],这些范围相互之间均不重叠。这种特点保证了任意字符对应的字节序列不会再其他字符对应的字节序列中出现。特别的,一个小于 128 的字节永远不会出现在多字节序列中,它只会代表与之对应的 ASCII 字符。
Lua 语言中的一些机制对 UTF-8 字符串来说同样“有效”。由于 Lua 语言使用 8 个字节来编码字符,所以可以像操作其他字符串一样读写和存储 UTF-8 字符串。字符串常量也可以包含 UTF-8 数据(当然,读者可能需要使用支持 UTF-8 编码的编辑器来处理使用 UTF-8 编码的源文件)。字符串连接对 UTF-8 字符串同样适用。对字符串的比较(小于、小于等于,等等)会按照 Unicode 编码中的字符代码顺序进行。
Lua 语言的操作系统库和输入输出库是与对应系统之间的主要接口,所以它们是否支持 UTF-8 取决于对应的操作系统。例如,在 Linux 操作系统下文件名使用 UTF-8 编码,而在 Windows 操作系统下文件名使用 UTF-16 编码。因此,如果要在 Windows操作系统中处理 Unicode 文件名,那么就要么使用额外的库,要么修改 Lua 语言的标准库。
让我们看一下字符串标准库中的函数是如何处理 UTF-8 字符串的。函数 reverse()upper()lower()byte()char()不适用与 UTF-8 字符串,这是因为它们针对的都是一字节字符。函数string.format()string.rep()适用于 UTF-8 字符串(格式选项 '%c' 除外,该格式选项针对一字节字符)。函数string.len()string.sub()可以用于 UTF-8 字符串,其中的索引以字节为单位而不是以字符为单位。通常,这些函数就够用了。
utf8 标准库的内容由于我打不出外文字就不再记录了。


4.6 练习

  • 练习 4.1:请问如何再 Lua 程序中以字符串的方式使用如下 XML 片段:
    Hello world
    ]]>
    请给出至少两种方式。

此处是要考察如何解析字符串,那么就应该从 string.substring.gsub 考虑,但是问题并不简单,首先注意到此片段中有回车有冗余空格,但不能直接使用 %s 和 %p 进行匹配替换,因为解析内容中可能有空格、有标点,所以应该更严谨一些。我的思路是,首先要把头部的去掉,然后把尾部的]]>去掉,中间会有很多冗余的空格,我们需要找到第一次出现有效数据的位置,作为起始位置,然后反转字符串找到尾部最后出现的有效数据的位置作为结束位置,最后使用string.sub()将有效内容提取出来。

data = [==[

]==]

function ExtractData(data)
    local result
    local startPos,endPos
    result = string.gsub(data, "", "")
    startPos = string.find(result, "%S")                --去除数据头部多余的空格
    endPos = -string.find(string.reverse(result), "%S") --去除数据尾部多余的空格
    result = string.sub(result, startPos, endPos)
    return result
end

print(ExtractData(data))

验证一下

> s = ExtractData(data)
> = string.gsub(s, " ", "1")
Hello1World     1
> = string.gsub(s, "\n", "1")
Hello World     0
>

可以看见除了有效内容中间的空格以外,其余的冗余空格和换行都已经清除完毕。只要保证头部和尾部的模式不出错,其他地方任意添加空格回车都不会影响到输出结果。第二种方式暂时没想到,以后想到了再更新。

  • 练习 4.2:假设你需要以字符串常量的形式定义一组包含歧义的转义字符序列,你会使用哪种方式?请注意考虑诸如可读性、每行最大长度及字符串最大长度等问题。

在 4.1 节中已经提到,字符串常量使用""''进行声明,具体应该使用单引号还是双引号,需要根据实际内容进行选择,如果文本中以双引号为主,就选择使用单引号声明,否则就使用双引号声明。如果二者差不多,我认为处于习惯考虑应该使用双引号声明,然后对文本中的双引号进行转义。

  • 练习 4.3:请编写一个函数,使之实现在某个字符串的指定位置插入另一个字符串:
    insert("hello world", 1, "start: ") --> start: hello world
    insert("hello world, 7, "small "") --> hello small world

从示例中可以看出来,插入规则是将第三个参数放入指定位置,第一个参数指定位置和其后的所有内容往后移动,需要注意的是,插入也可以进行尾部插入,所以范围其实可以溢出到#s1 +1,代码如下:

function insert(s1, pos, s2)
    if pos > #s1 + 1 then
        error("index out of range")     --超越边界
    end
    if pos == 1 then                    --头部插入
        return s2 .. s1
    elseif pos == #s1 + 1 then              --尾部插入
        return s1 .. s2
    else
        local tempLeft = string.sub(s1, 1, pos - 1)
        local tempRight = string.sub(s1, pos, -1)
        return tempLeft .. s2 .. tempRight
    end
end

验证一下

> = insert("ll", 1,"a")
all
> = insert("ll", 2,"a")
lal
> = insert("ll", 3,"a")
lla
> = insert("b", 1,"a")
ab
> = insert("b", 2,"a")
ba
> ^Z
  • 练习 4.4
    这个题目我打不出外文字就不写了

  • 练习 4.5:请编写一个函数,该函数用于移除指定字符串中的一部分,移除的部分使用起始位置和长度指定:
    remove("hello world", 7, 4)

与练习 4.3 套路大致相同,但多了一个删除长度超出字符串总长度的判断,同时,值得注意的是,当字符串内容全部删除的时候得到的是空字符串而非nil。

function remove(str, startPos, length)
    if (startPos > #str) or (startPos + length -1 > #str) then      --起始位置溢出边界或者起始位置加长度超出边界
        error("index out of range")
    end
    if startPos == 1 then                                           --头部删除
        return string.sub(str, startPos + length, -1)
    elseif startPos + length - 1 == #str then                           --尾部删除         
        return string.sub(str, 1, startPos - 1)
    else
        local tempLeft = string.sub(str, 1, startPos - 1)
        local tempRight = string.sub(str, startPos + length, -1)
        return tempLeft .. tempRight
    end
end

验证一下:

> = remove("ab",1,0)
ab
> = remove("ab",1,1) 
b
> = remove("ab",1,2) 

> = remove("ab",1,3) 
.\exercise4_5.lua:3: index out of range
stack traceback:
        [C]: in function 'error'
        .\exercise4_5.lua:3: in function <.\exercise4_5.lua:1>
        (tail call): ?
        [C]: ?
> = remove("ab",2,1) 
a
> = remove("ab",2,2) 
.\exercise4_5.lua:3: index out of range
stack traceback:
        [C]: in function 'error'
        .\exercise4_5.lua:3: in function <.\exercise4_5.lua:1>
        (tail call): ?
        [C]: ?
> = remove("ab",3,0)
.\exercise4_5.lua:3: index out of range
stack traceback:
        [C]: in function 'error'
        .\exercise4_5.lua:3: in function <.\exercise4_5.lua:1>
        (tail call): ?
        [C]: ?
> 
  • 练习 4.7:请编写一个函数判断指定的字符串是否为回文字符串:
    ispali("step on no pets")
    ispali("banana")

回文串就是检查字符串内容与其反转字符串内容是否一致,代码如下:

function ispali(str)
    local reStr = string.reverse(str)
    return str == reStr
end

验证一下:

> = ispali("abba")
true
> = ispali("abbac")
false
> = ispali("")      
true
>
  • 练习 4.8:重写之前的练习,使得它们忽略空格和标点符号。

练习 4.3 和 4.5 都是指定位置的程序,所以并不涉及空格和标点符号问题,因此需要修改的就是 4.1 和 4.7,4.1 已经算考虑到了空格和标点问题,所以只需要修改 4.7即可。

function ispali(str)
    local s = string.gsub(str, "%s", "")
    s = string.gsub(s, "%p", "")
    return s == string.reverse(s)
end

验证一下:

> = ispali("a bba")
true
> = ispali("a.;,.bba")
true
>

你可能感兴趣的:(4 字符串)