一、前言
RTThread中出现多种结构体和指针、链表,主要是因为面向对象编程,其中包含的封装、多态、继承等思想在RTThread中都非常重要,C 语言本身是纯面向过程 语言,没有 class、public/private、继承、多态这些关键字,但可以通过「结构体 + 函数指针 + 内存布局 」完美模拟出面向对象的三大核心特性:封装、继承、多态。所以我们这次要学习如何用c语言实现面向对象编程。
二、封装
1.封装的概念
就是把数据和操作数据的函数放到一个模块去,保证同一类的东西放一起,对外只放出给用户使用的函数接口,隐藏内部函数的实现细节。
2.举例:面向过程写法 vs C 模拟面向对象写法
我们用平常我们调用 GPIO 举例,平常我们要使用一个GPIO,要先init一个GPIOx和对应的引脚,然后设置引脚模式,接着用write或者read函数来操控GPIO。这样写的话pin引脚和操作函数就是分离的。
cpp
/* 分散的函数和全局变量 */
void gpio_init(int pin, int mode) { /* 初始化 */ }
void gpio_write(int pin, int value) { /* 写引脚 */ }
int gpio_read(int pin) { /* 读引脚 */ }
把引脚数据和操作函数**打包进一个结构体,**这样子我们要初始化一个引脚,直接创建一个GPIO结构体,调用结构体里面的函数指针就能完成同样的GPIO调用,优势就是pin引脚有了自己的操作函数,更容易理解。
cpp
/* GPIO结构体:数据 + 函数指针 */
struct gpio_dev
{
int pin; /* 数据:引脚号 */
void (*init)(int mode); /* 方法:初始化 */
void (*write)(int val); /* 方法:写引脚 */
int (*read)(void); /* 方法:读引脚 */
};
/* 底层实现函数 */
static void _gpio_init(int mode) { /* 初始化硬件 */ }
static void _gpio_write(int val) { /* 写寄存器 */ }
static int _gpio_read(void) { return 0; }
/* 创建一个GPIO设备对象 */
struct gpio_dev led = {
.pin = 52,
.init = _gpio_init,
.write = _gpio_write,
.read = _gpio_read
};
/* 使用:通过对象调用方法 */
int main(void)
{
led.init(1); /* 初始化输出模式 */
led.write(0); /* 拉低点亮 */
return 0;
}
数据和操作函数都放在结构体里,通过结构体对象来调用操作,这就是封装的本质。
二、继承:层次化 + 代码复用
1.继承的概念
继承,顾名思义,A继承B的东西,我们用类的思想来看,动物一整个大类,特点就是能动,是活的,人类就继承了动物的能动,活着的特点,同时有自己的特点,就是会思考,而狗继承了动物能动,活着的特点,自己的特点就是四肢走路,会吠叫等。
所以在继承的思想中,我们把动物叫做"父类对象",人类和狗叫做"子类对象"。子类会在继承了父类的数据的基础上,拥有自己的特点。
这样的好处就是代码能够复用,并且层次化,便于管理,比如,我们假设有一个电器大类作为父类,相关的函数是通电和断电,灯泡继承电器这个大类的通电和断电的函数,就不需要写一个自己的通电和断电的函数了,只需要写自己特殊的、自有的调节亮度,调节灯光颜色的函数就行了;而空调也继承电器这个大类,同样也不需要再重新写一个通电和断电的函数,只需要写一个调节温度和调整模式的函数就行。这就能体现继承思想中代码复用和层次化的概念。
而在c语言中,继承的关键就在于一个c语言中的知识点:结构体第一个成员的内存地址,和结构体本身的首地址一样。假设父节点结构体大小是20字节,子节点结构体大小是24字节,那么我定义一个子节点结构体,前20个字节就是放到父类结构体,后4个字节放的是自己的数据。
cpp
struct base_device // 大小:20字节
{
char name[16]; // 偏移0
int (*init)(void); // 偏移16
};
struct uart_device // 大小:24字节
{
struct base_device parent; // 前20字节:父类部分
int baudrate; // 偏移20:子类特有,占4字节
};
父类结构体放在子类结构体第一位时:派生类首地址= 基类成员首地址=基类第一个成员地址
cpp
派生类 uart 对象的内存分布:
地址偏移 0 → [基类结构体首地址]
地址偏移 20 → [基类结构体末地址]
地址偏移 24 → [子类自己的数据]
2.举例
功能:定义父类 base、子类型 uart_device;初始化对象,调用父类的 init 接口,注册到全局链表。
下面不采取强制转换的写法,但是在RTThread中,都是采用强制转换,通过把子类结构体转换成父类结构体,简约代码。
cpp
#include <rtthread.h>
// 父类:动物
struct Animal
{
void (*speak)(struct Animal *self);
};
// 子类:猫
struct Cat
{
struct Animal parent; // 放在第一位:继承
int age; // 猫独有:年龄
};
// 子类:狗
struct Dog
{
struct Animal parent; // 放在第一位:继承
int weight; // 狗独有:体重
};
// 猫的叫声实现
static void cat_speak(struct Animal *self)
{
// 向下转型:父类指针转回Cat,才能访问age
struct Cat *cat = (struct Cat *)self;
rt_kprintf("喵!猫年龄:%d\n", cat->age);
}
// 狗的叫声实现
static void dog_speak(struct Animal *self)
{
// 向下转型:父类指针转回Dog,才能访问weight
struct Dog *dog = (struct Dog *)self;
rt_kprintf("汪!狗体重:%d\n", dog->weight);
}
void test_no_cast(void)
{
struct Cat cat;
cat.age = 2;
cat.parent.speak = cat_speak;
struct Dog dog;
dog.weight = 10;
dog.parent.speak = dog_speak;
// 直接取 parent 的地址,没有强制类型转换
cat.parent.speak(&cat.parent);
dog.parent.speak(&dog.parent);
}
下面是采取强制转换的方式,在RTThread源码中,子类继承父类时,使用到父类的函数或者数据,涉及到"向上转型"这个概念,意思就是将子类指针强转转换成父类指针,由于父类结构体放在子类结构体第一位,父类结构体地址与子类结构体地址是一样的,以下面的例子为例,我们强转之后,该结构体内存大小就从animal+独有数据变成了animal,强转后的结构体能访问的范围就变成了只能访问该结构体的第一个成员------也就是父类结构体,相当于整个结构体变成了父类的结构体了,这样就能调用父类的函数了。
cpp
#include <rtthread.h>
// 父类:动物
struct Animal
{
void (*speak)(struct Animal *self);
};
// 子类:猫
struct Cat
{
struct Animal parent; // 放在第一位:继承
int age; // 猫独有:年龄
};
// 子类:狗
struct Dog
{
struct Animal parent; // 放在第一位:继承
int weight; // 狗独有:体重
};
// 猫的叫声实现
static void cat_speak(struct Animal *self)
{
// 向下转型:父类指针转回Cat,才能访问age
struct Cat *cat = (struct Cat *)self;
rt_kprintf("喵!猫年龄:%d\n", cat->age);
}
// 狗的叫声实现
static void dog_speak(struct Animal *self)
{
// 向下转型:父类指针转回Dog,才能访问weight
struct Dog *dog = (struct Dog *)self;
rt_kprintf("汪!狗体重:%d\n", dog->weight);
}
void test_with_cast(void)
{
struct Cat cat;
cat.age = 3;
cat.parent.speak = cat_speak;
struct Dog dog;
dog.weight = 15;
dog.parent.speak = dog_speak;
// 向上强转:子类指针 → Animal*(父类指针)
struct Animal *animal1 = (struct Animal *)&cat;
struct Animal *animal2 = (struct Animal *)&dog;
animal1->speak(animal1);
animal2->speak(animal2);
}
输出
喵!猫年龄:3
汪!狗体重:15
三、多态
多态简单来说,就是一个函数,在不同的情况下使用的效果不同,以此实现函数复用。
C 语言模拟面向对象的继承,本质就是把子类结构体的第一个成员定义为父类结构体变量,让父类和子类的内存首地址重合;这样你既可以直接通过结构体中:子类对象.父类成员.成员名 访问父类内容,也能直接把子类指针强制转成父类指针,使得当前结构体拥有的内存大小符合父类结构体大小,用统一的父类接口去操作不同子类对象,实现多态,但是父类指针只能访问父类里定义的成员,想要访问子类独有成员,需要再向下强制转回子类指针。
多态核心
父类结构体里放的是函数指针,不是写死的函数,子类如果有自己需要的函数,让自己结构体内的父类结构体内的函数指针指向它,重写这个函数指针指向自己的实现;当你 子类对象.父类成员.函数名 当你使用这种方式调用函数指针时,调用的实际是你指向的那个函数,实现同一个父类里的函数指针名字,但是结果不同。
例子
父类定义时只有名字和一个函数指针,统筹了动物"speak"这个动作的函数指针,然后我们定义2种动物,猫和狗,继承动物的成员,加上自己有的成员,再分别写猫和狗的"speak"的函数,然后再main函数创建猫和狗之后,让函数指针分别指向他们的speak函数就行。
关于animal_talk这个函数,这个就是整个多态的入口了,我们想调用猫和狗的speak函数时,我们就把猫和狗的结构体向上强转,用父类的视角去调用,这样就能实现main函数实现的效果,如下,都是调用的animal_talk函数,但是出来的效果不同。
cpp
#include <stdio.h>
// 父类:Animal
typedef struct animal
{
// 函数指针
void (*speak)(struct animal *self);
char name[16];
} animal_t;
// 子类1:Cat,第一个成员必须是父类
typedef struct cat
{
animal_t base; // 放在第一位!继承
int claw_len; // 猫独有成员
} cat_t;
// 子类2:Dog,第一个成员必须是父类
typedef struct dog
{
animal_t base; // 放在第一位!继承
int bark_volume;// 狗独有成员
} dog_t;
// 猫的speak实现
void cat_speak(animal_t *animal)
{
// 向下转型,拿到子类独有数据
cat_t *cat = (cat_t *)animal;
printf("%s:喵喵,爪子长度:%d\n", cat->base.name, cat->claw_len);
}
// 狗的speak实现
void dog_speak(animal_t *animal)
{
dog_t *dog = (dog_t *)animal;
printf("%s:汪汪,音量:%d\n", dog->base.name, dog->bark_volume);
}
// 统一接口函数,只认父类指针!这就是多态核心
void animal_talk(animal_t *animal)
{
animal->speak(animal);
}
int main(void)
{
// 构造猫对象
cat_t mycat = {
.base = {
.speak = cat_speak,
.name = "小黑猫"
},
.claw_len = 5
};
// 构造狗对象
dog_t mydog = {
.base = {
.speak = dog_speak,
.name = "大黄狗"
},
.bark_volume = 80
};
// 多态调用:同一个animal_talk函数,传入不同子类,执行不同代码
animal_talk((animal_t *)&mycat);
animal_talk((animal_t *)&mydog);
return 0;
}
输出
小黑猫:喵喵,爪子长度:5
大黄狗:汪汪,音量:80