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 等
};

我们来实现以下newdelete:

// 要将参数透传给对象的构造函数,所以使用 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 voidstring_dtor(void *_this) {
    struct Stringthis = _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方法的时候,还是执行的 Circledraw方法。

浏览 16
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报