模块的封装之C语言类的继承和派生


 

[交流][微知识]模块的封装(二):C语言的继承和派生


  在模块的封装(一):C语言的封装中,我们介绍了如何使用C语言的结构体来实现一个类的封装,并通过掩码结构体的方式实

现了类成员的保护。这一部分,我们将 在此的基础上介绍C语言类的继承和派生。其实继承和派生是一个动作的两种不同角度的表达

。当我们继承了一个基类而创造了一个新类时,派生的概念就诞生了。派生当然是从基类派生的。派生出来的类当然是继承了基类的

东西。继承和派生不是一对好基友,他们根本就是一个动作的两种不同的说法,强调动作的起始点的时候,我们说这是从某某类继承

来的,强调动作的终点时,我们说派生出了某某类,

  我们知道,类总会提供一些方法,可以让我们方便的使用,比如: 

1     window_t tWin = new_window();    //!< 创建一个新的window对象
2     tWin.show();                                 //!< 显示窗体

显然,能够实现这一技术的必要手段就是将函数指针一起封装在结构体重。在C语言中,类的方法(method)是通过函数指针(或

者函数指针的集合)-----我们叫虚函数(表)来实现的。虚函数表同样可以单独存在,我们称之为interface。在C语言中,虚函数

表是可以直接通过封装了纯函数指针的结构体来实现的。如下所示:

 1 //! \name interface definition
 2 //! @{
 3 #define DEF_INTERFACE(__NAME,...)    4             typedef struct __NAME __NAME; 5             __VA_ARGS__ 6             struct __NAME {
 7 
 8 #define END_DEF_INTERFACE(__NAME)    9             };
10 //! @}

例如,我们可以使用上面的宏定义一个字节流的接口:

1 DEF_INTERFACE(i_pipe_byte_t)
2     bool (*write)(uint8_t chByte);
3     bool (*read)(uint8_t *pchByte)
4 END_DEF_INTERFACE(i_pipe_byte_t)

这类的接口非常适合定义一个模块的依赖接口----比如,某一个数据帧解码的模块是依赖于对字节流的读写的,

通过在该模块中使用这样的一个接口,并通过专门的接口注册函数,即可实现所谓的面向接口开发---将模块的

逻辑实现与具体应用相关的数据流隔离开,例如:

frame.c

 1 ...
 2 DEF_CLASS(frame_t)
 3     i_pipe_byte_t tStream;     //!< 流接口
 4     ...
 5 END_DEF_CLASS(frame_t)
 6 
 7 //! 接口注册函数
 8 bool frame_register_stream_interface(frame_t *ptFrame, i_pipe_byte_t tStream)
 9 {
10     //! 去除掩码结构体的保护
11     CLASS(frame_t) *ptF = (CLASS(frame_t) *)ptFrame;
12     //! 合法性检查
13     if (NULL == tStream.write || NULL == tStream.read || NULL == ptFrame ) {
14         return false;
15     }
16     ptF->tStream = tStream;    //!< 设置接口
17     return true;
18 }

frame.h

 1 ...
 2 EXTERN_CLASS(frame_t)
 3     i_pipe_byte_t tStream;     //!< 流接口
 4     ...
 5 END_EXTERN_CLASS(frame_t)
 6 
 7 //! 接口注册函数
 8 extern bool frame_register_stream_interface(frame_t *ptFrame, i_pipe_byte_t tStream);
 9 
10 extern bool frame_init(frame_t *ptFrame);

基于这样的模块,一个可能的外部使用的方法是这样的:

app.c

 1 ...
 2 static bool serial_out(uint8_t chByte)
 3 {
 4     ...
 5 }
 6 
 7 static bool serial_in(uint8_t *pchByte)
 8 {
 9     ...
10 }
11 
12 static frame_t s_tFrame;
13 ...
14 void app_init(void)
15 {
16     //! 初始化
17     frame_init(&s_tFrame);    
18 
19     //! 初始化接口
20     do {
21         i_pipe_byte_t tPipe = {&serial_out, &serial_in};
22         frame_register_stream_interface(&s_tFrame, tPipe);
23     } while(0);
24 }

像这个例子展示的这样,将接口直接封装在掩码结构体的形式,我们并不能将其称为“实现(implement)

了接口i_pipe_byte_t”,这只是内部将虚函数表作为了一个普通成员而已,我们可以认为这是加入了private

