C语言面向对象(上):面向对象三大特性的实现

目录

C语言面向对象

为什么要面向对象

C语言作为一门面向过程的高级语言,具有非常高的运行效率,但相对来说它的封装和扩展性能就没有那么强。为了能够写出具有足够封装性和扩展性的C语言程序,我们就需要用面向对象的思想来编写C语言程序。

有人可能会觉得面向对象的效率低,但事实上C语言运行已经十分高效,面向对象的编程方式并不会带来非常显著的效率下降。其次,当工程所涉及的模块越来越多,功能越来越复杂的时候,面向对象就成为了必然选择。

面向对象可以降低代码耦合度。你是否遇到过为了加某一新功能,牵一发而动全身,引入了诸多BUG不说,最后还没能完全支持新功能。这种情况大概率是因为函数及变量各处随意调用导致代码耦合度过高,或者函数接口设计不当,不得不持续引入新的函数接口以实现新功能。因此能否基于面向对象的思想编写高扩展性和可维护性的C程序越来越成为判断一名C语言程序员水平高低的标准之一。

下面将介绍几种简单的C语言面向对象的方法,这些方法仅根据我个人经验总结得出,如有谬误欢迎指正。

面向对象三大特性的实现

面向对象的编程思想具有三大特征:封装、多态、继承。

封装

按我的理解,封装就是把具有相同性质的变量、函数及接口统一管理,只能通过某个渠道才能访问里面的内容。好比是一个存放了各种东西的仓库,只能用特定钥匙才能打开它并使用仓库里存放的东西,这个仓库就是对里面存放东西的封装,外面看不到里面到底有什么。以JAVA为例,JAVA的封装性体现在类(class)、文件(.java)和包(package)上。

类是能够体现封装性最重要的特征之一,JAVA中一个类的非静态成员可以通过该类的实例对象访问,而在C语言中,就需要结构体来承担JAVA中类的职责,所谓类的实例对象,在C中就是以该结构体为变量类型的变量,很多时候我们会用typedef来将一个结构体定义为一个类型,类型命名时常以_t作为结尾。但与JAVA的类不同的是,访问一个C的结构体可以通过一个变量或者是该结构体类型的指针。

typedef struct {
	int a;
	char b;
} example_t;
// 实例对象
example_t obj = {
		.a = 1, 
		.b = 'b'
		};
// 指针形式
example_t *p_obj = (example_t *)malloc(sizeof(example_t));
p_obj->a = 2;
p_obj->b = 'z';

上面只是变量的实现方法,那么有没有办法在结构体内包含一个函数呢?C语言中虽然不能直接将一个函数放到结构体里面,但是可以通过函数指针实现,而这个函数指针类似于是JAVA中的抽象函数,指定了函数返回值类型以及参数列表个数及其类型,但其本质仍是一个指针,我们需要为该函数指针赋值,即将其指向同类型的函数地址

typedef struct {
	int a;
	char b;
	int (*func)(int, int);
} example_t;

int get_sum(int x, int y)
{
	return x + y;
}

void test(void)
{
	example_t obj = {
		.a = 1,
		.b = 'c',
		.func = get_sum
	};
	// 返回值为9
	int ret = obj.func(4, 5);
}

上例中,*func表示返回值为int,参数列表有两个参数,都是int,符合这个特征的函数指针,我们可以看到get_sum刚好符合这个条件,我们就可以把get_sum的函数地址赋值给func,当通过obj调用func时,实际上会跳转到get_sum的地址,并开始执行get_sum中的代码。通过上例我们不难看出,只要符合*func所指定类型的,不管函数内部执行什么都可以,那么我们就也可以用不同的函数去赋值,实现不同的功能。

typedef struct {
	int a;
	char b;
	int (*func)(int, int);
} example_t;

int get_sum(int x, int y)
{
	return x + y;
}

int get_minus(int x, int y)
{
	return x - y;
}

void test(void)
{
	example_t obj = {
		.a = 1,
		.b = 'c',
		.func = get_sum
	};
	// 返回值为9
	int ret1 = obj.func(4, 5);
	// 改变指向的函数
	obj.func = get_minus;
	// 返回值变为-1
	int ret2 = obj.func(4, 5);
}

至此我们可以看到C语言的结构体也可以实现类似于JAVA中类的功能,甚至实现了部分多态的功能。

文件

文件也是封装性的体现之一,为什么这么说呢?对于一个.c文件,如果其他文件想要调用它的全局变量或者函数等,可以通过extern关键字声明后使用。如果我们希望一些变量或者函数只能够在本文件中使用的时候(即不希望被外界调用),那么可以通过static关键字修饰,可以理解为static关键字修饰的变量或函数被封装到这个.c文件里面了,只允许该.c文件自己使用,这样可以避免跨文件随意调用造成的耦合。

// 在文件1中定义了变量a,sum和被static修饰的minus函数
int a = 1;
int sum(int a, int b)
{
	return a + b;
}
static int minus(int a, int b)
{
	reutn a - b;
}
// 在文件2中调用文件1中的a和sum函数
void test(void)
{
	// 在函数内extern表示该变量或函数的作用域仅限于该函数,可防止扩大其作用域,破坏封装性
	extern int a;
	extern int sum(int a, int b);
	// 被static修饰的变量或函数不能extern
	// extern int minus(int a, int b);
	int res = sum(a + b);
}

JAVA中通过import引入jar包,C语言中则是通过include头文件引入头文件中定义的函数、类型等。因此只有那些允许被其他文件使用的函数、变量、类型、宏等才应该被放入.h头文件中,即意味着放入头文件中的内容将被公开。如果把只有内部(即.h对应的.c文件)才需要用到的一些函数或变量等放入头文件,一方面会引起调用者的困惑,一堆乱糟糟的函数不知道那些需要被调用,另一方面,调用者可能因错误调用本不应被公开的函数等,导致内部状态等改变,进而引起程序执行出错。所以当我们用面向对象的思路编程时,一定要非常小心哪些内容应放到头文件,可以被公开,而哪些不需要,那么不需要的最好可以通过static修饰。

// algorithm.c源文件
static int sum(int a, int b)
{
	return a + b;
}
static minus(int a, int b)
{
	return a - b;
}
static int multiple(int a, int b)
{
	return a * b;
}
int calculate(int a, int b, int c)
{
	return multiple(sum(a, b), minus(b, c));
}
// algorithm.h头文件
int calculate(int a, int b, int c);
// test.c源文件
#include "algorithm.h"
void test(void)
{
	int res = calculate(2, 6, 3);
}

注意上述例子是在.c源文件中include,那么和.h头文件中include有什么区别呢?假设我们在test.h文件中include了algorithm.h,再由test.c include test.h,我们也能实现相同的功能,但是,如果我们还有一个app.h头文件include了test.h,那么algorithm.h中定义的内容也会泄露到app.h中形成连锁反应,所以如果只是源文件用到了某个头文件,尽量不要用下面例子中在它自身的头文件中去include,而是像上面代码一样,在源文件中include,否则非常容易导致循环依赖,导致编译不通过,比如algorithm.h又include了app.h,就会形成循环依赖,相当于《我include了我自己》。

// algorithm.h头文件
#incluide "app.h"
int calculate(int a, int b, int c);
// test.h头文件
#include "algorithm.h"

int test(void);
// test.c源文件
#include "test.h"
int test(void)
{
	return calculate(2, 6, 3) + 5;
}
// app.h头文件
/* 这里相当于同时include了"algorithm.h"
 * "algorithm.h"和"app.h"形成了循环依赖,非常容易出错 */
#include "test.h"

封装性小结

JAVA中的类对应C中的结构体,private作用域相当于是加了static的函数,default的作用域类似于没有加static但是也没有在头文件中声明的函数,其他文件仍可通过extern引用,而头文件中的内容则可以认为是public的内容。

继承

继承即是在父类或者基类的基础上,由子类继承其变量、函数类型,并根据需求进行扩充。一般父类中定义的是所有子类都具有的属性或者通用的方法,通过继承,我们可以规范子类成员的类型的方法。但目前我并没有遇到C语言中可以实现继承的比较好的方式,只能将父类,即父结构体,以成员的形式放在子结构体中

// 父结构体
typedef struct {
	const char *name;
	int age;
} people_t;
// 子结构体1
typedef struct {
	people_t base;
	const char *school;
} student_t;
// 子结构体2
typedef struct {
	people_t base;
	int salary;
	int (*earn_money)(void);
} adult_t;
// 孙结构体
typedef struct {
	adult_t base;
	bool has_beard;
} man_t;

