表格驱动 C++ 示例

193 阅读7分钟
原文链接: zhuanlan.zhihu.com
  • 碎碎念
  • 原始代码
  • 表格驱动写法
  • enum 值作为表格索引
  • 静态检查
  • 辅助宏 static_check_table
  • 额外细节讨论

碎碎念

我们说程序写得好,其实包含两层含义,不同语境下有所侧重。

一层含义是程序实现了很厉害的功能。比如某个酷炫的特效,或者某个很高效的算法。另一层含义是,程序表达得很好,清晰易懂,也容易修改。以写文章来类比,一层是文章内容,另一层是表达文笔。

计算机的书籍,也可粗分这两类。一类书籍教具体的技术细节,比如算法、图形学等。另一类书籍是教如何表达,比如《代码大全》、《重构》等。

本文说代码写得好,更多是指表达得好。假如代码表达不好,就算有很厉害的算法,写着写着就会乱掉。代码写得乱,就会出错。而这些错误本来是可避免的。

说到写好代码,很多文章都会讨论架构、设计模式、高内聚低耦合、命名规范之类。这些知识自然很重要,但总感觉有点虚有点宏大,有时太宏大了就不知道如何落实,要写得多代码才能慢慢理解。

也有些代码技巧,从细处着手,强调写好每一个函数,知道就可用到。让我挑选随学随用的两个小技巧,我会选择:

  1. ScopeGuard.
  2. 表格驱动。

关于 ScopeGuard 之前写过文章了,见ScopeGuard 介绍和实现。而表格驱动在《代码大全》第 18 章已有讨论。

此文只是补充一个具体的 C++ 例子。

原始代码

下面这段代码是我从真实工程中摘抄下来的,是处理图像的一小段代码,只是简化并改改名字。

#include <stdio.h>
#include <assert.h>

typedef enum {
    Orientation_Up = 0,
    Orientation_Down,
    Orientation_Left,
    Orientation_Right,
    Orientation_UpMirrored,
    Orientation_DownMirrored,
    Orientation_LeftMirrored,
    Orientation_RightMirrored,
} OrientationType;

static void doSomething(OrientationType oriType) {
    bool needMirror = false;
    int rotation = 0;
    switch (oriType) {
        case Orientation_Up:
            rotation = 0;
            needMirror = false;
            break;
        case Orientation_Down:
            rotation = 180;
            needMirror = false;
            break;
        case Orientation_Left:
            rotation = 270;
            needMirror = false;
            break;
        case Orientation_Right:
            rotation = 90;
            needMirror = false;
            break;
        case Orientation_UpMirrored:
            rotation = 0;
            needMirror = true;
            break;
        case Orientation_DownMirrored:
            rotation = 180;
            needMirror = true;
            break;
        case Orientation_LeftMirrored:
            rotation = 270;
            needMirror = true;
            break;
        case Orientation_RightMirrored:
            rotation = 90;
            needMirror = true;
            break;
    }

    printf("rotation = %d\n", (int)rotation);
    printf("needMirror = %d\n", (int)needMirror);
}

int main(int argc, const char *argv[]) {
    doSomething(Orientation_UpMirrored);
    return 0;
}

doSomething 函数中的那个 switch, case 是很典型的写法。也没有什么大问题,只是还不够清晰,要加个新类型,就需要添加一个 case。

表格驱动写法

这种典型的 switch, 每个 case 有很相似代码,可以写成表格驱动风格。

static void doSomething(OrientationType oriType) {
    const struct Info {
        OrientationType type;
        int rotation;
        bool needMirror;
    } infos[] = {
        {Orientation_Up, 0, false},
        {Orientation_Down, 180, false},
        {Orientation_Left, 270, false},
        {Orientation_Right, 90, false},
        {Orientation_UpMirrored, 0, true},
        {Orientation_DownMirrored, 180, true},
        {Orientation_LeftMirrored, 270, true},
        {Orientation_RightMirrored, 90, true},
    };

    bool needMirror = false;
    int rotation = 0;
    for (size_t idx = 0; idx < sizeof(infos) / sizeof(infos[0]); idx++) {
        if (infos[idx].type == oriType) {
            needMirror = infos[idx].needMirror;
            rotation = infos[idx].rotation;
            break;
        }
    }
    printf("rotation = %d\n", (int)rotation);
    printf("needMirror = %d\n", (int)needMirror);
}

原始代码中,以 oriType 作为 switch 的选项。case 中有 rotation、needMirror 两项,于是表格就有对应的三项。这里的表格是个数组,有时表格也可以是个字典。根据 type 字段,在表格中线性查找,找到表格对应的项。

不用担心表格的初始化耗时,在编译优化时,表格就会被初始化,并不是每次调用 doSomething 才初始化。新代码短了很多,也更加容易修改。当新加一个类型,只需要在表格中插入一个记录。

