c印记(十三):表驱动编程——优美的逻辑优化者

来源:互联网 发布:凯文史派西出柜 知乎 编辑:程序博客网 时间:2024/04/29 20:31

一、前言

在《代码大全》中,专门用了一章(18章)的内容来讲解表驱动,并且在序言中推荐为初级程序员首读章节,可见其重要性。当然,表驱动也并不是什么“银弹”,只能说是一种不错的技巧。 有过编程经验的人可能或多或少的使用过表驱动,只是不清楚自己使用的某些编程技巧就是传说中的表驱动而已。接下来就来说说什么是表驱动,以及如何应用。

二、表驱动

2.1 什么是表驱动?

在《代码大全》中,对表驱动的描述如下:

“表” 是几乎所有数据结构课本都要讨论的非常有用的数据结构。表驱动方法出于特定的目的来使用表,程序员们经常谈到“表驱动”方法,但是课本中却从未提到过什么是”表驱动”方法。表驱动方法是一种使你可以在表中查找信息,而不必用很多的逻辑语句(if或Case)来把它们找出来的方法。事实上,任何信息都可以通过表来挑选。在简单的情况下,逻辑语句往往更简单而且更直接。但随着逻辑链的复杂,表就变得越来越富有吸引力了。

比如,如果使用1~7 表示一个星期(星期一至星期日)对应的序号,现在,需要实现的功能是:当传入表示星期几的数字时,需要返回,数字对应的字符串名字(比如传入数字 2, 那么就需要返回字符串 “星期二”)。 如果使用逻辑语句 if来实现的话,如下所示:

char* getWeekName(int num){    char* name = NULL;    if (num == 1)    {        name = "Monday";    }    else if (num == 2)    {        name = "Tuesday";    }    else if (num == 3)    {        name = "Wednesday";    }    else if (num == 4)    {        name = "Thursday";    }    else if (num == 5)    {        name = "Friday";    }    else if (num == 6)    {        name = "Saturday";    }    else if (num == 7)    {        name = "Sunday";    }    return name;}

这羊的代码看起来就显得有些冗长,而且如果在书写的弄错几个括号的话,就可能出现错误的结果。用switch——case逻辑语句也是类似:

char* getWeekName(int num){    char* name = NULL;    switch (num)    {    case 1:         name = "Monday";        break;    case 2:        name = "Tuesday";        break;    case 3:        name = "Wednesday";        break;    case 4:        name = "Thursday";        break;    case 5:        name = "Friday";        break;    case 6:        name = "Saturday";        break;    case 7:        name = "Sunday";        break;    default:        break;    }       return name;}

和if逻辑语句实现的请差不多,也比较冗长。而且只要用过switch——case的人,可能都知道,在使用的时候,如果一不小心些掉了“break”关键字的话,就直接掉落到下一个case语句,得到错误的结果。

那现在来看一下如果使用表驱动的话会如何实现呢:

static const char* g_week_names[] = {    "Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"};const char* getWeekName(int num){    return g_week_names[num - 1];}

和前面的两种实现一对比,是不是觉得表驱动方法的实现要简洁得多了呢。而且效率也更高,比如使用if实现,如果传入的是数字是‘7’,那么,在得到正确的名字字符串之前,要先进行六次判断(星期一到星期六),而表驱动中,直接就是数组的下表索引,一步到位。

2.2 数据驱动

表驱动是属于数据驱动编程的一类技巧。 数据驱动编程的核心出发点是:相对于程序逻辑,人类更擅长于处理数据。数据比程序逻辑更容易驾驭,所以我们应该尽可能的将设计的复杂度从程序代码转移至数据。

数据驱动的一些优势:

  • 可读性更强
  • 更容易修改,也就是说易于扩展,能够更好的面向变化
  • 更易重用

很多设计思路背后的原理其实都是相类相通的,具体的,数据驱动编程背后的实现思想如下:

  • 控制复杂度,通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标(从上一节总使用表驱动和逻辑语句两种方法的代码简洁度的对比中就可见一二)

  • 隔离变化,就比如上一节中获取星期几名字字符串的例子中,如果要实现多国语言版本的星期几名字,使用逻辑语句实现的两个版本,要修改起来就麻烦无比。而对于表驱动实现方式,只需要更换字符串数组就可以了,函数部分完全不用修改。这里变化的只是星期几名字的字符串(数据),而不变的就是获取它的函数。这就是隔离变化

  • 机制和策略的分离,和上面一点相似,也可以说将数据与逻辑分离。 对于上一节的例子中,可以将获取星期几的函数中的实现内容看作机制或逻辑, 将代表星期几的字符串数组(或者多国语言版本的数组)看作是策略或数据。

