《代码整洁之道》第 3 章 函数

第 3 章 函数

3.1 短小

函数的第一规则是短小。函数不该有 100 行那么长,20 行封顶最佳。…… 每个函数都只有两行、三行或四行长。每个函数都一目了然。每个函数都只做一件事。而且,每个函数都依序把你带到下一个函数。这就是函数应该达到的短小程度!

if 语句、else 语句、while 语句等,其中的代码块应该只有一行。该行大抵应该是一个函数调用语句。这样不但能保持函数短小,而且,因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。这也意味着函数不应该大到足以容纳嵌套结构。所以,函数的缩进层级不该多于一层或两层。当然,这样的函数易于阅读和理解。

3.2 只做一件事

函数应该做一件事。 做好这件事。只做这一件事。

如果函数只是做了该函数名下同一抽象层上的步骤,则函数还是只做了一件事。编写函数毕竟是为了把大一些的概念(换言之,函数的名称)拆分为另一抽象层上的一系列步骤。

所以,要判断函数是否不止做了一件事,还有一个方法,就是看是否能再拆出一个函数,该函数不仅只是单纯地重新诠释其实现。

3.3 每个函数一个抽象层级

自顶向下读代码:向下规则

要确保函数只做一件事,函数中的语句都要在同一抽象层级上。换一种说法。我们想要这样读程序:程序就像是一系列 TO 起头的段落,每一段都描述当前抽象层级,并引用位于下一抽象层级的后续 TO 起头段落。

To include the setups and teardowns, we include setups, then we include the test page content, and then we include the teardowns. (要容纳设置和分拆步骤,就先容纳设置步骤,然后纳入测试页面内容,再纳入分拆步骤。)
To include the setups, we include the suite setup if this is a suite, then we include the regular setup. (要容纳设置步骤,如果是套件,就纳入套件设置步骤,然后再纳入普通设置步骤。)
To include the suite setup, we search the parent hierarchy for the “SuiteSetUp” page and add an include statement with the path of that page. (要容纳套件设置步骤,先搜索"SuiteSetUp” 页面的上级继承关系,再添加一个包括该页面路径的语句。)
To search the parent.. (要搜索.....)

3.4 switch 语句

写出只做一件事的 switch 语句也很难。Switch 天生要做 N 件事。不幸我们总无法避开 switch 语句,不过还是能够确保每个 switch 都埋藏在较低的抽象层级,而且永远不重复。当然,我们利用多态来实现这一点。

public Money calculatePay (Employee e)
throws InvalidEmployeeType {
  switch (e.type) {
  	case COMMISSIONED:
  		return calculateCommissionedPay(e);
    case HOURLY: 
    	return calculateHourlyPay(e);
    case SALARIED:
    	return calculateSalariedPay(e);
    default:
    	throw new InvalidEmployeeType (e. type);
  }
}

该函数有好几个问题。首先,它太长,当出现新的雇员类型时,还会变得更长。其次,它明显做了不止一件事。第三,它违反了单一权责原则(Single Responsibility Principle, SRP),因为有好几个修改它的理由。第四,它违反了开放闭合原则(Open Closed Principle, OCP),因为每当添加新类型时,就必须修改之。不过,该函数最麻烦的可能是到处皆有类似结构的函数。例如,可能会有

isPayday (Employee e, Date date),

deliverPay (Employee e, Money pay),

如此等等。它们的结构都有同样的问题。
该问题的解决方案是将 switch 语句埋到抽象工厂底下,不让任何人看到。该工厂使用 switch 语句为 Employee 的派生物创建适当的实体,而不同的函数,如calculatePayisPaydaydeliverPay 等,则藉由 Employee 接口多态地接受派遣。

public abstract class Employee {
  public abstract boolean isPayday{) ;
  public abstract Money calculatePay() ;
  public abstract void deliverPay (Money pay) ;
}

public interface EmployeeFactory {
	public Employee makeEmployee (EmployeeRecord r) throws InvalidEmployeeType;
}
  
public class EmployeeFactoryImpl implements EmployeeFactory {
  public Employee makeEmployee (EmployeeRecord r) throws InvalidEmployeeType {
  switch (r.type) {
    case COMMISSIONED:
      return new CommissionedEmployee(r) ;
    case HOURLY:
      return new HourlyEmployee (r) ;
    case SALARIED:
      return new SalariedEmploye(r) ;
    default:
      throw new InvalidEmployeeType (r.type);
    }
  }
}

3.5 使用描述性的名称

别害怕长名称。长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。使用某种命名约定,让函数名称中的多个单词容易阅读,然后使用这些单词给函数取个能说清其功用的名称。

3.6 函数参数

最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)。有足够特殊的理由才能用三个以上参数(多参
数函数)——所以无论如何也不要这么做。

3.6.1 一元函数的普遍形式

向函数传入单个参数有两种极普遍的理由。你也许会问关于那个参数的问题,就像在 boolean fileExists("MyFile") 中那样。也可能是操作该参数,将其转换为其他什么东西,再输出之。

还有一种虽不那么普遍但仍极有用的单参数函数形式,那就是事件(event)。在这种形式中,有输入参数而无输出参数。程序将函数看作是一个事件,使用该参数修改系统状态,例如 void passwordAttemptFailedNtimes(int attempts)

对于转换,使用输出参数而非返回值令人迷惑。如果函数要对输入参数进行转换操作,转换结果就该体现为返回值。实际上,StringBuffer transform(StringBuffer in) 要比 void transform(StringBuffer out) 强,即便第一种形式只简单地返回输出参数也是这样。至少,它遵循了转换的形式。

3.6.2 标识参数

标识参数丑陋不堪。向函数传入布尔值简直就是骇人听闻的做法。这样做,方法签名立刻变得复杂起来,大声宣布本函数不止做一件事。如果标识为 true 将会这样做,标识为 false 则会那样做!

方法调用 render(true) 对于可怜的读者来说仍然摸不着头脑。卷动屏幕,看到 render(Boolean isSuite), 稍许有点帮助,不过仍然不够。应该把该函数一分为二:reanderForSuite()renderForSingleTest()

3.6.3 二元函数

有两个参数的函数比一元函数难懂。

当然,有些时候两个参数正好。例如,Point p = new Point(0,0); 就相当合理。笛卡儿点天生拥有两个参数。如果看到 new Point(0),我们会倍感惊讶。

然而,本例中的两个参数却只是单个值的有序组成部分! 而 output-Streamname 则既非自然的组合,也不是自然的排序。
即便是如 assertEquals(expected, actual) 这样的二元函数也有其问题。你有多少次会搞错 actualexpected 的位置呢?这两个参数没有自然的顺序 expected 在前,actual 在后,只是一种需要学习的约定罢了。

3.6.5 参数对象

如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数应该封装为类了。例如,下面两个声明的差别:

Circle makeCircle (double X, double y, double radius);
Circle makeCircle(Point center, double radius);

从参数创建对象,从而减少参数数量,看起来像是在作弊,但实则并非如此。当一组参数被共同传递,就像上例中的 x 和 y 那样,往往就是该有自己名称的某个概念的一部分。

3.6.7 动词与关键词

函数和参数应当形成一种非常良好的动词/名词对形式。例如,write(name) 就相当令人认同。不管这个 “name” 是什么,都要被 “write”。 更好的名称大概是writeField(name), 它告诉我们,"name" 是一个 “field"。

最后那个例子展示了函数名称的关键字 (keyword) 形式。使用这种形式,我们把参数的名称编码成了函数名。例如,assertEqual 改成assertExpectedEqualsActual(expected, actual) 可能会好些。这大大减轻了记忆参数顺序的负担。

3.7 无副作用

副作用是一种谎言。 函数承诺只做一件事,但还是会做其他被藏起来的事。有时,它会对自己类中的变量做出未能预期的改动。有时,它会把变量搞成向函数传递的参数或是系统全局变量。无论哪种情况,都是具有破坏性的,会导致古怪的时序性耦合及顺序依赖。

输出参数:参数多数会被自然而然地看作是函数的输入。如果你编过好些年程序,我担保你一定被用作输出而非输入的参数迷惑过。例如:
appendFooter(s);

这个函数是把 s 添加到什么东西后面吗? 或者它把什么东西添加到了 s 后面? s 是输入参数还是输出参数?稍许花点时间看看函数签名:
public void appendFooter (StringBuffer report)

事情清楚了,但付出了检查函数声明的代价。你被迫检查函数签名,就得花上一点时间。应该避免这种中断思路的事。

在面向对象编程之前的岁月里,有时的确需要输出参数。然而,面向对象语言中对输出=参数的大部分需求已经消失了,因为 this 也有输出函数的意味在内。换言之,最好是这样调用 appendFooter:
report.appendFooter();

3.8 分隔指令与询问

函数要么做什么事,要么回答什么事,但二者不可得兼。函数应该修改某对象的状态,或是返回该对象的有关信息。两样都干常会导致混乱。看看下面的例子:
public boolean set (String attribute, String value);

该函数设置某个指定属性,如果成功就返回 true,如果不存在那个属性则返回 false。 这样就导致了以下语句: .
if (set ("username", "unclebob")} ...

从读者的角度考虑一下吧。这是什么意思呢?它是在问 username 属性值是否之前已设置为 unclebob 吗?或者它是在问 username 属性值是否成功设置为 unclebob 呢?从这行调用很难判断其含义,因为 set 是动词还是形容词并不清楚。

…… 真正的解决方案是把指令与询问分隔开来,防止混淆的发生:

if (attributeExists("username")) {
	setAttribute("username", "unclebob");
	...
}

3.9 使用异常替代返回错误码

从指令式函数返回错误码轻微违反了指令与询问分隔的规则。它鼓励了在 if 语句判断中把指令当作表达式使用。
if (deletePage (page) == E_OK)
这不会引起动词/形容词混淆,但却导致更深层次的嵌套结构。当返回错误码时,就是在要求调用者立刻处理错误。

