类固醇宏:纯 C 如何从元编程中受益

2021-07-23 04:52:40

你有没有想过将日常的 C 预处理器作为一些体面的元编程的工具?您是否曾将 C 预处理器设想为一种工具,如果使用得当,它可以提高代码的正确性、清晰度和整体可维护性?认识 Metalang99,这是一种简单的函数式语言,可让您创建复杂的元程序。它代表了一个只有头文件的宏库,所以你需要设置它的一切是 -Imetalang99/include 和一个 C99 编译器 正式地说,C 和 C++ 预处理器都可以执行 Metalang99(它们是相同的,除了 C++20 的 __VA_OPT__) .务实地说,只有纯 C 才能从中受益。不过,今天我将只关注两个附带的库——Datatype99 和 Interface99。在 Metalang99 之上实现,它们充分释放了预处理器元编程的潜力,因此对普通 C 程序员更有用。我还将解决一些关于编译时间、编译错误以及我的方法在现实世界中的适用性的挑剔问题。有一个重要的事情叫做代码重复。一共有三种 只是为了这篇博文的目的!实际上,可能不止三个。:每当您在代码中遇到重复时,您首先尝试通过使用函数,然后使用宏来消除它。例如,不是每次都复制粘贴相同的读取用户数据的代码,我们可以将其具体化为函数 read_user: void read_user ( char *user ) { printf ( "Type user: " ); const bool user_read = scanf ( "%15s" , user ) == 1 ;断言(用户读取); printf ( "新用户 %s \n " , 用户 ); } char amy [ 16 ], luke [ 16 ];读取用户(艾米);读取用户(卢克);

#define list_for_each(pos, head) \ for (pos = (head)->next; pos != (head); pos = pos->next) struct list_head *current ; list_for_each (current , &self ->items ) { // 做一些有意义的事情... } 更进一步,有时您无法通过微不足道的宏(无法循环/递归的宏)消除重复。从技术上讲,C 中的所有宏都是微不足道的,因为预处理器会自动阻止宏递归(C99 委员会,nd;Paul Fultz II,nd;kokosing,nd;Vittorio Romeo,nd):[ /bin/sh] -E 代表“预处理仅,” -P 代表“不打印包含的标题”。 $ clang rec.c -E -P -Weverything -std=c99rec.c:3:1: 警告:禁用递归宏扩展 [-Wdisabled-macro-expansion]FOO(1, 2, 3)^rec.c: 1:24:注意:扩展自宏 'FOO'#define FOO(x, ...) x; FOO(__VA_ARGS__) ^1;生成 FOO(2, 3)1 警告。 typedef int BinaryTreeLeaf ; typedef struct { struct BinaryTree *lhs ;二叉树叶 x ; struct BinaryTree *rhs ;二进制树节点; typedef struct { enum { Leaf , Node} tag ; union { BinaryTreeLeaf 叶 ;二叉树节点节点; } 数据 ;二叉树;有经验的 C 程序员可能已经注意到该模式称为标记联合。它的描述如下: typedef struct { enum { <tag>... } tag; union { <type> <tag>... } data;} <name>;

