Lua篇 — 字符串

Posted by Xun on Wednesday, August 2, 2023

lua 字符串是 lua 中的一种基本类型,本文将以 lua-5.4.1 版本进行源码解析。

简介

  • 在 lua 中,使用字符串的方式和其他编程语言相似,如:
  local str = "Hello World"
  print(str)
  • 然而,lua 中的 string ,实现上并非直接为 c 中的 char* 或者 char[] ,而是使用了封装的类型。
  • 从 xlua 或 tolua 中可以知道,当 lua 侧访问 C# 侧的字符串变量时,C# 侧会通过调用 lua_pushstring/lua_pushlstring 方法将字符串传输到 lua 虚拟机的栈上,而 C# 侧访问 lua 侧的字符串变量时,则是通过调用 lua_tolstring 方法,从栈上读取字符串。

字符串压栈

  • lua_pushstring 的实现如下:
// lapi.c

LUA_API const char *lua_pushstring (lua_State *L, const char *s) {
  lua_lock(L);
  if (s == NULL)
    setnilvalue(s2v(L->top));
  else {
    TString *ts;
    ts = luaS_new(L, s);
    setsvalue2s(L, L->top, ts);
    s = getstr(ts);  /* internal copy's address */
  }
  api_incr_top(L);
  luaC_checkGC(L);
  lua_unlock(L);
  return s;
}
  • 可以看到,lua_pushstring 通过调用 luaS_new 方法,创建了一个 TString * 类型的变量,然后将其设置到栈上。
  • TString 结构为:
// lobject.h

typedef struct TString {
  CommonHeader;
  lu_byte extra;  /* reserved words for short strings; "has hash" for longs */
  lu_byte shrlen;  /* length for short strings */
  unsigned int hash;
  union {
    size_t lnglen;  /* length for long strings */
    struct TString *hnext;  /* linked list for hash table */
  } u;
  char contents[1];
} TString;

  • TString 结构变量意义分别为:
    • CommonHeader :需要 gc 的对象的公共头部。
    • extra :长短字符串有不同的含义。
      • 短字符串(len <= 40):大于 0 表示被持有,即不会被 gc 回收(lstring.h 的 isreserved 方法)。
      • 长字符串(len > 40):为 0 表示字符串没有经过 hash 计算,经过 hash 计算后则为 1 (lstring.c 的 luaS_hashlongstr 方法)。
    • shrlen :短字符串的长度(字节数,UTF-8 编码的中文字符通常占用 3 个字节)。
    • hash :字符串经过 hash 计算后的结果。
    • u :union 结构,有两个字段。
      • lnglen :长字符串的长度(字节数)。
      • hnext :TString 链表,存放下一个 hash 值对 G(L)->strt.size 取模结果相同的 TString。
    • contents[1] :用来标记字符串的起始位置,创建 TString 对象时会根据字符串的长度和起始位置来确定需要申请的内存大小。

创建 lua 字符串

  • 再看 luaS_new 方法的代码:
// lstring.c

TString *luaS_new (lua_State *L, const char *str) {
  unsigned int i = point2uint(str) % STRCACHE_N;  /* hash */
  int j;
  TString **p = G(L)->strcache[i];
  for (j = 0; j < STRCACHE_M; j++) {
    if (strcmp(str, getstr(p[j])) == 0)  /* hit? */
      return p[j];  /* that is it */
  }
  /* normal route */
  for (j = STRCACHE_M - 1; j > 0; j--)
    p[j] = p[j - 1];  /* move out last element */
  /* new element is first in the list */
  p[0] = luaS_newlstr(L, str, strlen(str));
  return p[0];
}
  • 为一个字符串创建 TString 对象时,首先通过查找 G(L)->strcache 的 TString 缓存中是否存在相同的字符串。其中,strcache 为二维数组,先通过字符串的长度对 STRCACHE_N (STRCACHE_N = 53)取模得到第一维索引 i,再对 strcache[i] 进行遍历。
    • 存在 :直接返回对应 TString 对象。
    • 不存在 :通过调用 luaS_newlstr 方法创建一个新的 TString 对象,插入到 strcache[i][0] 中,strcache[i] 最多保存 STRCACHE_M + 1 (STRCACHE_M = 2)个对象。
  • strcache 为 lua 的 TString 缓存机制,gc 过程会对其进行检查,如果 TString 对象可以回收,则会 strcache[i][j] 替换为一个不会被回收的 lua 字符串 memerrmsg ,原有的对象则进行回收(lstring.c 的 luaS_clearcache 方法)。
  • luaS_newlstr 用来创建新的 TString 对象,其代码如下:
// lstring.c

