标签 博客 下的文章

C语言也重构

所谓的重构是这样的一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种有纪律的、经过训练的、有条不紊的程序整理方法,可以将整理过程中不小心引入错误的机率降到最低,本质上说,重构就是[在代码写好之后改进它的设计]。Martin Fowler

重构,一种改善代码’体质’的方法’。侯捷

重构是上进程序员每天的进行式。是一项工程而不是靠着天份挥洒的艺术。– 侯捷

如果有人说’重构’仅仅限于Java语言或者其他面向对象的语言,那他就大错特错了。按照Martin Fowler的诠释,’重构’就是一种代码整理,而且是在保持接口功能不变的一种代码整理,这样的话每种语言都可以’重构’,也许’重构’在你平时的工作学习中已经不知不觉地发生了,只是你还没有认识到那是’重构’罢了。自从从大学毕业后我就一直在’把弄’C语言,对C语言也算是’情有独钟’了,所以自然而然的就开始探索和积累C语言的重构方法。这里个人能力毕竟有限,并不能穷尽所有方法。

按照我的理解,重构[注1]应该是’三位一体’的,即"前提(或叫保证) + 理论 + 工具",前提(或保证)指的是单元测试,回顾一下本篇首Martin Fowler对重构的诠释 — ‘在不改变代码行为的前提下’,而如何保证代码经过’大修’后,能保证其外在行为不变呢,只有测试;理论则是指重构的来龙去脉、理论和方法了,这也是本篇所主要关注的;至于工具,那就是重构’催化剂’,保证对大型项目重构的效率,遗憾的是至今业界仍没有一款成熟的支持C语言的重构工具,而与此相反针对像Java语言这样的面向对象语言,各厂家则是’众星捧月’,工具也不断推陈出新,其中缘由也不难理解。

Internet上讨论C重构的资料少之又少,个人认为比较全面的是Alejandra Garrido的’CRefactory project‘,说这份资料比较全面是因为Alejandra Garrido将之分类清楚(但也许不是所有人都喜欢这样分类的,我就是其中之一),但是这份资料不够详细,关于每条item的说明仅仅几行,而且没有例子。不过不管怎样,这份资料都是值得看的,它也许会给你提供一些思路上的提示。

关于重构理论最权威的莫过于Martin Fowler那本’Refactoring – Improving the Design of Existing Code’了,虽然Martin青睐Java,但这并不太多影响其他’语种’的小工们从这本大师的书中汲取’思想的精华’。

在这里我仅针对C代码中的’Bad Smell’给出自己的重构想法,之所以没有像Martin书中那样给出分类,也许是因为自己对重构的理解还不那么到位的缘故^_^,也许以后会给出像模像样、合情合理的分类,好了,下面我们就一起开始嗅嗅代码的味道吧。

1、Duplicated Code
重复代码自古有之,历史悠久自不必说了。Martin书中将之放在Bad Smell的第一位,足见其’Bad Enough’^_^。’Duplicated Code’将给你带来诸多不便,诸如维护更多的代码、代码修正后可能的不一致性等,这里列举三种情况来具体说明How smelly the ‘Duplicated Code’ is!

(1) 太多的128、256…
[e.g.]
void func1(…) {
 char path[128];
 …
}

void func2(…) {
 char path[256];
 …
}

[solution & notice]
#define  MAX_PATH 256

void func1(…) {
 char path[MAX_PATH];
 …
}

void func2(…) {
 char path[MAX_PATH];
 …
}

避免在程序中多次出现magic number,特别是当多个magic number重复时,坏的味道就散发出来了,我们可以定义一些常量符号还替换这些magic number。

(2) 一山难容二虎
[e.g.1]
void func1() {
        time_t          in_tm;
        time_t          out_tm;
        char            in_str[100];
        char            out_str[100];
        struct tm       *ptm = NULL;

        memset(in_str, 0, sizeof(in_str));
        memset(out_str, 0, sizeof(out_str));

        in_tm   = time(NULL);
        out_tm  = time(NULL);

        ptm = localtime((time_t*)&(in_tm));
        sprintf(in_str, "%d%02d%02d%02d%02d%02d",
                ptm->tm_year+1900,
                ptm->tm_mon +1,
                ptm->tm_mday,
                ptm->tm_hour,
                ptm->tm_min,
                ptm->tm_sec);

        ptm = localtime((time_t*)&(out_tm));
        sprintf(out_str, "%d%02d%02d%02d%02d%02d",
                ptm->tm_year+1900,
                ptm->tm_mon +1,
                ptm->tm_mday,
                ptm->tm_hour,
                ptm->tm_min,
                ptm->tm_sec);
}

[e.g.2]
//in test.c
void func1() {
        time_t          in_tm;
        char            in_str[100];
        struct tm       *ptm = NULL;

        memset(in_str, 0, sizeof(in_str));

        in_tm   = time(NULL);

        ptm = localtime((time_t*)&(in_tm));
        sprintf(in_str, "%d%02d%02d%02d%02d%02d",
                ptm->tm_year+1900,
                ptm->tm_mon +1,
                ptm->tm_mday,
                ptm->tm_hour,
                ptm->tm_min,
                ptm->tm_sec);
}

// in test.c, too
void func2() {
        time_t          out_tm;
        char            out_str[100];
        struct tm       *ptm = NULL;

        memset(out_str, 0, sizeof(out_str));

        out_tm   = time(NULL);

        ptm = localtime((time_t*)&(out_tm));
        sprintf(out_str, "%d%02d%02d%02d%02d%02d",
                ptm->tm_year+1900,
                ptm->tm_mon +1,
                ptm->tm_mday,
                ptm->tm_hour,
                ptm->tm_min,
                ptm->tm_sec);
}

[solution & notice]
在一个函数中存在功能相同的’代码群落’(如e.g.1)或者是在同一个源文件中在不同的函数实现中存在功能相同的’代码群落’(如e.g.2),这是典型的’Duplicated Code’,解决方法很简单 — 析出函数(Extract Function)或者析出宏(Extract Macro, 这可是C语言的特色哟)。如解决上面两个例子中的问题我们便可以这么做:
a) 析出函数time2str
void time2str(char *s, const time_t t) {
        time_t          tmp     = t;
        struct tm       *ptm    = NULL;

        ptm = localtime((time_t*)&(tmp));

        sprintf(s, "%d%02d%02d%02d%02d%02d",
     
           ptm->tm_year+1900,
                ptm->tm_mon +1,
                ptm->tm_mday,
                ptm->tm_hour,
                ptm->tm_min,
                ptm->tm_sec);
}

b) 析出宏TIME_2_STR
#define TIME_2_STR(s, t)     do { \
                struct tm *ptm = localtime((time_t*)&t); \
                sprintf(s, "%d%02d%02d%02d%02d%02d", \
                        ptm->tm_year+1900, \
                        ptm->tm_mon +1, \
                        ptm->tm_mday, \
                        ptm->tm_hour, \
                        ptm->tm_min,  \
                        ptm->tm_sec); \
        } while(0)

用time2str或TIME_2_STR替换掉那些重复的代码后,就会发现原来世界可以这么简洁!

(3) 你中有我,我中有你
[e.g.]
/* foo1.c in project foo */
static int foo1_mkdir(const char *path) {
 …
}

/* foo2.c in project foo */
static int foo2_mkdir(const char *path) {
 …
}

[solution & notice]
/* foo_common.c */
static int foo_mkdir(const char *path) {
 …
}

/* foo1.c */
#include "foo_common.h"

/* foo2.c */
#include "foo_common.h"

在较大的工程中,多个程序员并行工作,不可避免的在各自的代码中实现了相同功能的一些方法,这就是’Bad Smell’,这样程序员要维护两份实现相同功能的代码,而且当fix bug时很有可能漏掉更新另一份。这种的散落在不同源文件中重复代码可以采用’Pull Up Method’方法将这个功能接口提取到一个公共的文件中去实现,而原先的两个文件只需包含接口头文件即可。

2、Long Function/Procedure/Method
事实证明越长的Function,理解起来越难,维护难度也越大。拆分函数意味着要把代码中一个功能相对独立的’代码群落’独立出去,这样其实带来了很多好处。首先你又给了自己一次重新阐述代码意图的机会,你可以起一个恰当的名字来表述这段被独立出去的’代码群落’的’工作性质’;其次一旦这段’代码群落’被独立出去了,它就可以被其他函数逻辑所共享;再者独立出去的’代码群落’与原先的代码的关系照比之前已经不那么紧密,所以一旦’代码群落’里发生什么’叛乱’,其不会蔓延到原先的代码中。在Martin书中曾专门引用Kent Beck的一段关于’Indirection and Refactoring’的论述来说明’拆分’的利弊,而在整个重构的理论中’拆分’的确也占据着’主流’地位。这里就不举例说明了,拆分也是我们平时经常做的事情,不是吗!:)

3、Long Parameter List
长长的参数列表虽然不是致命的,但却是最最不顺眼的。想想当你去调用一个接口时发现它有10个参数,为完成这个调用你需要来来回回看N遍那个接口的原型还不见得传入的实参全部符合原型要求。实际情况就是这样,而且遭遇这种情况的时候还是很多的。怎么办?重构!如何做?看下面例子。

[e.g]
int x_add_book(const char *title,
                const char *author,
                const char *isbn,
                const char *pub_date,
                int pages,
                int price,
                const char *publisher,
                const char *pub_addr,
                const char *pub_homepage);

[solution & notice]
哇,添加一本书要这么参数亚,晕倒!让它少些参数吧 — 合并!这个例子中的参数之间有相关性,可以尝试组合一下。组合完可能是这样的:
struct pub_info {
        char            name[MAX_NAME_LEN];
        char            addr[MAX_STR_LEN];
        char            homepage[MAX_STR_LEN];
};

struct book_info {
        char            title[MAX_NAME_LEN];
        char            author[MAX_NAME_LEN];
        char            isbn[MAX_STR_LEN];
        char            pub_date[MAX_STR_LEN];
        int             pages;
        int             price;
        struct pub_info pub;
};

int x_add_book(const struct book_info *book);
重构完了。不知道大家发现这样修改的好处没有,比如现在我想在添加一本书时,再加上这本书的位置信息,如放在那个书架上,这样如果我们使用e.g.中的做法,我们需要修改接口;而使用重构之后的方式我们只需要修改book_info结构体,x_add_book接口则无需修改!这也是增加一个间接层的好处之一!

那么是不是所有的长参数列表的函数都可以这么重构呢?有些时候参数列表中的参数都是毫无联系的,如果硬要把他们’组合’在一起反倒不顺眼,这也符合’强扭的瓜不甜’的道理:) 这时候可以考虑某个参数是否可以调用其他函数得到该值,如果可以的话,我们就可以在删除该参数而改在函数体中调用其他接口获取这个值,这种方法类似于Martin书中的’Replace Parameter with Method’;尽量不要用全局变量来代替某一参数,过多的全局变量也是Bad Smell!

如果还不行的话,没办法,保留这一长参数列表的接口,千万不要为了’哨子’而付出太多的代价!

4、Comments
我们不是反对注释,注释是很好的增强代码可读性和可维护性的手段,而且注释有时会帮助你发现代码中的味道!想象一下我们的注释一般会出现在什么样的位置呢?如下图:
/*
 * 注释1
 */
return_type func_name(params list…) {
 /*
  * 注释2
  */
 …

 /*
  * 注释3
  */
 …’代码群落’…
 
}

一般我们在’注释1′的位置描述函数的功能,有时候我们发现这段注释描述比函数名更加清楚,这也是一个不好的味道,这时我们可以使用’Rename Method’来重构之;在注释2的位置我们可能会描述一些’precondition’,这里不妨用’Introduce Assertion’来替换掉这些注释;注释3会详尽介绍’代码群落’干的是什么活,仔细考量,看看能否将这段’代码群落’用’Extract Method’方法析出,而这段注释恰恰可以为析出后的函数作命名之用。另外’Rename Method’和’Introduce Assertion’两种重构技巧较简单,望文即可生义,自然无需多说!

5、其他
Alejandra Garrido的’CRefactory project’中的一些条目还是对我很有启发性的,这里仅列出我认为必要的。
(1) contract variable scope
我将之理解为:’约束变量的作用范围’:一个在特定作用范围内定义的变量仅仅被该范围内的一个inner scope中的代码所使用,我们大可把这个变量声明移到这个inner scope中。

(2) Replace expression with variable
这其实也是个’Duplicated Code’问题,只是重复的’代码群落’是一个expression,如 (i < 10) ? 1 : 0条件表达式等,这样的话我们把它赋值给一个变量,然后在以后使用这个变量即可。

(3) Convert global variable into parameter
对于这个条目我们可以举个例子:
[e.g]
int g_cnt = 1;

void fun1() {
 if (g_cnt > 10) {
  g_cnt = 1;
 } else {
  g_cnt++;
 }
}

void func2() { /> g_cnt += 3;
}

void func() {
 if (g_cnt == 2) {
  func1();
 } else {
  func2();
 }
}

[solution & notice]
在这个例子中func、func1和func2都对全局变量g_cnt进行了访问,并且都有权对g_cnt进行修改,而实际func1和func2的设计意图可能就是func的辅助函数,负责对func需要操作的变量进行修改,而不一定都需要访问全局变量,这时我们可以这样修改:
int g_cnt = 1;

void fun1(int *cnt) {
 if (*cnt > 10) {
  (*cnt) = 1;
 } else {
  (*cnt)++;
 }
}

void func2(int *cnt) {
 (*cnt) += 3;
}

void func(int *cnt) {
 if (*cnt == 2) {
  func1(cnt);
 } else {
  func2(cnt);
 }
}

func(&g_cnt);

感觉这个条目的目的就是尽量减少有权访问全局变量的访问点个数。

OK! C语言并不复杂,所以重构的技巧、代码的smell也不如面向对象语言那么多,呵呵,这里我就想到这么多了!最后别忘了重构仅仅是一种有计划有规则的’代码整理’,它并不是那种不可逾越的技术高峰。在积累了一段时间的重构’经验’后,重构完全可能会成为你开发过程中的一个不可缺少的习惯了,而养成习惯的必经之路就是持续不断的练习、积累和总结!让自己从此走上’C语言重构之路’吧。

[注1]
关于重构重要性、重构时机等话题在Martin书中有详尽说明!

当数组作参数时

C语言中的数组和指针总保持着'千丝万缕'的联系,这里仅针对数组作为函数实参时的情况做些说明^_^。

C语言中的数组可分为一维数组和多维数组两类,而多维数组中又以二维数组最为常见。这里也仅针对这一维数组和二维数组作简要说明。

看过'高质量C++编程指南'的人可能都知道书中有这样一句'注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针',这句话针对一维数组固然是正确的,但是对于多维数组,这显然不完全正确。但是如果你说C语言中的数组默认就指一维数组,那么这也就过得去了。C语言之所以把数组形参当作指针是出于效率考虑,试想如果把一个数组全部拷贝这样势必带来性能上的损失,如果数组很大的话,这种完全拷贝的方法就是不能忍受的了。所以目前无论你在函数声明中像'void func1(char a[])'这样写,还是像'void func1(char *a)',编译器都会把它看成后者的形式,对于一维数组,显然这没什么可说的,但是对于二维数组来说,其中还有不少值得商榷的地方。

C语言中的二维数组可以看作为'数组的数组',而且其采用'行主序',即'最右边的下标'是最先变化的。由于指针和数组的关系导致,二维数组可以广义表示为多种形式:
(1) char a[m][n] — 标准形式;
(2) char *p[n] — 指针数组形式;
(3) char (*p)[n] — 数组(行)指针的形式
(4) char **p — 指针的指针的形式

这些形式虽然都能表示二维数组,但是它们并不等价,这也给参数原型设计带来一定的不便,不过二维数组作为参数后的转化还是有原则可循的,那就是:'数组的数组'被转换为'数组的指针',下面就逐一说说每种形式对应的函数参数原型,通过例子认真体会一番:
(1) char a[m][n] — void func(char (*p)[]); 二维数组退化为数组的指针,关于如何声明数组的指针,可以参见"理解C复杂声明之'优先级规则'"和"C复杂声明解析"两篇文章;
(2) char *p[n] — void func(char **p); 这个是一个指针数组,我们只需要取地址即可;
(3) char (*p)[n] — void func(char (*p)[]); 这个本身就是一个数组指针,原封不动即可;
(4) char **p — void func(char **p); 对于指针的指针类型,同样原封不动。

三维以上的数组不常用,用起来也较复杂,这里不作说明。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats