用户自定义类型

  本章将介绍如何用C语言编写新的类型来扩展Lua。下面将从一个小示例入手,使用元表和其他机制来扩展它。

  这个示例实现了一种很简单的类型—布尔数组。选用这个示例是因为它不涉及到复杂的算法,从而可以使读者专注于API的问题。不过,这个示例本身还是具有实用价值的。当然,可以在Lua中用table来实现布尔数组。但C语言实现可以将每个布尔值存储在一个bit中,从而将内存用量减少到不足table方法的3%

  这个实现需要以下定义:

  1. #include <limits.h>
  2. #define BITS_PER_WORD (CHAR_BIT* sizeof(unsigned int))
  3. #define I_WORD(i) ((unsigned int)(i)/BITS_PER_WORD)
  4. #define I_BIT(i) (1 << ((unsigned int)(i) % BITS_PER_WORD))

  BITS_PER_WORD是一个无符号整型的bit数量。宏I_WORD根据给定的索引来计算对应的bit位所存放的word(字),I_BIT计算出一个掩码,用于访问这个word中的正确bit

  可以使用以下结构来表示数组:

  1. typedef struct NumArray {
  2. int size;
  3. unsigned int values[1]; /* 可变部分 */
  4. } NumArray

  这里。由于C89标准不允许分配0长度的数组,所以声明了数组values需要有一个元素来作为占位符。接下来会在分配数组时定义实际的大小。下面这个表达式可以计算出具有n个元素的数组大小:

  1. sizeof(NumArray) + I_WORD(n - 1)*sizeof(unsigned int)

  注意,这里无须对I_WORD加1,因为原来的结构中已经包含了一个元素的空间。

  

  userdata

  首先要面临的问题是如何在Lua中表示这个NumArray结构。Lua为此提供了一种基本类型userdatauserdata提供了一块原始的内存区域,可以用来存储任何东西。并且,在Lua中userdata没有任何预定义的操作。

  函数lua_newuserdata会根据指定的大小分配一块内存,并将对应的userdata压入栈中,最后返回这个内存块的地址:

  1. void *lua_newuserdata(lua_State *L, size_t size);

  如果由于某些原因,需要通过其他机制来分配内存。那么可以创建只有一个指针大小的userdata,然后将指向真正内存块的指针存入其中。在下一章中就有这样的例子。

  以下函数就用lua_newuserdata创建了一个新的布尔数组:

  1. static int newarray(lua_State *L) {
  2. int i, n;
  3. size_t nbytes;
  4. NumArray *a;
  5. n = luaL_checkint(L, 1);
  6. luaL_argcheck(L, n >= 1, 1, "invalid size")
  7. nbytes = sizeof(NumArray) + I_WORD(n - 1)*sizeof(unsigned int);
  8. a = (NumArray *)lua_newuserdata(L, nbytes);
  9. a->size = n;
  10. for(i = 0; i <= I_WORD(n-1); i++)
  11. a->values[i] = 0; /* 初始化数组 */
  12. return 1; /* 新的userdata已在栈上 */
  13. }

  其中,宏luaL_checkint只是在调用luaL_checkinteger后进行了一个类型转换。只要在Lua中注册好newarray,就可以通过语句a=array.new(1000)来创建一个新数组。

  可以通过这样的调用array.set(array, index, value),在数组中存储元素。后面的内容会介绍如何使用元表来实现更传统的语法array[index]=value。无论哪种写法,底层函数都是相同的。

  下面将遵循Lua惯例,假设索引从1开始。

  1. static int setarray(lua_State *L)
  2. NumArray *a = (NumArray *)lua_touserdata(L, 1);
  3. int index = luaL_checkint(L, 2) - 1;
  4. luaL_checkany(L, 3);
  5. luaL_argcheck(L, a != NULL, 1, "'array' expected");
  6. luaL_argcheck(L, 0 <= index && index < a->size, 2, "index out of range");
  7. if(lua_toboolean(L, 3))
  8. a->values[I_WORD(index)] |= I_BIT(index); /* 设置bit */
  9. else
  10. a->values[I_WORD(index)] &= ~I_BIT(index); /* 重置bit */
  11. return 0;

  由于Lua中任何值都可以转换为布尔,所以这里对第3个参数使用luaL_checkany,它只确保了在这个参数位置上有一个值。如果用错误的参数调用了setarray,就会得到这样的错误消息:

  1. array.set(0, 11, 0)
  2. --> stdin:1: bad argument #1 to 'set' ('array' expected)
  3. array.set(a, 0)
  4. --> stdin:1: bad argument #3 to 'set' (value expected)

  下一个函数用于检索元素:

  1. static int getarray(lua_State *L) {
  2. NumArray *a = (NumArray *)lua_touserdata(L, 1);
  3. int index = luaL_checkint(L, 2) - 1;
  4. luaL_argcheck(L, a != NULL, 1, "'array' expected");
  5. lua_pushboolean(L, a->values[I_WORD(index)] & I_BIT(index));
  6. return 1;
  7. }

  下面还定义了一个函数用于检索一个数组的大小:

  1. static int getsize(lua_State *L) {
  2. NumArray *a = (NumArray *)lua_touserdata(L, 1);
  3. luaL_argcheck(L, a != NULL, 1, "'array' expected");
  4. lua_pushinteger(L, a->size);
  5. return 1;
  6. }

  最后,需要一些代码来初始化这个库:

  1. static const struct luaL_Reg arraylib [] = {
  2. {"new", newarray},
  3. {"set", setarray},
  4. {"get", getarray},
  5. {"size", getsize},
  6. {NULL, NULL}
  7. };
  8. int luaopen_array(lua_State *L) {
  9. luaL_register(L, "array", arraylib);
  10. return 1;
  11. }

  同样其中用到了辅助库的luaL_register,它会根据给定的名称(本例中为“array”)创建一个table,并用数组arraylib中指定的名称/函数对来填充它。

  在打开库后,就可以在Lua中使用这个新类型了:

  1. a = array.new(1000)
  2. print(a) --> userdata: 0x8064d48
  3. print(array.size(a)) --> 1000
  4. for i = 1, 1000 do
  5. array.set(a, i, i%5 == 0)
  6. end
  7. print(array.get(a, 10)) --> true

  

  元表

  当前的实现有一个重大的安全漏洞,假定用户写了这样的语句array.set(io.stdin, 1, false)io.stdin的值是一个userdata,是一个文件流指针(FILE *)。由于这是一个userdataarray.set会认为它是一个合法的参数,结果就使内存遭到破坏(如果幸运的话,可能会得到一个索引超出范围的错误)。但对于有些Lua库来说,这种行为是不可接受的。问题的原因不在于如何使用一个C程序库,而在于程序库不应破坏C数据或在Lua中导致核心转储(Core Dump)。

  一种辨别不同类型的userdata的方法是,为每种类型创建一个唯一的元表。每当创建了一个userdata后,就用相应的元表来标记它。而每当得到一个userdata后,就检查它是否拥有正确的元表。由于Lua代码不能改变userdata的元表,因此也就无法欺骗代码了。

  另外还需要有个地方来存储这个新的元表,然后才能用它来创建新的userdata,并检查给定的userdata是否具有正确的类型。在前面已提到过,有三个候选地可用于存储元表:注册表、环境或程序库中函数的upvalue。在Lua中,通常习惯是将所有新的C类型注册到注册表中,以一个类型名作为key,元表作为value。由于注册表中还有其他的内容,所以必须小心地选择类型名,以避免与key冲突。在示例中,将使用“LuaBook.array”作为其新类型的名称。

  通常,辅助库中提供了一些函数来帮助实现这些内容。可以使用的辅助库函数有:

  1. int luaL_newmetatable(lua_State *L, const char *tname);
  2. void luaL_getmetatable(lua_State *L, const char *tname);
  3. void *luaL_checkudata(lua_State *L, int index, const char *tname);

  luaL_newmetatable函数会创建一个新的table用作元表,并将其压入栈顶,然后将这个table与注册表中的指定名称关联起来。luaL_getmetatable函数可以在注册表中检索与tname关联的元表。luaL_checkudata可以检查栈中指定位置上是否为一个userdata,并且是否具有与给定名称相匹配的元表。如果该对象不是一个userdata,或者它不具有正确的元表,就会引发一个错误;否则,它就返回这个userdata的地址。

  现在可以修改前面的实现了。第一步是修改打开库的函数。新版本必须为数组创建一个元表:

  1. int luaopen_array(lua_State *L) {
  2. luaL_newmetatable(L, "LuaBook.array");
  3. luaL_register(L, "array", arraylib);
  4. return 1;
  5. }

  下一步是修改newarray,使其能为所有新建的数组设置这个元表:

  1. static int newarray(lua_State *L) {
  2. <如前>
  3. luaL_getmetatable(L, "LuaBook.array");
  4. lua_setmetatable(L, -2);
  5. return 1; /* 新的userdata已在栈中 */
  6. }

  lua_setmetatable函数会从栈中弹出一个table,并将其设为指定索引上对象的元表。在本例中,这个对象就是一个新建的userdata

  最后,setarraygetarraygetsize必须检查其第一个参数是否为一个合法的数组。为了简化这样的任务,定义了以下宏:

  1. #define checkarray(L) \
  2. (NumArray *)luaL_checkudata(L, 1, "LuaBook.array")

  使用这个宏getsize同样简单:

  1. static int getsize(lua_State *L)
  2. NumArray *a = checkarray(L);
  3. lua_pushinteger(L, a->size);
  4. return 1;

  由于setarraygetarray使用了一段相同的代码来检查第二个索引参数,所以将这段公共部分单独组成以下函数:

  1. static unsigned int *getindex(lua_State *L, unsigned int *mask)
  2. {
  3. NumArray *a = checkarray(L);
  4. int index = luaL_checkint(L, 2) - 1;
  5. luaL_argcheck(L, 0 <= index && index < a->size, 2,
  6. "index out of range");
  7. /* 返回元素地址 */
  8. *mask = I_BIT(index);
  9. return &a->values[I_WORD(index)];
  10. }

  使用了getindexsetarraygetarray也同样简单明了:

  1. static int getarray(lua_State *L) {
  2. unsigned int mask;
  3. unsigned int *entry = getindex(L, &mask);
  4. luaL_checkany(L, 3);
  5. if(lua_toboolean(L, 3))
  6. *entry |= mask;
  7. else
  8. *entry &= ~mask;
  9. return 0;
  10. }
  11. static int getarray(lua_State *L) {
  12. unsigned int mask;
  13. unsigned int *entry = getindex(L, &mask);
  14. lua_pushboolean(L, *entry & mask);
  15. return 1;
  16. }

  现在,如果试图这样调用array.get(io.stdin, 10),就会得到一个正确的错误消息:

  1. error: bad argument #1 to 'get' ('array' expected)

  

   面向对象的访问

  下一步是将这种类型变换成一个对象,然后就可以用普通的面向对象语法来操作它的实例了。例如:

  1. a = array.new(1000)
  2. print(a:size()) --> 1000
  3. a:set(10, true)
  4. print(a:get(10)) --> true

  注意,a:size()等价于a.size(a)。因此,必须使表达式a.size返回前面定义的函数getsize。实现这点的关键是使用__index元方法。对于table而言,Lua会在找不到指定key时调用这个元方法。对于userdata,则会在每次访问时都调用它,因为userdata根本没有key

  假设,运行了以下代码:

  1. local metaarray = getmetatable(array.new(1))
  2. metaarray.__index = metaarray
  3. metaarray.set = array.set
  4. metaarray.get = array.get
  5. metaarray.size = array.size

  第一行创建了一个数组,并将它的元表赋予了metaarray。然后将metaarray.index设为metaarray。当对a.size求值时,由于a是一个userdata,所以Lua无法在对象a中找到key "size"。因此,Lua会尝试通过a的元表的index字段来查找这个值,而这个字段也就是metaarray自身。由于metaarray.sizearray.size,因此a.size(a)的结果就是array.size(a)

  其实,在C中也可以达到相同的效果,甚至还可以做得更好。现在的数组是一种具有操作的对象,可以无须在table array中保存这些操作。程序库只要导出一个用于创建新数组的函数new就可以了,所有其他操作都可作为对象的方法。C代码同样可以直接注册这些方法。

  操作getsizegetarraysetarray无须作任何改变,唯一需要改变的是注册它们的方式。现在,需要修改打开程序库的函数。首先,需要设置两个独立的函数列表,一个用于常规的函数,另一个用于方法:

  1. static const struct luaL_Reg arraylib_f[] = {
  2. {"new", newarray},
  3. {NULL, NULL}
  4. };
  5. static const struct luaL_Reg arraylib_m[] = {
  6. {"set", setarray},
  7. {"get", getarray},
  8. {"size", getsize},
  9. {NULL, NULL}
  10. };

  新的打开函数luaopen_array必须创建元表,并将它赋予__index字段,然后在元表中注册所有的方法,最后创建并填充array table

  1. int luaopen_array(lua_State *L) {
  2. luaL_newmetatable(L, "LuaBook.array");
  3. /* 元表.__index = 元表 */
  4. lua_pushvalue(L, -1); /* 复制元表 */
  5. luaL_register(L, NULL, arraylib_m);
  6. luaL_register(L, "array", arraylib_f);
  7. return 1;
  8. }

  其中用到了luaL_register的另一个特性。在第一次调用中,以NULL作为库名,luaL_register不会创建任何用于存储函数的table,而是以栈顶的table作为存储函数的table。在本例中,栈顶table就是元表本身,因此luaL_register会将所有的方法放入其中。第二次调用luaL_register则提供了一个库名,它就根据此名(array)创建了一个新table,并将指定的函数注册在这个table中(也就是本例中唯一的new函数)。

  最后,给这个数组类型添加一个__tostring方法。使print(a)可以打印出“array”以及数组的大小,就像“array(1000)”。这个函数如下:

  1. int array2string(lua_State *L) {
  2. NumArray *a = checkarray(L);
  3. lua_pushfstring(L, "array(%d)", a->size);
  4. return 1;
  5. }

  lua_pushfstring可以在栈顶创建并格式化一个字符串。另外,还需要将array2string加到列表arraylib_m中,从而将这个函数加入数组对象的元表。

  1. static const struct luaL_Reg arraylib_m[] = {
  2. {"__tostring", array2string},
  3. <其他方法>
  4. };

  

  数组访问

  另一种面向对象写法是使用常规的数组访问写法。相对于a:get(i),可以简单地写为a[i]。对于上面的示例,很容易可以做到这点。由于函数setarraygetarray所接受的参数次序暗合相关元方法的参数次序,因此在Lua代码中可以快速地将这些元方法定义为:

  1. local metaarray = getmetatable(array.new(1))
  2. metaarray.__index = array.get
  3. metaarray.__newindex = array.set
  4. metaarray.__len = array.size

