Tizeng's blog Ordinary Gamer

Lua学习笔记2

2019-09-12
Tizeng
Lua

主要内容为教材《Lua程序设计(第4版)》第四部分:C语言API。

Lua是一种嵌入式语言,因为它能被当做库来扩展某个应用程序,同时使用了Lua的程序也可以在Lua环境中注册新的函数,从而增加一些无法直接用Lua编写的功能,因此Lua也是一种可扩展的语言

以C语言为例,上述两种对Lua的定位分别对应了C和Lua的两种交互形式,第一种形式中(嵌入式语言),C拥有控制权,Lua被用作库,这种形式中的C被称为应用代码(application code);第二种形式(可扩展语言)中,Lua拥有控制权,而C被用作库,此时C被称为库代码(library code)。应用代码和库代码都使用相同的API与Lua进行通信,这些API被称为C API。

虚拟栈

Lua和C的通信主要依靠的是栈,几乎所有的API调用都是在操作这个栈中的值,包括数据交换。

以Lua中的表为例,表的键和值可以是任意类型,那么如果我们用函数来定义一个表的赋值操作,就需要好几十种不同的函数,因为要考虑每种类型的组合,这样既不高效,还有可能误触发垃圾回收。因此Lua和C之间使用栈来交换数据,栈中的每个元素都能保存Lua中任意类型的值,当希望从Lua中获取一个值时,只需要将它压入栈中,而如果需要传值给Lua,首先将它压入栈,然后调用Lua将其弹出即可。这样就避免了将每种类型都去排列组合的麻烦。

独立解释器

用C++写一个简单的Lua解释器:

#include <iostream>
#include <string>
extern "C" {
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
}
using namespace std;

int main() {
    int error;
    lua_State* L = luaL_newstate();
    luaL_openlibs(L);
    string input;

    while (cin) {
        cout << ">";
        getline(cin, input);
        error = luaL_loadstring(L, input.c_str()) || lua_pcall(L, 0, 0, 0);
        if (error) {
            cerr << lua_tostring(L, -1) << endl;
            lua_pop(L, 1);
        }
    }

    lua_close(L);
    return 0;
}

注意extern "C"这段是必须的,否则可能会出错,因为我们是C++项目,而头文件是用C写的。或者将其直接替换成lua.hpp

运行这段代码,我们就可以在黑窗中正常的输入Lua语句并运行了,如果输入的语法有误,会有错误信息打印在屏幕上。

在C中调用Lua函数

首先在名为test.lua的文件中定义如下函数:

function myAdd(x, y)
    return x + y
end

然后将其至于主程序同一目录下,下面定义一个调用上面Lua函数的C++程序,其中的具体操作涉及到很多lua_开头的方法,它们是包含在lua.h中Lua提供的基础函数,它们被称为C API,遵循C语言的操作模式,包括创建新的lua环境的函数、调用lua函数的函数、读写环境中全局变量的函数等:

#include <iostream>
#include <string>
#include <cstdarg>
#include "lua.hpp"

void error(lua_State* L, const char* format, ...) {
    va_list vl; // declare variable argument list
    va_start(vl, format); // initiate vl
    vfprintf(stderr, format, vl); // print arguments in vl with format
    va_end(vl); // end using variable argument list
    lua_close(L);
    exit(EXIT_FAILURE);
}

double myAdd(lua_State* L, double x, double y) {
    int isNum;
    double z;
    // 函数和参数入栈
    lua_getglobal(L, "myAdd");
    lua_pushnumber(L, x);
    lua_pushnumber(L, y);
    if (lua_pcall(L, 2, 1, 0) != LUA_OK) { // 两个传入参数和一个返回值,如果没有错误,lua_pcall将返回0
        error(L, "error running function 'f': %s", lua_tostring(L, -1));
    }
    z = lua_tonumberx(L, -1, &isNum); // 取栈顶结果
    if (!isNum) {
        error(L, "function '%s' should return a number", lua_tostring(L, -1));
    }
    lua_pop(L, 1);
    return z;
}

int main() {
    lua_State* L = luaL_newstate();
    luaL_openlibs(L);
    string input;

    luaL_dofile(L, "test.lua"); // 读取Lua文件
    cout << myAdd(L, 2, 3) << endl;

    lua_close(L);
    return 0;
}

运行结果如下:

img