数据驱动的应用:

  • 函数级别的设计,如上一节的例子一样
  • 应用程序级别的设计,比如一个多媒体播放器应用程序中,可以使用表驱动来实现其有限状态机(例子状态:播放,暂停,停止等)
  • 也可以在编程语言级别或者系统级别的设计中使用,比如底层使用编译型语言来高效率的实现各种机制,然后上层使用脚本语言来实现各种策略

当然,数据驱动编程也不是“银弹”,不是说使用数据驱动就能天下无敌了。 数据驱动只是一种设计思想或者技巧。这里的数据也不是平常意义的数据,比如上面说到的如果使用表驱动来实现有限状态机的话,这里的数据可能就变成了状态,或者状态对应的action函数。

2.3 表查询

既然是表驱动,那就会涉及到表查询的设计和实现方法,具体如下:

  • 直接查询,就像第二节的表驱动实现一样,通过传入的‘num’直接在数组g_week_names中索引
  • 间接查询,或者索引访问表,也就是说在表查询的时候,先用一个基本类型的数据在索引表(一般是简略的表)中查询得到一个键值,然后利用这个键值在主表或数据表(一般是较详细的表)中查询得到最终的主数据。

    这里用《代码大全》中的一个例子稍微改变一下来说明,用4位数字来表示商品的编号,那么其范围就是0000到9999。而实际上在某一个超市的的商品种类的数量只有100种,且这100种商品的4位数编号并不是连续的。然后每一种商品除了编号之外,在表中对应的信息还用,单价,打折情况,生产厂家等等数据,每个数据单元都比较大,假如是100个字节。 如果在这里使用直接查询的方式的话,就需要建立一张有10000个单元,且每个单元为100字节的表,其表占用的内存总大小为: 10000 * 100 ≈ 976KB。

现在,我们换一种做法,创建两张表来实现这个查询。可能有人会说,创建一张表数据就比较大了,如果创建两张表,那占用的空间岂不是更大? 如果两张表都是一样的,那可能会这样。但如果两张表不一样呢?

首先,0000~9999这10000个数字,完全可以用一个16位(2字节)的整型来表示,所以这里就用16位整型来创建一张包含1000个单元的表,每个单元2字节,暂时称作:表1。其单元格中存放的是对应到表2中相应商品的索引值
然后,因为超市只有100种商品,那么这里可以建立一个含有100个单元,每个单元100个字节,暂时称作:表2。单元格中存放的是商品的详细信息。

这种方式占用的内存大小为: 10000 * 2 + 100 * 100 ≈ 29 KB

两相对比,差距非常巨大,达到了33倍左右。其示意图如下:

索引访问表

左边的表就是索引表,右边的是主表。

索引表中大部分都是空的,灰色阴影部分代表对应到主表中的索引值,比如上图中索引表的第一个阴影,其单元格内存储的值就是‘0’(如果主表的键值是从0开始的),而单元格对应的下表(一个4位数的整数,比如,1234)就代表商品的编号。

  • 阶梯查询,或者说阶梯访问,它的结构就是成阶梯状的,其基本思想就是,表中的数据对不同的数据范围有效,而不是对不同的数据点有效。

比较典型的例子就是:成绩等级。 假如成绩的分数为100分制(也就是最高分为100),而成绩的等级分为,A,B,C,D,F,那么其对应关系为:

  • >= 90: A
  • < 90: B
  • < 80: C
  • < 70: D
  • < 60: F

为了简单,这里分数均为正数,不考虑类似70.5 分之类的小数情况。 如果使用if逻辑语句的话,实现如下:

char getScoreLevel(int score){    char level = 0;    if (score >= 90)    {        level = 'A';    }    else if (score >= 80)    {        level = 'B';    }    else if (score >= 70)    {        level = 'C';    }    else if (score >= 60)    {        level = 'D';    }    else if (score >= 0)    {        level = 'F';    }    return level;}

接下来就是使用表驱动的方法实现:

static const int g_score_range_limit[] = { 90,80,70,60,0 };static const char g_score_levels[] = { 'A','B','C','D','F' };char getScoreLevel(int score){    char level = 0;    int level_idx = 0;    int level_max = sizeof(g_score_levels) / sizeof(g_score_levels[0]);    while (level_idx < level_max)    {        if (score >= g_score_range_limit[level_idx])        {            level = g_score_levels[level_idx];            break;        }        level_idx++;    }    return level;}

这里前面章节的例子一样,如果这里的等级种类表多时或者名字不一样(比如将‘A’变为‘a’等),对于表驱动实现的话,只需要修改外面的数据或策略就可以了。