TString *luaS_newlstr (lua_State *L, const char *str, size_t l) {
  if (l <= LUAI_MAXSHORTLEN)  /* short string? */
    return internshrstr(L, str, l);
  else {
    TString *ts;
    if (unlikely(l >= (MAX_SIZE - sizeof(TString))/sizeof(char)))
      luaM_toobig(L);
    ts = luaS_createlngstrobj(L, l);
    memcpy(getstr(ts), str, l * sizeof(char));
    return ts;
  }
}

  • 可以看到,在这里字符串分成了两类处理,长度在 LUAI_MAXSHORTLEN(LUAI_MAXSHORTLEN = 40)以内的为短字符串,超过 LUAI_MAXSHORTLEN 的为长字符串。
  • 需要注意的是,C# 中的 string.Length 方法则是计算 Unicode 字符的数量,而 lua 的字符串长度是使用 C 语言的 strlen 方法计算的,即得到的是字符串中所有字符编码所占的子节总数,而不是单纯的字符数量。如,在 GB2312 编码中,一个中文字符占 2 个字节,在 UTF-8 编码中,一个中文字符通常占 3 个字节。因此涉及到字符串的字符相关的操作,如字符查找、字符串截取等,对应的索引值都需要考虑多字节字符的情况。

lua 短字符串

  • 短字符串,调用了 internshrstr 方法进行处理,其代码如下:
// lstring.c

static TString *internshrstr (lua_State *L, const char *str, size_t l) {
  TString *ts;
  global_State *g = G(L);
  stringtable *tb = &g->strt;
  unsigned int h = luaS_hash(str, l, g->seed, 1);
  TString **list = &tb->hash[lmod(h, tb->size)];
  lua_assert(str != NULL);  /* otherwise 'memcmp'/'memcpy' are undefined */
  for (ts = *list; ts != NULL; ts = ts->u.hnext) {
    if (l == ts->shrlen && (memcmp(str, getstr(ts), l * sizeof(char)) == 0)) {
      /* found! */
      if (isdead(g, ts))  /* dead (but not collected yet)? */
        changewhite(ts);  /* resurrect it */
      return ts;
    }
  }
  /* else must create a new string */
  if (tb->nuse >= tb->size) {  /* need to grow string table? */
    growstrtab(L, tb);
    list = &tb->hash[lmod(h, tb->size)];  /* rehash with new size */
  }
  ts = createstrobj(L, l, LUA_VSHRSTR, h);
  memcpy(getstr(ts), str, l * sizeof(char));
  ts->shrlen = cast_byte(l);
  ts->u.hnext = *list;
  *list = ts;
  tb->nuse++;
  return ts;
}
  • 首先,获取了 G(L) 中的 strt , 是一个 stringtable 类型,其代码为:
// lstate.h

typedef struct stringtable {
  TString **hash;
  int nuse;  /* number of elements */
  int size;
} stringtable;
  • strt 是一个全局的字符串表,其中的字段含义为:
    • hash :所有短字符串 TString 的链表数组,根据 hash 计算结果对 size 取模得到数组索引,相同索引的 TString 则存入数组下的链表中。
    • nuse :当前 TString 的数量。
    • size :字符串表的大小,即数量上限。
  • 取模计算方法为:
// lobject.h

#define lmod(s,size) \
	(check_exp((size&(size-1))==0, (cast(int, (s) & ((size)-1)))))
  • 由于 size 为 2 的指数次幂,假设 size 为 2 ^ n ,size - 1 即为 0 ~ n - 1 位都为 1 的值,如 2 ^ 3 = 00001000,那 2 ^ 3 - 1 = 00000111,则位与运算,就能得到 0 ~ n - 1 位的值,也就是取模运算。
  • 获取全局字符串表后,就对字符串进行 hash 计算,计算方法为:
// lstring.c

unsigned int luaS_hash (const char *str, size_t l, unsigned int seed,
                        size_t step) {
  unsigned int h = seed ^ cast_uint(l);
  for (; l >= step; l -= step)
    h ^= ((h<<5) + (h>>2) + cast_byte(str[l - 1]));
  return h;
}
  • hash 计算的流程为:
    • 通过一个种子 seed ,和字符串长度做异或计算,得到基础 hash 值。
    • 根据步进长度 step ,对字符串的对应字符,和当前 hash 值移位后相加。
    • 当前 hash 值和字符相加后的结果再进行异或计算。
  • 这里的 seed ,是在创建 lua 虚拟机的时候进行设置的,其代码为:
// lstate.c

