让错的代码显出错误(转载)

原文标题为:Making Wrong Code Look Wrong

作者:周思博 (Joel Spolsky,直译为"约耳.斯珀儿斯奇")
译者:梅普华 (Paul May)
2005.5.11
由 GalaxySong 少量校正

Joel Spolsky 是 Fog Creek Software (设立在纽约的一家小型软体公司) 的创立者。约耳毕业于耶鲁大学 (Yale University),并曾经在微软,Viacom 和 Juno 担任程序人员与管理工作。

 

目录

  1. 从面包厂说起
  2. 一个例子
  3. 可能方案一
  4. 可能方案二
  5. 正解
  6. 一个通则
  7. 我是匈牙利派
  8. 例外处理

时间回到1983年九月,我第一个真正的工作是在以色列的 Oranim。这家大型面包工厂每晚都用六个货机般大的巨型炉子烤出为数十万的面包。

我第一次走进那家面包厂时,觉得里头实在脏得离谱。炉壁发黄机器生锈而且到处都是油。

"这里一直都这么脏吗?"我问道。

"什么?你讲这什么话!"经理回答说,"我们才刚打扫过。这已经是几周以来最干净的时候了。"

说得真好!

我花了好几个月每天早上打扫才真正了解他们的意思。对面包工厂来说,干净是指机器里没有生面团在烤,垃圾堆里没有发酵的面团,而且地板上也没有堆生面团。

干净并不是指炉子漆得雪白亮丽。炉子大概十年才会漆一次,并不会每天都来一回。干净也不是说把油擦得干干净净。事实上很多机器都得定期上油,一层薄净的油通常暗示机器刚做过清洁保养。

面包工厂里这整套干净的概念都得经由学习而来。圈外人不可能走进去就能说出哪里干净哪里脏。圈外人绝不会想到要看面团滚圆机(把方面团滚成球形的机
器,见右边附图)内壁有没有刮干净。圈外人会觉旧炉子外壁镶板掉色是有问题的,因为镶板很大很显眼。不过面包师傅根本不在意炉子的涂漆开始发黄。因为面包
的味道还是一样棒。

在面包工厂待两个月,你学会如何看出干净。

程序码也是一样的。

当你刚开始写程序或尝试读用新语言写的程序时,所有程序码看起来都一样神秘不可解。而在了解该种程序语言前,你连明显的语法错误都看不出来。

在学习的第一阶段,你会开始发现一种我们通常称为编程风格的东西。于是你开始注意那些不遵循缩排标准的程序码和有用多个大写字母的变量。

也就是这个阶段你会说:该死的混蛋,我们这里一定要定出一些一致的编程风格!然后第二天写出一份你们团队用的编程风格,接下来用六天来讨论
One True Brace Style(译者:就是 K&R style),然后再花三星期把旧程序码改写成符合 One True
Brace
Style,一直做到经理发现并责怪你把时间浪费在不能赚钱的事为止。你想想其实不需要一次全部改好,看到哪里改到哪里也没什么关系。于是有一半的程序码
已经改成 One True Brace
Style,而没多久你就忘记这件事了。接下来你就开始满脑子想着其它与赚钱无关的事,比如把某个字串类别换成另一个字串类别等等。

当你对某特定环境下的程序愈来愈精通时,就会开始学着看到其它东西。那些东西可能完全合法并符合编程风格,却又会让你担心不已。

举例来说在 C 语言里

char dest, src;

 

这是合语法的程序码;这可能符合你的编程规范,甚至可能是故意这样写的,不过如果你写 C 的经验够,就会注意这种写法把 dest 定义成字符指针却把 src 定义成字符而已,这可能是你的意思,不过也可能不是。反正这段程序看起来有点不对劲。

来看更细微的例子

if ( i != 0)
foo;

 

这段程序是百分之百正确的;它符合大多数的编程规范也完全没有错误,不过你可能会质疑if叙述所接的单叙述主体并未用大括号包起来,因为你脑子里想到有人可能会插入另一行程序码

if ( i != 0)
bar;
foo;

 

又忘记加上大括号,结果让 foo 变成永远会执行! 所以当你看到没有用大括弧包起来的程序码区段时,可能就会感觉到一丝丝让你不舒服的气味。

好啦,到目前为止我已经提到三种程序师的成就层级:

  1. 你不知道干净和脏有什么分别。
  2. 你对干净有粗浅的认知,主要以是否符合编程规范为准。
  3. 你开始能嗅出藏在表面下不对劲的蛛丝马迹。你会察觉这是问题并且找出来修正。
    不过其实还有更高的层次,而这也就是我真正要说的:
  4. 你有计划地架构程序码,藉助能察觉问题的灵眼让程序码更正确。

这是真正的艺术:仔细地设计让错误显而易见的编程规范,藉此制作出稳固的程序。

所以现在我要带你看一个小例子,然后再展示一个通用的规则。你可以利用这个通则,设计出创造增加程序稳固的编程规范。最后我会把主题导引到为某种匈牙利命名法(可能不是让人们晕倒的那种)进行辩护,并且批判某些环境(也可能不是你最常用的那种环境)下的例外处理。

不过如果你深信匈牙利命名法不是好东西,认为例外处理是从自巧克力奶昔以来最棒的发明,而且完全不想听听其它意见,没问题,你可以改去罗力那里看看
好看的漫画;反正你在这里也没什么好看的;事实上,在一分钟内我就会拿出实际的程序码范例,这些范例很可能会让你在不爽前就晕睡过去了。没错。我想我的计
划是把你哄到沉沉入睡,趁你睡着无法抵抗时把"匈牙利命名法=好、例外处理=坏"的想法偷偷塞进你脑子里面。

一个例子

好了。提到这个例子。让我们假装你正在写某种 web 应用程序,因为这阵子小朋友似乎都流行写这玩意。

现在有一种叫跨站脚本漏洞(Cross Site Scripting Vulnearability)的安全漏洞,缩写为 XSS 。我在这里不谈细节;你只需要知道在写 web 应用程序时,一定要小心绝不能把使用者填入表单的任何字串直接传回来。

举例来说,如果你有一个网页会让使用者在编辑框输入姓名,传送后就会跳到另一个写着"你好啊,张三!"(假设使用者的名字是张三)的网页。很好,这
就是个安全漏洞,因为使用者可能不输入张三而输入某种奇怪的 HTML 及 JavaScript,这些奇怪的 JavaScript
就可能会做些低级事情,比如读出你写的 cookie 内容转送到坏人的坏网站去。而这些低级事现在看起来就是你搞的鬼。

让我们把程序用虚拟码的方法写出来。想像以下的程序

s = Request("name")

 

会由 HTML 表格读取使用者输入(一个 POST 的参数)。如果你曾经写出下面的程序码:

Write "你好," & Request("name")

 

那你的网站已经有让 XSS 攻击的漏洞了。光这样就够了。
你必须在复制回 HTML 之前先编码才能避免这个漏洞。所谓编码就是把"换成",把>换成>,如此类推。所以

Write "你好," & Encode(Request("name"))

 

是绝对安全的。

所有来自使用者的字串都是不安全的。任何不安全的字串都得先编码后才能输出。

让我们尝试设计一组编程规范,确保当你犯这种错时程序码看起来就是错的。如果程序码有错(至少看起来错),就很有机会被修改或审视这段程序的人抓到。

可能方案一

方案一是将所有字串立即编码,由使用者取得后马上就进行:

s = Encode(Request("name"))

 

所以我们的规范会写着:如果你看到没有被 Encode 包住的 Request ,程序一定是错的。

你开始训练自己的眼睛找寻落单的 Request ,因为它们违反规范。

这是有用的,因为只要你遵循规范就不会有 XSS 问题。不过这并不是最好的架构。比方说你可能想要把这些使用者字串存到数据库里,这时候储存以
HTML 编码过的字串并不合理,因为字串有可能会用在 HTML 网页以外的场合。假如是信用卡处理程序要用时编码过的数据就会产生问题。大部份
web 应用程序开发都会依循一个原则:所有字串在内部都是未编码的,要等到送至 HTML 网页的前一瞬间才会处理,因此这可能并不是正确的架构。

我们真的要能让字串维持在不安全格式一段时间。

好吧。我再试看看。

可能方案二

如果建立一种编程规范,要求在写出任何字串时必须加以编码,是否可以满足要求吗?

s = Request("name")
// 很后面...
Write Encode

 

