C/C++脚本化: 探索篇
版权声明:原创文章,未经授权,请勿转载
使用C/C++等系统级语言开发业务逻辑效率太低? 这个功能用脚本写最多只要三天?我们对执行速度和资源又没啥要求…… 不知道过去你是否也有过这样的想法?
随着软件工程与计算机硬件的飞速发展,对于一些开发场景来说,系统级语言多少显得有点“大材小用”了,这迫使我们需要结合一门脚本语言相互配合,快速迭代产品。
本文主要描述通过脚本语言来扩展C/C++,而非为脚本语言写C/C++模块的应用场景。
1 为什么要脚本化
我们知道C/C++是一种高效且功能强大的系统级编程语言, 被广泛用于操作系统、服务器、游戏引擎及客户端应用程序的开发。然而随着计算机硬件性能的飞速提升,在许多对性能要求不高的场景下,相比其所带来的复杂度与开发成本,其他方面的优势已经不再显著。许多开发者转向了脚本语言,利用其与生俱来的灵活性、丰富的生态,快速迭代产品,达到降本增效的目的,而需要性能的场景又可以反过来结合C/C++,这样即保证了程序的高效运行又兼顾了开发速度。
比如近些年流行的 Electron
, 它将WEB技术应用到了桌面端应用程序, 即用JavaScript写业务逻辑, 以HTML、CSS渲染用户界面。
另外一些开发者期望将程序的相关功能,通过脚本语言的接口暴露给用户,以便用户可以通过这些接口来扩展程序的功能,提高灵活性以及适应性。如IDA
静态分析工具就使用Python
来扩展软件功能,这样用户可以通过Python编写插件、执行一些自定义任务。此外还有Microsoft Office
与Visual Basi
、《魔兽世界》与Lua
等等。
2 现有项目的脚本化及方式
尽管与脚本语言结合有诸多优势,但现有大部分的项目仍然是通过传统C/C++实现。因此针对这部分现有项目,我们可以在其版本迭代、新功能开发、维护期间,通过脚本化部分功能模块或业务逻辑,达到简化开发与维护的工作,即某项独立的任务通过调用脚本语言编写的模块来完成。
这里我们主要探讨脚本语言扩展C/C++的场景,目前主要有如下两种扩展方式:
- 直接嵌入脚本语言的解释器
- 通过子程序的方式调用脚本模块(不适用于Lua)
2.1 嵌入解释器
将脚本语言解释器作为一个模块集成到C/C++项目中,然后以调用函数的方式运行脚本模块,通常需要等待脚本运行结束之后才会返回。执行结果通过解释器API获取,通常包含:返回值
及上下文对象
。
2.1.1 优点
- 灵活性高
通过解释器提供的API,开发人员可以轻松修改和扩展解释器的功能。 - 交互简单
脚本与宿主程序运行在同一进程内,两者之间可以通过暴露接口的方式直接交互,而无需编写进程通讯方面的代码。 - 资源共享
由于在同一进程内,资源可以相互共享(通过接口访问)。
2.1.2 缺点
- 兼容性风险
不同发行版的解释器提供的API可能并不兼容,切换解释器版本困难。 - 并行执行差
大部分脚本语言解释器是单线程设计,不能提供真正意义上的多核并行,且这种机制不能通过在多个逻辑线程上运行解释器解决,如CPython
的全局解释器锁(GIL)[1]。
2.1.3 例子
下面是通过Boost.Python
嵌入Python解释器[2],并执行了一个简单的脚本文件的例子
script.py:
print('Hello World !')
number = 42
embedding.cpp:
int main(int argc, char **argv)
{
namespace python = boost::python;
// Initialize the interpreter
Py_Initialize();
// Retrieve the main module
python::object main = python::import("__main__");
// Retrieve the main module's namespace
python::object global(main.attr("__dict__"));
// Run a python script in an global environment.
python::object result = python::exec_file("script.py", global);
// Extract an object the script stored in the global dictionary.
BOOST_TEST(python::extract<int>(global["number"]) == 42);
return 0;
}
2.2 子程序调用脚本模块
这种方式同命令行终端中运行脚本是一样的,都是通过创建子进程来运行脚本模块。但该方式不适用于Lua,因为Lua的定位是嵌入式脚本语言,需要嵌入到宿主程序才能发挥作用。
2.2.1 优点
- 并行执行方便
通过多进程并行,可以很好的利用多核优势。 - 版本兼容性好
可以在不同解释器发行版之间快速切换,无需担心API兼容性问题,能够快速的升级至新版本。 - 稳定性好
尽管脚本语言很少像C/C++一样,在遇到程序缺陷时发生崩溃,但多进程结构的稳定性本身也是一种优势。
2.2.2 缺点
- 灵活性差
相对于直接嵌入解释器的方式来说,此种方式较难以修改和扩展解释器。比如我们期望将程序的某个功能能在脚本中的使用,那么需要将其包装为该解释器的扩展,而不是通过API直接向解释器注册。如果存在很多这样的需求,那这是一个艰巨的任务ಠᴗಠ
。 - 交互复杂
由于脚本与宿主程序不在同一进程内,两者之间的交互需要跨进程通讯,具体的通讯方式需要开发人员视业务场景自行选择,不过一般存在如下几种:- 通过子进程的标准输入、输出流,该方式实现简单且跨平台,但很多模块都会使用标准输入、输出,精确控制有一定难度。
- 通过
Socket
网络通讯,任何语言都可以方便地处理网络,实现相对容易,跨平台性非常好。 - 通过命名管道、消息队列、共享内存等常见
IPC
技术,这种方式不容易跨平台或语言,通常需要为不同的系统、语言编写代码。
2.2.3 例子
下面的例子是通过Boost.Process
[3] 将Python脚本文件作为子进程运行,读取并存储子进程标准输出流中的内容,待其运行结束后再打印出来。
script.py:
print('Hello World !')
number = 42
subprocess.cpp:
int main(int argc, char **argv)
{
namespace bp = boost::process;
bp::ipstream is; //reading pipe-stream
bp::child c(bp::search_path("python script.py"), file, bp::std_out > is);
std::vector<std::string> data;
std::string line;
while (c.running() && std::getline(is, line) && !line.empty())
data.push_back(line);
c.wait();
// 输出: Hello World !
for (const auto& i : data)
std::cout << i << std::endl;
return 0;
}
2.3 总结
通过以上,我们可以看出两种方式的侧重不同:前者简单灵活,可以对解释器灵活控制,但缺乏并行机制。后者虽然弥补了前者的缺陷,但却带来了新的复杂度。
在实际的开发过程中,我们可以根据实际情况,选择其一或搭配使用。比如:
- 需要与宿主程序强耦合或对解释器深度定制的场景,推荐嵌入解释器方案。
- 需要异步、并行处理的场景,推荐子程序方案。
- 如果以上场景都会涉及,推荐通过嵌入解释器并包装成一个可执行程序,然后以子程序的方式调用(两种方案结合使用)。
3 脚本语言的选择
- Lua [4]
Lua的目标是轻量级嵌入式脚本语言,特点是快速、简洁、轻量级,但缺点是标准库、生态都相对较小,据说它的执行速度是所有脚本语言最快的。基于此特性很多游戏客户端、服务器使用Lua作为脚本化解决方案。 - Python
Python语法简洁、清晰、易于上手,并且拥有广泛而丰富的生态和第三方库,通过Boost.Python库可以很方便的与C++集成,但执行速度较慢。 - JavaScript
JavaScript语法简单、灵活、执行速度快,有丰富的生态与第三方库。被广泛应用于Web开发,许多开发者或多或少都曾使用过这门语言,无论他是否为Web开发者。
建议结合业务实际情况,选择一种即可,如果看中脚本语言的生态,不在乎执行速度则建议使用Python。如果在乎执行效率则建议采用Lua,择中则考虑JavaScript。如果实在不知道选择什么好,可以试试你掌握的最多的那一个。
结语
以上是个人在桌面端应用程序开发迭代中的一些思考和感悟,恐有不实之处,就权当抛砖引玉。如能给你带来一点收获,我将倍感荣幸。
参考资料
[1] Python/C API Reference Manual: Thread State and the Global Interpreter Lock
[2] Boost.Python: Using the interpreter
[3] Boost.Process: Synchronous I/O
[4] Extending a C++ Application with Lua 5.2
Comments ()