属性的,可重载的内部成员函数。下面我们将来介绍如何真正的实现(implement)指定的接口。首先,我

们要借助下面定义的宏

 1 #define DEF_CLASS_IMPLEMENT(__NAME,__INTERFACE,...) 2     typedef union __NAME __NAME; 3     __VA_ARGS__ 4     typedef struct __##__NAME __##__NAME; 5     struct __##__NAME { 6         const __INTERFACE method;
 7 
 8 #define END_DEF_CLASS_IMPLEMENT(__NAME,__INTERFACE) 9     };10     union __NAME {11         const __INTERFACE method;12         uint_fast8_t chMask[(sizeof(__##__NAME) + sizeof(uint_fast8_t) - 1) / sizeof(uint_fast8_t)];13     };
14 
15 #define EXTERN_CLASS_IMPLEMENT(__NAME,__INTERFACE,...) 16     typedef union __NAME __NAME;17     __VA_ARGS__18     union __NAME {19         const __INTERFACE method;20         uint_fast8_t chMask[(sizeof(struct {21             const __INTERFACE method;
22 
23 #define END_EXTERN_CLASS_IMPLEMENT(__NAME, __INTERFACE) 24         }) + sizeof(uint_fast8_t) - 1) / sizeof(uint_fast8_t)];25     };

为了很好的说明上面宏的用法,我们以一个比较具体的例子来示范一下。这是一个通用的串行设备

驱动的例子,这个例子的意图是,为所有的类似USART,I2C,SPI这样的串行数据接口建立一个

基类,随后不同的外设都从该基类继承并派生出属于自己的基类,比如USART类等----这种方法

是面向对象开发尤其是面向接口开发中非常典型的例子。首先,我们要定义一个高度抽象的接口,

该接口描述了我们是期待如何最简单的使用一个串行设备的,同时一起定义实现了该类的基类

serial_dev_t;

serial_device.h

 1 //! 这是一个实现了接口i_serial_t的基类serial_dev_t
 2 EXTERN_CLASS_IMPLEMENT( serial_dev_t, i_serial_t,
 3 
 4         //! 这是我们定义的接口i_serial_t 这里的语法看起来似乎有点怪异,后面将介绍
 5         DEF_INTERFACE( i_serial_t)     
 6             fsm_rt_t (*write)(serial_dev_t *ptDev, uint8_t *pchStream, uint_fast16_t hwSize);    //!< i_serial_t 接口的write方法
 7             fsm_rt_t (*read)(serial_dev_t *ptDev, uint8_t *pchStream, uint_fast16_t hwSize);     //!< i_serial_t 接口的read方法
 8         END_DEF_INTERFACE( i_serial_t )
 9     )
10     //! 类serial_dev_t的内部定义
11     ...
12 END_EXTERN_CLASS_IMPLEMENT( serial_dev_t, i_serial_t )

如果不仔细看,这个例子似乎比较清楚了,一个基类serial_dev_t实现了接口i_serial_t.但仔细一看

这里不光语法奇怪,而且含有很多细节。

首先,接口居然是定义在类里面的,而且是定义在参数宏EXTERN_CLASS_IMPEMENT里面的。

其次,似乎类serial_dev_t在接口i_serial_t定义之前就已经能implement他了,而接口i_serial_t反

过来在自己定义中引用了基类serial_dev_t 。如果你曾经用过类似下面的结构体,就知道蹊跷在哪里

了,同时也知道解决的原理。

 

1 //! 一个无法编译通过的写法
2 typedef struct {
3     ....
4     item_t *ptNext;
5 }item_t;

等效的正确的写法

1 //! 前置声明的例子
2 typedef struct item_t item_t;
3 struct item_t {
4     ...
5     item_t *ptNext;
6 };

可见前置声明是解决这类问题的关键,回头看EXTERN_CLASS_IMPLEMENT的宏,你就会看到前置

声明的结构。以此为例,我来演示一下如何使用参数宏实现方便的前置声明:

1 #define DEF_FORWARD_LIST(__NAME)    2     typedef struct __NAME __NAME;3     struct __NAME {
4 
5 #define END_DEF_FORWARD_LIST(__NAME)  6     };

使用的时候这样

1 DEF_FORWARD_LIST(item_t)
2     ...
3     item_t *ptNext;
4 END_DEF_FORWARD_LIST(item_t)