现在当你看到一个落单没有 Encode 跟着的 Write 时就知道有有问题了。
唉,这也不太好...有时候你的程序里会有一小段的 HTML 码,这种情况下是不能够编码的:

If mode = "linebreak" Then prefix = "<br>"
// 很后面...
Write prefix

 

这照我们的规范来看是错的,我们必须要在输出时加以编码:

Write Encode(prefix)

 

不过现在应该要新增一行的"<br>"却被编码成 <br>,结果变成使用者可以看到的字符<br>。这样的解法也不对。
所以说有时候你不能在读入字串时编码,有时候你也不能在输出时编码,这两种提案都不能用。可是没有适当的编码规范,我们还是有出下列问题的风险:

s = Request("name")
//...好几页之后...
name = s
//...好几页之后...
recordset("name") = name // 把名字存在数据库中的姓名栏
//...好几天后...
theName = recordset("name")
//...好几页甚至好几个月之后...
Write theName

 

我们还会记得要对字串编码吗?你在任何单一的地方都看不到问题。连可以嗅的地方都没有。如果这种程序有一大缸子,要一大票侦探才能追踪出所有字串的来源并确认是否已编码。

正解

所以让我提议一种能用的编程规范。我们只有一个规则:

所有来自使用者的字串都必须存在以"us"(表示Unsafe String,不安全字串)为字首的变量(或数据库栏位)中。所有经HTML编码或来自确认安全来源的字串都必须存在以"s"(表示Safe String,安全字串)为字首的变量中。
让我们重写程序,只是依规范重新命名变量,其它完全不动。

us = Request("name")
//...好几页之后...
usName = us
//...好几页之后...
recordset("usName") = usName
//...好几天后...
sName = Encode(recordset("usName"))
//...好几页甚至好几个月之后...
Write sName

 

新规范中值得注意的是,只要遵循编码规范,不安全字串相关的错误一定可以由单一行的程序码看出来:

s = Request("name")

 

是之前的错误,因为你可以看到 Request 的结果被指派给以 s 开头的变量,这违反了规则。Request 的结果一定是不安全的,所以必须指派给以"us"开头的变量。

us = Request("name")

 

一定没问题。

usName = us

 

一定没问题。

sName = us

 

一定是错的。

sName = Encode(us)

 

一定是对的。

Write usName

 

一定是错的。

Write sName

 

没问题,下面也一样没问题

Write Encode(usName)

 

每一行程序光是看程序码本身就足以检查,而且如果每一行程序都对,组合起来整个程序也是对的。

终于好了,利用这套编码规范,你的眼睛学着看到 Write usXXX
就知道是错的,而且你也立即知道要如何修正。我知道一开始要看到错误的程序是有一点难,不过进行三个星期后你的眼睛就会习惯,就像面包厂的工人看到大面包
工厂就会马上说:"搞什么鬼,这里都没人在扫哦!这算啥面包厂!"

事实上我们可以再把规则延伸一点,把 Request 和 Encode 函数改名(或封装)成 UsRequest 和 SEncode;换句话说,传回不安全字串以及安全字串的函数要和变量一样,分别要用 Us 及 S 作为字首。现在看看程序码:

us = UsRequest("name")
usName = us
recordset("usName") = usName
sName = SEncode(recordset("usName"))
Write sName

 

看到我们的成果没?现在你可以看看等号两边的字首是否相同就能找到错误。

us = UsRequest("name") // 没问题,两边都以US开头

s = UsRequest("name") // 错

usName = us // 对

sName = us // 一定错。

sName = SEncode(us) // 一定对。

 

我还能再进一步把 Write 改名成 WriteS 并把 SEncode 改名成SFromUs:

us = UsRequest("name")
usName = us
recordset("usName") = usName
sName = SFromUs(recordset("usName"))
WriteS sName

 

这使得错误更加显而易见。你的眼睛会学习"看出"可疑的程序码,另外这也能协助你经由一般编写或阅读程序码的动作找到隐藏的安全漏洞。

让错的代码显出错误是很棒没错,不过却不是所有安全问题的最佳解答。它无法找到所有可能的问题或错误,因为你可能没法子看过每一行程序码。不过绝对
比什么都不做要好,而我很希望有套编码规范能让错误的程序码至少看起来是错的。你马上就能获得好处,每当程序师的眼睛扫过一行程序,就能检查并防止某些特
定的错误。

