[如何写优雅代码]写好函数
1.函数写得好,头发掉得少
首先要明确我们为什么要写好函数,目的是什么,我们先来看两个函数的对比:
public static String testableHtml(
PageData pageData,
boolean includeSuiteSetup
) throws Exception {
WikiPage wikiPage = pageData.getWikiPage();
StringBuffer buffer = new StringBuffer();
if (pageData.hasAttrinute("Test")) {
if (includeSuiteSetup) {
WikiPage suiteSetUp = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_SETUP_NAME, wikiPage);
}
if (suiteSetUp != null) {
WikiPagePath pagePath = suiteSetUp.getPageCrewler().getFullPath(suiteSetUp);
String pagePathName = PathParser.render(pagePath);
buffer.append("!include -setup .")
.append(pathPathName)
.append("\n")
}
WikiPage setup = PageCrawlerImpl.getInheritedPage("SetUp", wikiPage);
if (setup != null) {
WikiPagePath setupPath = wikiPage.getPageCrawler().getFullPath(setup);
String setupPathName = PathParser.render(setupPath);
buffer.append("!include -setup")
.append("setupPathName")
.append("\n");
}
}
buffer.append(pageData.getContent());
if (pageData.hasAttribute("Test")) {
WikiPage teardonw = PageCrawlerImpl.getInheritedPage("TearDown", wikiPage);
if (teardown != null) {
WikiPagePath tearDownPath = wikiPage.getPageCrawler().getFullPath(teardown);
String tearDownPathName = PathParser.render(tearDownPath);
buffer.append("\n")
.append("!include -teardown")
.append("tearDownPathName")
.append("\n")
}
if (includeSuiteSetup) {
WikiPage suiteTeardown = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_TEARDOWN_NAME, wikiPage);
if (suiteTeardown != null) {
WikiPagePath pagePath = suiteTeardown.getCrawler().getFullPath(suiteTeardown);
buffer.append("!include -teardown .")
.append(pagePathName)
.append("\n");
}
}
}
pageData.setContent(buffer.toString());
return pageData.getHtml();
}
我们姑且称之为函数1.0,相信一眼扫过,能搞懂这个函数1.0在干什么的难度还是很大的,再看一个与之等价的函数2.0:
public static String renderPageWithSetupsAndTeardowns(
PageData pageData, boolean isSuite
) throws Exception{
boolean isTestPage = pageData.hasAttribute("Test")
if (isTestPage){
WikiPage testPage = pageData.getWikiPage();
StringBuffer newPageContent = new StringBuffer();
includeSetupPages(testPage, newPageContent, isSuite);
newPageContent.append(pageData.getPageContent());
includeTeardownPages(testPage, newPageContent, isSuite);
pageData.setContent(newPageContent.toString());
}
return pageData.getHtml();
}
相比之下,这个函数2.0更简洁一些了,好像大概能搞懂整个函数是在构造一个页面并渲染HTML操作。
想象一下你刚进一家公司,雄心壮志地准备大干一场时,当你打开“前辈”留下的代码的时候,最希望看到哪一个函数表现呢?为了咱们仅剩的几根头发,是不是也要选第二种函数啊!
所以,要让“后继者”一目了然地读懂函数,才是写好函数的关键与目的。
2.短小
这是函数的第一原则!本书的观点是代码最好控制在20行左右,遇到if、else、while、try...catch语句块时,尽量保持语句块里只有一行代码,这行代码大抵是一个函数调用语句,并让函数名字的名字拥有说明性的功能,可以很好的减轻读者阅读上的难度。
所以按照上述的原则,每个函数的缩进不能多余一层或两层。
现在让我们再重构函数2.0
public static String renderPageWithSetupsAndTeardowns(
PageData pageData, boolean isSuite
) throws Exception{
if (isTestPage(pageData)){
includeSetupAndTeardownPages(pageData, isSuite);
}
return pageData.getHtml();
}
这样看函数3.0的意图变得更清晰了:把设置和拆解页放入一个测试页面,再渲染为HTML。
3.只做一件事(保证每个函数一个抽象级)
显然函数1.0做了好几件事:创建缓冲区、获取页面、搜索继承下来的页面、添加神秘字符串、生成HTML...不仅手忙脚乱,还让旁观者摸不清头脑。而函数3.0只做了一件事:将设置和拆解包纳到测试页面里。
怎么检查函数是否做了多件事呢?
首先你要知道函数里的一件事是指的是属于同一抽象层面的事情,比如我们可以用To起头段落来描述函数3.0:为了renderPageWithSetupsAndTeardowns,要检查页面是否是测试页面,如果是,就容纳设置和分拆步骤,无论是否为测试页面,最后都渲染成HTML。
但是你很难用To起头段落描述函数1.0和函数2.0,因为它们不只做了一个抽象层面的事,它里面除了getHtml()这样抽象等级高的概念,还有append()这种抽象等级低的概念。
另外还可以通过拆分函数来判断函数是否只做了一件事,比如针对函数3.0的if判断,拆分出includeSetupAndTeardownPagesIfTestPage其实没有太大的意义,只不过是重新诠释了代码。
4.自顶向下规则读代码
当每个函数里的事情都是一个抽象层面的事情,这样从抽象等级高的函数引出抽象等级低的函数,代码自然而然就能够自顶向下顺序阅读了。
5.使用描述性的函数名
一个好的函数名应该描述函数做的事,比如把isTestable改成includeSetupAndTeardownPages,就不会让阅读者猜怎么样才算testable了。
也不用惧怕长名称,长而具有描述性的名称,要比短而令人费解的名称强的多。另外如果遵循了函数只做同一抽象层面的事的原则,给函数气一个短小的名字也不是难事。
6.函数参数与函数意义相关
函数参数最理想的数量是0个,其次是1个、再差是2个,请尽量避免3个。为什么不要太多参数?因为参数不易对付,它们带有太多概念性,每多一个参数,那就多了很多问号:这个参数是干什么的?对这个函数的意义是什么?参数不仅使函数的意义变得厚重,更让测试覆盖所有用例时举步维艰。
一元函数
单参数函数一般有三种理由,一个是为了问参数问题,比如fileExists("MyFile")会问参数所指文件是否存在并返回布尔结果;一个是为了操作参数做一件事,比如closeFile("MyFile")会关闭参数所指文件,这类函数是一个“事件”,这类函数最好不要有返回值,并在命名上谨慎选择,清晰地告诉读者这个函数做了一件事情改变了系统状态!
二元函数
二元函数要比一元函数难懂,比如writeField(name)和writeField(outputStream, name),前者扫一眼就能读懂,后者就要停顿一下思考第一个参数的意义,所以尽可能的给二元函数瘦身,比如用outputStream.writeFiled(name)来替换writeField(outputStream, name)。
当然有些场景使用两个参数更合理,比如定一个坐标时,point(x, y)就要比point(x)要来得自然。
参数对象
当函数参数达到三个及以上的时候,就说明一些参数应该封装成类了。
7.无副作用
副作用是指,函数只承诺做一件事,但还是会做其他被藏起来的事,有时函数会对自己类中的变量做出未预期的改动,有时会函数参数搞成全局变量,无论哪种情况都是具有破坏性的,会导致很多古怪的时序性耦合以及顺序依赖。
比如某个函数checkPassword(userName, password)顾名思义就是检查用户密码正确性,但其在检查密码不合法的时候会初始化会话,初始化会话会造成数据丢失,而调用者看函数名字并不知道这件事,本只想检查用户的时效性,但可能会抹除现有会话数据的风险,这时把函数名字改成checkPasswordAndInitializeSession会更安全(但也违背了函数只做一件事的原则)。
8.指令与询问分隔
函数要么做事,要么回答事,两者不可兼得,比如避免出现boolean set(attribute, value)这样既做事又回答问题的函数。
使用异常替代错误码也轻微违背了指令与询问分隔的原则,比如
if(deletePage(page) == E_OK)
而且过多的定义错误码可能会使得一些调用者漏掉对异常情况的判定,整个检查结果的代码也会被一堆if else包围。
抽离try/catch代码块
try/catch代码块丑陋不堪,搞乱代码结构,把错误处理与正常流程混为一谈,最好把try代码块和catch代码块抽离出来形成函数,因为错误处理就是一件事。
本文内容属于个人学习笔记,主要信息来源书籍《代码整洁之道》/《Clean Code》