笨办法学C 练习27:创造性和防护性编程

练习27:创造性和防护性编程

原文:Exercise 27: Creative And Defensive Programminghtml

译者:飞龙git

你已经学到了大多数C语言的基础,而且准备好开始成为一个更严谨的程序员了。这里就是从初学者走向专家的地方,不只仅对于C,更对于核心的计算机科学概念。我将会教给你一些核心的数据结构和算法,它们是每一个程序员都要懂的,还有一些我在真实程序中所使用的一些很是有趣的东西。程序员

在我开始以前,我须要教给你一些基本的技巧和观念,它们能帮助你编写更好的软件。练习27到31会教给你高级的概念和特性,而不是谈论编程,可是这些以后你将会应用它们来编写核心库或有用的数据结构。github

编写更好的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,由于它会混淆比较。

  • 记录为何存在这些先决条件,当一个程序员碰到错误时,他能够弄清楚这些是不是真正必要的。

  • 若是你修改了输入,确保当函数退出或停止时它们也会正确产生。

  • 老是要检查所使用的函数的错误代码。例如,人们有时会忘记检查fopenfread的返回代码,这会致使他们在错误下仍然使用这个资源。这会致使你的程序崩溃或者易受攻击。

  • 你也须要返回一致的错误代码,以便对你的每一个函数添加相同的机制。一旦你熟悉了这一习惯,你就会明白为何个人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,提交一个补丁来修复它。

相关文章
相关标签/搜索