Lua源码笔记--字符串连接

Lua源码笔记–字符串连接


Lua字符串连接大概有四种方式:

  • Lua语法糖 ‘
  • table.concat
  • string.format
  • string.rep

如何选择合适的字符串连接方式?

1 Lua语法糖 …

Lua语法糖 … 可以很方便的做字符串连接。

使用限制:

  • 第一个拼接元素必须是字符串,否则会报错,如 s = 1 … “a” 会报错。
  • 第2-N个元素只能是数字或字符串。
  • 在连接数字时, … 与数字中间一定要有空格,不然会报错。在书写时,最好保证无论连接的是什么类型 … 左右都有空格。

简单示例

local a = "1" .. "2" .. "3"

生成的Lua字节码:

	1	[1]	LOADK    	0 -1	; "1"
	2	[1]	LOADK    	1 -2	; "2"
	3	[1]	LOADK    	2 -3	; "3"
	4	[1]	CONCAT   	0 0 2

先创建三个字符串,再通过一个CONCAT函数将三个字符串连接成一个字符串。

源码:

CONCAT函数(luaV_concat)会先计算连接后字符串长度,然后申请内存,并将字符串内容依次memcpy到新内存中。有一点要注意的是,通过 … 连接字符串时,会把子串全部压到堆栈上,如果子串过多,会不断的扩张栈的大小,合并完后,还会触发栈回缩,对性能有一定影响。核心代码如下:

void luaV_concat (lua_State *L, int total, int last) {
  do {
      // 先计算要连接的字符串的长度
	  size_t tl = tsvalue(top-1)->len;
	  for (n = 1; n < total && tostring(L, top-n-1); n++) {
        size_t l = tsvalue(top-n-1)->len;
        if (l >= MAX_SIZET - tl) luaG_runerror(L, "string length overflow");
        tl += l;
      }
      
      // 申请对应长度的内存
	  buffer = luaZ_openspace(L, &G(L)->buff, tl);
	  
	  // 通过memcpy拼接字符串
      for (i=n; i>0; i--) {  /* concat all strings */
        size_t l = tsvalue(top-i)->len;
        memcpy(buffer+tl, svalue(top-i), l);
        tl += l;
      }
}

误区:
之前一直以为每一次 … 连接都会产生一个新的字符串常量,导致一些中间字符串的生成,但从上面解释可以看出,并不如此:

@(误区)

s = a .. b .. c .. d
==> s =  ab .. c .. d
==> s = abc .. d
==> s = abcd

什么情况下会产生中间字符串,如下:

local s = ""
for i=1,3 do
	s = s .. "a"
end

生成的Lua字节码:

	5	[4]	LOADK    	1 -4	; ""
	6	[5]	LOADK    	2 -5	; 1
	7	[5]	LOADK    	3 -6	; 3
	8	[5]	LOADK    	4 -5	; 1
	9	[5]	FORPREP  	2 3	; to 13
	10	[6]	MOVE     	6 1
	11	[6]	LOADK    	7 -7	; "a"
	12	[6]	CONCAT   	1 6 7
	13	[5]	FORLOOP  	2 -4	; to 10

每次循环都会调用CONCAT函数新建一个新的字符串(中间字符串),而且这些字符串在最终的字符串生成完后由于没有被引用,又马上被垃圾回收器回收,导致全局表被多次rehash。当循环的次数很多时,不仅会分配大量内存去存中间字符串,还会多次扩缩容并rehash全局表,导致效率低下。

2 table.concat

Lua table模块内置的concat函数,将table数组部分从start到end位置元素以指定sep连接起来。

table.concat(table, sep, start, end)

使用限制:

  • 链接元素必须是字符串或数字。
  • 必须先把所有待拼接元素放入一个table里面,使用起来可能不大方便。

示例:

local t = {1,2,3}
table.concat(t)

生成的Lua字节码:

	1	[1]	NEWTABLE 	0 3 0
	2	[1]	LOADK    	1 -1	; 1
	3	[1]	LOADK    	2 -2	; 2
	4	[1]	LOADK    	3 -3	; 3
	5	[1]	SETLIST  	0 3 1	; 1
	6	[2]	GETGLOBAL	1 -4	; table
	7	[2]	GETTABLE 	1 1 -5	; "concat"
	8	[2]	MOVE     	2 0
	9	[2]	CALL     	1 2 1
	10	[2]	RETURN   	0 1

先创建一个table并初始化,然后调用concat函数。

源码:

concat内部实现函数是tconcat,如下:

static const luaL_Reg tab_funcs[] = {
  {"concat", tconcat},
  ...
};

如果对Lua的栈不熟,看tconcat函数可能会有点不好看懂,下面简单解释下:

static int tconcat (lua_State *L) {
	// table.concat的参数都存放在栈上,可以简单理解为:
	// 栈1号位存的是table
	// 栈2号位存的是sep
	// 栈3、4号位存的是起始(start)和结束(end)的位置
	
	// 先从2号位置取出sep,如果2号位置是nil,即没有指定sep,则sep=""
	const char *sep = luaL_optlstring(L, 2, "", &lsep);

    // 检查栈1号位置存的数据类型是否是一个table
	luaL_checktype(L, 1, LUA_TTABLE);

	// i table起始索引,如果起始索引为nil,则默认从1开始
	i = luaL_optint(L, 3, 1);

    // last table结束索引,如果结束索引为nil,则默认为table数组部分大小。
	last = luaL_opt(L, luaL_checkint, 4, luaL_getn(L, 1));

    // 申请一块buff,存合并后的数据,初始buff大小为8192
    // 当buff大小用完后就直接用luaV_concat在栈上做字符串连接
	luaL_buffinit(L, &b);

    // 把table里面索引从i到last的值取出来,并放到buff里面,buff大小为BUFSIZ(8192)
    // 每当写满一个buff,就把buff生成一个TString,并放到栈上,并把buff清空重新写
	for (; i < last; i++) {
		addfield(L, &b, i);
		luaL_addlstring(&b, sep, lsep);
	}
	
	// 把最后一个元素放入buff
	// 把buff生成一个TString,并放到栈上
	if (i == last) 
		addfield(L, &b, i);
	
	// 把栈上之前所有生成的TString通过luaV_concat(就是 .. 语法糖的合并函数)合并成一个TString,放到栈上,相当于返回值,供上层函数取用
	luaL_pushresult(&b);
	return 1;
}

从源码上看,table.concat没有频繁申请内存,只有当写满一个8192的BUFF时,才会生成一个TString,最后生成多个TString时,会有一次内存申请并合并。在大规模字符串合并时,应尽量选择这种方式。

误区:

每次都要通过索引从table里面查找对应的值,这个查找会很耗时。

这个说法不对, table.concat 只会对数组部分进行字符串拼接,通过索引查询数组时,时间复杂度为O(1)。

3 string.format

Lua string模块内置的format函数,和C语言的sprintf类似,可以将不同类型的数据格式化成字符串。

格式:

string.format(fmt, […])

  • 类型
%c - 接受一个数字, 并将其转化为ASCII码表中对应的字符
%d, %i - 接受一个数字并将其转化为有符号的整数格式
%o - 接受一个数字并将其转化为八进制数格式
%u - 接受一个数字并将其转化为无符号整数格式
%x - 接受一个数字并将其转化为十六进制数格式, 使用小写字母
%X - 接受一个数字并将其转化为十六进制数格式, 使用大写字母
%e - 接受一个数字并将其转化为科学记数法格式, 使用小写字母e
%E - 接受一个数字并将其转化为科学记数法格式, 使用大写字母E
%f - 接受一个数字并将其转化为浮点数格式
%g(%G) - 接受一个数字并将其转化为%e(%E, 对应%G)及%f中较短的一种格式
%q - 接受一个字符串并将其转化为可安全被Lua编译器读入的格式
%s - 接受一个字符串并按照给定的参数格式化该字符串
  • 标志
符号: 一个+号表示其后的数字转义符将让正数显示正号. 默认情况下只有负数显示符号.
占位符: 一个0, 在后面指定了字串宽度时占位用. 不填时的默认占位符是空格.
对齐标识: 在指定了字串宽度时, 默认为右对齐, 增加-号可以改为左对齐.
宽度数值
小数位数/字串裁切: 在宽度数值后增加的小数部分n, 若后接f(浮点数转义符, 如%6.3f)则设定该浮点数的小数只保留n位, 若后接s(字符串转义符, 如%5.3s)则设定该字符串只显示前n位.

示例:

local a = 1
local b = "abc"
string.format("id:%d,name:%s", a, b)

生成的Lua字节码:

	1	[1]	LOADK    	0 -1	; 1
	2	[2]	LOADK    	1 -2	; "abc"
	3	[3]	GETGLOBAL	2 -3	; string
	4	[3]	GETTABLE 	2 2 -4	; "format"
	5	[3]	LOADK    	3 -5	; "id:%d,name:%s"
	6	[3]	MOVE     	4 0
	7	[3]	MOVE     	5 1
	8	[3]	CALL     	2 4 1
	9	[3]	RETURN   	0 1

先将参数a, b压栈,再调用format函数。

源码:

format内部实现函数是str_format,如下:

static const luaL_Reg strlib[] = {
  ...
  {"format", str_format},
  ...
};

str_format函数并不难看懂,下面简单解释下:

static int str_format (lua_State *L) {
  // 指向string.format参数在栈上的位置,可以简单的理解为:
  // 1:指向第一个参数
  // 2: 指向第二个参数
  int arg = 1;

  // 从栈上获取第一个参数:fmt,两个指针分别指向该字符串的起止位置
  const char *strfrmt = luaL_checklstring(L, arg, &sfl);
  const char *strfrmt_end = strfrmt+sfl;
  
  // 初始化一个8192大小的BUFF,格式化的字符串就往里面写。
  luaL_Buffer b;
  luaL_buffinit(L, &b);
  
  while (strfrmt < strfrmt_end) {
    // 非%...的直接写入BUFF
    if (*strfrmt != L_ESC)
      luaL_addchar(&b, *strfrmt++);
    
    // %% 直接写入%
    else if (*++strfrmt == L_ESC)
      luaL_addchar(&b, *strfrmt++);  /* %% */
    else { /* format item */
      // 每找到一个%... 就写入form
      // 把找到的%...,格式化后的结果写入buff
      char form[MAX_FORMAT];  /* to store the format (`%...') */
      char buff[MAX_ITEM];  /* to store the formatted item */
      
      strfrmt = scanformat(L, strfrmt, form);
      switch (*strfrmt++) {
        // 对%c做具体格式化
        case 'c': {
          sprintf(buff, form, (int)luaL_checknumber(L, arg));
          break;
        }
        ...
      }

      // 将格式化后结果写入BUFF
      // 如果BUFF大小不够,则先将BUFF生成一个TString,并压入栈上,清空BUFF重新写
      luaL_addlstring(&b, buff, strlen(buff));
    }
  }

  // 把栈上之前所有生成的TString通过luaV_concat(就是 .. 语法糖的合并函数)合并成一个TString,放到栈上,相当于返回值,供上层函数取用
  luaL_pushresult(&b);
  return 1;
}

从上面源码可以看出,string.format要先解析字符串,再将不同类型的数据格式化成字符串,然后写入BUFF, 写BUFF的方式和table.concat是一样的。

4 string.rep

Lua string内置模块中另一个可以做字符串连接是的rep,不过使用局限性很大,只能重复的对某一个字符串做N次拼接。

string.rep(str, n)

使用限制:

  • 只能对字符串做重复拼接。

示例:

string.rep("abc", 3)

生成的Lua字节码:

	1	[1]	GETGLOBAL	0 -1	; string
	2	[1]	GETTABLE 	0 0 -2	; "rep"
	3	[1]	LOADK    	1 -3	; "abc"
	4	[1]	LOADK    	2 -4	; 3
	5	[1]	CALL     	0 3 1
	6	[1]	RETURN   	0 1

先PUSH参数到栈上,然后调用rep函数。

源码:

concat内部实现函数是str_rep,如下:

static const luaL_Reg tab_funcs[] = {
  {"rep", str_rep},
  ...
};

str_rep代码非常简短,也非常好理解:

static int str_rep (lua_State *L) {
  // 从栈1的位置获取字符串s
  const char *s = luaL_checklstring(L, 1, &l);

  // 从栈2的位置获取重复次数n
  int n = luaL_checkint(L, 2);
  
  // 初始化一个buff,buff大小为8192
  luaL_buffinit(L, &b);

  // 把字符串 s 写入到buff,重复写 n 次
  // 当buff大小不够时的处理同string.format的实现。 
  while (n-- > 0)
    luaL_addlstring(&b, s, l);
  
  // 获得最后的结果
  luaL_pushresult(&b);
  return 1;
}

5. 总结

这四种字符串连接方式,从源码上分析,其实实现方式差不多,只是table.concat要先创建一个table再去拼接,string.format需要解析字符串,这些可能会有点耗时,但我觉得影响并不大。在选择连接方式时,只需考虑易用和代码简洁性就可以了。下面有些小建议,仅供参考:

  • 简单且子串较少的字符串拼接,用 …
local a = "a" .. 1 .. 2
  • 子串多尽量用table.concat
local t = {"a", "b", "c", "d"}
local s = table.concat(t)
  • 有数据格式转换或字符宽度、对齐等要求的用string.format
local a=1
local b="abc"
local s = string.format("id:%02d, name:%04s", a, b)
  • 重复的字符串拼接用string.rep, 这个没的说
string.rep("abc", 3)

猜你喜欢

转载自blog.csdn.net/fengshenyun/article/details/89952494