C 语言实现面向对象第一步

首先申明下,看完这篇文章的一些做法,你可能会觉得很傻x,但是我仅仅是抱着一种尝试和学习的态度,实际中可能也并不会这么去用。

什么是 OOP(Object-oriented Programming, OOP)?OOP 这种编程范式大概起源于 Simula。

它依赖于:

封装(encapsulation)继承(inheritance)多态(polymorphism)。就 C++、Java 而言,OOP 的意思是利用类层级(class hierarchies)及虚函数进行编程。

从而可以通过精制的接口操作各种类型的对象,并且程序本身也可以通过派生(derivation)进行功能增量扩展。

举个 Bjarne Stroustrup FAQ 用过的栗子:

比如可能有两个(或者更多)设备驱动共用一个公共接口:

代码语言:javascript复制class Driver { // 公共驱动接口

public:

virtual int read(char* p, int n) = 0; // 从设备中读取最多 n 个字符到 p

// 返回读到的字符总数

virtual bool reset() = 0; // 重置设备

virtual Status check() = 0; // 读取状态

};Driver 仅仅是一个接口。

没有任何数据成员,而成员函数都是纯虚函数。

不同类型的驱动负责对这个接口进行相应的实现:

代码语言:javascript复制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 中定义的接口。不难想象,可以通过这种方式使用某个驱动:

代码语言:javascript复制 void f(Driver& d) // 使用驱动

{

Status old_status = d.check();

// ...

d.reset();

char buf[512];

int x = d.read(buf,512);

// ...

}这里的重点是,f() 不需要知道它使用的是何种类型的驱动;

它只需知道有个 Driver 传递给了它;

也就是说,有一个接口传递给了它。

我们可以这样调用 f() :

代码语言:javascript复制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这个结构体:

代码语言:javascript复制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 类型指针。

所以整个对象模型大概是这个样子:

代码语言:javascript复制struct Class {

size_t size; /* size of an object */

void * (* ctor) (void * this, va_list * vl);

void * (* dtor) (void * this);

//.... clone 等

};我们来实现以下new和delete:

代码语言:javascript复制// 要将参数透传给对象的构造函数,所以使用 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。

代码语言:javascript复制// 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的实现:

代码语言:javascript复制// 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;来看下怎么用吧:

代码语言:javascript复制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方法。