技术杂谈:为什么要用 C 语言写面向对象程序?

PUBLISHED ON APR 25, 2018

    虽然 C 语言没有直接支援面向对象程序的语法,但我们可以在一些真实世界的项目看到具有面向对象思维的 C 程序代码,一些知名的例子像是 Linux 核心和 GTK+ 等。然而,我们在 C 语言的教材很少直接探讨这个议题,大部分教材的态度是在教完基本的 C 语法后就转往 C++,像是 C 程序设计艺术 (全华图书) 的前面是 C 语言,后面就塞入 C++ 相关的内容来增加版面。那么,我们为什么要用 C 语言写面向对象程序呢?

    面向对象是一种程序设计的范式 (paradigm),理论上是抽象的程序设计思维,但却会受到程序语言特性的影响,从每个程序语言的语法就可以看到不同程度的物件特性。对面向对象来说,最基本的是数据 (data) 和行为 (behavior) 连动所带来的状态 (state) 改变;再进一步就是封装 (encapsulation)、组合 (composition)、继承 (inheritance)、多态 (polymorphism) 等特性;一些相对次要的特性包括构造函数 (constructor)、运算符重载 (operator overloading) 等。如果仔细观察不同语言,就会发现不同语言对上述特性的支援度不同。

    对于 C 语言来说,如果要创造新的复合类型,就是用结构 (struct) 将该类型的属性 (fields) 包起来。以下我们以二维空间的点 (point) 为例,来建立一个类和相关的方法,这是一个常见的例子:

    #include <stdlib.h>
    
    // Declare Point class.
    typedef struct point {
        double x;
        double y;
    } Point;
    
    // The constructor of Point.
    Point* point_new(double x, double y)
    {
        Point* self = (Point*) malloc(sizeof(Point));
        
        self->x = x;
        self->y = y;
        
        return self;
    }
    
    // The getter of x.
    double point_x(Point* self)
    {
        return self->x;
    }
    
    // The setter of x.
    void point_set_x(Point* self, double x)
    {
        self->x = x;
    }
    
    // The getter of y.
    double point_y(Point* self)
    {
        return self->y;
    }
    
    // The setter of y.
    void point_set_y(Point* self, double y)
    {
        self->y = y;
    }
    
    // The destructor of Point.
    void point_free(Point* self)
    {
        if (self) {
            free(self);
            self = NULL;
        }
    }
    

    在我们这个例子中,Point 类内的属性和方法已经有基本的连动,但除此之外,就什么都没有;我们没用 opaque pointer 将物件封装,也没有实现其他的面向对象特性。Point 算不算一个物件呢?

    我们延续 Point 类的例子,来看外部程序如何使用 Point 物件:

    // Excerpt.
    
    int main(void)
    {
        // Create a Point object.
        Point* pt = point_new(0, 0);
    
        // Access x and y.
        printf("(%.2f, %.2f)\n", point_x(pt), point_y(pt));
        
        // Mutate x and y.
        point_set_x(pt, 3);
        point_set_y(pt, 4);
        
        // Access x and y again.
        printf("(%.2f, %.2f)\n", point_x(pt), point_y(pt));
        
        // Free the object.
        point_free(pt);
    
        // Return the program status.
        return 0;
    }
    

    在我们这个例子中,除了手动将 Point 物件带进函式的语法有点 verbose 以外,这个例子就是基本的物件属性存取。这样的程序代码表面上看起来不太面向对象,但物件和函式已有基本的连动了。

    由于 C 语言没有内建的面向对象语法,我们要撰写面向对象程序时都要自己撰写额外的样板程序代码去仿真一些面向对象的特性。基本上,这些做法并没有真正的标准,都是我们对某项面向对象特性解构后重新用 C 语言去实现。像是有开发者以 C 语言宏写了一整套的轻量级物件系统 (见 lw_oopc),我们可以参考该物件系统的写法,甚至也可以直接将该物件系统拿来用,但这些面向对象的语法就不适合放在 C 语言的标准里。由于 C 的面向对象程序没有标准的做法,我们看到某一套实现时,要去思考该写法背后的思维,为什么要这样写?解决了什么语法特性?而不仅是直接硬背下来。

    程序设计讨论区上其实也不乏相关的讨论 (像这里),由一些相关的讨论,可以看出不同开发者对这个议题的态度不同。有一派开发者会说「对,我们可以这样做」,然后就会开始讨论一些用 C 仿真面向对象的技巧 (甚至是黑魔术);另一派则会直接说「为什么不直接用 C++」。Stroustrup 博士在做 cfront 时应该也想过类似的问题,最后的答案就是做出一个保留 C 特性的新语言,也就是 C++。

    那么,我们什么时候会用 C 语言写面向对象程序呢?通常是在 (1) 维护现有程序代码和 (2) 需要使用 C 而非 C++ 或其他语言时。程序设计初心者会以为追逐新兴语言很重要,但是,我们并不会因为 Rust 这类新兴编译语言出现后就把整个软件项目翻掉再来一次,通常会有更强烈的理由才这么做;在网络上有时会看到用 Rust 重写某个类 Unix 系统指令或工具的社群项目,往往就是得到一个功能不齐的次级品,而对资讯界没有实质的贡献。有时候我们的目标环境不允许我们用 C++ 或其他比较肥大的语言,只能使用 Bash、C、Lua 等相对节省运算资源的工具,这时候用一些面向对象的手法整理程序代码的确会有所帮助。

    C 语言无法获得完整的面向对象特性,但撰写一些基于物件的特性的程序代码倒是没有问题。像是我们可以透过 opaque pointer 和 static function 很容易就获得具有封装概念的物件,这种轻量级物件对于整理程序代码相当有帮助。相较起来,要在 C 语言中撰写具有多态特性的程序就比较费工,需要撰写较多的𣖙板程序代码来达到这样的语法特性;至于继承则是无法取得的特性,只能用组合的方法来仿真继承的效果。C 语言毕竟是程序性的 (procedural) 程序语言,当我们需要撰写大量样板程序代码来满足某些语法特性时,或许我们误用了工具。

    除了实用的观点,用 C 撰写面向对象程序也是很好的头脑体操。由于 C 没有内建的面向对象语法,我们可以重新思考到底我们平日所熟悉的面向对象特性想要达成什么目的,我们需要用什么 C 语言特性来满足这些需求;藉由这个解构再组合的过程,我们对面向对象又有进一步的了解。当然,面向对象只是撰写程序的过程中所用到的一些特性,不是最后的产品,我们也不需要一直沉醉在这样的头脑体操中;我们已经有 C++ (或 Java) 了,如果能直接用现有的工具就能解决问题,何必重造轮子呢?

    你或许对以下产品有兴趣
    All code in the website is licensed under Apache 2.0 unless otherwise mentioned.