严谨的代码风格

专业程序员与业余程序员之分主要在于一种态度,什么态度?专业态度

一、严谨的代码风格

傻瓜都可以写出机器能读懂的代码,但只有专业程序员才能写出人能读懂的代码。作为专业程序员,每当写下一行代时,要记得程序首先是给人读的,其次才是给机器读的。
  • 1
  • 2

命名要展示对象的功能。

文件名:单词小写,多个单词用下划线分隔。 
如: dlist.c (这里d代表double,是通用的缩写方法) 
注意: 文件名一定要能传达文件的内容信息,别人一看到文件名就是知道文件中放的是什么内容。只把一个类或者一类的代码放在一起是好的习惯,这样就很容易给文件取 一个直观的名字。业余爱好者常常把很多没关系的代码糅到一个文件中,结果造成代码杂乱无章,也很难给它取一个恰当的名字。

函数名:单词小写,多个单词用下划线分隔。 
如:find_node 
注意:同样,一个函数只完成单一功能,不要用代码的长度来衡量是不是要把一段代码独立 
成一个函数。即使只有几行代码,只要它完成的是一项独立的功 能,都应该提为一个单独 
的函数,而函数名可以直观的反应出它的功能。如果在给函数起名时遇到了困难,通常是函 
数设计不合理,应该仔细思考一下。

结构/枚举/联合名:首字母大写,多个单词连写。 
如:struct _DListNode;

宏名:单词大写,多个单词下划线分隔 
如:#define MAX_PATH 260

变量名:单词小写,多个单词下划线分隔。 
如:DListNode* node = NULL;

面向对象的命名方式

1.以对象为中心,采用主语(对象)+谓语(动作),取代传统的谓语(动作)+宾语(目标)。 
如:dlist_append 
2.第一个参数为对象,并用thiz命名。 
如:dlist_append(DList* thiz, void* value); 
3.对象有自己的生命周期,都有create和destroy函数。 
排版布局要美观大方。

合理使用空行

1.函数体之间用空行分隔。 
2.结构/联合/枚举声明空行分隔。 
3.不同功能的代码块之间用空行分隔。 
4.类似的代码放在一起,和其它部分用空行分隔。比如宏定义,类型定义,函数声明和全局变量放在一起。 
5.使用空行时,一行就够了,不要使用连续多个空行,那样让人感觉空荡荡。

合理使用空格

1.等号两边用空格。如: 
如:int a = 100; 
2.参数之间用空格。如: 
如:test(int a, int b, int c) 
3.语句末的分号与前面内容不要加空格。 
如:test(a, b, c); 
4.其它有助让代码更美观的地方。

合理使用括号

1.用括号分隔子表达式,不要只靠默认优先级来判断。 
如:((a && b) || (c && d)) 
2.用括号分隔if/while/for等语句的代码块,那怕代码只有一行。 
如:

if(a > b)
{
    return c;
}
  • 1
  • 2
  • 3
  • 4

合理的缩进方式

每一级都正常缩进,用tab缩进取代空格缩进(Linux kernel也遵循此规则)。用空格缩进的目的是防止代码因编辑器的tab宽度不同而变乱,这个担心现在是多余的了,代码编辑器都支持tab宽度设置了。 如果缩进的居次太多(比如超过三层),可能是代码设计上出了问题。 
如:

