🏠Homepage🏠 | 🔥GitHub🔥

C 语言中的面向对象

By 「玩火」

其实这是个很无趣的话题,只是我实在不知道实训报告如何水 30 页于是想写写这个然后一稿多用(

没啥有用的内容,看个乐子就好。

我们经常会说:C 是过程式语言,Java、C++ 是面向对象语言。于是常常会有人把面向对象与面向对象语言混为一谈,实际上用 Java、C++ 既可以写出非常过程式的代码也可写出非常函数式的代码。而同样的,面向对象的思想也可融入到 C 语言当中。尽管缺少一些语言特性,但面向对象的思想能为大项目的编写减少很多心智负担。

广义面向对象

上学时老师都会说,面向对象编程的基本特征是「封装」「继承」「抽象」。但看上去这三个特征和「面向对象编程」这个词中的每个元素都没啥关系。而且有些老师还不解释为啥,挺让初学者摸不着头脑。实际上这三个基本特征是从「对象」这个最原始的概念中逐步发掘出来的可以帮助编程时减少心智负担的思想,它们是现代软件工程的基石但并不是面向对象的全部。

既然是「面向对象」就得从「面向」的「对象」说起。什么是「对象」?抽象的说就是数据和对其的操作。数据,在 C 中也就是一块内存;操作,在 C 中就是函数。而一块内存有各种解读方式,可以当成一组 char,也可当成一个复杂的结构体。就比如 C++ 中的 vector 的数据部分在 C 里就可以定义成:

struct vector_t {
    size_t length, capacity, elem_size;
    void *data;
};

当然 C 没法直接把操作像 OO 语言那样放在 struct 里面,只能退而求其次,放弃封装,在每个操作前标记所属对象并手动传 this

typedef struct vector_t *vector;

vector vector_new(size_t elem_size) {
    vector v = malloc(sizeof(struct vector_t));
    v->elem_size = elem_size;
    v->length = 0;
    v->capacity = 16;
    v->data = malloc(elem_size * v->capacity);
    return v;
}

void vector_delete(vector this) {
    free(this->data);
}

void vector_expend(vector this) {
    size_t capa = this->capacity;
    this->capacity *= 2;
    void *data = malloc(this->elem_size * this->capacity);
    memcpy(data, this->data, this->elem_size, capa);
    free(this->data);
    this->data = data;
}

通过 vector 构造出来的对象的数据有着相同的结构,而且数据之上有同样的一组操作。可以说它们是同一类对象,而这样对同一类对象的数据结构和操作的抽象在很多面向对象语言里面就被称为「类」。

想起一件往事,我初中接触 VB6.0 的时候发现新建非窗体文件的时候有两个选项,一个是「模块」,另一个是「类模块」。我当时困惑了很久,「类模块」难道就是长得像模块的东西么,后来才知道其实「类模块」不是模块而是类。这名字起的很是迷惑。高中的时候十六告诉我,VB6.0 其实是个完整的面向对象语言,它甚至还支持继承……

动态派发

同一个类构造出的对象有完全相同的数据结构和操作。而不同类构造出来的对象也可能有语义上的交集。比如数据中有相同含义的字段,比如有语义相同的操作。例如所有对象都有数据占内存空间、深拷贝操作,需要管理内存的对象都有递归回收内存的操作。这些对象的「性质」可以被保存在一个结构体里用来代替原来的对象:

struct object_t {
    void *ref;
    const size_t size;
    void (*deleter)(void*);
};

typedef struct object_t *object;

object vector_object_impl(vector this) {
    object obj = malloc(sizeof(struct object_t));
    obj->ref = this;
    *(&obj->size) = sizeof(struct vector_t);
    obj->deleter = vector_delete;
    return obj;
}

void object_delete(object this) {
    this->deleter(this->ref);
    free(this);
}

object 这样的类在面向对象语言中会被称为 vector 的基类,而且还是个虚基类。实际上 C++ 的继承就是用类似这样的虚函表实现的。从某种角度来说,「类」是对「对象」的抽象,而「基类」则是对「类」的进一步抽象:「类」描述了「对象」的样子,「基类」描述了「类」的样子。

objectdeleter 非常特别,从数据上来看它是一个指针,但从语义上来说它更像是一个操作。而且作为一个操作,它并不只有一个固定实现。调用它可能会删除一个数组,也可能删除一个链表。这就是所谓的「多态」。既然一个操作有多种实现,那就需要通过「派发」来选择调用时使用哪个实现。而这里的 deleter 只有在运行时调用才能知道究竟会采用哪种实现,所以被成为动态派发。相对的,在 C++、Java 中还有静态派发,定义相同名称但输入数据类型不同的操作,通过编译器的静态分析选择运行时将会使用的实现。

设计模式

先来点名人名言:

一种设计模式对应一种语言特性

By 千里冰封

Java 不支持像 C++ 那样一个类继承多个类,于是诞生了代理模式这样绕过这个问题的设计模式。而 C 中没有类,为了良好的抽象,也会形成一种固定的设计模式。设计模式的本质就是编写大量代码之后总结出的常用设计思路,而为了方便编写这些常用设计思路的代码就产生了语言特性。从汇编中的 if&goto 到 C 中的 for 循环,从 C 中的 struct&函数 到 C++ 中的类。这些抽象减轻了人的思维压力,提高了人的生产力。

计算机发展的历史就是人类尝试控制复杂度的历史,运行时有时间复杂度空间复杂度,编写时有代码复杂度和逻辑复杂度。封装抽象出的黑盒系统可以把复杂度分离到不同部分,而每一个部分的复杂度都能控制住人脑能接受的范围。模块化使我们能在不同项目间共享复杂度。而良好的编程习惯可以让代码变得有序,减少代码层面的复杂度。运行时的调度、垃圾收集利用算力分担手工管理的复杂度。

前段时间有群友暴论「应该站在机器的角度设计语言」「程序员应该管理一切」。但是我觉得吧计算机设计出来本身就是完成自动化工作的,用算力分担复杂度并没什么不对的,反倒是在空间复杂度和时间复杂度都能接受的情况下还手工优化有些开倒车的意思了。毕竟工程上最头疼的反而还是逻辑复杂度,先要写出来没 bug 才应该再考虑性能优化,不然不就等同于对空气优化了。再说,就算是优化也应该控制好隔离,尽量不破坏系统的可维护性,否则就算优化到位了项目也会因为难以维护而慢慢更不上时代的技术栈而慢慢腐朽。

在这刀耕火种的时代,还有人怀念过去。

By 红姐

封装

虽然 C 中没有访问控制,但多文件联编时不同头文件之间只有头文件的信息是共享的。利用这个特性可以做到一定程度上的封装。将公开的函数定义放在头文件中,将私有的字段放在单独的结构中并在头文件中仅用 void* 表示。这样就能做到粗糙地隐藏私有函数和私有数据了。

// header
struct student_t {
    void *_private;
};

typedef struct student_t *student;

// implementation
struct student_private {};

student student_new() {
    student s = malloc(sizeof(struct student_t));
    s._private = student_private_new();
}

这样看上去挺麻烦的,需要分离公开字段私有字段,使用时取私有字段还需要两次寻址。对于两次寻址,有一个利用内存布局的改进方式。只要保证所有公开字段在内存分配时全都在前半段,然后在数据结构的后半段追加私有字段。利用私有的结构表能读取到私有字段,而用公开的结构表只能读取到公开字段。

// header
struct student_t {};
typedef struct student_t *student;

// implementation
struct student_full {
    stuct student_t public;
};

student student_new() {
    student s = malloc(sizeof(struct student_full));
}

上面这段代码中 student_t 代表了公开字段部分的结构表,而 student_full 代表了完整对象的结构表,它的开头就是一个完整的 student_t 结构体,这样一来 student_tstudent_full 的公共前缀就拥有了相同的内存布局,而直接把 student_full 的指针转换成 student_t 依然可以正常使用公开字段。不过这个方法依赖 POD 的内存布局特性,在 C++ 中并不能使用。

如果所有字段都是私有字段呢?很多时候对象的字段要么只读要么修改的时候牵一发而动全身只能用方法修改,那对象的所有数据都应当作为私有字段来避免使用者误操作造成对象的数据关系被破坏,这对降低使用字段互相关联的大对象的逻辑复杂度非常管用。既然没有公开字段,那么暴露给使用者的只有指向对象数据的内存空间的指针和对对象的一系列操作。

// header
typedef void *student;

// implentation
struct student_t {};

student student_new() {
    student s = malloc(sizeof(struct student_t));
}

这种思路很出名的一个应用就是 Windows 中的「句柄」,本质上「句柄」就是一个指向操作系统分配的资源的指针,利用操作系统的接口即可使用这些资源。到这里,其实 C 中的封装大概已经走到了尽头。

写到这里我忽然想到了一些事情,我看很多奇奇怪怪的 OOP 教程总不喜欢使用共有字段,明明一个类的字段全是正交的(数据类,Data Class)也要让这些字段全私有然后用 gettersetter,即使这些这些 gettersetter 不包含任何逻辑没有任何意义。现在想想莫非这些教程的写法起源于 C 语言的一些封装思路?

如果是真的那也太古板了吧(汗

不管是设计模式还是编程范式,最重要的果然还是活用啊。

Prototype

以下为整活内容,没有人真的这样用

除了基于「类」这个样板来生成对象,另一些语言有它们自己的想法。比如基于原型(Prototype)的面向对象语言 Javascript 中对象是基于对象生成的,原对象就被称为新对象的原型。如果我们构造一种可以表达任意类型的类型,然后在此基础上构造对象,再从对象生成新对象,就可以体验基于原型的 OOP 了。