练习27:创造性和防御性编程
你已经学到了大多数C语言的基础,并且准备好开始成为一个更严谨的程序员了。这里就是从初学者走向专家的地方,不仅仅对于C,更对于核心的计算机科学概念。我将会教给你一些核心的数据结构和算法,它们是每个程序员都要懂的,还有一些我在真实程序中所使用的一些非常有趣的东西。
在我开始之前,我需要教给你一些基本的技巧和观念,它们能帮助你编写更好的软件。练习27到31会教给你高级的概念和特性,而不是谈论编程,但是这些之后你将会应用它们来编写核心库或有用的数据结构。
编写更好的C代码(实际上是所有语言)的第一步是,学习一种新的观念叫做“防御性编程”。防御性编程假设你可能会制造出很多错误,之后尝试在每一步尽可能预防它们。这个练习中我打算教给你如何以防御性的思维来思考编程。
创造性编程思维
在这个简单的练习中要告诉你如何做到创造性是不可能的,但是我会告诉你一些涉及到任务风险和开放思维的创造力。恐惧会快速地扼杀创造力,所以我采用,并且许多程序员也采用的这种思维方式使我不会惧怕风险,并且看上去像个傻瓜。
- 我不会犯错误。
- 人们所想的并不重要。
- 我脑子里面诞生的想法才是最好的。
我只是暂时接受了这种思维,并且在应用中用了一些小技巧。为了这样做我会提出一些想法,寻找创造性的解决方案,开一些奇奇怪怪的脑洞,并且不会害怕发明一些古怪的东西。在这种思维方式下,我通常会编写出第一个版本的糟糕代码,用于将想法描述出来。
然而,当我完成我的创造性原型时,我会将它扔掉,并且将它变得严谨和可考。其它人在这里常犯的一个错误就是将创造性思维引入它们的实现阶段。这样会产生一种非常不同的破坏性思维,它是创造性思维的阴暗面:
- 编写完美的软件是可行的。
- 我的大脑告诉我了真相,它不会发现任何错误,所以我写了完美的软件。
- 我的代码就是我自己,批判它的人也在批判我。
这些都是错误的。你经常会碰到一些程序员,它们对自己创造的软件具有强烈的荣誉感。这很正常,但是这种荣誉感会成为客观上改进作品的阻力。由于这种荣誉感和它们对作品的依恋,它们会一直相信它们编写的东西是完美的。只要它们忽视其它人的对这些代码的观点,它们就可以保护它们的玻璃心,并且永远不会改进。
同时具有创造性思维和编写可靠软件的技巧是,采用防御性编程的思维。
防御性编程思维
在你做出创造性原型,并且对你的想法感觉良好之后,就应该切换到防御性思维了。防御性思维的程序员大致上会否定你的代码,并且相信下面这些事情:
- 软件中存在错误。
- 你并不是你的软件,但你需要为错误负责。
- 你永远不可能消除所有错误,只能降低它们的可能性。
这种思维方式让你诚实地对待你的代码,并且为改进批判地分析它。注意上面并没有说你充满了错误,只是说你的代码充满错误。这是一个需要理解的关键,因为它给了你编写下一个实现的客观力量。
就像创造性思维,防御性编程思维也有阴暗面。防御性程序员是一个惧怕任何事情的偏执狂,这种恐惧使他们远离可能的错误或避免犯错误。当你尝试做到严格一致或正确时这很好,但是它是创造力和专注的杀手。
八个防御性编程策略
一旦你接受了这一思维,你可以重新编写你的原型,并且遵循下面的八个策略,它们被我用于尽可能把代码变得可靠。当我编写代码的“实际”版本,我会严格按照下面的策略,并且尝试消除尽可能多的错误,以一些会破坏我软件的人的方式思考。
永远不要信任输入
永远不要提供的输入,并总是校验它。
避免错误
如果错误可能发生,不管可能性多低都要避免它。
过早暴露错误
过早暴露错误,并且评估发生了什么、在哪里发生以及如何修复。
记录假设
清楚地记录所有先决条件,后置条件以及不变量。
防止过多的文档
不要在实现阶段就编写文档,它们可以在代码完成时编写。
使一切自动化
使一切自动化,尤其是测试。
简单化和清晰化
永远简化你的代码,在没有牺牲安全性的同时变得最小和最整洁。
质疑权威
不要盲目遵循或拒绝规则。
这些并不是全部,仅仅是一些核心的东西,我认为程序员应该在编程可靠的代码时专注于它们。要注意我并没有真正说明如何具体做到这些,我接下来会更细致地讲解每一条,并且会布置一些覆盖它们的练习。
应用这八条策略
这些观点都是一些流行心理学的陈词滥调,但是你如何把它们应用到实际编程中呢?我现在打算向你展示这本书中的一些代码所做的事情,这些代码用具体的例子展示每一条策略。这八条策略并不止于这些例子,你应该使用它们作为指导,使你的代码更可靠。
永远不要信任输入
让我们来看一个坏设计和“更好”的设计的例子。我并不想称之为好设计,因为它可以做得更好。看一看这两个函数,它们都复制字符串,main
函数用于测试哪个更好。
undef NDEBUG
#include "dbg.h"
#include <stdio.h>
#include <assert.h>
/*
* Naive copy that assumes all inputs are always valid
* taken from K&R C and cleaned up a bit.
*/
void copy(char to[], char from[])
{
int i = 0;
// while loop will not end if from isn't '\0' terminated
while((to[i] = from[i]) != '\0') {
++i;
}
}
/*
* A safer version that checks for many common errors using the
* length of each string to control the loops and termination.
*/
int safercopy(int from_len, char *from, int to_len, char *to)
{
assert(from != NULL && to != NULL && "from and to can't be NULL");
int i = 0;
int max = from_len > to_len - 1 ? to_len - 1 : from_len;
// to_len must have at least 1 byte
if(from_len < 0 || to_len <= 0) return -1;
for(i = 0; i < max; i++) {
to[i] = from[i];
}
to[to_len - 1] = '\0';
return i;
}
int main(int argc, char *argv[])
{
// careful to understand why we can get these sizes
char from[] = "0123456789";
int from_len = sizeof(from);
// notice that it's 7 chars + \0
char to[] = "0123456";
int to_len = sizeof(to);
debug("Copying '%s':%d to '%s':%d", from, from_len, to, to_len);
int rc = safercopy(from_len, from, to_len, to);
check(rc > 0, "Failed to safercopy.");
check(to[to_len - 1] == '\0', "String not terminated.");
debug("Result is: '%s':%d", to, to_len);
// now try to break it
rc = safercopy(from_len * -1, from, to_len, to);
check(rc == -1, "safercopy should fail #1");
check(to[to_len - 1] == '\0', "String not terminated.");
rc = safercopy(from_len, from, 0, to);
check(rc == -1, "safercopy should fail #2");
check(to[to_len - 1] == '\0', "String not terminated.");
return 0;
error:
return 1;
}
copy
函数是典型的C代码,而且它是大量缓冲区溢出的来源。它有缺陷,因为它总是假设接受到的是合法的C字符串(带有'\0'
),并且只是用一个while
循环来处理。问题是,确保这些是十分困难的,并且如果没有处理好,它会使while
循环无限执行。编写可靠代码的一个要点就是,不要编写可能不会终止的循环。
safecopy
函数尝试通过要求调用者提供两个字符串的长度来解决问题。它可以执行有关这些字符串的、copy
函数不具备的特定检查。他可以保证长度正确,to
字符串具有足够的容量,以及它总是可终止。这个函数不像copy
函数那样可能会永远执行下去。
这个就是永远不信任输入的实例。如果你假设你的函数要接受一个没有终止标识的字符串(通常是这样),你需要设计你的函数,不要依赖字符串本身。如果你想让参数不为NULL
,你应该对此做检查。如果大小应该在正常范围内,也要对它做检查。你只需要简单假设调用你代码的人会把它弄错,并且使他们更难破坏你的函数。
这个可以扩展到从外部环境获取输入的的软件。程序员著名的临终遗言是,“没人会这样做。”我看到他们说了这句话后,第二天有人就这样做,黑掉或崩溃它们的应用。如果你说没有人会这样做,那就加固代码来保证他们不会简单地黑掉你的应用。你会因所做的事情而感到高兴。
这种行为会出现收益递减。下面是一个清单,我会尝试对我用C写的每个函数做如下工作:
- 对于每一个参数定义它的先决条件,以及这个条件是否导致失效或返回错误值。如果你在编写一个库,比起失效要更倾向于错误。
- 对于每个先决条件,使用
assert(test && "message");
在最开始添加assert
检查。这句代码会执行检查,失败时OS通常会打印断言行,通常它包括信息。当你尝试弄清assert
为什么在这里时,这会非常有用。 - 对于其它先决条件,返回错误代码或者使用我的
check
宏来执行它并且提供错误信息。我在这个例子中没有使用check
,因为它会混淆比较。 - 记录为什么存在这些先决条件,当一个程序员碰到错误时,他可以弄清楚这些是否是真正必要的。
- 如果你修改了输入,确保当函数退出或中止时它们也会正确产生。
- 总是要检查所使用的函数的错误代码。例如,人们有时会忘记检查
fopen
或fread
的返回代码,这会导致他们在错误下仍然使用这个资源。这会导致你的程序崩溃或者易受攻击。 - 你也需要返回一致的错误代码,以便对你的每个函数添加相同的机制。一旦你熟悉了这一习惯,你就会明白为什么我的
check
宏这样工作。
只是这些微小的事情就会改进你的资源处理方式,并且避免一大堆错误。
避免错误
上一个例子中你可能会听到别人说,“程序员不会经常错误地使用copy
。”尽管大量攻击都针对这类函数,他们仍旧相信这种错误的概率非常低。概率是个很有趣的事情,因为人们不擅长猜测所有事情的概率,这非常难以置信。然而人们对于判断一个事情是否可能,是很擅长的。他们可能会说copy
中的错误不常见,但是无法否认它可能发生。
关键的原因是对于一些常见的事情,它首先是可能的。判断可能性非常简单,因为我们都知道事情如何发生。但是随后判断出概率就不是那么容易了。人们错误使用copy
的情况会占到20%、10%,或1%?没有人知道。为了弄清楚你需要收集证据,统计许多软件包中的错误率,并且可能需要调查真实的程序员如何使用这个函数。
这意味着,如果你打算避免错误,你不需要尝试避免可能发生的事情,而是要首先集中解决概率最大的事情。解决软件所有可能崩溃的方式并不可行,但是你可以尝试一下。同时,如果你不以最少的努力解决最可能发生的事件,你就是在不相关的风险上浪费时间。
下面是一个决定避免什么的处理过程:
- 列出所有可能发生的错误,无论概率大小,并带着它们的原因。不要列出外星人可能会监听内存来偷走密码这样的事情。
- 评估每个的概率,使用危险行为的百分比来表示。如果你处理来自互联网的情况,那么则为可能出现错误的请求的百分比。如果是函数调用,那么它是出现错误的函数调用百分比。
- 评估每个的工作量,使用避免它所需的代码量或工作时长来表示。你也可以简单给它一个“容易”或者“难”的度量。当需要修复的简单错误仍在列表上时,任何这种度量都可以让你避免做无谓的工作。
- 按照工作量(低到高)和概率(高到低)排序,这就是你的任务列表。
- 之后避免你在列表中列出的任何错误,如果你不能消除它的可能性,要降低它的概率。
- 如果存在你不能修复的错误,记录下来并提供给可以修复的人。
这一微小的过程会产生一份不错的待办列表。更重要的是,当有其它重要的事情需要解决时,它让你远离劳而无功。你也可以更正式或更不正式地处理这一过程。如果你要完成整个安全审计,你最好和团队一起做,并且有个更详细的电子表格。如果你只是编写一个函数,简单地复查代码之后划掉它们就够了。最重要的是你要停止假设错误不会发生,并且着力于消除它们,这样就不会浪费时间。
过早暴露错误
如果你遇到C中的错误,你有两个选择:
- 返回错误代码。
- 中止进程。
这就是处理方法,你需要执行它来确保错误尽快发生,记录清楚,提供错误信息,并且易于程序员来避免它。这就是我提供的check
宏这样工作的原因。对于每一个错误,你都要让它你打印信息、文件名和行号,并且强制返回错误代码。如果你使用了我的宏,你会以正确的方式做任何事情。
我倾向于返回错误代码而不是终止程序。如果出现了大错误我会中止程序,但是实际上我很少碰到大错误。一个需要中止程序的很好例子是,我获取到了一个无效的指针,就像safecopy
中那样。我没有让程序在某个地方产生“段错误”,而是立即捕获并中止。但是,如果传入NULL
十分普遍,我可能会改变方式而使用check
来检查,以保证调用者可以继续运行。
然而在库中,我尽我最大努力永不中止。使用我的库的软件可以决定是否应该中止。如果这个库使用非常不当,我才会中止程序。
最后,关于“暴露”的一大部分内容是,不要对多于一个错误使用相同的信息或错误代码。你通常会在外部资源的错误中见到这种情况。比如一个库捕获了套接字上的错误,之后简单报告“套接字错误”。它应该做的是返回具体的信息,比如套接字上发生了什么错误,使它可以被合理地调试和修复。当你设计错误报告时,确保对于不同的错误你提供了不同的错误消息。
记录假设
如果你遵循并执行了这个建议,你就构建了一份“契约”,关于函数期望这个世界是什么样子。你已经为每个参数预设了条件,处理潜在的错误,并且优雅地产生失败。下一步是完善这一契约,并且添加“不变量”和“后置条件”。
不变量就是在函数运行时,一些场合下必须恒为真的条件。这对于简单的函数并不常见,但是当你处理复杂的结构时,它会变得很必要。一个关于不变量的很好的例子是,结构体在使用时都会合理地初始化。另一个是有序的数据结构在处理时总是排好序的。
后置条件就是退出值或者函数运行结果的保证。这可以和不变了混在一起,但是也可以是一些很简单的事情,比如“函数应总是返回0,或者错误时返回-1”。通常这些都有文档记录,但是如果你的函数返回一个分配的资源,你应该添加一个后置条件,做检查来确保它返回了一个不为NULL
的东西。或者,你可以使用NULL
来表示错误,这种情况下,你的后置条件就是资源在任何错误时都会被释放。
在C编程中,不变量和后置条件都通常比实际的代码和断言更加文档化。处理它们的最好当时就是尽可能添加assert
调用,之后记录剩下的部分。如果你这么做了,当其它人碰到错误时,他们可以看到你在编写函数时做了什么假设。
避免过多文档
程序员编写代码时的一个普遍问题,就是他们会记录一个普遍的bug,而不是简单地修复它。我最喜欢的方式是,Ruby on Rails系统只是简单地假设所有月份都有30天。日历太麻烦了,所以与其修复它,不如在一些地方放置一个小的注释,说这是故意的,并且几年内都不会改正。每次一些人试图抱怨它时,他们都会说,“文档里面都有!”
如果你能够实际修复问题,文档并不重要,并且,如果函数具有严重的缺陷,你在修复它之前可以不记录它。在Ruby on Rails的例子中,不包含日期函数会更好一些,而不是包含一个没人会用的错误的函数。
当你为防御性编程执行清理时,尽可能尝试修复任何事情。如果你发现你记录了越来越多的,你不能修复的事情,需要考虑重新设计特性,或简单地移除它。如果你真的需要保留这一可怕的错误的特性,那么我建议你编写它、记录它,并且在你受责备之前找一份新的工作。
使一切自动化
你是个程序员,这意味着你的工作是通过自动化消灭其它人的工作。它的终极目标是使用自动化来使你自己也失业。很显然你不应该完全消除你做的东西,但如果你花了一整天在终端上重复运行手动测试,你的工作就不是编程。你只是在做QA,并且你应该使自己自动化,消除这个你可能并不是真的想干的QA工作。
实现它的最简单方式就是编写自动化测试,或者单元测试。这本书里我打算讲解如何使它更简单,并且我会避免多数编写测试的信条。我只会专注于如何编写它们,测试什么,以及如何使测试更高效。
下面是程序员没有但是应该自动化的一些事情:
- 测试和校验。
- 构建过程。
- 软件部署。
- 系统管理。
- 错误报告。
尝试花一些时间在自动化上面,你会有更多的时间用来处理一些有趣的事情。或者,如果这对你来说很有趣,也许你应该编写自动化完成这些事情的软件。
简单化和清晰化
“简单性”的概念对许多人来说比较微妙,尤其是一些聪明人。它们通常将“内涵”与“简单性”混淆起来。如果他们很好地理解了它,很显然非常简单。简单性的测试是通过将一个东西与比它更简单的东西比较。但是,你会看到编写代码的人会使用最复杂的、匪夷所思的数据结构,因为它们认为做同样事情的简单版本非常“恶心”。对复杂性的爱好是程序员的弱点。
你可以首先通过告诉自己,“简单和清晰并不恶心,无论谁在干什么事情”来战胜这一弱点。如果其它人编写了愚蠢的观察者模式涉及到19个类,12个接口,而你只用了两个字符串操作就可以实现它,那么你赢了。他们就是错了,无论他们认为自己的复杂设计有多么高大上。
对于要使用哪个函数的最简单测试是:
- 确保所有函数都没有问题。如果它有错误,它有多快或多简单就不重要了。
- 如果你不能修复问题,就选择另外一个。
- 它们会产生相同结果嘛?如果不是就挑选具有所需结果的函数。
- 如果它们会产生相同结果,挑选包含更少特性,更少分支的那个,或者挑选你认为最简单的那个。
- 确保你没有只是挑选最具有表现力的那个。无论怎么样,简单和清晰,都会战胜复杂和恶心。
你会注意到,最后我一般会放弃并告诉你根据你的判断。简单性非常讽刺地是一件复杂的事情,所以使用你的品位作为指引是最好的方式。只需要确保在你获取更多经验之后,你会调整你对于什么是“好”的看法。
质疑权威
最后一个策略是最重要的,因为它让你突破防御性编程思维,并且让你转换为创造性思维。防御性编程是权威性的,并且比较无情。这一思维方式的任务是让你遵循规则,因为否则你会错失一些东西或心烦意乱。
这一权威性的观点的坏处是扼杀了独立的创造性思维。规则对于完成事情是必要的,但是做它们的奴隶会扼杀你的创造力。
这条最后的策略的意思是你应该周期性地质疑你遵循的规则,并且假设它们都是错误的,就像你之前复查的软件那样。在一段防御性编程的时间之后,我通常会这样做,我会拥有一个不编程的休息并让这些规则消失。之后我会准备好去做一些创造性的工作,或按需做更多的防御型编程。
顺序并不重要
在这一哲学上我想说的最后一件事,就是我并不是告诉你要按照一个严格的规则,比如“创造!防御!创造!防御!”去做这件事。最开始你可能想这样做,但是我实际上会做不等量的这些事情,取决于我想做什么,并且我可能会将二者融合到一起,没有明确的边界。
我也不认为其中一种思维会优于另一种,或者它们之间有严格的界限。你需要在编程上既有创造力也要严格,所以如果想要提升的话,需要同时做到它们。
附加题
- 到现在为止(以及以后)书中的代码都可能违反这些规则。回退并挑选一个练习,将你学到的应用在它上面,来看看你能不能改进它或发现bug。
- 寻找一个开源项目,对其中一些文件进行类似的代码复查。如果你发现了bug,提交一个补丁来修复它。