看到 <tag>... 和 <type> <tag>...?这些是代码重复的小怪物。即使是简单的可变参数宏也无法生成它们 可变参数宏是一个可以接受无界参数序列的宏,因为标签(变体名称)和相应的类型相互交错。我们可能想在裸标记联合之上构建一些语法糖,但事实是我们不能。例如,这就是同一个二叉树在 Rust 中的样子: typedef struct { void (*move_forward )( void *self , int distance ); void (*move_back )( void *self , int distance ); void (*move_up )( void *self , int distance ); void (*move_down )( void *self , int distance );飞机表; // 这里的`MyAirplane_*` 方法的定义... const AirplaneVTable my_airplane = { MyAirplane_move_forward , MyAirplane_move_back , MyAirplane_move_up , MyAirplane_move_down , };你能注意到这里的重复吗?对,在 AirplaneVTable my_airplane 的定义中。接口方法的名字我们已经知道了,为什么还要再指定呢?为什么我们不能只编写 impl(Airplane, MyAirplane) 来收集所有方法的名称并在每个方法之前添加 MyAirplane 呢?在 Rust 中: trait Airplane { fn move_forward( & mut self , distance : i32) ; fn move_back( & mut self , distance : i32) ; fn move_up( & mut self , distance : i32) ; fn move_down( & mut self , distance : i32) ; } impl Airplane for MyAirplane { // 这里的 `MyAirplane` 方法的定义... } 我想你已经知道答案了:因为预处理器宏不能循环/递归,因此不能迭代无界的参数序列。 Metalang99 是预处理器的自然扩展;它允许您通过使用宏迭代来消除第三种代码重复。这种可能性导致了对代数数据类型和软件接口的完全支持,我们将在接下来的两节中讨论这两者。读者,跟我来!回想一下前面提到的 BinaryTree 标记联合。借助 Datatype99,一个在 Metalang99 之上实现的库,它可以定义如下:

并按如下方式操作这称为模式匹配,一种将和类型(标记联合)分解为其各自组件的技术。: int sum ( const BinaryTree *tree ) { match (*tree ) { of (Leaf , x ) return * X ; of (Node , lhs , x , rhs ) 返回 sum (*lhs ) + *x + sum (*rhs );简洁的部分是,宏的这种使用不仅减少了样板文件,而且还降低了失败的风险:如果二叉树只是 Leaf(因为变量 rhs 还没有被引入到范围在 of(Leaf, x)) 之后,或者用 .tag = Leaf 和节点的数据构造二叉树。这同样适用于上述 AirplaneVTable。以下是使用 Interface99 定义它是多么容易: #include <interface99.h> #define Airplane_INTERFACE \ iFn(void, move_forward, void *self, int distance); \ iFn(void, move_back, void *self, int distance); \ iFn(void, move_up, void *self, int distance); \ iFn(void, move_down, void *self, int distance);接口(飞机); // 这里的`MyAirplane_*` 方法的定义... implPrimary (Airplane , MyAirplane ); implPrimary(Airplane, MyAirplane) 是这里最引人注目的部分;它从上下文中推导出方法的名称,使您免于每次添加/删除/重命名接口方法时更新定义的负担。 // 接口(飞机); typedef struct AirplaneVTable AirplaneVTable ; typedef struct 飞机 飞机 ; struct AirplaneVTable { void (*move_forward)( void *self , int distance ); void (*move_back )( void *self , int distance ); void (*move_up )( void *self , int distance ); void (*move_down )( void *self , int distance ); }; struct Airplane { void *self ; const AirplaneVTable *vptr ; }; // implPrimary(Airplane, MyAirplane); const AirplaneVTable MyAirplane_Airplane_impl = { .move_forward = MyAirplane_move_forward , .move_back = MyAirplane_move_back , .move_up = MyAirplane_move_up , .move_down = MyAirplane_move_down , };

与您手写时几乎相同。虚拟方法表是如此普遍,以至于它们几乎用在 C:Linux 内核中的每个中/大型项目中。事实证明,他们使用自己的、非正式指定的方法分派技术,这与虚拟表非常相似。 FFmpeg。为了定义媒体编解码器,他们利用了 AVCodec 结构和一些回调函数。 Interface99 和 Datatype99 都将非正式的软件开发模式具体化为完全正式的编程抽象。每次编写一个单独的函数来多次执行特定任务时,您在概念上都是这样做的。 Interface99 和 Datatype99 都依赖于大量使用宏,如果没有像 Metalang99 这样的东西,这是不可能的。这一切都很好也很有趣,但是编译错误呢?他们看起来怎么样?他们完全可以理解吗?我知道元编程 Hello, Boost/Preprocessor! 的错误消息是多么疯狂,以及弄清楚它们的含义是多么令人沮丧。虽然在技术上不可能处理所有类型的语法不匹配,但我已经付出了巨大的努力来使大多数诊断变得易于理解。让我们假设您在宏调用中不小心犯了语法错误。然后你会看到类似这样的东西:如果你使用 GCC,你可以直接从控制台看到如此简洁的错误。否则,您必须使用 -E 预处理您的文件并自行搜索 Metalang99 错误。

$ gcc playground.c -Imetalang99/include -Idatatype99 -ftrack-macro-expansion=0playground.c: 在函数 'ml99_error_3':playground.c:3:1: 错误:调用'ml99_error_3' 声明属性错误:ML99_assertIsTuple: Bar(int) 必须是 (x1, ..., xN) 3 |数据类型(A, (Foo, int), Bar(int)); | ^~~~~~~~ $ gcc playground.c -Imetalang99/include -Idatatype99 -ftrack-macro-expansion=0playground.c: 在函数'ml99_error_3':playground.c:3:1: 错误:调用'ml99_error_3 ' 用属性错误声明:ML99_assertIsTuple: (Foo, int) (Bar, int) must be (x1, ..., xN),你错过了一个逗号吗? 3 |数据类型(A, (Foo, int) (Bar, int)); | ^~~~~~~~ 如果错误不是真的在语法部分,你会看到这样的:playground.c:3:1: error: unknown type name 'NonExistingType' 3 | datatype( | ^~~~~~~~playground.c:3:1: 错误:未知类型名称 'NonExistingType'playground.c:3:1: 错误:未知类型名称 'NonExistingType' 匹配 (*tree) { of (Leaf , x ) return *x ; // of(Node, lhs, x, rhs) return sum(*lhs) + *x + sum(*rhs); } playground.c: In function 'sum':playground. c:6:5: 警告:枚举值 'NodeTag' 未在 switch [-Wswitch] 6 中处理 | match(*tree) { | ^~~~~ #define Foo_INTERFACE iFn(void, foo, int x, int y) ; interface (Foo ); typedef struct { char dummy ; } MyFoo ; // 缺少`void MyFoo_Foo_foo(int x, int y)`. impl (Foo , MyFoo );

Playground.c:12:1: 错误:'MyFoo_Foo_foo' 未在此处声明(不在函数中);你的意思是“MyFoo_Foo_impl”? 12 | impl(Foo, MyFoo); | ^~~~ | MyFoo_Foo_impl 当宏失败时,仅通过查看控制台或查看它的调用(这非常罕见)我不明白出了什么问题,我用 -E 观察扩展。这就是 Datatype99 和 Interface99 的正式规范发挥作用的地方:即使在扩展的代码中,我也不会看到意外的东西,因为代码生成语义是固定的,并且在它们相应的 README.mds 中进行了布局。编译时间并不是真正的问题。让我们看看编译 datatype99/examples/binary_tree.c 需要多少: [ /bin/sh] -ftrack-macro-expansion=0 是一个 GCC 选项,它告诉编译器不要打印无用的宏扩展床单。此外,它极大地加快了编译速度,因此我建议您始终将它与 Metalang99 一起使用。如果使用 Clang,则可以指定 -fmacro-backtrace-limit=1 以达到大致相同的效果。仅当头文件中有大量宏内容时,这可能是一个问题。如果是这样,我建议使用一种广为人知的技术,称为预编译头,以便将它们转换为某些编译器的中间表示,然后放入缓存中,而不是在每个文件包含时不必要地重新编译。正如通常在软件工程中所做的那样,宏是一种权衡:您是继续编写样板代码,从而减慢开发过程并增加出现错误的风险,还是开始以实现巨大的复杂性为代价使用强大的宏你还记得抽象抽象定律吗,我的朋友? 😁 还有一些不太容易理解的错误?如果您坚持第一个选择,您确定在运行时而不是在编译时更容易找出代码的问题,尤其是当未具体化的抽象与您的业务逻辑交织在一起时?您是否同意更多错误最终会隐藏在部署的生产代码中而不是被编译器智能地发现(如静态类型与动态类型)?

如果你坚持第二种选择,你确定你的团队会让你将所有这些元编程机制集成到你的代码库中,即使是间接使用?我见过几组开发人员不得不审查他们使用的所有第三方代码,除了所谓的“可信”库,如 OpenSSL 或 glibc。 – 不是每个程序员都可以/想要查看 Metalang99。不要误会,Metalang99、Datatype99、Interface99我已经用我能做到的最简单、最干净的方式做了,但是预处理器的本质真的很让人感觉另外,Metalang99的审阅者应该对编程语言有一些基本的熟悉理论;至少,审阅者应该理解诸如 EBNF 语法、操作语义、lambda 演算等术语,以便阅读规范.. C99 委员会。 nd “C99 草案,第 6.10.3.4 节,第 2 段 - 重新扫描和进一步替换。” http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf。