65.9K
CodeProject 正在变化。 阅读更多。
Home

cobj:纯 C 中的多态性

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2016 年 6 月 7 日

MIT

15分钟阅读

viewsIcon

18542

downloadIcon

290

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 变量。生成器会从中创建一个 structstruct 的大小对编译器是已知的,所以你可以将对象分配在任何地方(栈、堆、内联在 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 模式:

  1. 定义属性
  2. 调用生成器
// 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_INTERFACESCOBJ_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 中获得良好体验的方式处理这些文件。当然,这不是一个理想的情况。但它也不是世界末日,因为有一个很好的变通方法,而且一切都很好。

  1. 你创建一个 .c 文件(例如,vax-helper.c,该文件不会在项目中编译,仅用于 Intellisense)。
  2. 你向每个使用的接口和类头文件添加一个 #include
  3. 你运行 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:输出文件。
  4. 运行此命令后,你将在一个 .h 文件中获得所有必需的声明,Visual Assist 将在你的项目中使用它,即使你没有在 .c 文件中包含它。
  5. 你可以通过构建事件来自动化此过程。

请注意,我将非常乐意在不同的 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 源代码树将始终比文章更新。

© . All rights reserved.