到这里,已经了解了表格驱动的风格是怎么样的。通常代码修改到这里也就够了。

enum 值作为表格索引

上面代码绝大多数情况下都没有问题。但假如这段代码出现在较高性能的场合,那个 for 循环线性遍历,让人有点不安。注意到 enum 的值为

typedef enum {
    Orientation_Up = 0,
    Orientation_Down,
    Orientation_Left,
    Orientation_Right,
    Orientation_UpMirrored,
    Orientation_DownMirrored,
    Orientation_LeftMirrored,
    Orientation_RightMirrored,
} OrientationType;

enum 从 0 开始,这种情况也很典型。而数组索引也从 0 开始,因而只要表格定义的顺序跟 enum 对应,enum 的值可以直接作为表格的索引。

static void doSomething(OrientationType oriType) {
    const struct Info {
        OrientationType type;
        int rotation;
        bool needMirror;
    } infos[] = {
        {Orientation_Up, 0, false},
        {Orientation_Down, 180, false},
        {Orientation_Left, 270, false},
        {Orientation_Right, 90, false},
        {Orientation_UpMirrored, 0, true},
        {Orientation_DownMirrored, 180, true},
        {Orientation_LeftMirrored, 270, true},
        {Orientation_RightMirrored, 90, true},
    };

    const Info &info = infos[(int)oriType];
    assert(info.type == oriType);
    bool needMirror = info.needMirror;
    int rotation = info.rotation;

    printf("rotation = %d\n", (int)rotation);
    printf("needMirror = %d\n", (int)needMirror);
}

上述代码用 oriType 作为索引,就可省略掉那个 for 循环。注意上面代码加了个 assert。

assert(info.type == oriType);

假如手误,写错了表格顺序。或者 enum 的值被改变,程序运行到这里就会触发 assert,于是有一次动态检查。

静态检查

到了这里,上面的代码已经算漂亮了,速度也快。但是还是有隐患,让人不安。上面代码有个隐含条件,需要表格项的索引跟 enum 对应。但假如有一天修改了 enum 的值,或者中途在表格中插入一项,代码就错了。

这里的隐患条件可以添加一个注释,但还不够。那个 assert 只会在运行时检查,更好的方式是在编译就做检查,假如写错就编译不过,这样可以引起程序员的注意。可以添加注释并加 static_assert 检查,就会变成

static void doSomething(OrientationType oriType) {
    // 以 oriType 为索引,表格项定义必须跟 enum 值对应
    constexpr struct Info {
        OrientationType type;
        int rotation;
        bool needMirror;
    } infos[] = {
        {Orientation_Up, 0, false},
        {Orientation_Down, 180, false},
        {Orientation_Left, 270, false},
        {Orientation_Right, 90, false},
        {Orientation_UpMirrored, 0, true},
        {Orientation_DownMirrored, 180, true},
        {Orientation_LeftMirrored, 270, true},
        {Orientation_RightMirrored, 90, true},
    };
    static_assert(infos[0].type == 0, "wrong type");
    static_assert(infos[1].type == 1, "wrong type");
    static_assert(infos[2].type == 2, "wrong type");
    static_assert(infos[3].type == 3, "wrong type");
    static_assert(infos[4].type == 4, "wrong type");
    static_assert(infos[5].type == 5, "wrong type");
    static_assert(infos[6].type == 6, "wrong type");
    static_assert(infos[7].type == 7, "wrong type");

    const Info &info = infos[(int)oriType];
    assert(info.type == oriType);
    bool needMirror = info.needMirror;
    int rotation = info.rotation;

    printf("rotation = %d\n", (int)rotation);
    printf("needMirror = %d\n", (int)needMirror);
}

修改后,假如表格中改变顺序或者 enum 值被修改,就会触发 static_assert,导致编译不过。constexpr 是 C++ 11 的关键字,C 似乎没有对应的东西。上面那段代码,在 C 中编译不过。

辅助宏 static_check_table

到了这里,代码基本可以了。但还是有问题,就是那个 static_assert 的检查有点啰嗦,有点重复。下一个表格又要写同样的代码。这时可以写一些辅助静态检查的宏, 比如:

#define static_check_concat_(A, B) A##B
#define static_check_concat(A, B) static_check_concat_(A, B)

#define static_check_table_0(tableName, typeName)
#define static_check_table_1(tableName, typeName) static_assert(infos[0].typeName == 0, "wrong type")

#define static_check_table_2(tableName, typeName) \
    static_check_table_1(tableName, typeName);    \
    static_assert(infos[1].typeName == 1, "wrong type")

#define static_check_table_3(tableName, typeName) \
    static_check_table_2(tableName, typeName);    \
    static_assert(infos[2].typeName == 2, "wrong type")

#define static_check_table_4(tableName, typeName) \
    static_check_table_3(tableName, typeName);    \
    static_assert(infos[3].typeName == 3, "wrong type")