static unsigned int luai_makeseed (lua_State *L) {
  char buff[3 * sizeof(size_t)];
  unsigned int h = cast_uint(time(NULL));
  int p = 0;
  addbuff(buff, p, L);  /* heap variable */
  addbuff(buff, p, &h);  /* local variable */
  addbuff(buff, p, &lua_newstate);  /* public function */
  lua_assert(p == sizeof(buff));
  return luaS_hash(buff, p, h, 1);
}
  • 可以看到,seed 的值和当前时间、虚拟机地址等多个随机值进行 hash 计算得到的,因此具有比较强的随机性,不容易被直接获取。
  • 计算出 hash 值后,就可以通过取模计算找到当前字符串应该进入哪个链表。通过查找链表,检查是否已经为相同字符串创建过 TString 对象,如果是则直接返回。如果没有找到,则申请新的内存创建新的对象。
// lstring.c

static TString *createstrobj (lua_State *L, size_t l, int tag, unsigned int h) {
  TString *ts;
  GCObject *o;
  size_t totalsize;  /* total size of TString object */
  totalsize = sizelstring(l);
  o = luaC_newobj(L, tag, totalsize);
  ts = gco2ts(o);
  ts->hash = h;
  ts->extra = 0;
  getstr(ts)[l] = '\0';  /* ending 0 */
  return ts;
}
// lstring.h

#define sizelstring(l)  (offsetof(TString, contents) + ((l) + 1) * sizeof(char))
  • 可以看到,计算新对象所需大小时,是通过获取 contents 字段在 TString 结构中的偏移量,加上字符串长度,以及字符串结尾的 ‘\0’ 一个字符长度。这也就解释了前面提到的,TString 中的 contents[1] ,只是用来标记起始值,不是真实的数组大小,因为最终需要的内存大小会按需申请。
  • 在创建新对象前,会检查当前的 strt 是否已经达到上限,如果达到上限,就调用 growstrtab 方法进行扩容,上限大小翻倍,并且全部对象都要以新的 size ,重新进行取模计算,设置到新的数组链表中。
// lstring.c

static void growstrtab (lua_State *L, stringtable *tb) {
  if (l_unlikely(tb->nuse == MAX_INT)) {  /* too many strings? */
    luaC_fullgc(L, 1);  /* try to free some... */
    if (tb->nuse == MAX_INT)  /* still too many? */
      luaM_error(L);  /* cannot even create a message... */
  }
  if (tb->size <= MAXSTRTB / 2)  /* can grow string table? */
    luaS_resize(L, tb->size * 2);
}
  • 可以看到,扩容的上限为 2^31 ,当达到这个上限时,会尝试进行一次 gc 回收。如果 gc 后还是达到上限,则不能再继续创建。
  • luaS_resize 不仅可以进行扩容,也可以进行缩小,gc 回收 strt 的过程,就会尝试进行缩小。
// lgc.c

static void checkSizes (lua_State *L, global_State *g) {
  if (!g->gcemergency) {
    if (g->strt.nuse < g->strt.size / 4) {  /* string table too big? */
      l_mem olddebt = g->GCdebt;
      luaS_resize(L, g->strt.size / 2);
      g->GCestimate += g->GCdebt - olddebt;  /* correct estimate */
    }
  }
}
  • 可以看到,当 strt 中实际的对象数量小于上限的 1/4 时,则会将 strt 的上限缩小为原来的 1/2 。

lua 长字符串

  • 长字符串,使用 luaS_createlngstrobj 方法进行创建。
// lstring.c

TString *luaS_createlngstrobj (lua_State *L, size_t l) {
  TString *ts = createstrobj(L, l, LUA_VLNGSTR, G(L)->seed);
  ts->u.lnglen = l;
  return ts;
}
  • 长字符串的创建则很简单,直接创建新的对象,也不进行 hash 计算,hash 值设置为 G(L)->seed 。在这种机制下,长字符串对内存的需求会很大,如果频繁创建不同的长字符串,就会有较大的内存占用,也就容易频繁触发 gc 回收。
  • 长字符串的 hash 计算方法为
// lstring.c

unsigned int luaS_hashlongstr (TString *ts) {
  lua_assert(ts->tt == LUA_VLNGSTR);
  if (ts->extra == 0) {  /* no hash? */
    size_t len = ts->u.lnglen;
    size_t step = (len >> LUAI_HASHLIMIT) + 1;
    ts->hash = luaS_hash(getstr(ts), len, ts->hash, step);
    ts->extra = 1;  /* now it has its hash */
  }
  return ts->hash;
}
  • 可以看到,长字符的 hash 计算并不是每个字符都进行计算,步进 step 是 len » LUAI_HASHLIMIT + 1 ,即字符串长度除以 32(2 ^ LUAI_HASHLIMIT),也就是说,如果字符串长度小于 32,则每一个字符都进行 hash 计算,否则会跳过某些字符进行 hash 计算。
  • 由于长字符串的长度最大可以达到 INT_MAX(2 ^ 31),如果大量长字符串都进行全字符 hash 计算,显然效率会比较低。因此长字符串只有在需要的时候才进行 hash 计算,并且字符串越长,计算的步进间隔也会越大,减少 hash 计算压力。

