程序员的代码注释需要写么?

“别给糟糕的代码加注释——重新写吧。”—Brian W. Kernighan与P. J. Plaugher

什么也比不上放置良好的注释来得有用。什么也不会比乱七八糟的注释更有本事搞乱一个模块。什么也不会比陈旧、提供错误信息的注释更有破坏性。

注释并不像辛德勒的名单。它们并不“纯然地好”。实际上,注释最多也就是一种必须的恶。若编程语言足够有表达力,或者我们长于用这些语言来表达意图,就不那么需要注释——也许根本不需要。

注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。注意,我用了“失败”一词。我是说真的。注释总是一种失败。我们总无法找到不用注释就能表达自我的方法,所以总要有注释,这并不值得庆贺。

如果你发现自己需要写注释,再想想看是否有办法翻盘,用代码来表达。每次用代码表达,你都该夸奖一下自己。每次写注释,你都该做个鬼脸,感受自己在表达能力上的失败。

我为什么要极力贬低注释?因为注释会撒谎。也不是说总是如此或有意如此,但出现得实在太频繁。注释存在的时间越久,就离其所描述的代码越远,越来越变得全然错误。原因很简单。程序员不能坚持维护注释。

代码在变动,在演化。从这里移到那里。彼此分离、重造又合到一处。很不幸,注释并不总是随之变动——不能总是跟着走。注释常常会与其所描述的代码分隔开来,孑然飘零,越来越不准确。例如,看看以下注释以及它本来要描述的代码行变成了什么样子:

MockRequest request;
private final String HTTP_DATE_REGEXP = 
 "[SMTWF][a-z]{2}\\,\\s[0-9]{2}\\s[JFMASOND][a-z]{2}\\s"+
 "[0-9]{4}\\s[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}\\sGMT";
private Response response;
private FitNesseContext context;
private FileResponder responder;
private Locale saveLocale;
// Example: "Tue, 02 Apr 2003 22:18:49 GMT"

在HTTP_DATE_REGEXP常量及其注释之间,有可能插入其他实体变量。

程序员应当负责将注释保持在可维护、有关联、精确的高度。我同意这种说法。但我更主张把力气用在写清楚代码上,直接保证无须编写注释。

不准确的注释要比没注释坏得多。它们满口胡言。它们预期的东西永不能实现。它们设定了无需也不应再遵循的旧规则。

真实只在一处地方有:代码。只有代码能忠实地告诉你它做的事。那是唯一真正准确的信息来源。所以,尽管有时也需要注释,我们也该多花心思尽量减少注释量。

1 注释不能美化糟糕的代码

写注释的常见动机之一是糟糕的代码的存在。我们编写一个模块,发现它令人困扰、乱七八糟。我们知道,它烂透了。我们告诉自己:“喔,最好写点注释!”不!最好是把代码弄干净!

带有少量注释的整洁而有表达力的代码,要比带有大量注释的零碎而复杂的代码像样得多。与其花时间编写解释你搞出的糟糕的代码的注释,不如花时间清洁那堆糟糕的代码。

2 用代码来阐述

有时,代码本身不足以解释其行为。不幸的是,许多程序员据此以为代码很少——如果有的话——能做好解释工作。这种观点纯属错误。你愿意看到这个:

// Check to see if the employee is eligible for full benefits 
if ((employee.flags & HOURLY_FLAG) && 
   (employee.age > 65))

还是这个?

if (employee.isEligibleForFullBenefits())

只要想上那么几秒钟,就能用代码解释你大部分的意图。很多时候,简单到只需要创建一个描述与注释所言同一事物的函数即可。

3 好注释

有些注释是必须的,也是有利的。来看看一些我认为值得写的注释。不过要记住,唯一真正好的注释是你想办法不去写的注释。

3.1 法律信息

有时,公司代码规范要求编写与法律有关的注释。例如,版权及著作权声明就是必须和有理由在每个源文件开头注释处放置的内容。

下例是我们在FitNesse项目每个源文件开头放置的标准注释。我可以很开心地说,IDE自动卷起这些注释,这样就不会显得凌乱了。

// Copyright (C) 2003,2004,2005 by Object Mentor, Inc. All rights reserved.
// Released under the terms of the GNU General Public License version 2 or later.

这类注释不应是合同或法典。只要有可能,就指向一份标准许可或其他外部文档,而不要把所有条款放到注释中。

3.2 提供信息的注释

有时,用注释来提供基本信息也有其用处。例如,以下注释解释了某个抽象方法的返回值:

// Returns an instance of the Responder being tested.
protected abstract Responder responderInstance();

这类注释有时管用,但更好的方式是尽量利用函数名称传达信息。比如,在本例中,只要把函数重新命名为responderBeingTested,注释就是多余的了。

下例稍好一些:

// format matched kk:mm:ss EEE, MMM dd, yyyy
Pattern timeMatcher = Pattern.compile(
 "\\d*:\\d*:\\d* \\w*, \\w* \\d*, \\d*");

在本例中,注释说明,该正则表达式意在匹配一个经由SimpleDateFormat.format函数利用特定格式字符串格式化的时间和日期。同样,如果把这段代码移到某个转换日期和时间格式的类中,就会更好、更清晰,而注释也就变得多此一举了。

3.3 对意图的解释

有时,注释不仅提供了有关实现的有用信息,而且还提供了某个决定后面的意图。在下例中,我们看到注释反映出来的一个有趣决定。在对比两个对象时,作者决定将他的类放置在比其他东西更高的位置。

public int compareTo(Object o)
{
 if(o instanceof WikiPagePath)
 {
  WikiPagePath p = (WikiPagePath) o;
  String compressedName = StringUtil.join(names, "");
  String compressedArgumentName = StringUtil.join(p.names, "");
  return compressedName.compareTo(compressedArgumentName);
 }
 return 1; // we are greater because we are the right type.
}

下面的例子甚至更好。你也许不同意程序员给这个问题提供的解决方案,但至少你知道他想干什么。

public void testConcurrentAddWidgets() throws Exception {
 WidgetBuilder widgetBuilder = 
  new WidgetBuilder(new Class[]{BoldWidget.class});
 String text = "'''bold text'''";
 ParentWidget parent = 
  new BoldWidget(new MockWidgetRoot(), "'''bold text'''");
 AtomicBoolean failFlag = new AtomicBoolean();
 failFlag.set(false);

 //This is our best attempt to get a race condition 
 //by creating large number of threads.
 for (int i = 0; i < 25000; i++) {
  WidgetBuilderThread widgetBuilderThread = 
   new WidgetBuilderThread(widgetBuilder, text, parent, failFlag);
  Thread thread = new Thread(widgetBuilderThread);
  thread.start();
 }
 assertEquals(false, failFlag.get());
}

3.4 阐释

有时,注释把某些晦涩难明的参数或返回值的意义翻译为某种可读形式,也会是有用的。通常,更好的方法是尽量让参数或返回值自身就足够清楚;但如果参数或返回值是某个标准库的一部分,或是你不能修改的代码,帮助阐释其含义的代码就会有用。

public void testCompareTo() throws Exception
{
 WikiPagePath a = PathParser.parse("PageA");
 WikiPagePath ab = PathParser.parse("PageA.PageB");
 WikiPagePath b = PathParser.parse("PageB");
 WikiPagePath aa = PathParser.parse("PageA.PageA");
 WikiPagePath bb = PathParser.parse("PageB.PageB");
 WikiPagePath ba = PathParser.parse("PageB.PageA");

 assertTrue(a.compareTo(a) == 0);  // a == a
 assertTrue(a.compareTo(b) != 0);  // a != b
 assertTrue(ab.compareTo(ab) == 0); // ab == ab
 assertTrue(a.compareTo(b) == -1);  // a < b
 assertTrue(aa.compareTo(ab) == -1); // aa < ab
 assertTrue(ba.compareTo(bb) == -1); // ba < bb
 assertTrue(b.compareTo(a) == 1);  // b > a
 assertTrue(ab.compareTo(aa) == 1); // ab > aa
 assertTrue(bb.compareTo(ba) == 1); // bb > ba
}

当然,这也会冒阐释性注释本身就不正确的风险。回头看看上例,你会发现想要确认注释的正确性有多难。这一方面说明了阐释有多必要,另外也说明了它有风险。所以,在写这类注释之前,考虑一下是否还有更好的办法,然后再加倍小心地确认注释正确性。

3.5 警示

有时,用于警告其他程序员会出现某种后果的注释也是有用的。例如,下面的注释解释了为什么要关闭某个特定的测试用例:

// Don't run unless you
// have some time to kill. 
public void _testWithReallyBigFile()
{
 writeLinesToFile(10000000);

 response.setBody(testFile);
 response.readyToSend(this);
 String responseString = output.toString();
 assertSubString("Content-Length: 1000000000", responseString);
 assertTrue(bytesSent > 1000000000);
}

