什么是一点五编程OPF Programming)?

简要介绍

一点五编程(One Point Five Programming),是一套编程思路和技巧,有明确的定义和编写规范。

这套东西由两部分组成:

后者 “点五” 虽然重要且实用,但并不如前面的 “一” 变化万千,不足以并列,故只能算半个,即“点五”。“一点五编程”由此得名。

“一点五编程”是一套通用的编程思路,不仅可用于C,也可以套用于TypeScript/JavaScriptLua等语言上。

“一点五编程”虽然也可以用在C++JavaC#上,但是这些语言本身支持接口概念,功能上是“一点五编程”的超集。 在这些语言上使用“一点五编程”技巧,会显得不自然。

“一”

长久以来,“模块化编程”的强大被严重低估,这个词语,被埋没在一大堆花哨的计算机术语之中,它的深度和潜力,极少被重点发掘。

有许多人认为自己理解了模块化编程,其实只是理解了各种博客对模块化编程的解释和类比,实际使用中却难以融会贯通。

这里用一个简单的程序,尝试解释下模块化编程的好处。请看下面的程序:

int blah(int *result) {
    int v1 = 0;
    double v3 = 0;
    char v4 = 0;
    long v2 = 0;

    if (v1 > 10 && v2 > 2*10 && v2 < 8*10) {
        if (v1 > 10)
            return 1;
        v2 /= 3;
        v1 += v1 * v2 + 3;
        if (v1 > 10)
            return 1;
    }

    if (v3 > 2 && (v3 * v4 > v3 / v4 - 5)) {
        if (v3 < 0 || v3 > v1)
            return 2;
        v3 += v4 * 3.1415926 + 3;
        if (v3 > 10)
            return 2;
    }

    *result = (v1 + v1 * v2 - v2) / (v3 / v4 - v4);

    return 0;
}

上面的代码,阅读起来比较费神,读者无法轻易知道各变量之间的关联,或者操作细节具体对应的上层逻辑。 通常是通过大量的注释去推理程序逻辑。

然而,依靠注释终究是下策,注释和代码是分离的。如果代码重构了,而注释却忘了更新,那么过时的注释,甚至可能增加阅读难度。

如果采用模块化的编写方式,那么代码可以变成如下形式:

int blah(int *result) {
    struct d1 e1 = {0, 0};
    struct d2 e2 = {0, 0};

    if (d1_check_with(&e1, 10)) {
        if (d1_fix(&e1))
            return 1;
    }

    if (d2_check_with(&e2, 5)) {
        if (d2_fix(&e2, e1.v1))
            return 2;
    }

    *result = d1_generate(&e1) / d2_generate(&e2);

    return 0;
}

这个时候再看代码,逻辑就清楚了许多。哪些数据是紧密关联的,哪些地方会打破这些界限,一目了然。

首先,混在一起的数据,依据功能单元被放到了不同的结构体中:

struct d1 {
    int v1;
    long v2;
}
struct d2 {
    double v3;
    int v4;
}

其次,针对这些数据的操作,也随之被归分到了对应的逻辑单元:

int d1_check_with(struct d1 *self, int n) {
    return v1 > n && v2 > n*2 && v2 < n*8;
}

int d1_fix(struct d1 *self) {
    if (self->v1 > 10)
        return 1;

    self->v2 /= 3;
    self->v1 += self->v1 * v2 + 3;

    if (self->v1 > 10)
        return 2;

    return 0;
}
int d2_check_with(struct d2 *self, int offset) {
    return v3 > 2 && (v3 * v4 > v3 / v4 - offset);
}

int d2_fix(struct d2 *self, int v1) {
    if (self->v3 < 0 || self->v3 > v1)
        return 1;

    self->v3 += self->v4 * 3.1415926 + 3;

    if (self->v3 > 10)
        return 2;

    return 0;
}

上述例子虽是简化的虚拟场景,但是相信读者能够对模块化编程的优点体会到一二。

实际上程序越是复杂,模块化编程的优势就越是明显,在日常编程中反复体味和实验,能够使功力逐步提升。

