想必很多人在第一次面对
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提供了一个无需任何硬件就可以直接仿真执行的例子工程。该例子工程以队列类为例子,展示了:
很多时候千言万语敌不过
代码
几行——学习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);
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, '.');
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 模板其实是一套头文件,既没有库(lib)也没有C语言源代码,更别提汇编了。
在任意的MDK工程中,只要你已经安装了此前我们提到过的CMSIS-Pack,就可以通过下述工具栏中标注的按钮,
打开RTE配置界面:
找到 Language Extension选项,将其展开后勾选PLOOC,单击OK关闭窗口。
此时,我们就可以在工程管理器中看到一个新的列表项“
Language Extension
”:
它是不可展开的,别担心,这就足够了。打开工程配置,如果你使用的是
Arm Compiler 6(armclang)
:
则我们需要在 C/C++选项中:
如果你使用的是
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++的书略微学习一下为好。后面的内容,我将假设你已经对面向对象的基本开发要素较为熟悉。
假设我们要创造一个新的类,叫做
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前的
勾选
(
这一步骤很重要
)
接下来,依次:
完成上述步骤后,保存
class_name.c
。
打开
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
所在的代码片断:
declare_class(my_class1_t)
def_class(my_class1_t,
public_member(
)
private_member(
)
protected_member(
)
)
end_def_class(my_class1_t)
很容易注意到:
-
declare_class(或者也可以写成 dcl_class)用于类型的“前置声明”
,它的本质就是
typedef struct my_class1_t my_class1_t;
第四步:如何设计构造函数
找到 typedef struct my_class1_cfg_t 对应的代码块:
typedef struct my_class1_cfg_t {
} my_class1_cfg_t;
可以看到,这是个平平无奇的结构体。它用于向我们的构造函数传递初始化类时所需的参数。在类的头文件中,你很容易找到构造函数的函数原型:
extern
my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG);
可以看到,其第一个参数是指向类实例的指针,而第二个参数就是我们的配置结构体。在类的C源代码文件中,可以找到构造函数的实体:
#undef this
#define this (*ptThis)
my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG)
{
class_internal(ptObj, ptThis, my_class1_t);
ASSERT(NULL != ptObj && NULL != ptCFG);
return ptObj;
}
此时,在构造函数中,
我们可以通过 this.xxxx 的方式来访问类的成员
,以便根据配置结构体中传进来的内容对类进行初始化。
也许你已经注意到了,我们的模板中并没有任何为类申请空间的代码。这是有意为之。原因如下:
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);
换句话说,对大部分类的大部分情况来说,我们都不需要考虑类的方法重载问题,就算有,很多时候也都是编译时刻的静态重载(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, )
{
class_internal(ptObj, ptThis, my_class1_t);
...
}
这里,
class_internal()
用于将
ptObj
转变成我们所需的
this
指针(这里的ptThis),借助宏的帮助,我们就可以实现
this.xxxx
这样无成本的形式主义了。
我们的模板还为每个类都提供了一个接口,并默认将构造和析构函数都包含在内,比如,我们可以较为优雅的对类进行构造和析构:
static my_class1_t s_tMyClass;
...
MY_CLASS.Init(&s_tMyClass, ...);
...
MY_CLASS.Depose(&s_tMyClass);
在 my_class1.h 中,我们可以找到这样的结构:
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);
end_def_interface(i_my_class1_t)
假设我们要加入一个新的方法,则只需要在
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);
void (*Method1) (my_class1_t *ptObj, );
end_def_interface(i_my_class1_t)
接下来,我们要在 my_class1.h 中添加对应方法的函数声明:
extern
void my_class1_method1(my_class1_t *ptObj, );
值得注意的是,习惯上函数的命名上与接口除大小写歪,还有一个简单的对应关系:即,所有的"
.
"直接替换成"
_
",比如,使用上:
与此同时,我们需要在
my_class1.c
中添加
my_class1_method1()
函数的实体:
void my_class1_method1(my_class1_t *ptObj, )
{
class_internal(ptObj, ptThis, my_class1_t);
...
}
const i_my_class1_t MY_CLASS1 = {
.Init = &my_class1_init,
.Depose = &my_class1_depose,
};