if(a > b)
{
    for(i = 0; i < 100; i++)
    {
        …
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

遵从团队的习惯

这个是最重要的,一个团队就要像一个团队的样子,不管你的水平有多高,遵循团队的规则是一个程序员的基本素养。如果团队的规则确实不好,大家应该一起完善它。

二、封装

1.what?

封装就是要保护好程序的隐私,不该让调用者知道的事,就坚决不要暴露出来。

2.why?

总体来说,封装主要有以下两大好处(具体影响后面再说): 
隔离变化 
程序的隐私通常是程序最容易变化的部分,比如内部数据结构,内部使用的函数和全局变量等等,把这些代码封装起来,它们的变化不会影响系统的其它部分。 
降低复杂度 
接口最小化是软件设计的基本原则之一,最小化接口容易被理解和使用。封装内部实现细节,只暴露最小的接口,会让系统变得简单明了,在一定程度上降低了系统的复杂度。

3.how?

隐藏数据结构 
暴露内部数据结构,会使头文件看起来杂乱无章,让调用者发蒙。其次是如果调用者图方便,直接访问这些数据结构的成员,会造成模块之间紧密耦合,给以后的修改带来困难。 
隐藏数据结构的方法 
如果是内部数据结构,外面完全不会引用,则直接放在C文件中就好了,千万不要放在头文件里; 
如果该数据结构在内外都要使用,则可以对外暴露结构的名字,而封装结构的实现细节。

做法如下: 
在头文件中声明该数据结构。 
如:

struct _LrcPool;
typedef struct _LrcPool LrcPool;
  • 1
  • 2

在C文件中定义该数据结构。

struct _LrcPool
{
    size_t unit_size;
    size_t n_prealloc_units;
};
  • 1
  • 2
  • 3
  • 4
  • 5

提供操作该数据结构的函数,哪怕只是存取数据结构的成员,也要包装成相应的函数。 
如:

void* lrc_pool_alloc(LrcPool* thiz); 
void lrc_pool_free(LrcPool* thiz, void* p);
  • 1
  • 2

提供创建和销毁函数。因为只是暴露了结构的名字,编译器不知道它的大小(所占内存空间),外部可以访问结构的指针(指针的大小的固定的),但不能直接声明结构的变量,所以有必要提供创建和销毁函数。 
如:这样是非法的:

LrcPool lrc_pool;
LrcPool* lrc_pool_new(size_t unit_size, size_t n_prealloc_units); 
void  lrc_pool_destroy(LrcPool* thiz);
  • 1
  • 2
  • 3

任何规则都有例外。有些数据结构纯粹是社交型的,为了提高性能和方便起见,常常不需要对它们进行封装,比如点(Point)和矩形(Rect)等。当然封装也不是坏事,MFC就对它们作了封装,是否需要封装要根据具体情况而定。 
隐藏内部函数 
内部函数通常实现一些特定的算法(如果具有通用性,应该放到一个公共函数库里),对调用者没有多大用处,但它的暴露会干扰调用者的思路,让系统看起 来比实际的复杂。函数名也会污染全局名字空间,造成重名问题。它还会诱导调用者绕过正规接口走捷径,造成不必要的耦合。 
隐藏内部函数的做法很简单: 
在头文件中,只放最小接口函数的声明。 
在C文件上,所有内部函数都加上static关键字。

禁止全局变量 
除了为使用单件模式(只允许一个实例存在)的情况外,任何时候都要禁止使用全局变量。这一点我反复的强调,但发现初学者还是屡禁不止,为了贪图方便而使用全局变量。请读者从现在开始就记住这一准则。 
全局变量始终都会占用内存空间,共享库的全局变量是按页分配的,那怕只有一个字节的全局变量也占用一个page,所以这会造成不必要空间浪费。全局变量也会给程序并发造成困难,想把程序从单线程改为多线程将会遇到麻烦。重要的是,如果调用者直接访问这些全局变量,会造成调用者和实现者之间的耦合。 
在整个系统程序员成长计划中,我们都是以面向对象的方式来设计和实现的(封装就是面向对象的主要特点之一)。为了避免不必要的概念混淆,这里先解释一下对象和类: 
关于对象:对象就是某一具体的事物,比如一个苹果, 一台电脑都是一个对象。每个对象都是唯一的实例,两个苹果,无论它们的外观有多么相像,内部成分有多么相似,两个苹果毕竟是两个苹果,它们是两个不同的对象。对象可以是一个实物,也可以是一个概念,比如一个苹果对象是实物,而一项政策就是一个概念。在软件中,对象是一个运行时概念,它只存在于运行环境中, 比如:代码中并不存在窗口对象这样的东西,要创建一个窗口对象一定要运行起来才行。 
关于:对象可能是一个无穷的集合,用枚举的方式来表示对象集合不太现实。抽象出对象的特征和功能,按此标准将 对象进行分类,这就引入类的概念。类就是一类事物的统称,类实际上就是一个分类的标准,符合这个分类标准的对象都属于这个类。当然,为了方便起见,通常只 需要抽取那些对当前应用来说是有用的特征和功能。在软件中,类是一个设计时概念,它只存在于代码中,运行时并不存在某个类和某个类之间的交互。我们说,编写一个双向链表,实际上指的是双向链表这个类。

三、构造通用的链表

1.专用链表和通用链表各自的特点与适用范围。

专用链表在这里是指它的实现和调用耦合在一起,只能被一个调用者使用,而不能单独在其它地方被重用。通用链表则相反,它具有通用性,可以在多处被重 复使用。尽管通用链表相对专用链表来说有很多优越之处,不过简单的断定通用链表比专用链表好也是不公正的,因为它们都有自己的优点和适用范围: 
专用链表的优点: 
更高性能。专用链表的实现和调用在一起,可以直接访问数据成员,省去了包装函数带来的性能开销,可以提高时间性能。专用链表无需实现完整的接口,只要满足自己的需要就行了,生成的代码更小,因此可以提高空间性能。 
更少依赖。自己实现不用依赖于别人。有时候你要写一个规模不大的跨平台程序,比如想在展讯手机平台和MTK手机平台上运行,虽然有现存的库可用,但你又不想把整个库移植过去,那么实现一个专用链表是不错的选择。 
实现简单。实现专用链表时,不需要考虑在各种复杂应用情况下的特殊要求,也不需要提供完整的接口,所以实现起来比通用链表更为简单。 
通用链表的优点(从全局来看): 
可靠性更高。通用链表的实现要复杂得多,复杂的东西意味着不可靠。但它是可以重复使用的,其存在的问题会随每一次重用而被发现和改正,慢慢的就行成一个可靠的函数库。 
开发效率更高。通用链表的实现要复杂得多,复杂的东西也意味着更高的开发成本。同样因为它是可以重复使用的,开发成本会随每一次重用而降低,从整个项目来看,会大大提高开发效率。 
考虑到链表是最常用的数据结构之一,很多地方都会用到它,实现通用的链表会更有价值。

2.如何编写一个通用的链表?

编写通用链表是一项复杂的任务,不可能在这一节中把它阐述清楚,这里我们先考虑三个问题: 
存值还是存指针 
通用链表首先是要做能够存放任何数据类型的数据,新手常见的做法是定义一个抽象数据类型,需要什么存放什么就定义成什么。如:

typedef int Type;
typedef struct _DListNode
{
    struct _DListNode* prev;
    struct _DListNode* next;
    Type data;
}DListNode;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这样的链表算不上是通用的,因为你存放整数时编译一次,存放字符串时,重义Type再编译一次,存放其它类型同样要重复这个过程。麻烦不说,关键是 没有办法同时使用多个数据类型。我们要找到一种同时可以表示不同数据类型的类型才行,有人说可以用union,但是数据类型是无穷无尽的,不可能在 union中表示它们的全部。 
可行的办法有两种: 
存值:

typedef struct _DListNode
{
    struct _DListNode* prev;
    struct _DListNode* next;
    void* data;
    size_t length;
}DListNode;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

存入时拷贝一份数据,保存数据的指针和长度。考虑到拷贝数据会带来性能开销,不合符C语言的风格,而且C语言中没有构造函数,实现深拷贝比较麻烦,所以在C语言中以这种方式实现的链表很少见。 
存指针:

typedef struct _DListNode
{
    struct _DListNode* prev;
    struct _DListNode* next;
    void* data;
}DListNode;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

只是保存指向对象的指针,存取效率高,是C语言中常见的做法。在存放整数时,可以把void*强制转换成整数使用,以避免内存分配(在现实中,90%以上的情况,链表都是存放结构的)。 
让C++可以调用 
这不是一个重要的话题,只是顺便提一下。C++中允许同名函数存在,所以编译器会对函数名重新编码。C++代码包含C语言的头文件时,重新编码名字与C语言库中的原函数名不一致,结果造成找不到函数的情况。为了让C语言实现的函数在C++中可以调用,需要在头文件中加点东西才行:

#ifdef __cplusplus
extern "C" {
#endif
…
#ifdef __cplusplus
}
#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

它表示如果在C++中调用这里的函数,编译器不能对函数名进行重新编码。 
完整的接口 
作为一个通用的链表,接口要比较完整才行,否则无法满足各种情况的需要(提供完整的接口并不违背最小接口原则)。实现具有完整接口的链表不是件容易的事,读者先实现插入删除等基本操作就行了,后面我们会慢慢扩展它的功能。

四、使用函数指针

大部分初学者在编写双向链表时,为了验证相关函数工作是否正常,都会编写一个dlist_print的函数,它的功能是在屏幕上打印出整个双向链表中的数据。从客观上讲,用dlist_print输出的信息来判断dlist的正确性不是最好的办法,不过脑袋里有质量概念总是值得表 扬的。当把专用的双向链表演化成通用的双向链表时,编写一个dlist_print已经不那么简单了。 
在专用双向链表中,dlist_printf的实现非常简单,如果里面存放的是整数,用”%d”打印,存放的字符串,用”%s”打印。现在的麻烦在于双向链表是通用的,我们无法预知其中存在的数据类型,也就是我们要面对数据类型的变化。 
这里我们要介绍一种新的方法: 
dlist_print的大体框架为:

DListNode* iter = thiz->first;
while(iter != NULL)
{
    print(iter->data);
    iter = iter->next;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

在上面代码中,我们主要是不知道如何实现print(iter->data);这行代码。可是谁知道呢?很明显,调用者知道,因为调用者知道 里面存放的数据类型。OK,那让调用者来做好了,调用者调用dlist_print时提供一个函数给dlist_print调用,这种回调调用者提供的函 数的方法,我们可以称它为回调函数法。 
调用者如何提供函数给dlist_print呢?当然是通过函数指针了。变量指针指向的是一块数据,指针指向不同的变量,则取到的是不同的数据。函 数指针指向的是一段代码(即函数), 
指针指向不同的函数,则具有不同的行为。函数指针是实现多态的手段,多态就是隔离变化的秘诀,这里只是一个开端,后面 我们会逐步的深入学习。 
回到正题上,我们看如何实现dlist_print: 
定义函数指针类型: 
typedef DListRet (DListDataPrintFunc)(void data); 
声明dlist_print函数: 
DListRet dlist_print(DList* thiz, DListDataPrintFunc print); 
实现dlist_print函数:

DListRet dlist_print(DList* thiz, DListDataPrintFunc print)
{
    DListRet ret = DLIST_RET_OK;
    DListNode* iter = thiz->first;
    while(iter != NULL)
    {
        print(iter->data);
        iter = iter->next;
    }
    return ret;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

调用方法

static DListRet print_int(void* data)
{
printf("%d ", (int)data);
return DLIST_RET_OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5

dlist_print(dlist, print_int); 
所有问题都解决了,是不是很简单? 我以前写过一篇关于函数指针的BLOG,文中声称不懂函数指针就不要自称是C语言高手,现在我仍然坚持这个观点。函数指针的概念本身很简单,关键在于灵活应用,这里是一个最简单的应用,希望读者仔细体会一下,后面将会有大量篇幅介绍。

五、函数指针回传值

这里我们请读者实现下列功能: 
对一个存放整数的双向链表,找出链表中的最大值。 
对一个存放整数的双向链表,累加链表中所有整数。 
多写多练,不要偷懒,写完之后请仔细思考一下有无改进的余地。 
实现这两个函数并不是件难事,但真正写好的人并不多。初学者通常的做法有两种: 
1.各写一个独立的函数。dlist_find_max用来找出最大值,dlist_sum用来求和。这种做法和前面写dlist_print时所犯的错误一样,会造成重复的代码,让dlist的实现随着应用环境的变化 
而变化。 
2.采用回调函数法。细心的初学者会发现,这两个函数的实现与dlist_print的实现很类似,无非是print那行代码要换成别的功能。能想 到这一点很好,不过在真正动手时,发现每个回调函数都要保存一些中间数据。大部分人选择了用全局变量来保存,这可以实现要求的功能,但违背了禁用全局变量 原则。 
这两个函数没有什么实用价值,但是通过它们我们可以学习几点: 
1.不要编写重复的代码 
写重复的代码很简单,甚至凭本能都可以写出来。但要想成为优秀的程序员,你一定要克服自己的惰情,因为重复的代码造成很多问题: 
重复的代码更容易出错。在写类似代码的时候,几乎所有人(包括我)都会选择Copy&Paste的方法,这种方法很容易犯一些细节上的错误,如果某个地方修改不完整,那就留下了”不定 
时”的炸弹,说不定什么时候会暴露出来。 
重复的代码经不起变化。无论是修改BUG,还是增加新特性,往往你要修改很多地方,如果忘掉其中之一,你同样得为此付出代价。请记住古惑仔的话,出来混迟早是要还的。大师们说过,在软件中欠下的BUG,你会为此还得更多。去除重复代码往往不是件简单的事情,需要更多思考和更多精力,不过事实证明这是最值得的投资。在这里,我们要怎么抽取这些重复的代码呢? 
这三个函数无非是要遍历双向链表并做一些事情,遍历双向链表我们可以提供一个dlist_foreach函数,至于要做什么,这是千变万化的行为,可以通过回调函数让调用者去做。 
2.任何回调函数都要有上下文 
大部分初学者都选择了回调函数法,不过都无一例外的选择了用全局变量来保存中间数据,这里我不想再强调全局变量的坏处了,记性不好的读者可以看看前面的内容。我们要说的是, 
在这种情况下,如何避免使用全局变量。 
很简单,给回调函数传递额外的参数就行了。这个参数我们称为回调函数的上下文,变量名用ctx(context的缩写)。要在这个上下文中存放什么东西呢?那得根据具体的回调函数而定,为了能保存任何数据类型,我们选择void*表示这个上下文。
下面我们看看怎么实现这个dlist_foreach:

DListRet dlist_foreach(DList* thiz, DListVisitFunc visit, void* ctx)
{
    DListRet ret = DLIST_RET_OK;
    DListNode* iter = thiz->first;
    while(iter != NULL && ret != DLIST_RET_STOP)
    {
        ret = visit(ctx, iter->data);
        iter = iter->next;
    }
    return ret;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

visit是回调函数,ctx就是我们说的上下文。要特别强调的一点是,ctx应该作为回调函数的第一个参数。为什么呢?在前面我们讲过的面向对象的函数命名规则中,我们以thiz作为函数的第一个参数,而thiz通常也就是函数的上下文。如果在这里恰好ctx==thiz,就不需要因为参数顺序不同而做转换了。 
实现求和的回调函数:

static DListRet sum_cb(void* ctx, void* data)
{
    long long* result = ctx;
    *result += (int)data;
    return DLIST_RET_OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

调用foreach: 
long long sum = 0; 
dlist_foreach(thiz, sum_cb, &sum); 
是不是很简单?以后在使用回调函数时,记得多加一个ctx参数,即使暂时用不着,留着方便以后扩展。好了,请读者用类似的方法实现查找最大值的功能吧。 
3.只做份内的事 
我见到不少任劳任怨的程序员,别人让他做什么他就做什么,不管是不是份内的事,不管是上司要求的还是同事要求的,都来者不拒。别人说需要一个XXX 功能的函数,他就写一个函数在他的模块里,日积月累后,他的模块变得乱七八糟的,成了大杂烩。我亲眼见过在系统设置和桌面两个模块里,提供很多毫不相干的 函数,这些函数造成不必要的耦合和复杂度。 
在这里也是一样的,求和和求最大值不是dlist应该提供的功能,放在dlist里面实现是不应该的。为了能实现这些功能,我们提供一种满足这些需求的机制就好了。热心肠是好的,但一定不能违背原则,否则就费力不讨好了。

猜你喜欢

转载自blog.csdn.net/hk121/article/details/81098772