4.1 字符串的表示与常用操作 本节摘要:Lua 字符串是不可变的字节序列,编码由使用者约定。三种定界(单引号、双引号、长括号)覆盖从短标签到多行模板的全部场景;驻留机制让相同文本共享一份内存、比较达引用级。本节按取段、变换、格式化、查探四组过完 string 库核心,讲清 tonumber/tostring 的互转礼仪与 format 的格式符,最后论证为什么循环拼接必须让位给 table.concat。 不可变的房客 先钉牢一个底层事实:Lua 字符串存字节,不存字符。 在内存里是六个字节(UTF-8 编码每字三字节), 数的是字节数, 切出来的是半个汉字的字节碎片。要"按字符"处理文本,得用 5.3 引入的 utf8 库( 、 ),或者自己按 UTF-8 的字节规律切分。
本节摘要:Lua 字符串是不可变的字节序列,编码由使用者约定。三种定界(单引号、双引号、长括号)覆盖从短标签到多行模板的全部场景;驻留机制让相同文本共享一份内存、比较达引用级。本节按取段、变换、格式化、查探四组过完 string 库核心,讲清 tonumber/tostring 的互转礼仪与 format 的格式符,最后论证为什么循环拼接必须让位给 table.concat。
先钉牢一个底层事实:Lua 字符串存字节,不存字符。"工作" 在内存里是六个字节(UTF-8 编码每字三字节),#s 数的是字节数,s:sub(1, 1) 切出来的是半个汉字的字节碎片。要"按字符"处理文本,得用 5.3 引入的 utf8 库(utf8.len、utf8.codepoint),或者自己按 UTF-8 的字节规律切分。英文协议、十六进制串、Base64 这些"纯字节"场景则完全不需要关心编码——这正是网关与协议脚本偏爱 Lua 的原因之一:字节就是字节,没有隐式转换。
第二个事实:不可变。任何"修改"字符串的操作都返回新串,原串纹丝不动:
> local s = "openresty" > local up = s:upper() > print(s, up) openresty OPENRESTY
为什么坚持不可变?看驻留池:

驻留(interning)是"不可变"的直接回报:既然内容永远不会变,相同内容就可以放心共享一份。表键查找、协议字段比较、配置名匹配,这些脚本最高频的操作因此获得了接近指针比较的速度。代价同样直接:拼接是重活,每次 .. 都要申请新内存、拷贝两段内容。
用一段代码把这个代价变成肌肉记忆:
-- 反面教材:十万次拼接 local s = "" for i = 1, 100000 do s = s .. i .. "," end -- 每一次 .. 都分配一个更长的新串 · 总拷贝量是平方级 -- 正确姿势:先进表,一次成型 local buf = {} for i = 1, 100000 do buf[#buf + 1] = i end local joined = table.concat(buf, ",") print(#joined) --> 588890(十万段逗号相连)
规律一句话:拼接超过几十次就该换 table.concat。日志器、响应组装、批量导出全部遵守这条纪律,游戏脚本里长帧的第一嫌疑人往往就是循环拼接。
单引号与双引号完全等价,区别只是视觉习惯——含双引号的文本用单引号包可少转义:
> print('he said "lua"') he said "lua" > print("it's fine") it's fine
转义序列用反斜杠:\n 换行、\t 制表、\\ 反斜杠、\65 按字节码给字符、\u{XXX} 按 Unicode 码点插入(5.3 起,按 UTF-8 编码落字节):
> print("\65\66\67") ABC > print(#"\u{4E2D}") 3
长括号 [[...]] 里转义失效、换行原样,多行文本的标准写法:
local sql = [[ SELECT node, load_avg FROM cluster_state WHERE load_avg > 0.7 AND ts > now() - interval '5 min' ]]
相邻文本可能出现 ]](比如嵌 SQL 或 XML)就用等号升级定界:[==[ ... ]==],与 1.2 节注释的等号分级同一套词法。顺带一个卫生细节:长括号开头的第一个换行会被忽略,所以习惯把 [[ 后立刻换行,内容从第二行开始。
字符串库的全部函数都能用冒号形式在字符串值上直呼:s:len()、s:sub(1,3)——背后是字符串元表挂了 string 库(机制第 5 章补全)。
取段与测度:
> local s = "gateway:8080" > print(#s, s:len()) 13 13 > print(s:sub(1, 7), s:sub(-5)) gateway :8080 > print(s:byte(1), s:char(103)) 103 g
sub 的负下标从尾部倒数;sub(i, j) 含头含尾,越界自动夹取不报错——协议切片时既当 substring 又当安全截断用。byte 与 char 在字节与数值间互转,解析二进制协议、算校验和的基本件。
变换:upper/lower、rep(复制)、reverse:
> print(("ab"):rep(3, "-")) ab-ab-ab > print(("warn"):upper()) WARN
格式化 format:与 C 的 printf 家族同源,格式符最常用的五个:
> print(string.format("%s=%d (%.1f%%)", "ratio", 7, 86.666)) ratio=7 (86.7%) > print(string.format("%08.3f", 3.14159)) 0003.142 > print(string.format("%q", 'a"b')) "a\"b" > print(string.format("%x", 255)) ff
%d 整数(浮点无整数表示会报错,1.3 节的坑)、%f 定小数位、%s 字串(自动 tostring)、%q 安全引号化(能被 Lua 重新读回的转义形式,写配置与序列化的利器)、%x 十六进制。所有"数值出文本"的场合都应过 format,.. 直拼只在确认整型或已 string 化时使用。
查探与模式:find 找位置、match 取捕获、gsub 替换、gmatch 迭代——这四个是 4.2 节的主角,先给一个开胃的:
> local path = "/api/v2/user/1001" > print(path:match("/api/(%w+)/(%w+)/(%d+)")) v2 user 1001
三个圆括号捕获三段文本,match 全部返回——多返回值在文本处理处的第一场演出。
1.3 节埋的线这里收拢。**进来(文本转数值)**用 tonumber,转不动回 nil,永远是输入校验的第一行:
> print(tonumber("6379"), tonumber("0x1f"), tonumber(" 42 "), tonumber("n/a")) 6379 31 42 nil
注意三点:认十六进制、容忍首尾空白、失败给 nil 不抛错。**出去(数值转文本)**分场合:给人看用 tostring(浮点带 .0);给程序看用 format 精确控制。两个高频事故再列一遍——浮点直拼多出 .0、%d 收到无整数表示的浮点直接报错——它们都是"整浮分家"的下游账单。
还有一个易被忽略的陷阱:字符串在算术里会强转,在比较里不会(1.4 节)。从宿主或配置进来的值若可能是字符串形态的数字,先 tonumber 再参与运算,5.3 之后混比直接抛错,早转早踏实。
配置回写、缓存落盘、调试打印,都要把 Lua 值变回"能被 load 读回来的文本"。%q 就是干这个的格式符——它把字符串转成语法安全的形式:
> print(string.format("%q", 'he said "hi"\nnew line')) "he said \"hi\"\ new line"
引号、反斜杠、换行、制表符全部转义成 Lua 字面量语法,回读无损。整数用 %d、浮点用 %.17g(保证精度往返)配着用,就能拼出简易序列化器:
local function serialize(t, indent) indent = indent or "" local parts = {} for k, v in pairs(t) do local key = type(k) == "string" and ("[%q]"):format(k) or ("[%d]"):format(k) local val if type(v) == "table" then val = serialize(v, indent .. " ") elseif type(v) == "string" then val = ("%q"):format(v) else val = tostring(v) end parts[#parts + 1] = ("%s%s = %s"):format(indent, key, val) end return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}" end print(serialize({ host = "10.0.0.8", port = 6379, tags = { "a", "b" } }))
输出是合法的表构造器文本,load("return " .. text) 能完整读回。真实的序列化库要处理循环引用、函数与 userdata 的拒绝策略——但骨架就是 %q 加递归。自己拼序列化时千万别手工加引号:值里带引号或换行就炸,%q 一律代劳。
字符串比较按字节序(不是本地化字母序):
> print("abc" < "abd", "Z" < "a", "2" < "10") true true false
"Z" < "a" 为真(大写 Z 的字节码更小)、"2" < "10" 为假(按位比字符,'2' 大于 '1')——数字字符串的"大小"与数值大小无关,按数值排先 tonumber。字节序的好处是确定、快、无本地化依赖;代价是人类语感不适用(中文按 UTF-8 字节排,拼音序别想)。协议字段名、常量键的比较全在字节序的舒适区,这也是它被选中的原因。
中文世界的脚本绕不开 UTF-8,三个高频操作给出可直接抄的实现。
按字符计数:utf8.len 一步到位,但它同时是校验器——遇到非法字节返回 nil 加位置:
> print(utf8.len("网关脚本")) 4 > local n, pos = utf8.len("网\xff关") -- 混入非法字节 > print(n, pos) nil 2
校验外部输入的编码合法性,utf8.len 是最便宜的手段(返回 nil 即拒绝)。
按字符截断:先数后切,切点必须在字符边界:
local function utf8_sub(s, i, j) local n = utf8.len(s) i = i or 1; j = j or n if i < 0 then i = n + i + 1 end if j < 0 then j = n + j + 1 end i, j = math.max(i, 1), math.min(j, n) if i > j then return "" end local start = utf8.offset(s, i) -- 第 i 个字符的字节位置 local finis = utf8.offset(s, j + 1) or #s + 1 return s:sub(start, finis - 1) end print(utf8_sub("网关脚本语言入门", 1, 4)) --> 网关脚本 print(utf8_sub("网关脚本语言入门", -2, -1)) --> 入门
utf8.offset 把"字符序号"翻译成"字节位置",转换完成后再用 sub 切——utf8 库负责翻译、string 库负责动刀,两者配合就是全部工作流。
逐字符遍历:utf8.codes 迭代(位置加码点):
for pos, cp in utf8.codes("Lua脚本") do io.write(("%d:%d "):format(pos, cp)) end -- 输出:1:76 2:117 3:97 4:25253 9:26412
注意位置跳变(第 4 个字符占三字节,下一个位置是 9)——码点值可以进 utf8.char 反向变回字符,做拼音首字母映射、全半角转换这类"按字符"的处理时,这个循环是标准入口。
给 4.1 节的纪律配上量级感:其一,单次 .. 连接的开销与结果长度成正比——连接两个百字节串是纳秒级,但一万次循环连接的累计是平方级,从"感知不到"变成"拖慢一帧"。其二,table.concat 的开销与总长度线性,加上缓冲表的 insert 开销,仍是循环拼接的数百分之一量级。其三,驻留比较与构造哈希的差距:两个相同内容的短字符串比较是一次指针对比,构造一个百字节的新串则要分配加哈希——热路径上"复用已构造的键"比"每次拼接新键"快一个数量级。三个数都指向同一条军规:字符串的省钱之道是少造新串。
# 数字节、sub 切字节,按字符处理上 utf8 库;]];下一节进入模式匹配——用几百行实现换来的文本武器。