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书中有详尽说明!
评论