这只解决了一个遗憾,另外一个疑惑就是为什么可以在参数宏里面插入另一段代码?答案是一直可以,我经常这么干:

1 # define SAFE_ATOM_CODE(...)     {2         istate_t tState = GET_GLOBAL_INTERRUPT_STATE();3         DISABLE_GLOBAL_INTERRUPT();4         __VA_ARGS__;5         SET_GLOBAL_INTERRUPT_STATE(tState);6     }

这是原子操作的宏,使用的时候,只要在“...”的位置写程序就好了,例如:

adc.c

 1 ...
 2 static volatile uint16_t s_hwADCResult;
 3 ...
 4 ISR(ADC_vect)
 5 {
 6     //! 获取ADC的值
 7     s_hwADCResult = ADC0;
 8 }
 9 
10 //! \brief 带原子保护的adc结果读取
11 uint16_t get_adc_result(void) 
12 {
13     uint16_t hwResult;
14     SAFE_ATOM_CODE(
15         hwResult = s_hwResult;
16     )
17     return hwResult;
18 }

adc.h

1 ...
2 //! 可以随时安全的读取ADC的结果
3 extern uint16_t get_adc_result(void);
4 ...

现在看来参数宏里面插入大段大段的代码根本不是问题,问题是当我不想插入的时候怎么办呢?例如这个例子里面,宏

EXTERN_CLASS_IMPLEMENT(_NAME,INTEFACE,...)这里我们真正关心的是 _NAME 和 INTEFACE,而是否插入

其他代码定义结构体里面是不确定的,我们很可能就这么用

1 EXTERN_CLASS_IMPLEMENT(example_t, i_serial_t)
2 ....
3 END_EXTERN_CLASS_IMPLEMENT(example_t, i_serial_t)

显然这时候变长参数就成了关键,幸好C99为我们提供了这个便利,直接在参数宏里面加入“...”在宏本体里面用

_VA_ARGS_就可以代表"..."的内容。

经过这样的介绍,回头看看前面类的定义,根本不算什么。

那么一个类实现(implement)了某个接口,这有神马意义呢?意义如下,我们就可以像正常类那么使用接口提

供的方法了:

 1 //! 假设我们获取了一个名叫“usart0”的串行设备
 2 serial_dev_t *ptDev = get_serial_device("usart0");
 3 
 4 uint8_t chString[] = "Hello World!";
 5 
 6 //! 我们就可以访问这个对象的方法,比如发送字符串
 7 while ( fsm_rt_cpl != 
 8     ptDev->method.write(ptDev, chString, sizeof(chString))
 9 );
10 //! 当然这个对象仍然是被掩码结构体保护的,因为ptDev的另外一个可见的成员是ptDev->chMask,你懂的

接下来我们要处理的问题就是继承和派生。。。哎,绕这么大圈子才切入本文的重点。记得有个谚语的全文叫“博士

卖驴,下笔千言,离题万里,未有驴子。。。”,要实现继承和派生只要借助下面这个装模作样的宏就可以了

1 //! \brief macro for inheritance
2 #define INHERIT(__TYPE)             __TYPE base;

是的他不过是把基类作为新类(结构体)的第一个元素,并起了一个好听的名字base。尼玛太坑爹了吧?没错其实

就是这样,没有什么复杂的,所以我们可以很容易的从serial_dev_t继承并为usart派生出一个类:

 1 #include "serial_device.h"
 2 ... 
 3 EXTERN_CLASS(usart_t) INHERIT(serial_dev_t)
 4 
 5     uint8_t chName[20];                        //!< 保存名字,比如"USART0"
 6     usart_reg_t *ptRegisters;                  //!< 指向设备寄存器
 7     ...
 8 
 9 END_EXTERN_CLASS(usart_t)
10 
11 //! \brief 当然要提供一个函数来返回基类咯
12 extern serial_dev_t *usart_get_base(usart_t *ptUSART);

完成了这些,关于OOC格式上的表面工作,基本介绍完毕。格式毕竟是表面工作,学会这些并不意味让你的代码面向

对象,最多看起来很高档。真正关键的是给自己面向对象的思维模式和训练自己相应的开发方法,这就需要你去看那些

介绍面向对象方法的书了。比如面向对象的思想啊,设计模式啊,uml建模啊还是那句老话,如果过不知道怎么入门看

《UML+OOPC》

 

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。