lua 字符串缓存

  • 前面分析了 lua 短字符串和长字符串的创建流程,可以看到,lua 字符串总共有两个缓存机制:
    • G(L)->strcache
    • G(L)->strt

strcache

  • strcache 适用于短字符串和长字符串,根据字符串长度取模计算结果进行缓存,但不会扩容。
  • 当 strcache[i] 的数量达到上限后,新插入的就会替换掉最早缓存的值,因此如果多个长度取模结果相同的长字符串,反复调用创建时,由于缓存上限只有 3 个(STRCACHE_N),此时缓存机制就会失效,即基本上每一个新字符串的调用都会申请创建新的字符串对象。
  • 因此,对于需要拼接参数的字符串,如使用 string.format ,尽量少使用长字符串,优先选择使用短字符串进行拼接。如果需要使用长字符串拼接时,则应该尽量避免频繁更改拼接的参数。
  • 然而,也正是由于缓存数量少,查找的效率会很高,通过取模计算能直接定位到 strcache[i] ,遍历 strcache[i] 也最多需要进行 3 次即可完成,对已经创建好 TString 的字符串,查找效率相对较高。
  • global_State 中,定义了 strcache 的大小。
// lstate.h

typedef struct global_State {
  ...
  TString *strcache[STRCACHE_N][STRCACHE_M];  /* cache for strings in API */
  ...
} global_State;

  • strcache 在 lua 虚拟机创建的时候进行初始化,代码如下:
// lstring.c

void luaS_init (lua_State *L) {
  global_State *g = G(L);
  int i, j;

  ...

  /* pre-create memory-error message */
  g->memerrmsg = luaS_newliteral(L, MEMERRMSG);
  luaC_fix(L, obj2gco(g->memerrmsg));  /* it should never be collected */
  for (i = 0; i < STRCACHE_N; i++)  /* fill cache with valid strings */
    for (j = 0; j < STRCACHE_M; j++)
      g->strcache[i][j] = g->memerrmsg;
}
  • 通过调用 luaS_newliteral 方法创建了一个 TString ,赋值到 G(L)->memerrmsg ,并设置这个对象不会被 gc 回收,然后对 strcache 进行初始化赋值,设置为 memerrmsg 。
  • gc 触发时,调用 luaS_clearcache 方法,将需要回收字符串的索引值设置为 memerrmsg ,进而后续流程回收字符串。
// lstring.c

void luaS_clearcache (global_State *g) {
  int i, j;
  for (i = 0; i < STRCACHE_N; i++)
    for (j = 0; j < STRCACHE_M; j++) {
      if (iswhite(g->strcache[i][j]))  /* will entry be collected? */
        g->strcache[i][j] = g->memerrmsg;  /* replace it with something fixed */
    }
}

strt

  • strt 只适用于短字符串,因此短字符串其实有双重缓存。短字符串通过对 hash 结果取模后,结果相同的保存到同一个链表中。和 strcache 不同,随着保存的短字符串增加,strt 会进行扩容,因此申请新内存的频率比 strcache 小得多。
  • 因此,lua 中尽量使用短字符串,尤其在移动设备的应用中,对内存大小要求比较高,相比于频繁申请内存的长字符串,短字符串对内存大小的控制会更加有效。
  • 然而,由于相同取模结果的字符串保存在一个链表中,所以查找效率相比 strcache 较低,尤其当取模结果集中到某个值时,查找效率会退化到 O(n) ,即需要对每一个 TString 进行检查。
  • 为了尽量避免这种情况,在数量达到上限后,strt 扩容时会重新对所有对象重新进行取模计算,原来集中到同一个值的会被重新分到多个链表中去,提升查找效率。
  • 同样,strt 在 lua 虚拟机创建时进行初始化,代码如下:
// lstring.c

void luaS_init (lua_State *L) {
  global_State *g = G(L);
  int i, j;
  stringtable *tb = &G(L)->strt;
  tb->hash = luaM_newvector(L, MINSTRTABSIZE, TString*);
  tablerehash(tb->hash, 0, MINSTRTABSIZE);  /* clear array */
  tb->size = MINSTRTABSIZE;
  ...
}
  • strt 初始大小设置为 128(MINSTRTABSIZE),达到上限后会进行扩容。当触发 gc 时,也会对 strt 进行检查,根据字符串对象数量来确定是否进行缩小。