函数lua_pcall如果在运行中出错,会返回一个错误码,并在栈中压入一条错误信息,但如果有错误处理函数,在压入错误信息前,会先调用错误处理函数,我们可以通过lua_pcall的最后一个参数指定这个错误处理函数,0表示不提供,若传入非0参数,那么该参数应该是错误处理函数在栈中的索引,因此通常来讲错误处理函数应该被提前压入栈且位于待调用函数之下。另外,有两种情况lua_pcall不会调用错误处理函数,一是内存分配失败,二是消息处理函数(?)本身出错。

通用的调用函数

上面的例子只能针对double型的数值,如果要调用不同类型的参数还要定义另外的函数,显然不合理,因此还要定义一个可以处理任意类型的通用函数。

void call_va(lua_State* L, const char* func, const char* typeIndicator, ...) {
    va_list vl;
    int argNum, resNum;

    va_start(vl, typeIndicator);
    lua_getglobal(L, func); // 函数入栈

    // 处理参数类型
    for (argNum = 0; *typeIndicator; argNum++) {
        luaL_checkstack(L, 1, "too many argument");
        switch (*typeIndicator++) { // 处理完一个参数类型后,将指针后移
            case 'd':
                lua_pushnumber(L, va_arg(vl, double));
                break;
            case 'i':
                lua_pushinteger(L, va_arg(vl, int));
                break;
            case 's':
                lua_pushstring(L, va_arg(vl, char*));
                break;
            case '>': // 参数类型处理完毕
                goto endargs;
            default:
                error(L, "invalid option (%c)", *(typeIndicator - 1));
        }
    }
    cout << "function in stack" << endl;
endargs:
    resNum = strlen(typeIndicator);
    if (lua_pcall(L, argNum, resNum, 0) != 0) {
        error(L, "error calling '%s': %s", func, lua_tostring(L, -1));
    }

    // 处理函数返回值类型
    resNum = -resNum; // 第一个结果的栈索引
    while (*typeIndicator) {
        switch (*typeIndicator++) { // 注意这里也要后移
            case 'd': {
                int isNum;
                double n = lua_tonumberx(L, resNum, &isNum); // resNum保证了得到相应顺序位置的返回值
                if (!isNum) {
                    error(L, "wrong result type");
                }
                //cout << n << endl;
                *va_arg(vl, double*) = n; // 修改传入的变量为返回结果
                break;
            }
            case 'i': {
                int isNum;
                int n = lua_tointegerx(L, resNum, &isNum);
                if (!isNum) {
                    error(L, "wrong result type");
                }
                *va_arg(vl, int*) = n;
                break;
            }
            case 's': {
                const char* s = lua_tostring(L, resNum);
                if (s == NULL) {
                    error(L, "wrong result type");
                }
                *va_arg(vl, const char**) = s;
                break;
            }
            default:
                error(L, "invalid option (%c)", *(typeIndicator - 1));
        }
        resNum++; // 将返回值变量索引后移
    }

    va_end(vl);
}

两个循环同时受变量argNumresNumtypeIndicator长度的控制,typeIndicator是一个字符串,长度随输入参数和返回值的数量变化,格式形如ddiis>id,d、i、s分别代表浮点型、整型和字符串型,输入和返回值由>隔开,有几个输几个,具体调用格式如下:

int main() {
    lua_State* L = luaL_newstate();
    luaL_openlibs(L);
    string input;

    luaL_dofile(L, "test.lua");
    cout << "general version: ";
    double z = -1;
    double x = 2, y = 3;
    call_va(L, "myAdd", "dd>d", x, y, &z);
    cout << z << endl;

    lua_close(L);
    return 0;
}

返回值我们现在外部定义好变量,然后以引用的方式传入,执行完毕后如果没有问题就会得到结果。

注意在调用时,输入和返回值的数量不光要与前面的typeIndicator长度相符,类型也一定要相符,否则可能会报错。

其他API

补两个也很常用的api,lua_rawget和lua_rawset,这是两个绕过元表中__index__newindex元方法的api,直接去取表中的元素或者给元素赋值,如果不想绕过元方法,可以使用lua_gettable和lua_settable。

lua_pushstring(L, key)
lua_gettable(L, -2) // 此时表的索引为-2
// 运行后会弹出压入的key,然后压入对应的value

lua_pushstring(L, key)
lua_pushnumber(L, value)
lua_settable(L, -3) // 此时表的索引为-3

还有比较常用的lua_getglobal(L, name)lua_setglobal(L, name),前者在lua脚本中寻找名为name的变量并将其值放到栈顶,后者将栈顶的值赋值给名为name的变量,并在操作完成后将键与值都弹出。

在Lua中调用C函数

