深入理解C语言中的用户自定义类型:从结构体到位操作的应用 | 南锋

南锋

南奔万里空,脱死锋镝余

深入理解C语言中的用户自定义类型:从结构体到位操作的应用

下面这个示例实现了一种很简单的类型,即布尔类型。选用这个示例的只要动机在于它不涉及复杂的算法,便于我们专注于API的问题。不过尽管如此,这个示例本身还是很有用的。当然,我们可以在Lua中用来表示实现布尔数组。但是,在C语言实现中,可以将每个布尔值存储在一个比特中,所使用的内存量不到使用表方法的3%。

这个示例需要以下定义:

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

BITS_PER_WORD表示一个无符号整型数的位数,宏I_WORD用于根据指定的索引来计算存放相应比特位的字,I_BIT用于计算访问这个字中相应比特位要用的掩码。
我们可以使用一下的结构体来表示布尔数组:

1
2
3
4
typedef struct BitArray{
int size;
unsigned int values[1];
}BitArray;

由于C89标准不允许分配长度为零的数组,所以我们声明数组values的大小为1,仅有一个占位符;等分配数组时,我们再设置数组的实际大小。下面这个表达式可以计算出拥有n个元素的数组大小:

1
sizeof(BitArray) * I_WORD(n -1) * sizeof(unsigned int)

此处n减去1是因为原结构中已经包含了一个元素的空间。

用户数据

在第一个版本中,我们使用显示的调用来设置和获取值,如下所示:

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

后续我们将介绍如何同时支持像a:get(i)这样的面向对象风格和像a[i]这样的常见语法。
在所有版本中,下列函数是一样的,参加示例。

示例 操作布尔数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
static int newarray(lua_State *L){
int i ;
size_t nbytes;
BitArray *a;

int n = (int)luaL_checkinteger(L,1);
luaL_argcheck(L, n >= 1, 1, "invalid size");
nbytes = sizeof(BitArray) + I_WORD(n-1)*sizeof(unsigned int);
a = (BitArray *)lua_newuserdata(L,nbytes);
a -> size = n;
for (i = 0; i <= I_WORD(n-1); i++)
a -> values[i] = 0;

return 1;
}

static int setarray(lua_State *L){
BitArray *a = (BitArray *)lua_touserdata(L,1);
int index = (int)luaL_checkinteger(L,2) - 1;

luaL_argcheck(L,a != NULL , 1, "'array' expected");
luaL_argcheck(L,0 <= index && index < a->size, 2 "index out of range");

luaL_checkany(L,3);

if (lua_toboolean(L,3))
a -> values[I_WORD(index)] |= I_BIT(index);
else
a -> values[I_WORD(index)] &= ~I_BIT(index);

return 0;
}

static int getarray (lua_State *L){
BitArray *a = (BitArray *)lua_touserdata(L,1);
int index = (int)luaL_checkinteger(L,2) - 1;

luaL_argcheck(L,a != NULL, 1, "'array' expected");
luaL_argcheck(L,0 <= index && index < a->size , 2, "index out of range");

lua_pushboolean(L, a-> values[I_WORD(index)] & I_BIT(index));
return 1;
}

下面让我们一点一点地分析。
我们首先关心的是如何在Lua中表示一个C语言结构体。Lua语言专门为这类任务提供了一个名为用户数据的剧本类型。用户数据为Lua语言提供了可以用来存储任何数据的原始内存区域,没有预定义的操作。
函数lua_newuserdata分配一块指定大小的内存,然后将相应的用户数据压栈,并返回该块内存的地址:

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

如果因为一些原因需要用其他方法来分配内存,可以很容易地创建一个指针大小的用户数据并在其中存储一个指向真实内存块的指针。
示例中第一个函数newarray使用lua_newuserdata创建新的数组。newarray的代码很简单,它检查了其唯一的参数,以字节为单位计算出数组的大小,创建了一个适当大小的用户数据,初始化用户数据的各个字段并将其返回给Lua。
第二函数是setarray,它有三个参数:数组、索引和新的值。setarray假定数组索引像Lua语言中的那样是从1开始的。因为Lua可以将任意值当做布尔类型,所以我们用luaL_checkany检查第三个参数,不过luaL_checkany只能确保该参数有一个值。如果用不符合条件的参数调用了setarray,将会收到一条解释错误的信息,例如:

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

