【在主画面加入捷径】
       
【选择语系】
繁中 简中

[C 语言] 程序设计教学:如何实现类 (class) 和物件 (object)

【赞助商连结】

    前言

    C 语言没有内建的面向对象 (object-oriented) 语法,但我们仍然可以用 C 语言写出有面向对象思维的语法 (可见这里)。早期就有一本经典在线教材 Object-Oriented Programming with ANSI C 整本都在讲用 C 写面向对象程序的方式 (出处),有需要的读者可自行前往拜读。本系列文章以简短的文字说明搭配短例,让读者很快学会使用 C 写面向对象程序的一些手法。

    注:本系列文章所用的手法和 Object-Oriented Programming with ANSI C 所介绍的方式不同,读者可自行比较。其实 Object-Oriented Programming with ANSI C 的程序代码不易阅读,故笔者未直接采用该教材的手法。

    由于 C 语言没有内建的面向对象语法,在各种 C 语言教材中 (包括本文) 实现面向对象程序的手法都是利用 C 语言的特性去仿真出来的;这些手法没有一定的准则 (gold standard),也不会放到 C 标准中。最重要的不是原封不动地照抄这些手法,而是理解为什么要用这些手法,透过这些手法达成了什么效果,从中慢慢建立自己惯用的方式。

    物件 (object) 是带有状态 (state) 和行为 (behavior) 的抽象实例,而类 (class) 则是建立物件的蓝图;对于类和物件,可以想像饼干模子和饼干的关系。状态透过存取属性 (fields) 来储存;行为透过函式 (function) 或副常式 (subroutine) 来实现。至于封装 (encapsulation)、继承 (inheritance)、多态 (polymorphism) 等特性,则是透过不同面向来加强物件,不是物件的必备条件。

    在本文中,我们使用二维空间的点 (point) 来展示如何制作类和物件,在这里我们没有用到进阶的面向对象特性,仅是一个带有状态和行为的简单物件。

    一般的写法

    我们先看 Point 物件的使用方式:

    #include <assert.h>
    #include <stdbool.h>
    #include <stdlib.h>
    #include <stdio.h>
    #include "point.h"
    
    int main(void)
    {
        bool failed = false;
        
        // Create a Point object.
        Point* pt = point_new(0, 0);
        if (!pt) {
            perror("Failed to allocate Point pt");
            failed = true;
            goto POINT_FREE;
        }
    
        // Check x and y.
        if (!(point_x(pt) == 0)) {
            failed = true;
            goto POINT_FREE;
        }
        
        if (!(point_y(pt) == 0)) {
            failed = true;
            goto POINT_FREE;
        }
        
        // Mutate x and y.
        point_set_x(pt, 3);
        point_set_y(pt, 4);
        
        // Check x and y again.
        if (!(point_x(pt) == 3)) {
            failed = true;
            goto POINT_FREE;
        }
        
        if (!(point_y(pt) == 4)) {
            failed = true;
            goto POINT_FREE;
        }
        
    POINT_FREE:
        // Free the object.
        point_free(pt);
        
        if (failed) {
            exit(EXIT_FAILURE);
        }
    
        return 0;
    }

    这个例子相当简单,就是透过 Point 物件 pt 存取座标点 xy,可以看得出来物件和函式有基本的连动。

    接着,我们来看 Point 类的公开方法,在 C 语言中透过头文件 (header) 来声明某个类的公开方法:

    #ifndef POINT_H
    #define POINT_H
    
    // Declare Point class.
    typedef struct point {
        double x;
        double y;
    } Point;
    
    // The constructor of Point.
    Point* point_new(double x, double y);
    
    // The getters of Point.
    double point_x(Point *self);
    double point_y(Point *self);
    
    // The setters of Point.
    void point_set_x(Point *self, double x);
    void point_set_y(Point *self, double y);
    
    // The destructor of Point.
    void point_free(void *self);
    
    #endif // POINT_H

    最后来看 Point 类内部的实现:

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

    本例的 Point 物件相当简单,但精神上已经是一个物件了。即使我们完全不使用进阶的面向对象特性,也可以撰写以物件为基础的 (object-based) 程序,利用物件来组织程序代码。

    注:一般的 object-based programming 是指有封装但没有继承和多态的物件,我们采更宽松的定义,只要有状态和方法连动的物件即可。

    替代的写法

    在大部分的 C 语言教材中,皆采用上一节的写法,因为这种写法简单易懂,除了用结构体仿真 this 指针外,使用一般函式的写法即可。在本节中,我们则提供另一个替代性的写法,虽然这个写法比较少见,但程序代码也相当简洁,具有一定的参考性。

    这种写法的关键在于将类 (class) 和物件 (object) 分成两个结构体,本节同样以二元座标系的点为例:

    #pragma once
    
    typedef struct point point_t;
    
    typedef struct point_class {
        point_t * (*new)(float, float);
        float (*x)(point_t *self);
        void (*set_x)(point_t *self, float value);
        float (*y)(point_t *self);
        void (*set_y)(point_t *self, float value);
        float (*distance)(point_t *p, point_t *q);
        void (*delete)(void *pt);
    } point_class_t;
    
    point_class_t * point_class_new(void);

    由这个声明可以观察到,除了原本储存数据的 point_t 结构外,我们额外声明一个表示类的 point_class_t 结构。这里使用到函式指针的手法。

    先看外部程序如何使用此点 (point) 类和物件:

    #include <assert.h>
    #include <math.h>
    #include <stdlib.h>
    #include "point.h"
    
    int main(void)
    {
        point_class_t *klass = point_class_new();
        if (!klass)
            return 1;
    
        point_t *a = klass->new(0.0, 0.0);
        point_t *b = klass->new(1.0, 2.0);
    
        assert(klass->x(b) == 1.0);
        assert(klass->y(b) == 2.0);
    
        klass->set_x(b, 3.0);
        klass->set_y(b, 4.0);
    
        assert(fabs(klass->distance(a, b) - 5.0) < 0.00001);
    
        klass->delete(a);
        klass->delete(b);
    
        free(klass);
    
        return 0;
    }

    由于 C 语言本身的限制,我们无法将物件 ab 直接和类结合在一起。所以用来仿真 this 指针的 ab 仍然会和函式分开。

    这个程序的好处是减少命名空间的污染,因为所以的函式都共用 klass 这个结构,klass 在无意间同时担任类 (class) 和命名空间 (namespace) 的双重角色。

    最后来看类实现的部分:

    #include <assert.h>
    #include <math.h>
    #include <stdlib.h>
    #include "point.h"
    
    struct point {
        float x;
        float y;
    };
    
    static point_t * _point_new(float, float);
    static float _point_x(point_t *);
    static void _point_set_x(point_t *, float);
    static float _point_y(point_t *);
    static void _point_set_y(point_t *, float);
    static float _point_distance(point_t *, point_t *);
    static void _point_free(void *);
    
    point_class_t * point_class_new(void)
    {
        point_class_t *klass = (point_class_t *) malloc(sizeof(point_class_t));
        if (!klass)
            return klass;
        
        klass->new = _point_new;
        klass->x = _point_x;
        klass->set_x = _point_set_x;
        klass->y = _point_y;
        klass->set_y = _point_set_y;
        klass->distance = _point_distance;
        klass->delete = _point_free;
    
        return klass;
    }
    
    static point_t * _point_new(float x, float y)
    {
        point_t *p = (point_t *) malloc(sizeof(point_t));
        if (!p)
            return p;
        
        p->x = x;
        p->y = y;
    
        return p;
    }
    
    static float _point_x(point_t *pt)
    {
        assert(pt);
    
        return pt->x;
    }
    
    static void _point_set_x(point_t *pt, float value)
    {
        assert(pt);
    
        pt->x = value;
    }
    
    static float _point_y(point_t *pt)
    {
        assert(pt);
    
        return pt->y;
    }
    
    static void _point_set_y(point_t *pt, float value)
    {
        assert(pt);
    
        pt->y = value;
    }
    
    static float _point_distance(point_t *a, point_t *b)
    {
        assert(a && b);
    
        float x = a->x - b->x;
        float y = a->y - b->y;
    
        return sqrt(x * x + y * y);
    }
    
    static void _point_free(void *pt)
    {
        if (!pt)
            return;
        
        free(pt);
    }

    其实实现点 (point) 的部分平淡无奇,重点在于用来仿真类的 klass 物件的产生方法。我们可以发现这个方法比传统的手法要费更多的工,写更多的样板程序代码,故可以理解为何这个手法较少见。

    【赞助商连结】