HTML文本解析器C模块 for LUA

  因为工作需要,这些天花时间把我之前的C++库 liigo::HtmlParser 封装成了LUA语言的C模块,代码已开源:https://github.com/liigo/htmlua。在此之前,我已经将近10年没有接触LUA了。仔细的阅读了官方文档,才勉强回忆起一部分。经过这次开发实践,又总结了一些经验,在本文中稍作记录。


作者:Liigo(庄晓立),2013年9月。

原创链接:http://blog.csdn.net/liigo/article/details/11619121

版权所有,转载请注明出处:http://blog.csdn.net/liigo


栈(Stack)

  用C语言开发LUA的C模块,关键是理解它的基于栈(Stack)的参数传递机制。无论是LUA调用C函数,还是C调用LUA函数,都是通过栈传递参数和返回值。LUA语言里面的各种值,在C语言里没有相应的表示,也就是说,C语言不能直接持有LUA对象的值,只能通过栈和栈上的某个索引,间接的操作LUA对象。LUA SDK提供的API函数,有许多都是用来操作栈的,入栈、出栈、数据类型转换等等。这里说的栈,是LUA SDK里定义一种数据结构和操作机制,与汇编语言里的栈有相似的地方,但也颇为不同。其中最大的不同是,针对每次(每系列)函数调用,Lua都分别使用一个独立的栈,而不是像其他语言那样每个线程使用一个共享的栈。LUA的栈有一个很大的优势是,方便通过栈索引引用栈内对象,无论是从栈顶还是栈底索引都很方便。例如,栈索引1处为被调用函数的参数1,索引2处为参数2,索引-1为栈顶。

函数(lua_CFunction)

  C模块里定义的LUA函数,其函数原型都统一定义为 int lua_CFunction(lua_State* L); 当在LUA里调用C函数时,首先参数从左至右依次入栈(参数1在索引1处,参数2在索引2处……),调用进入C函数后,通过C API函数从栈内读取参数并转换为相应的C值,进行各种运算后,再把返回值压入栈中,最后返回“返回值的数目”(LUA支持多返回值)。

  LUA的C模块,需要编译为动态链接库,xxx.dll,并且要至少公开一个函数:luaopen_xxx()。DLL文件名中的xxx必修跟公开函数名称中的xxx保持一致,全小写字母。luaopen_xxx()的函数原型也是上面提到的lua_CFunction,函数调用约定是C语言默认的cdecl,在Visual Studio C++中通常借助.def文件导出该函数。

Table 还是 Userdata?

  开发者往往希望把功能类似的函数分类,或抽象出有方法(Methods)的对象,便于管理也便于用户使用。例如Lua核心库里操作文本的函数string.xxx都在string名下,还可以写为 s:len()(等价于string.len(s)),有点类似java/C++里调用对象方法的味道。那么,Lua抽象的对象,用什么类型来表示呢?无非有table、usertable、light usertable三种选择。light userdata不能独立设置元表metatable(只能所有light userdata共享同一个metatable),自然也不能独立添加对象成员方法,被首先排除。剩下的table和userdata选谁呢?我(Liigo)选的是table,理由是1、方便;2、正统(例如Lua核心库里的string/math/package/os等等都是用的table)。有些人一想到自定义类型就想当然的使用userdata,但我分析后认为userdata并没有明显的优势。Lua负责自动分配userdata内存?可多少情况下C/C++对象都是自己分配内存创建对象,一个4字节或8字节的userdata里面存个指针似乎也没体现出好处。

  userdata实现对象封装、加入成员方法,需要这样处理:把C/C++指针存到userdata自己分配的内存里,创建一个table并将其设置为userdata的metatable,调用lua_setfield()给该metatable添加函数类型的成员(设为x, y, z...),特别的,给metatable添加一个__index成员,其值为该metatable自身,最后在方法的实现函数内调用lua_touserdata(L,1)取回C/C++指针进行后续操作。只有这样才能达到目的:u:x() 等价于 u.x(u) 等价于 u["x"](u) 等价于 u.metatable.__index.x(u) 等价于 u.metatable.x(u)。中间这几层逻辑关系挺绕人的,要想透彻理解有一点难度。提示一点:对对象下标求值时(u.x或u["x"]),metatable内的__index将生效,lua规定__index可以是函数或table,前面应用的就是__index为table的情况。总之这个办法相对复杂,需要处理的细节较多,需要记忆的内容也多,实践中容易出错。

  table实现对象封装、加入成员方法,就简单许多:创建一个table t作为被封装的对象,C/C++指针就放在t[0]处(lua_pushlightuserdata(L,p), lua_rawseti(L,-2,0)),调用lua_setfield()给table t添加函数类型的成员(设为x, y, z...),在方法实现函数内读取t[0](lua_touserdata(L,1))拿到C/C++指针进行后续操作。目的达成:t:x() 等价于 t.x(t) 等价于 t["x"](t)。

  关于类型检查的处理。Lua本身是不检查参数类型的,如果用户错误的传入一个string你拿来当自己的自定义类型,肯定是要运行出错了。基于Lua的惯用法,此时应该检查参数类型,发现错误的话通过调用lua_error()明确的向用户报告错误。userdata版方案怎么检查类型?可以事先在创建对象时往metatable里加入一个特定名称和/或特定值的成员,比如metatable.$type="type1"或者metatable[0]="type1",需要检查类型时,判断是userdata,取metatable非空,取特定成员值判断是否一致。table版方案怎么检查类型?与前面类似,可以事先在创建table t对象时加入一个特定名称和/或特定值的成员,比如t.$type="type2"或者t[1]="type2",需要检查类型时,判断是table,取特定成员值看是否一致。两者的处理本质上是一样的,但是还是table版方案简洁一点,不需要取metatable并判断是否为空这一步骤。

  如果担心用户向基于table的对象成员赋值扰乱内部状态(尤其是t[0]保存有C/C++指针绝不容许用户自行更改),可以借助__newindex元方法予以阻止。

全局名称

  进入 lua 5.2 时代之后,C模块开发者逐渐推崇不污染全局名称的方式,尽量少用甚至不用全局变量。在以前,每个模块以自己的模块名定义一个全局变量,甚至,每个函数都定义一个全局变量,这样很容易导致全局名称的泛滥,程序一大,引用的模块一多,全局名称互相覆盖导致莫名其妙结果的概率就越来越大。Lua 5.2为了表示治理的决心,把 luaL_register() 都取消了。解决办法就是,C模块内部不注册任何全局名称,仅通过模块入口函数 luaopen_xxx() 返回"值"(value),体现在lua脚本里,该值被require() 函数返回,于是用户可以用局部变量接收:local html = require "htmlua"。往这个“值”加入成员函数的方案,上一节已经提到,再具体一点就是调用 luaL_newlib()。但是,一切都要便宜行事,切不可认死理,具体问题具体分析,需要用到全局名称的地方,也不能盲目排斥。例如我在 htmlua 模块里面就定义了 htmlnode, htmltag 这两个全局名称,用于容纳节点类型、标签类型等数值常量,主要是出于方便用户使用的角度考虑。

for in 循环 和 ipairs/pairs 函数

  Lua语言有个 for in 格式的循环语句,配合 ipairs/pairs 之类的迭代器函数(iterator function),用起来很方便。而且此循环机制是开放的,只要用户实现了约定的迭代器函数,就可以跟 for in 循环配合使用。可惜的是,Lua官方文档对此机制解释的很抽象,很难理解。下面我(Liigo)试图用自己的语言表述。迭代器函数iterator必须返回三个值:next函数、对象o、初始索引i(通常是nil)。其中next函数必须接收对象o和索引i这两个参数,并返回多个值。next函数的第一个返回值是索引i的后续索引nexti——如果索引i为nil则返回首个索引,next函数后面几个返回值是与索引nexti对应的任意数量和任意类型的值。上述对象o、索引i都可以是任意类型(可为nil),对迭代器函数iterator的参数也没有限制。循环机制:在循环之前先调用一次(也是唯一一次)迭代器函数iterator(),得到 next, o, i;然后进入循环,不停的调用 i,... = next(o,i),直到next返回的索引i为nil时终止循环;循环过程中调用next得到的除第一个返回值是循环索引外,其余返回值将赋值给for in循环中介于for和in之间的变量列表,供循环体中的代码使用。以lua代码 for v1,v2,v3 in init() do ... end 为例,对上述过程用伪代码表示如下:

local next, o, i = iterator()
while(true) do
	i, v1, v2, v3 = next(o, i)
	if(i == nil) then break end
	... //code in for-in block
end

  实现 iterator 函数很简单,直接返回 next, o, nil 三个值即可。关键是实现 next 函数,首先需要判断索引i是否为nil,如果是nil则返回第一个索引,否则返回下一个索引;需要终止循环时,返回nil作为索引(其他返回值也均为nil)。

  至于 iterator 函数的命名,可以借用 ipairs / pairs,也可以任意取名,例如 string.gmatch。在我的 htmlua 模块里,为 parser 对象定义了方法 ipairs(),为 node 对象定义了方法 pairs() ,目的都是使其支持 for in 循环。

用不用 luaL_xxx 函数?

  一开始我对 luaL_xxx 系列函数有误解,看到它们绝大多数被声明在头文件 lauxlib.h 内,就误以为都是些辅助类函数,全是对LUA核心C API的包装函数,用多了会影响对核心C API的理解。所以一开始我是排斥、拒绝使用 luaL_xxx 之类函数的,尽量借助C API自身实现功能,哪怕是操作上复杂一些。后来发现这是错误的,虽然多数 luaL_xxx 是包装函数、辅助函数,但还是有一部分是无法取代的、必不可少的,例如 luaL_openlibs(),luaL_ref()(LUA内部不开放机制决定了luaL_ref无法取代)。而且,lua核心源代码里也经常使用luaL_xxx()系列函数。现在我认为 luaL_xxx 跟 lua_xxx 一样都是LUA C API的一部分。

其他(Other)

  开发Lua的C模块,比开发易语言支持库要简单一些,主要是少了类似反射机制的自我表述文档。易语言的支持库被加载后,里面有什么函数、什么功能、什么参数、各参数是什么含义,都直接显示给用户了,文档内嵌在支持库内。而Lua的模块则完全没有这些,一个模块发布出去,如果不配备额外的文档,用户根本无从知道如何使用。Lua的栈也很容易把索引搞混,不如易语言的参数传递机制那么直观。

  感觉LUA官方的API文档不是很细致,我发现多处表述不准确、有欠缺的地方。例如lua_copy()的说明中用了“move”这个词,与函数名称中的“copy”并陈,令人非常迷惑,copy是复制的意思,move是移动的意思,两者截然不同。还有,lua_insert(), lua_replace()的文字说明中,竟然没有明确提及“会弹出栈顶的值”,这么重要的信息遗漏,对开发者很不负责任。另外,官方文档对for in循环的运行机制表述隐晦不直观,不易理解,见上文。


《全文完》谢谢收看。

你可能感兴趣的:(liigo,Parser,C/C++,Lua)