我们知道,在C语言中,结构体其实是存储了信息的一片空间,结构体内部的变量或者指针等,都是只是用来标识该信息存放相对于结构体起始位置的偏移以及所占空间大小,以上面孙结构体为例,其实际结构体存储的内容为:

typedef struct {
	const char *name;
	int age; // people_t 类型访问边界
	int salary;
	int (*earn_money)(void); // adult_t 类型访问边界
	bool has_beard; // man_t 类型访问边界
} man_t;

如果我们有一个man_t类型的变量x,要访问其age成员,那么我们需要调用x.base.base.age,还有一种方法是将其强转为people_t类型,并通过people_t指针访问age成员

void test(void)
{
	man_t x;
	// 一般访问方式
	x.base.base.age = 30;
	// 父类型指针方式
	people_t *peo = (people_t *)&x;
	peo->age = 30;
}

以此类推,当我们传入一个函数的时候,也可以用父结构体指针的方式来扩展可接受的参数类型,相当于JAVA中可以通过父类对象接受子类对象,如

void init_people(people_t *peo)
{
	if(peo == NULL)
		return;
	peo->name = "default";
	peo->age = "20";
}

void test(void)
{
	man_t man;
	adult_t adt;
	student_t stu;
	init_people((people_t *)&man);
	init_people((people_t *)&adt);
	init_people((people_t *)&stu);
}

(ps. 其实C语言中我们甚至还可以将父类对象强转为子类对象,不过这种方式有一定访问非法地址的风险,所以需要有限制条件,会在后续内容中提到)

多态

所谓多态,用更加通俗一点的理解就是通过相同的接口,达到实现不同功能的目的。JAVA中多态一般体现为重写(Override),它是基于继承机制的一个操作,即在不同子类中根据需求重写父类的一个接口,以实现不同功能。在C中,有三种方式可以实现多态。

函数指针

函数指针已经在介绍C语言封装的时候提到了,函数指针规定了所指向函数的类型,即统一了函数接口,并可以通过该函数指针跳转对应函数。

typedef struct {
	int pre_val;
	int (*func)(int a, int b);
} alg_t;

int sum(int a, int b)
{
	return a + b;
}

int munus(int a, int b)
{
	return a - b;
}

void test(void)
{
	// 实例对象alg1的func函数指针指向了sum函数
	alg_t alg1 = {
		.pre_val = 0,
		.func = sum
	};
	// 实例对象alg2的func函数指针指向了minus函数
	alg_t alg2 = {
		.pre_val = 0,
		.func = minus
	};
	// 调用不同对象的同一接口,实现不同功能
	alg1.pre_val = alg1.func(4, 5);
	alg2.pre_val = alg2.func(4, 5);
}

父结构体指针

这种方式其实更加接近函数的重载,但不能改变参数个数。当一个接口中允许传入的参数是一个父结构体指针时,事实上我们也可以传入一个子结构体指针。

typedef enum {
	DEFAULT,
	STUDENT,
	TEACHER
} role_t;
// 父结构体
typedef struct {
	const char *name;
	int age;
	role_t role; // 用来标识子结构体类型
} people_t;
// 子结构体1
typedef struct {
	people_t base;
	const char *school;
	int score;
} student_t;
// 子结构体2
typedef struct {
	people_t base;
	const char *course;
} teacher_t;

void init_people(people_t *peo)
{
	if (peo == NULL)
		abort();
	// 判断子结构体类型
	switch (peo->role) {
	case DEFAULT:
		peo->name = "Unkown";
		peo->age = -1;
	case STUDENT:
		// 注意这里将父结构体指针强转为子结构体指针务必保证类型正确,否则可能会访问非法地址及数据溢出风险
		student_t *stu = (student_t *)peo;
		stu->name = "John";
		stu->age = 10;
		stu->school = "Tsinghua"
		stu->score = 99;
		break;
	case TEACHER:
		// 注意这里将父结构体指针强转为子结构体指针务必保证类型正确,否则可能会访问非法地址及数据溢出风险
		teacher_t *tea = (teacher_t *)peo;
		tea->name = "Mike";
		tea->age = 35;
		tea->course= "C language";
		break;
	default:
		abort();
	}
}

