OOPC精要——撩开“对象”的神秘面纱
前言:
何为面向过程:
面向过程,本质是“顺序,循环,分支”
面向过程开发,就像是总有人问你要后续的计划一样,下一步做什么,再下一步做什么,意外、事物中断、突发事件怎么做。理论上来说,任何一个过程都可以通过“顺序,循环,分支”来描述出来,但是实际上,很多项目的复杂度,都不是“顺序循环分支”几句话能说清楚的。稍微大一点的项目,多线程,几十件事情并发, 如果用这种最简单的描述方式,要么几乎无法使用,缺失细节太多,要么事无巨细,用最简单的描述,都会让后期复杂度提升到一个爆炸的状态。
何为面向对象:
面向对象,本质是“继承,封装,多态”
面向对象的核心是把数据和处理数据的方法封装在一起。面向对象可以简单的理解为将一切事物模块化 ,面向对象的代码结构,有效做到了层层分级、层层封装,每一层只理解需要对接的部分,其他被封装的细节不去考虑,有效控制了小范围内信息量的爆炸。然而当项目的复杂度超过一定程度的时候,模块间对接的代价远远高于实体业务干活的代价, 因为面向对象概念的层级划分,要实现的业务需要封装,封装好跟父类对接。多继承是万恶之源,让整个系统结构变成了网状、环状,最后变成一坨乱麻。
Erlang 的创建者 JoeArmstrong 有句名言:
面向对象语言的问题在于,它们依赖于特定的环境。你想要个香蕉,但拿到的却是拿着香蕉的猩猩,乃至最后你拥有了整片丛林。
能解决问题的就是最好的:
程序设计要专注于“应用逻辑的实现”本身,应该尽量避免被“某种技术”分心 。《UNIX编程艺术》,第一原则就是KISS原则,整本书都贯彻了KISS(keep it simple, stupid!) 原则。写项目、写代码,目的都是为了解决问题。而不是花费或者说浪费过多的时间在考虑与要解决的问题完全无关的事情上。不管是面向过程,还是面向对象,都是为了解决某一类问题的技术。各有各的用武之地:
在驱动开发、嵌入式底层开发这些地方,面向过程开发模式,干净,利索,直观,资源掌控度高。在这些环境,面向过程开发几乎是无可替代的。
在工作量大,难度较低、细节过多、用简单的规范规则无法面面俱到的环境下,用面向对象开发模式,用低质量人力砸出来产业化项目。
1、面向对象编程
面向对象只是一种设计思路,是一种概念,并没有说什么C++是面向对象的语言,java是面向对象的语言。C语言一样可以是面向对象的语言,Linux内核就是面向对象的原生GNU C89编写的,但是为了支持面向对象的开发模式,Linux内核编写了大量概念维护modules,维护struct的函数指针,内核驱动装载等等机制。而C++和java为了增加面向对象的写法,直接给编译器加了一堆语法糖。
2、什么是类和对象
在C语言中,结构体是一种构造类型,可以包含若干成员变量,每个成员变量的类型可以不同;可以通过结构体来定义结构体变量,每个变量拥有相同的性质。
在C++语言中,类也是一种构造类型,但是进行了一些扩展,可以将类看做是结构体的升级版,类的成员不但可以是变量,还可以是函数;不同的是,通过结构体定义出来的变量还是叫变量,而通过类定义出来的变量有了新的名称,叫做对象(Object)在 C++ 中,通过类名就可以创建对象,这个过程叫做类的实例化,因此也称对象是类的一个实例(Instance) 类的成员变量称为属性(Property),将类的成员函数称为方法(Method)。在C语言中的使用struct这个关键字定义结构体,在C++ 中使用的class这个关键字定义类。
结构体封装的变量都是 public 属性,类相比与结构体的封装,多了 private 属性和 protected 属性, private 和protected 关键字的作用在于更好地隐藏了类的内部实现 ,只有类源代码才能访问私有成员,只有派生类的类源代码才能访问基类的受保护成员,每个人都可以访问公共成员。这样可以有效的防止可能被不知道谁访问的全局变量。
结构体封装的变量都是 public 属性,类相比与结构体的封装,多了 private 属性和 protected 属性, private 和protected 关键字的作用在于更好地隐藏了类的内部实现 ,只有类源代码才能访问私有成员,只有派生类的类源代码才能访问基类的受保护成员,每个人都可以访问公共成员。这样可以有效的防止可能被不知道谁访问的全局变量。
C语言中的结构体:
1//通过struct 关键字定义结构体
2struct object
3{
4 char *name;
5 //指向函数的指针类型
6 void (*setname)(struct object *this,char *name);
7};
8void setname(struct object *this,char *name)
9{
10 this->name=name;
11}
C++语言中的类:
1//通过class关键字类定义类
2class object{
3 public:
4 void setname(char *name);
5 private:
6 char *name;
7};
8void object::setname(char *name){
9 this->name = name;
10}
3、内存分布的对比
不管是C语言中的结构体或者C++中的类,都只是相当于一个模板,起到说明的作用,不占用内存空间;结构体定义的变量和类创建的对象才是实实在在的数据,要有地方来存放,才会占用内存空间。
结构体变量的内存模型:
结构体的内存分配是按照声明的顺序依次排列,涉及到内存对齐问题。
为什么会存在内存对齐问题,引用傻孩子公众号裸机思维的文章《漫谈C变量——对齐》加以解释:
在ARM Compiler里面,结构体内的成员并不是简单的对齐到字(Word)或者半字(Half
Word),更别提字节了(Byte),结构体的对齐使用以下规则:整个结构体根据结构体内对齐要求最大的那个元素来对齐。比如,整个结构体内部对齐要求最大的元素是希望对齐到WORD,那么整个结构体就默认对齐到4字节。
结构体内部,成员变量的排列顺序严格按照定义的顺序进行。
结构体内部,成员变量自动对齐到自己的大小——这就会导致空隙的产生。
结构体内部,成员变量可以通过 attribute ((packed))单独指定对齐方式为byte。
strut对象的内存模型:
1//通过struct 关键字定义结构体
2struct {
3 uint8_t a;
4 uint16_t b;
5 uint8_t c;
6 uint32_t d;
7};
memory layout:
成员变量在堆区或栈区分配内存,成员函数放在代码区。对象的大小只受成员变量的影响,和成员函数没有关系。对象的内存分布按照声明的顺序依次排列,和结构体非常类似,也会有内存对齐的问题。
可以看到结构体和对象的内存模型都是非常干净的,C语言里访问成员函数实际上是通过指向函数的指针变量来访问(相当于回调),那么C++编译器究竟是根据什么找到了成员函数呢?
实际上C++的编译代码的过程中,把成员函数最终编译成与对象无关的全局函数,如果函数体中没有成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可。
如果成员函数中使用到了成员变量该怎么办呢?成员变量的作用域不是全局,不经任何处理就无法在函数内部访问。
C++规定,编译成员函数时要额外添加一个this指针参数,把当前对象的指针传递进去,通过this指针来访问成员变量。
this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。
这样通过传递对象指针完成了成员函数和成员变量的关联。这与我们从表明上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象。
无论是C还是C++,其函数第一个参数都是一个指向其目标对象的指针,也就是this指针,只不过C++由编译器自动生成——所以方法的函数原型中不用专门写出来而C语言模拟的方法函数则必须直接明确的写出来。
4 掩码结构体
在C语言的编译环境下,不支持结构体内放函数体,除了函数外,就和C++语言里定义类和对象的思路完全一样了。还有一个区别是结构体封装的对象没有好用的private 和protected属性,不过C语言也可以通过掩码结构体这个骚操作来实现private 和protected的特性。
注:此等操作并不是面向对象必须的,这个属于锦上添花的行为,不用也不影响面向对象。
先通过一个例子直观体会一下什么是掩码结构体,以下例子来源为:傻孩子的PLOOC的readme,作者仓库地址:https://github.com/GorgonMeducer/PLOOC
1typedef struct __byte_queue_t {
2 uint8_t *pchBuffer;
3 uint16_t hwBufferSize;
4 uint16_t hwHead;
5 uint16_t hwTail;
6 uint16_t hwCount;
7}__byte_queue_t;
8
9typedef struct {
10 uint8_t chMask [sizeof(struct __byte_queue_t)];
11} byte_queue_t;
您甚至可以这样做…如果您对内容很认真的话
1typedef struct byte_queue_t {
2 uint8_t chMask [sizeof(struct {
3 uint32_t : 32;
4 uint16_t : 16;
5 uint16_t : 16;
6 uint16_t : 16;
7 uint16_t : 16;
8 })];
9} byte_queue_t;
通过这个例子,我们可以发现给用户提供的头文件,其实是一个固态存储器,即使用字节数组创建的掩码,用户通过掩码结构体创建的变量无法访问内部的成员,这就是实现属性私有化的方法。至于如何实现只有类源代码才能访问私有成员,只有派生类的类源代码才能访问基类的受保护成员的特性,这里先埋个伏笔,关注本公众号,后续文章再深入探讨。
还回到掩码结构体本身的特性上,可以发现一个问题,单纯的掩码结构体丢失了结构体的对齐信息:
因为掩码的本质是创建了一个
chMask
数组,我们知道数组是按照元素对齐的,因此数组chMask
对齐到字节,又由于chMask
是结构体byte_queue_t
的中的对齐要求最大的那个元素(也是唯一元素),因此整个结构体的对齐就是按字节对齐;通过分析容易发现,原本的结构体中对齐要求最高的元素是指针
pchBuffer
,由于它要求对齐到word,因此整个结构体都是按照Word对齐的。当你用掩码结构体声明结构体变量的时候,这个变量多半不是对齐到word的而是对齐到了任意的字节地址上。更具文章《漫谈C变量——对齐(1)》和《漫谈C变量——对齐(2)》中的介绍,当我们我们用指针访问结构体时,如果指针默认的对齐方式与对象实际的对齐方式不符时,就会引发“非对齐访问”——轻则性能下降,重则触发hardfault。
为了解决这个问题,可以利用 __alignof__()
来获取__byte_queue_t
的对齐值,再使用__attribute__((align))
来指定chMask
的对齐方式。改进如下:
1typedef struct __byte_queue_t {
2 uint8_t *pchBuffer;
3 uint16_t hwBufferSize;
4 uint16_t hwHead;
5 uint16_t hwTail;
6 uint16_t hwCount;
7}__byte_queue_t;
8
9typedef struct byte_queue_t {
10 uint8_t chMask [sizeof(__byte_queue_t)]
11 __attribute__((aligned(__alignof__(__byte_queue_t))));
12} byte_queue_t;
这部分理解起来可能稍微有点复杂,但是不理解也没关系,现在先知道有这个东西,后续文章还会有更骚的操作来更直观的实现封装、继承和多态!
5 C语言实现类的封装
如果你趟过了掩码结构体那条河,那么恭喜你,你已经成功上岸了。我们继续回到面向对象的问题上,面向对象的核心是把数据和处理数据的方法封装在一起。封装并不是只有放在同一个结构体里这一种形式,放在同一个接口头文件里(也就是.h)里,也是一种形式——即,一个接口头文件提供了数据的结构体,以及处理这些数据的函数原型声明,这已经完成了面向对象所需的基本要求。下边将通过C语言的具体实例加以说明。
假设我们要封装一个基于字节的队列类,不妨叫做byte_queue_t
,因此我们建立了一个类文件byte_queue.c
和对应的接口头文件byte_queue.h
。
byte_queue.h
1//! the original structure in class source code
2//! the masked structure: the class byte_queue_t in header file
3typedef struct byte_queue_t {
4 uint8_t chMask [sizeof(struct {
5 uint8_t *pchBuffer;
6 uint16_t hwBufferSize;
7 uint16_t hwHead;
8 uint16_t hwTail;
9 uint16_t hwCount;
10 })] __attribute__((aligned(__alignof__(struct {
11 uint8_t *pchBuffer;
12 uint16_t hwBufferSize;
13 uint16_t hwHead;
14 uint16_t hwTail;
15 uint16_t hwCount;
16 }))));
17} byte_queue_t;
18...
19extern bool queue_init(byte_queue_t *ptQueue,
20 uint8_t *pchBuffer,
21 uint16_t hwSize);
22extern bool enqueue(byte_queue_t *ptQueue, uint8_t chByte);
23extern bool dequeue(byte_queue_t *ptQueue, uint8_t *pchByte);
24extern bool is_queue_empty(byte_queue_t *ptQueue);
25...
byte_queue.c
1#include "./queue.h"
2
3//! the original structure in class source code
4typedef struct __byte_queue_t {
5 uint8_t *pchBuffer;
6 uint16_t hwBufferSize;
7 uint16_t hwHead;
8 uint16_t hwTail;
9 uint16_t hwCount;
10} __byte_queue_t ;
需要注意的是,这里之所以不像前面那样首先定义类型__byte_queue_t,然后在掩码结构体byte_queue_t的定义中直接使用__byte_queue_t来计算数组的大小并取得对齐方式,是因为:
__byte_queue_t 里包含了类的成员信息,我们不希望用户能够直接访问这些成员;
用户使用模块时只会包含 byte_queue.h,因此必然不能直接把__byte_queue_t放置到该头文件中;
基于上述考虑,byte_queue.h 的掩码结构体定义只能自己再抄写一份;
目前这种方式是“防君子不妨小人的”,但如果我们真正不想暴露任何成员信息给用户时,可以考虑使用前面介绍过的完全抹去成员变量名称的方式——在这种情况下就更不能将__byte_queue_t 放置到 byte_queue.h 中了。
可以看到,实际上类型byte_queue_t
是一个掩码结构体,里面只有一个起到掩码作用的数组chMask
,其大小、对齐方式和真正后台的的类型__byte_queue_t
相同——这就是掩码结构体实现私有成员保护的秘密。 解决了私有成员保护的问题,剩下还有一个问题,对于byte_queue.c
的函数来说byte_queue_t
只是一个数组,那么正常的功能要如何实现呢?下面的代码片断将为你解释一切:
1...
2#define __class(__NAME) __##__NAME
3#define class(__NAME) __class(__NAME)
4#ifndef this
5# define this (*ptThis)
6#endif
7
8bool is_queue_empty(byte_queue_t *ptQueue)
9{
10 class(byte_queue_t) *ptThis = (class(byte_queue_t) *)ptQueue;
11 if (NULL == ptQueue) {
12 return true;
13 }
14 return ((this.hwHead == this.hwTail) && (0 == this.hwCount));
15}
16...
可以从这里看出来,只有类的源文件才能看到内部使用的结构体,而掩码结构体是模块内外都可以看到的,简单来说,如果实际内部的定义为外部的模块所能直接看见,那自然就没有办法起到保护作用。
从编译器的角度来说,这种从byte_queue_t
到__byte_queue_t
类型指针的转义是逻辑上的,并不会因此产生额外的代码,简而言之,使用掩码结构体几乎是没有代价的。
再次强调:实现面向对象,掩码结构体并不是必须的,只是锦上添花,所以不理解的话,也不要纠结
想要更深入了解C语言面向对象的思想,建议参考的书籍:《UML+OOPC嵌入式C语言开发精讲》
如果你觉得文章还不错,就请点击右上角选择发送给朋友或者转发到朋友圈。您的支持和鼓励是我们最大的动力。喜欢就请关注我们吧~
长按二维码
关注我们