cobj:纯 C 中的多态性





5.00/5 (10投票s)
cobj 是一个基于预处理器的接口多态性生成器
引言
尽管我们有大量的上层和脚本语言及平台,但 C 仍然是一个非常重要的参与者,例如在嵌入式领域、内核,甚至应用程序代码中。
如果你想在 C 中实现多态性,这通常是一件复杂的事情。在语言层面,函数指针允许多态代码。但你需要自己处理所有“脏活”。
即使你成功地将接口实现为一个函数指针的 struct
,你仍然需要通过指针调用它,并且还要向它传递一些状态。调用函数指针,包括传递状态,可读性不强,并且经常需要手动转换。所以你没有编译时检查。
这就是 cobj
的用武之地:它是一个简单的接口基础继承模式,以及一个生成器,它仅通过预处理器即可生成所有必需的样板代码。
请注意,cobj
不是运行时。事实上,它的分发中甚至不包含任何一个 .c 文件。所以 cobj
不关心生命周期、分配、序列化或任何类似的东西。它只关心调用方法和传递状态。它不分配,所以没有对 malloc
的依赖,你可以将对象初始化在任何地方,包括栈上,或者内联在其他结构中。
它是开源的,遵循非常宽松的许可证(MIT 许可证),托管在 GitHub 上(https://github.com/gprossliner/cobj)。如果你有任何问题,包括关于文档或演示项目的问题,请在 GitHub 上提交一个 issue!
实体
cobj
中有这些基本实体
- 接口
- 声明一组方法
- 类
- 实现一个或多个接口
- 可以声明 `private` 变量
- 可以声明初始化参数
- 对象
- 为特定类初始化的块内存,包含 `private` 变量和描述符
- 参考文献
- 对象和接口的组合
- 是“
queryinterface
”调用的结果 - 接口方法只能通过引用调用
- 描述符
- 描述符是全局变量,用于表示和描述类的接口
- 通常仅在生成代码内部需要,而不是在自己的代码中需要
- 生成为常量,所以它们通常会进入程序内存,不占用 RAM。
演示
我不想在展示代码之前写数千字,所以我会展示单个片段。演示中的整体场景如下:
有一个名为“gpio_pin
”的接口,它定义了四个方法:
bool get_value()
void set_value(bool value)
void set_options(gpio_options options)
void toggle()
定义了两个类来实现这个接口:第一个是“hw_gpio_pin
”,它访问一些(虚拟)硬件外设寄存器;第二个是“gpio_pin_inverter
”,它接收另一个 gpio_pin
来反转其逻辑状态。请参阅 GitHub 演示中的其他示例接口和类。
对象初始化
在大多数情况下,类都有一些与之关联的状态。因此,cobj
允许轻松初始化对象,因为你可以为每个类自动拥有的“initialize
”方法定义零个或多个参数。
对于演示,我们有以下参数:
hw_gpio_pin_initialize(int pin_nr)
gpio_pin_inverter_initialize(gpio_pin * pin)
请注意,初始化方法返回 bool
。这个值本身不被 cobj
使用。你可以用它来表示初始化的结果。当你确信对象的初始化不会失败时,就不需要检查返回值。
私有状态
大多数类需要一些 private
状态来实现其功能。如果你有单例,你可以使用普通的 global static
变量,但如果你可能有类的多个实例(一个对象),则必须在其他地方指定此状态。
在 cobj
中,你可以定义 private
变量。生成器会从中创建一个 struct
。struct
的大小对编译器是已知的,所以你可以将对象分配在任何地方(栈、堆、内联在 struct
中)。
由于 C 没有“this-call 调用约定”,因此 this
参数(在 cobj
中命名为“self
”,以避免使用 C++ 关键字)必须手动作为第一个参数传递。请注意,cobj
默认生成类型安全的代码,因此编译器会检查你是否混合了类!
消费者代码
现在是时候向你展示一些代码了。这是任何 .c 文件都可以使用的代码,该文件包含定义接口的 .h 文件(gpio_pin.h,稍后我会展示)。
这个文件是应用程序的逻辑,它透明地操作 gpio_pin
的任何实现。
// file: logic.c
#include "gpio_pin.h"
void logic(gpio_pin * input_pin, gpio_pin * output_pin)
{
// read the value of the input_pin
bool value = gpio_pin_get_value(input_pin);
// the output must have the same state as the input
gpio_pin_set_value(output_pin, value);
}
逻辑并不复杂,而且确实有更简单的方法来实现这个目标,但我不想花你的时间在复杂的应用程序逻辑上,cobj
不关心你的实现。;-)
请注意命名和签名,它们总是
interfacename_methodname(interfacename * self [, arguments...]);
参数“input_pin
”和“output_pin
”是接口的**引用**。引用代表接口的特定实现。它有两个指针大小的字段。它是“queryinterface
”方法的结果,我稍后会展示。你只能通过引用调用方法,而不能直接通过对象调用。通常,你只传递引用,而不是对象。因此,在运行时使用 cobj
时,引用将是接口最常用的实体。它是接口的“自然”表示。所以我选择接口名称作为 `typedef` 引用 `struct` 的名称。每当你看到签名中的“interfacename
”时,它就代表一个引用。
下面的代码是驱动程序,它为逻辑代码声明并初始化类的实例。
// file: main.c
// include interface headers
#include "gpio_pin.h"
// include class headers
#include "hw_gpio_pin.h"
#include "gpio_pin_inverter.h"
// additional includes
#include "logic.h"
// declare the objects needed
static hw_gpio_pin input_pin_hw;
static hw_gpio_pin output_pin_hw;
void main()
{
// we use pin #13 for the input
hw_gpio_pin_initialize(&input_pin_hw, 13);
// and pin #12 for the output
hw_gpio_pin_initialize(&output_pin_hw, 12);
// to call any functions, and to pass it to the logic, we need to query for interfaces:
gpio_pin input_pin_ref;
gpio_pin output_pin_ref;
gpio_pin_queryinterface(&input_pin_hw.object, &input_pin_ref);
gpio_pin_queryinterface(&output_pin_hw.object, &output_pin_ref);
// start the logic
logic(&input_pin_ref, &output_pin_ref);
}
请注意以下约定:
classname_initialize(classname * self [, arguments...])
interfacename_queryinterface(objectvariable.object, &interfacename)
一些注意事项
- 你总是必须调用
initialize
方法,即使你没有定义自己的变量,因为object_descriptor
字段必须被初始化,这在生成的代码中完成。 initialize
返回bool
,你可以在应用程序中用它来表示成功。返回值的意义在cobj
中没有,它只是从方法中返回它,而且它本身永远不会返回false
。所以,如果你知道一个方法不会失败,你可能不需要测试它。queryinterface
在接口未被给定类实现时返回false
。在这种情况下,引用未被初始化。如果你不确定一个类是否实现了特定的接口,你必须检查/断言返回值,否则你将使用未初始化的方法,其行为是未定义的。
声明元数据
cobj
基于预处理器。为了表示列表(如方法列表、变量列表等),它使用 x-macro 技术。下面我将简要介绍一下,也许我可以专门写一篇关于它的文章,尽管问我!
x-macro 技术
通用模式是,你使用一组其他符号(如“METHOD
”)`#define` 一个符号(如“METHODS
”)。当预处理器之后处理“METHODS
”时,它会在其当前定义中使用“METHOD
”宏。在此之后,“METHODS
”仍然被定义,因此通过重新定义“METHOD
”,你可以通过单个定义生成不同的代码。
示例
这是一个通用的 x-宏示例,仅用于展示如何在接口和类头文件中使用它们。
#define METHODS METHOD(foo) METHOD(bar)
// all of the following code can be place in a different .h file
// for example a "generator.h" and included by #include "generator.h"
// define how to generate METHOD(name)
// in this case some forward-declaration
#define METHOD(name) void name(void);
// generate the code by let METHODS expand
METHODS
// METHODS is still defined, and processed.
// so we can redefine "METHOD" now, to generate some other code
// let's do the implementation
#define METHOD(name) void name(){}
// and generate it
METHODS
此文件生成以下代码:
void foo();
void bar();
void foo{}
void bar{}
\ 字符用于提高定义的可读性
C 允许预处理器定义跨越多行,而反斜杠字符用作行继续符(https://gcc.gnu.org/onlinedocs/gcc-3.0.1/cpp_3.html)
cobj
使用了它,你也可以为你的定义使用它,因为它更具可读性。否则,例如,你必须将所有方法定义放在一行中。
因此,前面的示例可以写成
#define METHODS \
METHOD(foo) \
METHOD(bar)
多行宏中的注释
请注意,你不能在多行宏的中间使用 C++ 风格的注释(// comment
),因为编译器将无法“看到”注释中的 \
。所以
#define METHODS \
METHOD(foo) // not ok \
METHOD(bar)
#define METHODS \
METHOD(foo) /* ok */ \
METHOD(bar)
如何定义接口
每个接口都必须在其自己的 .h 文件中定义。它基于通用的 cobj
模式:
- 定义属性
- 调用生成器
// file "gpio_pin.h"
#ifndef GPIO_PIN_H_ // include guard
#define GPIO_PIN_H_
// include needed headers, maybe other interface headers:
#include "stdbool.h"
// define the name of the interface, should match the name of the .h file
#define COBJ_INTERFACE_NAME gpio_pin
// define the methods of the interface
// by using the COBJ_INTERFACE_METHOD->COBJ_INTERFACE_METHOD x-macro
#define COBJ_INTERFACE_METHODS \
COBJ_INTERFACE_METHOD(bool, get_value) \
COBJ_INTERFACE_METHOD(void, set_value, bool, value)
// run the generator, note that the cobj directory needs to be in your include path!
#include "cobj-interface-generator.h"
#endif
请注意以下几点:
- 生成器必须始终包含在定义**之后**,而不是在文件开头!
- 参数类型和名称之间的**逗号**是**强制的**!没有它会更好,但预处理器无法按空格分割。
- 你可以为类型使用任何修饰符,表达式
COBJ_INTERFACE_METHOD(unsigned int, foo, const void *, address, struct data data)
是有效的。 - 由于
cobj
必须显式处理任意数量的参数,因此参数数量存在限制。此限制目前设置为16
。如果你定义一个有超过 16 个参数的方法,你将收到一个错误。如果这对你来说不够,你可以提交一个 GitHub issue。
如何定义类
类由两个文件组成。一个用于头文件(hw_gpio_pin.h)。这个类头文件必须由任何初始化类或分配类的文件包含,但不是为了调用任何接口引用的方法(在这种情况下,只需要包含interface.h文件)。
类.h 文件
首先,我将向你展示如何创建 class.h 文件。其中包含内联注释来解释文件的结构。
// file hw_gpio_pin.h
#ifndef HW_GPIO_PIN_H_ // include guard
#define HW_GPIO_PIN_H_
// define the name of the class
#define COBJ_CLASS_NAME hw_gpio_pin
// define the parameters for the initialize method
#define COBJ_CLASS_PARAMETERS \
COBJ_CLASS_PARAMETER(int, pin_nr)
// define the private variables
#define COBJ_CLASS_VARIABLES \
COBJ_CLASS_VARIABLE(int, port_address) \
COBJ_CLASS_VARIABLE(int, port_mask)
// define the implemented interfaces
#define COBJ_CLASS_INTERFACES \
COBJ_CLASS_INTERFACE(gpio_pin)
// include the .h file of the implemented interfaces,
// while define the "COBJ_INTERFACE_IMPLEMENTATION_MODE" symbol
// this is mandatory, so that generator implements the entities needed!
#define COBJ_INTERFACE_IMPLEMENTATION_MODE
#include "gpio_pin.h"
#undef COBJ_INTERFACE_IMPLEMENTATION_MODE
// call the generator
#include "cobj-classheader-generator.h"
#endif
请注意以下几点:
- 建议 .h 和 .c 文件的名称与
COBJ_CLASS_NAME
符号提供的名称匹配。 - 此时,当你包含已实现接口的.h 文件时,必须定义
COBJ_INTERFACE_IMPLEMENTATION_MODE
符号。这会导致生成器生成实现此接口在该类中所需代码。 COBJ_CLASS_INTERFACES
或COBJ_INTERFACE_IMPLEMENTATION_MODE
中的一个可能是多余的,我还没有找到一种方法可以互换使用(例如,不要求COBJ_INTERFACE_IMPLEMENTATION_MODE
,而是检查包含的.h 是否在COBJ_CLASS_INTERFACES
中)。
类.c 文件
下一步是在 .c 文件中实现方法。请注意以下几点:
- 每个实现方法都有
_impl
后缀。 - 每个实现方法都必须是
static
。 - 您无需声明实现方法,它们由生成器声明。
- self 参数使用与消费者端不同的
typedef
,这允许轻松访问private
变量。它还使用_impl
后缀,并且始终是指针。 - “
COBJ_IMPLEMENTATION_FILE
”符号必须在包含类头文件之前在.c 文件中定义。 - 实现方法的签名是:
static return_type interfacename_methodname_impl(classname_impl * self [, arguments])
- 初始化方法的签名是:
static bool classname_initialize_impl(classname_impl * self [, init_arguments])
示例(hw_gpio_pin.c)
// file "hw_gpio_pin.c"
// define the COBJ_IMPLEMENTATION_FILE symbol, to use the correct generator mode
#define COBJ_IMPLEMENTATION_FILE
// include the class-header
// this also includes the interface-headers indirectly, so you don't need to do it by yourself!
// It's generally recommended to use include-guards in include files,
// like shown in the .h file examples
#include "hw_gpio_pin.h"
// the initialize methods
static bool initialize_impl(hw_gpio_pin_impl * self, int pin_nr)
{
self->port_address = 0;
self->port_mask = 0;
// implementation, see attached file
}
static bool gpio_pin_get_value_impl(hw_gpio_pin_impl * self)
{
// implementation, see attached file
}
static void gpio_pin_set_value_impl(hw_gpio_pin_impl * self, bool value)
{
// implementation, see attached file
}
static void gpio_pin_set_options_impl(hw_gpio_pin_impl * self, gpio_pin_options options)
{
// implementation, see attached file
}
static void gpio_pin_toggle_impl(hw_gpio_pin_impl * self)
{
// implementation, see attached file
}
接口注册表
因为 cobj
需要为接口生成一些代码(描述符或可调用方法),所以这段代码必须在某个 .c 文件中生成。
你可以将每个接口放在一个 .c 文件中,但由于你通常不需要任何自定义代码,因此建议在项目中仅使用一个 .c 文件,名为 interface_registry.c,其中包含所有接口的全局代码。如果你拆分文件,你必须确保每个接口只包含在一个注册表中。否则,你会遇到未定义符号或多重定义的链接器错误。
interface_registry.c 文件仅定义 COBJ_INTERFACE_REGISTRY_MODE
符号,然后包含所有接口的.h 文件。在我们的例子中,我们只有一个,所以文件是:
// file: interface_registry.c
#define COBJ_INTERFACE_REGISTRY_MODE
#include "gpio_pin.h"
关于运行时开销的一点说明
cobj
引入的开销实际上非常小。它可以归结为以下操作:
- 分配:
cobj
不进行分配,因此你对内存拥有完全控制权。这也意味着分配开销为零。初始化是两个调用指令,其中一个通常是内联的,存储一个指针,以及initialize_impl
中的任何自定义代码。 - 获取引用:当你调用
queryreference
时,会执行对对象class_definition
字段的生成方法的间接调用。该实现通过一系列if
语句,按照声明顺序检查请求的接口。所以你是 O(n),其中 n 是该类实现的接口数量。 - 在引用上调用接口方法:调用接口方法包括一个到入口方法的
static
调用,该入口方法执行对实现方法的间接调用。为了确保没有警告,实际上我们有两个调用(method_thunk
调用method_impl
),但它们都是static
的,所以第二个调用通常是内联的。
如何使用代码
在下载中,我只提供了源代码,没有项目或 Makefile。有几种不同的 IDE 和编译器,所以你可以选择任何你喜欢的。
环境和可移植性
代码已针对 GCC v4.4.7 进行测试,并且即使在设置为 -wall(所有警告)的情况下,也能编译为零警告的代码。这是 cobj
的设计标准,并且生成了大量代码,只是为了确保你不会收到生成代码的警告。
如果你设置了项目或 Makefile,请注意 /cobj 目录需要包含在 include 路径中。
为了混淆 private
变量的名称,cobj
目前使用 __COUNTER__
宏,这是一个 gcc 扩展。欢迎提出替代方案!如果你使用其他编译器,请提交一个 issue。
cobj
不直接依赖于使用的操作系统,或者是否使用了操作系统。它也没有依赖于特定的体系结构。即使在 8 位世界中,它也应该能很好地工作。目前仅在 32 位上进行了测试,因此如果你发现任何可移植性问题,请提交一个 issue。
智能感知和代码补全
不同的工具对复杂的.h 文件(如 cobj
生成器)的处理方式不同。我测试了 Visual Assist(http://www.wholetomato.com/),因为它集成在 Atmel Studio 中,但它未能以一种让你在 IDE 中获得良好体验的方式处理这些文件。当然,这不是一个理想的情况。但它也不是世界末日,因为有一个很好的变通方法,而且一切都很好。
- 你创建一个 .c 文件(例如,vax-helper.c,该文件不会在项目中编译,仅用于 Intellisense)。
- 你向每个使用的接口和类头文件添加一个
#include
。 - 你运行 gcc 编译器,只进行预处理,而不包含其他内容。
-I"demo" -I"cobj" -E -P -o "vax-helper\vax-helper.h" "vax-helper\vax-helper.tmp.c"
-I"cobj"
:将 cobj 目录添加到 include 路径。
-E
:仅预处理。
-P
:禁止预处理器在输出中生成行标记(可读性)。
-o
:输出文件。 - 运行此命令后,你将在一个 .h 文件中获得所有必需的声明,Visual Assist 将在你的项目中使用它,即使你没有在 .c 文件中包含它。
- 你可以通过构建事件来自动化此过程。
请注意,我将非常乐意在不同的 IDE 和工具上进行任何测试!只需通过“讨论”联系我,或在 GitHub 上提交一个 issue!
如何找到元数据中的错误?
我们都知道 C 编译器在报告错误方面不是最好的。通常,你只错过一个分号,就会得到数百个错误。如果你使用像 cobj
这样的元编程框架,情况也不会更好。
我真的很想允许用户在发生错误时了解出了什么问题,但控制编译器输出非常有限,因为你无法在宏定义中嵌入 #error
这样的预处理器语句。
所以我在错误方面选择了以下策略:
- 如果在你的某个文件中存在语法错误,请转到错误的位置,并查阅文档或示例,了解出了什么问题。
- 如果你在文件中存在语义错误,例如在定义名称和类型之间遗漏了逗号,错误的位置通常在一个 cobj 文件中。在这种情况下,源代码中有注释可以指导你哪里出了问题,例如:
/*
Common Error:
expected ';', ',' or ')' before 'COBJPVT_GEN_METHOD_ARGS_SEPERATOR_1'
or any odd number:
Cause:
The syntax of the COBJ_INTERFACE_METHOD is invalid, because the parameter
list must always contain pairs values.
You may be missing the comma between the type and the name.
Resolution:
Insert a comma between the type and the name, like:
Wrong: COBJ_INTERFACE_METHOD(void, foo, int i)
Right: COBJ_INTERFACE_METHOD(void, foo, int, i)
*/
提示:在出错时,不要只使用 IDE 的错误列表视图。GCC 在日志中输出的信息比 IDE 通常显示的要多得多,而且错误是排序的(始终从第一个错误或警告开始!)。例如,如果你忘记在 .c 文件中实现一个 _impl
方法,输出窗口只会显示“unresolved symbol gpio_pin_get_value
”,而 Output Windows 还会显示它正在编译的 .o 文件名。
提示:为了进一步诊断问题,检查预处理后的文件很有帮助。在 GCC 中,你可以使用 -E
或 -save-temps
开关。
关注点
构建 cobj
真的很有趣。并且它在定义的场景中运行得很好。如果你有任何改进的想法,如果你发现任何 bug,如果你想参与,如果你做了一些你想分享的测试……
请使用 CodeProject 上的讨论区!
在 GitHub 上提交一个 issue(https://github.com/gprossliner/cobj/issues)
你可以提交 issue,不仅是为了代码中的 bug,也包括如果你需要更多演示、在集成到你的项目中时遇到问题,或者你想讨论改进。
我也非常希望收到任何“案例研究”,如果有人选择在一个项目中使用 cobj
。哪些方面做得好?学到的最痛苦的教训是什么?我也很希望有人对 cobj
进行性能测试。
历史
这是 cobj
的第一个版本。它仍在开发中,所以有些东西可能会改变,或者会有一些改进。请也查看 GitHub 页面,在那里你会找到额外的示例。我会尽量在有任何变化时更新这篇文章,但 GitHub 源代码树将始终比文章更新。