三、一些例子

接下来就来看一些表驱动的应用例子。

3.1 在工厂模式中使用表驱动

至于工厂模式是什么,有兴趣的可以取问度娘。为了简单,这里说的工厂模式为:简单工厂模式,其c++的实现如下所示:

class product /** 产品基类*/{public:    product() {}    virtual ~product() {}public:    virtual int getId() = 0;};class productA: public product /**产品A,并继承产品基类 */{public:    productA() {}    virtual ~productA() {}public:    virtual int getId() { return 1; }};class productB : public product /**产品B,并继承产品基类*/{public:    productB() {}    virtual ~productB() {}public:    virtual int getId() { return 2; }};class productFactory /** 产品工厂 */{public:    productFactory(){}    ~productFactory(){}public:    product* createProduct(int type); /** 创建产品对象 */};product* productFactory::createProduct(int type){/** 根据传图的类型,决定要创建哪一种产品的对象 */    product* pd = NULL;    switch (type)    {    case 1: pd = new productA; break;    case 2: pd = new productB; break;    default:        break;    }    return pd;}

从上面可以看到,在创建产品对象的工厂类成员函数中,是使用的switch逻辑语句。 虽然工厂模式将创建对象的动作封装起来了,但是它内部实现,仍然会有switch或者if等逻辑语句。比如现在要增加productC这样一个类型,就需要去修改createProduct函数,给switch新增一个case项,这样的修改多有不便。于是,表驱动就可以登场了。 因为是c++,创建对象都是使用new关键字。所以这里需要使用模板函数类泛化创建的对象类型,具体实现如下(类型声明都是一样的,唯一的差别是createProduct函数的实现):

template<typename T>product* newProduct() /** 创建产品对象的函数模板 */{    return new T;}typedef product* (*newProductFunc_t)();static const newProductFunc_t g_new_product_funcs[] ={/** 注册产品对象创建函数 */    newProduct<productA>,    newProduct<productB>,};product* productFactory::createProduct(int type){    return g_new_product_funcs[type - 1]();}

如上面这种实现,当需要添加productC这样一个产品时,只需要在g_new_product_funcs数组中添加一下newProduct就可以了。

3.2 表驱动与函数指针联合应用

有过工作经验或者编程经验的人基本上都遇到过在一个函数里面处理事件,消息或者命令等逻辑行为。形式大概如下:

enum msg_type{    MSG_A,    MSG_B,    MSG_C,};void processMsgA(int argc, char* argv[]){    //todo something.}void processMsgB(int argc, char* argv[]){    //todo something.}void msgProcess(int msg, int argc, char* argv[]){    switch (msg)    {    case MSG_A:        processMsgA(argc, argv);        break;    case MSG_B:        processMsgB(argc, argv);        break;    case MSG_C:        //todo something.        break;    default:        break;    }}

如果代码写得不太规整的,可能大多数直接在case语句块里面写实现功能的表达式(如上面的处理MSG_C部分),甚至是在case语句块里面再嵌套switch逻辑语句,在可读性方面很不理想。而且如果要新增加一个消息的时候,就必须修改msgProcess函数,添加相应的case语句块,增加了出错的可能性。那如果使用表驱动来实现,情况又如何呢? 代码如下:

enum msg_type{    MSG_A,    MSG_B,    MSG_C,};void processMsgA(int argc, char* argv[]){    //todo something.}void processMsgB(int argc, char* argv[]){    //todo something.}void processMsgC(int argc, char* argv[]){    //todo something.}typedef struct msg_item_s{    int msg;    void(*processMsg)(int argc, char* argv[]);}msg_item_t;static const msg_item_t g_msg_items[] ={    {MSG_A, processMsgA},    {MSG_B, processMsgB},    {MSG_C, processMsgC},};void msgProcess(int msg, int argc, char* argv[]){    int num_items = sizeof(g_msg_items) / sizeof(g_msg_items[0]);    for (int i = 0; i < num_items; i++)    {        if (g_msg_items[i].msg == msg)        {            g_msg_items[i].processMsg(argc, argv);        }    }   }

在使用表驱动法实现的消息处理函数中,不管是新增消息,还是去掉某些消息,都不会影响msgProcess函数的实现。只需要修改g_msg_items数组就可以了。更进一步,还可以使用一个API将g_msg_items数组封装起来,这样处理消息的函数processA,processB,processC等就可以专门用一个.c或.cpp文件来放置,这样就能和msgProcess这个机制函数彻底的隔离开来。

3.3 表驱动实现有限状态机

这里简单的使用多媒体播放器的几个API来说明表驱动实现有限状态机的机制。按照前面的先例,先来看看使用逻辑语句来实现API,如下所示:

enum player_state{    PLAY_STOP,    PLAY_PAUSE,    PLAY_PLAY,};typedef struct my_player_s {    int state;}my_player_t;void do_play() /** 播放器内部需要实现或执行的一些行为 */{    //todo something}void do_pause(){    //todo something}void do_stop(){    //todo something}void playerStop(my_player_t& player){    if (player.state != PLAY_STOP)    {        do_stop();        player.state = PLAY_STOP;    }}void playerPause(my_player_t& player){    if (player.state == PLAY_PLAY)    {        do_pause();        player.state = PLAY_PAUSE;    }}void playerPlay(my_player_t& player){    if (player.state != PLAY_PLAY)    {/** 很多情况,这里可能还要细分是PLAY_STOP状态,以及PLAY_PAUSE状态时 do_play的行为可能有差异 */        do_play();        player.state = PLAY_PLAY;    }}

可以看到虽然一个播放器的,PLAY,STOP,PAUSE等状态在API的实现函数中进行了状态切换,行车了有限状态机。但是这里的问题是,不能直观的看出状态转换的逻辑或规则。而且如果写好这份代码之后再增加或减少(比如去掉stop状态,只留下paly和pause)状态就会非常麻烦。

老规矩,接下来又该表驱动表演了。这里需要首先确定状态机的状态转换的机制:

动作\状态 PLAY PAUSE STOP doPlay na PLAY PLAY doPause PAUSE na na doStop STOP STOP na

上面的表格中,最上面一排表示播放器的当前状态,最左侧的一列表示要进行的动作, 表格中的其他内容,表示进行动作之后状态会变成的状态。na表示不需要动作或状态变化。

enum player_state{    PLAY_STOP,    PLAY_PAUSE,    PLAY_PLAY,    PLAY_STATE_MAX,};enum player_cmd{    DO_PLAY,    DO_PAUSE,    DO_STOP,};typedef struct my_player_s {    int state;}my_player_t;void do_play(my_player_t& player){    //todo something    player.state = PLAY_PLAY;}void do_pause(my_player_t& player){    //todo something    player.state = PLAY_PAUSE;}void do_stop(my_player_t& player){    //todo something    player.state = PLAY_STOP;}typedef void(*doCmdFunc_t)(my_player_t& player);/** state| STOP | PUASE | PLAY | */static const doCmdFunc_t g_state_map[][PLAY_STATE_MAX] ={    {do_play,  do_play, nullptr},/** action, do play */    { nullptr, nullptr, do_pause},/** action, do pause */    { nullptr, do_stop, do_stop},/** action, do stop */};void doPlayerCmd(my_player_t& player, int cmd){    doCmdFunc_t func = g_state_map[cmd][player.state];    if (func)    {        func(player);    }}void playerStop(my_player_t& player){    doPlayerCmd(player, DO_STOP);}void playerPause(my_player_t& player){    doPlayerCmd(player, DO_PAUSE);}void playerPlay(my_player_t& player){    doPlayerCmd(player, DO_PLAY);}

这里是比较简陋的例子实现(不要细究完不完善的问题),但大概原理是相似的,对于状态的转换规则的改变或者增加,减少状态,都只需要修改g_state_map这样一个二维数组就可以了。器转换规则也非常明了,当前处于什么状态,在当前状态下能做什么,不能做什么。能转到什么状态,不能转到什么状态等等都一目了然。

四、总结

虽然,表驱动看上去有不少优点,但是也如上面讲到的,表驱动或者数据驱动并非“银弹”,它们只是一些设计思路或技巧。使用时,也需要评估是否合适。比如使用一两个if逻辑语句就可以搞定,且明确知道后续也不会再有任何变化了。你非要去硬套表驱动,那就有些得不偿失了。在适合的时候可以使用表驱动代替if和switch等逻辑语句,但并不是说完全不用这些逻辑语句了,需要注意这其中的度。

另外,这里说的都是比较简单的数组类型的表,其实不管是数据驱动,还是表驱动,都是一类设计思路或技巧,他们并不局限在上面讲的范围之内。比如《代码大全》中,除了18章中在将表驱动,其他章节也有所涉及,比如第19章第1节:用决策表代替复杂表的逻辑。对于这一点,其实在游戏编程中也常常会用到,比如在游戏编程中,使用决策树来决定游戏中一些小兵的行为(其中决策树就是策略,游戏引擎就是机制)。这种方式可以说它是表驱动,也可以说它是数据驱动。

原创粉丝点击