示例中的最后一个函数是getarray,该函数类似于setarray,用于获取元素。
我们还需要定义一个获取数组大小的函数和一些初始化库的额外代码,参见示例:

示例 布尔数组库的额外代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int getsize (lua_State *L){
BitArray *a = (BitArray *)lua_touserdata(L.1);
luaL_argcheck(L,a != NULL , 1, "'array' expected");
lua_pushinteger(L, a -> size);
return 1;
}

static const struct luaL_Reg arraylib[] ={
{"new",newarray},
{"set",setarray},
{"get",getarray},
{"size",getsize},
{NULL,NULL}
};

int luaopen_array (lua_State *L){
luaL_newlib(L, arraylib);
return 1;
}

我们再一次使用了辅助库中的luaL_newlib,该函数创建了一张表,并且用数组arraylib指定的”函数名-函数指针”填充了这张表。

元表

我们当前的额实现有一个重大的漏洞。假设用户泄露一条像array.set(io.stdin,1,false)这样的语句,那么io.stdin的值会是一个带有指向文件流(FILE*)的指针的用户数据,array.set会开心地认为它时一个合法的参数;其后果可能就是内存崩溃。这种行为对于任何一个Lua库而言都是不可接受的。无论你如何使用库,都不应该破坏C语言的数据,也不应该让Lua语言崩溃。
要区别不同类型的用户数据,一种常见的方法是为每种类型创建唯一的元表。每次创建用户数据时,用相应的元表进行标记;每当获取用户数据时,检查其是否有正确的元表。由于Lua代码不能改变用户数据的元表,因此不能绕过这些检查。
我们还需要有个地方来存储这个新的元表,然后才能用它来创建新的用户数据和检查指定的用户数据是否具有正确的类型。我们之前已经看到过,存储元表有两种方法,即存储在注册表中或者库函数的上值中。在Lua语言中,惯例是将所有新的C原因类型注册到注册表中,用类型名作为索引,以元表作为值。由于注册表中还有其他索引,所以必须谨慎选择类型名以避免冲突。在我们的示例中将使用”LuaBook.array”作为这个新类型的名称。
通常,辅助库会提供一些函数来帮忙实现这些内容。我们将使用新的辅助函数包括:

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

函数luaL_newmetatable会创建一张新表,然后将其压入栈顶,并将该表与注册表中的指定名称关联起来。函数luaL_getmetatable从注册表中获取与tname关联的元表。最后,luaL_checkudata会检查栈中指定位置上的对象是否是于指定名称的元表匹配的用户数据。如果该对象不是用户数数据,或者该用户数据没有正确的元表,luaL_checkudata就会引发错误;否则,luaL_checkudata就返回这个用户数据的地址。
现在让我们开始修改前面的代码。第一步是修改打开库的函数,让该函数为数组创建元表:

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

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

1
2
3
4
5
6
7
static int newarray (lua_State *L){
同前
luaL_getmatatable(L,"LuaBook.array");
lua_setmetatable(L,-2);

return 1;
}

函数lua_setmetatable会从栈中弹出一个表,并将其设置为指定索引上对象的元表。在本例中这个对象就是新建的用户数据。
最后,setarray、getarray和getsize必须检查其第一个参数是否是一个有效的数组。为了简化这项任务,我们定义如下的宏:

1
#define checkarray(L) (BitArray *)luaL_checkudata(L,1,"LuaBook.array")

有了这个宏,getsize的定义就很简单了:

1
2
3
4
5
static int getsize (lua_State *L){
BitArray *a = checkarray(L);
lua_pushinteger(L,a->size);
return 1;
}

由于setarray和getarray还共享了用来读取和检查它们的第二个参数的代码,所以我们将其通用部分提取出来组成了一个新的辅助函数(getparams)

示例 setarray/getarray的新版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static unsigned int *getparams (lua_State *L, unsigned int *mask){
BitArray *a = checkarray(L);
int index = (int)luaL_checkinteger(L,2) -1 ;
luaL_argcheck(L,0 <= index && index < a-> size, 2, "index out of range");
*mask = I_BIT(index);
return &a -> values[I_WORD(index)];
}

static int setarray (lua_State *L){
unsigned int mask;
unsigned int *entry = getparams(L,&mask);
luaL_checkany(L,3);
if (lua_toboolean(L,3))
*entry |= mask;
else
*entry &= ~mask;
reuturn 0;
}

static int getarray(lua_State *L){
unsigned int mask;
unsigned int *entry = getparams(L,&mask);
lua_pushboolean(L,*entry & mask);
return 1;
}

在这个新版本中,setarray和getarray都很简单,参见示例。现在,如果调用他们时使用了无效的用户数据,我们将会收到一条相应的错误信息:

1
2
a = array.get(io.stdin,10)
-- bad argument #1 to 'get' (LuaBook.array expected, got FILE*)

面向对象访问

下一步是将这种新类型转换成一个对象,以便用普通的面向对象语法来操作其实例。例如

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

请注意,a:size()等价于a.size(a)。因此,我们必须让表达式a.size返回函数getsize。此处的关键机制在于元方法__index。对于表而言,Lua会在找不到的指定键时调用这个元方法;而对于用户数据而言,由于用户数据根本没有键,所以Lua在每次访问时都会调用该元方法。
假设我们运行了一下代码:

1
2
3
4
5
6
7
do 
local metaarry = getmetatable(array.new(1))
metaarry.__index = metaarry
metaarry.set = array.set
metaarry.get = array.get
metaarry.size = array.sizeo
end

在第一行中,我们创建了一个数组用户获取分配给metaarray的元表(我们无法在Lua中设置用户数据的元表,但是可以获取用户数据的元表)。然后,将metaarray.__index设置为metaarray。当对a.size求值时,因为对象a是一个用户数据,所以Lua在对象a中无法找到键”size”。因此,Lua会尝试通过a的元表的__index字段来获取这个值,而这个字段正好就是metaarray。由于metaarray.size就是array.size,所以a.size(a)就是我们想要的array.size(a)。
当然,用C语言也可以达到相同的效果,甚至还可以做得更好:既然数组有自己的操作的对象,那么在表array中也就无需包含这些操作了。我们的库只需导出一个用于创建新数组的函数new就行了,所有的其他操作都变成了对象的方法。C语言代码同样可以直接注册这些方法。
操作getsize、getarray和setarray无须做任何改变,唯一需要改变的是注册它们的方式。换而言之,我们必须修改打开库的函数。首先,我们需要两个独立的函数列表,一个用户常规的函数,另一个用户方法。

1
2
3
4
5
6
7
8
9
10
11
static const struct luaL_Reg arraylib_f[] = {
{"new",newarray},
{NULL,NULL}
};

static const struct luaL_Reg arraylib_m[] = {
{"set",setarray},
{"get",getarray},
{"size",getsize},
{NULL,NULL}
};

新的打开函数luaopen_array必须创建元表,并把它赋给自己的__index字段,然后在元表中注册所有方法,创建和填充表array:

1
2
3
4
5
6
7
8
int luaopen_array (lua_State *L){
luaL+newetatable(L,"LuaBook.array"); // 创建元表
lua_pushvalue(L,-1); // 复制元表
lua_setfield(L,-2,"__index"); // mt.__index = mt
luaL_setfuncs(L,arraylib_m,0); // 注册元方法
luaL_newlib(L,arraylib_f); // 创建库
return 1;
}

这里,我们再次使用了luaL_setfuncs将列表arraylib_m中的函数赋值到栈顶的元表中。然后,调用luaL_newlib创建一张新表,并在该表中注册来自列表arraylib_f的函数。
最后,向新类型中新增一个__tostring元方法,这样print(a)就可以打印出”array”以及用括号括起来的数组的大小了。该函数如下:

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

调用lua_pushfstring格式化字符串,并将其保留在栈顶。我们还需要将array2string添加到列表arraylib_m中,以此将函数加入到数组对象的元表中:

1
2
3
4
static const struct luaL_Reg arraylib_m[] = {
{"__tostring", array2string},
other methods(其他方法)
};

数组访问

另一种更好的面向对象的表示方法是,使用普通的数组符号来访问数组。只需要简单地使用a[i]就可以替代a:get(i)。对于上面的是示例,由于函数setarray和getarray本身就是按照传递给相应元方法的参数的顺序来接收参数的,所以很容易做到这一点。一种快速的解决方案就是直接在Lua中定义这些元方法:

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

必须在数组原来的实现中运行这段代码,无须修改面向对象的访问。这样,就可以使用标准语法了:

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

如果还要更加完美,可以在C语言中注册这些元方法。为此,需要再次修改初始化函数。参见如下示例:

示例 新的初始化比特数组库的代码

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

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

##轻量级用户数据
到现在为止,我们使用的用户数据成为完全用户数据。Lua语言还提供了另一种数据,成为轻量级用户数据。
轻量级用户数据时一个代表C语言指针的值,即它时一个void*值。因为轻量级用户数据是一个值而不是一个对象,所以无须创建它(就好比我们也不需要创建数组)。要将一个轻量级用户数据放入栈中,可以调用lua_pushlightuserdata:

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

尽管名字差不多,但实际上轻量级用户数据和完全用户数据之间区别很大。轻量级用户数据不是缓冲区,而只是一个指针,它们也没有元表。与数值一样,轻量级用户数据不受垃圾收集器的管理。
有时,人们会将轻量级用户数据当做完全用户数据的一种廉价替代物来使用,但这种用法并不普遍。首先,轻量级用户数数据没有元表,因此没有办法得知其类型。其次,不要“完全”二字所迷惑,实际上完全用户数据的开销也并不大。对于给定的内存大小,完全用户数据与malloc相比值增加了一点开销。
轻量级用户数据的真正用途是相等性判断。由于完全用户数据时一个对象,因此它只和自身相等;然而,一个轻量级用户数据表示的是一个C语言指针的值。因此,它与所有表示相同指针的轻量级用户数据相等。因此,我们可以使用轻量级用户数据在Lua语言中查找C语言对象。
我们已经见到过轻量级用户数据的一种典型用法,即在注册表中被用作键。在这种情况下,轻量级用户数据的相等性是至关重要的。每次使用lua_pushlightuserdata压入相同的地址时,我们都会得到相同的Lua值,也就是注册表中相同的元素。
Lua语言中另一种典型的场景是把Lua语言对象当做对应的C语言对象的代理。例如:输入/输出库使用Lua中的用户数据来表示C语言的流。当操作时从Lua语言到C语言时,从Lua对象到C对象的映射很简单。还是以输入/输出库为例,每个Lua语言流会保存指向其相应C语言流的指针。不过,当操作时从C语言到Lua语言时,这种映射就可能比较棘手。例如,假设在输入/输出系统中有某些回调函数(例如,那些告诉我们还有多少数据需要被读取的函数),回调函数接收它要操作的C语言流,那么如何从中得到其相应的Lua对象呢?由于C语言流是由C语言标准库定义的而不是我们定义的,因此无法在C语言流中存储任何东西。
轻量级用户数据为这种映射提供了一种好的解决方案。我们可以保存一张表,其中键是带有流地址的轻量级用户数据,值是Lua中表示流的完全用户数据。在回调函数中,一旦有了流地址,就可以将其作为轻量级用户数据,把它当做这张表的索引来获取对应的Lua对象(这张表很可能得事弱引用的;否则,这些完全用户数据可能永远不会被作为垃圾回收)。

+