当然,如今我们多数会利用附上恰当解释性字符串的@Ignore属性来关闭测试用例。比如@Ignore("Takes too long to run[2]")。但在JUnit4之前的日子里,惯常的做法是在方法名前面加上下划线。如果注释足够有说服力,就会很有用了。

这里有个更麻烦的例子:

public static SimpleDateFormat makeStandardHttpDateFormat()
{
 //SimpleDateFormat is not thread safe, 
 //so we need to create each instance independently.
 SimpleDateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z");
 df.setTimeZone(TimeZone.getTimeZone("GMT"));
 return df;
}

你也许会抱怨说,还会有更好的解决方法。我大概会同意。不过上面的注释绝对有道理存在,它能阻止某位急切的程序员以效率之名使用静态初始器。

3.6 TODO注释

有时,有理由用//TODO形式在源代码中放置要做的工作列表。在下例中,TODO注释解释了为什么该函数的实现部分无所作为,将来应该是怎样。

//TODO-MdM these are not needed
// We expect this to go away when we do the checkout model
protected VersionInfo makeVersion() throws Exception
{
return null;
}

TODO是一种程序员认为应该做,但由于某些原因目前还没做的工作。它可能是要提醒删除某个不必要的特性,或者要求他人注意某个问题。它可能是恳请别人取个好名字,或者提示对依赖于某个计划事件的修改。无论TODO的目的如何,它都不是在系统中留下糟糕的代码的借口。

如今,大多数好IDE都提供了特别的手段来定位所有TODO注释,这些注释看来丢不了。你不会愿意代码因为TODO的存在而变成一堆垃圾,所以要定期查看,删除不再需要的。

3.7 放大

注释可以用来放大某种看来不合理之物的重要性。

String listItemContent = match.group(3).trim();
// the trim is real important. It removes the starting 
// spaces that could cause the item to be recognized
// as another list.
new ListItemWidget(this, listItemContent, this.level + 1);
return buildList(text.substring(match.end()));

3.8 公共API中的Javadoc

没有什么比被良好描述的公共API更有用和令人满意的了。标准Java库中的Javadoc就是一例。没有它们,写Java程序就会变得很难。

如果你在编写公共API,就该为它编写良好的Javadoc。不过要记住本章中的其他建议。就像其他注释一样,Javadoc也可能误导、不适用或者提供错误信息。

4 坏注释

大多数注释都属此类。通常,坏注释都是糟糕的代码的支撑或借口,或者对错误决策的修正,基本上等于程序员自说自话。

推荐你阅读《代码整洁之道》,第4章中有详细讲解。

本书大致可分为3个部分。前几章介绍编写整洁代码的原则、模式和实践。这部分有相当多的示例代码,读起来颇具挑战性。读完这几章,就为阅读第2部分做好了准备。如果你就此止步,只能祝你好运啦!

第2部分最需要花工夫。这部分包括几个复杂性不断增加的案例研究。每个案例都清理一些代码——把有问题的代码转化为问题少一些的代码。这部分极为详细。你的思维要在讲解和代码段之间跳来跳去。你得分析和理解那些代码,琢磨每次修改的来龙去脉。

你付出的劳动将在第3部分得到回报。这部分只有一章,列出从上述案例研究中得到的启示和灵感。在遍览和清理案例中的代码时,我们把每个操作理由记录为一种启示或灵感。我们尝试去理解自己对阅读和修改代码的反应,尽力了解为什么会有这样的感受、为什么会如此行事。结果得到了一套描述在编写、阅读、清理代码时思维方式的知识库。

如果你在阅读第2部分的案例研究时没有好好用功,那么这套知识库对你来说可能所值无几。在这些案例研究中,每次修改都仔细注明了相关启示的标号。这些标号用方括号标出,如:[H22]。由此你可以看到这些启示在何种环境下被应用和编写。启示本身不值钱,启示与案例研究中清理代码的具体决策之间的关系才有价值。

如果你跳过案例研究部分,只阅读了第1部分和第3部分,那就不过是又看了一本关于写出好软件的“感觉不错”的书。但如果你肯花时间琢磨那些案例,亦步亦趋——站在作者的角度,迫使自己以作者的思维路径考虑问题,就能更深刻地理解这些原则、模式、实践和启示。这样的话,就像一个熟练地掌握了骑车的技术后,自行车就如同其身体的延伸部分那样;对你来说,本书所介绍的整洁代码的原则、模式、实践和启示就成为了本身具有的技艺,而不再是“感觉不错”的知识。

猜你喜欢

转载自blog.csdn.net/epubit17/article/details/121080769