笔者编程多年,曾自负掌握了很多编程技巧和语言,但是对于“模块化编程”这个概念,却经常有新的感悟。

这个世界上,有很多看似厉害的东西,了解后觉得不过如此。同时也有一些看似简单的东西,越学越觉得深奥。

“模块化编程”这个看似朴实的主题,所蕴含的力量,也许不曾被完全知晓。

“点五”

内功心法介绍完毕,下面就是“一点五编程”的招式“点五”了。这个招式的口诀,已经写在了我们的LOGO上:(*p)->f(p)

在解释原理之前,我们先放出这张图:

  VARIABLE           OBJECT             INTERFACE
  +--------+  .--->  +--------+  .--->  +--------+
  |     ---+--'      |     ---+--'      |   f1   |
  +--------+         +--------+         +--------+
                     |        |         |   f2   |
                     |        |         +--------+
                     |        |
                     |        |
                     +--------+

这张图已经展示了(*p)->f(p)的工作方式。

这个招式的终极目的,是提供一种“自顶向下”的程序设计方式。别小看“自顶向下”这个貌似土里土气的词,它是构建大型程序的关键。

最朴素的程序设计方式,即“自底向上”的设计方式,是实现A,然后在A的基础上实现B,最后在B的基础上实现最终的结果C

自顶向下的设计,则是先实现C的一部分,然后在另外的时间,或者由另外的人,实现B,最后将这个B跟已经实现的C组合起来,成为最终的系统。

自顶向下,和自底向上,也可以结合使用: 比如 A-B这一步,用自顶向下的设计,B-C这一步用自底向上的设计; 也可以反过来,B-C这一步,用自顶向下的设计,A-B这一步用自底向上的设计。诸如此类。

下面用一个例子进行阐述:

假设我们需要编写一套通用的绘图函数,类似画矩形draw_rect, 画圆圈draw_circle,这些绘图函数要能够适应不同的设备。

如何处理呢?只要有一个通用的画点draw_point函数,就能够在它的基础上实现画矩形,画圆,所以问题简化成了: 实现一个通用的画点draw_point函数

使用“点五”技巧,我们编写这样的接口结构体

typedef int (*painter_draw_point_fn_t)(void *self, struct point pos, int color);

struct painter_i {
    painter_draw_point_fn_t draw_point;
};

接下来,神奇的事情就产生了,我们可以在没有实现任何屏幕驱动代码的情况下,先去实现画矩形的函数!

int draw_rect(struct painter_i **painter, struct point p1, struct point p2, int color) {
    struct point c;

    /// ...

    for (/* 获取到下一个矩形边的点到 c 里面 */) {

        /// (*p)->f(p)
        (*painter)->draw_point(painter, c, color);


    }

    /// ...

    return 0;
}

这就是所谓的“自顶向下”程序设计。先实现上层逻辑,再实现底层逻辑。

底层的部分可以由我们自己,或者我们的伙伴,去实现具体的屏幕驱动。

举个例子,我们现在先实现一套RGB屏幕的驱动:

struct rgb_screen {
    struct painter_i *interface;    // 此字段必须是 rgb_screen 的第一个字段
    int color_type;
    int color_mask;
    /// ...
};
int rgb_screen_draw_point(struct rgb_screen *self, struct point pos, int color) {
    /// 在RGB屏幕上画点,本函数会使用`self->color_type`和`self->color_mask`。
}

struct pointer_i rgb_screen_interface = {
    .draw_point = (painter_draw_point_fn_t)rgb_screen_draw_point,
};

int rgb_screen_init(struct rgb_screen *self) {
    self->interface = &rgb_screen_interface;
}

接下来,我们再来实现一套单色屏幕的驱动:

struct mono_screen {
    struct painter_i *interface;    // 此字段必须是 mono_screen 的第一个字段
    unsigned char bit_buffer[1024*768];
    /// ...
};
int mono_screen_draw_point(struct mono_screen *self, struct point pos, int color) {
    /// 在单色屏幕上画点,本函数会使用`self->bit_buffer`。
}

