C 语言实现面向对象第一步--对象模型
共 5977字,需浏览 12分钟
·
2021-01-26 14:52
首先申明下,看完这篇文章的一些做法,你可能会觉得很傻x,但是我仅仅是抱着一种尝试和学习的态度,实际中可能也并不会这么去用。
什么是 OOP(Object-oriented Programming, OOP)?
OOP 这种编程范式大概起源于 Simula。
它依赖于:
封装(encapsulation) 继承(inheritance) 多态(polymorphism)。
就 C++、Java 而言,OOP 的意思是利用类层级(class hierarchies)及虚函数进行编程。
从而可以通过精制的接口操作各种类型的对象,并且程序本身也可以通过派生(derivation)进行功能增量扩展。
举个 Bjarne Stroustrup FAQ 用过的栗子:
比如可能有两个(或者更多)设备驱动共用一个公共接口:
class Driver { // 公共驱动接口
public:
virtual int read(char* p, int n) = 0; // 从设备中读取最多 n 个字符到 p
// 返回读到的字符总数
virtual bool reset() = 0; // 重置设备
virtual Status check() = 0; // 读取状态
};
Driver 仅仅是一个接口。
没有任何数据成员,而成员函数都是纯虚函数。
不同类型的驱动负责对这个接口进行相应的实现:
class Driver1 : public Driver { // 某个驱动
public:
Driver1(Register); // 构造函数
int read(char*, int n);
bool reset();
Status check();
// 实现细节
};
class Driver2 : public Driver { // 另一个驱动
public:
Driver2(Register);
int read(char*, int n);
bool reset();
Status check();
// 实现细节
};
这些驱动含有数据成员,可以通过它们创建对象。它们实现了 Driver 中定义的接口。不难想象,可以通过这种方式使用某个驱动:
void f(Driver& d) // 使用驱动
{
Status old_status = d.check();
// ...
d.reset();
char buf[512];
int x = d.read(buf,512);
// ...
}
这里的重点是,f() 不需要知道它使用的是何种类型的驱动;
它只需知道有个 Driver 传递给了它;
也就是说,有一个接口传递给了它。
我们可以这样调用 f() :
void g() {
Driver1 d1(Register(0xf00)); // create a Driver1 for device
// with device register at address 0xf00
Driver2 d2(Register(0xa00)); // create a Driver2 for device
// with device register at address 0xa00
// ...
int dev;
cin >> dev;
if (dev==1)
f(d1); // use d1
else
f(d2); // use d2
// ...
}
当 f() 使用某个驱动时,与该驱动相对应的操作会在运行时被隐式选择。
例如,当 f() 得到 d1 时,d.read() 使用的是 Driver1::read();
而当 f() 得到 d2 时,d.read() 使用的则是 Driver2::read()。
这被称为运行时绑定,在一些动态语言中,鸭子类型(duck typing) 常用来实现这种“多态”— 不关心是什么东西,只要觉得它可以run,就给他写个叫 run
的函数即可。
当然 OOP 也并非万能药。
不能简单地把 “OOP” 等同于“好”。
OOP 的优势在于类层级可以有效地表达很多问题;OOP 的主要弱点在于太多人设法强行用层级模式解决问题。
并非所有问题都应该面向对象。也可以考虑使用普通类(plain class)(也就是常说的 C With Class)、泛型编程和独立的函数(就像数学、C,以及 Fortran 中那样)作为解决问题的方案。
当然,OOP != 封装、继承、多态。
本文仅仅是想讨论下在 C 中如何实现封装、继承、多态。
封装可以借助 struct
,将数据和方法都放到一个结构体内,使用者可以无需关注具体的实现。
一种很直白简单的方式,就是使用函数指针表示成员方法和数据放在一个struct
内。
比如在搜狗开源的服务端框架 Workflow
中就大量使用了这种方式:
这里可以看下 __poller_message
这个结构体:
struct __poller_message
{
int (*append)(const void *, size_t *, poller_message_t *);
char data[0];
};
这里 append
函数指针就算是一个成员方法,这样会非常灵活,你可以给它赋任何一种具体实现。
(PS: char[0] 数组是一种 C 语言中常用技巧,通常放在结构体的最后,常用来构成缓冲区。
使用这样的写法最适合制作动态 buffer,可以这样分配空间:malloc(sizeof(struct XXX)+ buff_len); 这样就直接把 buffer 的结构体和缓冲区一块分配了**。**
用起来也非常方便,因为现在空数组其实变成了buff_len长度的数组了。
感兴趣的可以去看下源码(学习分支):https://github.com/sogou/workflow/tree/study
当然了,这里我选择了模仿 C++ 对象模型,在《Inside the C++ Object Model》中提到了三种对象模型设计思路:
简单对象模型: 对象中只存储每个成员(包括函数和数据)的指针 表格驱动对象模型: 对象中存储两个指针,一个指向存储数据的表,一个指向存储函数指针的表(虚函数的解决方案) C++ 实际对象模型: 对象存储 non-static 数据,static成员(数据和函数) 和 non-static 函数都单独存放(注意,并没有指针指向它们,这可以在编译时自动确定地址), 还有一个虚表指针指向存储虚函数指针的表格(这个表第一个元素可能存放的是 type_info object 以支持RTTI)
那这里选择对象只存储数据本身和函数指针。
我们需要一个创建对象和回收资源的方法,可以抄抄 C++ 的作业,C++ 中构造对象使用的是new
运算符,new
运算符完成了 内存分配 + 调用类构造函数两件事。
delete
则回收资源,主要是调用类的析构函数 + 释放内存。
new()
方法必须知道当前正在创建的是什么类型的对象,在 C++ 中,编译器会自动识别,并生成对应的汇编。
但是在 C 中我们只能手动将类型相关的信息作为参数。
然后在 new 方法内使用一系列的 if 去分别处理每种类型?
这种方法显然不合适,每个对象应该知道怎么构造自己以及如何析构,也就是类型信息应该自带构造和析构函数。
所以设计了一个 Class
类,Class
类包含类的元信息,比如类的大小(分配内存时会用)、构造、析构函数等。
其它所有的类都继承自这个类。
所谓的继承实际上就是将一个Class
类型指针放在第一字段。
很简单,因为只有统一放在对象开头,new
方法内才能识别出这个 Class
类型指针。
所以整个对象模型大概是这个样子:
struct Class {
size_t size; /* size of an object */
void * (* ctor) (void * this, va_list * vl);
void * (* dtor) (void * this);
//.... clone 等
};
我们来实现以下new
和delete
:
// 要将参数透传给对象的构造函数,所以使用 C 语言变长参数
// type 是具体的类类型参数
void * new (const void * type, ...) {
// 因为 Class 放在第一个字段,所以可以直接做截断,转为 Class
const struct Class *class = type;
// 分配对象内存
void *this = calloc(1, class->size);
*(struct Class**)this = class; // 这一步实际上是将每一个类构造出的对象,填充上指向类类型的指针
// 执行构造函数
if(class->ctor) {
// 变长参数,C 语法
va_list vl;
va_start(vl, type);
this = class->ctor(this, &vl);
va_end(vl);
}
return this;
}
// 传入待析构的对象指针
void delete (void * self) {
// 获取 Class 类型指针
const struct Class **this = self;
// 如果有析构函数, 就执行析构
if(self && *this && (*this)->dtor) {
self = (*this)->dtor(self);
}
// 释放内存
free(self);
}
接着,我们基于这个Class
来实现一个 String。
// string.h
// 这就是需要传入 new 函数的第一个参数,类型指针
extern const void * StringNew;
struct String {
const void *class; /* 父类, 都是 Class */
char * content; /* 字符串内容 */
char *(*get_content)(struct String*); // 获取
void (*set_content)(struct String*, const char *); // 设置
};
这是String
的实现:
// string.c
// getter
static char *get_content(struct String *str) {
return str->content;
}
// setter
static void set_content(struct String *str, const char *newcontent) {
if(str->content) {
free(str->content);
}
str->content = strdup(newcontent);
}
// 构造函数
static void* string_ctor(void *_this, va_list *args) {
struct String * this = _this;
// 初始化内容
const char *content = va_arg(*args, const char*);
this->content = strdup(content);
// 设置成员函数指针
this->get_content = get_content;
this->set_content = set_content;
return this;
}
// 析构函数
static void* string_dtor(void *_this) {
struct String* this = _this;
// 释放字符串内存
if(this->content) {
free(this->content);
this->content = NULL;
}
return this;
}
// 定义一个 Class 变量,即 String 类型的 Class
static const struct Class _String = {
sizeof(struct String),
string_ctor,
string_dtor
};
// 然后将 _String 变量取地址赋值给定义在 string.h 的 StringNew
// StringNew 就相当于构造字符串的类模板了,以后需要将这个指针传递给 new 函数
const void *StringNew = &_String;
来看下怎么用吧:
void test_str() {
// 构造
struct String *str = new(StringNew, "test");
printf("%s\n", str->get_content(str));
str->set_content(str, "newtest");
printf("%s\n", str->get_content(str));
// 析构
delete(str);
}
是不是有点那味了?
就是每次都得显示的传 this
参数,这个没办法,语法不支持。
不过应该是可以用宏包一下。
好了,整体的框架已经搭好了,可以基于这种模式去实现继承、多态了。
这部分我就放在第二篇写了,可以自己先去试下,达到大概这种效果:
Circle
继承自Graph
,然后可以将 Circle
对象向上转型为 Graph
,但是Graph
去调用具体 draw
方法的时候,还是执行的 Circle
的 draw
方法。