代码改变世界

编写自文档化的代码

2009-06-21 23:02 by Anders Cui, ... 阅读, ... 评论, 收藏, 编辑

文所以载道也。  —— 宋·周敦颐《通书·文辞》

对于我们程序员来说,我们的工作也是写作——几乎每天都要写代码;而且还要载“道”,不仅仅要满足客户的需求,还要让代码具有高度的可读性,这样其他的程序员可以更容易地对代码进行修改和扩展。

按这样的要求,我们需要为代码编写足够的文档,也就是将代码“文档化”。常见的做法有两种,外部文档和注释。

外部文档

外部文档指的是在代码文件之外编写的附加文档,比如在Word文档中采用大量的篇幅(如UML图、表格)来设计或记录相关的包、类型、类型成员、成员参数之类的信息。这看起来很规范,但如果你用过这种方式,一定会讨厌它。这种方式的主要问题在于:

1)增加很多额外的工作:编写代码本身的压力已经很大,在压力之下,我们往往选择做那些最必需的事情,就是实现功能,如果时间紧急,编写文档就可能是草草了事了。

2)文档需要与代码保持同步:即使你在开始认真编写了文档,后来代码有了修改和扩展(这是不可避免的,即使采用所谓的冻结需求),那么文档就需要更新,否则就会提供误导信息。

3)大量的文档难以管理:如果代码量较大,那么本身就需要大量的文档;同时文档也需要进行版本管理,那么就产生了不同版本的文档;另外这些文档基本上是一些简单的文本。如此一来,要在这些文档中找到所需的信息,难上加难。

文档具有这些问题,一个重要的原因是,它们离代码太“”了。我们可以将它们搬到代码文件里面,这就是第二种做法:注释。

注释

程序员对文档往往比较抵触,对注释的态度就温和多了,甚至相当一部分人支持编写大量的注释。的确,如果在IDE中看到分布合理的绿色代码块(注释文本的常见颜色),人们会感觉比较舒服,如果满屏幕全是代码,心里不免会犯怵。

从语法的角度来看,注释就是编译器将忽略不计的源代码块。所以,在这里你想写什么就写什么。

从语义的角度来看,注释是昏暗泥泞的小路和明亮通畅的大道之间的区别。注释是对其所处位置的代码的解释,它可以强调某些特定问题、描述某个复杂算法、对代码进行合理分隔、协助进行维护的程序员(这个人有可能是你自己)。由此可把注释看作是代码的一种“内部文档”。

那是不是就需要大量的注释呢?至少我们曾经被这样教导过,但事实并非如此。不知道你的习惯怎样,我在阅读代码的时候,看到注释一般会先看注释,我在假定这些注释对代码提供了附加的价值。但我发现,注释往往很随意,甚至有可能误导别人。你可以说,这是注释编写者的问题,注释是无辜的,但必须承认的是,注释比代码更容易说谎。究其原因,注释虽然离代码很近,但仍然是一种文档,它具有与外部文档类似的问题:

1)增加很多额外的工作

2)需要与代码保持同步

3)大量的注释可能会妨碍代码的阅读:注释在那里,人们不能置之不理,如果注释太多就成阻碍了。《重构》一书认为注释过多是一种“坏味道”。

看来,注释也有不少问题,它们离代码仍有距离。能否将“文档”与代码的距离再拉近一点?那该怎么做?

先来考虑我们为什么要添加注释。这往往是因为代码本身不容易让人看懂,也就是说代码的意图和表现有距离,所以才需要使用注释。如果能够做到让代码本身就体现出意图,是不是就不需要注释了?这种方式就是本文的主题:代码的自文档化。(需要注意的是,自文档化能够取代大多数的注释,但并不能100%取代)

什么是代码的自文档化(Self Documenting Code)?