struct pointer_i mono_screen_interface = {
    .draw_point = (painter_draw_point_fn_t)mono_screen_draw_point,
};

int mono_screen_init(struct mono_screen *self) {
    self->interface = &mono_screen_interface;
}

上述“底层”的代码,可以直接搭配已经写好的上层代码使用:

struct rgb_screen scr1;
struct mono_screen scr2;

/// ...

draw_rect(&scr1, p1, p2, RED);
draw_rect(&scr2, p1, p2, WHITE);

draw_circle(&scr1, p1, 10, RED);
draw_circle(&scr2, p1, 10, RED);

不少学员在学习“点五”技巧的时候,觉得复杂,难以看清全貌。这是一种错觉,或者说心态上的障碍。

其实应该换个思路想想:

“一点五编程”只有这半招,不像其他体系,需要学习很多的知识。 这半招的学习曲线,纵然不是那么平滑,但是一旦迈过,后面就是一马平川!

带着这种心理预期,只要跟着例子,把上述步骤按顺序走,就一定能得到想要的效果。

这个过程经过很多次重复之后会形成直觉,后面甚至不需要敲代码,就能在脑海中构建出程序的模样。

编码规范

“一点五编程”的核心思想,不涉及到编码规范。但是为了传承和协作,一个好用的编码规范是很必要的。 为此,“一点五编程”拟定了如下的编码规范:

  1. 方法类函数,首个参数必须是方法所归属的结构体,且变量名建议使用self
  2. 接口结构体,以_i结尾。比如struct painter_i, struct iter_i等等。
  3. 方法命名,建议使用所属结构体_方法名。比如struct my_vecadd方法, 函数名称为my_vec_add
  4. 函数只返回状态码,计算结果通过一个指针参数传出来。

对于上述第4条,作如下解释

很多C代码通过返回值返回计算结果,通过指针参数返回错误信息,示例如下:

int fn(int a, int b, int *error) {
    if (some_check()) {
        *error = CODE_xxx;
        return SOMETHING;
    }

    return a + b;
}

“一点五编程” 不使用上述风格,而是统一使用下面的风格:

int fn(int a, int b, int *result) {
    if (some_check())
        return CODE_xxx;

    *result = a + b;
    return 0;
}

即通过返回值返回错误信息,通过指针参数返回计算结果。 且无特殊情况时,所有的函数都一致通过返回0表达“成功”。

上面这一点,与很多返回布尔值,即用1表示“成功”的代码风格不同,需要注意。

这样做的好处,是可以使用统一的错误处理代码。

迭代器

“一点五编程”大量使用迭代器。迭代器本身只是“一点五编程”思路的实践应用, 但由于使用普遍,故而值得单独提出来说。

“一点五编程”的迭代器,只需实现两个接口: _iter_init_iter_next, 使用时,代码看上去大概是这样:

int blah(void) {
    struct xxx_iter iter;
    struct element *tmp;
    size_t index;


    /// 调用 _init 初始化迭代器,迭代器的初始化可能失败,故不能忽略返回值

    if (xxx_iter_init(&iter))
        return XXX_ITER_INIT_failed;


    /// 反复调用 _next 获取迭代器的下一个元素,元素通过传入指针获取
    /// 元素下标也能通过传入指针获取(传入NULL表示不需要下标)

    //while (!xxx_iter_next(&iter, &tmp, NULL))
    while (!xxx_iter_next(&iter, &tmp, &index)) {
        if (do_something_on_element(tmp))
            return XXX_ITER_ELE_ERROR;
    }


    return 0;
}

以上的迭代器设计方式,是一种建议而非强制。

迭代器的合理使用,能够极大程度提高代码的可读性。同时灵活使用迭代器还可以规避大量重复代码。

使用建议

“一点五编程”的格言是“重剑无锋,大巧不工”。它是编程范式界的“拳击”, 理论和套路很少,用精简的招式,快速的反应来应对现实世界里的各种编程难题。