# 17 | 脚本语言:搭建高性能的混合系统 你好,我是Chrono。 经过了前面这么多节课的学习,相信你已经认识到了C++的高效、灵活和强大。使用现代特性,再加上标准库和第三方库,C++几乎“无所不能”。 但是,C++也有自己的“阿喀琉斯之踵”,那就是语言复杂、学习曲线陡峭、开发周期长、排错/维护成本高。 所以,C++不能完全适应现在的快速开发和迭代的节奏,最终只能退到后端、底层等领域。要想充分发挥C++的功力,就要辅助其他的语言搭建混合系统,尽量扬长避短,做好那最关键、最核心的部分,这样才能展现出它应有的价值。 由于当前的操作系统、虚拟机、解释器、引擎很多都是用C或者C++编写的,所以,使用C++,可以很容易地编写各种底层模块,为上层的Java、Go等语言提供扩展功能。 不过,今天我不去说这些大型语言,而是讲两种轻便的脚本语言:Python和Lua,看看C++怎么和它们俩实现无缝对接:以C++为底层基础,Python和Lua作为上层建筑,共同搭建起高性能、易维护、可扩展的混合系统。 ## Python Python应该是除了JavaScript以外最流行的一种脚本语言了,一直在TIOBE榜单里占据前三名的位置。而且,在新兴的大数据、人工智能、科学计算等领域,也都有着广泛的应用。很多大公司都长期招聘Python程序员,就是看中了它的高生产率。 Python本身就有C接口,可以用C语言编写扩展模块,把一些低效耗时的功能改用C实现,有的时候,会把整体性能提升几倍甚至几十倍。 但是,使用纯C语言写扩展模块非常麻烦,那么,能不能利用C++的那些高级特性来简化这部分的工作呢? 很多人都想到了这个问题,于是,就出现了一些专门的C++/Python工具,使用C++来开发Python扩展。其中,我认为最好的一个就是[pybind11](https://github.com/pybind/pybind11)。 pybind11借鉴了“前辈”Boost.Python,能够在C++和Python之间自由转换,任意翻译两者的语言要素,比如把C++的vector转换为Python的列表,把Python的元组转换为C++的tuple,既可以在C++里调用Python脚本,也可以在Python里调用C++的函数、类。 pybind11名字里的“11”表示它完全基于现代C++开发(C++11以上),所以没有兼容旧系统的负担。它使用了大量的现代C++特性,不仅代码干净整齐,运行效率也更高。 下面,我就带你看看怎么用pybind11,让C++来辅助Python,提升Python的性能。 pybind11是一个纯头文件的库,但因为必须结合Python,所以首先要有Python的开发库,然后再用pip工具安装。 pybind11支持Python2.7、Python3和PyPy,这里我用的是Python3: ``` apt-get install python3-dev apt-get install python3-pip pip3 install pybind11 ``` pybind11充分利用了C++预处理和模板元编程,把原本无聊重复的代码都隐藏了起来,展现了“神奇的魔法”——只需要短短几行代码,就可以实现一个Python扩展模块。具体怎么实现呢? 实际上,你只要用一个宏“**PYBIND11\_MODULE**”,再给它两个参数,Python模块名和C++实例对象名,就可以了。 ``` #include // pybind11的头文件 PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo { m.doc() = "pybind11 demo doc"; // 模块的说明文档 } // Python模块定义结束 ``` 代码里的pydemo就是Python里的模块名,之后在Python脚本里必须用这个名字才能import。 第二个参数“m”其实是pybind11::module的一个实例对象,封装了所有的操作,比如这里的doc()就是模块的说明文档。它只是个普通的变量,起什么名字都可以,但为了写起来方便,一般都用“m”。 假设这个C++源文件名是“pybind.cpp”,现在你就可以用g++把它编译成在Python里调用的模块了,不过编译命令比较复杂: ``` g++ pybind.cpp \ #编译的源文件 -std=c++11 -shared -fPIC \ #编译成动态库 `python3 -m pybind11 --includes` \ #获得包含路径 -o pydemo`python3-config --extension-suffix` #生成的动态库名字 ``` 我来稍微解释一下。第一行是指定编译的源文件,第二行是指定编译成动态库,这两个不用多说。第三行调用了Python,获得pybind11所在的包含路径,让g++能够找得到头文件。第四行最关键,是生成的动态库名字,**前面必须是源码里的模块名**,而后面那部分则是Python要求的后缀名,否则Python运行时会找不到模块。 编译完后会生成一个大概这样的文件:pydemo.cpython-35m-x86\_64-linux-gnu.so,现在就可以在Python里验证了,使用import导入,然后用help就能查看模块说明: ``` $ python3 >>> import pydemo >>> help(pydemo) ``` 刚才的代码非常简单,只是个空模块,里面什么都没有,现在,我们来看看怎么把C++的函数导入Python。 你需要用的是**def()函数**,传递一个Python函数名和C++的函数、函数对象或者是lambda表达式,形式上和Python的函数也差不多: ``` namespace py = pybind11; // 名字空间别名,简化代码 PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo { m.def("info", // 定义Python函数 []() // 定义一个lambda表达式 { py::print("c++ version =", __cplusplus); // pybind11自己的打印函数 py::print("gcc version =", __VERSION__); py::print("libstdc++ =", __GLIBCXX__); } ); m.def("add", // 定义Python函数 [](int a, int b) // 有参数的lambda表达式 { return a + b; } ); } // Python模块定义结束 ``` 这样我们就非常轻松地实现了两个Python函数,在Python里可以验证效果: ``` import pydemo # 导入pybind11模块 pydemo.info() # 调用C++写的函数 x = pydemo.add(1,2) # 调用C++写的函数 ``` pybind11也支持函数的参数、返回值使用标准容器,会自动转换成Python里的list、dict,不过你需要额外再包含一个“stl.h”的头文件。 下面的示例代码演示了C++的string、tuple和vector是如何用于Python的: ``` #include // 转换标准容器必须的头文件 PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo { m.def("use_str", // 定义Python函数 [](const string& str) // 入参是string { py::print(str); return str + "!!"; // 返回string } ); m.def("use_tuple", // 定义Python函数 [](tuple x) // 入参是tuple { get<0>(x)++; get<1>(x)++; get<2>(x)+= "??"; return x; // 返回元组 } ); m.def("use_list", // 定义Python函数 [](const vector& v) // 入参是vector { auto vv = v; py::print("input :", vv); vv.push_back(100); return vv; // 返回列表 } ); } ``` 因为都是面向对象的编程语言,C++里的类也能够等价地转换到Python里面调用,这要用到一个特别的模板类class\_,注意,它有意模仿了关键字class,后面多了一个下划线。 我拿一个简单的Point类来举个例子: ``` class Point final { public: Point() = default; Point(int a); public: int get() const; void set(int a); }; ``` 使用pybind11,你需要在模板参数里写上这个类名,然后在构造函数里指定它在Python里的名字。 导出成员函数还是调用函数def(),但它会返回对象自身的引用,所以就可以连续调用,在一句话里导出所有接口: ``` py::class_(m, "Point") // 定义Python类 .def(py::init()) // 导出构造函数 .def(py::init()) // 导出构造函数 .def("get", &Point::get) // 导出成员函数 .def("set", &Point::set) // 导出成员函数 ; ``` 对于一般的成员函数来说,定义的方式和普通函数一样,只是你必须加上取地址操作符“&”,把它写成函数指针的形式。而构造函数则比较特殊,必须调用init()函数来表示,如果有参数,还需要在init()函数的模板参数列表里写清楚。 pybind11的功能非常丰富,我们不可能一下子学完全部的功能,刚才说的这些只是最基本,也是非常实用的功能。除了这些,它还支持异常、枚举、智能指针等很多C++特性,你可以再参考一下它的[文档](https://github.com/pybind/pybind11),学习一下具体的方法,挖掘出它的更多价值。 如果你在工作中重度使用Python,那么pybind11绝对是你的得力助手,它能够让C++紧密地整合进Python应用里,让Python跑得更快、更顺畅,建议你有机会就尽量多用。 ## Lua 接下来我要说的第二个脚本语言是小巧高效的Lua,号称是“最快的脚本语言”。 你可能对Lua不太了解,但你一定听说过《魔兽世界》《愤怒的小鸟》吧,它们就在内部大量使用了Lua来编写逻辑。在游戏开发领域,Lua可以说是一种通用的工作语言。 Lua与其他语言最大的不同点在于它的设计目标:不追求“大而全”,而是“小而美”。Lua自身只有很小的语言核心,能做的事情很少。但正是因为它小,才能够很容易地嵌入到其他语言里,为“宿主”添加脚本编程的能力,让“宿主”更容易扩展和定制。 标准的Lua(PUC-Rio Lua)使用解释器运行,速度虽然很快,但和C/C++比起来还是有差距的。所以,你还可以选择另一个兼容的项目:LuaJIT([https://luajit.org/](https://luajit.org/))。它使用了JIT(Just in time)技术,能够把Lua代码即时编译成机器码,速度几乎可以媲美原生C/C++代码。 不过,LuaJIT也有一个问题,它是一个个人项目,更新比较慢,最新的2.1.0-beta3已经是三年前的事情了。所以,我推荐你改用它的一个非官方分支:OpenResty-LuaJIT([https://github.com/openresty/luajit2](https://github.com/openresty/luajit2))。它由OpenResty负责维护,非常活跃,修复了很多小错误。 ``` git clone git@github.com:openresty/luajit2.git make && make install ``` 和Python一样,Lua也有C接口用来编写扩展模块,但因为它比较小众,所以C++项目不是很多。现在我用的是LuaBridge,虽然它没有用到太多的C++11新特性,但也足够好。 LuaBridge是一个纯头文件的库,只要下载下来,把头文件拷贝到包含路径,就能够直接用: ``` git clone git@github.com:vinniefalco/LuaBridge.git ``` 我们先来看看在Lua里怎么调C++的功能。 和前面说的pybind11类似,LuaBridge也定义了很多的类和方法,可以把C++函数、类注册到Lua里,让Lua调用。 但我不建议你用这种方式,因为我们现在有LuaJIT。它内置了一个ffi库(Foreign Function Interface),能够在Lua脚本里直接声明接口函数、直接调用,不需要任何的注册动作,更加简单方便。而且这种做法还越过了Lua传统的栈操作,速度也更快。 使用ffi唯一要注意的是,**它只能识别纯C接口,不认识C++**,所以,写Lua扩展模块的时候,内部可以用C++,但对外的接口必须转换成纯C函数。 下面我写了一个简单的add()函数,还有一个全局变量,注意里面必须要用extern "C"声明: ``` extern "C" { // 使用纯C语言的对外接口 int num = 10; int my_add(int a, int b); } int my_add(int a, int b) // 一个简单的函数,供Lua调用 { return a + b; } ``` 然后就可以用g++把它编译成动态库,不像pybind11,它没有什么特别的选项: ``` g++ lua_shared.cpp -std=c++11 -shared -fPIC -o liblua_shared.so ``` 在Lua脚本里,你首先要用ffi.cdef声明要调用的接口,再用ffi.load加载动态库,这样就会把动态库所有的接口都引进Lua,然后就能随便使用了: ``` local ffi = require "ffi" -- 加载ffi库 local ffi_load = ffi.load -- 函数别名 local ffi_cdef = ffi.cdef ffi_cdef[[ // 声明C接口 int num; int my_add(int a, int b); ]] local shared = ffi_load("./liblua_shared.so") -- 加载动态库 print(shared.num) -- 调用C接口 local x = shared.my_add(1, 2) -- 调用C接口 ``` 在ffi的帮助下,让Lua调用C接口几乎是零工作量,但这并不能完全发挥出Lua的优势。 因为和Python不一样,Lua很少独立运行,大多数情况下都要嵌入在宿主语言里,被宿主调用,然后再“回调”底层接口,利用它的“胶水语言”特性去粘合业务逻辑。 要在C++里嵌入Lua,首先要调用函数**luaL\_newstate()**,创建出一个Lua虚拟机,所有的Lua功能都要在它上面执行。 因为Lua是用C语言写的,Lua虚拟机用完之后必须要用函数**lua\_close()**关闭,所以最好用RAII技术写一个类来自动管理。可惜的是,LuaBridge没有对此封装,所以只能自己动手了。这里我用了智能指针shared\_ptr,在一个lambda表达式里创建虚拟机,顺便再打开Lua基本库: ``` auto make_luavm = []() // lambda表达式创建虚拟机 { std::shared_ptr vm( // 智能指针 luaL_newstate(), lua_close // 创建虚拟机对象,设置删除函数 ); luaL_openlibs(vm.get()); // 打开Lua基本库 return vm; }; #define L vm.get() // 获取原始指针,宏定义方便使用 ``` 在LuaBridge里,一切Lua数据都被封装成了**LuaRef**类,完全屏蔽了Lua底层那难以理解的栈操作。它可以隐式或者显式地转换成对应的数字、字符串等基本类型,如果是表,就可以用“\[\]”访问成员,如果是函数,也可以直接传参调用,非常直观易懂。 使用LuaBridge访问Lua数据时,还要注意一点,它只能用函数**getGlobal()**看到全局变量,所以,如果想在C++里调用Lua功能,就一定不能加“local”修饰。 给你看一小段代码,它先创建了一个Lua虚拟机,然后获取了Lua内置的package模块,输出里面的默认搜索路径path和cpath: ``` auto vm = make_luavm(); // 创建Lua虚拟机 auto package = getGlobal(L, "package"); // 获取内置的package模块 string path = package["path"]; // 默认的lua脚本搜索路径 string cpath = package["cpath"]; // 默认的动态库搜索路径 ``` 你还可以调用**luaL\_dostring()和luaL\_dofile()**这两个函数,直接执行Lua代码片段或者外部的脚本文件。注意,luaL\_dofile()每次调用都会从磁盘载入文件,所以效率较低。如果是频繁调用,最好把代码读进内存,存成一个字符串,再用luaL\_dostring()运行: ``` luaL_dostring(L, "print('hello lua')"); // 执行Lua代码片段 luaL_dofile(L, "./embedded.lua"); // 执行外部的脚本文件 ``` 在C++里嵌入Lua,还有另外一种方式:**提前在脚本里写好一些函数,加载后在C++里逐个调用**,这种方式比执行整个脚本更灵活。 具体的做法也很简单,先用luaL\_dostring()或者luaL\_dofile()加载脚本,然后调用getGlobal()从全局表里获得封装的LuaRef对象,就可以像普通函数一样执行了。由于Lua是动态语言,变量不需要显式声明类型,所以写起来就像是C++的泛型函数,但却更简单: ``` string chunk = R"( -- Lua代码片段 function say(s) -- Lua函数1 print(s) end function add(a, b) -- Lua函数2 return a + b end )"; luaL_dostring(L, chunk.c_str()); // 执行Lua代码片段 auto f1 = getGlobal(L, "say"); // 获得Lua函数 f1("say something"); // 执行Lua函数 auto f2 = getGlobal(L, "add"); // 获得Lua函数 auto v = f2(10, 20); // 执行Lua函数 ``` 只要掌握了上面的这些基本用法,并合理地划分出C++与Lua的职责边界,就可以搭建出“LuaJIT + LuaBridge + C++”的高性能应用,运行效率与开发效率兼得。比如说用C++写底层的框架、引擎,暴露出各种调用接口作为“业务零件”,再用灵活的Lua脚本去组合这些“零件”,写上层的业务逻辑。 ## 小结 好了,今天我讲了怎么基于C++搭建混合系统,介绍了Python和Lua这两种脚本语言。 Python很“大众”,但比较复杂、性能不是特别高;而Lua比较“小众”,很小巧,有LuaJIT让它运行速度极快。你可以结合自己的实际情况来选择,比如语言的熟悉程度、项目的功能/性能需求、开发的难易度,等等。 今天的内容也比较多,我简单小结一下要点: 1. C++高效、灵活,但开发周期长、成本高,在混合系统里可以辅助其他语言,编写各种底层模块提供扩展功能,从而扬长避短; 2. pybind11是一个优秀的C++/Python绑定库,只需要写很简单的代码,就能够把函数、类等C++要素导入Python; 3. Lua是另一种小巧快速的脚本语言,它的兼容项目LuaJIT速度更快; 4. 使用LuaBridge可以导出C++的函数、类,但直接用LuaJIT的ffi库更好; 5. 使用LuaBridge也可以很容易地执行Lua脚本、调用Lua函数,让Lua跑在C++里。 ## 课下作业 最后是课下作业时间,给你留两个思考题: 1. 你觉得使用脚本语言与C++搭建混合系统有什么优势? 2. 你觉得“把C++嵌入脚本语言”和“把脚本语言嵌入C++”有什么区别,哪种方式更好? 欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。 ![](https://static001.geekbang.org/resource/image/e4/2b/e47906e7f83ec210cc011e2652eee12b.jpg)