gitbook/罗剑锋的C++实战笔记/docs/245905.md
2022-09-03 22:05:03 +08:00

368 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.h> // 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/stl.h> // 转换标准容器必须的头文件
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<int, int, string> x) // 入参是tuple
{
get<0>(x)++;
get<1>(x)++;
get<2>(x)+= "??";
return x; // 返回元组
}
);
m.def("use_list", // 定义Python函数
[](const vector<int>& 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_<Point>(m, "Point") // 定义Python类
.def(py::init()) // 导出构造函数
.def(py::init<int>()) // 导出构造函数
.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<lua_State> 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)