一个通则

这种让错误程序看起来错的作法有个前提,就是要让对的东西在萤幕上紧靠在一起。当我看到某个字串时并要决定程序码正确与否,我必须知道字串出现的所
有位置以及字串是安全的还是不安全的。我不希望这些数据出现在另一个档案或是要卷动画面才能看到的另一页。我必须能当场看到,而这说的就是一套变量命名规
范。

有很多其它的例子可以说明,只要把某些东西搬在一起就可以改善程序码。大多数的编程规范都有如下的规则:

  • 保持函数名称简短。
  • 变量定义的地方离使用的位置愈近愈好。
  • 不要用宏建立你个人专属的程序语言。
  • 不要使用goto。
  • 不要让右括弧离左括弧超过一个画面。

这些规则有一个共同点,就是尽量让一行程序码实际作用的相关信息在画面上愈近愈好。这样能提高眼球找出程序实质运作内容的机会。

大体上我得承认我有点害怕会藏东西的程序语言功能。当你看到程序码

i = j * 5;

 

就 C 来说你至少会知道 j 会乘以 5 而结果会存到 i 。

不过如果你在C++里看到相同的片段,你什么都不知道。在C++中唯一能知道真正发生什么事的方法就是找出 i 和 j
所属的类型,而这个类型可能会在完全不一样的地方定义。因为j的运算子 * 可能有重载,在你要做乘法时会做些很机灵的事。而 i 的运算子 =
可能也是重载的,而两者类型可能是不相容的,于是又调用到某个自动类型强制转换的函数。光是检查变量的类型还不足以确认,还得检查运行该类型的程序码才
行,万一运行时又有继承其它类型就更麻烦了,因为你得回溯类别继承的祖宗八代才能找到真正的程序码,不巧又有用到别处的多型就真的有大麻烦了,因为光是知
道 i 和 j
定义的类型并不够,还得知道它们此刻的类型,这不知道要看多少的程序码,而且依照计算理论的停机问题,你永远都不能真的百分之百确定自己已经看完所有地方
了(啊啊啊啊啊!!!)。

当你看到C++的 i = j * 5 时,兄弟,你只能自求多福了。这对我来说降低了光看程序码找出在问题的能力。

当然啰,理论上这应该没什么关系。当你做些重载运算子 * 之类的聪明事时,只是为了要提供一个优美而安全的抽象罢了。天啊,其实 j 是个 Unicode 字串类型,一个 Unicode 字串乘以一个整数显然是把繁体中文转成简体中文的良好抽象作法,不是吗?

问题当然出在没有绝对安全的抽象方法。我已经在抽象出错定律里讨论很多了,所以不会在这里重复。

Scott Meyers 示范了各种抽象出错(至少是C++)的型式以及所造成的伤害,他靠这个主题就创出一番事业了。(顺便一提,Scott 的书 Effective C++ 第三版刚刚上市,整本书都重写过;今天就去买一本吧!)

好吧。

有点跑题了。我最好回顾一下到目前为止的内容:

  • 找出能让错误程序看起来错的编程规范。
  • 让正确的信息集中在程序码中相同的地方,方便你看出某些问题并立即修正。

我是匈牙利派 {anchor:我是匈牙利派}

我们现在回到恶名昭彰的匈牙利命名法。

匈牙利命名法是微软程序设计师 Charles Simonyi 发明的。 Simonyi 在微软做的主要计划是 Word;事实上他还主持了世界上第一个所见即所得的文档处理器(在 Xerox Parc 名为 Bravo 计划)。

在所见即所得的文档处理中会用到可卷动的视窗,所以座标值有两种意义:相对于视窗或相对于处理页。两种座标的差异很大,所以好好安排是非常重要的。

我猜这正是 Simonyi 开始采用某些之后被称作匈牙利命名法的原因之一。它看起来像匈牙利文,而 Simonyi 是从匈牙利来,所以以匈牙利为名。在 Simonyi 版本的匈牙利命名法中,每个变量都会加一个小写的字首,表示变量内容的种类。

我是故意用种类(kind)这个词,因为 Simonyi 在他的文章中误用了类型(type),结果好几世代的程序师都误解了他的意思。