Hash Dos

  • 在 lua 5.2.0 以前,luaS_new 方法不区分长短字符串,也没有 strcache,所有字符串都会进入 strt 中缓存,其中 hash 计算方法为
// lstring.c

  unsigned int h = cast(unsigned int, l);  /* seed */
  size_t step = (l>>5)+1;  /* if string is too long, don't hash all its chars */
  size_t l1;
  for (l1=l; l1>=step; l1-=step)  /* compute hash */
    h = h ^ ((h<<5)+(h>>2)+cast(unsigned char, str[l1-1]));
  • 对字符串长度大于 32 的字符串,步进间隔增大来降低计算耗时,同时所有字符串都统一进入 strt 缓存管理,对内存压力较小。然而,在 lua 5.2.0 发布后不久,有人提出这个设计会给予 Hash Dos 攻击的机会,因为攻击者可以轻易构造出上千万拥有相同 hash 计算结果的不同字符串,如:
    • a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1”
    • a2a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1”
    • a3a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1”
  • 可以看到,上面几个字符串,长度都为 32,hash 计算步进长度都为 2,也就是说,最终进行 hash 计算的都是加粗的字符 a ,因此最终的 hash 结果也是相同的。当大量输入这类字符串到 lua 中, strt 的缓存链表就会非常长,频繁进行字符串处理时,效率就会变得非常低。在用于如 http 服务等大量依赖字符串处理的场景下,lua 的输入字符串不可控制,也就很容易被人恶意利用进行攻击。
  • 因此,从 lua 5.2.1 开始,则开始划分长短字符串,长字符串不再进入全局字符串表中缓存,同时使用了随机种子 seed ,使得攻击者无法轻易构造出拥有相同 hash 计算结果的不同字符串,从而避免 Hash Dos 攻击风险。
  • 然而,由于长字符串的特殊处理,所以长字符串的内存需求会变大。尤其在内存较小的移动设备上,对长字符串的使用需要进行把控,避免产生大量临时长字符串引发频繁 gc 。

字符串连接

  • lua 中,使用 “..” 符号来实现字符串拼接。符号识别的代码如下:
// llex.c

static int llex (LexState *ls, SemInfo *seminfo) {
  luaZ_resetbuffer(ls->buff);
  for (;;) {
    switch (ls->current) {
      ...

      case '.': {  /* '.', '..', '...', or number */
        save_and_next(ls);
        if (check_next1(ls, '.')) {
          if (check_next1(ls, '.'))
            return TK_DOTS;   /* '...' */
          else return TK_CONCAT;   /* '..' */
        }
        else if (!lisdigit(ls->current)) return '.';
        else return read_numeral(ls, seminfo);
      }
      ...
    }
  }
}
// lparser.c

static BinOpr getbinopr (int op) {
  switch (op) {
    ...

    case TK_CONCAT: return OPR_CONCAT;

    ...
  }
}
  • 可以看到,在解析代码文本时,识别到 “..” 时,会返回一个 TK_CONCAT 枚举,通过 TK_CONCAT 可以得到对应的操作类型 OPR_CONCAT 。
  • 解析表达式的主要代码如下:
// lparse.c

static BinOpr subexpr (LexState *ls, expdesc *v, int limit) {
  BinOpr op;
  UnOpr uop;
  enterlevel(ls);
  uop = getunopr(ls->t.token);
  if (uop != OPR_NOUNOPR) {  /* prefix (unary) operator? */
    int line = ls->linenumber;
    luaX_next(ls);  /* skip operator */
    subexpr(ls, v, UNARY_PRIORITY);
    luaK_prefix(ls->fs, uop, v, line);
  }
  else simpleexp(ls, v);
  /* expand while operators have priorities higher than 'limit' */
  op = getbinopr(ls->t.token);
  while (op != OPR_NOBINOPR && priority[op].left > limit) {
    expdesc v2;
    BinOpr nextop;
    int line = ls->linenumber;
    luaX_next(ls);  /* skip operator */
    luaK_infix(ls->fs, op, v);
    /* read sub-expression with higher priority */
    nextop = subexpr(ls, &v2, priority[op].right);
    luaK_posfix(ls->fs, op, v, &v2, line);
    op = nextop;
  }
  leavelevel(ls);
  return op;  /* return first untreated operator */
}
  • 解析表达式的主要流程为:
    • 读取每个表达式信息,获取操作类型。
    • 在优先级表中,查找操作类型的左参数的值是否大于传入的值 limit 。
      • 如果左侧参数比较大,则将右侧参数传入,递归读取下一个表达式(即先执行优先级高的操作类型)。
      • 如果传入的 limit 比较大,则返回操作类型。
    • 根据操作类型,进行相应的计算处理。
  • 操作符的优先级表和操作符枚举 BinOpr 对应,为:
// lparser.c

static const struct {
  lu_byte left;  /* left priority for each binary operator */
  lu_byte right; /* right priority */
} priority[] = {  /* ORDER OPR */
   {10, 10}, {10, 10},           /* '+' '-' */
   {11, 11}, {11, 11},           /* '*' '%' */
   {14, 13},                  /* '^' (right associative) */
   {11, 11}, {11, 11},           /* '/' '//' */
   {6, 6}, {4, 4}, {5, 5},   /* '&' '|' '~' */
   {7, 7}, {7, 7},           /* '<<' '>>' */
   {9, 8},                   /* '..' (right associative) */
   {3, 3}, {3, 3}, {3, 3},   /* ==, <, <= */
   {3, 3}, {3, 3}, {3, 3},   /* ~=, >, >= */
   {2, 2}, {1, 1}            /* and, or */
};
// lcode.h

typedef enum BinOpr {
  /* arithmetic operators */
  OPR_ADD, OPR_SUB, OPR_MUL, OPR_MOD, OPR_POW,
  OPR_DIV, OPR_IDIV,
  /* bitwise operators */
  OPR_BAND, OPR_BOR, OPR_BXOR,
  OPR_SHL, OPR_SHR,
  /* string operator */
  OPR_CONCAT,
  /* comparison operators */
  OPR_EQ, OPR_LT, OPR_LE,
  OPR_NE, OPR_GT, OPR_GE,
  /* logical operators */
  OPR_AND, OPR_OR,
  OPR_NOBINOPR
} BinOpr;
  • 可以看到,字符串拼接的优先级为 {9, 8} ,也就是说,当连续多个 “..” 进行字符串拼接时,会从最右侧的字符串开始依次向左进行拼接。

string 方法

  • lua 虚拟机创建的时候,会调用 luaL_openlibs 方法,将各个模块方法进行注册到全局表中。
// linit.c

static const luaL_Reg loadedlibs[] = {
  {LUA_GNAME, luaopen_base},
  {LUA_LOADLIBNAME, luaopen_package},
  {LUA_COLIBNAME, luaopen_coroutine},
  {LUA_TABLIBNAME, luaopen_table},
  {LUA_IOLIBNAME, luaopen_io},
  {LUA_OSLIBNAME, luaopen_os},
  {LUA_STRLIBNAME, luaopen_string},
  {LUA_MATHLIBNAME, luaopen_math},
  {LUA_UTF8LIBNAME, luaopen_utf8},
  {LUA_DBLIBNAME, luaopen_debug},
  {NULL, NULL}
};

LUALIB_API void luaL_openlibs (lua_State *L) {
  const luaL_Reg *lib;
  /* "require" functions from 'loadedlibs' and set results to global table */
  for (lib = loadedlibs; lib->func; lib++) {
    luaL_requiref(L, lib->name, lib->func, 1);
    lua_pop(L, 1);  /* remove lib */
  }
}
// lauxlib.c

LUALIB_API void luaL_requiref (lua_State *L, const char *modname,
                               lua_CFunction openf, int glb) {
  luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
  lua_getfield(L, -1, modname);  /* LOADED[modname] */
  if (!lua_toboolean(L, -1)) {  /* package not already loaded? */
    lua_pop(L, 1);  /* remove field */
    lua_pushcfunction(L, openf);
    lua_pushstring(L, modname);  /* argument to open function */
    lua_call(L, 1, 1);  /* call 'openf' to open module */
    lua_pushvalue(L, -1);  /* make copy of module (call result) */
    lua_setfield(L, -3, modname);  /* LOADED[modname] = module */
  }
  lua_remove(L, -2);  /* remove LOADED table */
  if (glb) {
    lua_pushvalue(L, -1);  /* copy of module */
    lua_setglobal(L, modname);  /* _G[modname] = module */
  }
}
  • 而字符串对应的模块,则通过 luaopen_string 方法,注册到 _G[“string”] 中,其中的方法有:
// lstrlib.c

static const luaL_Reg strlib[] = {
  {"byte", str_byte},
  {"char", str_char},
  {"dump", str_dump},
  {"find", str_find},
  {"format", str_format},
  {"gmatch", gmatch},
  {"gsub", str_gsub},
  {"len", str_len},
  {"lower", str_lower},
  {"match", str_match},
  {"rep", str_rep},
  {"reverse", str_reverse},
  {"sub", str_sub},
  {"upper", str_upper},
  {"pack", str_pack},
  {"packsize", str_packsize},
  {"unpack", str_unpack},
  {NULL, NULL}
};

string.format

  • string.format (formatstring, ···)
    • 返回不定数量参数的格式化版本,格式化串为第一个参数(必须是一个字符串)。