if (deletePage (page) == E_OK) {
	if (registry.deleteReference(page.name) == E_OK) {
		if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
			logger.log("page deleted");
    } 
    else {
    	logger.log ("configKey not deleted");
    }
  }
  else {
    logger.1og ("deleteReference from registry failed") ;
  } 
}
else {
   logger.1og ("delete failed") ;
   return E_ERROR;
}

另一方面,如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,得到简化:

try {
	deletePage(page) ;
	registry.deleteReference(page.name);
	configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
	logger.1og(e.getMessage());
}

3.9.1 抽离 Try/Catch 块

Try/catch代码块丑陋不堪。它们搞乱了代码结构,把错误处理与正常流程混为一谈。最
好把try和catch代码块的主体部分抽离出来,另外形成函数。

public void delete(Page page) {
  try {
    deletePageAndAl1References (page) ;
  }
  catch (Exception e) {
    1ogError(e);
  }
}

private void deletePageAndAllReferences (Page page) throws Exception {
  deletePage(page);
  registry.deleteReference (page.name);
  configKeys.deleteKey(page.name.makeKey());
}

private void logError (Exception e) {
	logger.log(e.getMessage());
}

在上例中, delete 函数只与错误处理有关。很容易理解然后就忽略掉。deletePageAndAllReference 函数只与完全删除一个 page 有关。错误处理可以忽略掉。有了这样美妙的区隔,代码就更易于理解和修改了。

3.9.2 错误处理就是一件事

函数应该只做一件事。 错误处理就是一件事。 因此,处理错误的函数不该做其他事。这意味着(如上例所示)如果关键字 try 在某个函数中存在,它就该是这个函数的第一个单词,而且在 catch/finally 代码块后面也不该有其他内容。

3.9.3 Error.java 依赖磁铁

返回错误码通常暗示某处有个类或是枚举,定义了所有错误码。

public enum Error {
  OK,
  INVALID,
  NO_SUCH,
  LOCKED,
  OUT_OF_RESOURCES,
  WAITING_FOR_EVENT;
}

这样的类就是一块依赖磁铁 (dependency magnet); 其他许多类都得导入和使用它。当 Error 枚举修改时,所有这些其他的类都需要重新编译和部署。这对 Error 类造成了负面压力。程序员不愿增加新的错误代码,因为这样他们就得重新构建和部署所有东西。于是他们就复用旧的错误码,而不添加新的。

使用异常替代错误码,新异常就可以从异常类派生出来,无需重新编译或重新部署

3.10 别重复自己

回头仔细看看代码清单 3-1,你会注意到,有个算法在 SetUp、SuiteSetUp、 TearDown 和 SuiteTearDown 中总共被重复了4次。识别重复不太容易,因为这 4 次重复与其他代码混在一起,而且也不完全一样。这样的重复还是会导致问题,因为代码因此而臃肿,且当算法改变时需要修改 4 处地方。而且也会增加 4 次放过错误的可能性

重复可能是软件中一切邪恶的根源。许多原则与实践规则都是为控制与消除重复而创建。例如,全部考德 (Codd) 数据库范式都是为消灭数据重复而服务。再想想看,面向对象编程是如何将代码集中到基类,从而避免了冗余。面向方面编程(Aspect Oriented Programming)、面向组件编程(Component Oriented Programming)多少也都是消除重复的一种策略。看来,自子程序发明以来,软件开发领域的所有创新都是在不断尝试从源代码中消灭重复。

3.11 结构化编程

有些程序员遵循 Edsger Djkstra 的结构化编程规则。Dijkstra 认为,每个函数、函数中的每个代码块都应该有一个入口、一个出口。遵循这些规则,意味着在每个函数中只该有一个return语句,循环中不能有 break 或 continue 语句,而且永永远远不能有任何 goto 语句。

我们赞成结构化编程的目标和规范,但对于小函数,这些规则助益不大。只有在大函数中,这些规则才会有明显的好处。
所以,只要函数保持短小,偶尔出现的 return、break 或 continue 语句没有坏处,甚至还比单入单出原则更具有表达力。另外一方面,goto 只在大函数中才有道理,所以应该尽量避免使用。

3.12 如何写出这样的函数

写代码和写别的东西很像。在写论文或文章时,你先想什么就写什么,然后再打磨它。

初稿也许粗陋无序,你就斟酌推敲,直至达到你心目中的样子。
我写函数时,一开始都冗长而复杂。有太多缩进和嵌套循环。有过长的参数列表。名称是随意取的,也会有重复的代码。不过我会配上-套单元测试,覆盖每行丑陋的代码。

然后我打磨这些代码,分解函数、修改名称、消除重复。我缩短和重新安置方法。有时我还拆散类。同时保持测试通过。

最后,遵循本章列出的规则,我组装好这些函数。

我并不从一开始就按照规则写函数。我想没人做得到。

posted @ 2023-07-23 11:19  CoolGin  阅读(23)  评论(0编辑  收藏  举报