专栏名称: 嵌入式微处理器
关注这个时代最火的嵌入式微处理器,你想知道的都在这里。
目录
相关文章推荐
光伏們  ·  山西分布式光伏装机容量突破千万千瓦 ·  2 天前  
光伏們  ·  山西分布式光伏装机容量突破千万千瓦 ·  2 天前  
歸藏的AI工具箱  ·  火山返利活动搞好可以实现 Deepseek ... ·  3 天前  
歸藏的AI工具箱  ·  火山返利活动搞好可以实现 Deepseek ... ·  3 天前  
申妈的妹子圈  ·  阿里第三季度营收2801亿,超市场预期 ·  4 天前  
申妈的妹子圈  ·  阿里第三季度营收2801亿,超市场预期 ·  4 天前  
51好读  ›  专栏  ›  嵌入式微处理器

如何快捷使用C语言搞定面向对象开发?

嵌入式微处理器  · 公众号  ·  · 2024-07-12 12:00

正文

“为什么要使用C语言来实现面向对象开发?”

“直接用C++不就好了么?”


想必很多人在第一次面对 OOPC Object-Oriented-Programming-with-ANSI-C )的时候,都会情不自禁的发出类似的疑问。其实,任何针对上述问题的讨论,其本身都是充满争议的——换句话说,无论我给出怎样的答案,都无法令所有人满意——正因如此,本文也无意去趟这摊浑水。

我写这篇文章的目的是为那些长期在MDK环境下从事C语言开发的朋友介绍一种方法: 帮助大家在偶尔需要用到“面向对象”概念的时候 简便快捷的使用C语言“搞定”面向对象开发

在开始后续内容之前,我们需要约定和强调一些基本原则:

  • “零消耗”原则 :即,我们所要实现的所有面向对象的特性都应该是“零资源消耗”或至少是“极小资源消耗”。这里的原理是: 能在编译时刻(Compiletime)搞定的事情,绝不拖到运行时刻(Runtime)

  • 务实原则 :即, 我们不在形式上追求与C++类似 ,除非它的代价是零或者非常小。

  • “按需实现”原则 :即,对任何类的实现来说, 我们并不追求把所有的OO特性都实现出来 ——这完全没有必要—— 我们仅根据实际应用的需求来实现最小的、必要的面向对象技术

  • “傻瓜化”原则 :即,类的建立和使用都必须足够傻瓜化。最好所见即所得。


在上述前提下,我们就快速进入到今天的内容吧。

【仅需一次的准备阶段】


首先,我们要下载 PLOOC的 CMSIS-Pack,具体链接如下:

https://raw.githubusercontent.com/GorgonMeducer/PLOOC/master/cmsis-pack/GorgonMeducer.PLOOC.4.6.0.pack


当然,如果你因为某些原因无法访问Github,也可以在关注 【裸机思维】 公众号后发送关键字 “ PLOOC ” 来获取网盘链接。

下载成功后,直接双击安装包即可。


一般来说,部署会非常顺利,但如果出现了安装错误,比如下面这种:



则很可能是您所使用的MDK版本太低导致的——是时候更新下MDK啦。关注【裸机思维】公众号后发送关键字"MDK",即可获得最新的MDK网盘链接。



PLOOC 是 P rotected- L ow-overhead- O bject- O riented-programming-with-ansi- C 的缩写,顾名思义,是一个强调地资源消耗且为私有类成员提供保护的一个面向对象模板。

它是一个开源项目,如果你喜欢,还请多多Star哦!

https://github.com/GorgonMeducer/PLOOC



【如何快速尝鲜】



为了简化用户对 OOC 的学习成本,PLOOC提供了一个无需任何硬件就可以直接仿真执行的例子工程。该例子工程以队列类为例子,展示了:
  • 类的定义方式

  • 如何实现类的方法(Method)

  • 如何为类定义接口(Interface)

  • 如何定义派生类

  • 如何重载接口

  • 如何在派生类中访问基类受保护的成员(Protected Member)

  • ……


很多时候千言万语敌不过 代码 几行——学习OOC确是如此。

例子工程的获取非常简单。首先打开 Pack-Installer,在Device列表中找到Arm,选择任意一款Cortex-M内核(比如 Arm Cortex-M3)。在列表中选择ARMCMx(比如下图中的ARMCM3)。

此时,在右边的Example选项卡中,就可以看到最底部出现了一个名为 plooc_example (uVision Simulator) 的例子工程。单击Copy,在弹出窗口中选择一个目录位置来保存工程:

单击OK后将打开自动打开如下所示的 MDK 界面:

直接单击编译,如果一切顺利,应该没有任何编译错误:


此时,我们可以直接进入调试模式:


可以看到,调试指针停在了 main() 函数的起始位置。我们先不着急开始全速运行。通过菜单打开 "Debug (printf) Viewer" 窗口:


一开始该窗口会出现在屏幕下方的窗体中,通过拖动的方式,我们可以将其挪到醒目的位置。此时,全速运行就可以看到例子工程所要展示的效果了:

该例子只展示了C99模式下使用PLOOC所构建的队列类(enhanced_byte_queue_t)的效果:

static enhanced_byte_queue_t s_tQueue;
printf("Hello PLOOC!\r\n\r\n"); do { static uint8_t s_chQueueBuffer[QUEUE_BUFFER_SIZE]; const enhanced_byte_queue_cfg_t tCFG = { s_chQueueBuffer, sizeof(s_chQueueBuffer), }; ENHANCED_BYTE_QUEUE.Init(&s_tQueue, (enhanced_byte_queue_cfg_t *)&tCFG); } while(0);
//! you can enqueue ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'p'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'L'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'O'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'O'); ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'C'); ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.'); ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.'); ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.');
//! you can dequeue do { uint_fast16_t n = ENHANCED_BYTE_QUEUE.Count(&s_tQueue); uint8_t chByte; printf("There are %d byte in the queue!\r\n", n); printf("let's peek!\r\n"); while(ENHANCED_BYTE_QUEUE.Peek.PeekByte(&s_tQueue, &chByte)) { printf("%c\r\n", chByte); } printf("There are %d byte(s) in the queue!\r\n", ENHANCED_BYTE_QUEUE.Count(&s_tQueue)); printf("Let's remove all peeked byte(s) from queue... \r\n"); ENHANCED_BYTE_QUEUE.Peek.GetAllPeeked(&s_tQueue); printf("Now there are %d byte(s) in the queue!\r\n", ENHANCED_BYTE_QUEUE.Count(&s_tQueue)); } while(0);

其输出为:


enhanced_byte_queue_t 实际上是从基类 byte_queue_t 基础上派生出来的,并添加了一个非常有用的功能:可以连续的偷看(Peek)队列里的内容,并可以在需要的时候,要么1)将已经偷看的内容实际都取出来;要么2)从头开始偷看——上述代码就展示了这一功能。

PLOOC 相较普通的OOC模板来说,除了可以隐藏类的私有成员(private member)以外,还能够以零运行时成本实现多肽( Polymorphism )——用通俗的话说就是:PLOOC允许拥有不同参数数量、不同参数类型的多个函数拥有相同的名字。

要获得这样的功能,就要打开 C11 (最好是 GNU11 )的支持。当我们打开工程配置,在“C/C++”选项卡中将 Language C 设置为 c11 (最好是 gnu11 ):

重新编译后,进入调试模式,将在输出窗口中看到额外的信息:


这些信息实际上对应如下的代码:

#if defined(__STDC_VERSION__) && __STDC_VERSION__ > 199901L    LOG_OUT("\r\n-[Demo of overload]------------------------------\r\n");    LOG_OUT((uint32_t) 0x12345678);    LOG_OUT("\r\n");    LOG_OUT(0x12345678);    LOG_OUT("\r\n");    LOG_OUT("PI is ");    LOG_OUT(3.1415926f);    LOG_OUT("\r\n");        LOG_OUT("\r\nShow BYTE Array:\r\n");    LOG_OUT((uint8_t *)main, 100);
LOG_OUT("\r\nShow Half-WORD Array:\r\n"); LOG_OUT((uint16_t *)(((intptr_t)&main) & ~0x1), 100/sizeof(uint16_t));
LOG_OUT("\r\nShow WORD Array:\r\n"); LOG_OUT((uint32_t *)(((intptr_t)&main) & ~0x3), 100/sizeof(uint32_t));#endif

你看,同一个函数 LOG_OUT() 当我们给它不同数量和类型的参数时,居然可以实现不同的输出效果,是不是特别神奇——这就是面向对象开发中多态的魅力所在。请记住:
  • 此时我们仍然使用的是C语言,而不是C++

  • 在C99下,我们可以实现拥有不同参数个数的函数共享同一个名字;

  • 在C11下,我们可以实现拥有相同参数个数但类型不同的函数共享同一个名字;

  • 我们在运行时刻的开销是0 ,一切在编译时刻就已经尘埃落定了。 我们并没有为这项特性牺牲任何代码空间。