// lstrlib.c

static int str_format (lua_State *L) {
  int top = lua_gettop(L);
  int arg = 1;
  size_t sfl;
  const char *strfrmt = luaL_checklstring(L, arg, &sfl);
  const char *strfrmt_end = strfrmt+sfl;
  luaL_Buffer b;
  luaL_buffinit(L, &b);
  while (strfrmt < strfrmt_end) {
    if (*strfrmt != L_ESC)
      luaL_addchar(&b, *strfrmt++);
    else if (*++strfrmt == L_ESC)
      luaL_addchar(&b, *strfrmt++);  /* %% */
    else { /* format item */
      char form[MAX_FORMAT];  /* to store the format ('%...') */
      int maxitem = MAX_ITEM;
      char *buff = luaL_prepbuffsize(&b, maxitem);  /* to put formatted item */
      int nb = 0;  /* number of bytes in added item */
      if (++arg > top)
        return luaL_argerror(L, arg, "no value");
      strfrmt = scanformat(L, strfrmt, form);
      switch (*strfrmt++) {
        case 'c': {
          nb = l_sprintf(buff, maxitem, form, (int)luaL_checkinteger(L, arg));
          break;
        }
        case 'd': case 'i':
        case 'o': case 'u': case 'x': case 'X': {
          lua_Integer n = luaL_checkinteger(L, arg);
          addlenmod(form, LUA_INTEGER_FRMLEN);
          nb = l_sprintf(buff, maxitem, form, (LUAI_UACINT)n);
          break;
        }
        case 'a': case 'A':
          addlenmod(form, LUA_NUMBER_FRMLEN);
          nb = lua_number2strx(L, buff, maxitem, form,
                                  luaL_checknumber(L, arg));
          break;
        case 'f':
          maxitem = MAX_ITEMF;  /* extra space for '%f' */
          buff = luaL_prepbuffsize(&b, maxitem);
          /* FALLTHROUGH */
        case 'e': case 'E': case 'g': case 'G': {
          lua_Number n = luaL_checknumber(L, arg);
          addlenmod(form, LUA_NUMBER_FRMLEN);
          nb = l_sprintf(buff, maxitem, form, (LUAI_UACNUMBER)n);
          break;
        }
        case 'p': {
          const void *p = lua_topointer(L, arg);
          if (p == NULL) {  /* avoid calling 'printf' with argument NULL */
            p = "(null)";  /* result */
            form[strlen(form) - 1] = 's';  /* format it as a string */
          }
          nb = l_sprintf(buff, maxitem, form, p);
          break;
        }
        case 'q': {
          if (form[2] != '\0')  /* modifiers? */
            return luaL_error(L, "specifier '%%q' cannot have modifiers");
          addliteral(L, &b, arg);
          break;
        }
        case 's': {
          size_t l;
          const char *s = luaL_tolstring(L, arg, &l);
          if (form[2] == '\0')  /* no modifiers? */
            luaL_addvalue(&b);  /* keep entire string */
          else {
            luaL_argcheck(L, l == strlen(s), arg, "string contains zeros");
            if (!strchr(form, '.') && l >= 100) {
              /* no precision and string is too long to be formatted */
              luaL_addvalue(&b);  /* keep entire string */
            }
            else {  /* format the string into 'buff' */
              nb = l_sprintf(buff, maxitem, form, s);
              lua_pop(L, 1);  /* remove result from 'luaL_tolstring' */
            }
          }
          break;
        }
        default: {  /* also treat cases 'pnLlh' */
          return luaL_error(L, "invalid conversion '%s' to 'format'", form);
        }
      }
      lua_assert(nb < maxitem);
      luaL_addsize(&b, nb);
    }
  }
  luaL_pushresult(&b);
  return 1;
}
  • string.format 的主要流程为:
    • 从栈上读取字符串,得到字符串的起止地址。
    • 创建一个 buffer ,用于存放新的字符串。
    • 从字符串的起始地址开始,处理每个字符。
      • 如果字符不为 % ,则写入 buffer 中。
      • 如果字符为 % :
        • 下一个字符也为 %,即 %%(转义字符),则写入一个 % ,并跳过下一个字符。
        • 根据下一个字符的字母类型,将参数进行格式检查后,写入 buffer 中。
    • 将 buffer 字符串创建一个 TString 对象,并压入栈上。
  • 可以看到,lua 的 string.format 是按顺序拼入参数的,无法像 C# 一样指定参数拼入位置。

string.gsub

  • string.gsub (s, pattern, repl [, n])
    • 将字符串 s 中,所有的(或是在 n 给出时的前 n 个)pattern 都替换成 repl ,并返回其副本。 repl 可以是字符串、表、或函数。 gsub 还会在第二个返回值返回一共发生了多少次匹配。
      • repl 为 string 字符串 :这个字符串作为替换字符串。其中,字符 % 是一个转义符, repl 中的所有 %d 表示 第 d 个捕获到的子字符串,d 可以是 1 到 9 , %0 表示整个匹配, %% 表示单个 %。
      • repl 为 table 表 :每次匹配时都会用第一个捕获的字符串作为 key 去查这张表。
      • repl 为 function 函数 :每次匹配发生时都会调用这个函数,所有捕获到的子串依次作为参数传入。
// lstrlib.c

static int str_gsub (lua_State *L) {
  size_t srcl, lp;
  const char *src = luaL_checklstring(L, 1, &srcl);  /* subject */
  const char *p = luaL_checklstring(L, 2, &lp);  /* pattern */
  const char *lastmatch = NULL;  /* end of last match */
  int tr = lua_type(L, 3);  /* replacement type */
  lua_Integer max_s = luaL_optinteger(L, 4, srcl + 1);  /* max replacements */
  int anchor = (*p == '^');
  lua_Integer n = 0;  /* replacement count */
  int changed = 0;  /* change flag */
  MatchState ms;
  luaL_Buffer b;
  luaL_argexpected(L, tr == LUA_TNUMBER || tr == LUA_TSTRING ||
                   tr == LUA_TFUNCTION || tr == LUA_TTABLE, 3,
                      "string/function/table");
  luaL_buffinit(L, &b);
  if (anchor) {
    p++; lp--;  /* skip anchor character */
  }
  prepstate(&ms, L, src, srcl, p, lp);
  while (n < max_s) {
    const char *e;
    reprepstate(&ms);  /* (re)prepare state for new match */
    if ((e = match(&ms, src, p)) != NULL && e != lastmatch) {  /* match? */
      n++;
      changed = add_value(&ms, &b, src, e, tr) | changed;
      src = lastmatch = e;
    }
    else if (src < ms.src_end)  /* otherwise, skip one character */
      luaL_addchar(&b, *src++);
    else break;  /* end of subject */
    if (anchor) break;
  }
  if (!changed)  /* no changes? */
    lua_pushvalue(L, 1);  /* return original string */
  else {  /* something changed */
    luaL_addlstring(&b, src, ms.src_end-src);
    luaL_pushresult(&b);  /* create and return new string */
  }
  lua_pushinteger(L, n);  /* number of substitutions */
  return 2;
}
  • str_gsub 的主要流程为:
    • 检查参数 1 和参数 2 是否为字符串。
    • 获取参数 3 的类型。
    • 获取参数 4 ,作为替换的次数,如果没有传入则使用主字符串长度 + 1,即全部替换。
    • 创建一个 buffer ,用于存放新的字符串。
    • 调用 match 方法,根据参数 2 进行正则匹配。
      • 匹配成功,得到一个匹配结果字符串的起止位置信息,调用 add_value 方法,将匹配结果根据参数 3 的类型将替换字符串写入 buffer 中。
        • string :根据匹配结果的起止位置信息,从源字符串拷贝到 buffer 中。
        • function :根据匹配结果的起止位置信息,将参数 3 的函数压入栈上,为匹配结果创建一个字符串压入栈上,执行方法,并将结果写入 buffer 中,如果失败则使将源字符串对应位置写入。
        • table :根据匹配结果的起止位置信息,为匹配结果创建一个字符串压入栈上,根据栈上的 key 对 table 进行查询,将查询结果写入 buffer 中,如果失败则使将源字符串对应位置写入。
      • 匹配失败,则将当前位置字符写入 buffer 中,移动一个字符位置,重新进行匹配,直到达到字符串的结尾。
    • 如果源字符串没有一次匹配成功,则直接返回源字符串和匹配次数结果。如果有一次以上匹配成功,则将 buffer 创建一个新的字符串返回,并返回匹配次数结果。
  • str_gsub 传入字符串替换的过程中,基本不会产生新的字符串。然而传入函数或表进行替换,由于需要将匹配的字符串结果作为函数参数或表查询键值,所以需要为其创建新的字符串,即在此过程中会产生临时字符串。因此,使用的时候需要注意,尽量避免出现大量临时长字符串出现,引起内存压力。

总结

  • lua 的字符串并非直接为 c 中的字符串,而是一个自定义的 TString 类型,有长短字符串的区分。对于不同类型的字符串,有不同的全局缓存方式。由于字符串的使用频率非常高,而且相比其他类型,字符串对内存的占用比较多。因此,了解 lua 字符串的实现细节,能更好地对内存大小进行控制。