必须在第一个数组实现上运行这段代码,而不能应用于那个为面向对象访问而修改的版本。使用这些标准语法很简单:

  1. a = array.new(1000)
  2. a[10] = true -- setarray
  3. print(a[10]) -- getarray --> true
  4. print(#a) -- getsize --> 1000

  如果还要更完美,可以在C代码中注册这些方法。为此,需要再次修改初始化函数:

  1. static const struct luaL_Reg arraylib_f [] = {
  2. {"new", newarray},
  3. {NULL, NULL}
  4. };
  5. static const struct luaL_Reg arraylib_m [] = {
  6. {"__newindex", setarray},
  7. {"__index", getarray},
  8. {"__len", getsize},
  9. {"__tostring", array2string},
  10. {NULL, NULL}
  11. };
  12. int luaopen_array(lua_State *L) {
  13. luaL_newmetatable(L, "LuaBook.array");
  14. luaL_register(L NULL, arraylib_m);
  15. luaL_register(L, "array", arraylib_f);
  16. return 1;
  17. }

  在这个新版本中,仍只有一个公共函数new。所有其他函数都作为特定操作的元方法。

  

   轻量级userdata(light userdata)

  到现在为止所使用的userdata都称为“完全userdata(full userdata)”。Lua还提供另一种“轻量级userdata(light userdata)”。

  轻量级userdata是一种表示C指针的值(即void *)。由于它是一个值,所以不用创建它。要将一个轻量级userdata放入栈中,只需调用lua_pushlightuserdata即可:

  1. void lua_pushlightuserdata(lua_State *L, void *p);

  尽管两种userdata在名称上差不多,但它们之间还是存在很大不同的。轻量级userdata不是缓冲,只是一个指针而已。它也没有元表,就像数字一样,轻量级userdata无须受垃圾收集器的管理。

  有时会将轻量级userdata当作一种廉价的完全userdata来使用。但这种用法并没有太大意义。首先,使用轻量级userdata时用户必须自己管理内存,因为轻量级userdata不属于垃圾收集的范畴。其次,不要被“完全”二字所迷惑,完全userdata的开销并不比轻量级userdata大多少。它们只为分配内存增加了一些malloc的开销。

  轻量级userdata的真正用途是相等性判断。一个完全userdata是一个对象,它只与自身相等。而一个轻量级userdata则表示了一个C指针的值。因此,它与所有表示同一个指针的轻量级userdata相等。可以将轻量级userdata用于查找Lua中的C对象。

  以下是一种比较典型的情况,假设正在实现一种Lua与某个窗口系统的绑定。在这种绑定中,用完全userdata表示窗口。每个userdata可以包含整个窗口的数据结构,也可以只包含一个指向系统所创建窗口的指针。当在一个窗口中发生了一个事件时(例如单击鼠标),系统要调用对应于该窗口的回调函数。而窗口是通过其地址来识别的。为了调用Lua中实现的回调函数,必须先找到表示指定窗口的userdata。若要寻找这个userdata,可以用一个table来保存窗口的信息,它的key是表示窗口地址的轻量级userdata,而value则是表示窗口本身的完全userdata。当得到一个窗口地址时,就可以把它作为一个轻量级userdata压入栈中,并用这个userdata来索引table。从而得到那个表示窗口本身的完全userdata,并由此调用回调函数。

?