什么时候才能成为一个专业程序员呢?三年还是五年工作经验?其实不用的,你马上就可以了,我没有骗你,因为专业程序员与业余程序员的区别主要在于一种态度,如果缺乏这种态度,拥有十年工作经验也还是业余的。
什么态度?专业态度!也就是星爷常说的专业精神。专业态度有多种表现形式,以后我们会一一介绍的。这里先介绍一下有关形象的态度,专业的程序员是很注重自己的形象的,当然程序员的形象不是表现在衣着和言谈上,而是表现在代码风格上,代码就是程序员的社交工具,代码风格可是攸关形象的大事。
有人说过,傻瓜都可以写出机器能读懂的代码,但只有专业程序员才能写出人能读懂的代码。作为专业程序员,每当写下一行代码时,要记得程序首先是给人读的,其次才是给机器读的。你要从一个业余程序员转向专业程序员,就要先从代码风格开始,并从此养成一种严谨的工作态度,生活上的不拘小节可不能带到编程中来。
代码风格有很多种,Windows 和Linux都有自己主流的代码风格,每个团队、每个公司也可能有自己的代码风格,争论哪种风格好哪种风格坏根本没有什么意义。有助于其他程序员理解的代码风格都是可以接受的,因为遵循特定代码风格的目的就是为了便于交流。
1 命名要展示对象的功能
1.1 文件名
文件名一定要能传达文件的内容信息,别人一看到文件名就能知道文件中放的是什么内容。把一个类的代码或者某一类代码放在一起是好的习惯,这样就很容易给文件取一个直观的名字。业余爱好者常常把很多没关系的代码糅到一个文件中,结果造成代码杂乱无章,也很难给它取一个恰当的名字.
1.2 函数名
单词小写,多个单词用下划线分隔。如:find_node
一个函数只完成单一功能。不要用代码的长度来衡量是否要把一段代码独立成一个函数。即使只有几行代码,只要这些代码完成的是一项独立的功能,都应该将其写为一个单独的函数,而函数名要能够直观地反应出它的功能。如果在给函数起名时遇到了困难,通常是函数设计不合理,则应该仔细思考一下并对函数进行相应修改。
1.3 结构/枚举/联合名
首字母大写,多个单词连写。如:struct _DListNode
宏名:单词大写,多个单词下划线分隔。
如:#define MAX_PATH 260
变量名:单词小写,多个单词下划线分隔。
如:DListNode* node = NULL;
1.4 面向对象命名方式
(1) 以对象为中心,采用“主语(对象)+谓语(动作)”的形式来命名,取代传统的“谓语(动作)+宾语(目标)”的形式。
如:dlist_append
(2) 第一个参数为对象,并用thiz命名。
如:dlist_append(DList* thiz, void* value);
(3) 对象有自己的生命周期,因此都有相应的创建和销毁函数。
2 排版布局要美观大方
2.1 合理使用空行
函数体之间用空行分隔。
结构/联合/枚举声明用空行分隔。
不同功能的代码块之间用空行分隔。
将功能类似的代码(如宏定义、类型定义、函数声明和全局变量)放在一起,和其他部分用空行分隔。
使用空行时,一行就够了,不要使用连续多个空行,那样会让人感觉代码段空荡荡的。
2.2 合理使用空格
等号两边用空格。如:int a = 100;
参数之间用空格。如:test(int a, int b, int c)
语句末的分号与前面内容不要加空格。如:test(a, b, c);
其他能让代码更美观的地方。
2.3 合理使用括号
用括号分隔子表达式,不要只靠默认优先级来判断。((a && b) || (c && d))
用括号分隔if/while/for等语句的代码块,那怕代码只有一行。
2.4 合理缩进
每一级都正常缩进,用tab缩进取代空格缩进(Linux内核源代码也遵循此规则)。用空格缩进的目的是防止代码因编辑器的tab宽度不同而变乱,这个担心现在是多余的 了,代码编辑器都支持tab宽度设置了。如果代码缩进的层次太多(比如超过三层),则可能是代码设计上出了问题。
2.5 遵从团队的习惯
这一点是最重要的,一个团队就要有一个团队的样子,不管你的水平有多高,遵循团队的规则是一个程序员的基本素养。如果团队的规则确实不好,大家应该一起完善它。做到这一点,你已经离成为专业程序员这个目标更近一步了,重新做一遍练习吧。随着后面的学习,你就可以真正走进专业程序员这个行列了。
3 谁动了你的隐私
3.1 什么是封装
人有隐私,程序也有隐私。有隐私不是什么坏事,问题是不应该让别人知道自己的隐私,否则可能会对自己造成不小的伤害,甚至会连累相关人物跟着倒霉。程序隐私的暴露,造成的不良影响不一定会泄露个人隐私那么大,但也不容小觑。封装就是要保护好程序的隐私,不该让调用者知道的事,就坚决不要暴露出来。
3.2 为什么要封装
总的来说,封装主要有以下两大好处。
隔离变化。程序的隐私通常是程序最容易变化的部分,比如内部数据结构、内部使用的函数和全局变量等,我们需要把这些代码封装起来,从而让它们的变化不会影响系统的其他部分。
降低复杂度。接口最小化是软件设计的基本原则之一,最小化的接口容易被理解和使用。封装内部实现细节,只暴露最小的接口,会让系统变得简单明了,在一定程度上降低了系统的复杂度。
3.3 如何封装
总的来说,封装主要有以下两大好处(具体影响后面再说)。隔离变化。程序的隐私通常是程序最容易变化的部分,比如内部数据结构、内部使用的函数和全局变量等,我们需要把这些代码封装起来,从而让它们的变化不会影响系统的其他部分。降低复杂度。接口最小化是软件设计的基本原则之一,最小化的接口容易被理解和使用。封装内部实现细节,只暴露最小的接口,会让系统变得简单明了,在一定程度上降低了系统的复杂度。封装过程中应注意一下问题:
内部函数通常实现一些特定的算法(如果具有通用性,应该放到一个公共函数库里),对调用者没有多大用处,但它的暴露会干扰调用者的思路,让系统看起来比实际的复杂。函数名也会污染全局名字空间,造成重名问题。它还会诱导调用者绕过正规接口走捷径,造成不必要的耦合。隐藏内部函数的做法很简单。
(1)在头文件中,只放最少的接口函数的声明。
(2)在C文件中,所有内部函数都加上static关键字。
全局变量始终都会占用内存空间,共享库的全局变量是按页分配的,哪怕只有一个字节的全局变量也占用一个页,这样一来就会造成不必要内存空间浪费。全局变量也会给程序并发造成困难,想把程序从单线程改为多线程将会遇到麻烦。重要的是,如果调用者直接访问这些全局变量,会造成调用者和实现者之间的耦合。
4 Write once, run anywhere(WORA)
4.1 专用链表和通用链表各自的特点与适用范围
专用链表在这里是指该链表的实现和调用耦合在一起,只能被一个调用者使用,而不能单独在其他地方被重用。通用链表则相反,它具有通用性,可以在多处被重复使用。尽管通用链表相对专用链表来说有很多优越之处,不过草率地断定通用链表比专用链表好也是不公正的,因为它们都有自己的优点和适用范围。()
注意 在本节中,为了避免读起来拗口,我把双向链表简写成链表了,希望大家不要介意。
专用链表的优点
考虑到链表是最常用的数据结构之一,很多地方都会用到它,实现通用的链表会更有价值。接下来我们要实现一个通用的链表,不过请大家记住,实现通用的链表并不是我们的目标,而是我们学习软件设计方法的手段。前面我许诺过要以简单的数据结构讲述复杂的软件设计方法,链表就是其中的载体之一。
5 拥抱变化
在专用双向链表中,dlist_printf的实现非常简单,如果里面存放的是整数,用 %d 打印,存放的是字符串,用 %s 打印。现在的麻烦在于双向链表是通用的,我们无法预知其中存在的数据类型,也就是说我们要面对数据类型的变化。怎么办呢?初学者可以参考的常用方法有以下几种。
5.1 实现多个函数,需要哪个就用哪个
比如实现dlist_print_int用来打印存放整数的双向链表,dlist_print_string用来打印存放字符串的双向链表等,其他类型都有自己的打印函数。
不过这种做法也有一些缺点。一是每个函数的实现方式类似,会带来大量重复的代码。二是由于数据类型的种类不确定,如果为每种数据类型都实现一个print函数,当要存放新的数据类型时,就不得不修改dlist的实现。
5.2 传入一个附加参数来决定如何打印
比如传入1表示按整数方式打印,传入2表示按字符串方式打印,以此类推。
这种做法比第一种好一点,至少不会造成大量重复的代码。但是同样存在增加新类型时要修改dlist_print函数的问题。
5.3 调用dlist的接口函数获取每一个位置的数据并打印出来
这种方法没有前面两种方法的缺点,而且是一种相当直观的方式。但奇怪的是偏偏很少有人使用这个方法,原因可能有两个:其一是太拘泥于传统的实现方式而没有想到这一种;其二是担心性能问题,因为通过索引取值,每一次都要从头开始定位,其性能开销为O.
其实这种方法是可以接受的,dlist_print函数只是用于辅助测试,我们并不需要太在乎它的性能开销,而且我们很少会在链表中存放成千上万的数据,因此这个函数带来的性能影响根本没有想的那样严重。所以在这里我们要介绍一种新的方法。
dlist_print的大体框架如下。
在上面代码中,我们主要是不知道如何实现 print(iter->data); 这行代码。那么谁知道呢?很明显,调用者知道,因为调用者知道链表里面所存放的数据类型。好吧,那就让调用者来做好了,调用者在调用dlist_print时会提供一个函数给dlist_print来调用,这种回调调用者所提供函数的方法,我们可以称之为回调函数法。
调用者如何提供函数给dlist_print呢?当然是通过函数指针了。变量指针指向的是一块数据,指针指向不同的变量,则取到的是不同的数据。函数指针指向的是一段代码(即函数),指针指向不同的函数,则具有不同的行为。函数指针是实现多态的手段,多态就是隔离变化的秘诀,这里只是一个开端,后面我们会逐步地深入学习。
请看详细实现过程
6 Don’t Repeat Yourself(DRY)
我见过不少任劳任怨的程序员,别人让他做什么他就做什么,不管是不是份内的事,不管是上司要求的还是同事要求的,都来者不拒。别人说需要一个某某功能的函数,他就写一个在他的模块里,日积月累,他的模块就成了一锅“大杂烩”。我亲眼见过有程序员在系统设置和桌面两个模块里,提供很多毫不相干的函数,这些函数会造成不必要的耦合和复杂度。在这里也是一样的,求和与求最大值并不是dlist应该提供的功能,放在dlist里面实现是不应该的。为了能实现这些功能,我们提供一种满足这些需求的机制就好了。热心肠是好的,但一定不要“管得太宽”,否则就费力不讨好了。
7 你的数据放在哪里
对于初学者来说这道题有点难度,很少有人能完全做对。不过没关系,我并不是要出一道难题来难倒大家,而是要刺激大家去思考,以期达到加深学习印象的效果。有了前面两次的经验,我想应该没人会去写一个dlist_to_upper函数,大家都会调用dlist_foreach来实现。不过新的问题又出现了,初学者还是有可能犯以下几种常犯的错误。
7.1 转换大写的方法不对
这是我们在课本里学到的写法,但在工程中是不能这样做的。因为大小写字母在不同语言中的定义是不一样的,“a”是一个字符常量,它的值在任何时候都是97,但在不同语言中,97却不一定代表“a”。我们不能简单地认为在97(a)—122(z)之间的字符就是小写字母,而是应该调用标准C函数islower来判断,同样转换为大写应该调用toupper而不是减去一个常量。
7.2 在双向链表中存放常量字符串,转换时出现段错误。
运行时会出现“Segmentation fault”错误。原因是“It”等字符串是常量,常量是不能被修改的。
7.3 在双向链表中存放的是临时变量,转换后发现所有字符串都一样。
运行时发现打印出几个感叹号。原因是执行dlist_append时没有复制一份,所以在dlist中存放的是同一个地址。而且这个dlist在当前函数返回后,里面保存的数据都无效了,因为这些数据指向的是临时变量。
7.4 存放时复制了数据,但没有释放所分配的内存。
这里看起来工作正常了,但存在内存泄露的bug。strdup调用malloc分配了内存,但没有地方去释放它们。
初学者对内存和指针只有一知半解的认识,常常犯一些连自己都莫名其妙的错误。为了避免这些不必要的错误,今天我们要学习各种数据存放的位置以及它们的特性,让初学者对编程有更进一步的认识。在程序中,数据存放的位置主要有以下几个。
7.5未初始化的全局变量(.bss段)
通俗地讲,bss段被用来存放那些没有初始化或初始化为0的全局变量。它有什么特点呢,让我们先来看看一个小程序的表现。
变量bss_array的大小为4M,而可执行文件的大小只有5K。由此可见,bss类型的全局变量只占运行时的内存空间,而不占用文件空间。
现在大多数操作系统在加载程序时,会把所有的bss全局变量清零。但为了保证程序的可移植性,最好能手工把这些变量初始化为0,这样可以使这些变量都有个确定的初始值。
当然了,作为全局变量,在整个程序的运行周期内,bss数据是一直存在的。
7.6初始化过的全局变量(.bss段)
与bss相比,data段就容易理解多了,看名称就大概能知道它里面存放着数据。当然,如果数据全是0,为了优化考虑,编译器会把它当作bss处理。通俗地讲,data段被用来存放那些初始化为非0值的全局变量。那么它又有什么特点呢,我们还是先来看看一个小程序的表现。
仅仅是把初始化的值改为非0值了,文件就变为4M多。由此可见,data类型的全局变量是既占文件空间,又占用运行时内存空间的。
同样,作为全局变量,在整个程序的运行周期内,data数据也是一直存在的。
7.7 常量数据(.bss段)
rodata的意义同样明显,ro代表read only(只读),rodata就是用来存放常量数据的。关于rodata类型的数据,要注意以下几点。
由此可见,把在运行过程中不会改变的数据设为rodata类型是有好处的。在多个进程间共享,可以大大提高空间利用率,甚至能不占用RAM空间。同时由于rodata在只读的内存页面中是受保护的,任何试图对它进行修改的行为都会被及时发现,这样一来还可以提高程序的稳定性。
字符串会被编译器自动放到rodata中,其他数据要放到rodata中,只需要为其加const关键字修饰即可。
7.8 代码(.bss段)
text段存放代码(如函数)和部分整数常量,它与rodata段很相似,相同的特性我们就不重复了,主要的区别在于text段是可以执行的。
8 栈和堆
8.1栈
栈是用来存放临时变量和函数参数的。将栈作为一种基本数据结构,我并不感到惊讶;将其用来实现函数调用,也是大家司空见惯的作法。直到我试图找到另外一种方式实现递归操作时,我才感叹于栈的巧妙。要实现递归操作,不用栈不是不可能,只是找不出比使用栈更优雅的方式。
通常情况下,栈是向下(低地址)增长的,每向栈中PUSH一个元素,栈顶就向低地址扩展,每从栈中POP一个元素,栈顶就向高地址回退。这里有一些比较有意思的问题:在x86平台上,栈顶寄存器为ESP,那么ESP的值是在PUSH操作之前修改呢,还是在PUSH操作之后修改呢?PUSH ESP这条指令会向栈中存入什么数据呢?据说x86系列CPU中,除了286外,都是先修改ESP,再压栈的。由于286没有CPUID指令,因此有的操作系统会用这种方法检查286的型号。
要注意的是,存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了,继续访问这些变量会造成意想不到的错误。
8.2堆
堆是最灵活的一种内存,它的生命周期完全由使用者控制。标准C提供以下几个函数来使用堆内存。
9 小结
本文通过一个简单需求的完成过程讲述了程序员应具备的态度和技能,是程序员进阶的必经之路。