void test(void)
{
	student_t stu;
	teacher_t tea;
	// 这里需要正确标识子结构体类型,才能将子结构体强转回父结构体
	stu.base.role = STUDENT;
	tea.base.role = TEACHER;
	init_people((people_t *)&stu);
	init_people((people_t *)&tea);
}

以此类推,其实上文中的父结构体指针也可以被void *代替,这样我们的父结构体就不一定必须要在头文件中定义,但此时父结构体中的内容在子结构体中必须单独自己定义,单独定义时因为没有任何约束条件,所以必须同父结构体的变量类型、顺序等相同,即在存储中的存储格式相同。

// inti.h头文件
typedef enum {
	STUDENT,
	TEACHER
} role_t;
// 子结构体1
typedef struct {
	// 父结构体中的字段
	const char *name;
	int age;
	role_t role; // 用来标识子结构体类型
	// 子结构体中额外的字段
	const char *school;
	int score;
} student_t;
// 子结构体2
typedef struct {
	// 父结构体中的字段
	const char *name;
	int age;
	role_t role; // 用来标识子结构体类型
	// 子结构体中额外的字段
	const char *course;
} teacher_t;

void init_people(void *peo);
// init.c源文件
#include "init.h"
void init_people(void *peo)
{
	if (peo == NULL)
		abort();
	people_t people = (people_t *) peo;
	// 判断子结构体类型
	switch (people ->role) {
	case STUDENT:
		// 注意这里将父结构体指针强转为子结构体指针务必保证类型正确,否则可能会访问非法地址及数据溢出风险
		student_t *stu = (student_t *)peo;
		stu->name = "John";
		stu->age = 10;
		stu->school = "Tsinghua"
		stu->score = 99;
		break;
	case TEACHER:
		// 注意这里将父结构体指针强转为子结构体指针务必保证类型正确,否则可能会访问非法地址及数据溢出风险
		teacher_t *tea = (teacher_t *)peo;
		tea->name = "Mike";
		tea->age = 42;
		tea->course= "C language";
		break;
	default:
		abort();
	}
}
// test.c源文件
#include "init.h"
void test(void)
{
	student_t stu;
	teacher_t tea;
	// 这里需要正确标识子结构体类型,才能将子结构体强转回父结构体
	stu.role = STUDENT;
	tea.role = TEACHER;
	init_people(&stu);
	init_people(&tea);
}

void *代替父结构体指针的好处是,父结构体不必被暴露出来,即上层调用者(这里是test.c)不会传入一个父结构体的对象。但是缺陷是传入的数据类型不明显,上层调用者有可能会传入其他奇怪的指针。另外,不管是父结构体指针还是void *这样做虽然增强了接口函数的通用性,但是还是会有指向非法地址或者溢出风险,比如上层调用者将role配错,就有可能引起不可预料的风险,因此需要谨慎使用。

弱函数

GUN C支持弱函数,可以通过在函数名前加__attribute__((weak))来表示一个函数是弱函数,弱函数一般用来实现一个接口的默认功能,当有一个返回值、函数名、参数列表完全相同的函数在其他地方被定义是,该弱函数会被覆盖,也就相当于JAVA中的重写(override),但是弱函数的方式只能允许同时定义一弱一强两个函数,不能够重复多次使用。在下面例子中,只有当宏定义OVERRIDE_EN是非零时,强函数才会被定义,并覆盖弱函数,否则将执行弱函数。

#define OVERRIDE_EN		1
__attribute__((weak)) int calc(int a, int b)
{
	return a + b;
}
#if OVERRIDE_EN
int calc(int a, int b)
{
	return a - b;
}
#endif
void test(void)
{
	// 当OVERRIDE_EN定义为非零时,结果为-1, 当OVERRIDE_EN未定义或为0时,结果为9
	int res = calc(4, 5);
}

总结

本文简单介绍了一些C语言实现面向对象的方法,可见C语言也不是不能实现一些面向对象才有的特性。至于这些面向对象的方法到底有什么用?下一篇文章将会以驱动设计为例,简单介绍一些应用的例子。(撰写中)

上一篇:Python 如何正确使用静态方法和类方法?


下一篇:【经典算法题】根据身高重建队列