Hobbes – 一种语言与嵌入式JIT编译器
摘要
Hobbes是摩根士丹利开发的一种开源语言、嵌入式JIT编译器和运行时,用于在C++应用程序中进行高效的动态表达式求值、数据存储和分析。
查看缓存全文
缓存时间: 2026/07/06 20:06
morganstanley/hobbes 来源:https://github.com/morganstanley/hobbes # hobbes 生命周期 活跃 一种语言、内嵌编译器和运行时,用于高效的动态表达式求值、数据存储和分析
| 章节 | 描述 |
|---|---|
| 构建 | 如何构建和安装 hobbes |
| 嵌入 | 在 C++ 程序中使用 hobbes |
| 求值 | 求值基本 hobbes 表达式 |
| 存储 | 记录数据以供带外分析 |
| 网络 | 与远程 hobbes 进程交互 |
| 推导式 | 转换/排序/连接/过滤/分组序列以进行数据分析 |
| 模式匹配 | 高效分类和解构数据 |
| 解析 | 使用 LALR(1) 文法解析文本 |
| 类型类 | 重载和编译时计算 |
| 非限定符模块 | 用户定义的“编译器插件”,用于自定义约束处理 |
关于 Hobbes 使用的说明
Hobbes 专为与 C/C++ 应用高性能集成而构建。虽然 Hobbes 是一种强类型语言并提供了编译时检查,但它没有沙盒运行时环境或运行时安全特性。根据设计,Hobbes 允许直接访问内存,并且没有数组边界检查。此外,Hobbes 支持通过网络(RPC)远程编译和执行原生代码。此功能仅供您在受信任的内部网络中使用。如果您选择使用此类功能,则需要了解这些设计选择并理解其安全含义。
构建
要构建 hobbes,您需要 LLVM(http://llvm.org/)3.3 或更高版本、cmake(http://cmake.org/)3.4 或更高版本、GNU gcc(https://gcc.gnu.org/)4.8 或更高版本,以及 2.5 或更高版本的 Linux 内核。安装好 LLVM、cmake 和 g++ 后,下载此代码后,您应该可以通过运行以下命令来构建和安装 hobbes:
$ cmake . $ make $ make install
根据您安装 LLVM 的位置,这可能无法正常工作,直到您将 LLVM cmake 模块的路径提供给 cmake。具体来说,您需要 LLVM 的 LLVMConfig.cmake 文件的路径。如果您将环境变量 LLVM_DIR 设置为指向该目录,则上述步骤应能成功完成。
构建过程将生成一个静态库 libhobbes.a,可以链接到 C++ 可执行文件中(如果您想在 .so 中使用 hobbes,构建会生成另一个要使用的静态库 libhobbes-pic.a)。此外,构建过程还会生成两个实用程序 hi 和 hog。hi 程序是 hobbes 表达式的一个基本交互式解释器,hog 程序将应用程序生成的结构化数据高效地记录到数据文件中(这些文件可以由 hi 同时加载和查询)。这些程序的源代码也很有参考价值,因为它们展示了 hobbes API 的许多不同方面。
嵌入
让我们考虑如何在简单的 C++ 程序中嵌入 hobbes。以下代码实现了一个非常基础的 shell,类似于 hi:
``C++ #include #include #include
int main() { hobbes::cc c;
while (std::cin) { std::cout << “> “ << std::flush; std::string line; std::getline(std::cin, line); if (line == “:q”) break;
try {
c.compileFn("print(" + line + ")")();
} catch (std::exception& ex) {
std::cout << "*** " << ex.what();
}
std::cout << std::endl;
hobbes::resetMemoryPool();
} return 0; } ``
首先,要编译任何表达式,我们需要构造一个 hobbes::cc 对象。然后,在异常处理器的上下文中,我们可以使用 compileFn 方法从这个 hobbes::cc 对象中编译函数,给它我们期望返回的类型(这里为 void())以及一个我们期望能编译为该类型值的字符串。如果编译的任何阶段失败(解析错误、类型不匹配等),则会引发包含失败详细信息的异常。最后,我们调用 hobbes::resetMemoryPool() 来释放已编译表达式可能动态分配的任何内存(即由已编译表达式分配的内存,而不是已编译函数本身的内存——后者仅在 hobbes::cc 被销毁或使用 hobbes::cc::releaseMachineCode 销毁时才会释放)。
当已编译函数决定分配内存时,该分配发生在“内存区域”中。内存区域是一个可动态增长的字节序列,分配来自该区域,并在调用 hobbes::resetMemoryPool() 时批量释放。这使得分配和释放非常高效,但需要一些思考来建立“逻辑事务”边界。活动内存区域是线程本地的,因此您可以在多个线程中同时使用同一个函数指针,而无需担心同步问题。
最后,如果我们把上面的程序放在名为“test.cpp”的文件中,我们可以这样构建它:
$ g++ -pthread -std=c++17 -I -I test.cpp -o test -L -lhobbes -ldl -lrt -ltinfo -lz -L `llvm-config --libs x86asmparser x86codegen x86 mcjit passes`
根据 LLVM 和 hobbes 在系统上的安装位置/方式,可能不需要显式的路径语句。内联调用 llvm-config 程序在 LLVM 用户中很常见,以避免显式列出多个库。
如果一切正常,我们现在应该有一个可以求值 hobbes 表达式的简单 shell。
求值
现在我们有了一个可工作的 shell,我们可以试验表达式以了解 hobbes 语言的工作原理。首先,我们有一些典型的常量值:
``
‘c’ ‘c’ 42 42 3.14159 3.14159 ``
总括来说,以下是支持的原始类型/常量集合:
| 名称 | 示例 | 描述 |
|---|---|---|
| unit | () | 类似于 C 中的 ‘void’,一个只有单一值的平凡类型 |
| bool | false | true 或 false |
| char | 'c' | 单个文本字符 |
| byte | 0Xff | 单个字节(0-255) |
| short | 42S | 2 字节数字 |
| int | 42 | 4 字节数字 |
| long | 42L | 8 字节数字 |
| float | 42.0f | 4 字节浮点值 |
| double | 42.0 | 8 字节浮点值 |
这些原始类型可以与数组组合:
``
[1, 2, 3] [1, 2, 3] “foobar” “foobar” 0xdeadbeef 0xdeadbeef ``
它们也可以与记录/元组组合:
``
{name=“Jimmy”, age=45, job=“programmer”} {name=“Jimmy”, age=45, job=“programmer”} (“Jimmy”, 45, “programmer”) (“Jimmy”, 45, “programmer”) ``
或者与记录/元组数组组合,它们将以表格形式方便地打印:
``
[{name=“Jimmy”, age=45, job=“programmer”}, {name=“Chicken”, age=40, job=“programmer”}] name age job
Jimmy 45 programmer Chicken 40 programmer
[(“Jimmy”, 45, “programmer”),(“Chicken”, 40, “programmer”)] Jimmy 45 programmer Chicken 40 programmer ``
我们可以将类型与变体或和类型组合(变体的“无名称”形式,类似于元组对应记录)。由于“裸”变体引入在定义上是不完整的,有时我们需要引入显式的类型注解:
``
|food=“pizza”|::|vehicle:int,food:[char]| |food=“pizza”| |1=“pizza”|::(int+[char]) |1=“pizza”| ``
此外,变体可以与等递归类型(https://en.wikipedia.org/wiki/Recursive_data_type#Isorecursive_types)组合以形成链表。这些在打印时具有方便的形式,并且内置函数可以避免递归和变体类型的样板代码:
``
roll(|1=(“chicken”, roll(|1=(“hot dog”, roll(|1=(“pizza”, roll(|0=()|))|))|))|) :: ^x.(()+([char]*x)) “chicken”:“hot dog”:“pizza”:[] cons(“chicken”, cons(“hot dog”, cons(“pizza”, nil()))) “chicken”:“hot dog”:“pizza”:[] ``
这些类型和类型构造器共同构成了 ML 系列编程语言(SML、ocaml、Haskell 等)中常见的“代数数据类型”。这些类型的语法和名称也来自 ML 和常见的学术编程语言教材,如 TaPL(https://www.cis.upenn.edu/~bcpierce/tapl/)和 PFPL(http://www.cs.cmu.edu/~rwh/pfpl.html)。
与 Haskell 一样,hobbes 支持一种 限定类型 的形式,包含类型类甚至用户定义的约束解析器(我们将在后面详细看到)。在许多其他用途中,这允许混合类型算术和类型推断共存:
``
0X01+2+3.0+4L+5S 15 (\x y z u v.x+y+z+u+v)(0X01,2,3S,4L,5.0) 15 ``
我们还可以使用 hi 程序(一个比前面的示例稍微复杂一些的解释器,随 hobbes 分发)来求值这样的表达式并检查它们的类型。例如,我们可以查询之前考虑的原始表达式的类型:
`` $ hi hi : an interactive shell for hobbes type ‘:h’ for help on commands
:t () () :t false bool :t ‘c’ char :t 0Xff byte :t 42S short :t 42 int :t 42L long :t 42.0f float :t 42.0 double ``
以及记录、数组、变体和递归值的类型:
``
:t [1..10] [int] :t “foobar” [char] :t 0xdeadbeef [byte] :t {name=“Jimmy”, age=45, job=“programmer”} { name:[char], age:int, job:[char] } :t (“Jimmy”, 45, “programmer”) ([char] * int * [char]) :t cons(“foo”,nil()) ^x.(() + ([char] * x)) ``
对于限定类型,我们可以使用此功能查看开放/未解析的类型约束集合:
``
:t \x.x.foo (a.foo::b) => (a) -> b :t .foo (a.foo::b) => (a) -> b ``
这里我们看到一个简单函数的类型(该函数仅返回其输入中“foo”字段的值,无论是完全显式的 \x.x.foo 形式还是等效的 .foo 简写形式)都有一个 约束(类型中 => 左侧的部分——与 Haskell 相同)。在这种情况下,整个类型表明:如果类型 a 有一个名为 foo 且类型为 b 的字段,则函数类型为 a -> b。此约束的额外复杂性允许该函数与 任何 满足该条件的类型 a 一起工作。这个特定的约束允许一种鸭式类型(https://en.wikipedia.org/wiki/Duck_typing)用于函数。
绑定
如果我们在 C++ 进程内部使用 hobbes(而不是仅仅用 hi 运行脚本),那么在某个时候,我们需要将 C++ 值(通常是函数)绑定 到 hobbes::cc 实例,以便它们可以在以后的动态表达式中使用。这项工作大部分是自动化的,通常使我们不必考虑绑定逻辑。
例如,考虑一个我们可能已经定义过的 C++ 函数:
C++ int applyDiscreteWeighting(int x, int y) { return x * y; }
现在我们可以将这个定义导入到我们之前的程序中,并通过添加如下行将其绑定到我们的 hobbes::cc 实例:
C++ c.bind("applyDiscreteWeighting", &applyDiscreteWeighting);
然后当我们运行程序时,它的 shell 将允许使用这个函数,并将拒绝任何以其类型结构不一致的方式使用它的表达式:
``
applyDiscreteWeighting(3, 4) 12 applyDiscreteWeighting(3, “pizza”) *** stdin:1,1-22: Cannot unify types: int != [char] ``
在其他情况下,使用 hobbes 原生理解并带有动态内存分配的类型可能更方便:
``C++ typedef std::pair*> MyRecord;
const hobbes::array* loadRecords(int key) { static const char* names[] = { “chicken”, “hot dog”, “foobar”, “jimmy” }; auto rs = hobbes::makeArray(key); for (size_t i = 0; i < rs->size; ++i) { rs->data[i].first = i; rs->data[i].second = hobbes::makeString(names[i % 4]); } return rs; }
// […]
c.bind(“loadRecords”, &loadRecords);
// […] ``
这些对 hobbes::makeArray 和 hobbes::makeString 的调用将从调用线程的内存区域分配内存,并产生 hobbes 知道如何通用解构并以方便形式打印的数据,正如我们在测试程序中再次运行所看到的:
``
loadRecords(10) 0 chicken 1 hot dog 2 foobar 3 jimmy 4 chicken 5 hot dog 6 foobar 7 jimmy 8 chicken 9 hot dog ``
如果我们更倾向于用具有有意义字段名称的结构体来表示记录,而不是元组,我们可以改用 DEFINE_STRUCT 宏来定义我们的类型(这纯粹是语法上的差异——在 hobbes 中,元组和结构体的最终运行时表示是相同的)。例如,如果我们这样定义先前示例中的 MyRecord 类型:
C++ DEFINE_STRUCT(MyRecord, (size_t, index), (const hobbes::array*, name) );
并更改 loadRecords 中的初始化代码以按名称引用这些字段:
C++ // [...] rs->data[i].index = i; rs->data[i].name = hobbes::makeString(names[i % 4]); // [...]
那么我们可以从 hobbes 中按名称访问这些字段,例如,此类记录的表将打印一个显示这些字段名称的标题:
``
loadRecords(10) index name
0 chicken 1 hot dog 2 foobar 3 jimmy 4 chicken 5 hot dog 6 foobar 7 jimmy 8 chicken 9 hot dog ``
我们还可以使用 C 函数类型进行“反向”绑定,这样不是让我们的 C++ 代码看起来像有效的 hobbes 符号,而是让 hobbes 表达式看起来像有效的 C++ 符号。例如,考虑一个像这样的 C++ 绑定:
``C++ int iter2(int (*pf)(int,int), int x) { return pf(pf(x, x), pf(x, x)); }
// […]
c.bind(“iter2”, &iter2);
// […] ``
这里我们绑定了一个本身将函数作为输入的函数,我们可以使用 hobbes 的匿名函数语法来决定这个 C++ 函数将使用的逻辑:
``
iter2(\x y.x*x+y, 2) 42 ``
变体可以作为 hobbes::variant<...> 类型在 C++ 和 hobbes 之间传递:
``C++ typedef hobbes::variant*> classification;
const classification* classifyParameter(int x) { if (x < 42) { return hobbes::make(42 - x); } else { return hobbes::make(hobbes::makeString(“achievement unlocked”)); } }
// […]
c.bind(“classifyParameter”, &classifyParameter);
// […] ``
假设您的变体的载荷类型可以打印,那么这些也应该以标准方式打印(显示构造函数名称“0”表示第一种情况,“1”表示第二种情况,因为此变体的构造函数没有名称):
``
classifyParameter(16) |0=26| classifyParameter(8675309) |1=“achievement unlocked”| ``
在将应用程序变量和函数绑定到 hobbes::cc 实例的过程中,hobbes 会决定如何将每个 C++ 类型“提升”为等价的 hobbes 类型。许多常见情况已经透明地处理了,如上所述,像 size_t、std::pair 和 hobbes::array 这样的类型无需我们进行任何特殊工作即可被“提升”。如果 hobbes 不知道如何提升某个类型,它将被提升为“不透明 C++ 类型”,这样 hobbes 只会检查一致的使用方式(并且会根据需要尊重类层次结构和派生/基类转换),但对该类型的结构一无所知。
例如,考虑这样一个绑定案例(旨在表示一个复杂的类型层次结构):
``C++ class Top { public: Top(int x, double y, int z) : x(x), y(y), z(z) { } int getX() const { return this->x; } double getY() const { return this->y; } int getZ() const { return this->z; } private: int x; double y; int z; };
class Left : public virtual Top { public: Left(int x, double y, int z) : Top(x, y, z) { } };
class Right : public virtual Top { public: Right(int x, double y, int z) : Top(x, y, z) { } };
class Bottom : public Left, public Right { public: Bottom(int x, double y, int z) : Left(xz, yy, zx), Right(xx, yy, zz), Top(x, y, z) { } };
// […]
c.bind(“getX”, memberfn(&Top::getX)); c.bind(“getY”, memberfn(&Top::getY)); c.bind(“getZ”, memberfn(&Top::getZ));
static Bottom b(1, 3.14159, 42); c.bind(“b”, &b);
// […] ``
现在回到我们的 shell,我们可以对绑定的 Bottom 实例调用绑定的 Top 方法(hobbes 会与 gcc 协调以应用正确的指针调整):
``
b.getX() 1 b.getY() 3.14159 b.getZ() 42 ``
如果提升您的 C++ 类型的默认逻辑不够用,可以使用 hobbes::lift
相似文章
Show HN: Tiny – 一种带有内联Go原生函数的动态解释型语言
Tiny是一种用Go编写的新的动态解释型编程语言,具有字节码虚拟机、JIT编译和内联Go原生函数,以实现高性能。
H2JVM - 用于编写JVM字节码的Haskell库
H2JVM 是一个Haskell库,允许开发者直接在Haskell中编写JVM字节码,支持底层JVM操作。
Show HN: Nibble
Nibble 是一种类 C 的系统编程语言,用 3000 行 C 代码实现,无需外部依赖或堆分配即可生成 LLVM IR。它支持 defer、递归、多种类型、结构体、指针,并包含图形演示。
Jam 编程语言
Raphael Amorim 宣布了 Jam,一种旨在结合 Rust 的安全性和 Zig 的简洁性的新编程语言,解决了在 AI 生成代码时代现有系统语言的复杂性和验证开销。
Show HN: Mach – 一款寻求贡献的编译型系统语言
Mach 是一种静态类型、编译型的系统语言,旨在实现简洁性、明确性和可维护性。它是开源的,并在 GitHub 上寻求贡献。