#define static_check_table_5(tableName, typeName) \
    static_check_table_4(tableName, typeName);    \
    static_assert(infos[4].typeName == 4, "wrong type")

#define static_check_table_6(tableName, typeName) \
    static_check_table_5(tableName, typeName);    \
    static_assert(infos[5].typeName == 5, "wrong type")

#define static_check_table_7(tableName, typeName) \
    static_check_table_6(tableName, typeName);    \
    static_assert(infos[6].typeName == 6, "wrong type")

#define static_check_table_8(tableName, typeName) \
    static_check_table_7(tableName, typeName);    \
    static_assert(infos[7].typeName == 7, "wrong type")

#define static_check_table(tableName, N, typeName)                                    \
    static_assert(sizeof(tableName) / sizeof(tableName[0]) == N, "wrong table size"); \
    static_check_concat(static_check_table_, N)(tableName, typeName)

有了 static_check_table 宏,代码可以写成

static void doSomething(OrientationType oriType) {
     // 以 oriType 为索引,表格项定义必须跟 enum 值对应
    constexpr struct Info {
        OrientationType type;
        int rotation;
        bool needMirror;
    } infos[] = {
        {Orientation_Up, 0, false},
        {Orientation_Down, 180, false},
        {Orientation_Left, 270, false},
        {Orientation_Right, 90, false},
        {Orientation_UpMirrored, 0, true},
        {Orientation_DownMirrored, 180, true},
        {Orientation_LeftMirrored, 270, true},
        {Orientation_RightMirrored, 90, true},
    };
    static_check_table(infos, 8, type);

    const Info &info = infos[(int)oriType];
    assert(info.type == oriType);
    bool needMirror = info.needMirror;
    int rotation = info.rotation;

    printf("rotation = %d\n", (int)rotation);
    printf("needMirror = %d\n", (int)needMirror);
}

static_check_table 中,第一个参数是表格名字,第二个参数是表格大小,第三个参数是需要检查的关键字。

static_check_table(infos, 8, type);

展开为

static_assert(sizeof(infos) / sizeof(infos[0]) == 8, "wrong table size"); 
static_assert(infos[0].type == 0, "wrong type"); 
static_assert(infos[1].type == 1, "wrong type"); 
static_assert(infos[2].type == 2, "wrong type"); 
static_assert(infos[3].type == 3, "wrong type"); 
static_assert(infos[4].type == 4, "wrong type"); 
static_assert(infos[5].type == 5, "wrong type"); 
static_assert(infos[6].type == 6, "wrong type"); 
static_assert(infos[7].type == 7, "wrong type");

额外细节讨论

有些人可能认为,那个 static_check_table 反而使得代码更多,更复杂了。但是 static_check_table 是通用的,并不依赖于具体场合,可以重复使用。比如另一段类似代码

typedef enum {
    SpeedMode_Default = 0,
    SpeedMode_SlowX2 = 1,
    SpeedMode_SlowX3 = 2,
    SpeedMode_SlowX4 = 3,
    SpeedMode_FastX2 = 4,
    SpeedMode_FastX3 = 5,
    SpeedMode_FastX4 = 6,
} SpeedMode;

static float getSpeedRate(SpeedMode mode) {
    switch (mode) {
        case SpeedMode_FastX2:
            return 2.0f;
        case SpeedMode_FastX3:
            return 3.0f;
        case SpeedMode_FastX4:
            return 4.0f;
        case SpeedMode_SlowX2:
            return 1 / 2.0f;
        case SpeedMode_SlowX3:
            return 1 / 3.0f;
        case SpeedMode_SlowX4:
            return 1 / 4.0f;
        default:
            return 1.0f;
    }
}

就可以相应修改为

static float getSpeedRate(SpeedMode mode) {
    constexpr struct Info {
        SpeedMode mode;
        float value;
    } infos[] = {
        {SpeedMode_Default, 1.0},
        {SpeedMode_SlowX2, 1 / 2.0f},
        {SpeedMode_SlowX3, 1 / 3.0f},
        {SpeedMode_SlowX4, 1 / 4.0f},
        {SpeedMode_FastX2, 2.0},
        {SpeedMode_FastX3, 3.0},
        {SpeedMode_FastX4, 4.0},
    };
    static_check_table(infos, 7, mode);
    const Info &info = infos[(int)mode];
    assert(info.mode == mode);
    return info.value;
}

这种 switch、case 的代码是很典型的。

另外一些细节

  • C 中没有 constexpr,做不了数组的静态检查。
  • 上面默认了 enum 从 0 开始,但很可能 enum 开始值是其他。线性查找下,枚举就算从其他值开始也没有所谓。而假如 enum 作为数组索引,就需要减去相应偏移值。static_check_table 也需要相应修改,添加一个偏移值的参数。