例子工程可以帮助我们快速的熟悉 OOC 的开发模式,那么在任意的普通工程中,我们要如何使用 PLOOC模板呢?

【PLOOC在任意普通工程中的部署】


PLOOC 模板其实是一套头文件,既没有库(lib)也没有C语言源代码,更别提汇编了。

在任意的MDK工程中,只要你已经安装了此前我们提到过的CMSIS-Pack,就可以通过下述工具栏中标注的按钮, 打开RTE配置界面:


找到 Language Extension选项,将其展开后勾选PLOOC,单击OK关闭窗口。

此时,我们就可以在工程管理器中看到一个新的列表项“ Language Extension ”:


它是不可展开的,别担心,这就足够了。打开工程配置,如果你使用的是 Arm Compiler 6(armclang)

则我们需要在 C/C++选项中:


  • 将Language C设置为 gnu11 (或者最低 c99 ):

  • (推荐,而不是必须)在Misc Controls中添加对微软扩展的支持,并在 Define中添加一个宏定义 _MSC_VER
-fms-extensions



如果你使用的是 Arm Compiler 5(armcc)

则需要在 C/C++ 选项卡中开启对 GNU Extension 和 C99的支持:

然而遗憾的是,作为一款已经停止更新的编译器, Arm Compiler 5 既不支持C11,也不支持微软扩展(-fms-extensions) ,这意味着PLOOC中的多态特性无法发挥最大潜能,着实有点遗憾( 但拥有不同参数数量的函数还是允许共享同一个名称的 )。

至此,我们就完成了PLOOC在一个工程中的部署。是不是特别简单?


也许文章到了一半我才问,已经有点迟了——大家都熟悉基本的面向对象概念吧?比如:

  • 类(class)

  • 私有成员(private member)

  • 公共成员(public member)

  • 保护成员(protected member)

  • 构造函数(constructor)

  • 析构函数(destructor)

  • 类的方法(method)

  • ……


如果不熟悉,还请找本C#或者C++的书略微学习一下为好。后面的内容,我将假设你已经对面向对象的基本开发要素较为熟悉。

那么,我们如何快速的在C语言工程中构建一个类呢?

【新建一个类从未如此简单】


假设我们要创造一个新的类,叫做 my_class1


第一步:引入模板


在工程管理器中,添加一个新的group,命名为 my_class1



右键单击 my_class1,并在弹出的菜单中选择 " Add New Item to Group my_class1 ":



在弹出的对话框中选择 User Code Template :



展开 Language Extension ,可以看到有两个 PLOOC模板,分别对应:


  • 基类和普通类(Base Class Template)
  • 派生类(Derived Class Template)

由于我们要创建的是一个普通类(未来也可以作为基类),因此选择“ Base Class Template ”。单击Location右边的 "..." 按钮,选择一个保存代码文件的路径后,单击“Add”。


此时我们可以看到, class_name.c 被添加到了 my_class1 中,且MDK自动在编辑器中为我们打开了两个模板文件: class_name.h class_name.c




第二步:格式化


在编辑器中打开或者选中 class_name.c。通过快捷键 CTRL+H 打开 替换窗口:


  • 在Look in中选择Current Document
  • 去掉 Find Opitons属性框中的 Match whold word前的 勾选 这一步骤很重要

接下来,依次:


  • 将小写的 替换为 my_class1
  • 将大写的 替换为 MY_CLASS1

完成上述步骤后,保存 class_name.c

打开 class_name.h ,重复上述过程,即:

  • 将小写的 替换为 my_class1
  • 将大写的 替换为 MY_CLASS1

完成后保存 class_name.h .

第三步:加入工程编译


在工程管理器中展开 my_class1 ,并将其中的 class_name.c 删除:



打开class_name.c 所在文件目录:



找到我们刚刚编辑好的两个文件 class_name.c class_name.h


用我们的类为这两个文件命名: my_class1.c my_class1.h


在MDK工程管理器中,将这两个文件加入 my_class1 下:


如果此前你的工程就是可以正常编译的话 ,在加入了上述文件后,应该依然可以正常编译:


第四步:如何设计你的类成员变量


打开 my_class1.h ,找到 def_class 所在的代码片断:


//! \name class my_class1_t//! @{declare_class(my_class1_t)
def_class(my_class1_t,
public_member( //!< place your public members here )
private_member( //!< place your private members here ) protected_member( //!< place your private members here ))
end_def_class(my_class1_t) /* do not remove this for forward compatibility *///! @}


很容易注意到:


  • 类所对应的类型会自动在尾部添加 "_t" 以表示这是一个自定义类型 ,当然这不是强制的,当你熟悉模板后,如果确实看它不顺眼,可以改成任何自己喜欢的类型名称。这里,由于我们的类叫做 my_class1 ,因此对应的类型就是 my_class1_t


  • declare_class(或者也可以写成 dcl_class)用于类型的“前置声明” ,它的本质就是
typedef struct my_class1_t my_class1_t

因此,并没有什么特别神秘的地方。

  • def_class用于定义类的成员。 其中 public_member用于存放公共可见的成员变量;private_member用于存放私有成员;protected_member用于存放当前类以及派生类可见的成员。 这三者的顺序任意,可以缺失,也可以存在多个 ——非常灵活。


第四步:如何设计构造函数


找到 typedef struct my_class1_cfg_t 对应的代码块:

typedef struct my_class1_cfg_t {        //! put your configuration members here    my_class1_cfg_t;


可以看到,这是个平平无奇的结构体。它用于向我们的构造函数传递初始化类时所需的参数。在类的头文件中,你很容易找到构造函数的函数原型:


/*! \brief the constructor of the class: my_class1 */externmy_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG);


可以看到,其第一个参数是指向类实例的指针,而第二个参数就是我们的配置结构体。在类的C源代码文件中,可以找到构造函数的实体:


#undef this#define this        (*ptThis)/*! \brief the constructor of the class: my_class1 */my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG){    /* initialise "this" (i.e. ptThis) to access class members */    class_internal(ptObj, ptThis, my_class1_t);    ASSERT(NULL != ptObj && NULL != ptCFG);
return ptObj;}


此时,在构造函数中, 我们可以通过 this.xxxx 的方式来访问类的成员 ,以便根据配置结构体中传进来的内容对类进行初始化。


也许你已经注意到了,我们的模板中并没有任何为类申请空间的代码。这是有意为之。原因如下:

  • 面向对象并非一定要使用动态内存分配,这是一种偏见

  • 我们只提供构造函数,而类的用户可以自由的决定如何为类的实例分配存储空间

  • 由于我们创造的类(比如 my_class1_t)本质上是一个完整的结构体类型,因此可以由用户像普通结构体那样:

    • 进行静态分配:即定义静态变量,或是全局变量

    • 使用池分配:直接为目标类构建一个专用池,有效避免碎片化。

    • 进行堆分配 :使用普通的 malloc() 进行分配,类的大小可以通过 sizeof() 获得,比如:
my_class1_cfg_t tCFG = {    ...};my_class1_t *ptNewItem = my_class1_init(     (my_class1_t *)malloc(sizeof(my_class1_t),     &tCFG);if (NULL == ptNewItem) {    printf("Failed to new my_class1_t \r\n");}
...
free(ptNewItem);


    当然,如果你说我就是要那种形式主义,那你完全可以定义一个宏:


#define new_class(__name, ...)                \({__name##_cfg_t tCFG = {                     \    __VA_ARGS__                               \};                                            \




    
__name##_init(                                \     (__name##_t *)malloc(sizeof(__name##_t), \     &tCFG);})


这可不就是一个根正苗红的 new() 方法么,比如:


my_class1_t *ptItem = new_class(my_class, );if (NULL == ptItem) {    printf("Failed to new my_class1_t \r\n");}
...
free(ptItem);


怎么样,是这个味道吧?析构函数类似,比如 my_class1_depose() 函数,同样不负责资源的释放——决定权还是在用户的手里,当然你也可以做完一套:


#define free_class(__name, __obj)               \    do {                                        \        __name##_depose((__name##_t *)(__obj)); \        free(__obj);                            \    } while(0)


形成组合拳,从分配资源、构造、析构到最后释放资源一气呵成:


my_class1_t *ptItem = new_class(my_class, );if (NULL == ptItem) {    printf("Failed to new my_class1_t \r\n");}
...
free_class(my_class, ptItem);


第五步:如何设计构类的方法(method)


我们开篇说过,实践面向对象最重要的是功能,而非形式主义。假设有一个类的方法叫做 method1,理想中,大家一定觉得如下的使用方式是最“正统”的:

my_class1_t *ptItem = new_class(my_class, );if (NULL == ptItem) {    printf("Failed to new my_class1_t \r\n");}
ptItem.method1();
free_class(my_class, ptItem);

在C语言中,我们完全可以实现类似的效果——只要你在类的定义中加入函数指针就行了——其实很多OOC的模板都是这么做的(比如lw_oopc)。但你仔细思考一下,在类的结构体中加入函数指针究竟有何利弊:

先来说好处:

  • 可以用“优雅”的方式来完成方法的调用;

  • 支持运行时刻的重载(Override);


再来说缺点:

  • 在嵌入式应用中,大部分类的方法都不需要重载,更别说是运行时刻的重载了;

  • 函数指针会占用4个字节;

  • 通过函数指针来实现的间接调用,其效率低于普通的函数直接调用。


换句话说,对大部分类的大部分情况来说,我们都不需要考虑类的方法重载问题,就算有,很多时候也都是编译时刻的静态重载(plooc_example就展示了静态重载的实现方式),那么在不考虑运行时刻动态重载的应用场景下,直接用普通函数来实现类的方法就是务实的一个选择了。

基于这种考虑,上述例子实际上应该写为:

my_class1_t *ptItem = new_class(my_class, );if (NULL == ptItem) {    printf("Failed to new my_class1_t \r\n");}
my_class1_method1(ptItem,);
free_class(my_class, ptItem);

这里, my_class1_method1() my_class1.h 提供声明、 my_class1.c 提供实现的一个函数。前缀 my_class1_ 用于防止命名空间污染。

另外一个值得注意的细节是,OOPC中,任何类的方法,其函数的第一个参数一定是指向类实例的指针——也就是我们常说的 this 指针。以 my_class1_method1() 为例,它的形式为:

#undef this#define this        (*ptThis)
void my_class1_method(my_class1_t *ptObj, ){ /* initialise "this" (i.e. ptThis) to access class members */    class_internal(ptObj, ptThis, my_class1_t);        ...    }

这里, class_internal() 用于将 ptObj 转变成我们所需的 this 指针(这里的ptThis),借助宏的帮助,我们就可以实现 this.xxxx 这样无成本的形式主义了。

第六步:如何设计类的接口(Interface)


我们的模板还为每个类都提供了一个接口,并默认将构造和析构函数都包含在内,比如,我们可以较为优雅的对类进行构造和析构:


static my_class1_t s_tMyClass;...MY_CLASS.Init(&s_tMyClass, ...);...MY_CLASS.Depose(&s_tMyClass);

在 my_class1.h 中,我们可以找到这样的结构:

//! \name interface i_my_class1_t//! @{def_interface(i_my_class1_t)    my_class1_t *  (*Init)       (my_class1_t *ptObj, my_class1_cfg_t *ptCFG);    void           (*Depose)     (my_class1_t *ptObj);    /* other methods */
end_def_interface(i_my_class1_t) /*do not remove this for forward compatibility *///! @}

假设我们要加入一个新的方法,则只需要在 i_my_class1_t 的接口定义中添加对应的函数指针即可,比如:

//! \name interface i_my_class1_t//! @{def_interface(i_my_class1_t)    my_class1_t *  (*Init)       (my_class1_t *ptObj, my_class1_cfg_t *ptCFG);    void           (*Depose)     (my_class1_t *ptObj);    /* other methods */    void           (*Method1)    (my_class1_t *ptObj, );end_def_interface(i_my_class1_t) /*do not remove this for forward compatibility *///! @}

接下来,我们要在 my_class1.h 中添加对应方法的函数声明:

externvoid my_class1_method1(my_class1_t *ptObj, );

值得注意的是,习惯上函数的命名上与接口除大小写歪,还有一个简单的对应关系:即,所有的" . "直接替换成" _ ",比如,使用上:


MY_CLASS1.Method1()

就对应为:

my_class1_method1()


与此同时,我们需要在 my_class1.c 中添加 my_class1_method1() 函数的实体:

void my_class1_method1(my_class1_t *ptObj, ){    class_internal(ptObj, ptThis, my_class1_t);    ...}

并找到名为 MY_CLASS1 的接口实例:

const i_my_class1_t MY_CLASS1 = {    .Init =             &my_class1_init,    .Depose =           &my_class1_depose,        /* other methods */};







请到「今天看啥」查看全文