C---Lua-集成指南-全-
C++ Lua 集成指南(全)
原文:
zh.annas-archive.org/md5/5bc17169f6fdf6c164eed4c5bdc7d221
译者:飞龙
前言
本书教你如何将 Lua 集成到 C++中。
Lua 是一种简单高效的脚本语言,在游戏行业和嵌入式系统中得到了广泛的应用。在这两个领域,大部分的控制程序和应用都是用 C++编写的。
通过将 Lua 和 C++结合在一起,你可以为自己打开更多的门和机会。
本书面向的对象
本书面向愿意学习如何将 Lua 集成到他们项目中的 C++程序员。本书也可以帮助那些想通过结合 C++练习和相关信息来学习 C++的 Lua 程序员。
为了完全理解和参与本书,需要具备 C++编程语言的基本理解。尽管对 C++的深入了解可能会使阅读本书变得更容易,但你可以通过在线研究自学本书中遇到的先进 C++技术。
不需要具备 Lua 编程语言的前置知识,尽管一些 Lua 经验可能会有所帮助。你可以经常参考 Lua 参考手册,随着你在本书中的进展来学习 Lua 编程语言。
本书涵盖的内容
第一章,让你的 C++项目 Lua-Ready,探讨了如何获取和编译 Lua 源代码以用于自己的项目。
第二章,Lua 基础,介绍了 Lua 编程语言。
第三章,如何从 C++调用 Lua,指导你如何加载 Lua 脚本并从 C++调用 Lua 函数。
第四章,将 Lua 类型映射到 C++,深入探讨了将参数传递给 Lua 函数以及在 C++中检索返回值的机制。
第五章,与 Lua 表一起工作,训练你如何在 C++中处理 Lua 表。
第六章,如何从 Lua 调用 C++,详细解析了从 Lua 调用 C++代码的过程。
第七章,与 C++类型一起工作,深入探讨了将 C++类导出到 Lua。
第八章,抽象 C++类型导出器,指导你如何构建一个模板类,可以将任何 C++类导出到 Lua。
第九章,回顾 Lua-C++通信机制,回顾和总结了将 Lua 与 C++集成的机制。
第十章,资源管理,探讨了某些高级内存管理技术和资源管理原则。
第十一章,Lua 的多线程,使你能够在多线程环境中使用 Lua。
为了充分利用这本书
本书将构建一个 Lua 执行器。每一章将专注于一个领域,并为执行器添加一个功能组。如果提供了章节末尾的练习,那么这是为下一章做准备的关键部分。对于经验更丰富的读者来说,这就是为什么一些代码在最初章节中可能没有得到最佳实现。这将帮助你专注于当前的主题。
本书假设您了解 C++并且愿意学习 Lua。如果您没有成为 Lua 程序员的打算,您只需要关注相关的 Lua 概念,而不是 Lua 编程。
由于这本书不是专门教授任何编程语言的书籍,当你遇到不熟悉的某个 API 时,你可以从 Lua 或 C++参考手册中获取更多信息。你可以通过经常在线研究来填补知识空白,从而丰富你的学习之旅。
根据您的学习风格,您可以选择亲自输入所有示例以获得更多实践经验,或者从 GitHub 下载源代码,快速跳过章节,并在开始实现自己的项目时根据需要回到特定章节。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Integrate-Lua-with-CPP
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名、用户输入和程序输出。
以下是一个示例:“然后你使用std::variant
创建一个联合类型,表示这个联合类型可能是,并且只能是预定义的 Lua 映射之一。”
代码块是这样设置的:
void LuaExecutor::setRegistry(const LuaValue &key,
const LuaValue &value)
{
pushValue(key);
pushValue(value);
lua_settable(L, LUA_REGISTRYINDEX);
}
当我们希望您注意代码块中的某个特定部分时,相关的行或项目将以粗体显示:
int luaNew(lua_State *L)
{
....
if (type == LUA_TNIL)
{
...
lua_pushcfunction(L, luaDelete);
lua_setfield(L, -2, "__gc");
}
...
}
粗体:表示新术语、重要概念或关键软件。以下是一个示例:“所有具有图形用户界面的系统都将提供一个启动 shell 的应用程序。通常,这些应用程序被称为终端或控制台。”
小贴士或重要注意事项
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
请将customercare@packtpub.com
作为收件人,并在邮件主题中提及书籍标题。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。
请将copyright@packt.com
作为收件人,并在邮件中附上相关材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Lua 集成到 C++》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢随时随地阅读,但又无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取这些好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781805128618
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。
第一部分 – Lua 基础
本书本部分将指导您从源代码获取和编译 Lua 的过程。然后,您将探索一些设置 C++项目以使用 Lua 的选项。接下来,将简要介绍 Lua 编程语言。
因此,本部分的目标是让您为将 Lua 集成到 C++的实际工作做好准备。如果您已经熟悉其中的一些主题,可以自由跳过。
本部分包括以下章节:
-
第一章,使您的 C++项目 Lua 就绪
-
第二章,Lua 基础
第一章:使您的 C++ 项目 Lua 准备就绪
在本书的整个过程中,您将学习如何将 Lua 集成到您的 C++ 项目中。每一章都将基于前一章学到的知识。本章将教您如何准备一个 C++ 项目以集成 Lua,并介绍本书中使用的工具,以便您更好地理解示例。如果您已经知道如何使用一些工具,请随意跳过这些部分。如果您不知道,请在阅读本章后进行更深入的学习。
在本章中,我们将涵盖以下主题:
-
编译 Lua 源代码
-
使用 Lua 库构建 C++ 项目
-
使用 Lua 源代码构建 C++ 项目
-
执行简单的 Lua 脚本
-
其他工具链选项
技术要求
要跟随本章和本书,您将需要以下内容:
-
一个可工作的 C++ 编译器,最好是 GNU C++ 编译器 或 Clang/LLVM
-
一个构建自动化工具,最好是 Make
-
您选择的代码编辑器
-
Lua 源代码
-
本章的源代码:
github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter01
您不需要先前的 Lua 编程知识来理解本章内容。如果您对本章中 Lua 代码示例有任何疑问,那没关系;您可以将其视为 C++ 代码阅读,尽管存在语法差异。您将在阅读本书的过程中学习 Lua。虽然成为 Lua 专家会很有益,但如果您的重点是 C++ 方面,您不需要成为 Lua 专家。
我们决定使用开源编译器和构建工具来处理本书中的代码示例,因为它们对每个人来说都很容易获得,并且也是大多数大型项目中的首选工具。
如果你正在使用 Linux 或 Mac 开发机器,GNU C++ 编译器(或 Clang/LLVM)和 Make 应该已经安装。如果没有,请安装您系统支持的版本。如果您是 Windows 用户,您可以首先查看本章的最后部分:其他 工具链选项。
使用的构建工具称为 Make。在实际项目中,您可能会使用其他构建工具。但 Make 是一个没有其他依赖的基本选项,其他构建工具具有类似的思想,这使得它适合本书的目的。如果您愿意,您可以调整本书中的示例以适应您选择的另一个构建工具。
您可以从 www.lua.org/download.xhtml
下载 Lua 源代码。您可以选择特定的版本,但您很可能会希望使用最新版本。您也可以从 Lua 的 Git 仓库克隆源代码,这里:www.github.com/lua/lua
。然而,这并不是官方推荐的,因为 Lua 稳定且紧凑,变化不频繁。
编译 Lua 源代码
访问 Lua 语言的方式有很多。如果你使用 Linux,你可以通过发行版的包管理器安装 Lua 进行开发。对于 Windows,你也可以找到预构建的二进制文件。然而,由于我们的目标是把 Lua 集成到你的 C++ 项目中,而不是将其作为独立的解释器使用,最好是自己从源代码构建。在学习 Lua 的过程中,这将帮助你更好地了解 Lua。例如,在现代代码编辑器中,包括 Visual Studio Code,你可以轻松地检查 Lua 库函数的声明和实现。
在本节中,我们将专注于从源代码编译 Lua。解压缩下载的 Lua 源代码存档。大多数压缩工具都支持这一点,Lua 下载网站也提供了说明。当你完成这个步骤后,你会看到一个简单的文件夹结构:
lua-5.4.6 % ls
Makefile README doc src
在下一节中,我们将学习前面的代码块做了什么。
Lua 源代码包具有典型的 POSIX(想想 Linux 和 Unix)文件夹结构。
-
Makefile
是包的根Makefile
-
src
子文件夹包含源代码 -
doc
子文件夹包含文档
介绍 shell
在本节中,我们将学习 POSIX 机器上的 zsh
(Z
shell)。另一个流行的 shell 是 bash
,使用它你也可以直接运行本书中的示例。即使你使用 集成开发环境(IDE)并手动将示例适配到你的 IDE,了解 shell 命令的基本知识也能帮助你更好地理解示例。所有 IDE 内部都使用各种命令行程序来完成它们的工作,这与我们在 shell 中将要做的类似。
简单来说,shell 为系统提供了一个命令行界面。当你交互式地访问 shell 时,也可以说你在访问一个终端。shell 和终端这两个词有时可以互换使用,尽管在技术上它们是不同的事物。幸运的是,我们在这里不需要担心术语上的差异。所有具有图形用户界面的系统也会提供一个应用程序来启动 shell。通常,这些应用程序被称为 终端 或 控制台。
你可以启动一个 shell 并尝试以下命令。要找出你正在使用的 shell,请使用这个命令:
% echo $SHELL
/bin/zsh
输出 /bin/zsh
表示正在使用的 shell 是 Z
shell。
要进入一个目录,请使用以下命令:
cd ~/Downloads/lua-5.4.6
lua-5.4.6 %
cd
是用于更改当前工作目录的命令。这会进入 Lua 源代码文件夹。
而且,正如你之前看到的,ls
是列出目录内容的命令:
lua-5.4.6 % ls
Makefile README doc src
另一个重要的事情是 %
符号。它表示 shell 提示符,不同的 shell 或用户角色可能会看到不同的符号。%
前面的部分是当前工作目录,%
后面的部分是你将在终端中输入的命令。
本节仅提供一个简要说明。如果你遇到你不知道的 shell 命令,可以在网上查找。
构建 Lua
在 shell 终端中,进入未解压的 Lua 源代码文件夹,并执行make all test
。如果你的工具链被检测为正常工作,你将已编译 Lua 库和命令行解释器。现在,让我们检查感兴趣的文件:
lua-5.4.6 % ls src/*.a src/lua
src/liblua.a src/lua
liblua.a
是你可以链接的 Lua 库。lua
是 Lua 命令行解释器。
让我们现在尝试解释器,看看我们是否可以成功运行它:
lua-5.4.6 % src/lua
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> 1+1
2
> os.exit()
在终端中,执行src/lua
以启动交互式 Lua 解释器。首先,输入1+1
:Lua 将返回结果2
。然后输入os.exit()
以退出 Lua 解释器。
你现在已成功从源代码编译了 Lua 库。接下来,我们将看看如何在你的项目中使用它。
使用 Lua 库构建 C++项目
使用 Lua 库构建你的 C++项目的好处是不必在你的项目和源代码管理系统中包含 100 多个 Lua 源文件。然而,它也有一些缺点。例如,如果你的项目需要支持多个平台,你需要维护多个预编译的库。在这种情况下,从 Lua 源代码构建可能更容易。
创建一个与 Lua 库一起工作的项目
在上一节中,我们从源代码构建了 Lua 库。现在,让我们将其提取出来以在我们的项目中使用。
记得源代码文件夹根目录下的Makefile
吗?打开它,你会看到这里显示的两行:
TO_INC= lua.h luaconf.h lualib.h lauxlib.h lua.hpp
TO_LIB= liblua.a
这些是你需要的头文件和静态库。
为你的项目创建一个文件夹。在其内部,创建一个名为main.cpp
的空源文件、一个空的Makefile
以及两个名为include
和lib
的空文件夹。将头文件复制到include
文件夹,将库文件复制到lib
文件夹。项目文件夹应看起来像这样:
project-with-lua-lib % tree
.
├── Makefile
├── lua
│ ├── include
│ │ ├── lauxlib.h
│ │ ├── lua.h
│ │ ├── lua.hpp
│ │ ├── luaconf.h
│ │ └── lualib.h
│ └── lib
│ └── liblua.a
└── main.cpp
如果你使用 Linux,tree
是一个用于打印文件夹层次结构的 shell 程序。如果你没有安装tree
,无需担心。你也可以检查你喜欢的 IDE 中的文件夹结构。
编写 C++代码
我们将编写一个简单的 C++程序来测试我们是否可以链接到 Lua 库。在 C++中,你只需要包含一个 Lua 的头文件:lua.hpp
。
按照以下内容编写main.cpp
:
#include <iostream>
#include <lua.hpp>
int main()
{
lua_State *L = luaL_newstate();
std::cout << "Lua version number is "
<< lua_version(L)
<< std::endl;
lua_close(L);
return 0;
}
上述源代码打开 Lua,打印其构建号,然后关闭 Lua。
编写 Makefile
作为第一个项目的部分,编写Makefile
非常简单。让我们利用这个机会来学习更多关于Makefile
的细节,如果你还没有很好地理解它的话。更多信息,你可以查看官方网站www.gnu.org/software/make/
.
按照以下代码编写Makefile
:
project-with-lua-lib: main.cpp
g++ -o project-with-lua-lib main.cpp -Ilua/include \
-Llua/lib -llua
这是一个非常基础的 Makefile
。在实际项目中,你需要一个更复杂的 Makefile
来使其更加灵活。你将在本章后面看到更灵活的示例。在这里,重点是第一次接触时的简单性。这个初始的 Makefile
包含以下元素:
-
project-with-lua-lib
是一个Makefile
目标。你可以在一个Makefile
中定义任意数量的目标。当你不指定明确的目标而调用make
时,它将执行文件中定义的第一个目标。 -
main.cpp
是目标的依赖项。你可以依赖于另一个目标或文件。你可以根据需要添加任意数量的依赖项。 -
目标调用
g++
命令来编译main.cpp
并将其与 Lua 库链接。在目标命令之前,你需要使用制表符而不是空格。 -
-o project-with-lua-lib
指定了编译后的可执行文件名称。你可以将其更改为任何你想要的名称。 -
-Ilua/include
将lua/include
添加到头文件的搜索路径。 -
-Llua/lib
将lua/lib
添加到库的链接搜索路径。 -
-llua
告诉链接器链接到静态 Lua 库:liblua.a
。
测试项目
在终端中,执行 make
来构建项目。然后执行 ./project-with-lua-lib
来运行编译后的项目:
project-with-lua-lib % make
project-with-lua-lib % ./project-with-lua-lib
Lua version number is 504
如前述代码所示,C++ 程序将执行并打印:Lua 版本号
是 504
。
恭喜!你已经通过链接预编译的 Lua 库完成了第一个 C++ 项目。在下一节中,我们将探讨如何直接使用 Lua 源代码来避免本节开头提到的缺点。
使用 Lua 源代码构建 C++ 项目
使用 Lua 源代码构建你的 C++ 项目的好处是它始终作为项目的一部分进行编译,并且不存在由于编译器不兼容性而产生的意外情况。
与链接预编译的 Lua 库相比的主要区别是,我们现在将首先从源代码编译 Lua 库。最好在不修改它或只将一些选定的文件复制到新的文件夹层次结构中时使用源代码包。这将在未来帮助你,如果你需要使用更新的 Lua 版本。在这种情况下,你只需要用新版本替换 Lua 源代码文件夹。
创建一个用于与 Lua 源代码一起工作的项目
要使用 Lua 源代码创建一个项目,我们需要回到 Lua 源代码包:
lua-5.4.6 % ls
Makefile README doc src
你需要 src
子文件夹。
为新项目创建一个文件夹。在其内部,创建一个空的 main.cpp
和一个空的 Makefile
,并将前面 shell 输出中显示的 src
子文件夹作为项目文件夹中的 lua
子文件夹复制过来。项目结构应如下所示:
project-with-lua-src % tree
.
├── Makefile
├── lua
│ ├── Makefile
│ ├── lapi.c
│ ├── ...
│ ├── lzio.c
│ └── lzio.h
└── main.cpp
编写 C++ 代码
你可以以与使用 Lua 库构建时完全相同的方式编写 C++ 代码。
编写 Makefile
让我们比上一个项目更进一步,为 Makefile
编写两个目标,如下所示:
project-with-lua-src: main.cpp
cd lua && make
g++ -o project-with-lua-src main.cpp -Ilua -Llua -llua
clean:
rm -f project-with-lua-src
cd lua && make clean
这个 Makefile
首先进入 lua
子文件夹,然后构建 Lua 库。之后,它编译 C++ 代码并将其与 Lua 库链接。lua
子文件夹是 Lua 源代码存档中 src
文件夹的副本。如果你不小心将整个存档复制到那里,你可能会看到一些编译错误。
Makefile
还包括一个 clean
目标。这将删除编译文件。通常,所有构建系统都会实现一个 clean
目标。
测试项目
在终端中输入 make
来构建项目。然后输入 ./project-with-lua-src
来执行编译后的项目:
project-with-lua-src % make
project-with-lua-src % ./project-with-lua-arc
Lua version number is 504
C++ 程序将执行并打印:Lua 版本号
是 504
。
测试 clean 目标
由于我们已经实现了 clean
目标,让我们也测试一下:
project-with-lua-src % make clean
rm -f project-with-lua-src
cd lua && make clean
rm -f liblua.a lua luac lapi.o lcode.o lctype.o ldebug.o
ldo.o ldump.o lfunc.o lgc.o llex.o lmem.o lobject.o
lopcodes.o lparser.o lstate.o lstring.o ltable.o ltm.o
lundump.o lvm.o lzio.o lauxlib.o lbaselib.o lcorolib.o
ldblib.o liolib.o lmathlib.o loadlib.o loslib.o lstrlib.o
ltablib.o lutf8lib.o linit.o lua.o luac.o
这通过删除编译器生成的文件来清理工作文件夹。在一个生产就绪的项目中,你会在构建过程中首先将所有中间文件输出到单独的文件夹中,最可能命名为 build
或 output
,并将该文件夹排除在源代码控制系统之外。
到目前为止,我们已经学习了两种将 Lua 集成到 C++ 项目中的方法。接下来,让我们学习如何从 C++ 中执行实际的 Lua 脚本。
执行简单的 Lua 脚本
要执行 Lua 脚本,你可以选择使用 Lua 库或 Lua 源代码。对于生产项目,我个人推荐使用源代码。对于学习目的,两种方式都可以。本书的其余部分我们将使用 Lua 源代码。
你能注意到这个吗?
即使你选择使用 Lua 源代码,在 Makefile
中,你首先将 Lua 源代码构建成 Lua 库,然后让你的项目链接到该库。与直接使用 Lua 库相比,使用 Lua 源代码只是在你项目中多进行一步操作。你可以更多地关注它们的相似之处,而不是差异。
现在,让我们看看一个更通用的项目结构。
创建项目
正如所说,接下来的章节中会有更复杂的项目。现在,我们将探索一个更通用的项目结构。我们将在一个共享位置构建和链接 Lua,而不是为每个项目创建副本。
以下是在其父文件夹中显示的项目结构:
% tree
.
├── Chapter01
│ ├── execute-lua-script
│ │ ├── Makefile
│ │ ├── main.cpp
│ │ └── script.lua
│ ├── project-with-lua-lib
│ └── project-with-lua-src
└── lua
├── Makefile
├── README
├── doc
└── src
本项目相关的两个文件夹如下:
-
execute-lua-script
文件夹包含主项目,其中包含一个 C++ 源文件、一个Makefile
和一个 Lua 脚本文件 -
lua
文件夹包含 Lua 源代码包,它是未解压的,直接使用。
显示的其他文件夹表明了本书源代码的组织方式——首先按章节,然后按项目。遵循确切的结构是可选的,只要你能让它工作。
编写 Makefile
我们在前两个项目中看到了两个简单的 Makefile
。让我们编写一个更灵活的 Makefile
,如下所示:
LUA_PATH = ../../lua
CXX = g++
CXXFLAGS = -Wall -Werror
CPPFLAGS = -I ${LUA_PATH}/src
LDFLAGS = -L ${LUA_PATH}/src
EXECUTABLE = executable
all: lua project
lua:
@cd ${LUA_PATH} && make
project: main.cpp
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) \
-o $(EXECUTABLE) main.cpp -llua
clean:
rm -f $(EXECUTABLE)
这个 Makefile
更灵活,应该足够用作学习目的的模板。它在以下方面与之前的版本不同:
-
在文件开头定义了一些变量。好处是在实际项目中,你可能需要编译多个文件。这样,你就不需要在每个目标中重复自己。这也更易于阅读。
-
默认目标,传统上命名为
all
,依赖于两个其他目标。 -
在
lua
目标中,命令前有一个@
符号。当make
执行时,这将阻止在终端中打印出命令内容。 -
LUA_PATH
是相对于此Makefile
所在文件夹的 Lua 源代码路径。 -
CXX
是定义 C++ 编译器程序的常规变量名。使用CC
作为 C 编译器。 -
CXXFLAGS
定义了提供给 C++ 编译器的参数。使用CFLAGS
作为 C 编译器。 -
CPPFLAGS
定义了提供给 C 预处理器的参数,C++ 与 C 共享相同的预处理器。 -
LDFLAGS
定义了提供给链接器的参数。在某些开发系统中,你可能需要在-o $(EXECUTABLE)
后放置LDFLAGS
。
Makefile
准备就绪后,让我们编写 C++ 代码。
编写 C++ 代码
要执行 Lua 脚本,我们需要一些实际的操作,我们可以通过调用一些 Lua 库函数来获取这些操作。按照以下方式编写 main.cpp
:
#include <iostream>
#include <lua.hpp>
int main()
{
lua_State *L = luaL_newstate();
luaL_openlibs(L);
if (luaL_loadfile(L, "script.lua") ||
lua_pcall(L, 0, 0, 0))
{
std::cout << "Error: "
<< lua_tostring(L, -1)
<< std::endl;
lua_pop(L, 1);
}
lua_close(L);
return 0;
}
代码执行以下操作:
-
使用
luaL_newstate
打开 Lua 并创建 Lua 状态。 -
使用
luaL_openlibs
打开 Lua 标准库。 -
使用
luaL_loadfile
加载名为script.lua
的 Lua 脚本,并使用lua_pcall
执行它。我们很快就会编写script.lua
。 -
如果脚本执行失败,则输出错误。这是在
if
子句中完成的。 -
使用
lua_close
关闭 Lua。
这里使用的 Lua 函数以及 Lua 状态将在 第三章 中详细解释。
测试项目
如果你创建了一个空的 script.lua
,请删除它。编译并运行项目:
execute-lua-script % make
execute-lua-script % ./executable
Error: cannot open script.lua: No such file or directory
如预期的那样,它说 script.lua
未找到。别担心。你将在下一节编写它。需要注意的是,你已经完成了项目的 C++ 部分的编码,并且已经编译了项目。
编写 Lua 脚本
按照下面显示的单一行编写 script.lua
:
print("Hello C++!")
这将打印 Hello C++!
。
再次执行项目,而无需重新编译 C++ 代码:
execute-lua-script % ./executable
Hello C++!
恭喜!你现在已经从一个 C++ 程序中执行了 Lua 脚本,并在编译后改变了 C++ 程序的行为。
下一节提供了一些设置不同开发环境的想法。
其他工具链选项
如果你没有访问原生 POSIX 系统的权限,有许多其他工具链选项。这里我们给出了两个例子。因为你的开发平台可能不同,操作系统更新和情况会变化,这些只提供了一些想法。你总是可以在线研究并实验,以获得适合自己的舒适设置。
使用 Visual Studio 或 Xcode
Lua 源代码是用 C 编写的,不需要其他依赖。你可以将 Lua 源代码包中的 src
文件夹复制到 Visual Studio 或 Xcode 中,直接将其添加到你的项目中,或者将其配置为你的主项目所依赖的 Lua 项目。根据需要调整项目设置。这是完全可行的。
无论你选择使用哪个 IDE,记得检查其许可证,看看你是否可以使用该 IDE 来满足你的需求。
使用 Cygwin
如果你使用 Windows,你可以获取 Cygwin 来获得 POSIX 的体验:
-
从
sourceware.org/cygwin/
下载 Cygwin 安装程序并运行它。 -
在选择软件包时,搜索名为
make
和gcc-g++
的两个软件包。选择它们进行安装。 -
稍微修改所有与 Lua 相关的 shell 命令和项目
Makefiles
。你需要明确构建库的 Linux 版本。例如,将cd lua && make
改为cd lua && make linux
。这是因为 Lua 的Makefile
无法检测 Cygwin 是 Linux 版本。 -
打开 Cygwin 终端,你可以像本章中的示例一样构建和运行项目。
摘要
在本章中,我们学习了如何编译 Lua 源代码,如何链接到 Lua 库,以及如何直接将 Lua 源代码包含到你的项目中。最后,我们从 C++ 代码中执行了一个 Lua 脚本。通过亲自遵循这些步骤,你应该能够舒适和自信地将 Lua 包含到你的 C++ 项目中,并为更复杂的工作做好准备。
在下一章中,我们将学习 Lua 编程语言的基础知识。如果你已经熟悉 Lua 编程语言,可以自由地跳过第二章,即“Lua 基础”。我们将在第三章,即“如何从 C++ 调用 Lua”中回到 Lua 与 C++ 之间的通信问题。
第二章:Lua 基础知识
在本章中,我们将学习 Lua 编程语言的基础知识。如果你只专注于 C++ 端,你不需要成为 Lua 专家,甚至不需要编写任何 Lua 代码。然而,了解基础知识将使你在将 Lua 集成到 C++ 中时更加高效。
如果你已经了解 Lua 编程,你可以跳过本章。如果你有一段时间没有使用 Lua,你可以使用本章来复习。如果你想了解更多关于 Lua 编程的知识,你可以获取官方 Lua 书籍:Programming in Lua。如果你不知道 Lua 编程,本章是为你准备的。如果你来自 C++,你可以阅读本书中关于 Lua 代码的简要说明来阅读任何 Lua 代码。当你需要时,你可以相信自己并在线上进行研究。
我们将讨论以下语言特性:
-
变量和类型
-
控制结构
技术要求
你将使用交互式 Lua 解释器来跟随本章中的代码示例。我们是从 第一章 中的 Lua 源代码构建的。你也可以使用来自其他渠道的 Lua 解释器,例如,由你的操作系统包管理器安装的那个。在继续之前,请确保你有访问权限。
当你在本章中看到代码示例,如以下示例,你应该在交互式 Lua 命令行中尝试该示例:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> os.exit()
%
Lua 解释器启动时输出的第一行是。使用 os.exit()
退出解释器。
你可以在本书的 GitHub 仓库中找到本章的源代码:github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter02
变量和类型
虽然你可能很清楚 C++ 是一种静态类型语言,但 Lua 是一种动态类型语言。在 C++ 中,当你声明一个变量时,你会给它一个明确的类型。在 Lua 中,每个值都携带自己的类型,你不需要显式指定类型。此外,在引用它之前,你不需要定义全局变量。尽管鼓励你声明它——或者更好的是,使用局部变量。我们将在本章后面学习局部变量。
在 Lua 中,有八个基本类型:nil、boolean、number、string、userdata、function、thread 和 table。
在本章中,我们将学习其中的六个:nil
、boolean
、number
、string
、table
和 function
。在我们深入了解之前,让我们尝试一些:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> a
nil
> type(a)
nil
> a = true
> type(a)
boolean
> a = 6
> type(a)
number
> a = "bonjour"
> type(a)
string
这里发生的情况如下:
-
交互式地,输入
a
检查全局变量a
的值。由于它尚未定义,其值为nil
。 -
使用
type(a)
检查变量a
的类型。在这种情况下,值是nil
,因为它尚未定义。 -
将
true
赋值给a
。使用=
进行赋值;与 C++ 中的用法相同。 -
现在,它的类型是
boolean
。 -
将
6
赋值给a
。 -
现在,它的类型是
number
。 -
将
"bonjour"
赋值给a
。 -
现在,它的类型是
string
。
在那里执行的每一行也是 Lua 语句。与 C++ 语句不同,你不需要在语句末尾放置分号。
接下来,我们将学习更多关于这些类型的内容。
空值
nil
类型只有一个值来表示非值:nil
。它的意义与 C++ 中的 nullptr(或 NULL)类似。
布尔值
boolean
类型有两个值。它们是 true 和 false。它在 Lua 中的行为与 C++ 中的 bool 相同。
数字
number
类型涵盖了 C++ 的 int、float 和 double 以及它们的变体(例如 long)。
在这里,我们还将学习 Lua 的算术运算符和关系运算符,因为它们主要用于数字。
算术运算符
算术运算符是执行数字算术运算的运算符。Lua 支持 六个 算术运算符:
-
+
: 加法 -
-
: 减法 -
*
: 乘法 -
/
: 除法 -
%
: 取模 -
//
: 向下取整除法
C++ 有七个算术运算符:+、-、*、/、%、++ 和 --。Lua 的算术运算符与它们的 C++ 对应项类似,但有以下不同之处:
-
没有自增(++)或自减(--)运算符。
-
C++ 没有返回除法结果整数部分的
//
运算符。由于 C++ 是强类型语言,C++ 可以通过正常除法隐式地实现相同的功能。以下是一个例子:Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> 5 / 3
1.6666666666667
> 5 // 3
1
-
注意 Lua 是一种动态类型语言。这意味着 5 / 3 不会像 C++ 那样产生 1。
关系运算符
关系运算符是测试两个值之间某种关系的运算符。Lua 支持 六个 关系运算符:
-
<
: 小于 -
>
: 大于 -
<=
: 小于或等于 -
>=
: 大于或等于 -
==
: 等于 -
~=
: 不等于
~=
运算符测试不等于。这与 C++ 中的 !=
运算符相同。其他运算符与 C++ 中的相同。
字符串
在 Lua 中,字符串始终是常量。你不能改变字符串中的一个字符并使其代表另一个字符串。你需要为那个新字符串创建一个新的字符串。
我们可以用双引号或单引号界定字面量字符串。其余部分与 C++ 字符串非常相似,如下例所示:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> a = "Hello\n" .. 'C++'
> a
Hello
C++
> #a
9
字符串上有两个在 C++ 中找不到的运算符。要连接两个字符串,可以使用 ..
运算符。要检查字符串的长度,可以使用 #
运算符。
与 C++ 转义序列类似,使用 \
转义特殊字符,如前一个输出中的换行符所示。如果你不想插入换行转义序列 \n
,可以使用长字符串。
长字符串
你可以使用 [[
和 ]]
来界定多行字符串,例如:
a = [[
Hello
C++
]]
这定义了一个等于单行定义 "Hello\nC++\n"
的字符串。
长字符串可以使字符串更易于阅读。你可以在 Lua 源代码中使用长字符串轻松定义 XML 或 JSON 字符串。
如果你的长字符串内容中包含 [[
或 ]]
,你可以在开括号之间添加一些等号,例如:
a = [=[
x[y[1]]
]=]
b = [==[
x[y[2]]
]==]
你可以添加多少个等号取决于你。然而,通常一个就足够了。
表
Lua 表类似于 C++ std::map 容器,但更灵活。表是 Lua 中构建复杂数据结构的唯一方式。
让我们尝试一个带有一些操作的 Lua 表。在一个 Lua 解释器中,逐个输入以下语句并观察输出:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> a = {}
> a['x'] = 100
> a['x']
100
> a.x
100
使用 {}
构造函数创建一个表,并将值 100
赋给字符串键 'x'
。另一种构建此表的方法是 a = {x = 100}
。要使用更多键初始化表,请使用 a = {x = 100, y = 200}
。
a.x
是 a['x']
的另一种语法。你使用哪种取决于你的风格偏好。但通常,点语法意味着表被用作记录或面向对象的方式。
除了 nil
之外,你可以使用所有 Lua 类型作为表键。你还可以在同一个表中使用不同类型的值。你拥有完全的控制权,如下所示:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> b = {"Monday", "Tomorrow"}
> b[1]
Monday
> b[2]
Tomorrow
> #b
2
> a = {}
> b[a] = "a table"
> b[a]
a table
> b.a
nil
> #b
2
此示例解释了与表相关的四个要点:
-
它首先以数组的形式创建一个表。请注意,它是从 1 开始索引的,而不是从 0 开始。当你向表构造函数提供一个仅包含值的条目时,它将被视为数组的一部分。你还记得
#
是长度运算符吗?当它用于表示序列或数组时,它可以告诉表的长度。 -
然后它使用另一个表
a
作为键和"a table"
字符串作为值添加另一个条目。这是完全可以的。 -
注意,
b.a
是nil
,因为b.a
表示使用'a'
字符串键的b['a']
,而不是b[a]
。 -
最后,我们再次尝试检查表长度。我们在表中添加了 3 个条目,但它输出长度为 2。来自 C++ 的你可能感到惊讶:Lua 不提供检查表长度的内置方式。长度运算符仅在表表示序列或数组时提供便利。你能够同时将表用作数组和映射,但你需要承担全部责任。
在本章的后面部分,当我们学习 for
控制结构时,我们将了解更多关于表遍历的内容。现在我们将学习 Lua 函数。
函数
Lua 函数与 C++ 函数具有类似的作用。但与 C++ 不同,它们也是基本数据类型之一的一等公民。
我们可以通过以下方式定义一个函数:
-
从
function
关键字开始。 -
后面跟着一个函数名和一对括号,其中可以定义所需的函数参数。
-
实现函数体。
-
使用
end
关键字结束函数定义。
例如,我们可以定义一个函数如下:
function hello()
print("Hello C++")
end
这将输出 "Hello C++"
。
要定义一个用于 Lua 解释器的函数,你有两种选择:
-
在交互式解释器中,只需开始输入函数。当你结束每一行时,解释器会知道,你可以继续输入函数定义的下一行。
-
或者,你可以在另一个文件中定义你的函数,该文件可以在以后导入到解释器中。这样做更容易工作。从现在开始,我们将使用这种方法。尽量将你的函数放在名为
1-functions.lua
的文件中的这个部分。
要调用函数,使用其名称和一对括号。这和调用 C++ 函数的方式一样,例如:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> dofile("Chapter02/1-functions.lua")
> hello()
Hello C++
dofile()
是 Lua 库中用于加载另一个 Lua 脚本的方法。在这里,我们加载定义了 Lua 函数的文件。如果你已经更改了脚本文件,你可以再次执行它来加载最新的脚本。
接下来,我们将学习关于函数参数和函数返回值的内容。
函数参数
函数参数,也称为参数,是在函数被调用时提供给函数的值。
你可以通过在函数名后面的括号内提供参数声明来定义函数参数。这和在 C++ 中一样,但你不需要提供参数类型,例如:
function bag(a, b, c)
print(a)
print(b)
print(c)
end
当调用函数时,你可以传递比定义的更多或更少的参数。例如,你可以调用我们刚刚定义的 bag
函数:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> dofile("Chapter02/1-functions.lua")
> bag(1)
1
nil
nil
> bag(1, 2, 3, 4)
1
2
3
你可以看到当提供的参数数量与定义的数量不同时会发生什么:
-
当传递的参数不足时,剩余的参数将具有
nil
值。 -
当传递的参数多于定义的参数时,额外的参数将被丢弃。
你不能为函数参数定义默认值,因为 Lua 在语言级别不支持它。但你可以检查你的函数,如果参数是 nil
,则分配给它默认值。
函数结果
你可以使用 return
关键字来返回函数结果。可以返回多个值。让我们定义两个函数,分别返回一个和两个值:
function square(a)
return a * a
end
function sincos(a)
return math.sin(a), math.cos(a)
end
第一个函数返回给定参数的 square
。第二个函数返回给定参数的 sin
和 cos
。让我们尝试一下我们的两个函数:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> dofile("Chapter02/1-functions.lua")
> square(2)
4
> sincos(math.pi / 3)
0.86602540378444 0.5
你可以从输出中看到,函数分别返回一个和两个值。在这个例子中,math.pi
、math.sin
和 math.cos
来自 Lua 的 math
库,该库默认在交互式解释器中加载。你有没有想过如何为我们的 sincos
函数创建一个基本库?
将函数放入表中
从整体的角度来看,Lua 的 math
库——以及任何其他库——只是包含函数和常量值的表。你可以定义自己的:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> dofile("Chapter02/1-functions.lua")
> mathx = {sincos = sincos}
> mathx.sincos(math.pi / 3)
0.86602540378444 0.5
> mathx["sincos"]
function: 0x13ca052d0
> mathx"sincos"
0.86602540378444 0.5
我们在这里创建了一个名为 mathx
的表,并将我们的 sincos
函数分配给 "``sincos"
键。
现在你已经知道如何创建自己的 Lua 库。为了完成我们对 Lua 类型的介绍,让我们看看为什么我们应该使用局部变量。
局部变量和作用域
到目前为止,我们一直在使用全局变量,因为我们只需要引用一个,对吧?是的,这很方便。但缺点是它们永远不会超出作用域,并且可以被所有函数访问,无论它们是否相关。如果你来自 C++ 背景,你不会同意这一点。
我们可以在 for
循环内使用 if
分支,或在函数内使用。
局部变量对于防止全局环境污染很有用。尝试定义两个函数来测试这一点:
function test_variable_leakage()
abc_leaked = 3
end
function test_local_variable()
local abc_local = 4
end
在第一个函数中,没有使用局部变量,因此将创建一个名为 abc_leaked
的全局变量。在第二个函数中,使用了一个局部变量——abc_local
——它将在其函数作用域之外不可知。让我们看看效果:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> dofile("Chapter02/1-functions.lua")
> abc_leaked
nil
> test_variable_leakage()
> abc_leaked
3
> test_local_variable()
> abc_local
nil
从输出中,我们可以验证以下:
-
首先,我们尝试第一个没有使用局部变量的函数。在调用函数之前,我们验证没有名为
abc_leaked
的全局变量。调用函数后,创建了一个全局变量——abc_leaked
。 -
然后我们尝试使用局部变量的第二个函数。在这种情况下,没有创建全局变量。
当你可以时,你应该始终使用局部变量。接下来,让我们熟悉 Lua 的控制结构。
控制结构
Lua 控制结构与 C++ 控制结构非常相似。在学习它们的时候,尝试将它们与它们的 C++ 对应物进行比较。
对于本节中显示的代码,你可以将它们放入另一个名为 2-controls.lua
的 Lua 脚本文件中,并在 Lua 解释器中使用 dofile
导入。你可以将每个示例放入一个单独的函数中,这样你就可以使用不同的参数测试代码。到现在为止,你应该已经熟悉了 Lua 解释器,所以我们不会在本章的其余部分展示如何使用它。
我们将首先探索如何在 Lua 中进行条件分支,然后我们将尝试循环。
if then else
Lua 的 if
控制结构类似于 C++ 的。然而,你不需要在测试条件周围使用括号,也不使用花括号。相反,你需要使用 then
关键字和 end
关键字来界定代码分支,例如:
if a < 0 then a = 0 end
if a > 0 then return a else return 0 end
if a < 0 then
a = 0
print("changed")
else
print("unchanged")
end
如果没有操作,else
分支是可选的。如果你每个分支只有一个语句,你也可以选择将所有内容写在一行中。
Lua 语言设计强调简洁性,因此 if
控制结构是唯一的条件分支控制。如果你想要实现类似于 C++ 的 switch 控制结构,怎么办呢?
模拟 switch
Lua 中没有 switch 控制结构。为了模拟它,你可以使用 elseif
。以下代码就是这样做的:
if day == 1 then
return "Monday"
elseif day == 2 then
return "Tuesday"
elseif day == 3 then
return "Wednesday"
elseif day == 4 then
return "Thursday"
elseif day == 5 then
return "Friday"
elseif day == 6 then
return "Saturday"
elseif day == 7 then
return "Sunday"
else
return nil
end
这与 C++ 的 if..else if
控制结构行为相同。if
和 elseif
条件将逐个检查,直到满足一个条件并返回一周中某天的名称。
while
Lua 的 do
关键字和 end
关键字。以下示例打印出一周中的日子:
local days = {
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"
}
local i = 1
while days[i] do
print(days[i])
i = i + 1
end
我们声明一个名为 days
的表,并使用它作为数组。当 i
索引达到 8
时,循环将结束,因为 days[8]
是 nil
并测试为 false
。来自 C++ 的你可能想知道为什么我们可以访问一个七元素数组的第八个元素。在 Lua 中,以这种方式访问表时,没有索引越界的问题。
你可以使用 break
立即结束循环。这对 repeat
循环和 for
循环都适用,我们将在下面解释。
repeat
Lua 的 do..while
控制结构也这样做,但结束条件被处理得不同。Lua 使用 until
条件来结束循环,而不是 C++ 的 while
。
让我们实现之前为 while
控制结构展示的相同代码,但这次使用 repeat
:
local days = {
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"
}
local i = 0
repeat
i = i + 1
print(days[i])
until i == #days
#days
返回 day
数组的长度。repeat..until
中的代码块将循环,直到 i
达到这个长度。
注意
请记住,对于 Lua 数组,索引从 1 开始。
要在 C++ 中使用 do..while
实现相同的代码,请执行以下操作:
const std::vector<std::string> days {
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"
};
size_t i = 0;
do {
std::cout << days[i] << std::endl;
i++;
} while (i < days.size());
C++ 的实现看起来与 Lua 版本非常相似,除了前面提到的结束条件:i < days.size()
。我们检查的是小于,而不是等于。
for, 数值
数值 for 循环遍历一个数字列表。它具有以下形式:
for var = exp1, exp2, exp3 do
do_something
end
-
var
被视为作用域限于for
块的局部变量。 -
exp1
是起始值。 -
exp2
是结束值。 -
exp3
是步长,是可选的。如果没有提供,则默认步长为 1。
为了更好地理解这一点,让我们看一个例子:
local days = {
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"
}
for i = 1, #days, 4 do
print(i, days[i])
end
i
是局部变量,初始值为 1
。当 i
变得大于 #days
时,循环将结束。还提供了一个步长 4
。因此,每次迭代后,效果是 i = i + 4
。一旦运行此代码,你就会发现只有星期一和星期五被打印出来。
也许会让你惊讶,浮点类型也可以工作:
Lua 5.4.6 Copyright (C) 1994-2022 Lua.org, PUC-Rio
> for a = 1.0, 4.0, 1.5 do print(a) end
1.0
2.5
4.0
如输出所示,for
循环从 1.0
开始打印,每次增加 1.5
,只要值不大于 4.0
。
for, 通用
通用 for
循环遍历由 迭代函数
返回的所有值。这种形式的 for
循环在遍历表时非常方便。
当我们讨论数值 for
循环时,我们看到了它们如何遍历基于索引的表。然而,Lua 中的表可以不仅仅是数组。表上最常见的迭代器是 pairs
和 ipairs
。它们返回表中的键值对。pairs
返回的键值对顺序未定义,就像大多数哈希表实现一样。ipairs
返回排序后的键值对。
即使对于基于索引的表,如果你想遍历所有内容,通用的 for
循环也可以更方便:
local days = {
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"
}
for index, day in pairs(days) do
print(index, day)
end
这个循环遍历整个数组,而不需要引用数组长度。pairs
迭代器逐个返回键值对,直到枚举表中的所有元素。之后,循环结束。
摘要
在本章中,我们学习了 Lua 的八种数据类型中的六种和四种控制结构。我们还学习了局部变量以及为什么你应该使用它们。这些知识将为你阅读本书的其余部分做好准备。
到现在为止,你应该能够阅读和理解大多数 Lua 代码。一些细节和主题故意没有包含在本章中。当你遇到它们时,你可以了解更多关于它们的信息。
在下一章中,我们将学习如何从 C++ 调用 Lua 代码。
练习
-
在 Lua 参考手册中定位标准字符串操作库。了解
string.gmatch
、string.gsub
和模式匹配。什么模式代表所有非空格字符? -
使用
string.gmatch
和一个通用的for
循环,反转句子 “C++ loves Lua.” 输出应该是 “Lua loves C++。” -
你能使用
string.gsub
并用一行代码实现相同的功能吗?
参考文献
官方 Lua 参考手册:www.lua.org/manual/5.4/
第二部分 – 从 C++ 调用 Lua
现在你已经熟悉了使用 Lua 设置 C++ 项目,你将开始学习如何从 C++ 调用 Lua 代码。
你将开始实现一个通用的 C++ 工具类来加载和执行 Lua 代码。首先,你将学习如何加载 Lua 脚本和调用 Lua 函数。然后,你将探索如何向 Lua 函数传递参数和处理返回值。最后,你将深入了解如何与 Lua 表一起工作。
本部分包括以下章节:
-
第三章,如何从 C++ 调用 Lua
-
第四章,将 Lua 类型映射到 C++
-
第五章,与 Lua 表一起工作
第三章:如何从 C++调用 Lua
在本章中,我们将实现一个 C++实用类来执行 Lua 脚本。这有两个目的。首先,通过这样做,你将详细了解如何集成 Lua 库并从 C++调用 Lua 代码。其次,你将拥有一个现成的 Lua 包装类。这有助于隐藏所有细节。我们将从一个基本的 Lua 执行器开始,然后随着我们的进展逐步添加更多功能。你将了解以下内容:
-
实现 Lua 执行器
-
执行 Lua 文件
-
执行 Lua 脚本
-
理解 Lua 堆栈
-
操作全局变量
-
调用 Lua 函数
技术要求
从本章开始,我们将更多地关注代码和 Lua 集成本身,并对工具链和项目设置进行简要说明。然而,你始终可以参考本书的 GitHub 仓库以获取完整的项目。请确保你满足以下要求:
-
你需要能够从源代码编译 Lua 库。第一章介绍了这一点。
-
你需要能够编写一些基本的 Lua 代码来测试我们将编写的 C++类。第二章介绍了这一点。
-
你可以创建一个
Makefile
项目,或者使用其他替代方案。在第一章中,我们创建了三个Makefile
项目。我们将为这一章创建一个新的项目。 -
你可以在此处访问本章的源代码:
github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter03
实现 Lua 执行器
我们将逐步实现一个可重用的 C++ Lua 执行器类。让我们称它为LuaExecutor
。我们将通过向其中添加新功能来继续改进这个执行器。
如何在 C++代码中包含 Lua 库
要与 Lua 库一起工作,你只需要三个头文件:
-
lua.h
用于核心函数。这里所有内容都有lua_
前缀。 -
lauxlib.h
用于辅助库(auxlib
)。辅助库在lua.h
中的核心函数之上提供了更多辅助函数。这里所有内容都有luaL_
前缀。 -
lualib.h
用于加载和构建 Lua 库。例如,luaL_openlibs
函数打开所有标准库。
Lua 是用 C 语言实现的,这三个头文件都是 C 语言的头文件。为了与 C++一起工作,Lua 提供了一个方便的包装器,lua.hpp
,其内容如下:
extern "C" {
#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
}
在你的 C++代码中,lua.hpp
是唯一需要包含的 Lua 头文件。解决这个问题后,让我们开始我们的 Lua 执行器工作。
C++文件名扩展名
Lua 库使用hpp
作为lua.hpp
的头文件扩展名。这是为了将其与其他 Lua 库的 C 头文件区分开来。在本书中,对于我们的 C++代码,我们使用h
作为声明头文件的扩展名,hpp
用于包含所有实现的头文件,cc
用于 C++类实现,cpp
用于不属于类的 C++代码。这只是命名源代码文件的一种方式。请随意使用你自己的约定。
获取 Lua 实例
我们需要获取“一个”Lua 库的实例来执行 Lua 脚本。使用其他 C++ 库,你可能创建一个特定的类实例并与之交互。对于 Lua,你得到一个 Lua 状态,并将此状态传递给不同的操作。
我们的 Lua 执行器将隐藏低级 Lua 库的细节,并为你的项目提供高级 API。以下是从 LuaExecutor.h
类定义开始的示例:
#include <lua.hpp>
class LuaExecutor
{
public:
LuaExecutor();
virtual ~LuaExecutor();
private:
lua_State *const L;
};
我们定义了一个构造函数、一个析构函数和一个类型为 lua_State
的私有成员变量,即 Lua 状态,按照 Lua 习惯命名为 L
。
以下是到目前为止的 LuaExecutor.cc
类实现的示例:
#include "LuaExecutor.h"
LuaExecutor::LuaExecutor()
: L(luaL_newstate())
{
luaL_openlibs(L);
}
LuaExecutor::~LuaExecutor()
{
lua_close(L);
}
类封装了 Lua 状态的创建和清理:
-
luaL_newstate()
创建一个新的 Lua 状态。我们在构造函数初始化列表中这样做。 -
luaL_openlibs(L)
为提供的 Lua 状态打开 Lua 标准库。这使得库函数——例如,string.gmatch
——可用于 Lua 脚本中。 -
lua_close(L)
关闭 Lua 状态并释放其分配的资源——例如,动态分配的内存等。
我们现在将更深入地了解 Lua 状态。
什么是 Lua 状态?
Lua 库不维护全局状态,除了 luaL_newstate
,所有 Lua 库函数都期望 Lua 状态作为第一个参数。这使得 Lua 可重入,并且可以轻松用于多线程代码。
Lua 状态是一个名为 lua_State
的结构,它保存了所有 Lua 内部状态。要创建 Lua 状态,请使用 luaL_newstate
。你可以使用 Lua 并透明地处理这个状态,而不必关心其内部细节。
我们可以将这比作 C++ 类。Lua 状态持有类成员变量和状态。Lua 库函数充当类成员函数的作用。更进一步,考虑 C++ 的 pimpl(指向实现的指针)惯用法:
class Lua
{
public:
void openlibs();
private:
LuaState *pImpl;
};
struct LuaState
{
// implementation details
};
在这个类比中,class Lua
是我们的伪 C++ Lua 库;struct LuaState
是定义并隐藏细节的私有实现。在头文件中,你只会看到它的前向声明,而不是定义。openlibs
公共成员函数内部使用 pImpl
(Lua 状态)。
作为高级话题,编译后的 C++ 成员函数将 this
作为第一个参数。期望 LuaState
作为第一个参数的 Lua 库函数可以以类似的方式进行理解:this
和 LuaState
都指向类的私有细节。
所有这些信息都是为了让你在传递 Lua 状态时感到舒适,同时仍然对直接操作它感到安心。现在,让我们回到 Lua 执行器的构建中继续前进。
执行 Lua 文件
在 第一章 中,我们使用了 Lua 库来加载文件并运行脚本。我们在这里也将这样做,但将以更合适的 C++ 方式进行。在 LuaExecutor.h
中添加以下新代码:
#include <string>
class LuaExecutor
{
public:
void executeFile(const std::string &path);
private:
void pcall(int nargs = 0, int nresults = 0);
std::string popString();
};
你当然可以将所有这些成员函数都设置为 const
,例如,std::string popString() const
,因为在 LuaExecutor
中我们只透明地保持 Lua 状态 L
,并不改变其值。在这里,我们省略它以防止代码列表中出现过多的换行符。
executeFile
是我们的公共函数,其他两个是内部辅助函数。在 LuaExecutor.cc
中,让我们首先实现 executeFile
:
#include <iostream>
void LuaExecutor::executeFile(const std::string &path)
{
if (luaL_loadfile(L, path.c_str()))
{
std::cerr << "Failed to prepare file: "
<< popString() << std::endl;
return;
}
pcall();
}
我们使用 luaL_loadfile
加载脚本,提供文件路径。luaL_loadfile
将加载文件,将其编译成 代码块,并将其放置到 Lua 栈 上。我们将在稍后解释什么是代码块以及什么是 Lua 栈。
大多数 Lua 库函数在成功时将返回 0。你也可以将返回值与 LUA_OK
进行显式比较,它定义为 0。在我们的情况下,如果没有错误发生,我们将继续到下一步调用 pcall
。如果有错误,我们将使用 popString
获取错误并将其打印出来。接下来,按照以下方式实现 pcall
:
void LuaExecutor::pcall(int nargs, int nresults)
{
if (lua_pcall(L, nargs, nresults, 0))
{
std::cerr << "Failed to execute Lua code: "
<< popString() << std::endl;
}
}
在 pcall
中,我们使用 lua_pcall
执行编译后的代码块,该代码块已经位于栈顶。这将也会从栈中移除代码块。如果发生错误,我们将检索并打印出错误信息。
除了 L
,lua_pcall
还需要三个其他参数。我们现在为它们传递 0。目前,你只需要知道第二个参数是 Lua 代码块期望的参数数量,第三个参数是 Lua 代码块返回的值数量。
最后,我们将实现最后一个函数,popString
:
std::string LuaExecutor::popString()
{
std::string result(lua_tostring(L, -1));
lua_pop(L, 1);
return result;
}
这会将 Lua 栈顶的元素作为字符串弹出。我们将在你学习更多关于 Lua 栈的知识时进行更多解释。
在尝试 LuaExecutor
之前,我们需要解释两个概念。
什么是代码块?
Lua 中的编译单元称为代码块。从语法上讲,代码块只是一个代码块。当放置在栈上时,代码块是函数类型的一个值。所以,虽然不是完全准确,你可以将其视为一个函数,Lua 文件是一个隐式的函数定义。此外,函数可以在其中定义嵌套函数。
什么是 Lua 栈?
Lua 栈是一个栈数据结构。每个 Lua 状态内部维护一个 Lua 栈。栈中的每个元素可以持有对 Lua 数据的引用。如果你还记得函数也是一个基本的 Lua 类型,你将更舒服地认为栈元素代表一个函数。Lua 代码和 C++ 代码都可以将元素推入和从 Lua 栈中弹出,无论是显式还是隐式。我们将在本章后面讨论更多关于 Lua 栈以及我们的 popString
函数是如何工作的。
现在你已经了解了代码块和 Lua 栈,Lua 中的两个重要概念,我们可以测试 LuaExecutor
。
到目前为止,正在测试 Lua 执行器
为了测试我们的执行器,我们需要编写一个 Lua 脚本和一些 C++ 测试代码来调用 LuaExecutor
。编写 script.lua
如下所示:
print("Hello C++")
这将在控制台打印 Hello C++
。
编写 main.cpp
如下所示:
#include "LuaExecutor.h"
int main()
{
LuaExecutor lua;
lua.executeFile("script.lua");
return 0;
}
这将创建一个 LuaExecutor
实例并执行 Lua 脚本文件。
现在,编写 Makefile
:
LUA_PATH = ../lua/src
CXX = g++
CXXFLAGS = -Wall -Werror
CPPFLAGS = -I${LUA_PATH}
LDFLAGS = -L${LUA_PATH}
EXECUTABLE = executable
all: lua project
lua:
@cd ../lua && make
project: main.cpp LuaExecutor.cc LuaExecutor.h
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) -o
$(EXECUTABLE) main.cpp LuaExecutor.cc -llua
clean:
rm -f $(EXECUTABLE)
与 第一章 中的 Makefiles
相比,我们有一个额外的源文件 LuaExecutor.cc
和一个额外的头文件 LuaExecutor.h
,project
目标依赖于它们。记住使用制表符进行缩进,而不是空格。如果你需要回顾,可以在 第一章 中找到如何编写 Makefile
的解释。
在编写完所有测试代码后,在终端中测试一下:
Chapter03 % make
Chapter03 % ./executable
Hello C++
如果你一切操作正确,代码将编译。执行时,它将输出 Hello C++
,这是来自 Lua 脚本文件的。
我们已经学习了如何从文件中执行 Lua 代码。现在让我们尝试直接执行一个 Lua 脚本。
执行 Lua 脚本
在某些项目中,你可能有一个文件层抽象,或者你可能从远程服务器获取 Lua 脚本。然后,你不能传递文件路径给 Lua 库并要求它为你加载它。你也许还希望将文件作为字符串加载以在执行之前进行更多审计。在这些情况下,你可以要求 Lua 库执行一个字符串作为 Lua 脚本。
要做到这一点,我们将向我们的 Lua 执行器添加一个新功能。在 LuaExecutor.h
中添加一个额外的函数:
class LuaExecutor
{
public:
void execute(const std::string &script);
};
这个新函数将直接接受字符串形式的 Lua 代码并执行它。
在 LuaExecutor.cc
中,添加以下实现:
void LuaExecutor::execute(const std::string &script)
{
if (luaL_loadstring(L, script.c_str()))
{
std::cerr << "Failed to prepare script: "
<< popString() << std::endl;
return;
}
pcall();
}
这个新函数与 executeFile
完全相同,只有一个区别。它调用 Lua 库的 luaL_loadstring
函数,将字符串编译成 Lua 代码并将编译后的代码块推入栈。然后,pcall
将弹出并执行代码块。
测试它
让我们测试一个 Lua 脚本。现在我们不再需要脚本文件。将 main.cpp
编写如下:
#include "LuaExecutor.h"
int main()
{
LuaExecutor lua;
lua.execute("print('Hello Lua')");
return 0;
}
这告诉 Lua 执行器运行 Lua 代码:
print('Hello Lua')
编译并运行项目,你将看到输出 Hello Lua
。
更多关于 Lua 编译和执行的内容
如前所述,luaL_loadstring
和 luaL_loadfile
将编译 Lua 代码,而 lua_pcall
将执行编译后的代码。在我们的 LuaExecutor
实现中,我们正在输出不同的错误消息——分别是 Failed to prepare
和 Failed to execute
。让我们测试两种不同的场景,以更好地理解执行阶段。
测试编译错误
在 main.cpp
中,更改执行 Lua 代码的语句,并故意删除关闭括号以制造 Lua 语法错误:
lua.execute("print('Hello Lua'");
现在重新编译项目并运行它。你应该会看到以下错误输出:
Failed to prepare script: [string "print('Hello Lua'"]:1:
')' expected near <eof>
pcall
没有被调用,因为 Lua 代码编译失败。
测试运行时错误
这次,将 Lua 代码更改为以下内容:
lua.execute("print(a / 2)");
没有语法错误。重新编译,运行项目,并查看新的错误:
Failed to execute Lua code: [string "print(a / 2)"]:1:
attempt to perform arithmetic on a nil value (global 'a')
这是一个执行错误,因为变量 a
尚未定义,但我们使用了它进行除法。
到目前为止,我们有一个可重用的 Lua 执行器,它可以执行 Lua 脚本文件和 Lua 代码。在向我们的执行器添加更多功能之前,让我们先了解更多关于 Lua 栈的知识。
理解 Lua 栈
Lua 栈用于 C/C++ 代码和 Lua 代码之间,以便它们可以相互通信。C++ 代码和 Lua 代码都可以显式或隐式地操作这个栈。
我们已经看到了一些 Lua 库函数从栈中读取和写入。例如,luaL_loadstring
可以将编译后的代码块推送到栈上,而 lua_pcall
则从栈中弹出代码块。让我们学习一些显式操作栈的方法。
推送元素
Lua 库提供了将不同类型的值推送到栈上的函数:
void lua_pushnil (lua_State *L);
void lua_pushboolean (lua_State *L, int bool);
void lua_pushnumber (lua_State *L, lua_Number n);
void lua_pushinteger (lua_State *L, lua_Integer n);
void lua_pushstring (lua_State *L, const char *s);
有更多的 lua_pushX
函数,但上面显示的是基本函数。lua_Number
是一个类型别名,很可能是 double
或 float
,而 lua_Integer
可以是 long
、long long
或其他。这取决于 Lua 库的配置和你的操作系统默认设置。你需要决定你的项目将支持的不同平台的作用域,以及你希望如何将它们映射到 C++ 类型。在大多数情况下,将 lua_Number
映射到 double
和将 lua_Integer
映射到 long
可能已经足够好了,但如果需要,你可以以更可移植的方式实现它。
查询元素
我们可以使用 lua_gettop
来检查栈中有多少元素。栈中的第一个元素是栈底,索引为 1。第二个元素索引为 2,以此类推。你也可以通过引用栈顶来访问栈。在这个引用系统中,栈顶索引为 -1,栈顶下第二个索引为 -2,依此类推。你可以在下面的图中看到这两种引用系统:
图 3.1 – 访问栈元素的两种方式
如图中所示,每个元素都可以用两个数字来索引。当你需要访问刚刚推送到栈上的元素时,使用负数索引可以非常方便。
与用于推送元素的 lua_pushX
类似,我们有 lua_toX
来查询元素:
int lua_toboolean (lua_State *L, int index);
const char *lua_tostring (lua_State *L, int index);
lua_Number lua_tonumber (lua_State *L, int index);
lua_Integer lua_tointeger (lua_State *L, int index);
查询函数始终会将值转换为请求的类型。这可能不是你想要的。在这种情况下,你可以使用 lua_type
来查询给定索引中元素的类型。还有相应的 lua_isX
函数来检查给定的栈索引是否包含某种类型。
弹出元素
要从栈中移除最顶部的 n
个元素,请使用 lua_pop
:
void lua_pop (lua_State *L, int n);
在你的项目中的高级操作中,大多数情况下你应该保持堆栈平衡。这意味着在你完成之后,堆栈大小保持与开始之前相同。与开始时相比,如果你从堆栈中移除更多元素,你将破坏堆栈,并在下次调用 Lua 时导致未定义的行为。另一方面,如果你从堆栈中移除较少的元素,你将浪费堆栈空间,并可能导致内存泄漏。因此,在操作结束时正确弹出元素非常重要。例如,在我们的 LuaExecutor::pcall
函数中,如果有错误,Lua 库将错误消息推送到堆栈上。因为这是由我们的操作触发的,我们需要使用 LuaExecutor::popString
来移除错误消息:
std::string LuaExecutor::popString()
{
std::string result(lua_tostring(L, -1));
lua_pop(L, 1);
return result;
}
此函数首先将堆栈顶部的元素读取为字符串,然后弹出堆栈顶部。
所有 C++ 和 Lua 之间的通信都需要使用 Lua 堆栈。有了对 Lua 堆栈的良好理解,我们可以继续学习 Lua 全局变量。
对全局变量进行操作
Lua 全局变量在整个 Lua 状态中都是可访问的。考虑以下 Lua 代码:
whom = "C++"
function hello()
print("Hello " .. whom)
end
hello
函数使用全局变量 whom
打印问候语。
我们如何从 C++ 获取和设置这个 Lua 全局变量?现在我们将扩展 LuaExecutor
来实现这一点,并使用 hello
函数来测试它。在本章中,我们只实现与字符串变量一起工作的方法,主要关注机制。
获取全局变量
你使用 Lua 库的 lua_getglobal
函数来获取全局变量。其原型如下:
int lua_getglobal (lua_State *L, const char *name);
lua_getglobal
需要两个参数。第一个是 Lua 状态。第二个是全局变量的名称。lua_getglobal
将全局变量的值推送到堆栈上并返回其类型。类型如下定义:
#define LUA_TNIL 0
#define LUA_TBOOLEAN 1
#define LUA_TLIGHTUSERDATA 2
#define LUA_TNUMBER 3
#define LUA_TSTRING 4
#define LUA_TTABLE 5
#define LUA_TFUNCTION 6
#define LUA_TUSERDATA 7
#define LUA_TTHREAD 8
你可以将返回的类型与这些常量进行比较,以查看返回的数据类型是否符合预期。
让我们扩展 LuaExecutor
来获取全局变量。在 LuaExecutor.h
中,添加一个新的函数声明:
class LuaExecutor
{
public:
std::string getGlobalString(const std::string &name);
};
此函数将获取一个 Lua 全局变量并将其作为字符串返回。在 LuaExecutor.cc
中实现它:
std::string
LuaExecutor::getGlobalString(const std::string &name)
{
const int type = lua_getglobal(L, name.c_str());
assert(LUA_TSTRING == type);
return popString();
}
我们调用 lua_getglobal
来获取全局变量并检查它是否为字符串类型。然后我们使用我们之前实现的 popString
函数从堆栈中弹出它以获取 Lua 库的错误消息。
设置全局变量
要从 C++ 设置 Lua 全局变量,我们同样使用堆栈。这次,我们将值推送到堆栈上。Lua 库将其弹出并分配给变量。Lua 库的 lua_setglobal
函数执行弹出和分配的部分。
我们将向我们的执行器添加设置全局变量的功能。在 LuaExecutor.h
中,添加一个额外的函数:
class LuaExecutor
{
public:
void setGlobal(const std::string &name,
const std::string &value);
};
它将设置一个 Lua 全局变量。变量的名称由 name
参数提供,值由 value
设置。在 LuaExecutor.cc
中添加实现:
void LuaExecutor::setGlobal(const std::string &name,
const std::string &value)
{
lua_pushstring(L, value.c_str());
lua_setglobal(L, name.c_str());
}
代码影响 Lua 栈,如图所示:
图 3.2 – 设置全局变量
正如解释的那样,我们首先使用 lua_pushstring
将值推入栈中,然后调用 lua_setglobal
库函数来设置全局变量。我们保持了栈大小的平衡。
现在,让我们测试我们的实现。
测试操作
我们将获取和设置 whom
全局变量,并调用我们的 hello
Lua 函数来测试我们的 Lua 执行器。将 main.cpp
修改如下:
#include <iostream>
#include "LuaExecutor.h"
int main()
{
LuaExecutor lua;
lua.executeFile("script.lua");
std::cout << "Lua variable whom="
<< lua.getGlobalString("whom")
<< std::endl;
lua.execute("hello()");
lua.setGlobal("whom", "Lua");
std::cout << "Lua variable whom="
<< lua.getGlobalString("whom")
<< std::endl;
lua.execute("hello()");
return 0;
}
测试代码做了以下四件事情:
-
加载
script.lua
,其内容是引用whom
全局变量的hello
函数。 -
调用我们的
getGlobalString
执行函数来检查whom
全局变量的值,并执行 Luahello
函数以从 Lua 的角度看到真相。 -
使用我们的
setGlobal
执行函数将whom
的值更改为"Lua"
。 -
从 C++ 方面和 Lua 方面验证
whom
是否具有新的值。
如果你到目前为止一切操作正确,这个测试代码将输出以下内容:
Lua variable whom=C++
Hello C++
Lua variable whom=Lua
Hello Lua
在 Lua 集成之旅中走这么远,做得很好。在了解获取和设置全局变量的知识后,让我们继续本章的最后一个主题:如何从 C++ 调用 Lua 函数。
调用 Lua 函数
在上一节中我们使用的 Lua hello
函数是一个很好的例子,用来展示全局变量,但它并不是你通常实现此类功能的方式。现在考虑一个更合适的实现:
function greetings(whom)
return "Hello " .. whom
end
这个 Lua greetings
函数期望 whom
作为函数参数,并返回问候字符串而不是打印它。你可以更灵活地使用问候字符串,例如,在 GUI 窗口中使用它。
在本章前面,当我们学习如何执行 Lua 脚本时,我们在执行器中实现了 execute
函数。我们可以用这个函数来调用 greetings
:
LuaExecutor lua;
lua.executeFile("script.lua");
lua.execute("greetings('Lua')");
但这并不是 C++ 调用 Lua 函数;这是一个 Lua 脚本调用 Lua 函数。C++ 只是编译 Lua 脚本,无法访问函数的返回值。要从 C++ 正确调用此函数,C++ 需要提供 Lua 函数参数并检索返回值。到目前为止,这应该不会令人惊讶:你需要使用 Lua 栈来完成这个操作。
实现函数调用
我们实际上已经学到了完成这项工作所需的一切。它的工作原理是对理解的一次飞跃。让我们先看看代码,然后再进行解释。
在 LuaExecutor.h
中添加一个执行 Lua 函数的函数:
class LuaExecutor
{
public:
std::string call(const std::string &function,
const std::string ¶m);
};
这个函数调用一个 Lua 函数,其名称由 function
提供。它向 Lua 函数传递一个参数,并期望 Lua 函数返回一个单一字符串类型的值。它现在并不非常通用,但对于目前的学习目的来说已经足够好了。
在 LuaExecutor.cc
中实现 call
函数:
std::string
LuaExecutor::call(const std::string &function,
const std::string ¶m)
{
int type = lua_getglobal(L, function.c_str());
assert(LUA_TFUNCTION == type);
lua_pushstring(L, param.c_str());
pcall(1, 1);
return popString();
}
我们在本章前面实现了 pcall
和 popString
。call
函数执行以下操作:
-
将 Lua 函数——在
function
参数中提供的名称——推入栈中。 -
将 Lua 函数参数——在
param
参数中提供的值——推入栈中。 -
调用 Lua 的
lua_pcall
库函数——表示 Lua 函数期望一个参数并返回一个值。
等等!代码的第一行看起来和获取全局变量完全一样,不是吗?确实如此!你还记得 function
是 Lua 中的基本类型之一吗?你正在将一个全局变量推入栈中,其名称是函数名,其值是函数体。实际上,你也可以用这种方式编写 Lua 函数:
greetings = function (whom)
return "Hello " .. whom
end
这写起来比较繁琐,但展示了底层实际发生的事情。
现在,让我们看看另一个相似之处:
-
在我们的 Lua
execute
和executeFile
执行函数中,我们首先将 Lua 脚本编译为一个代码块并将其推入栈中。然后我们调用lua_pcall
,表示参数数量为零和返回值数量为零。 -
要调用 Lua 函数,我们首先使用
lua_getglobal
将函数加载到栈中。然后我们将参数推入栈中。最后,我们调用lua_pcall
来执行 Lua 函数,表示它需要一个参数并将返回一个值。
执行 Lua 脚本是一个简化版的调用 Lua 函数,无需传递参数和检索返回值。
通过观察相似之处而不是差异,你会更好地理解。现在让我们测试我们的工作。
测试
将 main.cpp
重新编写如下:
#include <iostream>
#include "LuaExecutor.h"
int main()
{
LuaExecutor lua;
lua.executeFile("script.lua");
std::cout << lua.call("greetings", "next adventure")
<< std::endl;
return 0;
}
这将输出 "Hello next adventure"
并结束本章内容。
摘要
在本章中,我们实现了一个 Lua 执行器。它不仅能够加载和执行 Lua 脚本,还能够调用特定的 Lua 函数。我们还学习了如何获取和设置 Lua 全局变量。在章节的过程中,我们解释了 Lua 栈。
请花点时间思考在调用 Lua 函数的过程中 Lua 栈是如何变化的。
在下一章中,我们将继续改进这个 Lua 执行器,并处理 Lua 数据类型和 C++ 数据类型映射。
练习
-
在
LuaExecutor
中实现另一个函数,用于调用具有两个参数和两个返回值的 Lua 函数。尝试使用不同的 Lua 数据类型。 -
在
LuaExecutor
中,我们使用std::cerr
将错误信息打印到控制台。到目前为止,调用者无法获取错误状态。设计一个接口来通知失败。你可以在LuaExecutor
构造函数中传递此接口的实现。
第四章:将 Lua 类型映射到 C++
在上一章中,我们学习了如何调用一个接受单个字符串参数并返回一个字符串值的 Lua 函数。在本章中,我们将学习如何调用接受任何类型和任何数量的参数的 Lua 函数,并支持多个返回值。为此,我们需要找到一个方便的方法将 Lua 类型映射到 C++类型。然后,我们将在此基础上逐步改进我们的 Lua 执行器。在这个过程中,您将继续深化对 Lua 栈的理解,并学习如何使用一些现代 C++特性来集成 Lua。
在本章中,我们将涵盖以下主题:
-
映射 Lua 类型
-
支持不同的参数类型
-
支持可变数量的参数
-
支持多个返回值
技术要求
本章更注重 C++编码。为了更好地理解本章,请确保您理解以下内容:
-
您熟悉现代 C++标准。我们将开始使用C++11和C++17中的特性。如果您只使用过C++03,请在遇到本章中的新 C++特性时,花些时间自己学习。作为提醒,我们将使用enum class、std::variant、std::visit和std::initializer_list。
-
您可以在此处找到本章的源代码:
github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter04
。 -
您可以从前面的 GitHub 链接中的
begin
文件夹中理解并执行代码。begin
文件夹整合了上一章问题的必要解决方案,并作为本章的起点。我们将向其中添加本章实现的新功能。
映射 Lua 类型
在第二章,Lua 基础中,我们学习了 Lua 类型。它们与 C++类型不同。要在 C++中使用它们,我们需要进行一些映射。在第三章,如何从 C++调用 Lua中,我们将 Lua 字符串映射到 C++的std::string
。这是通过在我们的 Lua 执行器中硬编码来实现的。
如果我们想在函数参数和函数返回值中支持所有可能的 Lua 类型怎么办?如果我们想以不同的参数数量调用 Lua 函数怎么办?为每种参数类型和参数数量组合创建一个 C++函数是不可行的。那样的话,我们的 Lua 执行器将受到数百个函数的困扰,只是为了调用 Lua 函数!
幸运的是,C++在面向对象编程和泛型编程方面非常强大。这两个范例导致了两种不同的方式,您可以在 C++中解决问题。
探索不同的映射选项
如前所述,C++支持面向对象编程和泛型编程。我们可以使用其中任何一个来设计类型系统。
使用面向对象类型
这种方法可能更容易理解。它自 C++诞生以来就得到了支持。我们可以定义一个表示所有可能类型的抽象基类,然后继承它并为每种类型实现一个具体类。
除了 C++,大多数编程语言都支持这种方法。如果你或你的团队使用多种编程语言,这种方法可能会在工作时减少概念切换。
但这种方法也更冗长。你还需要考虑其他因素。例如,在映射定义之后,你可能会想要防止创建 Lua 中不存在的类型。你必须将基类构造函数设为私有,并声明几个朋友。
使用泛型类型
这种方法依赖于一个新的 C++17 特性:std::variant。你可以为每个 Lua 类型定义并映射一个简单的 C++类,而不需要继承。然后你使用std::variant
创建一个联合类型,表示这个联合类型可能并且只能是从预定义的 Lua 映射中来的。
这将导致代码更少。代码越少,出错的机会就越小。现代编程倾向于采用新的范式,而不仅仅是传统的面向对象方法。
这种方法的缺点是,并非所有组织都能如此迅速地采用新的 C++标准,这反过来又使得它们理解起来不那么广泛。
在本章中,我们将实现这种方法。在完成本章后,如果你愿意,你可以自己实现面向对象类型。但在我们继续之前,让我们看看用于本章的Makefile
。
介绍一些新的 Makefile 技巧
在深入细节之前,让我们看一下Makefile
。你可以在 GitHub 仓库中的begin
文件夹中找到一个副本,如下所示:
LUA_PATH = ../../lua
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Werror
CPPFLAGS = -I${LUA_PATH}/src
LDFLAGS = -L${LUA_PATH}/src
EXECUTABLE = executable
ALL_O = main.o LuaExecutor.o LoggingLuaExecutorListener.o
all: clean lua project
lua:
@cd ${LUA_PATH} && make
project: ${ALL_O}
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) -o $(EXECUTABLE)
${ALL_O} -llua
clean:
rm -f ${ALL_O} $(EXECUTABLE)
与第三章中使用的Makefile
相比,只有四个不同之处:
-
在
CXXFLAGS
中,我们要求编译器通过添加-std=c++17
将我们的代码编译为 C++17。如果没有这个,它将使用默认的标准,这可能是较旧的 C++版本。 -
新变量
ALL_O
定义了将生成的所有目标文件。每个 C++源文件将被编译成一个目标文件。记住,当你添加新的源文件时,在这里添加一个新的目标文件。否则,如果没有生成目标文件,链接器将找不到应该在缺失的目标文件中存在的符号,你将得到链接器错误。 -
现在的
project
目标依赖于所有目标文件。Make
足够智能,可以自动为你编译源文件中的目标文件,使用相应的源文件作为目标文件的一个依赖项。 -
all
目标有一个额外的依赖项:clean
目标。这总是清理项目并重新构建它。当你手动编写目标文件时,你可以让它依赖于多个头文件。当Make
为你这样做时,它无法告诉你哪些头文件需要依赖。所以,这是一个用于学习目的的小项目的技巧。对于更正式的项目,你应该考虑在不先清理的情况下正确编译所有内容。
如果你难以理解这个 Makefile
,请查看 第一章 和 第三章 中的解释。更好的是,你可以在网上进行更多研究。如果你没有紧急需要学习关于 Makefile
的知识,仅仅使用它,并对其感到舒适,也是完全可以的。
记住
本书使用的 Makefile
示例更倾向于简单性,而不是生产灵活性。
我们通过解释一些新的 Makefile
机制,稍微分散了对 C++ 的注意力。这将是本书中最后一次介绍 Makefile
。在接下来的章节中,请参考 GitHub 源代码。
解释是必要的,以防你从 C++ 编译器和链接器中得到难以理解的错误。现在我们可以回到我们的重点。我们将定义一些简单的 C++ 结构,这些结构映射到 Lua 类型。之后,我们可以使用 std::variant
来声明一个联合类型。拥有联合类型将使我们能够将任何类型的值传递给我们的 C++ 函数。现在,让我们在 C++ 中定义 Lua 类型。
定义 Lua 类型
第一项任务是我们在 C++ 中如何定义一个 Lua 类型。我们希望 Lua 类型有一个清晰的定义,因此像 std::string
这样的类型就不再足够独特了。
自 C++11 以来,我们有了 enum class 的支持。我们可以在 C++ 中使用枚举类来限制 Lua 类型,如下所示:
enum class LuaType
{
nil,
boolean,
number,
string,
};
目前,我们只支持可以映射到简单 C++ 类型的 Lua 基本类型。你可以将这个声明放在一个名为 LuaType.hpp
的文件中,并像下面这样将其包含在 LuaExecutor.h
中:
#include "LuaType.hpp"
我们称它为 *.hpp
因为我们将直接在头文件中放置类型实现并内联所有函数。这部分的理由是因为实现类将是简单的,部分是因为这是一本书,限制代码行数很重要。你可以将代码分离到头文件和源文件中,或者将包含实现的头文件命名为 LuaType.h
。这取决于惯例,每个公司或组织都有自己的惯例,C++ 中有许多实现某种方式的方法。
在 C++ 中实现 Lua 类型
正如解释的那样,我们将使用没有继承的简单类。每个类将有两个字段:一个 type
字段,它是我们刚刚定义的 LuaType
,以及一个 value
字段,用于在 C++ 中实际数据存储。
在 LuaType.hpp
中实现四个结构。在 C++ 中,结构与类相同,但默认情况下其成员对公共访问。当我们想要定义数据时,我们通常使用结构。首先,实现 LuaType::nil
:
#include <cstddef>
struct LuaNil final
{
const LuaType type = LuaType::nil;
const std::nullptr_t value = nullptr;
static LuaNil make() { return LuaNil(); }
private:
LuaNil() = default;
};
我们选择使用 nullptr
来表示 Lua 的 nil 值。它的类型是 std::nullptr_t
。我们还将其构造函数设为私有,并提供了一个静态函数来创建新对象。
设计模式
在这里,我们使用了一个设计模式——私有构造函数的静态工厂方法。在我们的实现中,这将防止使用 new
在堆上创建对象。Lua 类型的 C++ 结构也不提供拷贝构造函数。这是一个设计选择——你可以完全支持传递和赋值,或者限制其使用。在这本书中,我们仅在其与 C++ 栈上的 Lua 执行器交互时限制其使用。如果你在 Lua 执行器之上还有其他层,你需要将结构转换为 C++ 基本类型或你自己的类型。这有助于抽象。
类似地,实现 LuaType::boolean
:
struct LuaBoolean final
{
const LuaType type = LuaType::boolean;
const bool value;
static LuaBoolean make(const bool value)
{
return LuaBoolean(value);
}
private:
LuaBoolean(const bool value) : value(value) {}
};
静态的 make
函数接受一个 boolean
值来创建一个实例。在私有构造函数中,我们使用成员初始化列表来初始化 value
成员变量。
对于 LuaType::number
,我们选择使用 C++ double 类型来存储值:
struct LuaNumber final
{
const LuaType type = LuaType::number;
const double value;
static LuaNumber make(const double value)
{
return LuaNumber(value);
}
private:
LuaNumber(const double value) : value(value) {}
};
Lua 本身在其基本 number 类型中不区分整数和浮点数,但如果你需要,你可以为整数和浮点数分别创建两个 C++ 类型。为此,你可以使用 Lua 的 lua_isinteger
库函数来检查数字是否为整数。如果不是,它就是一个 double。在这本书中,我们只实现了基本 Lua 类型的映射。在一个游戏系统中,你可能想强制使用浮点类型。在一个嵌入式系统中,你可能想强制使用整数类型。或者,你可以在项目中支持使用两者。通过引用 LuaNumber
的实现,这很容易实现。
知识链接
在 Lua 代码中,你可以使用 math.type
库函数来检查一个数字是整数还是浮点数。
最后,对于 LuaType::string
,我们使用 std::string
来存储值:
#include <string>
struct LuaString final
{
const LuaType type = LuaType::string;
const std::string value;
static LuaString make(const std::string &value)
{
return LuaString(value);
}
private:
LuaString(const std::string &value) : value(value) {}
};
这就结束了我们的类型实现。接下来就是所有魔法发生的地方。
实现联合类型
我们定义了一个 LuaType
枚举类来标识 Lua 类型,以及结构来表示不同类型的 Lua 值。当我们想要传递 Lua 值时,我们需要一个类型来表示它们。不使用公共基类,我们可以使用 std::variant
。它是一个模板类,接受一系列类型作为其参数。然后它可以安全地在代码中表示这些类型中的任何一种。要看到它的实际应用,请将以下内容添加到 LuaType.hpp
:
#include <variant>
using LuaValue = std::variant<
LuaNil, LuaBoolean, LuaNumber, LuaString>;
using
关键字创建了一个类型别名,LuaValue
。它可以代表模板参数中指定的四种类型中的任何一种。
与联合类型一起工作
如果您之前没有使用过 std::variant
,您可能想知道我们如何判断它实际持有的类型。如果您传递 LuaValue
的值,您无法直接访问 type
或 value
字段。这是因为没有公共基类。在编译时,编译器无法仅通过查看 std::variant
变量来确定支持哪些字段。为此,我们需要一个小技巧。C++17 也提供了 std::visit
来帮助解决这个问题。让我们实现一个辅助函数来从 LuaValue
获取 LuaType
。在 LuaType.hpp
中添加以下代码:
inline LuaType getLuaType(const LuaValue &value)
{
return std::visit(
[](const auto &v) { return v.type; },
value);
}
这个函数使调用站点更高效。此外,它需要是一个内联函数,因为我们直接在头文件中实现它。如果没有使用 **inline**
关键字,函数可能会被包含在不同的源文件中,具有相同的符号,从而导致链接错误。
**std::visit
接受两个参数。第一个是一个 C++ std::visit
使得类型信息可用。如果您之前从未遇到过这个概念或用法,可能需要一些时间来消化。您可以将这个可调用项视为 lambda。如果您在其他编程语言中使用过 lambda,例如 Java、Kotlin、Swift 或 Python,C++ 的 lambda 非常相似。在其他编程语言中,lambda 通常作为最后一个参数,称为 尾随 lambda,在某些情况下更容易阅读。了解 C++ lambda 的最佳方式是使用它,并尝试在完全掌握之前使其变得舒适。
因此,让我们实现另一个辅助函数来获取每种类型的字符串表示。在 LuaTypp.hpp
中添加以下函数:
inline std::string
getLuaValueString(const LuaValue &value)
{
switch (getLuaType(value))
{
case LuaType::nil:
return "nil";
case LuaType::boolean:
return std::get<LuaBoolean>(value).value
? "true" : "false";
case LuaType::number:
return std::to_string(
std::get<LuaNumber>(value).value);
case LuaType::string:
return std::get<LuaString>(value).value;
}
}
这将帮助我们通过获取存储在 LuaValue
中的内容来测试本章其余部分的实现。您可以使用 std::get
从 std::variant
联合中获取特定类型。
我们已经讨论了 std::variant
、std::visit
和 std::get
,但不足以成为该领域的 C++ 专家。在继续之前,请随意对这些内容进行更多研究。
接下来,让我们使用我们已实现的 Lua 映射来使调用 Lua 函数更加灵活。首先,我们将移除 Lua 执行器中 call
函数使用的硬编码的 std::string
。
支持不同的参数类型
在上一章中,我们实现了以下方式调用 Lua 函数的 C++ 函数:
std::string call(const std::string &function,
const std::string ¶m);
在这一步中,我们的目标是使其更通用,我们希望以下内容:
LuaValue call(const std::string &function,
const LuaValue ¶m);
实际上,请先在 LuaExecutor.h
中进行此更改。为了使其工作,我们将实现辅助函数来将 LuaValue
C++ 类型推送到和从 Lua 栈中弹出,而不是 std::string
。让我们首先处理推送到栈上的操作。
推送到栈上
在之前的调用函数中,我们将 std::string
类型的 param
参数以以下方式推送到 Lua 栈中:
lua_pushstring(L, param.c_str());
为了支持更多的 Lua 类型,我们可以实现一个接受 LuaValue
作为参数的 pushValue
方法,并根据 LuaValue
的 type
字段调用不同的 lua_pushX
Lua 库函数。
在 LuaExecutor.h
中,添加以下声明:
class LuaExecutor
{
private:
void pushValue(const LuaValue &value);
};
在 LuaExecutor.cc
中,实现 pushValue
函数:
void LuaExecutor::pushValue(const LuaValue &value)
{
switch (getLuaType(value))
{
case LuaType::nil:
lua_pushnil(L);
break;
case LuaType::boolean:
lua_pushboolean(L,
std::get<LuaBoolean>(value).value ? 1 : 0);
break;
case LuaType::number:
lua_pushnumber(L,
std::get<LuaNumber>(value).value);
break;
case LuaType::string:
lua_pushstring(L,
std::get<LuaString>(value).value.c_str());
break;
}
}
我们的实现只在 LuaType
上使用一个 switch
语句。我们之前在本章中实现了 getLuaType
函数,位于 LuaType.hpp
中。在每一个 case
中,我们使用 std::get
从 LuaValue
类型联合中获取类型值。接下来,我们将查看弹出部分。
从栈中弹出
从 Lua 栈中弹出是推送部分的逆操作。我们将从 Lua 栈中获取值,并使用 Lua lua_type
库函数来检查其 Lua 类型,然后创建一个具有匹配 LuaType
的 C++ LuaValue
对象。
为了使事情更加模块化,我们将创建两个函数:
-
getValue
将 Lua 栈位置转换为LuaValue
-
popValue
用于弹出并返回栈顶元素
将以下声明添加到 LuaExecutor.h
中:
class LuaExecutor
{
private:
LuaValue getValue(int index);
LuaValue popValue();
};
在 LuaExecutor.cc
中,让我们首先实现 getValue
:
LuaValue LuaExecutor::getValue(int index)
{
switch (lua_type(L, index))
{
case LUA_TNIL:
return LuaNil::make();
case LUA_TBOOLEAN:
return LuaBoolean::make(
lua_toboolean(L, index) == 1);
case LUA_TNUMBER:
return LuaNumber::make(
(double)lua_tonumber(L, index));
case LUA_TSTRING:
return LuaString::make(lua_tostring(L, index));
default:
return LuaNil::make();
}
}
代码相当直接。首先,我们检查请求的栈位置的 Lua 类型,然后相应地返回一个 LuaValue
。对于不支持的 Lua 类型,例如表和函数,我们目前只返回 LuaNil
。有了这个,我们可以如下实现 popValue
:
LuaValue LuaExecutor::popValue()
{
auto value = getValue(-1);
lua_pop(L, 1);
return value;
}
我们首先使用 -1
作为栈位置调用 getValue
来获取栈顶元素。然后我们弹出栈顶元素。
在实现了栈操作之后,我们现在可以通过组合栈操作来实现新的 call
函数。
将其组合起来
花点时间再次阅读旧的 call
函数实现。如下所示:
std::string LuaExecutor::call(
const std::string &function,
const std::string ¶m)
{
int type = lua_getglobal(L, function.c_str());
assert(LUA_TFUNCTION == type);
lua_pushstring(L, param.c_str());
pcall(1, 1);
return popString();
}
要实现我们的新 call
函数,不需要做太多更改。我们只需要将执行栈操作的代码行替换为我们刚刚实现的新的辅助函数。在 LuaExecutor.cc
中编写新的 call
函数如下:
LuaValue LuaExecutor::call(
const std::string &function, const LuaValue ¶m)
{
int type = lua_getglobal(L, function.c_str());
assert(LUA_TFUNCTION == type);
pushValue(param);
pcall(1, 1);
return popValue();
}
我们已经将处理 std::string
的行替换为处理 LuaValue
的新行。
由于我们有一个专门的 getValue
函数和 popValue
函数来将原始 Lua 值转换为 LuaValue
,我们可以利用这个机会让 popString
也使用它们。重写如下:
std::string LuaExecutor::popString()
{
auto result = std::get<LuaString>(popValue());
return result.value;
}
在这里,我们已经去掉了在 popString
中使用 Lua 库函数。限制对第三方库的依赖仅限于少数几个函数是一种良好的实践。另一种思考方式是,在一个类中,内部可以有低级函数和高级函数。
接下来,让我们测试我们改进的 Lua 执行器。
测试一下
由于我们使用了 C++17 特性来实现 LuaValue
,因此我们将使用现代 C++ 编写测试代码。编写 main.cpp
如下:
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
lua->executeFile("script.lua");
auto value1 = lua->call(
"greetings", LuaString::make("C++"));
std::cout << getLuaValueString(value1) << std::endl;
auto value2 = lua->call(
"greetings", LuaNumber::make(3.14));
std::cout << getLuaValueString(value2) << std::endl;
return 0;
}
在此测试代码中,我们首先使用 std::unique_ptr
来持有我们的 Lua 执行器和其监听器,然后使用 greetings
Lua 函数加载 Lua 脚本。这个 Lua 函数来自上一章。实际操作是调用 Lua 函数两次:首先使用 LuaString
,然后使用 LuaNumber
。
编译并运行测试代码。如果你一切都做对了,你应该看到以下输出:
Hello C++
Hello 3.14
如果你看到编译器或链接器错误,不要感到气馁。在构建新的 C++ 代码时,看到一些难以理解的错误信息是很常见的,尤其是在应用新知识时。追踪错误并尝试纠正它们。如果你需要,也可以与 GitHub 上的代码进行比较。
注意
到目前为止,我们已经学到了很多。我们改进的 Lua 执行器可以以更灵活的方式调用 Lua 函数,尽管它仍然只接受一个参数。现在,你应该对使用常见的 C++ 类型来表示不同的 Lua 类型感到舒适和自信。在继续进一步改进我们的 Lua 执行器以调用接受可变数量参数的 Lua 函数之前,先休息一下并反思。
现在,让我们继续改进我们的 Lua 执行器。
支持可变数量的参数
Lua 的 function
支持可变数量的参数。让我们在 script.lua
中实现一个:
function greetings(...)
local result = "Hello"
for i, v in ipairs{...} do
result = result .. " " .. v .. ","
end
return result
end
这将返回一个问候消息,并将所有参数包含在消息中。三个点(...
)表示该函数接受可变数量的参数。我们可以使用 ipairs
遍历参数。
我们如何在 C++ 中支持这一点?对于堆栈操作,我们只需要推送更多的值。主要决定是如何声明 Lua 执行器 call
函数以接受可变数量的参数。
实现 C++ 函数
自 C++11 以来,我们可以使用 可变参数函数模板 来传递 参数包。参数包是任意大小的参数列表。
在 LuaExecutor.h
中,将 call
函数声明更改为以下内容:
template <typename... Ts>
LuaValue call(const std::string &function,
const Ts &...params);
typename... Ts
定义了一个模板参数包,函数将其作为 params
参数接受。
现在,让我们来实现它。删除 LuaExecutor.cc
中的 call
实现文件。由于我们现在正在使用模板,我们需要将实现放在头文件中。在 LuaExecutor.h
中添加以下代码:
template <typename... Ts>
LuaValue LuaExecutor::call(const std::string &function,
const Ts &...params)
{
int type = lua_getglobal(L, function.c_str());
assert(LUA_TFUNCTION == type);
for (auto param :
std::initializer_list<LuaValue>{params...})
{
pushValue(param);
}
pcall(sizeof...(params), 1);
return popValue();
}
此实现可以分为四个步骤,代码中通过空行分隔:
-
它获取要调用的 Lua 函数。这没有变化。
-
它推送 C++ 函数参数。在这里,我们选择从参数包中创建一个
std::initializer_list
并遍历它。 -
它调用 Lua 函数。我们使用
sizeof...(params)
获取参数包的大小,并告诉 Lua 我们将发送这么多参数。 -
它从 Lua 函数中获取返回值并将其返回。
完成第 2 步有不止一种方法。你可以使用 lambda 来解包参数包,甚至有不同选项来编写这个 lambda。当 C++20 逐渐被采用时,你将拥有更多选项。然而,这些选项超出了本书的范围。在这里,我们选择使用更传统的方式来实现,这样更多的人更容易理解。
接下来,让我们测试我们的实现是否有效。
测试它
在 main.cpp
中,替换调用 lua->call
并打印结果的行,如下所示:
auto result = lua->call("greetings",
LuaString::make("C++"), LuaString::make("Lua"));
std::cout << getLuaValueString(result) << std::endl;
在测试代码中,我们向 Lua 的 greetings
函数传递了两个字符串。由于我们支持可变数量的参数,你可以传递任意数量的参数,包括零。你应该看到类似 Hello
C++, Lua,
的输出。
关于我们机制的更多说明
到目前为止,我们在 Lua 执行器中实现了一个通用的函数来调用任何 Lua 函数,并且可以接受任意数量的参数。请花点时间思考以下要点,这将加深你的理解:
-
被调用的 Lua 函数不需要声明为接受可变数量的参数,而我们的 C++ 函数则需要这样做。 当从 C++ 调用 Lua 函数时,你总是需要告诉 Lua 库已经推送到栈上的参数数量。
-
Lua 函数不需要返回值。 你可以尝试注释掉
greetings
函数中的返回语句。C++ 方面将得到一个LuaNil
,因为 Lua 库保证将请求的返回值数量推送到栈上,当 Lua 函数没有返回足够值时使用 nil。 -
Lua 函数可以返回多个值。 我们只会得到第一个值,Lua 库将丢弃其余的,因为当我们调用 Lua 函数时,我们只请求一个返回值。
我们当前的实现已经支持了调用普通 Lua 函数的大部分用例,除了上面提到的最后一点。接下来,我们将支持多个返回值以完成 Lua 函数调用机制。
支持多个返回值
为了处理获取多个返回值,让我们首先创建一个实际上执行这一操作的 Lua 函数。在 script.lua
中添加以下函数:
function dump_params(...)
local results = {}
for i, v in ipairs{...} do
results[i] = i .. ": " .. tostring(v) ..
" [" .. type(v) .. "]"
end
return table.unpack(results)
end
这将获取每个参数并打印出其类型。我们首先将它们放入一个表中,然后解包这个表,使得每个表条目作为一个单独的值返回。
现在,我们有一些决定要做。我们对当前的 call
函数很满意,除了它的返回值。然而,在 C++ 中,我们不能为不同的返回类型重载一个函数。我们需要创建另一个返回值列表的函数。
我们如何从 Lua 获取多个返回值?与 call
相比,有两个差异需要我们解决:
-
我们如何告诉 Lua 库我们期望一个可变数量的返回值,而不是一个固定数量的?
-
我们如何在 C++ 中获取这个可变数量的返回值?
为了解决第一个问题,在调用 Lua 库的 lua_pcall
函数时,我们可以指定一个表示预期返回值数量的魔法数字:LUA_MULTRET
。这意味着我们将接受 Lua 函数返回的任何内容,而库不会丢弃额外的返回值或用 nil
填充。这个魔法数字是唯一需要指定返回值数量的特殊情况。它在 lua.h
中内部定义为 -1
。
为了解决第二个问题,我们只需要在调用 Lua 函数前后计算 Lua 栈中的元素数量。这是因为 Lua 库将所有返回值推入栈中,所以栈中的新元素就是返回值。我们已经实现了 popValue
来弹出栈顶元素。我们需要另一个函数来从栈中弹出多个值。
解决了这两个问题后,让我们开始实施。
实现 C++ 函数
在 LuaExecutor.h
中添加以下声明:
class LuaExecutor
{
public:
template <typename... Ts>
std::vector<LuaValue> vcall(
const std::string &function,
const Ts &...params);
private:
std::vector<LuaValue> popValues(int n);
};
我们添加了另一个函数来调用 Lua 函数。我们称它为 vcall
,因为它返回一个 std::vector
。我们还添加了一个 popValues
辅助函数,用于从 Lua 栈中弹出顶部 n
个元素。
首先,让我们在 LuaExecutor.h
中实现 vcall
:
template <typename... Ts>
std::vector<LuaValue> LuaExecutor::vcall(
const std::string &function, const Ts &...params)
{
int stackSz = lua_gettop(L);
int type = lua_getglobal(L, function.c_str());
assert(LUA_TFUNCTION == type);
for (auto param :
std::initializer_list<LuaValue>{params...})
{
pushValue(param);
}
if (pcall(sizeof...(params), LUA_MULTRET))
{
int nresults = lua_gettop(L) - stackSz;
return popValues(nresults);
}
return std::vector<LuaValue>();
}
现在我们有五个步骤,具体说明如下:
-
使用
lua_gettop
记录栈大小。 -
使用
lua_getglobal
将 Lua 函数推入栈中。 -
使用
pushValue
将所有参数推入栈中。 -
使用
pcall
调用 Lua 函数,并传递LUA_MULTRET
以指示我们将从 Lua 函数中获取所有返回值。Lua 库将保证弹出你在 步骤 2 和 步骤 3 中推入的所有元素。 -
使用
popValues
弹出所有返回值并将它们返回。我们再次检查栈大小。新栈大小减去存储在stackSz
中的原始栈大小就是返回值的数量。
接下来,我们将实现最后一部分,即辅助函数,用于从 Lua 栈中弹出所有返回值。在 LuaExecutor.cc
中添加以下代码:
std::vector<LuaValue> LuaExecutor::popValues(int n)
{
std::vector<LuaValue> results;
for (int i = n; i > 0; --i)
{
results.push_back(getValue(-i));
}
lua_pop(L, n);
return results;
}
Lua 将第一个返回值推入栈中,然后是第二个,依此类推。因此,栈顶需要存储在向量的末尾。在这里,我们按顺序读取返回值,从栈的中间开始,向栈顶移动。-i
是从栈顶开始计算的 ith
位置。
接下来,让我们测试一下。
测试
在 main.cpp
中,按照以下方式更改测试代码:
auto results = lua->vcall(
"dump_params",
LuaString::make("C++"),
LuaString::make("Lua"),
LuaNumber::make(3.14),
LuaBoolean::make(true),
LuaNil::make());
for (auto result : results)
{
std::cout << getLuaValueString(result) << std::endl;
}
我们向新函数传递了不同类型的 LuaValue
列表(LuaString
、LuaNumber
、LuaBoolean
和 LuaNil
)。这将输出以下内容:
1: C++ [string]
2: Lua [string]
3: 3.14 [number]
4: true [boolean]
你观察到任何异常情况吗?我们传递了五个参数,但只得到了四个返回值!LuaNil
没有打印出来。为什么?这是因为,在 dump_params
中,我们使用了 table.unpack
来返回多个值。Lua 的 table.unpack
会停止在它看到 nil 值时。如果你将 LuaNil::make()
移到列表的中间,你会错过更多的返回值。这是预期的。这是 Lua 的事情。类似于 C++ 的 char*
字符串,它会在第一次看到 NULL
字符时结束。
概述
在本章中,我们首先探讨了如何将 Lua 类型映射到 C++ 类型,目的是在 C++ 函数调用中易于使用。然后,我们了解了一种调用任何 Lua 函数的通用方法。
本章逐步推进。你继续改进 Lua 执行器。每一步都产生了一个里程碑。这反过来又基于上一章的工作。通过以下练习,你也将有机会通过实际编码回顾你所学的知识。我们将继续使用这种方法继续本书。
在下一章中,我们将学习如何集成 Lua 表。
练习
-
实现
LuaType::function
和LuaFunction
以涵盖 Lua 函数类型。无需担心LuaFunction
中的值字段。你可以使用nullptr
。为了测试它,你需要调用一个返回另一个函数的 Lua 函数,并在 C++ 中打印出返回值是一个函数。 -
实现
LuaType::table
和LuaTable
以涵盖 Lua 表类型。遵循与上一个问题相同的说明。 -
在上一章中,我们实现了
getGlobalString
和setGlobal
以与 Lua 全局值一起工作。重写这两个方法以支持更多类型。你可以使用新的名称getGlobal
和setGlobal
,并使用LuaValue
。 -
实现一个私有的
dumpStack
调试函数。此函数将输出当前 Lua 栈。你只需要支持LuaValue
中当前支持的类型。在LuaExecutor
的不同位置插入对该函数的调用。这将加深你对 Lua 栈的理解。**
第五章:使用 Lua 表格
在本章中,我们将继续改进我们的 Lua 执行器以与表格一起工作。许多机制都是对上一章所学内容的扩展。你还将了解 Lua 中的面向对象编程(OOP)以及如何调用 Lua 对象方法。总的来说,Lua 对象本质上就是 Lua 表格。
我们将涵盖以下主题:
-
使用 Lua 表格条目
-
使用 Lua 数组
-
Lua 中的 OOP
-
使用 Lua 表格函数
技术要求
这里是本章的技术要求:
-
你可以在此章节的源代码
github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter05
中找到。 -
你可以理解并执行前一个 GitHub 链接中的
begin
文件夹中的代码。如果你还没有这样做,请尝试自己完成前一章的练习,或者至少理解begin
文件夹中的解决方案。 -
你可以理解位于 GitHub 中的
Makefile
并构建项目。或者,你也可以使用自己的方式来构建源代码。
使用 Lua 表格条目
表格条目是表格元素的键值对。Lua 表格键可以是多种数据类型——例如,函数类型。出于实际考虑,尤其是在与 C++集成时,我们只考虑字符串键和整数键。
在script.lua
中添加一个简单的表格如下:
position = { x = 0, y = 0 }
position
通过字符串索引。我们将学习如何在 C++中读取和写入它。
获取表格条目值
到目前为止,在 C++代码中,我们只使用一条信息来定位 Lua 中的值。考虑我们如何实现LuaExecutor::getGlobal
和LuaExecutor::call
。为了定位全局变量或调用函数,我们将变量或函数的名称传递给 Lua 库方法。
要处理表格条目,我们需要两个信息——表格和表格条目键。首先,我们需要定位表格;之后,我们可以使用条目键来处理条目值。
获取条目值的 Lua 库方法声明如下:
int lua_gettable(lua_State *L, int index);
等等!我们分析了我们需要两个信息来定位表格条目,不是吗?为什么lua_gettable
除了 Lua 状态L
之外,只接受一个有意义的参数index
呢?还记得 Lua 栈吗?栈顶通常用于传递额外信息。引用 Lua 参考手册,lua_gettable
执行以下操作:
将值t[k]
推入栈中,其中t
是给定索引处的值,k
是栈顶的值。此函数从栈中弹出键,用结果值替换它。(www.lua.org/manual/5.4/manual.xhtml#lua_gettable
)
如解释所述,两个键都位于 Lua 栈中。如图 图 5**.1 所示,在调用之前,表格条目键必须位于栈顶,而表格可以位于栈中的任何其他位置。这是 Lua 的设计决策。由于您可能有时会处理同一个表格,您可以在栈中某个位置保留表格引用,以避免每次访问时都重复将其推入栈中:
图 5.1 – lua_gettable
在理解了机制之后,是时候做出设计决策了。我们应该如何在 C++ 中实现表格访问?以下是一些可能性:
-
我们可以将表格推入栈中并保留它。例如,如果我们正在处理一个复杂的表格,我们可以实现一个 C++ 类来加载栈底部的表格,并让 C++ 对象专门与该表格工作。
-
我们可以在需要时将表格推入栈中,并在不再需要时立即弹出。如果 C++ 类处理许多 Lua 值且每次推入 Lua 表格不是性能问题,这种方法效果很好。
由于我们正在实现一个通用的 Lua 执行器,我们将选择后者。在 LuaExecutor.h
中声明以下函数:
class LuaExecutor
{
public:
LuaValue getTable(const std::string &table,
const std::string &key);
};
它获取表格名称和表格条目键名称,并返回一个 LuaValue
实例。目前我们只关心字符串类型的键。在 LuaExecutor.cc
中实现如下:
LuaValue LuaExecutor::getTable(
const std::string &table, const std::string &key)
{
int type = lua_getglobal(L, table.c_str());
assert(LUA_TTABLE == type);
lua_pushstring(L, key.c_str());
lua_gettable(L, -2);
auto value = popValue();
lua_pop(L, 1);
return value;
}
代码执行以下操作以获取表格条目值:
-
它使用
lua_getglobal
将表格引用推入栈顶。 -
它使用
lua_pushstring
将表格条目键推入栈顶。现在,表格位于栈顶的第二个位置。 -
它调用
lua_gettable
弹出条目键并推入条目值。现在,条目值位于栈顶。 -
它使用
LuaExecutor::popValue
弹出栈顶的LuaValue
。现在,栈顶再次是表格引用。 -
它使用
lua_pop
弹出表格,因为它不再需要。 -
它返回表格条目值。
在此实现中,我们限制自己仅与全局作用域中的表格一起工作。这是因为我们正在实现一个通用的 Lua 执行器。对于特殊用例,您可以实现特定的 C++ 类。
现在,让我们看看如何设置表格条目值。
设置表格条目值
设置表格条目值的 Lua 库函数声明如下:
void lua_settable(lua_State *L, int index);
Lua 参考手册中的引言很好地解释了这一点:
相当于 t[k] = v
,其中 t
是给定索引的值, v
是栈顶的值, k
是紧挨栈顶的值。从栈中弹出键和值。(www.lua.org/manual/5.4/manual.xhtml#lua_settable
)
这可以在 图 5**.2 中看到。现在,我们需要将条目键和条目值都推入 Lua 栈:
图 5.2 – lua_settable
在LuaExecutor.h
中添加函数声明,如下所示:
class LuaExecutor
{
public:
void setTable(const std::string &table,
const std::string &key,
const LuaValue &value);
};
我们将值作为LuaValue
传递。在LuaExecutor.cc
中实现如下:
void LuaExecutor::setTable(const std::string &table,
const std::string &key, const LuaValue &value)
{
int type = lua_getglobal(L, table.c_str());
assert(LUA_TTABLE == type);
lua_pushstring(L, key.c_str());
pushValue(value);
lua_settable(L, -3);
lua_pop(L, 1);
}
代码的说明如下:
-
它使用
lua_getglobal
将表引用推送到栈顶。 -
它使用
lua_pushstring
将表条目键推送到栈上。 -
它使用
LuaExecutor::pushValue
将表条目值推送到栈上。现在,表引用是栈顶的第三个元素。 -
它使用
lua_settable
设置表条目。这也从栈中弹出顶部两个元素。 -
它使用
lua_pop
弹出表。表是在步骤 1中推送到栈上的。
接下来,让我们测试我们到目前为止的实现。
使用字符串键测试表操作
在main.cpp
中,添加以下辅助函数以打印出position
表:
void dumpPosition(LuaExecutor *lua)
{
auto x = lua->getTable("position", "x");
auto y = lua->getTable("position", "y");
std::cout << "x=" << std::get<LuaNumber>(x).value
<< ", y=" << std::get<LuaNumber>(y).value
<< std::endl;
}
dumpPosition
调用LuaExecutor::getTable
,这是我们刚刚实现的,以获取并打印x
字段和y
字段。在main()
中,将测试代码更改为如下:
dumpPositon(lua.get());
lua->setTable("position", "x", LuaNumber::make(3));
lua->setTable("position", "y", LuaNumber::make(4));
dumpPositon(lua.get());
这首先打印一个position
表,然后将position.x
更改为3
,将position.y
更改为4
,并再次打印表。如果你一切都做对了,你应该看到以下输出:
x=0, y=0
x=3, y=4
接下来,让我们学习如何处理整数类型的表键。
你还记得吗?
如果一个 Lua 表只使用整数键,这个表还能被称为什么?
处理 Lua 数组
是的——只包含整数键的 Lua 表被称为数组或序列。在script.lua
中添加以下数组:
seq = { 0, 0, 0 }
从 C++方面来看,与字符串键相比,唯一的区别是键的数据类型。通过使用整数键,可以简单地通过重载getTable
和setTable
函数来处理。在LuaExecutor.h
中添加以下声明:
class LuaExecutor
{
public:
LuaValue getTable(const std::string &table,
int index);
void setTable(const std::string &table,
int index,
const LuaValue &value);
};
index
是 Lua 数组索引——从 1 开始。不要与 Lua 栈索引混淆。在 Lua 执行器的公共 API 中,不应提及 Lua 栈或 Lua 状态。
一种实现方法是复制字符串键版本,而不是调用lua_pushstring(L, key.c_str())
,而是调用lua_pushinteger(L, index)
。这会起作用。但如果我们这样做,重复自己有什么意义?有没有其他技巧?
使用数组索引优化
Lua 非常注重速度。因为数组是 Lua 表的一种常见形式,Lua 库提供了特殊函数来处理数组,如下所示:
void lua_geti(lua_State *L, int index, int key);
void lua_seti(lua_State *L, int index, int key);
这些函数接受两份数据。index
参数是表在 Lua 栈中的位置。key
参数是数组索引,因为它也是表条目的键。与使用lua_gettable
和lua_settable
相比,你不再需要将表条目键推送到栈上。lua_seti
期望值在栈顶。
现在,让我们实现数组的getTable
函数。在LuaExecutor.cc
中添加以下代码:
LuaValue LuaExecutor::getTable(
const std::string &table, int index)
{
int type = lua_getglobal(L, table.c_str());
assert(LUA_TTABLE == type);
lua_geti(L, -1, index);
auto value = popValue();
lua_pop(L, 1);
return value;
}
代码正在执行以下操作:
-
它从一个全局变量中获取表并将其引用推入栈顶。
-
它使用指定的数组索引调用
lua_geti
。Lua 库会将值推入栈中。 -
它使用
LuaExecutor::popValue
将值作为LuaValue
弹出。 -
它弹出表引用。
-
它返回值。
这根本不需要将数组索引推入栈中。同样,实现 setTable
函数用于数组。在 LuaExecutor.cc
中添加以下代码:
void LuaExecutor::setTable(const std::string &table,
int index, const LuaValue &value)
{
int type = lua_getglobal(L, table.c_str());
assert(LUA_TTABLE == type);
pushValue(value);
lua_seti(L, -2, index);
lua_pop(L, 1);
}
之前的代码解释如下:
-
它从一个全局变量中获取表并将其引用推入栈顶。
-
它使用
LuaExecutor::pushValue
将index
数组位置的值推入栈中。现在,表引用位于栈的第二个位置。 -
它调用
lua_seti
来设置数组位置的值。它还从栈中弹出值。现在,表引用再次位于栈顶。 -
它弹出表引用。
接下来,让我们测试一下。
测试数组索引优化
在 main.cpp
中,添加另一个辅助函数以打印一个 seq
Lua 数组,如下所示:
void dumpSeq(LuaExecutor *lua)
{
auto v1 = lua->getTable("seq", 1);
auto v2 = lua->getTable("seq", 2);
auto v3 = lua->getTable("seq", 3);
std::cout << "seq={"
<< std::get<LuaNumber>(v1).value << ", "
<< std::get<LuaNumber>(v2).value << ", "
<< std::get<LuaNumber>(v3).value << "}"
<< std::endl;
}
这使用 LuaExecutor::getTable
与整数键。将 main()
中的测试代码替换为以下内容:
dumpSeq(lua.get());
lua->setTable("seq", 1, LuaNumber::make(3));
lua->setTable("seq", 2, LuaNumber::make(9));
lua->setTable("seq", 3, LuaNumber::make(27));
dumpSeq(lua.get());
这将 seq
数组更改为 { 3, 9, 27 }
。如果你一切都做对了,你应该看到以下输出:
seq={0, 0, 0}
seq={3, 9, 27}
干得好,Lua,对于优化。还有,你自己做得很好,能走到这一步。但是字符串键呢?在现实场景中,很多时候,Lua 表不是一个数组。
回顾字符串键
当我们第一次学习使用字符串键来访问 Lua 表时,我们选择了更长的路径将键推入栈中。这是因为它是一个通用机制,一旦学会,你就可以改为使用其他数据类型作为表键。
对于字符串键,我们也应该期望有优化。以下是 Lua 库为此提供的函数:
int lua_getfield(
lua_State *L, int index, const char *k);
void lua_setfield(
lua_State *L, int index, const char *k);
这些函数与 lua_geti
和 lua_seti
类似。lua_getfield
还会返回表条目值的类型。在本章结束时,你将得到作业,用它们重写 LuaExecutor
。你也可以选择现在就做。
在学习如何从 Lua 表中调用函数之前,让我们编写一个 Lua 类。带有函数的 Lua 表更像是 C++ 对象。
Lua 中的 OOP
Lua 中的 OOP 与 C++ 中的不同。在 C++ 中,你定义一个类并创建类的实例。定义的类在语言级别上是唯一的类型。
在 Lua 中,没有原生的类概念。Lua 中的 OOP 是基于原型的。如果你熟悉 JavaScript,这更像是 JavaScript。对于 Lua 表,如果一个条目不存在,你可以指示 Lua 检查另一个表,该表作为你显式引用的表的原型。
为了便于理解,我们可以将这个原型表称为“类”,将表称为“对象”。或者,你也可以将这种关系称为“继承”。尽管原型和类是两种不同的面向对象(OO)方法,但有时人们会交替使用这两个术语。
让我们编写一个类,我们将用它来学习如何调用 Lua 表函数。假设我们想要保存一个我们想去的地方的列表,并记录我们是否访问过它们。在script.lua
中,定义一个用作原型的表,如下所示:
Destinations = { places = {} }
setmetatable(Destinations.places, {
__newindex = function (t, k, v)
print("You cannot use Destinations directly")
end,
})
我们定义了一个名为Destinations
的表。它包含一个名为places
的映射,用于跟踪位置并记录是否访问过。键将是我们想去的地方,值将是布尔值。在我们解释了如何使用元表来实现面向对象行为之后,我们将定义表函数。
使用 Lua 元表实现继承
由于Destinations
首先是一个普通的表,默认情况下你可以修改它的places
条目。我们如何防止用户直接使用它呢?你可能已经知道或者猜到了。我们需要设置一个 Lua 元表。我们可以使用setmetatable
来覆盖表上的一些操作。这类似于 C++中的运算符重载。
在我们的例子中,我们将__newindex
Destinations.places
设置为一个只打印错误信息的函数。当我们将值赋给不存在的表键时,会调用__newindex
。这类似于重载 C++的索引运算符。我们可以做得更极端,但这个简单的限制已经足够展示我们的态度。
我们还可以提供一个__index
元方法,用于访问不存在的表键。这就是我们如何实现继承行为。假设我们有一个名为dst
的表,它使用Destinations
作为其原型。当我们调用dst.wish()
向愿望列表添加一个城市时,Lua 实际上首先通过dst["wish"]
查找函数。由于dst
没有wish
方法,Lua 会调用__index
元方法,在其中我们可以调用Destinations
表中的wish
方法。这就是Destinations
如何作为dst
的原型。
要看到它的实际应用,在script.lua
中为Destinations
添加一个构造函数以创建新实例:
function Destinations.new(global_name)
local obj = { places = {} }
setmetatable(obj, {__index = Destinations})
if global_name then _G[global_name] = obj end
return obj
end
new
方法涉及以下步骤:
-
它创建一个新的本地表
obj
,其中包含一个名为places
的条目,与原型表匹配。 -
它将
obj
的__index
元方法设置为Destination
表。这是你可以用来设置表作为元方法的另一种语法糖。然后,Lua 将直接将缺失键的查找重定向到原型表。 -
如果提供了
global_name
,它将新创建的对象分配给一个全局变量。全局变量存储在唯一的表_G
中。如果我们只使用 Lua 代码,我们就不需要这一步。这是为了让新对象在 C++中易于访问。 -
它返回新的对象。
更多关于设计决策
我们提供了一个选项在对象创建器中设置全局变量。这有点不寻常,可以被认为是从构造函数中产生副作用。你不应该盲目地复制这种模式。考虑以下选项:
你需要创建一个 Lua 执行器,执行一些操作,然后让它运行。这就像调用一个 shell 命令。本书中的大多数示例都是这样使用 Lua 的。滥用全局作用域的可能性很小。因此,将对象分配给全局变量既方便又高效。
你需要大量地与 Lua 执行器一起工作。然后,你可以实现一个特殊的 C++ 函数来创建并保留表在栈上,另一个函数用于稍后删除它。
你需要专门与表对象一起工作。你可以在 C++ 的构造函数中创建它,并将表保持在 Lua 栈的底部,正如本章前面所指出的。
更好的做法是根本不使用 Lua 表。在这本书中,我们需要学习如何将 Lua 表与 C++ 集成,这样你就可以在需要时进行非常复杂的交互。但也许你可以更清晰地划分 C++ 领域和 Lua 领域,它们只互相发送简单的指令和结果。
在对象构造完成之后,我们可以实现其成员函数以使 Destinations
完成。
实现 Lua 类成员函数
为了实现一个功能齐全的目的地愿望清单,我们需要添加地点到愿望清单的方法、标记地点为已访问以及检查愿望清单的状态。让我们首先定义愿望清单修改函数。在 script.lua
中添加以下代码:
function Destinations:wish(...)
for _, place in ipairs{...} do
self.places[place] = false
end
end
function Destinations:went(...)
for _, place in ipairs{...} do
self.places[place] = true
end
end
wish
函数接受可变数量的参数,将它们作为键添加到 places
映射中,并将它们的值设置为 false
以指示未访问状态。went
函数类似,它将参数标记为已访问。
冒号运算符 (:
) 是一种语法糖,用于将表作为 self
参数传递给函数。例如,我们的 wish
函数声明等同于以下内容:
function Destinations.wish(self, ...)
在这里,self
将是调用 wish
方法的引用表。这个 self
参数是大多数面向对象语言的工作方式。C++ 将它隐藏起来,并将 this
指针传递给编译后的成员方法。Python 需要在成员函数定义中显式使用 self
作为第一个参数,没有语法糖可用。但是,在调用 Python 成员函数时,你不需要显式传递 self
。
接下来,在 script.lua
中实现愿望清单查询函数如下:
function Destinations:list_visited()
local result = {}
for place, visited in pairs(self.places) do
if visited then result[#result + 1] = place end
end
return table.unpack(result)
end
function Destinations:list_unvisited()
local result = {}
for place, visited in pairs(self.places) do
if not visited then
result[#result + 1] = place
end
end
return table.unpack(result)
end
这些函数分别列出已访问地点和未访问地点。
测试它
在使用 C++ 之前,你可以在 Lua 解释器中测试 Destinations
类以确保其正确实现。以下是一个示例:
Lua 5.4.6 Copyright (C) 1994-2023 Lua.org, PUC-Rio
> dofile("script.lua")
> dst = Destinations.new()
> dst:wish("London", "Paris", "Amsterdam")
> dst:went("Paris")
> dst:list_visited()
Paris
> dst:list_unvisited()
London Amsterdam
你可以向愿望清单中添加一些城市,标记其中一个为已访问,并打印出清单。
在准备好 Lua 类之后,我们可以学习如何从 C++ 调用 Lua 表函数。
与 Lua 表函数一起工作
对于我们的 Lua 执行器,我们希望以调用全局函数相同的支持级别调用表函数。类似于 call
和 vcall
,我们可以定义两个名为 tcall
和 vtcall
的函数,分别调用表函数并分别返回单个值和值列表。
我们需要向新的 C++ 成员函数中添加两条更多信息——即以下内容:
-
表名,这是显而易见的
-
是否应该将
self
参数传递给表函数
关于后者的更多内容:
-
当表函数不引用
self
并像 C++ 静态成员函数一样使用时,我们不需要传递self
-
当表函数引用
self
并像 C++ 成员函数一样使用时,我们需要传递self
让我们实现代码来巩固我们刚刚讨论的内容。
实现表函数支持
在 LuaExecutor.h
中添加以下声明:
class LuaExecutor
{
public:
template <typename... Ts>
LuaValue tcall(
const std::string &table,
const std::string &function,
bool shouldPassSelf,
const Ts &...params);
template <typename... Ts>
std::vector<LuaValue> vtcall(
const std::string &table,
const std::string &function,
bool shouldPassSelf,
const Ts &...params);
};
table
是表名。function
是函数名,它是表中的一个键。shouldPassSelf
表示我们是否应该将表作为第一个参数传递给表函数。params
是函数参数的列表。
接下来,让我们在 LuaExecutor.h
中按照以下方式编写 tcall
函数;请注意,参数列表已被省略以节省空间:
template <typename... Ts>
LuaValue LuaExecutor::tcall(...)
{
int type = lua_getglobal(L, table.c_str());
assert(LUA_TTABLE == type);
type = lua_getfield(L, -1, function.c_str());
assert(LUA_TFUNCTION == type);
if (shouldPassSelf) {
lua_getglobal(L, table.c_str());
}
for (auto param :
std::initializer_list<LuaValue>{params...}) {
pushValue(param);
}
int nparams = sizeof...(params) +
(shouldPassSelf ? 1 : 0);
pcall(nparams, 1);
auto result = popValue();
lua_pop(L, 1);
return result;
}
在前面的列表中,它通过换行符分隔执行了六个步骤,如下所示:
-
它获取一个全局表并将其推入栈中。
-
它将表函数推入栈中。我们使用了
lua_getfield
快捷方式。 -
如果
shouldPassSelf
为true
,则再次将表引用推入栈中。 -
它推入剩余的函数参数。
-
它调用表函数。请注意传递的参数数量。
-
它弹出表函数的结果,弹出在 步骤 1 中推入的表引用,并返回函数结果。
如果你已经完成了上一章的作业,你可以在新行中插入 dumpStack();
来查看 Lua 栈如何变化。
仔细消化一下 vcall
的实现。现在,你需要自己实现 vtcall
。
小贴士
参考 vcall
和 tcall
。请注意获取返回值的数量以及你应该在哪里放置 int stackSz = lua_gettop(L);
。
你可以使用下面的测试代码来测试你是否正确实现了 vtcall
。
测试它
我们将在 C++ 中使用 Lua 的 Destinations
类。在 main.cpp
中,用以下代码替换测试代码:
lua->tcall("Destinations", "new", false,
LuaString::make("dst"));
lua->tcall("dst", "wish", true,
LuaString::make("London"),
LuaString::make("Paris"),
LuaString::make("Amsterdam"));
lua->tcall("dst", "went", true,
LuaString::make("Paris"));
auto visited = lua->vtcall(
"dst", "list_visited", true);
auto unvisited = lua->vtcall(
"dst", "list_unvisited", true);
这与我们在交互式 Lua 解释器中测试 Destinations
时所做的是同一件事。对此的解释如下:
-
它创建了一个类的实例并将对象存储在
dst
全局变量中。在lua->tcall
调用中,我们将shouldPassSelf
设置为false
。 -
它将三个城市添加到
dst
的愿望清单中。从现在起,我们正在使用dst
并将实例作为self
参数传递给表函数。 -
它将
Paris
标记为已访问。 -
它获取已访问城市的列表。
-
它获取未访问城市的列表。
添加以下行以打印 visited
和 unvisited
列表:
std::cout << "Visited:" << std::endl;
for (auto place : visited) {
std::cout << std::get<LuaString>(place).value
<< std::endl;
}
std::cout << "Unvisited:" << std::endl;
for (auto place : unvisited) {
std::cout << std::get<LuaString>(place).value
<< std::endl;
}
编译并运行代码。如果你一切都做得正确,你应该会看到以下输出:
Visited:
Paris
Unvisited:
London
Amsterdam
恭喜!你已经在 C++ 中实现了一个调用 Lua 表格函数的机制。这到目前为止是我们学过的最复杂的逻辑!
概述
在本章中,我们学习了如何在 C++ 中使用 Lua 表格。我们还简要介绍了 Lua 中的面向对象编程(OOP)以及它与 C++ 中的 OOP 的不同之处。
我们还探讨了某些设计决策以及为什么 LuaExecutor
以这种方式实现。它是为了学习如何将 Lua 与 C++ 集成,具有可以分解为章节的结构。
到目前为止,你可以使用 LuaExecutor
调用大多数 Lua 脚本,尽管它有一些限制。例如,我们不支持将另一个表格(除了 self
)作为参数传递给函数。你可以尝试自己实现这样的函数,但这可能不是一个好主意。最好是保持 Lua 和 C++ 之间的通信简单。
慢慢来,实验和实践我们所学的。到目前为止,重点是学习如何从 C++ 调用 Lua 代码。在下一章中,我们将开始学习如何从 Lua 调用 C++ 代码。
练习
-
重新编写
LuaExecutor::getTable
和LuaExecutor::setTable
的字符串键重载版本。使用 Lua 库函数lua_getfield
和lua_setfield
。你可以使用本章中的相同测试代码来测试你是否正确实现了它们。 -
实现
LuaExecutor::vtcall
。无论你是否已经到达这个阶段,你都应该已经做到了。
第三部分 - 从 Lua 调用 C++
在你掌握了从 C++ 调用 Lua 的知识后,在这一部分,你将继续学习如何从 Lua 调用 C++。
你将首先学习如何实现和导出一个可以从 Lua 脚本中调用的 C++ 函数。然后,复杂性将逐步增加。你将导出一个 C++ 类作为 Lua 模块,并改进其导出过程。最后,你将拥有一个通用的模块导出器,可以帮助你导出任何 C++ 类到 Lua。
本部分包括以下章节:
-
第六章,如何从 Lua 调用 C++
-
第七章,处理 C++ 类型
-
第八章,抽象 C++ 类型导出器
第六章:如何从 Lua 调用 C++
在前三个章节中,我们专注于学习如何从 C++ 调用 Lua。在本章中,我们将开始学习如何从 Lua 调用 C++。这对于你的应用程序来说很重要,因为虽然 Lua 脚本可以扩展你的 C++ 应用程序,但它们也可以从你提供的原生 C++ 代码中的函数中受益。
这也意味着我们将学习更多概念,并将不同的事物拼接在一起以使其工作。尽管章节以无缝流动的方式展开,使它们延续前一章,但你可能需要不同的节奏来吸收新概念。如果你需要练习编码,请多次阅读这些部分。
我们将涵盖以下主题:
-
如何注册 C++ 函数
-
如何覆盖 Lua 库函数
-
如何注册 C++ 模块
技术要求
这里是本章的技术要求:
-
你可以在
github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter06
找到本章的源代码。 -
基于上一章的学习,你现在应该对我们的 Lua 执行器添加代码感到自信
本章将介绍许多新概念和 Lua 库 API。你可以通过在线查阅 Lua 参考手册来加强学习:www.lua.org/manual/5.4/
。
如何注册 C++ 函数
Lua 是用 C 编写的,因此它不能直接访问你的 C++ 类。从 Lua 调用 C++ 代码的唯一方法是通过使其调用 C++ 函数——即纯 C 函数。
如何为 Lua 声明 C++ 函数
要将函数注册到 Lua 中,它必须符合以下原型:
typedef int (*lua_CFunction) (lua_State *L);
函数只接收一个参数,即 Lua 状态。它需要返回一个整数值,表示它产生多少返回值。Lua 状态是函数调用的私有,其栈包含从 Lua 代码调用 C++ 函数时传递的参数。C++ 函数需要将其返回值推送到栈上。
我们将首先实现一个简单的函数并将其导出到 Lua。然后,我们将看到更复杂的示例以了解更多。
实现你的第一个 Lua C++ 函数
让我们给我们的 Lua 执行器添加一个简单但有用的功能。它将提供一个函数来检查其版本代码,以便 Lua 代码可以查询它。在 LuaExecutor.cc
中,在 #include
指令下方,添加以下符合 lua_CFunction
的函数实现:
namespace
{
int luaGetExecutorVersionCode(lua_State *L)
{
lua_pushinteger(L, LuaExecutor::versionCode);
return 1;
}
}
函数将 LuaExecutor::versionCode
整数常量推送到其私有栈中,并返回 1
以指示它返回一个值。我们可以在 LuaExecutor.h
中如下定义此常量:
class LuaExecutor
{
public:
static const int versionCode = 6;
};
我们将使用值 6
表示 第六章。
你可能已经注意到函数位于匿名命名空间内。这是为了确保它不能被访问 LuaExecutor.cc
文件之外。这也帮助于逻辑代码分组。
接下来,让我们让这个函数对 Lua 可用。
如何将 C++ 函数注册到 Lua
有几种方法可以将 C++ 函数注册到 Lua 中。在这里,我们将查看注册 C++ 函数到 Lua 全局表中的最简单方法。在本章后面学习 C++ 模块时,我们将学习一种更合适的方法来注册 C++ 函数到它们自己的表中。您已经从 第三章 中知道了如何做这件事。我只需要用以下代码指出,您应该将其添加到您刚刚编写的相同匿名命名空间中:
namespace
{
void registerHostFunctions(lua_State *L)
{
lua_pushcfunction(L, luaGetExecutorVersionCode);
lua_setglobal(L, "host_version");
}
}
是的——我们只需要将其设置为 Lua 全局变量!我们使用 lua_pushcfunction
将 lua_CFunction
类型推送到栈上。然后,我们将其分配给名为 host_version
的全局变量。
使用全局变量来表示宿主执行器版本听起来非常合理。但您不应该过度使用 Lua 全局变量。现在,让我们试试看。
测试它
我们需要修改三个地方来测试我们到目前为止的进度。您可以从本章源代码的 begin
文件夹开始工作。
从我们的 Lua 执行器的构造函数中调用 registerHostFunctions
,如下所示:
LuaExecutor::LuaExecutor(...)
{
...
registerHostFunctions(L);
}
这将我们的函数注册到 Lua。
将 script.lua
的内容替换如下:
print("Host version is " .. host_version())
这将从 Lua 调用我们的 C++ 函数并打印出结果。
将 main.cpp
的内容替换为以下测试代码:
#include "LuaExecutor.h"
#include "LoggingLuaExecutorListener.h"
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
lua->executeFile("script.lua");
return 0;
}
这将测试代码重置为仅创建 Lua 执行器并运行 scripts.lua
。运行项目,如果您一切操作正确,您应该看到以下输出:
Host version is 6
恭喜!您已经从 Lua 代码中调用了第一个 C++ 函数。基于这次学习,让我们来看看如何覆盖 Lua 库函数。
如何覆盖 Lua 库函数
为什么您想要覆盖 Lua 库函数?首先,这有助于以渐进的方式学习在 Lua 中调用 C++ 函数,然后再学习 C++ 模块。其次,但更重要的是,这是实际项目中的常见需求。
假设您正在制作一个游戏,其中资源打包在一个私有存档中,而您的 Lua 脚本需要访问它们。覆盖 Lua 的 io
和 file
库可以为您的 Lua 开发者提供无缝的体验,并在同时加强安全性。您可以确保 Lua 脚本只能访问您希望它们访问的资源,而不会访问宿主文件系统上的其他内容。当您的用户可以更改 Lua 脚本时,这一点尤为重要。
让我们实现一个更简单的情况。我们使用 Lua 的 print
函数来输出调试信息。我们希望将 Lua 调试输出与 C++ 输出合并,以便我们可以在同一位置按打印时间顺序获取所有日志。
重新实现 Lua 的 print 函数
因为 Lua 的 print
函数可以接受可变数量的参数,所以我们需要在我们的实现中考虑这一点。在 LuaExecutor.cc
中,在上一节中的命名空间下方,添加 另一个 命名空间,如下所示:
namespace
{
int luaPrintOverride(lua_State *L)
{
int nArgs = lua_gettop(L);
std::cout << "[Lua]";
for (int i = 1; i <= nArgs; i++)
{
std::cout << " "
<< luaL_tolstring(L, i, NULL);
}
std::cout << std::endl;
return 0;
}
}
当你在 Lua 中调用 print
函数时,luaPrintOverride
C++ 函数最终会被调用。它接受 lua_State
作为单个参数,其关联的 Lua 堆栈用于传递来自 Lua 调用站点的实际参数。为了理解发生了什么,请参阅以下图表:
图 6.1 – 覆盖 Lua 打印函数
Lua 的 print
函数将它的参数推入调用私有的 Lua 堆栈。C++ 函数首先使用 lua_gettop
检查 Lua 调用站点传递的参数数量。然后,它打印出 "[Lua]"
以指示打印来自 Lua 而不是 C++。接下来,它遍历每个参数并打印它们,用空格分隔。最后,它返回 0
以告诉 Lua 库它没有要返回给调用站点的值。
为了加强
对于每个 lua_CFunction
调用,Lua 状态和 Lua 堆栈都是私有的。因此,堆栈中的所有内容都是由 Lua 调用站点传递的参数组成的。在推送返回值之前,您不需要从堆栈中移除它们,因为您已经告诉 Lua 库有多少个值被推入堆栈作为 C++ 函数的返回值。
接下来,让我们看看我们如何可以用我们刚刚实现的 C++ 版本覆盖 Lua 的 print
函数。
覆盖 Lua 打印函数
在相同的匿名命名空间中,添加以下函数:
namespace
{
void overrideLuaFunctions(lua_State *L)
{
const struct luaL_Reg overrides[] = {
{"print", luaPrintOverride},
{NULL, NULL}};
lua_getglobal(L, "_G");
luaL_setfuncs(L, overrides, 0);
lua_pop(L, 1);
}
}
覆盖库函数的过程包括以下步骤:
-
获取库表
-
将感兴趣的功能重新分配到你的新实现中
每行代码执行以下操作:
-
它定义了一个
luaL_Reg
数组,这是一个表示名称和lua_CFunction
对的结构。我们将名称设置为"print"
,与我们要覆盖的函数名称相同。我们将函数设置为我们的新实现。数组的最后一个条目必须是{NULL, NULL}
以标记定义的结束。 -
它将
_G
Lua 表推入堆栈,因为print
函数是一个全局变量,而_G
表包含所有全局变量。 -
它使用
luaL_setfuncs
将我们的函数列表从 步骤 1 设置到_G
表中。现在您可以忽略最后一个参数;我们将在下一节中学习它。 -
它从堆栈中弹出
_G
表以保持堆栈平衡。 -
此外,
luaL_Reg
在 Lua 库中定义如下:
typedef struct luaL_Reg {
const char *name;
lua_CFunction func;
} luaL_Reg;
覆盖 Lua 库函数实际上就像重新分配一些表键到不同的值一样简单!现在,让我们看看它是否有效。
测试它
与上一节类似,从我们 Lua 执行器的构造函数中调用 overrideLuaFunctions
,如下所示:
LuaExecutor::LuaExecutor(...)
{
...
overrideLuaFunctions(L);
}
您不需要更改其他任何内容。使用相同的 main.cpp
和 script.lua
文件,运行项目。如果您一切都做得正确,您应该看到以下输出:
[Lua] Host version is 6
输出中现在有一个 [Lua]
前缀,证明它是从我们的 C++ 覆盖中打印出来的,而不是 Lua 库函数。
接下来,让我们了解 C++ 模块,这是将你的 C++ 功能添加到 Lua 的首选方式。
如何注册 C++ 模块
在本节中,我们将导出 C++ 类实例到 Lua。你可能之前使用过或甚至实现过 Lua 模块,这些模块可以被 Lua 解释器自动找到和加载,并通过 Lua 的 require
函数返回。在这里,重点是集成 Lua 到 C++ 中,在这种情况下,从 C++ 执行器开始,以利用你的 C++ 应用程序的其他部分。因此,如果你之前使用过独立的 Lua 模块,这里会有所不同。
在上一章中,我们实现了一个名为 Destinations
的 Lua 类来跟踪我们想要去的地点。现在让我们用 C++ 重新实现它,以便我们可以将其导出到 Lua。
实现 C++ 类
创建两个源文件,Destinations.h
和 Destinations.cc
。请记住将 Destinations.cc
添加到 Makefile
中。编写头文件如下:
#ifndef _DESTINATIONS_H
#define _DESTINATIONS_H
#include <map>
#include <vector>
#include <string>
class Destinations
{
public:
Destinations(const std::string &name);
void wish(const std::vector<std::string> &places);
void went(const std::vector<std::string> &places);
std::vector<std::string> listVisited() const;
std::vector<std::string> listUnvisited() const;
private:
std::string name;
std::map<std::string, bool> wishlist;
};
#endif // _DESTINATIONS_H
我们使用一个 map
变量来保存地点列表以及我们是否访问过它们,并且有一个 name
成员变量来识别实例。成员函数的命名和 Lua 版本相同,如下所示:
-
wish
将地点列表添加到愿望列表中,作为未访问
。 -
went
将地点列表标记为已访问
。 -
listVisited
返回已访问的地点 -
listUnvisited
返回未访问的地点
现在,让我们在 Destinations.cc
中实现成员函数。它们是普通的 C++ 函数,不使用任何 Lua 功能。因此,我们将只列出代码,不做过多解释。首先,让我们实现构造函数:
#include "Destinations.h"
Destinations::Destinations(const std::string &name)
: name(name), wishlist({}) {}
这将初始化 wishlist
为一个空映射。
然后,按照以下方式编写 wish
函数:
void Destinations::wish(
const std::vector<std::string> &places)
{
for (const auto &place : places)
{
wishlist[place] = false;
}
}
然后,按照以下方式实现 went
函数:
void Destinations::went(
const std::vector<std::string> &places)
{
for (const auto &place : places)
{
wishlist[place] = true;
}
}
wish
函数和 went
函数非常相似,用于标记地点为已访问或未访问。
最后,实现查询函数。按照以下方式编写 listVisited
函数:
std::vector<std::string>
Destinations::listVisited() const
{
std::vector<std::string> results;
for (const auto &[place, visited] : wishlist)
{
if (visited)
{
results.push_back(place);
}
}
return results;
}
然后,按照以下方式编写 listUnvisited
函数:
std::vector<std::string>
Destinations::listUnvisited() const
{
std::vector<std::string> results;
for (const auto &[place, visited] : wishlist)
{
if (not visited)
{
results.push_back(place);
}
}
return results;
}
在准备好 C++ 类之后,我们的下一个任务是将其导出到 Lua。
要导出到 Lua 的内容
将 C++ 类导出到 Lua 实际上是将其实例导出到 Lua。有时,只导出一个实例,C++ 类作为工具库工作,类似于 Lua 的 string
库。有时,导出多个实例,Lua 扩展了 C++ 的 面向对象 编程(OOP)。
重要的是要注意,无论你想要导出多少个实例,过程都是相同的。当覆盖 Lua 库函数时,我们检索一个现有的表,并将其中的一些函数设置为我们的实现。要导出 C++ 类实例,类似地,我们需要做以下操作:
-
创建一个新的表
-
将我们想要导出的函数添加到表中
如果你记得我们只能导出 lua_CFunction
原型的函数到 Lua,你将清楚地看到我们无法直接导出我们的公共成员函数到 Lua。我们需要一些包装函数。让我们首先编写一些存根。在 Destinations.cc
中的 #include
指令下方,添加以下代码:
namespace
{
int luaWish(lua_State *L) { return 0; }
int luaWent(lua_State *L) { return 0; }
int luaListVisited(lua_State *L) { return 0; }
int luaListUnvisited(lua_State *L) { return 0; }
const std::vector<luaL_Reg> REGS = {
{"wish", luaWish},
{"went", luaWent},
{"list_visited", luaListVisited},
{"list_unvisited", luaListUnvisited},
{NULL, NULL}};
}
我们定义了四个 lua_CFunction
原型的包装函数和一个 luaL_Reg
实例的列表。我们使用 vector
而不是 array
,因为在 C++ 中我们更喜欢 vector
,除非我们不得不使用数组。
接下来,让我们设计一个可重用的机制来导出我们的包装器到 Lua。
设计一个可重用的导出机制
有许多种方法可以做到这一点。我们选择一种与我们的 Lua 执行器一起工作并让它注册我们的 C++ 模块的方法。首先,让我们定义一个抽象类来表示 C++ 模块。创建一个名为 LuaModule.h
的新文件,并按照以下内容编写其内容:
#ifndef _LUA_MODULE_H
#define _LUA_MODULE_H
#include <lua.hpp>
#include <string>
#include <vector>
class LuaModule
{
public:
virtual const std::string &luaName()
const override = 0;
virtual const std::vector<luaL_Reg> &luaRegs()
const overrode = 0;
virtual ~LuaModule() = default;
};
#endif // _LUA_MODULE_H
LuaModule
抽象类定义了两个抽象方法,以提供将 C++ 模块注册到 Lua 所需的数据。luaName
返回模块实例的名称;我们将用它作为 Lua 表的名称。luaRegs
返回要随其 Lua 名称一起导出的函数列表。
让我们扩展我们的 Destinations
C++ 类以符合此协议。更改其声明如下:
class Destinations : public LuaModule
{
public:
...
const std::string &luaName() const;
const std::vector<luaL_Reg> &luaRegs() const;
...
};
然后,将以下实现添加到 Destinations.cc
:
const std::string &
Destinations::luaName() const
{
return name;
}
const std::vector<luaL_Reg> &
Destinations::luaRegs() const
{
return REGS;
}
代码简单地返回实例名称作为 luaName
以及我们为存根定义的 REGS
作为 luaRegs
。
现在是时候最终将我们的 C++ 类注册到 Lua 中了。在 LuaExecutor.h
中,添加如下函数声明:
class LuaExecutor
{
public:
void registerModule(LuaModule &module);
};
registerModule
函数将 LuaModule
的一个实例注册到执行器持有的 Lua 状态。
接下来,在 LuaExecutor.cc
中实现它:
void LuaExecutor::registerModule(LuaModule &module)
{
lua_createtable(L, 0, module.luaRegs().size() - 1);
luaL_setfuncs(L, module.luaRegs().data(), 0);
lua_setglobal(L, module.luaName().c_str());
}
这需要一些解释。让我们按顺序探索这里每一行代码所做的事情:
-
它使用
lua_createtable
创建一个表。这个库函数将表推入堆栈。第二个参数暗示表中将使用多少元素作为序列。我们没有,所以我们传递0
。第三个参数暗示表中将使用多少元素作为映射。我们所有的函数都是这样使用的,所以我们传递我们的向量计数减去结束标记。这些提示有助于 Lua 中的内存分配,因为 Lua 将负责创建一个适当大小的表,以避免在以后增加表容量的不必要重新分配。 -
它使用
luaL_setfuncs
将我们的函数设置到表中。这与当我们覆盖 Lua 库函数时的工作方式完全相同。现在也忽略第三个参数。module.luaRegs().data()
返回我们的函数列表作为数组而不是向量。std::vector::data
是 C++ 的一个特性。 -
它使用
module.luaName()
返回的名称将刚刚创建的表分配给一个全局变量。从现在起,我们的 C++ 模块可以通过这个表访问。
将 C++ 模块导出到 Lua 可能听起来很复杂且令人印象深刻。但实际上,涉及的粘合代码并不多。将我们刚刚所做的工作与覆盖 Lua 库函数进行比较。稍作停顿,然后我们将测试我们的机制以查看它是否正常工作。
测试我们的机制
在 main.cpp
中添加几行代码,使其看起来像这样:
#include "LuaExecutor.h"
#include "LoggingLuaExecutorListener.h"
#include "Destinations.h"
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
auto wishlist = std::make_unique<Destinations>(
"destinations");
lua->registerModule(*wishlist.get());
lua->executeFile("script.lua");
return 0;
}
我们创建了一个 Destinations
类的实例,并将其命名为 "destinations"
。然后,我们将它注册到我们的 Lua 执行器中。
现在,将以下代码添加到 script.lua
中:
destinations.wish("London", "Paris", "Amsterdam")
destinations.went("Paris")
print("Visited:", destinations.list_visited())
print("Unvisited:", destinations.list_unvisited())
这是在做以下操作:
-
它将伦敦、巴黎和阿姆斯特丹添加到愿望清单中。
-
它将巴黎标记为已访问。
-
它打印出已访问的城市。
-
它打印出未访问的城市。
运行项目,如果您正确地遵循了所有步骤,您应该看到以下输出:
[Lua] Visited:
[Lua] Unvisited:
应该没有错误,并且由于我们的包装函数只是占位符,它不会返回任何有用的内容。因此,这就是我们构建架构基础的方式。接下来,我们将专注于使其在底层工作。
访问 C++ 类实例
我们的包装函数是 lua_CFunction
类型。它们本质上是与任何类无关的 C++ 静态方法。我们如何访问正确的类实例?我们必须做一些记账。
幸运的是,Lua 提供了一种机制来保存注册的 C++ 函数的数据。它被称为 upvalue。Upvalue 只能通过 C/C++ 代码中关联的函数访问,并且在不同函数调用之间共享。我们可以在 upvalue 中保存类实例的指针。
为什么它被称为 upvalue?在这个阶段,如果不解释,它更容易理解,就像变量被称为变量一样。
你注意到了吗?
从之前的描述中,upvalue 在函数作用域中表现得像 C++ 静态变量。那么,为什么我们使用 upvalue 而不是静态变量?因为 upvalue 与 Lua 库中的 C++ 函数相关联。这样,我们可以使用相同的 C++ 函数,但具有不同的 upvalue。
哪种 Lua 数据类型可以用来保存 C++ 指针?我们可以使用 userdata。这种类型用于存储任意 C/C++ 数据。特别是,在我们的情况下,我们需要使用 light userdata,其目的是存储 C/C++ 指针。这对我们来说是一个完美的匹配。
总之,我们需要将类实例的 this
指针作为 light userdata 保存在 lua_CFunction
实现的 upvalue 中。
在这里,我们涉及了两个新的 Lua 概念。它们专门用于与 C/C++ 代码一起工作,因此您可能不太熟悉 Lua 编程中的它们。让我们看看代码的实际运行情况,以帮助理解。
如何提供 upvalue
我们将只关注注册 C++ 模块的情况。到目前为止,我们已经忽略了 luaL_setfuncs
的第三个参数,并且始终传递 0
。
这个第三个参数是什么意思?它是第二个参数中提供的列表中所有函数可用的 upvalue 的计数。
你如何提供 upvalues?当然——你将它们推送到栈上!
让我们重写函数,如下注册 C++模块:
void LuaExecutor::registerModule(LuaModule &module)
{
lua_createtable(L, 0, module.luaRegs().size() - 1);
int nUpvalues = module.pushLuaUpvalues(L);
luaL_setfuncs(L, module.luaRegs().data(), nUpvalues);
lua_setglobal(L, module.luaName().c_str());
}
只有两处改动。首先,我们希望在LuaModule
中实现另一个函数,用于将 upvalues 推送到栈上,并返回推送了多少个 upvalues。然后,我们将 upvalue 计数作为luaL_setfuncs
的第三个参数传递。
记得将pushLuaUpvalues
添加到LuaModule.h
中,如下所示:
class LuaModule
{
public:
virtual int pushLuaUpvalues(lua_State *L)
{
lua_pushlightuserdata(L, this);
return 1;
}
};
我们提供了一个默认实现,将this
作为 upvalue 推送。在派生类中,它们可以重写这个函数并推送更多的 upvalues。
接下来,让我们看看我们如何访问这个 upvalue。
如何访问 upvalues
Lua upvalues 的访问就像它们在栈上一样,尽管它们实际上并不在栈上。所以,一个魔法栈索引LUA_REGISTRYINDEX
被用来标记 upvalue 伪区域的开始。Lua 提供了一个lua_upvalueindex
宏来定位你的 upvalues 的索引,所以你实际上不需要处理这个魔法数字。
这样我们就可以访问存储为 upvalue 的 C++类实例了。在Destinations.cc
中,向匿名命名空间添加以下函数:
namespace
{
inline Destinations *getObj(lua_State *L)
{
return reinterpret_cast<Destinations *>(
lua_touserdata(L, lua_upvalueindex(1)));
}
}
我们可以使用这个辅助函数来获取实例的指针。它使用lua_touserdata
通过伪索引从栈中获取我们的 light userdata。这个辅助函数将从我们注册的存根中调用。
为了加强
传递给lua_CFunction
函数的 Lua 状态和栈对该函数的每次调用都是私有的。
现在我们已经弄清楚如何访问类实例,我们可以完成我们的存根。
完成我们的存根
将luaWish
编写如下:
int luaWish(lua_State *L)
{
Destinations *obj = getObj(L);
std::vector<std::string> places;
int nArgs = lua_gettop(L);
for (int i = 1; i <= nArgs; i++)
{
places.push_back(lua_tostring(L, i));
}
obj->wish(places);
return 0;
}
它首先使用我们刚刚实现的getObj
辅助函数获取类实例。然后,它将 Lua 调用站点的所有参数放入一个 vector 中。最后,它调用真实对象的方法obj->wish
。这就是包装器的作用——它将调用路由到真实对象。
luaWent
的代码与此类似,如下所示:
int luaWent(lua_State *L)
{
Destinations *obj = getObj(L);
std::vector<std::string> places;
int nArgs = lua_gettop(L);
for (int i = 1; i <= nArgs; i++)
{
places.push_back(lua_tostring(L, i));
}
obj->went(places);
return 0;
}
唯一的区别是它调用obj->went
而不是其他。
最后,按照以下方式实现查询函数:
int luaListVisited(lua_State *L)
{
Destinations *obj = getObj(L);
auto places = obj->listVisited();
for (const auto &place : places)
{
lua_pushstring(L, place.c_str());
}
return places.size();
}
int luaListUnvisited(lua_State *L)
{
Destinations *obj = getObj(L);
auto places = obj->listUnvisited();
for (const auto &place : places)
{
lua_pushstring(L, place.c_str());
}
return places.size();
}
这些函数使用对象函数获取一个位置列表,然后将列表推送到栈上以返回到 Lua 调用站点。
现在,我们已经实现了所有功能,我们可以进行测试了。
进行测试
我们不需要修改任何测试代码,因为我们已经使用这些函数测试了我们的存根。现在,重新编译并运行项目。
回想一下,Lua 测试代码看起来是这样的:
destinations.wish("London", "Paris", "Amsterdam")
destinations.went("Paris")
print("Visited:", destinations.list_visited())
print("Unvisited:", destinations.list_unvisited())
如果你一切操作正确,你应该看到以下输出:
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
恭喜你让它工作起来!
这章与前面的章节相比,思维模式有很大的改变。如果你需要,请花点时间反思。
概述
在本章中,我们学习了如何在 Lua 中调用 C++ 代码。我们首先学习了如何将一个简单的 C++ 函数注册到 Lua 中。所有注册的函数都必须符合 lua_CFunction
。然后,我们发现了如何覆盖 Lua 库函数。最后,我们实现了一个 C++ 类并将其导出到 Lua。在过程中,我们还遇到了 upvalue 和 light userdata 的概念。
在下一章中,我们将继续我们的旅程,更详细地介绍 C++ 中的用户定义数据以及更多的数据交换机制。
练习
-
在
Destinations
类中,我们只使用了其中一个 upvalue。添加另一个 upvalue 并对其进行实验。哪个 upvalue 对应哪个伪索引? -
尝试修改函数中的第二个 upvalue,看看下次函数被调用时值是否被保留。当它在另一个函数中被访问时又会怎样呢?
-
在
LuaType.hpp
中,添加LuaType::lightuserdata
并为其实现一个结构体,命名为LuaLightUserData
。在执行器和辅助函数中支持这个情况。当从 Lua 栈中弹出值时,你不需要支持这个类型。
第七章:与 C++类型一起工作
在上一章中,我们学习了如何在 Lua 中调用 C++代码以及如何注册 C++模块。你可能已经注意到,你可以在 C++中创建对象并将它们注册到 Lua 中,但如何在 Lua 中自由创建 C++对象呢?这正是本章我们将要实现的目标。
我们将学习以下主题,并采用自顶向下的方法将 C++类型导出到 Lua:
-
如何使用 Lua 注册表
-
如何使用 userdata
-
将 C++类型导出到 Lua
技术要求
本章的技术要求如下:
-
你可以访问本章的源代码,请访问
github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter07
。 -
上一章向您展示了如何注册 C++模块;您必须已经完成了编码问题。您可以在本书 GitHub 仓库中的
begin
文件夹中查看答案。 -
由于本章内容复杂,我们采用自顶向下的方法,以便你能在本章末尾获得一个可工作的实现。如果你更喜欢从开始就看到完整的代码,可以查看本书 GitHub 仓库中的
end
文件夹。
如何使用 Lua 注册表
Lua 注册表是一个全局表,可以被所有 C/C++代码访问。这是 C++代码可以在不同函数调用之间保持状态的地方之一。除非你使用 Lua debug库,否则你无法在 Lua 代码中访问这个表。然而,你不应该在生产代码中使用 debug 库;因此,注册表仅适用于 C/C++代码。
注册表与 upvalues 的比较
在上一章中,我们学习了 Lua upvalues。一个upvalue在调用之间保持特定 Lua C 函数的状态。另一方面,注册表可以被所有 Lua C 函数访问。
要将 C++类型导出到 Lua,我们将使用 Lua userdata 来表示类型和注册表,以便我们为类型提供函数的元表。我们首先学习注册表,然后是 userdata,最后将所有内容组合起来以将 C++类型导出到 Lua。
让我们在 Lua 执行器中添加对注册表的支持,以便我们知道如何使用它。
支持注册表
由于注册表是一个表,我们必须使用键来获取值,并使用键来设置值。我们可以使用LuaValue
来表示不同类型的 Lua 值。
在LuaExecutor.h
中添加以下函数声明:
class LuaExecutor
{
public:
LuaValue getRegistry(const LuaValue &key);
void setRegistry(const LuaValue &key,
const LuaValue &value);
};
getRegistry
将返回注册表中key
对应的值。setRegistry
将使用key
将value
设置到注册表中。
现在,让我们实现它们并学习哪些 Lua 库函数可以使用。在LuaExecutor.cc
中实现getRegistry
,如下所示:
LuaValue LuaExecutor::getRegistry(const LuaValue &key)
{
pushValue(key);
lua_gettable(L, LUA_REGISTRYINDEX);
return popValue();
}
这看起来很简单,对吧?它重用了我们从上一章中学到的两个知识点:
-
我们使用
lua_gettable
从一个表中获取值,其中栈顶是密钥,表位于由函数参数指定的栈中的位置。我们在第五章中学习了这一点。 -
与 upvalues 类似,注册是关于 Lua 栈的特殊用例,因此它也有一个名为
LUA_REGISTRYINDEX
的伪索引。我们第一次遇到这个伪索引是在第六章。 -
通过结合这两点,我们得到了一个比获取普通表值更简单的实现。这是因为我们不需要将表推入栈中。
接下来,我们将实现setRegistry
。在LuaExecutor.cc
中添加以下代码:
void LuaExecutor::setRegistry(const LuaValue &key,
const LuaValue &value)
{
pushValue(key);
pushValue(value);
lua_settable(L, LUA_REGISTRYINDEX);
}
这只需要调用一个 Lua 库函数:lua_settable
。value
位于栈顶,key
位于次顶。这种简单性归功于 Lua API 在伪索引方面的优秀设计。
通过扩展我们的 Lua 执行器,让我们测试它以查看注册是如何工作的。
测试注册
在main.cpp
中替换测试代码,如下所示:
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
auto key = LuaString::make("darabumba");
lua->setRegistry(key,
LuaString::make("gwentuklutar"));
auto v1 = lua->getRegistry(key);
lua->setRegistry(key, LuaString::make("wanghaoran"));
auto v2 = lua->getRegistry(key);
std::cout << getLuaValueString(key)
<< " -> " << getLuaValueString(v1)
<< " -> " << getLuaValueString(v2);
return 0;
}
测试代码执行以下操作,通过换行符分隔:
-
创建
LuaExecutor
和监听器,就像往常一样 -
使用一些字符串设置一个键值对到注册中
-
将密钥设置为另一个值
-
打印出密钥、初始值和当前值
如果您一切操作正确,您应该看到以下输出:
darabumba -> gwentuklutar -> wanghaoran
在上一章中,我们使用一个 upvalue 来存储 light userdata。现在,让我们通过注册来测试 light userdata。用以下操作替换注册操作:
auto regkey = LuaLightUserData::make(listener.get());
lua->setRegistry(regkey, LuaString::make(
"a LuaExecutorListener implementation"));
auto regValue = lua->getRegistry(regkey);
std::cout << std::hex << listener.get() << " is "
<< getLuaValueString(regValue);
在这里,我们使用 light userdata 作为密钥,并使用一个字符串作为值来解释密钥的含义。您应该看到以下类似的输出:
0x14f7040d0 is a LuaExecutorListener implementation
您的密钥地址在每次运行中都会不同。
现在我们已经涵盖了注册并回顾了 light userdata,让我们来了解 Lua userdata。
如何使用 userdata
从本节开始,我们将把上一章的Destinations
类转换并注册到 Lua 中作为一个类型,这样 Lua 代码就可以从中创建对象。在我们深入细节之前,让我们做一些高级的修改来展示我们在项目层面想要实现的内容。
准备 C++类型
在上一章中,我们向Destinations
类的构造函数传递一个名称,因为我们是在 C++中创建其实例,并且需要为每个实例设置一个 Lua 名称。
在本章中,我们将导出 C++类型到 Lua。Lua 将创建对象并给它一个名字。所有更改都将发生在Destinations
类中。我们在 Lua 执行器中实现的注册 C++模块的机制足够灵活,可以支持 C++类型的注册。
为了反映这个变化和差异,我们将修改构造函数以及模块名称的提供方式。在Destinations.h
中修改构造函数,如下所示:
class Destinations : public LuaModule
{
public:
Destinations();
};
然后,删除以下私有成员变量:
class Destinations : public LuaModule
private:
std::string name;
};
我们将注册一个类型而不是一个实例。我们可以使用静态变量作为 Lua 表的名称。你可以在 Destinations.cc
中相应地更改构造函数:
Destinations::Destinations() : wishlist({}) {}
现在,让我们重新实现如何提供 Lua 类型/表名称。在 Destinations.cc
中更改 luaName
,使其使用静态变量:
namespace
{
const std::string NAME("Destinations");
}
const std::string &Destinations::luaName() const
{
return NAME;
}
在匿名命名空间的开头添加一个名为 NAME
的字符串常量,其值为 Destinations
,并在 luaName
中返回它。
最后,更改 main.cpp
中的测试代码,如下所示:
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
auto wishlist = std::make_unique<Destinations>();
lua->registerModule(*wishlist.get());
lua->executeFile("script.lua");
return 0;
}
与 第六章 末尾的代码相比,这里唯一的改变是我们移除了 Destinations
类构造函数的参数。
这是我们希望在 C++ 端实现的内容。确保它能编译。从现在开始,我们将专注于重新封装 Destinations
类,并让 script.lua
从它创建对象。
接下来,让我们了解 userdata。
什么是 userdata?
在前一章中,我们将类的实例作为表导出,并将类成员函数作为表函数。要直接导出类型,我们将创建 userdata 而不是表。
在 第二章 中,我们了解到 userdata 是 Lua 中的基本类型之一,但我们没有深入探讨其细节。在前一章中,我们使用了 lightuserdata。userdata 和 lightuserdata 之间有什么区别?
userdata 是由 Lua 库通过调用 C/C++ 代码创建的任意数据序列,Lua 对其进行透明处理。另一方面,lightuserdata 必须是 C/C++ 指针。
使 userdata 更适合表示新类型的原因之一是,像表一样,你可以为其设置元方法和元表。这就是你提供类型函数并使其在 Lua 中成为有用类型的方式。
Lua 中的面向对象编程
在 第五章 中,我们学习了 Lua 中的面向对象编程和 __index
元表。如果这听起来不熟悉,请在继续之前回顾那章。
现在,让我们看看我们应该将什么放入 userdata 中,以便我们可以导出 C++ 类型。
设计 userdata
要创建 userdata,Lua 提供了以下函数:
void *lua_newuserdatauv (
lua_State *L, size_t size, int nuvalue);
这个函数分配了一个长度为 size
字节的连续内存块作为 userdata,并将 userdata 的引用压入堆栈。你还可以将用户值附加到 userdata 上。用户值的数量通过 nuvalue
传入。我们不会使用用户值,所以我们将传入 0
。lua_newuserdatauv
返回已分配的原始内存的地址。
由于 Lua 是用 C 编写的,你可以使用 lua_newuserdatauv
来分配 C 数组或结构。Lua 甚至会在垃圾回收期间负责释放它。
使用 C++,我们希望 userdata 表示一个类。调用这个 Lua 库函数来分配 C++ 类既不便携也不方便。因此,我们将自己动手——我们将自己创建 C++ 对象,并使 userdata 成为对象的指针。
解耦
虽然我们将 Lua 集成到 C++ 中,但我们选择尽可能地将 C++ 方面和 Lua 方面解耦,并且只公开必要的接口。C++ 内存管理已经是一个复杂的话题。我们选择让 C++ 管理 C++ 对象的创建,并且只使用 Lua userdata 来保持指针并作为垃圾回收信号。
让我们从编写一个用于对象创建的 Lua C 函数开始。在 Destinations.cc
的匿名命名空间末尾,添加一个名为 luaNew
的函数。同时将其添加到 REGS
向量中:
int luaNew(lua_State *L);
const std::vector<luaL_Reg> REGS = {
{"new", luaNew},
...
{NULL, NULL}};
int luaNew(lua_State *L)
{
Destinations *obj = new Destinations();
Destinations **userdata =
reinterpret_cast<Destinations **>(
lua_newuserdatauv(L, sizeof(obj), 0));
*userdata = obj;
return 1;
}
luaNew
将负责创建 Destinations
实例。这是一个三步过程:
首先,我们使用 new
在堆中创建类的实例,并将其指针存储在 obj
中。
然后,我们调用 lua_newuserdatauv
来创建一个 userdata,用于存储 obj
中的指针。userdata
的大小将是 sizeof(obj)
,即 C++ 对象指针的大小。因为 lua_newuserdatauv
返回原始内存的指针,而我们已使此内存包含指向 Destinations
实例的指针,所以我们需要将分配的内存地址保存为一个指向指针的指针。
最后,我们将 *userdata
指向 obj
。由于 userdata
已经在栈顶,我们可以返回 1
以将分配的 userdata 返回给 Lua。
当 Lua 代码通过 luaNew
创建 Destinations
实例时,Lua 方面将获得一个 userdata。
指向指针的指针
C++ 允许你创建一个指向指针的指针。在这种情况下,一个指针包含另一个指针的地址。这在纯 C++ 代码中不常用,但与 C APIs 交互时可能很有用。Lua 库是一个 C 库。
接下来,让我们准备 script.lua
,以便它可以使用 C++ 类型。
准备 Lua 脚本
替换 script.lua
的内容,如下所示:
dst = Destinations.new()
dst:wish("London", "Paris", "Amsterdam")
dst:went("Paris")
print("Visited:", dst:list_visited())
print("Unvisited:", dst:list_unvisited())
dst = Destinations.new()
dst:wish("Beijing")
dst:went("Berlin")
print("Visited:", dst:list_visited())
print("Unvisited:", dst:list_unvisited())
脚本的这个第一部分与上一章中使用的脚本类似,除了我们改为使用新的表名 Destinations.new()
,并且我们切换到使用带有冒号的对象调用约定。脚本的第二部分是第一部分的重复,但使用了不同的城市名称。这是为了演示我们可以在 Lua 中创建许多 Destinations
实例。
如果你此时运行项目,你将看到以下错误:
[LuaExecutor] Failed to execute: script.lua:2: attempt to index a userdata value (global 'dst')
目前,这是预期的。因为我们向 Lua 返回了 userdata 而不是 table,所以到目前为止,它只是一个对 Lua 来说透明的原始内存块。Lua 不知道如何在原始内存块上调用方法。如前所述,我们需要为 userdata 设置一个元表才能使其工作。我们将在下一步中这样做,以便将所有内容组合在一起。
将 C++ 类型导出到 Lua
在上一节中,我们将类实例的指针作为 userdata 返回给 Lua。在本节中,我们将为 userdata 导出成员函数。为此,我们需要为 userdata 设置元表。
为 userdata 设置元表
在 第五章 中,我们了解到每个表都可以有一个元表。同样,每个 userdata 也可以有一个元表。在 第二章 中,我们了解到在 Lua 代码中,元表需要在对象创建时设置。
在此,我们需要在 C++ 代码中设置元表,而不是为每个对象创建一个新的元表,我们可以创建一个单独的元表并将其存储在注册表中。然后,每个对象只需要引用这个单一的元表。这将提高效率并减少内存占用。Lua 库甚至为此提供了辅助函数。
首先,让我们看看代码;解释将随后进行。将 luaNew
的内容替换如下:
const std::string METATABLE_NAME(
"Destinations.Metatable");
int luaNew(lua_State *L)
{
Destinations *obj = new Destinations();
Destinations **userdata =
reinterpret_cast<Destinations **>(
lua_newuserdatauv(L, sizeof(obj), 0));
*userdata = obj;
int type = luaL_getmetatable(
L, METATABLE_NAME.c_str());
if (type == LUA_TNIL)
{
lua_pop(L, 1);
luaL_newmetatable(L, METATABLE_NAME.c_str());
lua_pushvalue(L, -1);
lua_setfield(L, -2, "__index");
luaL_setfuncs(L, REGS.data(), 0);
}
lua_setmetatable(L, 1);
return 1;
}
如您所见,这里的代码通过换行符分隔。在这种情况下,我们只插入了第二部分。其余的代码保持不变。此外,我们声明了一个新的常量 METATABLE_NAME
。您可以将它放在 NAME
常量之后。第二段代码执行以下操作:
-
首先,它从注册表中获取包含
METATABLE_NAME
键的元表。为此使用了luaL_getmetatable
库函数。 -
如果找不到,则创建元表。我们将在稍后详细说明这一点。
-
最后,代码部分使用
lua_setmetatable
将元表设置到 userdata 上。这个库函数期望元表位于栈顶。在我们的情况下,userdata 在栈中的位置通过1
参数指定。lua_setmetatable
将从栈中弹出元表。
为了更好地理解这一点,请参阅以下图:
图 7.1 – 设置元表
前面的图显示了当元表已经在注册表中时,栈的状态如何变化。
接下来,让我们看看当元表不在注册表中时的案例。
为 userdata 创建元表
在我们的实现中,当 Lua 首次从 Lua 创建类实例时创建元表。具体来说,创建元表的代码如下;这是从之前的代码列表中复制的:
if (type == LUA_TNIL)
{
lua_pop(L, 1);
luaL_newmetatable(L, METATABLE_NAME.c_str());
lua_pushvalue(L, -1);
lua_setfield(L, -2, "__index");
luaL_setfuncs(L, REGS.data(), 0);
}
当我们需要创建元表时,栈的状态会发生变化,如下面的图所示:
图 7.2 – 创建元表
这个过程涉及八个步骤。步骤 4 到 7 处理创建新的元表。
-
使用
lua_newuserdatauv
创建一个 userdata,它包含对类实例的指针。 -
尝试使用
luaL_getmetatable
从注册表中获取元表。元表尚不存在,因此我们将得到一个 nil 值。 -
使用
lua_pop
从栈中弹出 nil 值。因为它没有用,并且位于 userdata 之上,我们需要将 userdata 作为栈顶返回。 -
使用
luaL_newmetatable
在注册表中创建一个空的元表。 -
使用
lua_pushvalue
将元表的引用副本推送到堆栈上。现在,我们有指向同一元表的两个引用;我们将在下一步中使用这些引用。 -
使用
lua_setfield
将元表的__index
字段设置为自身。这确保了我们不需要为__index
字段创建另一个表。 -
使用
luaL_setfuncs
将REGS
中的类型函数设置为元表。 -
使用
lua_setmetatable
将元表设置为 userdata。
通过这种方式,当 Lua 代码在 userdata 对象上调用方法时,它将调度到适当的 Lua C 函数包装器。
我们如何在 Lua C 包装函数中获取对象?
在上一章中,我们使用了 upvalue。尽管这仍然可以工作,但在这里已经不再适用了。让我们让 C++中的对象检索再次工作。
在 C++中获取对象
在script.lua
中,我们已更改 Lua 代码,使其使用带有冒号的对象调用约定。这意味着对象将被作为第一个参数传递给 Lua C 函数。
要获取对象引用,重写getObj
函数,如下所示:
inline Destinations *getObj(lua_State *L)
{
luaL_checkudata(L, 1, METATABLE_NAME.c_str());
return *reinterpret_cast<Destinations **>(
lua_touserdata(L, 1));
}
此函数从 Lua C 包装函数中调用以获取对象引用。这最初是在上一章中使用 upvalue 实现的。
首先,使用 Lua 的luaL_checkudata
库函数,我们检查第一个参数是否是具有METATABLE_NAME
注册表中元表的 userdata。这是一个安全措施,以确保 Lua 代码不会传递其他类型的对象。对元表名称的检查之所以有效,是因为只有 C/C++代码可以访问注册表。
然后,我们将第一个参数作为Destinations **
的用户数据类型转换并解引用以获取对象指针。
由于我们现在在调用中从 Lua 传递对象,我们需要稍微修改我们的包装函数。
让包装器再次工作
在luaWish
和luaWent
中,我们期望得到一个城市列表。现在,我们需要排除第一个参数。只有一个字符需要更改。重写luaWish
,如下所示:
int luaWish(lua_State *L)
{
Destinations *obj = getObj(L);
std::vector<std::string> places;
int nArgs = lua_gettop(L);
for (int i = 2; i <= nArgs; i++)
{
places.push_back(lua_tostring(L, i));
}
obj->wish(places);
return 0;
}
我们只将for
循环中的i = 1
更改为i = 2
。你也可以对luaWent
做同样的操作。
现在我们已经将Destinations
C++类型导出到 Lua 中,让我们测试一下。
测试一下
由于我们在本章中采用了自顶向下的方法,所有测试代码都已完成。现在,我们只需要运行项目。
如果你一直在跟随,你应该看到以下输出:
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
[Lua] Visited: Berlin
[Lua] Unvisited: Beijing
前两行来自script.lua
中创建的第一个对象。最后两行来自创建的第二个对象。
如果你代码中有任何错误,你将在这里看到一些错误输出。回去看看你遗漏了什么。在本章中,代码是上一章的延续,但堆栈的状态相当复杂。
我们应该说“恭喜”吗?等等;你发现我们遗漏在导出类型中的东西了吗?
让我们在构造函数和析构函数中打印一些内容来追踪对象的生命周期。重写构造函数和析构函数,如下所示:
Destinations::Destinations() : wishlist({})
{
std::cout << "Destinations instance created: "
<< std::hex << this << std::endl;
}
Destinations::~Destinations()
{
std::cout << "Destinations instance destroyed: "
<< std::hex << this << std::endl;
}
构造函数和析构函数只是简单地以十六进制格式输出对象指针值。
再次运行项目并查看结果。你应该会看到以下类似的输出:
Destinations instance created: 0x12df07150
Destinations instance created: 0x12f804170
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
Destinations instance created: 0x12f8043a0
[Lua] Visited: Berlin
[Lua] Unvisited: Beijing
Destinations instance destroyed: 0x12df07150
创建了三个实例,但只有一个被销毁!被销毁的是在 main.cpp
中创建的,作为 Destinations
类原型的那个。这意味着我们在 Lua 中泄漏了对象!
我们需要修复这个问题!
提供一个终结器
你想知道为什么会有内存泄漏吗?
这是因为 Lua 使用垃圾回收。我们分配一个 userdata 并将其用作指针。当 userdata 在 Lua 中超出作用域时,这个指针会被垃圾回收。C++ 对象本身不会被删除。
Lua 完美支持以这种方式使用 userdata,但你需要在关联的 userdata 被垃圾回收时帮助 Lua 删除对象。为了帮助 Lua,你可以在元表中提供一个 __gc
元方法。这被称为终结器。它在垃圾回收过程中被调用以删除你的实际对象。
让我们为 Destinations
类型提供一个名为 luaDelete
的终结器。在 luaNew
之上添加另一个 Lua C 函数:
int luaDelete(lua_State *L)
{
Destinations *obj = getObj(L);
delete obj;
return 0;
}
很简单,对吧?Lua 在垃圾回收期间将 userdata 对象传递给终结器。
现在,让我们进行注册。在 luaNew
中添加两行,如下所示:
int luaNew(lua_State *L)
{
....
if (type == LUA_TNIL)
{
...
lua_pushcfunction(L, luaDelete);
lua_setfield(L, -2, "__gc");
}
...
}
这将元表中的 __gc
元方法设置为我们的 luaDelete
函数。
Lua 和 C++ 之间的交互可以在以下图中看到:
图 7.3 – 对象创建和销毁
当 Lua 代码创建 Destinations
类的对象时,它会触发对 luaNew
的调用,该调用创建 C++ 对象并返回其指针作为 userdata。当 Lua 完成对该对象的处理后,一段时间后,会触发垃圾回收并调用 luaDelete
C++ 终结器。
让我们再次运行项目。你应该会看到以下类似的输出:
Destinations instance created: 0x14c609a60
Destinations instance created: 0x14c704170
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
Destinations instance created: 0x14c7043a0
[Lua] Visited: Berlin
[Lua] Unvisited: Beijing
Destinations instance destroyed: 0x14c609a60
Destinations instance destroyed: 0x14c7043a0
Destinations instance destroyed: 0x14c704170
这样,我们已经创建了三个实例并销毁了三个实例。
恭喜!
摘要
在本章中,我们学习了如何将 C++ 类型导出到 Lua,以便 Lua 代码可以创建 C++ 对象。我们还了解了注册表和 userdata 类型。最后但同样重要的是,我们实现了垃圾回收的终结器。
本章的工作基于上一章中注册的 C++ 模块。你可以选择导出 C++ 类或 C++ 类实例。这是一个设计选择,取决于你的项目需求。
在下一章中,我们将学习如何实现模板类以帮助将 C++ 类型导出到 Lua。
练习
在 main.cpp
文件中,我们创建了一个 Destinations
实例并将其注册到 Lua 中。在 Lua 代码中,这个实例被用来创建更多的实例。考虑到 Lua 使用基于原型的面向对象编程,这是可以的。如果你想使你的 C++ 代码更接近 C++ 的编程方式,你可以为 Destinations
创建一个工厂类并将工厂注册到 Lua 中。这样做,并让工厂只导出 luaNew
函数。你不需要修改 script.lua
或 Lua C 封装函数的实现。
第八章:抽象 C++类型导出器
在上一章中,我们学习了如何将 C++类导出到 Lua 作为用户定义的类型。类的实例被导出到 Lua 作为类型的原型。
上一章的练习要求你为类型创建一个工厂类。然而,由于这个要求,每种类型都需要自己的工厂类。
在本章中,我们将学习如何实现一个通用的 C++类型导出器,这样你就可以将其用作任何 C++类型的工厂类,而无需重复工作。
在本章中,我们将涵盖以下主题:
-
回顾工厂实现
-
设计类型导出器
-
模拟类型导出器
-
定义
LuaModuleDef
-
重新实现
luaNew
-
你是否足够灵活?
技术要求
你可以在此处访问本章的源代码:github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter08
。
回顾工厂实现
我们将回顾上一章的练习。本书采用的解决方案需要渐进和最小化更改。它也自然地引出了本章介绍的功能。
如果你还没有实现自己的解决方案并且愿意停下来思考一下,这是一个尝试的机会。许多技术在解释上很简单,但很难掌握。理解它们的最佳方式是通过反复回顾和实践,直到你获得“啊哈”的顿悟。
现在,我们将回顾一个解决方案。重点是变化和关键概念。
定义工厂
要创建一个工厂,我们只需要更改Destinations.h
和Destinations.cc
。在你的首选 IDE 中,你可以打开第七章的end
项目以及本章的begin
项目来检查差异。
让我们先看看工厂类声明的头文件。你可以在本章begin
项目的Destinations.h
中找到以下声明:
class Destinations
{
public:
Destinations();
~Destinations();
void wish(const std::vector<std::string> &places);
void went(const std::vector<std::string> &places);
std::vector<std::string> listVisited() const;
std::vector<std::string> listUnvisited() const;
private:
std::map<std::string, bool> wishlist;
};
class DestinationsFactory : public LuaModule
{
public:
const std::string &luaName() const override;
const std::vector<luaL_Reg> &
luaRegs() const override;
};
与上一章的变化是,我们创建了一个名为DestinationsFactory
的工厂类,它实现了LuaModule
接口。实际上,我们将LuaModule
实现从Destinations
移动到DestinationsFactory
,这样Destinations
类型就不知道任何关于 Lua 的事情。这是工厂类的一个好处。系统可以更好地分层。
你知道吗?
如果你使用 Linux 或 Mac,你也可以使用diff
Chapter07/end/Destinations.h Chapter08/begin/Destinations.h
。
接下来,我们将回顾工厂实现。
实现工厂
工厂只有两个成员函数,其实现如下:
const std::string &
DestinationsFactory::luaName() const
{
return NAME;
}
const std::vector<luaL_Reg> &
DestinationsFactory::luaRegs() const
{
return FACTORY_REGS;
}
现在,luaRegs
不再返回REGS
,而是返回FACTORY_REGS
,它被定义为如下:
const std::vector<luaL_Reg> FACTORY_REGS = {
{"new", luaNew},
{NULL, NULL}};
这意味着,现在,我们只导出一个函数,luaNew
,到 Lua。
如第六章所述,Lua 库期望数组的最后一个条目是{NULL, NULL}
,以标记数组的结束。这是基于 C 的库的典型技术,因为它们通常将一个指向数组项的指针作为数组的输入,并需要确定数组在哪里结束。
此外,从REGS
中删除luaNew
,使其看起来像以下列表:
const std::vector<luaL_Reg> REGS = {
{"wish", luaWish},
{"went", luaWent},
{"list_visited", luaListVisited},
{"list_unvisited", luaListUnvisited},
{NULL, NULL}};
之前,REGS
有两个用途:
-
从 Lua 创建的新实例的
__index
元表。这是在luaNew
中通过luaL_setfuncs(L, REGS.data(), 0)
来完成的。 -
已注册的 Lua 模块,这是一个普通的 Lua 表。这是通过调用
LuaExecutor::registerModule
来完成的。
现在,REGS
只服务于第一个目的,并将第二个责任交给了FACTORY_REGS
。这是另一个结构改进。
这些就是我们创建工厂所需的所有更改。您可以从 GitHub 获取完整的源代码。然而,代码更改并不多,对吧?我们只是移动了一些东西,现在我们有一个不同的对象创建机制。
现在,基于这个工厂概念,我们准备继续本章的主要焦点。从现在开始,您可以使用begin
项目作为开发的基础。让我们开始设计一个通用的 C++类型导出器。
设计类型导出器
首先,让我们定义我们的范围。我们希望使刚刚创建的工厂通用化,并使其能够与任何 C++类一起工作——也就是说,C++类仍然需要以某种方式实现和提供lua_CFunction
包装器。自动创建这些包装器是可能的,但这将需要实现一个重量级的 C++模板库,这与 Lua 没有直接关系,并且超出了本书的范围。
在定义了范围之后,让我们做一些高级设计。
选择设计模式
当我们谈论在 C++中使某物通用时,通常意味着我们需要使用模板。为了与我们的 Lua 执行器一起工作,我们需要导出LuaModule
。因此,我们需要将导出器实现为一个模板类,它可以提供LuaModule
。
我们如何提供LuaModule
?我们可以使导出器继承自LuaModule
接口,或者使其其中一个成员函数返回LuaModule
。
后者选项中流行的设计模式之一是建造者模式。这可以通过以下伪代码来演示:
class LuaModuleBuilder
{
LuaModuleBuilder withOptionA(...);
LuaModuleBuilder withOptionB(...);
...
LuaModule build();
};
LuaModuleBuilder builder;
auto module = builder.withOptionX(...).build();
建造者通常有许多函数来定制它所创建的事物的不同属性,同时还有一个build
函数来创建最终对象。
由于我们的目标仅仅是帮助进行对象创建,就像在工厂练习中一样,而不是定制对象,因此建造者模式是多余的。我们将选择纯 C++继承。导出器类型可以定义如下:
template <typename T>
class LuaModuleExporter : public LuaModule;
这是一个模板类。它将导出 C++类型T
为LuaModule
。
现在,让我们模拟导出器。
模拟导出器
在导出器的设计中,我们有两个主要的考虑因素。首先,它是 LuaModule
,因此需要实现它的纯虚函数。其次,我们希望它类似于我们在工厂练习中实现的内容,这意味着我们对 luaRegs
虚拟函数实现中要返回的内容有一个相当好的想法。
让我们开始。添加一个名为 LuaModuleExporter.hpp
的新文件,并定义 LuaModuleExporter
类,如下所示:
template <typename T>
class LuaModuleExporter final : public LuaModule
{
public:
LuaModuleExporter(
const LuaModuleExporter &) = delete;
~LuaModuleExporter() = default;
static LuaModuleExporter<T> make()
{
return LuaModuleExporter<T>();
}
private:
LuaModuleExporter() {}
};
这使得导出器成为一个最终类,并防止它被复制构造。因为导出器的目的是提供 LuaModule
,我们没有逻辑让它通过值传递,所以添加一些限制可以防止未来的错误。我们通过将 delete
关键字赋给复制构造函数来实现这一点。我们还想控制对象创建,所以我们使构造函数私有。这还有一个副作用——你不能使用 new
操作符来创建类的实例。
现在,按照以下方式为 LuaModule
添加实现:
class LuaModuleExporter final : public LuaModule
{
public:
const std::string &luaName() const override
{
return name;
}
const std::vector<luaL_Reg> &luaRegs() const override
{
return factoryRegs;
}
private:
const std::string name = "TODO";
const std::vector<luaL_Reg> factoryRegs = {
{"new", luaNew},
{NULL, NULL}};
static int luaNew(lua_State *L)
{
return 0;
}
};
这很简单。在 Lua 模块级别,我们只想导出一个函数来创建具体对象。因此,我们只会注册 luaNew
。模块的名称需要传递进来。我们将在实现细节时找到一种方法。
因此,我们为导出器创建了一个占位符。这是一个系统级的设计合约。现在,让我们编写测试代码来查看它应该如何使用。
准备 C++ 测试代码
在 main.cpp
中编写 main
函数如下:
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
auto module = LuaModuleExporter<
Destinations>::make();
lua->registerModule(module);
lua->executeFile("script.lua");
return 0;
}
与上一章相比,唯一的区别是 LuaModule
的创建方式。现在,它是通过 LuaModuleExporter<Destinations>::make()
创建的。
到目前为止,项目应该可以编译。当你运行它时,它不应该在 C++ 端崩溃;尽管如此,在这个阶段,它将无法执行任何有意义的操作,你应该会看到 Lua 的错误信息。
现在,我们将看到我们需要什么 Lua 代码。
准备 Lua 测试脚本
将 script.lua
编写如下:
dst = Destinations.new()
dst:wish("London", "Paris", "Amsterdam")
dst:went("Paris")
print("Visited:", dst:list_visited())
print("Unvisited:", dst:list_unvisited())
我们在上一章中使用了这个代码片段。这将帮助我们验证我们是否会在本章后面得到相同的结果。
接下来,让我们开始使导出器工作。
定义 LuaModuleDef
首先,我们需要提供模块的名称,然后是 __index
元表。最后,我们需要提供一个元表的名称。回想一下,在 Destinations.cc
中,元表的名称硬编码如下:
const std::string METATABLE_NAME(
"Destinations.Metatable");
现在,这需要传递给导出器。让我们定义一个结构来表示上述三块信息。在 LuaModule.h
中添加以下声明:
template <typename T>
struct LuaModuleDef
{
const std::string moduleName;
const std::vector<luaL_Reg> moduleRegs;
const std::string metatableName() const
{
return std::string(moduleName)
.append(".Metatable");
}
};
这定义了 moduleName
和 moduleRegs
。元表名称基于模块名称,并在其后附加 ".Metatable"
。
注意,这个结构也是模板化的。这表明定义是为某种 C++ 类型。我们将在本章后面使用模板。
现在,我们可以将这个结构传递给导出器。
使用 LuaModuleDef
在LuaModuleExporter.hpp
中,在导出器创建期间接受LuaModuleDef
实例。重写相关代码如下:
class LuaModuleExporter final : public LuaModule
{
public:
static LuaModuleExporter<T> make(
const LuaModuleDef<T> &luaModuleDef)
{
return LuaModuleExporter<T>(luaModuleDef);
}
const std::string &luaName() const override
{
return luaModuleDef.moduleName;
}
private:
LuaModuleExporter(
const LuaModuleDef<T> &luaModuleDef)
: luaModuleDef(luaModuleDef) {}
const LuaModuleDef<T> luaModuleDef;
};
更改如下:
-
我们添加了一个私有成员变量
luaModuleDef
-
我们向
make
和私有构造函数添加了一个类型为LuaModuleDef
的参数 -
我们将
luaName
改为返回luaModuleDef.moduleName
-
我们删除了在模拟过程中引入的私有成员变量
name
现在,我们可以为Destinations
类定义LuaModuleDef
。
在Destinations.h
中,删除DestinationsFactory
的声明并添加以下代码:
struct DestinationsLuaModuleDef
{
static LuaModuleDef<Destinations> def;
};
在Destinations.cpp
中,删除DestinationsFactory
的所有实现,并在匿名命名空间之后添加以下代码:
LuaModuleDef DestinationsLuaModuleDef::def =
LuaModuleDef<Destinations>{
"Destinations",
{{"wish", luaWish},
{"went", luaWent},
{"list_visited", luaListVisited},
{"list_unvisited", luaListUnvisited},
{NULL, NULL}},
};
最后,在main.cpp
中,将模块创建代码更改为以下语句:
auto module = LuaModuleExporter<Destinations>::make(
DestinationsLuaModuleDef::def);
这将Destinations
类的LuaModuleDef
泵送到导出器中。确保项目可以编译。
现在,我们将填补其余缺失的部分,使导出器真正工作。
重新实现luaNew
由于我们将在LuaModuleExporter
中存储LuaModuleDef
,为了访问它,我们需要找到LuaModuleExporter
的实例。让我们首先实现一个辅助函数来完成这个任务。
由于导出器也是LuaModule
,它已经具有第六章中实现的值机制。LuaModule::pushLuaUpvalues
会将LuaModule
实例的指针作为值推入。要检索它,我们可以添加以下函数:
class LuaModuleExporter final : public LuaModule
{
private:
static LuaModuleExporter<T> *getExporter(
lua_State *L)
{
return reinterpret_cast<LuaModuleExporter<T> *>(
lua_touserdata(L, lua_upvalueindex(1)));
}
};
这与第六章中的getObj
函数相同,但现在它是一个静态成员函数。
通过一种从静态成员函数访问导出器实例的方式,我们可以将LuaModuleExporter::luaNew
编写如下:
static int luaNew(lua_State *L)
{
auto luaModuleDef = getExporter(L)->luaModuleDef;
T *obj = new T();
T **userdata = reinterpret_cast<T **>(
lua_newuserdatauv(L, sizeof(obj), 0));
*userdata = obj;
auto metatableName = luaModuleDef.metatableName();
int type = luaL_getmetatable(
L, metatableName.c_str());
if (type == LUA_TNIL)
{
lua_pop(L, 1);
luaL_newmetatable(L, metatableName.c_str());
lua_pushvalue(L, -1);
lua_setfield(L, -2, "__index");
luaL_setfuncs(
L, luaModuleDef.moduleRegs.data(), 0);
lua_pushcfunction(L, luaDelete);
lua_setfield(L, -2, "__gc");
}
lua_setmetatable(L, 1);
return 1;
}
这实际上是从Destinations.cc
中复制的。除了使用T
typename
代替硬编码的类名之外,更改已在前面的代码中突出显示。你可以看到它们都是关于泵送LuaModuleDef
。
如果你忘记了luaNew
是如何工作的,你可以查看上一章,其中有一些图表显示了 Lua 堆栈如何变化。
最后,让我们实现LuaModuleExporter::luaDelete
的存根如下:
static int luaDelete(lua_State *L)
{
T *obj = *reinterpret_cast<T **>(
lua_touserdata(L, 1));
delete obj;
return 0;
}
luaDelete
在luaNew
中注册为__gc
元方法。
你还记得吗?
如前一章所述,我们将luaDelete
设置为luaNew
中创建的用户数据的终结器。在 Lua 垃圾回收过程中,终结器将被调用,参数为用户数据引用。
你也可以在Destinations.cc
中删除REGS
、FACTORY_REGS
、luaNew
和luaDelete
。它们不再使用。
现在,我们可以测试导出器。执行项目。如果你一切都做对了,你应该会看到以下输出:
Destinations instance created: 0x12a704170
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
Destinations instance destroyed: 0x12a704170
我们并没有真正改变上一章中的测试代码,除了Destinations
类如何导出到 Lua 的方式。
如果你遇到了任何错误,不要气馁。这是这本书中最复杂的一章,我们需要在多个文件中正确实现代码才能使其工作。回顾你的步骤并修复错误。你可以做到!此外,在 GitHub 上,有多个针对本章的检查点项目,你可以参考。如前所述,我们不会自动生成 lua_CFunction
包装器。泛化也需要有界限。
但是,让我们检查我们的实现有多通用。
你足够灵活吗?
为了回答这个问题,让我们将 script.lua
修改如下:
dst = Destinations.new("Shanghai", "Tokyo")
dst:wish("London", "Paris", "Amsterdam")
dst:went("Paris")
print("Visited:", dst:list_visited())
print("Unvisited:", dst:list_unvisited())
是的,新的要求是在 Lua 代码中,当创建 Destinations
对象时,我们可以提供一个未访问地点的初始列表。
这意味着我们需要支持参数化对象创建。
我们的导出器支持这个功能吗?这应该是一个常见的用例。
现在是思考人生、喝杯咖啡或做任何其他事情的好时机。我们几乎接近这本书的第三部分结束。
如果你还记得,我们的对象创建代码如下:
static int luaNew(lua_State *L)
{
...
T *obj = new T();
...
}
作为一名经验丰富的 C++ 程序员,你可能认为,因为 std::make_unique<T>
可以将其参数传递给 T
的构造函数,所以一定有办法让 LuaModuleExporter<T>::make
做同样的事情。没错,但 std::make_unique<T>
的魔力在于 C++ 编译时。那么,当参数在 C++ 代码编译后通过 Lua 代码传递时,你将如何处理呢?
别担心。让我们来探索工厂方法设计模式。工厂方法是一个定义为一个方法或接口的合约,用于创建并返回一个对象。然而,对象的创建方式并不重要,也不属于合约的一部分。
为了看到它是如何工作的,让我们为 LuaModuleDef
实现一个。添加另一个成员变量,命名为 createInstance
,如下所示:
struct LuaModuleDef
{
const std::function<T *(lua_State *)>
createInstance =
[](lua_State *) -> T* { return new T(); };
};
这是一点高级的 C++ 使用。因此,重要的是你要考虑以下几点:
-
createInstance
被声明为一个成员变量,而不是一个成员函数。这是因为你可以在对象构造期间简单地给成员变量赋一个不同的值以实现不同的行为,但如果你使用成员函数,你需要创建一个子类来覆盖行为。我们应该在可能的情况下优先选择组合而不是继承。 -
createInstance
的类型是std::function
。使用这种类型,你可以像使用函数一样使用这个变量。如果你在这方面更熟悉 Lua,你会明白一个命名的 Lua 函数也是一个变量。在这里,我们想要达到相同的效果。T *(lua_State *)
是函数的类型。这意味着该函数期望一个类型为lua_State*
的参数,并将返回一个类型为T
的指针。你可以查看 C++ 参考手册来了解更多关于std::function
的信息。 -
然后,我们提供一个默认的实现,作为一个 C++ lambda 表达式。这个 lambda 表达式简单地创建一个堆中的实例,没有任何构造函数参数。
要使用这个工厂方法,按照以下方式更改 LuaModuleExporter::luaNew
:
static int luaNew(lua_State *L)
{
...
T *obj = luaModuleDef.createInstance(L);
...
}
我们已经从 new T()
更改为 luaModuleDef.createInstance(L)
,它仍然做同样的事情。
然而,请注意,我们不再在 LuaModuleExporter
中创建对象。
最后,为了回答这个问题,是的,我们足够灵活。
关于现代 C++
在 1998 年,C++ 首次标准化为 C++98。直到 2011 年的 C++11,它变化很小。从那时起,C++ 快速采用了语言规范中的现代编程技术。Lambda 和 std::function
只是众多例子中的两个。如果你了解其他语言(例如 Java),你可以做一些类比(lambda 和函数式接口),尽管语法不同。我以这种方式实现了 LuaModuleDef
,而不是使用更传统的方法,以展示一些现代 C++ 特性的例子。这是未来,我鼓励你更深入地探索现代 C++。使用 Java、Kotlin 和 Swift 的人默认使用这些技术。通过采用这些新技术并帮助 C++ 赶上,你可以在这里扮演重要的角色。
在 Destinations.cc
中,按照以下方式更改 LuaModuleDef
实例:
LuaModuleDef DestinationsLuaModuleDef::def =
LuaModuleDef<Destinations>{
"Destinations",
{{"wish", luaWish},
...
{NULL, NULL}},
[](lua_State *L) -> Destinations *
{
Destinations *obj = new Destinations();
std::vector<std::string> places;
int nArgs = lua_gettop(L);
for (int i = 1; i <= nArgs; i++)
{
places.push_back(lua_tostring(L, i));
}
obj->wish(places);
return obj;
},
};
这将使用提供的 lambda 初始化 createInstance
字段,而不是默认的 lambda。新的 lambda 与 luaWish
包装器做类似的事情。这个优点在于你可以完全控制这个 lambda。你可以为 Destinations
类创建另一个构造函数,并简单地调用新的构造函数。
我们可以用新的 Lua 脚本测试项目。你应该能看到以下输出:
Destinations instance created: 0x142004170
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London Shanghai Tokyo
Destinations instance destroyed: 0x142004170
如你所见,上海
和 东京
已经被添加到未访问列表中。
更进一步的设计改进
我们在 LuaModuleDef
中创建对象,但在 LuaModuleExporter
中销毁它们,并且我们的用例不涉及对象所有权的转移。为了更好的设计,应该由创建对象的同一类销毁它们,这将在下一章中实现。
这次,真的是完成了。
摘要
在这一章中,我们实现了一个通用的 C++ 模块导出器,主要用于对象创建部分。这确保了你只需实现一次复杂对象创建逻辑,就可以与许多 C++ 类一起重用。此外,这一章标志着 第三部分,从 Lua 调用 C++ 的结束。
在下一章中,我们将回顾 Lua 和 C++ 之间的不同通信机制,并进一步探讨它们。
练习
这是一个开放练习。你可以编写一个新的 C++ 类,或者找到你过去工作中的某个类,然后使用 LuaModuleExporter
将其导出到 Lua。尝试提供一个有趣的 createInstance
实现以及。
第四部分 – 高级主题
到这本书的这一部分,你将已经学会了所有将 Lua 与 C++ 集成的常见机制。
在这部分,你将回顾你所学的知识,这也可以作为快速参考的来源。你还将学习如何实现一个可以被 Lua 加载的独立 C++模块,作为一个可动态加载的库。然后,你将学习一些高级内存管理技术以及如何使用 Lua 实现多线程。
本部分包括以下章节:
-
第九章,回顾 Lua-C++通信机制
-
第十章,管理资源
-
第十一章,使用 Lua 进行多线程
第九章:回顾 Lua-C++ 通信机制
在本书的 第二部分 中,我们学习了如何从 C++ 调用 Lua。在 第三部分 中,我们学习了如何从 Lua 调用 C++。在本书的学习过程中,我们探讨了众多示例,其中一些示例依赖于高级 C++ 技术。
本章将总结 Lua 和 C++ 之间的所有通信机制,去除大部分 C++ 的细节。我们还将深入探讨一些在示例中尚未展示的主题。
您可以使用本章回顾您所学的内容。对于每个主题,我们将列出一些重要的 Lua 库函数。您可以在 Lua 参考手册中查找更多相关函数。
在未来的编程旅程中,随着您的进步,您可能会在项目中采用不同的 C++ 技术。在这种情况下,本章将是一个有用的快速参考来源。
我们将涵盖以下主题:
-
栈
-
从 C++ 调用 Lua
-
从 Lua 调用 C++
-
实现独立的 C++ 模块
-
在 Lua 中存储状态
-
用户数据
技术要求
您可以访问本章的源代码,网址为 github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter09
。
您可以访问 Lua 参考手册,并养成在 www.lua.org/manual/5.4/
频繁检查 API 详细信息的习惯。
栈
Lua 栈可以服务于两个目的:
-
*在 C++ 和 Lua 之间交换数据**。传递函数参数和检索函数返回值适用于此用途。
-
*保留中间结果**。例如,我们可以在完成表格之前在栈上保留一个表格引用;我们可以将一些值推送到栈上,然后弹出并使用它们作为 upvalues。
Lua 栈有两种形式:
-
*Lua 状态附带的可公开访问的公共栈**。一旦通过
luaL_newstate
或lua_newstate
创建了 Lua 状态,您就可以传递状态并在所有可以访问 Lua 状态的函数中访问相同的 Lua 栈。 -
每个
lua_CFunction
调用的私有栈。栈仅对函数调用可访问。多次调用相同的lua_CFunction
不会共享相同的栈。因此,传递给lua_CFunction
调用的栈是函数调用的私有栈。
推送到栈上
您可以使用 lua_pushXXX
函数将值或对象引用推送到栈上——例如,lua_pushstring
。
查看 Lua 参考手册以获取此类函数的列表。
查询栈
您可以使用 lua_isXXX
函数检查给定栈位置是否包含特定类型的项。
您可以使用 lua_toXXX
函数将给定栈位置转换为特定类型。这些函数总是会成功,尽管如果栈位置包含不同类型的项,则结果值可能会令人惊讶。
您可以在 Lua 参考手册中查找此类函数的列表。
其他栈操作
有一些其他常用的栈操作。
确保栈大小
Lua 栈以预定义的大小创建,应该足够大,可以满足大多数操作。如果您需要将大量项推送到栈上,您可以通过调用以下函数来确保栈大小可以满足您的需求:
int lua_checkstack (lua_State *L, int n);
n
是所需的大小。
计数项
要检查栈中的项数,请使用 lua_gettop
。返回值是计数。
重置栈顶
要将栈顶设置为特定索引,请使用 lua_settop
函数,其声明如下:
void lua_settop (lua_State *L, int index);
这可以清除栈顶的一些项,或者用 nil 填充栈。我们可以使用它来有效地从栈中清除临时项,如我们示例中的 LuaModuleExporter::luaNew
所示:
T *obj = luaModuleDef.createInstance(L);
lua_settop(L, 0);
在 luaNew
中,我们传递了 Lua 状态,即 Lua 栈,到一个外部工厂方法。因为我们不知道工厂方法将如何使用 Lua 栈,所以在工厂方法返回后我们清空了栈,以消除任何可能的副作用。
复制另一个项
如果一个项已经在栈中,您可以通过调用此函数快速将其副本推送到栈顶:
void lua_pushvalue (lua_State *L, int index);
如果支持项的值或对象难以获取,这可以为您节省一些麻烦。
Lua 库支持一些其他栈操作,但它们很少用来实现复杂效果。您可以在参考手册中查看它们。
从 C++ 调用 Lua
要从 C++ 调用 Lua 代码,我们可以使用 lua_pcall
,其声明如下:
int lua_pcall(
lua_State *L, int nargs, int nresults, int msgh);
这将调用一个 Lua 可调用项,它可以是一个函数或一个代码块。您可以将要调用的 Lua 函数推送到栈上,或者将文件或字符串编译成一个代码块并将其放置到栈上。nargs
是可调用项的参数数量。参数被推送到可调用项之上的栈上。nresults
是可调用项将返回的返回值数量。使用 LUA_MULTRET
来表示您期望一个可变数量的返回值。msgh
是错误消息处理器的栈索引。
lua_pcall
在 保护模式 下调用可调用项,这意味着调用链中可能发生的任何错误都不会传播。相反,lua_pcall
从中返回一个错误状态码。
在我们实现的 LuaExecutor
类中,您可以找到许多从 C++ 调用 Lua 的示例。
在 Lua 参考手册中,您可以找到其他类似于 lua_pcall
的库函数,尽管 lua_pcall
是最常用的一个。
从 Lua 调用 C++
要从 Lua 调用 C++ 代码,C++ 代码需要通过 lua_CFunction
实现导出,其定义如下:
typedef int (*lua_CFunction) (lua_State *L);
例如,在 LuaExecutor
中,我们实现了一个函数:
int luaGetExecutorVersionCode(lua_State *L)
{
lua_pushinteger(L, LuaExecutor::versionCode);
return 1;
}
这将返回一个整数给 Lua 代码。将此函数导出到全局表的一个简单方法可以如下实现:
void registerHostFunctions(lua_State *L)
{
lua_pushcfunction(L, luaGetExecutorVersionCode);
lua_setglobal(L, "host_version");
}
您可以使用 lua_pushcfunction
将 lua_CFunction
推送到栈上,然后将其分配给您选择的变量。
然而,更有可能的是,您应该将一组函数作为一个模块导出。
导出 C++ 模块
要导出 C++ 模块,您只需将函数表导出到 Lua。在 LuaExecutor
中,我们是这样实现的:
void LuaExecutor::registerModule(LuaModule &module)
{
lua_createtable(L, 0, module.luaRegs().size() - 1);
int nUpvalues = module.pushLuaUpvalues(L);
luaL_setfuncs(L, module.luaRegs().data(), nUpvalues);
lua_setglobal(L, module.luaName().c_str());
}
该过程是首先创建一个表,然后使用 lua_createtable
将引用推送到栈上。然后,您可以推送 共享 upvalues(我们将在本章后面回顾 upvalues),最后使用 luaL_setfuncs
将函数列表添加到表中。
如果您不需要 upvalues,您可以使用一个快捷方式:
void luaL_newlib (lua_State *L, const luaL_Reg l[]);
luaL_newlib
和 luaL_setfuncs
都接受一个结构列表来描述函数:
typedef struct luaL_Reg {
const char *name;
lua_CFunction func;
} luaL_Reg;
结构为 lua_CFunction
提供了一个 name
值,该值用作表条目的键。
实现独立 C++ 模块
到目前为止,在这本书中,我们只在 C++ 代码中显式将 C++ 模块注册到 Lua。然而,还有另一种方法向 Lua 提供一个 C++ 模块。
您可以为模块生成共享库并将其放置在 Lua 的搜索路径中。当 Lua 代码 require 模块时,Lua 将自动加载共享库。
通过重用我们的 Destinations
类,这很简单实现。创建一个名为 DestinationsModule.cpp
的文件,并按照以下内容填充:
#include "Destinations.h"
#include "LuaModuleExporter.hpp"
#include <lua.hpp>
namespace {
LuaModuleExporter module =
LuaModuleExporter<Destinations>::make(
DestinationsLuaModuleDef::def);
}
extern "C" {
int luaopen_destinations(lua_State *L)
{
lua_createtable(L, 0, module.luaRegs().size() - 1);
int nUpvalues = module.pushLuaUpvalues(L);
luaL_setfuncs(L, module.luaRegs().data(), nUpvalues);
return 1;
}
}
已实现的模块称为 destinations
。Lua 所需的代码级合约如下:
-
您需要提供
lua_CFunction
,其名称必须以luaopen_
开头,然后附加模块名称。 -
lua_CFunction
需要将它创建的内容留在栈上。
luaopen_destinations
的代码几乎与我们在上一节中解释的 LuaExecutor::registerModule
相同。唯一的区别是我们将表引用留在了栈上,因为 Lua 的 require
函数会弹出它。
extern “C”
默认情况下,C++ 编译器会对 C++ 函数名进行名称修饰。这意味着在函数编译后,函数将具有比源代码中声明的更复杂的符号名。为了防止这种情况发生,您可以将函数声明放在一个 extern "C"
块中。否则,Lua 在编译后无法找到该函数,因为合约已被破坏。
编译独立模块
要编译共享库,请将以下行添加到您的 Makefile
中:
DESTINATIONS_O = Destinations.o DestinationsModule.o
DESTINATIONS_SO = destinations.so
destinations: ${DESTINATIONS_O}
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) -shared \
-o $(DESTINATIONS_SO) ${DESTINATIONS_O} -llua
在终端中,执行 make destinations
命令以创建共享库。您将得到一个名为 destinations.so
的文件,这是 Lua 将加载的二进制文件。
测试独立模块
要测试独立模块,在 destinations.so
所在的文件夹中,启动 Lua 交互式解释器并执行以下语句:
Chapter09 % ../lua/src/lua
Lua 5.4.6 Copyright (C) 1994-2023 Lua.org, PUC-Rio
> Destinations = require "destinations"
> dst = Destinations.new("Shanghai", "Tokyo")
Destinations instance created: 0x155a04210
> dst:wish("London", "Paris", "Amsterdam")
> dst:went("Paris")
> print("Visited:", dst:list_visited())
Visited: Paris
> print("Unvisited:", dst:list_unvisited())
Unvisited: Amsterdam London Shanghai Tokyo
> os.exit()
Destinations instance destroyed: 0x155a04210
最重要的语句是 require
语句。它加载 destinations.so
并将模块分配给 Destinations
全局变量。
我们在模块二进制文件所在的同一文件夹中启动了 Lua 交互式解释器,因为require
将在当前工作目录中搜索模块。或者,你也可以将库放在系统搜索路径中。你可以查看参考手册了解更多关于require
及其行为的信息。
当你需要跨多个项目重用二进制形式的模块或在 C++侧强制代码隔离时,一个独立的 C++模块很有用,但这只是一个设计选择。
在 Lua 中存储状态
在 Lua 中存储状态有两种方式为lua_CFunction
:upvalues和注册表。让我们回顾一下它们,并更深入地了解 upvalues。
Upvalues
为了引入 upvalue 的完整定义,我们需要同时引入Lua C 闭包。引用 Lua 参考手册:
当创建 C 函数时,可以将其与一些值关联起来,从而创建一个 C 闭包;这些值被称为 upvalue,并且每当函数被调用时都可以访问。
简单来说,闭包仍然是我们的老朋友lua_CFunction
。当你将其与一些值关联时,它就变成了闭包,而这些值就变成了 upvalue。
重要的是要注意,Lua C 闭包和 upvalue 是不可分割的。
要创建一个闭包,请使用以下库函数:
void lua_pushcclosure(
lua_State *L, lua_CFunction fn, int n);
这从lua_CFunction
创建了一个闭包,并将n
个值与它关联。
要看到它的实际效果,让我们解决前一章的设计问题:
我们在LuaModuleDef
中创建对象,但在LuaModuleExporter
中销毁它们。为了更好的设计,应该由创建对象的同一类销毁它们。
实现 Lua C 闭包
以下特性是前一章的延续。如果你需要更好的理解,可以回顾前一章。
要做到这一点,我们可以为LuaModuleDef
实现一个destroyInstance
成员变量,如下所示:
struct LuaModuleDef
{
...
const std::function<void(T *)> destroyInstance =
[](T *obj) { delete obj; };
...
};
现在,对象将在同一个LuaModuleDef
实体中创建和销毁。要使用destroyInstance
,修改LuaModuleExporter::luaDelete
,如下所示:
static int luaDelete(lua_State *L)
{
auto luaModuleDef = getExporter(L)->luaModuleDef;
T *obj = *reinterpret_cast<T **>(
lua_touserdata(L, 1));
luaModuleDef.destroyInstance(obj);
return 0;
}
回想一下,getExporter
用于检索第一个 upvalue,它是指向导出器的指针:
static LuaModuleExporter<T> *getExporter(lua_State *L)
{
return reinterpret_cast<LuaModuleExporter<T> *>(
lua_touserdata(L, lua_upvalueindex(1)));
}
这对luaNew
有效,因为LuaModuleExporter
是从LuaModule
继承的,它在默认实现中将其this
作为 upvalue 压入栈中:
class LuaModule
{
public:
virtual int pushLuaUpvalues(lua_State *L)
{
lua_pushlightuserdata(L, this);
return 1;
}
};
然后,压入的 upvalue 被用作LuaExecutor::registerModule
中所有导出函数的共享 upvalue:
void LuaExecutor::registerModule(LuaModule &module)
{
lua_createtable(L, 0, module.luaRegs().size() - 1);
int nUpvalues = module.pushLuaUpvalues(L);
luaL_setfuncs(L, module.luaRegs().data(), nUpvalues);
lua_setglobal(L, module.luaName().c_str());
}
共享的 upvalue 只被压入栈中一次,并且与提供给luaL_setfuncs
的所有函数相关联。
共享 upvalue 并不是真正共享
所说的共享 upvalues 在设置期间为每个函数复制。之后,函数访问它们自己的 upvalue 副本。在 Lua 参考手册中,这些被称为共享 upvalues,因为它们只被压入栈中一次,用于所有要注册的函数,这对于 API 调用来说只相关。我认为这个术语是误导性的。你应该把它们看作是普通的 upvalue。
然而,getExporter
对于 luaDelete
不会起作用,因为 luaDelete
不是一个导出函数,也没有传递给 luaL_setfuncs
。为了支持 luaDelete
,修改 luaNew
,如下所示:
static int luaNew(lua_State *L)
{
auto exporter = getExporter(L);
auto luaModuleDef = exporter->luaModuleDef;
...
if (type == LUA_TNIL)
{
...
lua_pushlightuserdata(L, exporter);
lua_pushcclosure(L, luaDelete, 1);
lua_setfield(L, -2, "__gc");
}
...
}
我们只需要将 exporter
作为 upvalue 压入 luaDelete
,并将 luaDelete
转换为闭包。
现在,LuaModuleExporter
有了一个更好的设计,因为它将对象构造和对象销毁都委托给了 LuaModuleDef
。同时,它在 getExporter
辅助函数中同时使用了 upvalues(用于 luaDelete
)和共享 upvalues(用于 luaNew
)。这表明设置后共享 upvalues 和 upvalues 没有区别。
注册表
注册表是一个预定义的 Lua 表,仅对 C/C++ 代码可访问。对于 Lua 状态,注册表对所有 C/C++ 函数是共享的,因此应仔细选择表键名以避免冲突。
值得注意的是,按照惯例,完整用户数据通过 luaL_newmetatable
将其元表放置在注册表中。
简单来说,注册表是 Lua 语言特别对待的 Lua 表,并提供了一些辅助函数。
用户数据
Lua 用户数据可以分为 轻量级用户数据 和 完整用户数据。
重要的是要注意,它们是不同的事物。在 Lua 库中,按照惯例,轻量级用户数据命名为 lightuserdata,而完整用户数据命名为 userdata。
轻量级用户数据
轻量级用户数据代表一个 C/C++ 指针。它是一个值类型,值在各个地方传递。你可以在 C/C++ 代码中使用 lua_pushlightuserdata
将一个指针压入栈中。你不能使用 Lua 库创建轻量级用户数据。
完整用户数据
完整用户数据是 Lua 库通过调用 lua_newuserdatauv
分配的原始内存区域。它是一个对象类型,只有其引用在各个地方传递。
因为完整用户数据是由 Lua 在堆中创建的,所以 Lua 垃圾回收就变得重要了。在 C++ 方面,你可以通过提供 __gc
元方法来提供一个 终结器。
有关如何利用完整用户数据在 Lua 中访问 C++ 对象的完整示例,请查看 LuaModuleExporter
。
概述
在本章中,我们简要回顾了 Lua 和 C++ 之间的所有通信机制。这应该已经充分巩固了您到目前为止的学习。
我们还学习了如何生成一个独立的 C++ 模块作为共享库。这为您组织项目开辟了新的途径。
在下一章中,我们将更详细地讨论资源管理。
第十章:管理资源
在上一章中,我们回顾了 Lua 和 C++之间的通信机制。在本章中,我们将学习更多关于管理资源的内容。资源可以是对象使用的任何东西,例如内存、文件或网络套接字。
我们将涵盖以下主题:
-
自定义 Lua 内存分配
-
将 C++对象内存分配委托给 Lua
-
什么是 RAII?
技术要求
我们将使用第九章的源代码作为基础来开发本章的示例。请确保你可以访问本书的源代码:github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter10
。
自定义 Lua 内存分配
在 Lua 运行时,以下情况下在堆中分配、重新分配或释放内存:
-
内存分配:当创建对象时会发生这种情况。Lua 需要分配一块内存来存储它。
-
内存重新分配:当需要更改对象大小时会发生这种情况,例如,当表格没有更多预分配空间时,向表格中添加条目。
-
内存释放:在垃圾回收期间,当对象不再需要时发生。
在大多数情况下,你不需要关心这个问题。但有时,了解或自定义 Lua 内存分配是有帮助的。以下是一些示例:
-
你需要分析 Lua 对象的内存占用情况,以找到优化机会。
-
你需要自定义内存的分配位置。例如,为了提高运行时效率,你可能有一个内存池,你可以简单地让 Lua 使用它,而不需要在堆中每次都分配新的内存区域。
在本节中,我们将了解如何通过提供内存分配函数来自定义 Lua 的内存分配。
Lua 内存分配函数是什么?
Lua 提供了一种简单的方式来自定义内存分配。当你创建 Lua 状态时,你可以提供一个内存分配函数,这样每当 Lua 需要管理内存时,它就会调用你提供的函数。
内存分配函数定义为以下类型:
typedef void * (*lua_Alloc) (void *ud,
void *ptr,
size_t osize,
size_t nsize);
函数返回指向新分配内存的指针,或者如果调用用于释放一块内存,则返回NULL
。其参数解释如下:
-
ud
是 Lua 状态的用户定义数据的指针。你可以使用相同的内存分配函数与多个 Lua 状态一起使用。在这种情况下,你可以使用ud
来识别每个 Lua 状态。Lua 对此是透明的。 -
ptr
是要重新分配或释放的内存的指针。如果它是NULL
,则调用内存分配器的目的是分配一个新的内存块。 -
osize
是由ptr
指向的先前分配的内存的原始大小。如果ptr
是NULL
,则osize
具有特殊含义——正在分配的 Lua 对象的类型,可以是LUA_TSTRING
、LUA_TTABLE
等。 -
nsize
是要分配或重新分配的内存大小。如果nsize
为0
,则表示要释放内存。
要注册你的内存分配函数,你可以使用 lua_newstate
来创建 Lua 状态,其声明如下:
lua_State *lua_newstate (lua_Alloc f, void *ud)
通过这种方式,你为要创建的 Lua 状态提供了内存分配函数和用户数据。请注意,你可以向 ud
提供空指针,并且这个用户数据是 C++ 端的对象,而不是 Lua 用户数据。
接下来,我们将实现一个内存分配函数。
实现内存分配函数
我们将扩展 LuaExecutor
来练习实现内存分配函数。当我们创建执行器时,我们想要传递一个标志来指示是否应该使用我们自己的内存分配函数。
你可以基于 第九章 的源代码开始这项工作。在 LuaExecutor.h
中修改构造函数,如下所示:
class LuaExecutor
{
public:
LuaExecutor(const LuaExecutorListener &listener,
bool overrideAllocator = false);
};
我们为构造函数添加了另一个布尔参数 overrideAllocator
。我们还提供了一个默认值 false
,因为在大多数情况下,我们不需要覆盖 Lua 内存分配器。
在 LuaExecutor.cc
中,在一个新的匿名命名空间中实现我们的内存分配函数,如下所示:
namespace
{
void *luaAlloc(
void *ud, void *ptr, size_t osize, size_t nsize)
{
(void)ud;
std::cout << "[luaAlloc] ptr=" << std::hex << ptr
<< std::dec << ", osize=" << osize
<< ", nsize=" << nsize;
void *newPtr = NULL;
if (nsize == 0)
{
free(ptr);
}
else
{
newPtr = realloc(ptr, nsize);
}
std::cout << std::dec << ", newPtr=" << newPtr
<< std::endl;
return newPtr;
}
}
luaAlloc
依赖于标准的 realloc
和 free
C 函数来分配、重新分配和释放内存。这正是默认 Lua 分配器所做的事情。但我们也记录了参数和返回值,以便更深入地了解内存使用情况。
要使用 luaAlloc
,在 LuaExecutor.cc
中修改构造函数,如下所示:
LuaExecutor::LuaExecutor(
const LuaExecutorListener &listener,
bool overrideAllocator)
: L(overrideAllocator ? lua_newstate(luaAlloc, NULL)
: luaL_newstate()),
listener(listener)
{ ... }
在这里,我们检查 overrideAllocator
是否为 true
。如果是,我们通过调用 lua_newstate
使用我们的内存分配函数。如果不是,我们通过调用 luaL_newstate
使用默认分配器。
现在,让我们测试我们的分配器。
测试它
重新编写 main.cpp
,如下所示:
#include "LuaExecutor.h"
#include "LoggingLuaExecutorListener.h"
#include "LuaModuleExporter.hpp"
#include "Destinations.h"
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(
*listener, true);
auto module = LuaModuleExporter<Destinations>::make(
DestinationsLuaModuleDef::def);
lua->registerModule(module);
lua->executeFile("script.lua");
return 0;
}
测试代码创建了一个 Lua 执行器,注册了 Destinations
模块,并执行了 script.lua
。这与我们在前面的章节中所做的是相似的。唯一需要注意的是,我们在创建 LuaExecutor
实例时将 overrideAllocator
设置为 true
。
重新编写 script.lua
,如下所示:
print("======script begin======")
dst = Destinations.new()
dst:wish("London", "Paris", "Amsterdam")
dst:went("Paris")
print("Visited:", dst:list_visited())
print("Unvisited:", dst:list_unvisited())
print("======script end======")
脚本创建了一个 Destinations
类型的对象并测试了其成员函数。这又与我们在前面的章节中所做的是相似的。
我们还打印出标记来标记脚本开始和结束执行的时间。这有助于我们定位感兴趣的事物,因为自定义的内存分配函数将会非常详细。
编译并执行项目。你应该得到一个类似于以下输出的结果:
...
[Lua] ======script begin======
[luaAlloc] ptr=0x0, osize=7, nsize=56, newPtr=0x14e7060c0
Destinations instance created: 0x14e7060e0
[luaAlloc] ptr=0x0, osize=4, nsize=47, newPtr=0x14e706100
[luaAlloc] ptr=0x0, osize=5, nsize=56, newPtr=0x14e706130
[luaAlloc] ptr=0x0, osize=0, nsize=48, newPtr=0x14e706170
[luaAlloc] ptr=0x0, osize=0, nsize=96, newPtr=0x14e7061a0
[luaAlloc] ptr=0x14e706170, osize=48, nsize=0, newPtr=0x0
...
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
[Lua] ======script end======
Destinations instance destroyed: 0x14e7060e0
...
这里突出显示的两行是 0x14e706170
地址处的对象分配和释放。你还会看到很多无关的内存分配输出,因为 Lua 也会使用自定义的内存分配函数来管理其内部状态的内存。
尽管这个定制的内存分配函数并不复杂,但你可以将所学内容扩展以改变内存管理的方式。这对于运行时优化或资源受限的系统很有用。
在下一节中,我们将探讨一个更高级的场景——*如何让 Lua 为 C++ 对象 分配内存。
将 C++ 对象的内存分配委托给 Lua
到目前为止,我们一直在 C++ 中创建 C++ 对象,并让 Lua 在 userdata 中存储其指针。这是在 LuaModuleExporter::luaNew
中完成的,如下所示:
static int luaNew(lua_State *L)
{
...
T **userdata = reinterpret_cast<T **>(
lua_newuserdatauv(L, sizeof(T *), 0));
T *obj = luaModuleDef.createInstance(L, nullptr);
*userdata = obj;
...
}
在这种情况下,Lua userdata 只存储一个指针。如您所回忆的,Lua userdata 可以表示更大的内存块,所以你可能想知道我们是否可以将整个 C++ 对象存储在 userdata 中,而不仅仅是指针。是的,我们可以。让我们学习如何做到这一点。
使用 C++ placement new
在 C++ 中,创建对象最常见的方式是调用 new T()
。这做两件事:
-
它为
T
类型的对象创建一块内存。 -
它调用
T
类型的构造函数。在我们的例子中,我们调用默认构造函数。
同样,销毁对象最常见的方式是调用 delete obj
。它也做两件事:
-
它调用
T
类型的析构函数。在这里,obj
是T
类型的对象。 -
释放持有
obj
的内存。
C++ 还提供了一个只通过调用构造函数来创建对象的 new 表达式。它不会为对象分配内存。相反,你告诉 C++ 将对象放在哪里。这个 new 表达式 被称为 placement new。
要使用 placement new,我们需要提供一个已分配内存的地址。我们可以用以下方式使用它:
T* obj = new (addr) T();
我们需要在 new
关键字和构造函数之间提供内存位置的地址。
现在我们已经找到了一种方法来解耦 C++ 内存分配和对象构造,让我们扩展我们的 C++ 模块导出器以支持将内存管理委托给 Lua。
扩展 LuaModuleDef
我们在这本书中实现了一个 C++ 模块导出系统。它有两个部分:
-
LuaModuleExporter
抽象了模块注册并实现了模块的 Lua 终结器。 -
LuaModuleDef
定义了模块名称、导出函数以及对象的构造和销毁。
首先,我们将向 LuaModuleDef
添加使用预分配内存的能力。
在 LuaModule.h
中,添加一个名为 isManagingMemory
的新成员变量,如下所示:
struct LuaModuleDef
{
const bool isManagingMemory;
};
当 isManagingMemory
为 true
时,我们表示 LuaModuleDef
实例正在管理内存分配和释放。当 isManagingMemory
为 false
时,我们表示 LuaModuleDef
不管理内存。在后一种情况下,LuaModuleExporter
应该让 Lua 管理内存,这将在我们扩展 LuaModuleDef
之后实现。
在添加了新标志后,修改 createInstance
,如下所示:
const std::function<T *(lua_State *, void *)>
createInstance = this -> T *
{
if (isManagingMemory)
{
return new T();
}
else
{
return new (addr) T();
}
};
我们添加了一个新参数 - void *addr
。当 LuaModuleDef
实例管理内存时,它使用正常的 new 操作符 分配内存。当实例不管理内存时,它使用 放置新表达式,其中 addr
是对象应该构造的地址。
这是 createInstance
的默认实现。当你创建 LuaModuleDef
实例时,你可以覆盖它并调用非默认构造函数。
接下来,我们需要修改 destroyInstance
以支持 isManagingMemory
。更改其默认实现,如下所示:
const std::function<void(T *)>
destroyInstance = this
{
if (isManagingMemory)
{
delete obj;
}
else
{
obj->~T();
}
};
当 LuaModuleDef
实例不管理内存时,我们只需调用对象的析构函数,obj->~T()
,来销毁它。
放置删除?
如果你想知道是否有与 放置新 匹配的 放置删除,答案是 no。要销毁对象而不释放其内存,你可以简单地调用其析构函数。
随着 LuaModuleDef
准备支持两种内存管理方式,接下来,我们将扩展 LuaModuleExporter
。
扩展 LuaModuleExporter
在我们为 C++ 对象将内存管理委托给 Lua 支持之前,我们将强调主要架构差异:
-
当 C++ 分配对象的内存时,就像我们在这本书中一直做的那样,Lua userdata 持有分配的内存地址的指针
-
当 Lua 分配对象的内存作为 userdata 时,userdata 持有实际的 C++ 对象
让我们开始扩展 LuaModuleExporter
。我们需要修改 luaNew
和 luaDelete
,以便它们能与 LuaModuleDef::isManagingMemory
一起工作。
在 LuaModuleExporter.hpp
中,修改 luaNew
,如下所示:
static int luaNew(lua_State *L)
{
auto exporter = getExporter(L);
auto luaModuleDef = exporter->luaModuleDef;
if (luaModuleDef.isManagingMemory)
{
T **userdata = reinterpret_cast<T **>(
lua_newuserdatauv(L, sizeof(T *), 0));
T *obj = luaModuleDef.createInstance(L, nullptr);
*userdata = obj;
}
else
{
T *userdata = reinterpret_cast<T *>(
lua_newuserdatauv(L, sizeof(T), 0));
luaModuleDef.createInstance(L, userdata);
}
lua_copy(L, -1, 1);
lua_settop(L, 1);
...
}
函数被分解为四个块,由新行分隔。这些块如下:
-
代码的前两行获取
LuaModuleExporter
实例和LuaModuleDef
实例。如果你需要了解getExporter
的工作原理,可以回顾 第八章。 -
if
子句创建 C++ 模块对象和 Lua userdata。当luaModuleDef.isManagingMemory
为true
时,执行的代码与 第八章 中的相同。当它是false
时,代码创建一个大小为sizeof(T)
的 userdata 来持有实际的T
实例。请注意,在这种情况下,userdata 的类型是T*
,其地址传递给luaModuleDef.createInstance
以用于 放置新。 -
它通过
lua_copy(L, -1, 1)
将 userdata 复制到栈底,并通过lua_settop
(L, 1
) 清除栈上除底部以外的所有内容。对象构造委托给LuaModuleDef
以清除栈上的临时项,以防LuaModuleDef
推送了任何项。与 第八章 中的代码相比,这两行代码是一个改进版本,可以覆盖更多情况以及不同的对象创建方式。 -
函数其余部分省略的代码保持不变。
最后,为了完成这个特性,修改 LuaModuleExporter::luaDelete
,如下所示:
static int luaDelete(lua_State *L)
{
auto luaModuleDef = getExporter(L)->luaModuleDef;
T *obj = luaModuleDef.isManagingMemory
? *reinterpret_cast<T **>(lua_touserdata(L, 1))
: reinterpret_cast<T *>(lua_touserdata(L, 1));
luaModuleDef.destroyInstance(obj);
return 0;
}
我们需要更改在终结器中获取 C++ 模块实例的方式。差异在于 userdata 是否持有实际对象或对象的指针。
接下来,让我们测试 Lua 分配 C++ 对象内存的机制是否工作。
使用 Destinations.cc
模块进行测试
我们只需要稍微调整 Destinations.cc
中的代码,以支持两种内存分配场景。修改 getObj
,如下所示:
inline Destinations *getObj(lua_State *L)
{
luaL_checkudata(L, 1, DestinationsLuaModuleDef::def
.metatableName().c_str());
if (DestinationsLuaModuleDef::def.isManagingMemory)
{
return *reinterpret_cast<Destinations **>(
lua_touserdata(L, 1));
}
else
{
return reinterpret_cast<Destinations *>(
lua_touserdata(L, 1));
}
}
我们在这里所做的更改与我们之前对 LuaModuleExporter::luaDelete
所做的更改类似,以便它支持 Lua userdata 所持有的不同内容。
要选择让 Lua 分配内存,更改 DestinationsLuaModuleDef::def
,如下所示:
LuaModuleDef DestinationsLuaModuleDef::def =
LuaModuleDef<Destinations>{
"Destinations",
{{"wish", luaWish},
{"went", luaWent},
{"list_visited", luaListVisited},
{"list_unvisited", luaListUnvisited},
{NULL, NULL}},
false,
};
在这里,我们将 LuaModuleDef::isManagingMemory
设置为 false
。
编译并执行项目。你应该看到以下输出:
Chapter10 % ./executable
[Lua] ======script begin======
Destinations instance created: 0x135e0af10
[Lua] Visited: Paris
[Lua] Unvisited: Amsterdam London
[Lua] ======script end======
Destinations instance destroyed: 0x135e0af10
如果你将 LuaModuleDef::isManagingMemory
设置为 true
,它也应该可以工作。
谁应该管理 C++ 对象的内存?
你可以选择让 C++ 或 Lua 管理 C++ 对象的内存分配。在复杂项目中管理内存可以提供更好的控制。在 Lua 中管理内存可以消除双重指针间接引用。还有心理上的考虑。对于一些来自 C++ 世界的人来说,让 Lua 分配 C++ 对象,尤其是当 Lua 只是项目中使用的库之一时,可能会感觉违反了资源所有权。对于一些来自 Lua 或 C 世界的人来说,让 Lua 做更多的事情可能更容易接受。然而,在现实世界的项目中,这些细节将被隐藏。正如本节所示,如果你有一个抽象,它很容易从一种方式转换为另一种方式。
接下来,我想向您介绍 RAII 资源管理惯用语。
什么是 RAII?
本章全部关于资源管理。资源可以是一块内存、一个打开的文件或一个网络套接字。尽管在本章中我们只使用了内存管理作为示例,但所有资源的原理都是相同的。
当然,所有获取的资源都需要释放。在 C++ 中,析构函数是一个释放资源的好地方。当与 Lua 一起工作时,Lua 终结器是一个释放资源的好触发器。
资源获取即初始化,或 RAII,是一个有用的资源管理惯用语。这意味着对象的创建和获取对象所需的资源应该是一个原子操作——一切都应该成功,或者部分获取的资源应该在引发错误之前释放。
通过使用这种技术,资源也与对象的生存周期相关联。这确保了在对象的整个生存周期内所有资源都可用。这将防止复杂的故障场景。例如,假设一项工作已经完成了一半,资源已经消耗,但由于某种新的资源不可用,它无法完成。
当设计 C++类时,你可以确保所有资源都在构造函数中获取,并在析构函数中释放。当与 Lua 集成时,确保你提供最终化器,并从最终化器中销毁对象。
最终化器将在 Lua 的垃圾回收周期之一中被调用。你不应该假设 Lua 何时这样做,因为这在不同平台和 Lua 版本之间是不可移植的。如果你内存受限,你可以通过从 C++中调用lua_gc
或从 Lua 中调用collectgarbage
来手动触发垃圾回收周期。
垃圾回收仅用于内存
记住,垃圾回收仅用于内存。如果你有一个不使用其他资源的简单类,你可能不想提供 Lua 最终化器。但如果后来类被修改为依赖于非内存资源,那么添加最终化器可能不是更改的一部分。然后,你会在以后的某个时候发现资源从奇怪的错误报告中泄露出来。
RAII 在多线程编程中获取共享资源时也非常有用。我们将在下一章中看到一个例子。
摘要
在本章中,我们更多地了解了资源管理。我们学习了如何为 Lua 提供一个定制的内存分配函数。我们还学习了如何在 Lua userdata 中持有实际的 C++对象。最后,我们熟悉了 RAII 资源管理技术。
在下一章中,我们将探讨将 Lua 集成到 C++时的多线程。
第十一章:使用 Lua 进行多线程
在上一章中,我们学习了一些在将 Lua 集成到 C++ 时管理资源的技术。在本章中,我们将学习如何使用 Lua 进行多线程操作。如果你在一个复杂的项目中使用 Lua,那么很可能你需要创建多个 Lua 实例。首先,我们将学习如何将多线程部分包含在 C++ 中,并使 Lua 对其无感知。然后,我们将看到 Lua 如何处理多线程,以防你需要使用它。理解多线程将有助于你项目的技术规划。
我们将涵盖以下主题:
-
C++ 中的多线程
-
Lua 中的多线程
-
使用 C++ 中的
coroutine
技术要求
我们将使用 第十章 的源代码作为本章节示例的基础。确保你可以访问这本书的源代码:github.com/PacktPublishing/Integrate-Lua-with-CPP/tree/main/Chapter11
。
C++ 中的多线程
什么是多线程?
根据不同的视角,有几个定义。从 CPU 的角度来看,能够同时执行多个指令线程的多核处理器是真正的多线程。从应用程序的角度来看,使用多个线程就是多线程。从开发者的角度来看,可能更多地关注线程安全和各种同步机制,这些不是多线程本身,而是其影响。
在本节中,我们将学习如何使用 Lua 与 C++ 的原生多线程支持。每个 C++ 线程都将有自己的 Lua 状态。因为 Lua 库不保留任何状态,Lua 状态也不会在不同线程之间共享,所以这是线程安全的。
C++ 如何支持多线程?
自从 C++11 以来,标准库通过 std::thread
支持多线程。每个 std::thread
实例代表一个执行线程。提供给线程最重要的东西是线程函数。这就是线程执行的内容。在其最简单的形式中,我们可以创建线程如下:
void threadFunc(...) {}
std::thread t(threadFunc, ...);
在这里,我们将一个 C++ 函数作为线程函数传递以创建一个线程。该函数可以可选地接受参数,std::thread
构造函数将参数转发给线程函数。线程创建后,线程函数开始在它自己的线程中执行。当线程函数完成后,线程结束。
我们也可以通过调用不同的构造函数,将类成员函数或类静态成员函数用作线程函数。你可以参考 C++ 参考手册来了解更多关于 std::thread
的信息。
在 C++11 之前
在 C++11 之前的时代,没有标准的多线程支持。人们不得不使用第三方库或使用低级库(如 pthreads)来实现自己的多线程。
这种多线程类型可能不会让你感到惊讶。这是人们谈论并广泛使用的一种多线程类型,即抢占式多线程。线程函数可以在任何时候暂停,也可以在任何时候恢复。
接下来,我们将通过一个真实示例来探索 C++ 多线程的实际应用。
使用多个 Lua 实例
在本节中,我们将实现一个线程函数,我们将执行一个 Lua 脚本。然后,我们将创建多个线程来执行这个相同的线程函数。
根据 第十章 的源代码,清理 main.cpp
并添加以下代码:
#include "LuaExecutor.h"
#include "LoggingLuaExecutorListener.h"
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
auto listener = std::make_unique
<LoggingLuaExecutorListener>();
std::mutex coutMutex;
在这里,我们添加了必要的头文件。listener
是 Lua 执行器监听器,并将为所有 Lua 执行器实例共享。coutMutex
是用于使用 std::cout
打印结果的互斥量,其用法我们将在下面看到。
接下来,实现线程函数,如下所示:
void threadFunc(int threadNo, int a, int b, int c)
{
auto lua = std::make_unique<LuaExecutor>(*listener);
lua->execute("function add_params(a, b, c) return a + b
+ c end");
auto result = lua->call("add_params",
LuaNumber::make(a), LuaNumber::make(b),
LuaNumber::make(c));
std::lock_guard<std::mutex> lock(coutMutex);
std::cout << "[Thread " << threadNo << "] "
<< a << "+" << b << "+" << c << "="
<< std::get<LuaNumber>(result).value
<< std::endl;
}
线程函数接受三个整数作为参数,创建一个 Lua 执行器,并执行一个 Lua 脚本来添加这三个整数。然后,它打印出结果。
由于标准输出只有一个可以打印的地方,我们使用互斥量来保护标准输出。否则,输出序列将是一个不同线程的混合,难以阅读。
我们使用互斥锁的方式是通过创建 std::lock_guard
而不是直接调用 std::mutex::lock
和 std::mutex::unlock
。锁保护器将在构造期间获取互斥量,并在超出作用域和被销毁时释放互斥量。这是一个 RAII 原则的例子。
RAII 概述
在上一章中,我们学习了资源获取即初始化(RAII)的概念。C++ 标准库在许多地方采用了这一原则。假设我们不这样使用锁,而是手动获取和释放它。如果在中间发生任何错误,存在风险是锁没有被在线程中释放,从而破坏整个应用程序。使用锁保护器,即使在抛出异常的情况下,锁也会始终被释放,因为 C++ 语言保证当锁超出作用域时,会调用锁的析构函数。在 C++11 之前,人们会通过创建一个在构造函数中获取锁并在析构函数中释放锁的包装类来实现自己的锁保护器。这种习惯用法被称为 std::scoped_lock
,它可以锁定多个互斥量。
最后,让我们实现 main
函数,如下所示:
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 5; i++)
{
int a = i * 3 + 1;
threads.emplace_back(threadFunc, i + 1,
a, a + 1, a + 2);
}
for (auto &t : threads)
{
t.join();
}
return 0;
}
这将创建一个线程列表并等待线程执行完成。
在第一个 for
循环中,我们使用 std::vector::emplace_back
在向量的末尾就地创建线程。对于大多数 C++ 实现,内部使用 placement new 并调用 std::thread(threadFunc, i, a, a + 1, a + 2)
。我们这样做是因为 std::thread
不能复制构造。可以理解的是,复制一个线程是没有意义的。
在第二个 for
循环中,我们使用 std::thread::join
等待所有线程完成执行。main
函数在应用程序的主线程中运行。当 main
退出时,所有其他线程都将被终止,即使它们还没有完成执行。
接下来,我们将测试我们的示例。
进行测试
编译并执行项目。你应该会看到以下类似的输出:
Chapter11 % ./executable
[Thread 2] 4+5+6=15
[Thread 3] 7+8+9=24
[Thread 5] 13+14+15=42
[Thread 1] 1+2+3=6
[Thread 4] 10+11+12=33
如果你多次运行项目,你会看到不同线程的结果顺序发生变化。这验证了我们在 Lua 中使用了多个线程。
对于大多数项目,当将 Lua 集成到 C++ 中时,这种机制应该足以用于多线程。这是 C++ 中的多线程。Lua 部分无需额外努力即可正常工作。每个 C++ 线程都有自己的 Lua 实例,并执行其 Lua 脚本的副本。不同的 Lua 实例不会相互干扰或了解彼此。
接下来,我们将探索 Lua 中的多线程。
Lua 中的多线程
要理解 Lua 中的多线程,让我们从一个基本问题开始。
Lua 如何支持多线程?
Lua 不支持 多线程。就这么简单。
但我们还没有完成这个部分。我们将通过两种方法进一步解释这一点——一种当代方法和一种老式方法。
当代方法
Lua 是一种脚本语言,它不支持 抢占式多线程。它根本不提供创建新线程的库函数,因此无法实现。
现在,CPU 和操作系统都是围绕 抢占式多线程 设计的——也就是说,一个执行线程可以在任何时候暂停和恢复。线程对其执行调度没有控制权。
然而,Lua 为 协程
提供了一种机制,这通常是一个函数。
协程
在 Kotlin 用于 Android 和后端开发中也非常受欢迎。
协作式多线程
当我们谈论线程时,大多数情况下,其含义是指它们是用于 CPU 核心执行的线程。当我们谈论 协作式多线程 时,在某些情况下,例如 Lua,你可能会发现只有一个线程在执行,并且只使用了一个 CPU 核心,即使有协程。可以说,这根本不是多线程。但我们不需要评判。我们需要理解这一点,因为在不同的上下文中,可能会有多个术语用于此。我们也可以称之为 协作式多任务处理,这在历史上从技术角度来看更为准确。
让我们看看 Lua 的 协程
在实际中的应用,并对其进行更详细的解释。
实现 Lua 协程
将 script.lua
的内容替换为以下代码:
function create_square_seq_coroutine(n)
return coroutine.create(function ()
for i = 1, n do
coroutine.yield(i * i)
end
end)
end
create_square_seq_coroutine
使用 coroutine.create
创建一个 协程
,而 coroutine.create
又以一个匿名函数作为其参数。你可以大致认为内部匿名函数就是 协程
。内部函数运行一个循环,从 1
到 n
。
你只能与协程一起使用yield
。当协程遇到yield
语句时,它将停止执行。提供给yield
的值将被返回到调用点,类似于return
的作用。下次执行coroutine
时,它将从上次yield
的地方继续执行,直到它达到另一个yield
语句或return
语句。
让我们启动一个交互式 Lua 解释器来测试我们的coroutine
:
Chapter11 % ../lua/src/lua
Lua 5.4.6 Copyright (C) 1994-2023 Lua.org, PUC-Rio
> dofile("script.lua")
> co = create_square_seq_coroutine(3)
> coroutine.resume(co)
true 1
> coroutine.resume(co)
true 4
> coroutine.resume(co)
true 9
> coroutine.resume(co)
true
> coroutine.resume(co)
false cannot resume dead coroutine
在这里,我们创建一个coroutine
来返回从1
到3
的平方。第一次我们resume
coroutine
时,它从开始执行并返回两个值,true
和1
。true
来自coroutine.resume
,表示coroutine
执行没有错误。1
是coroutine
产生的。下次我们resume
coroutine
时,循环继续进行下一个迭代并返回4
。请注意,当coroutine.resume
只返回一个值时的情况。循环已经完成,但还有代码需要为coroutine
执行,例如隐式的返回语句。因此,coroutine.resume
返回true
。之后,coroutine
已经完成,无法再恢复,coroutine.resume
将返回带有错误信息的false
。
如果这是你第一次在任何编程语言中使用coroutine
,这可能会让你觉得神奇且不合逻辑。一个不在线程中的函数,怎么可能不达到其结束而再次从中部执行呢?我将在本节的最后部分解释为什么这是如此普通(但请记住,在面试中提到你知道coroutine
以及为什么它如此辉煌),在此之前,让我们探索另一个例子,看看coroutine
可以非常有用的一个场景。
Lua 协程作为迭代器
我们已经看到了如何使用迭代器与generic for
来简化我们的生活,例如ipairs
。
但迭代器究竟是什么?
一个iterator
返回一个迭代函数,可以反复调用,直到它返回nil
或无。
基于我们刚刚实现用于生成平方序列的coroutine
,让我们构建一个迭代器。在script.lua
中添加另一个函数,如下所示:
function square_seq(n)
local co = create_square_seq_coroutine(n)
return function()
local code, value = coroutine.resume(co)
return value
end
end
square_seq
是一个 Lua iterator
,因为它返回其内部函数作为iterator
function
。内部函数继续恢复使用create_square_seq_coroutine
创建的协程。当iterator
function
返回nil
或无时,调用者有责任停止调用iterator
function
。
让我们在交互式 Lua 解释器中测试这个iterator
:
Chapter11 % ../lua/src/lua
Lua 5.4.6 Copyright (C) 1994-2023 Lua.org, PUC-Rio
> dofile("script.lua")
> for v in square_seq(3) do print(v) end
1
4
9
你可以看到,对于1
、2
和3
,打印出了预期的三个值。
通过查看使用情况,你甚至无法判断是否涉及到任何协程或协作多线程。我认为,这是这种编程范式比抢占式多线程更有价值的例子之一。
到目前为止,我们已经探讨了 Lua 的 coroutine
和 Lua 的 iterator
。它们可能更加复杂,但这些示例已经足够展示它们是如何工作的。你可以参考 Lua 参考手册来了解更多关于 coroutine
和 iterator
的信息。
接下来,让我用自己的话来解释这一点。
介绍多栈
传统上,一个线程是一个 CPU 核心的执行单元,与其相关的执行栈和 程序计数器(PC)。PC 是 CPU 寄存器,用于存储下一个要执行的指令的地址。正如你所见,这相当低级,涉及到更多我们不打算讨论的细节。
由于这种传统观念在我们心中已经根深蒂固,即使是无意识的,它可能已经成为了你理解协程的障碍。
或者,让我们借助计算机科学中的一个基本原理来寻求帮助——解耦。
广为人知的抢占式多线程机制已经是解耦的应用。它将线程从 CPU 核心中解耦。有了它,你可以在有限的物理 CPU 核心中拥有无限的线程池。
当你接受这一点,我们只需要再进一步。如果你接受执行栈也可以从执行线程中解耦,这就是 coroutine
的工作方式。
在这种合作式多线程机制中,一个线程可以有自己的执行栈池。一个执行栈包含调用栈和 PC。因此,现在我们有一个三层系统。
我将这些协程称为多栈,这是一个我创造的术语,用以更好地解释它。看看 图 11.1。1*,它暗示了以下内容:
-
CPU 与线程之间的关系:线程的数量多于 CPU 核心。一个 CPU 核心可以执行任何线程。当一个线程被恢复时,它可以被任何 CPU 核心拾取。这是我们熟知的 抢占式多线程,这通常需要 CPU 硬件支持,并由操作系统透明地管理。
-
线程与协程之间的关系:一个线程可以有多个协程,每个协程都有自己的执行栈。因为操作系统在线程级别停止,操作系统没有协程的概念。为了使一个线程执行另一个协程,当前的协程必须放弃其执行并自愿让出。这就是为什么它被称为 合作式多线程。协程也没有线程的概念;拥有它的线程可以被抢占,并在稍后由另一个 CPU 核心拾取,但这些对协程来说都是透明的。如果编程环境支持,协程也可以被不同的线程拾取。
图 11.1 – 多栈
抽空思考一下这两个关系,它们在图 11**.1中得到了解释和说明。这是解释协程的一种方式,尽管可能有些不寻常。这里的目的是在不同机制和技术之间找到相似之处。
接下来,我们将从另一个角度来探讨协作多线程和协程
。
传统的做法
到目前为止,在本节中,我们一直专注于解释当代的协程
概念。希望通过对这两个关系的解释,你能理解和说明为什么协程
为现代计算系统增加了另一层宝贵的多线程支持。
然而,这并不是什么新鲜事。计算机从一开始就是这样工作的。
当然,在最开始,正如你所知,我们给机器喂入带孔的纸带。所以,多线程是毫无希望的。
然后,后来,它变得更加复杂。但仍然,CPU 中只有一个处理单元,有一个特权且原始的控制程序在一个死循环中运行。这个循环的主要作用是检查是否有其他程序想要运行。如果有,它将那个程序的起始地址加载到 PC 中——CPU 的程序计数器。然后,CPU 开始执行另一个程序。
你可能已经猜到了问题。如果有另一个程序想要运行呢?按照过去的做法,它必须等待第一个程序完成。正如你可以想象的那样,这并不公平。所以,我们改进了它,并规定所有程序都应该友好地轮流执行。这样做允许特权控制程序恢复运行,并找出是否有其他程序需要运行。
这被称为协作多线程。除了特权程序之外,每个程序都是一个协程
实例,只不过在那个时代还没有发明这个术语。
这有助于,但并不总是如此。假设有一个程序决定等待一个永远不会发生的 I/O,并且不释放;计算机将无休止地徒劳等待。
更晚些时候,计算机变得更加强大,能够支持运行更复杂的操作系统。它将决定哪个程序运行的逻辑移到了操作系统中。如果一个程序正在等待 I/O 或已经运行了足够长的时间,操作系统将暂停它,并恢复另一个程序的运行。这就是抢占式多线程。结果证明,这是一个正确的举措。操作系统更加公平,计算机可以做得更多。
快进到近几年——摩尔定律不再适用,或者至少已经暂停。所以,CPU 不会得到 1,000 个核心,但工作计算机中的线程数量却在不断增加。因此,操作系统抢占和迭代所有线程的成本现在已经成为一个关注点。
我们能做什么?
一些聪明的人发现我们只需要做我们最初做的事情——再次使用协作多线程。但这次,控制程序是你的主程序——既然你不能对自己自私,你将尽你所能对所有协程公平。
这是一个计算机系统演变的简化版本。它并非在历史上完全准确,并且加入了一些戏剧性的元素。目标是让你意识到协程
是一个简单的概念,并且你可以感到舒适地使用它。
接下来,我们将学习如何使用 C++中的协程。
使用 C++中的协程
如果你有一个替代方案,不要在 C++中使用 Lua 协程
。正如迭代器示例所示,你可以将协程
包装在一个普通函数中,并不断调用它,直到它返回 nil。
但为了减少主观性并保证完整性,我们可以使用以下 Lua 库函数从 C++启动或恢复协程:
int lua_resume (lua_State *L, lua_State *from, int narg,
int *nresults);
这个函数类似于pcall
。它期望被调用的函数以及可选的堆栈上的参数。这个函数将是协程。L
是协程的栈。from
是从中调用协程的栈。narg
是协程
函数的参数数量。nresults
指向一个整数,Lua 将输出产生的或返回到整数的值的数量。
让我们通过一个例子来了解它是如何工作的。在LuaExecutor.h
中添加一个函数声明,如下所示:
class LuaExecutor
{
public:
LuaValue resume(const std::string &function);
};
resume
函数以有限的方式支持协程
。它不支持传递参数,但这很容易做到,你可以参考LuaExecutor::call
。它只期望一个作为整数的返回值。这只是为了演示 Lua API,而不是在我们的 Lua 执行器中添加对协程
的完整支持。
实现resume
,如下所示:
inline LuaValue
LuaExecutor::resume(const std::string &function)
{
lua_State *thd = lua_newthread(L);
int type = lua_getglobal(thd, function.c_str());
assert(LUA_TFUNCTION == type);
int nresults = 0;
const int status = lua_resume(thd, L, 0, &nresults);
if (status != LUA_OK && status != LUA_YIELD)
{
lua_pop(thd, 1);
return LuaNil::make();
}
if (nresults == 0)
{
return LuaNil::make();
}
const int value = lua_tointeger(thd, -1);
lua_pop(thd, nresults);
lua_pop(L, 1);
return LuaNumber::make(value);
}
这是一个五步的过程,通过换行符分隔,具体解释如下:
-
它使用
lua_newthread
创建一个新的 Lua 状态。这个新状态的引用也被推送到由L
拥有的主栈上。我们可以称这个新状态为协程状态。但thd
是协程栈。Lua 的lua_newthread
库函数创建了一个新的 Lua 状态,它与主状态L
共享相同的全局环境,但拥有自己的执行栈。是的,API 名称有点误导,但它就是这样。 -
它将执行为协程的函数名称推送到新的 Lua 栈上。
-
它调用
lua_resume
来启动或恢复协程。由于我们总是创建一个名为thd
的新状态,我们总是从头开始启动协程。要恢复它,我们需要将thd
保存在某个地方,并在未来的调用中传递它。 -
它检查是否存在错误;或者,如果协程没有返回任何结果,这意味着它已经结束。
-
它检索我们期望的单个整数,从协程栈中弹出,从主栈中弹出协程栈的引用,并返回该值。
照顾其他 Lua 状态
协程需要自己的 Lua 状态来执行。你需要将其 Lua 状态的引用保存在某个地方,直到你不再需要协程。如果没有引用,Lua 将在垃圾回收期间销毁状态。如果你有很多协程,将所有这些额外的 Lua 状态保留在主堆栈中可能会很混乱。因此,如果你想在 C++ 中与协程一起工作,你需要设计一个系统来保存和查询这些 Lua 状态。
接下来,将以下 Lua 函数添加到 script.lua
中:
function squares()
for i = 2, 3 do
coroutine.yield(i * i)
end
end
此函数产生两个值。for
循环是硬编码的,因为我们不支持在 LuaExecutor::resume
中传递参数。
对于演示的最后部分,编写 main.cpp
如下所示:
#include "LuaExecutor.h"
#include "LoggingLuaExecutorListener.h"
#include <iostream>
int main()
{
auto listener = std::make_unique<
LoggingLuaExecutorListener>();
auto lua = std::make_unique<LuaExecutor>(*listener);
lua->executeFile("script.lua");
auto result = lua->resume("squares");
if (getLuaType(result) == LuaType::number)
{
std::cout << "Coroutine yields "
<< std::get<LuaNumber>(result).value
<< std::endl;
}
return 0;
}
这设置了 Lua 执行器并调用 resume
函数以启动 coroutine
。
编译并运行项目;你应该看到以下输出:
Coroutine yields 4
这展示了如何使用 lua_resume
。你可以阅读 Lua 参考手册以获取有关此 API 的更详细信息。
C++ 代码也可以作为协程执行。这可以通过提供 lua_CFunction
实现给 lua_resume
或者在协程中的 Lua 代码调用 lua_CFunction
实现来完成。在这种情况下,C++ 代码也可以通过调用 lua_yieldk
来产生。
使用 C++ 与协程结合可能会非常复杂,但如果你已经定义了你的用例,这可以抽象出来以隐藏复杂的细节。本节只是一个引子。你可以决定是否以这种方式使用 Lua。
摘要
有了这些,我们已经完成了这本书的最后一章。在这一章中,我们重点介绍了多线程机制、抢占式多线程和协作式多线程,以及 Lua 协程。
Lua 协程可以在不使用 C++ 的情况下用于高级 Lua 编程,并且你可以将这些所有细节从 C++ 中隐藏起来。我们只是触及了冰山一角。你可以阅读 Lua 参考手册并多加练习。你也可以通过实验相关的 Lua 库函数来探索如何与 C++ 一起使用协程。
在这本书中,我们逐步实现了 LuaExecutor
。每一章都为其添加了更多功能。然而,它并不完美。例如,LuaValue
可以改进以使其更容易使用,LuaExecutor
也可以支持更多的表操作。你可以在学习机制之后将 LuaExecutor
作为基础来适应你的项目,或者以完全不同的方式实现自己的。
我相信,到这一点,你可以进行改进并添加最适合你的更多功能。你总是可以回顾这些章节作为提醒,并在 Lua 参考手册中搜索你需要的内容。