如果你仔细读 Simonyi 的文章,就会发现他所讲的和我之前范例所用的命名规范是一样的,在我的范例中把 us 和 s
分别定义为不安全字串和安全字串。这两者的类型都是字串。如果你把某种字串指派另一种,编译器并不会给任何警告,Intellisense
也不会说些什么。可是它们的语意是不同的;它们解读和处理的方式都不同,要把两种字串互相指派时还要某些转换函数做转换,否则就会有执行时期的问题。祝你
好运。

微软内部称 Simonyi 对匈牙利命名法的原始概念为"应用匈牙利命名法",因为它用于应用程序部门,也就是 Word 及 Excel。在
Excel 的原始程序码里有大量的 rw 和
col,你看到这些字首就知道它们指的是行(row)和列(column)。没错,它们都是整数,可是两者间的转换完全没有意义。有人告诉我说
Word 的程序码里有大量的 xl 和 xw,xl 代表相对于排版页面的水平座标,而 xw
则代表相对视窗的水平座标。两者都是整数但却是不能互转的。两个程序里都有很多
cb,意思是位元组的个数。没错,这也是整数类型,不过光看变量名就可以得到更多信息:这是位元组的个数,也就是缓冲区的大小。另外如果你看到 xl
= cb 就可以拉警报了。这显然是错的程序,虽然 xl 和 cb 都是整数,可是把以像素为单位的水平位移设成位元组个数绝对是疯了。

在应用匈牙利命名法中字首可以用于函数和变量。因此虽然我没真的看过 Word 的原始码,我还是敢打赌 Word 里一定有个叫
YlFromYw 的函数,可以把垂直方向的视窗座标转成垂直方向的排版页座标。应用匈牙利命名法用 TypeFromType 取代传统的
TypeToType,这样每个函数名就会以传回的类型开头,这正与我稍早在范例中把 Encode 改名为 SFromUs
的作法相同。事实上在正规的应用匈牙利命名法中 Encode 函数一定要改名为 SFromUs
。应用匈牙利命名法在该函数命名上并没有提供其它选择。这其实是件好事,因为你少一件事要背,另外也不必担心 Encode
究竟是用什么类型。程序也变得精确多了。

应用匈牙利命名法非常有用,特别是当初 C 语言盛行,而编译器尚未提供很有用的类型系统时。

不过接下来却出了一些问题。

黑暗世界占用了匈牙利命名法。

似乎没有人知道为什么或是如何发生的,不过似乎是视窗团队中写文件的人不小心创造出后来名为系统匈牙利命名法的东西。

某处有人读了 Simonyi 的文章看到里面用了"类型"这个字眼,因此认为作者指的就是类型,意思就像是类别或是类型系统中,或是编译器所做的类型检查。其实不然。作者很小心并精确的解释他用"类型"这个字的意义,不过没有用。伤害已经造成了。

应用匈牙利命名法的字首很有用而且有意义,"ix"表示阵列索引,"c"表示个数,"d"表示两个数字间的差(比如"dx"表示"宽度"),如此类推。

系统匈牙利命名法的字首作用就差多了,"l"表示长整数,"ul"表示正长整数而"dw"代表双字组(呃,事实上就是正长整数)。在系统匈牙利命名法中,字首只能告诉你变量真正的数据类型。

这误解了 Simonyi
的意图和运行,差异虽细微实质上却是完全不同。这件事唯一的教训是让你知道,如果你写出些没人能懂的艰深难解学术文章,你的想法可能会一再被误解,结果变
得非常荒谬,完全违背你的原意。所以在系统匈牙利命名法中会出现大量的dwFoo表示"双字组的某某",可恶的是某个变量是双字组这件事对你几乎是完全没
用的。难怪大家都很讨厌系统匈牙利命名法。

系统匈牙利命名法的流传既深又广;它是整个视窗程序设计文件的标准;Charles Petzold
的视窗程序设计(学习视窗程序设计的圣经)等书籍更为它广为宣扬,很快的它也成为匈牙利命名法的主要势力,即使在微软内部也一样。在微软内也只有少数不在
Word 和 Excel 团队的程序师了解他们搞出什么样的错。