当Lua调用C函数时,我们必须注册该函数,即以一种恰当的方式为Lua提供该C函数的地址。与上面C调用Lua函数类似,Lua调用C函数时,也使用了一个相同类型的栈,C函数从栈中获取参数,并将结果压入栈中。不同的是,此处的栈不是全局的,即每个函数都有其私有的局部栈。当Lua调用一个C函数时,第一个参数总是位于这个局部栈中索引为1的位置,不管这个行为何时发生,每次调用都只会看到本次调用自己的私有栈,其中索引为1的位置上就是第一个参数。

所有在Lua中注册的函数都必须使用一个相同的原型:

typedef int (*lua_CFunction) (lua_State *L);

这里要注意返回值为整型,代表压入栈中的返回值的个数。在该函数返回后,Lua会自动保存返回值并清空栈。

方法1:将C函数嵌入应用程序

我们上面定义了一个简易的Lua解释器,我们可以在它的内部添加一个C函数,然后在读取用户输入之前,将这个函数注册,就能在Lua窗口中调用了。

static int l_add(lua_State* L) {
    double x, y;
    //x = lua_tonumber(L, 1);
    //y = lua_tonumber(L, 2);
    x = luaL_checknumber(L, 1);
    y = luaL_checknumber(L, 2);
    lua_pushnumber(L, x + y);
    return 1;
}

int main() {
    int error;
    lua_State* L = luaL_newstate();
    luaL_openlibs(L);
    lua_register(L, "l_add", l_add); // 注册C函数
    string input;

    while (cin) {
        cout << ">";
        getline(cin, input);
        error = luaL_loadstring(L, input.c_str()) || lua_pcall(L, 0, 0, 0);
        if (error) {
            cerr << lua_tostring(L, -1) << endl;
            lua_pop(L, 1);
        }
    }
    lua_close(L);
    return 0;
}

img

方法2:导出C函数库(C模块)

我们同样可以将C函数所在源文件导出成动态(静态)库,然后在Lua文件中包含这个库,就可以调用其中的C函数了:

extern "C" {
    static int add(lua_State* L) {
        double x, y;
        //x = lua_tonumber(L, 1);
        //y = lua_tonumber(L, 2);
        x = luaL_checknumber(L, 1);
        y = luaL_checknumber(L, 2);
        lua_pushnumber(L, x + y);
        return 1;
    }
}

static const struct luaL_Reg mylibs[] = {
    {"add", add},
    {NULL, NULL}
};

extern "C"  __declspec(dllexport) int luaopen_mylib(lua_State* L) {
    //luaL_newlibtable(L, mylibs);
    //luaL_setfuncs(L, mylibs, 0);
    luaL_newlib(L, mylibs);
    return 1;
}

定义的库函数格式前面已经说过了,下面两个一个是包含需要注册函数的数组,类型为luaL_Reg;和一个打开函数(主函数),用于读取前面定义的数组。注意两个函数定义时都要加上extern "C"关键词,因为我们是C++项目,主函数前面还要加上__declspec(dllexport)告诉Lua去读取它(详见官网)。

这个过程说来容易,实现的时候很多坑,在windows环境下要建立Lua开发环境,首先要将下载的源码导入到工作目录的lua/include文件夹中,然后我们需要编译Lua的动态链接库,将除luac.clua.c的所有源文件导入VS项目中,在项目属性->常规->配置属性中选择动态库(dll),然后将VS输出配置改为x64,点击生成解决方案,得到.dll和.lib两个库,并将它们移到lua文件夹待用。

下一步是编译Lua编译器,这时需要将源码中的luac.c文件加回来,然后选择导出类型为.exe,重新生成解决方案导出,得到编译器luac.exe,同样放在lua文件夹中。

最后编译Lua解释器,与之前不同的是,这次项目中只需要lua.c,其他的源文件都要移除,然后在项目设置中将include文件夹加入附加包含目录,并在链接器设置中也要加入lua目录使之包含刚刚生成的库文件,然后就可以导出解释器lua.exe了,同样把它放在lua中,就可以运行Lua代码了。

Lua环境搭好之后,用相同的方法导出上面的C++代码为dll文件,并放入lua文件夹,然后就可以包含我们导出的库并调用里面的函数了:

lua_call_Cfunction_dll.png

Lua中的模式匹配

  • 函数string.find(str, pattern, startIdx, isSimple)
    • 在目标字符串中搜索指定模式,返回匹配位置开始和结束的两个索引,如果匹配失败则返回nil,后面两个参数是可选的,一个是搜索开始的索引,一个是是否简单搜索(忽略模式,单纯的查找字符串)
  • 函数string.match(str, pattern)
    • 直接返回相匹配的字符串
  • 函数string.gsub(str, pattern, substitute, times)
    • 返回将所有pattern替换为substitute后的字符串,最后一个参数可以限定替换的次数,第三个参数可以是一个函数或一个表
  • 函数string.gmatch(s, pattern)
    • 返回一个函数,通过这个函数可以遍历一个字符串中所有出现的指定模式
        s = "some string"
        words = {}
        for w in string.gmatch(s, "%a+") do
            words[#words +1] = w
        end
      

      上面的代码提取字符串中所有的单词并存入表words,其中%a+模式会匹配一个或多个字母组成的序列,即单词

Lua中的模式使用百分号作为转义符(类似C中的printf函数),如%d是数字,可以用%d%d/%d%d/%d%d%d%d来匹配dd/mm/yyyy日期格式,%p表示标点符号等,这些写法的大写形式表示该类的补集。

数据结构

前面已经说过,Lua中表作为所有数据结构的基础,下面总结一下一些常见的套路。

如果我们需要实现一个矩阵,大致有两种思路,一种是把一个表中的所有元素定义为一个新表,用来表示矩阵的行,其中储存每行的元素,还有一种常见的写法是通过行列的索引计算新的索引,这样就可以只用一维数组储存:

local mt = {}
for i = 1, N do
    local aux = (i - 1) * M
    for j = 1, M do
        mt[j + aux] = 0
    end
end

在其他语言中,如何压缩稀疏矩阵(即大多数元素是0或nil的矩阵)占用的内存空间需要特别的技巧,但是Lua中只有非nil元素才占用空间,因此我们很少用到那些技巧,这是Lua的优势之一。

我们经常使用邻接矩阵(adjacency matrix)来表示图结构,在一个顶点数量为V的图中,用一个O(V*V)的矩阵可以表示出每个节点的关系,而且我们查询和每个节点有关系的节点只需要O(V)的时间复杂度,查询两个节点是否相连只需要查询m[i][j]是否存在,时间复杂度是O(1)。

adjacency_matrix

但是这个结构对于大多数图结构来说会浪费很多空间复杂度,对于没有方向的图来说不光储存了双倍的空间,很多空间还给了0或nil这种并不需要的信息,鉴于Lua不需要担心这种情况,这里不做过多讨论,有一种更进一步的结构叫做邻接表(adjacency list),有时间在其他地方总结。

Debug日志

(1) 编译时出现链接错误

出现形如

LNK2019    无法解析的外部符号 _lua_close,该符号在函数 _main 中被引用  LuaCTest    D:\Projects\LuaCTest\LuaCTest\LuaCTest.obj  1

的若干错误,应该是库没有链接上,而我编译Lua源码时用的教程生成的是dll文件,也就是动态库,就算加入了VS的目录中也不能解决,因此后面又按照网上的方法重新建了一个解决方案生成静态库,然后在同一个解决方案下直接链接,就没问题。

后面再回头看那个动态库的版本,按照第二个教程将Lua源文件全部包含进VS项目之后就可以跑了。

(2) VS断点无法进入(未加载pdb引发)

解决方法:在VS菜单栏依次进入调试->选项->常规,取消勾选要求源文件与原始版本完全匹配,然后重新生成解决方案。

(3) dll库无法读取

.dll文件名也要和库名一致。

(4) mutiple lua VMs detected

读取库后报这个错,查资料说是解释器和dll库没有全部动态链接导致,因此重新编译了Lua解释器和编译器,并确保导出dll时VS中的预处理器设置加入了LUA_BUILD_AS_DLL

img

这个错整了很久,查了很多解决方法都不行,无法确定到底是解释器导出有问题还是dll库导出有问题还是都有问题,之前Lua源码是按官网的一个教程用build.cmd文件编译的,没有用VS,所以干脆用VS全部编译重来一次。

这两天梯子都挂了,没有谷歌和YouTube,解决问题的效率变低了,最后还是用手机上的梯子查了YouTube视频解决的问题。

(5) 导出解释器时始终失败

报“无法解析的外部命令”的链接错误,解决方法是将项目中的除lua.c的Lua源文件删除,然后在项目设置中将源文件的目录作为附加目录。

总结

学习完上述内容后,应当能熟练描述lua是如何调用C函数的?在C中又是如何调用lua函数的?


上一篇 Lua入门

Comments

Content