唯一能完整并正确地描述代码的文档时代码本身,通常情况下,这也是你能获得的唯一文档。因此,我们应当努力使代码成为良好的文档,一种人人可以读懂的文档。这也就是通常所说的良好的可读性,做到了这一点,犯错的可能性就降低了,同时代码的维护成本也降低了——人们不需要花太多时间去熟悉你的代码。(更多信息,可以参考Ward Cunningham的wiki

代码自文档化的技巧

我们可以采用多种方式来提高代码的可读性。其中一些技巧是非常基础的,我们在编程之初已学习过,而有些则更为巧妙。这里首先给出两个例子,加深一下你对代码可读性好坏的印象。

让人郁闷的代码
static int fval(int i)
{
    
int ret = 2;
    
for (int n1 = 1, n2 = 1, i2 = i - 3; i2 >= 0--i2)
    {
        n1 
= n2; n2 = ret; ret = n1 + n2;
    }
    
return (i < 2? 1 : ret;
}

 

让人舒服的代码


看到第一个函数,是不是想骂人了?骂完了,你还得跟踪代码才能了解代码在干什么;而第二个则很清晰,你会由衷的感谢代码的作者。事实上,两者的功能一样,都是计算斐波纳契序列的值。所以说,为了不被人骂,我们也得注重代码的可读性:) 下面来看看有哪些具体的技巧。

1)好的代码样式

代码的样式极大地影响着代码的清晰度。

a)让“正常”流程贯穿你的代码,异常情况不该扰乱它。比如在使用if…else…时。

b)避免过多的嵌套

c)还有其它更为基本的,比如代码行不要过长,一行内只有一个语句等等(见前面的坏例子)。

2)选择有意义的名称

这一点怎么强调都不过分。如果文件、类型、函数、变量、参数都有了适当的名称,那么将减少大量的注释。这种方式可以让我们编写更接近于自然语言的代码。

关于有意义的名称,可以写另一篇文章了。这里仅作简单介绍。

a)属性、变量和参数命名

如果是名词性的内容,用名词命名。如user,userName(考虑一下两者的不同),还有elapsedTime等等。

如果是逻辑性的内容,要体现出来。比如isEmpty,isRunning等等。对比下面的代码:

使用良好的命名
if(isEmpty)
{
}

if(flag)
{
}

b)函数/方法命名

函数往往表示行为,所以在逻辑上应包含一个动词,如Reset、Start等等。

有时我们还可以隐含参数和返回值的信息,如看到ContainsKey,我们可以了解它的参数应当是key,而返回值为bool类型的值,表示是否包含该key。

这样的例子,从.NET Framework的类库中可以大量看到,要学习这些规范,建议阅读《.NET设计规范》。

回头看看那个可恶的fval(),你能看出它是干什么的吗?

c)类型和命名空间的命名

这些也有一些约定俗成的东西,比如Attribute、Exception和接口命名等等,最重要的是团队内保持一致。在此不再赘述。

d)文件的命名

一般而言,文件的名字应该能反映出所包含的类型。但在.NET中,有partial类的概念,同一个类的代码可能分布在不同文件中,这时建议先对代码进行合理的分组,其中一个主文件,它的名称与类型名称相同,其它文件以此为基础,并且整个团队保持一致。比如ASP.NET中的Default.aspx.cs和Default.aspx.designer.cs。

关于命名,还有一个要注意的地方:并非写得越多越好。比如PersonClass,DataObject,还有常见的匈牙利命名法,iCount,strName这样的都该放弃,别人从count和name就可以了解到足够的信息了。

3)分解为原子函数

重构》一书曾提到,“当我看到一段需要注释才能让人理解意图的代码,我就会将它放进一个独立的函数中”。这种方式衍生的一个问题是,函数细化到什么程度?

a)一个函数,一种操作:同时为此操作起一个容易理解的名字,这时,注释还需要吗?

b)减少副作用:副作用总需要额外的说明

c)简短:虽然你是想只包含一种操作,但发现最后的代码竟然有数百行,那说明还不够细化,继续分解,如此递归下去

4)通过语言层面的机制减少误解

a)如果参数是非负整数,那么考虑用uint来代替int,否则就需要添加注释来说明

b)如果需要一个不能被改变的值,就使用readonly或const

c)使用枚举描述一组相关的值

d)选择合适的修饰符,当你为方法使用的修饰符是public或private时,别人读到时的感受肯定不同

5)使用常量

在阅读代码的时候,映入眼帘的如果是这样的代码:if(counter == 76),相信你会变得紧张、不知所措,这样的数字称为魔数(Magic Number)。我们的程序不需要像魔术那样让人看不懂,我们现在讨论的是提高可读性。为它添加一个有意义的名字吧,比如bananasPerCake,它的一个额外好处是带来了更好的可维护性,因为现在只要修改一处。

这里针对不仅仅是数字,而是任何让人看不懂的硬编码。

6)强调重要的代码

有时代码的读者不需要了解所有信息,这时可以:

a)在类中按一定的顺序进行声明。比如public的成员放在前面,这往往是读者最需要了解的,可以考虑的一种方式是使用C#中的#region。

b)隐藏不重要的信息,这时自然就强调了重要的信息了

c)限制嵌套的条件语句的数量,否则就容易掩盖那些重要的条件分支了

7)考虑上下文信息

在一个地方就只考虑它该考虑的事情。比如异常处理,在团队内可以约定,统一在业务逻辑层进行处理,而不是在任何地方都进行处理。

关于相关的技巧就写到这里,相信还会第8, 9, …, N条可以补充,不过在这些之后如果还是不满足怎么办?这时候考虑使用注释。

N+1)编写有意义的注释

如果不能以代码本身的方式提高可读性,最后才考虑注释。关于编写注释,又可以写一篇文章了,这里只想说,把注释作为最后一种选择,而且添加的注释应该给代码带来附加价值

不过,我们总有犯错误的时候,怎样补救呢?重构代码吧,为了自己,也为了其他要阅读你代码的人。不过,可以使用一些好用的工具,免得在重构的时候继续犯错,这里推荐一个免费的工具:CodeRush Xpress,我曾做过一个介绍。它极大地增强了VS的重构能力,对于上面提供的几点比如代码样式、重命名、提取方法、提取常量都提供了良好的支持。

最后,介绍一下我最近在考虑的一种编码方式,希望它能帮助你编写更具可读性的代码。

前面说了这么多,其目的就是让代码具有更好的可读性,那最有可读性的文本是什么呢?当然是自然语言,但我们不能以自然语言编码(至少现在还不行)。那么退一步,考虑伪代码,它介于自然语言和编程语言之间,形式非常灵活。如果能以伪代码的方式编码,是不是也很好?这里还是借助于CodeRush Xpress。

以接近伪代码的方式编程

以我最近遇到的一个问题为例,不过要简化一下。客户需要以编程的方式从VSS上Checkout一个文件,现在了解到实现此功能需要的信息有文件的服务器端路径,本地路径,本次操作的注释信息,还有如果本地文件如果是可写的,要提示用户是否覆盖,暂时先考虑这些。首先对于输入的服务器端路径可能对应文件夹,也可能对应文件,要分开考虑,此时可以写出如下的代码:

C# Code
public void Checkout()
{
    
string serverPath;
    
string localPath;
    
string comment;

    
if (IsFile(serverPath))
    {
        CheckoutFile(serverPath, localPath, comment);
    }
    
else
    {
        CheckoutFolder(serverPath, localPath, comment);
    }
}


这样需要三个新方法:IsFile、CheckoutFile和CheckoutFolder,注意它们的命名表达了我的思路(意图)。在VS中可以生成它们的方法存根:

generate-method-stub

IsFile比较简单,先不管它,现在考虑CheckoutFile的实现。在Checkout一个文件时,要考虑本地文件可写的情况,可以写出如下的代码:

C# Code
private void CheckoutFile(string serverPath, string localPath, string comment)
{
    
if (IsWritable(localPath))
    {
        
if (!WantToGetLocalCopy())
        {
            CheckoutWithoutGettingLocalCopy(serverPath, localPath, comment);
        }
    }

    CheckoutAndGetLocalCopy(
serverPath, localPath, comment);
}


这里又添加了几个方法,方法名同样表达了思路。到这一步方法们都比较小了,可以直接实现,对于CheckoutFolder,也按同样方式实现。最后检查一下,开始声明的几个局部变量应该作为参数,把它们“提升”为参数:

promote-to-parameter

这样完成了Checkout方法。这种方式的好处在于基本上按照自然的思维方式来编码,把想做的事情命名为方法即可,可读性自然较高。而且,最终将方法分解、细化,符合前面的第三条。建议尝试一下这种方法。

小结

文档的可读性是如此重要,以至于可以作为程序员是否职业的一个标准。通过本文的分析可以了解到,使代码自文档化是提高可读性的最佳选择。

参考

编程匠艺

重构

Self Documenting Code by c2.com

http://en.wikipedia.org/wiki/Self-documenting