接下来就是大反抗了。有群程序师们从一开始就没搞懂过匈牙利命名法,他们发现自己用的竟是烦人又几近无用的分支,于是就起来反抗。不过系统匈牙利命
名法里还是有些好东西可以帮你看出问题。如果用系统匈牙利命名法,至少会在使用时知道变量类型。不过没应用匈牙利命名法那么有价值就是了。

大反抗在.NET
第一版发行时到达巅峰,那时微软终于告诉大家"不建议使用匈牙利命名法"。这还真是欢声雷动啊。我根本不认为微软会花心思解释原因。他们只是扫瞄文件中命
名指引的章节然后加上"不要使用匈牙利命名法"的字句。当时匈牙利命名法非常不受欢迎所以没有人会真的抱怨,而除 Excel 及 Word
以外的人都因为不必再用这么麻烦的命名规范而松了一口气,他们认为在有强类型检查及 Intellisense 的时代也不需要这种规范。

不过应用匈牙利命名法还是很有价值的,它加强了程序码的连结让程序码更易阅读,编写,除错及维护,最重要的是它让错误的程序看得出错。

例外处理

在继续之前还有一件事我说过要做,就是再骂一次例外处理。我上次这样做惹来很多麻烦。我在周思博趣谈软体首页上一篇即兴的评论中写说我不喜欢例外处
理,因为它实际上就是隐藏的goto,我认为这比看得到的goto更糟糕。当然就有几百万人跑出来痛骂我。全世界唯一跳出来替我辩护的当然也就是
Raymond Chen 。顺带一提,他既然是世界上最好的程序师,当然得出来讲讲话,对吗?

这篇文章讲到例外处理的重点了。你的眼睛学着看到错误的程序码,这样就能防止问题发生。为了让程序能变得真正稳固,进行程序码检视时得有一套能集中信息的命名规范。换而言之,你眼前有关程序运作的信息愈多,寻找错误的结果愈好。当你看到以下的程序码时

dosomething();
cleanup();

 

你的眼睛会说没什么问题啊。我们总是要做清除的动作。不过 dosomething 有可能会引发一个例外,所以有可能不会调用
cleanup。用 finally 等很简单就能修正这个问题,不过这并不是我的重点:问题在于要知道 cleanup
一定会被调用到的唯一方法,就是调查整个 dosomething
调用树,看看是否有任何场合会产生例外。这也还好,可控制式例外处理(checked
exception)可以让你不用那么辛苦,不过重点是例外处理把信息分散开来了。你得去看其它地方才能知道程序能正确执行,所以无法运用你眼睛天赋的功
能去学习看出错的程序码,因为根本没东西可看。

如果我写个小脚本程序,只是每天一次到处收集数据然后印出来,这时候例外处理好用得不得了。我只想忽略所有可能出错的地方,直接把整个程序用一个大
try/catch 包起来,如果有出什么问题就用 catch
把错误电邮给自己。例外处理对简单随便写的程序很有用,对脚本程序或是不是非常重要或无关生死的程序也不错。不过如果你在写一套作业系统或核电厂程序,或
是用于开心(心脏?)手术的高速电锯,例外处理可是危险的很。

我知道大家会认为我是个无法正确理解例外处理的笨程序师,完全不知道只有当我衷心接纳例外处理后它才能改善我的生活。这种想法真是太糟糕了。想要写
出真正可信赖的程序码,应该要尝试用考虑到人是有弱点的简单工具,而不是靠那些提供有问题的抽象并把副作用隐藏起来,还认为程序员是绝不出错的复杂工具。

补充读物

如果你还是衷心于例外处理,读读 Raymond Chen 的文章(更干净更优雅,不过更难读):"例外处理用得正确与否,很难由程序码看得出来......例外处理太难了,我实在不够聪明无法掌握。"

Raymond对致命宏的文章 A rant against flow control macros 讨论了另一个让信息分散导致程序无法维护的例子。"当看到使用[宏]的程序码时,你必须看遍各个头文件才能了解它们的作用。"

想要了解匈牙利命名法的历史背景,可以由 Simonyi 的原文匈牙利命名法开始。Doug Klunder
在另一篇比较清楚的文章中把它引进 Excel 团体 。想知道更多匈牙利命名法的故事以及如何被文件编写人破坏的始末,可以去看 Larry
Osterman 站上的贴文,特别是 Scott Ludwig 的评论,或是 Rick Schaut 贴的文章。