Clean Code第三章:函数 --阅读与讨论

尼克徐 发布于 2015年10月25日 | 更新于 2015年10月25日
无人欣赏。

alt text

图片来源:Chapter 3. Functions

无论是在何种编程语言中,函数都是最常用的语法结构了。

根据需求写出函数不难,但写好一个函数却并不容易。

请看下面的一个函数,盯住它三分钟,看看能不能看懂这个函数在做什么:

代码清单1

public static String testableHtml( 
    PageData pageData,
    boolean includeSuiteSetup
) throws Exception {
    WikiPage wikiPage = pageData.getWikiPage(); 
    StringBuffer buffer = new StringBuffer(); 
    if (pageData.hasAttribute("Test")) {
        if (includeSuiteSetup) { 
            WikiPage suiteSetup = PageCrawlerImpl
                .getInheritedPage( SuiteResponder.SUITE_SETUP_NAME, wikiPage);
            if (suiteSetup != null) {
                WikiPagePath pagePath = suiteSetup.getPageCrawler()
                    .getFullPath(suiteSetup);
                String pagePathName = PathParser.render(pagePath); 
                buffer.append("!include -setup .")
                    .append(pagePathName) .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 teardown = 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.getPageCrawler()
                    .getFullPath (suiteTeardown);
                String pagePathName = PathParser.render(pagePath); 
                buffer.append("!include -teardown .")
                    .append(pagePathName) .append("n");
            } 
        }
    } 
    pageData.setContent(buffer.toString()); return pageData.getHtml();
}

是不是搞懂了?很难吧!

如果做几个简单的方法抽离和重命名,就能在9行代码里搞定,再用三分钟阅读下面的代码,理解了吗?

代码清单2

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.getContent()); 
        includeTeardownPages(testPage, newPageContent, isSuite); 
        pageData.setContent(newPageContent.toString());
    }
    return pageData.getHtml(); 
}

看出差别了吧!第一个函数非常难懂,第二个却极易阅读和理解。

该函数包含把一些设置和拆解页放入一个测试页面,再渲染为html,属于某个基于Web的测试框架。

怎样才能让一个函数易于阅读和理解,并清晰表达其意图?这就需要注意以下几点:

一,短小

函数的第一规则是要短小,第二条规则是还要更短小。

短小到最好每个函数只有两行、三行或四行长。

上面的第二个函数还是不够短小,经再次重构后:

代码清单3

public static String renderPageWithSetupsAndTeardowns( 
    PageData pageData, boolean isSuite) throws Exception { 
    if (isTestPage(pageData))
        includeSetupAndTeardownPages(pageData, isSuite); 
        return pageData.getHtml();
}

代码块和缩进

if语句,else语句,while语句等,这种代码块在函数中,应该只有一行。

这就是说,函数不应该大到足以容纳嵌套结构。函数的缩进层级不该多于一层或两层。

二,只做一件事

函数应该做一件事。做好这件事,只做这一件事(重说三)。

但是什么算是一件事?

例如上面的代码(代码清单3),也很容易看成做了三件事:

判断是否为测试页面

如果是,则容纳近设置和分拆步骤

渲染成HTML

那么,这个函数是做了一件事,还是三件事?

注意,此时要理解“函数抽象层”的概念:

如果函数只是做了该函数名下同一抽象层的步骤,那么该函数还是只做了一件事。

编写函数,就是为了把大一些的概念(函数抽象层)拆分为另一抽象层上的一系列步骤。

“代码清单1”明显的包含了多个不同的抽象层级,做了不止一件事。

“代码清单2”也有两个抽象层。

你会发现,很难将“代码清单3”再缩短。这说明该抽象层级已经无法再被拆分了。再拆分下去,就是把if语句和includeSetupAndTeardownPages等语句合并出一个函数来,但那只是重新诠释,并未改变抽象层级。

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

函数中的区段

注意,如果一个函数内部可以被分为多个“区段”,例如declarations,initialization等等区段,就是该函数做事太多的征兆。只做一件事的函数无法被合理地切分为多个区段。

三,每个函数一个抽象层级

要确保函数只做一件事,函数中的语句都要在同一抽象层级上。

例如“代码清单1”里面有getHtml()等位于较高抽象层的代码,也有pagePathName=PathPaser.render(pagePath)的中间抽象层,还有.append("n")相当低的抽象层。

函数中混杂不同抽象层级,往往令人迷惑,更恶劣的事,就象“破窗效应”,一旦抽象层级混杂,更多的细节和抽象层级就会在函数中纠结起来。

四,函数的排列(阅读)顺序:自顶向下

我们要让代码拥有自顶向下的阅读顺序,要让每个函数后面都跟着下一个抽象层级的函数。

这样一来,在查看函数列表时,就能循着抽象层级向下阅读了。

五,处理好Switch语句

写出短小的,只做一件事的switch语句很难。即便是只有两个条件的switch语句,也比单个代码块大得多。

例如以下代码:

代码清单4

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); 
    }
}

该函数有好几个问题,

1,它太长,当出现新的雇员类型时,还会更长。

2,明显不止做了一件事。

3,违反单一权责原则(Single Responsibility Principle)

4, 违反开放闭合原则(Open Closed Principle)

最麻烦的是,函数calculatePay(Employee e)是根据不同的员工调用不同的函数,而这种函数肯定不止一个,类似的switch结构还可能出现在其他的与员工有关的函数中。

例如可能会有:isPayday(Employee e, Date date) ,deliverPay(Employee e, Money pay)等等有同样麻烦的函数。

该问题的解决方案,是将switch语句埋在抽象工厂里面,不可见于其他类。该工厂用switch为Employee的派生类创建适当的对象,而不同的函数,如calculatePay,isPayday和deliverPay等,则由Employee接口多态地接受和处理。

对于switch语句的处理办法是,可以只出现一次,用于创建多态对象,而且隐藏在某个继承关系中,对系统里其他部分不可见。

请尽量达到这一点,可能有违反这条规矩的时候,但需要有非常特殊的理由。

代码清单5

public abstract class Employee {
    public abstract public abstract public abstract
    boolean isPayday();
    Money calculatePay();
    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);
            } 
    }
}

六,使用描述性的名称

好的名称要能够较好的描述函数所做的事情。例如isTestable或includeSetupAndTeardownPages。

函数越短小,功能越单一,就越便于取个好名字。

别害怕长名称,长而有描述性的名称,要比短而令人费解的名称好。

别害怕花时间取名字。

选择描述性的名称,能理清你关于模块的设计思路,并帮你改进之。追索好名称,往往导致对代码的改善重构。

命名方式要保持一致,使用与模块名一脉相承的短语、名词和动词给函数命名。

例如,includeSetupAndTeardownPages,includeSetupPages,includeSuiteSetupPage和includeSetupPage等。

七,善用函数参数

最理想的参数数量是零,其次是一,再次是二,尽量避免三。有足够特殊的理由才能用三个以上的参数。

参数带有太多的概念性,所以能不用尽量不用。参数与函数名处在不同的抽象层级,它要求你了解目前并不重要的细节(例如StringBuffer类型的参数)。

从测试的角度看,参数也是非常麻烦。要编写能够确保参数的组合运行正常的测试用例非常不容易。

输出参数比输入参数更难以理解。我们关于认为信息通过参数输入函数,通过返回值从函数输出。不太期望信息通过参数输出。

一元函数的普遍形式

向函数传入单个参数,有以下几个很普遍的理由:

1,问关于那个参数的问题,例如:boolean fileExists("MyFile")

2,操作该参数,将其转换成其他的形式。例如:InputStream fileOpen("Myfile")把String类型的文件名称转换为InputStream类型的返回值。

3,参数是个事件,使用该参数改变系统状态。

应该尽量避免编写不遵循以上形式的一元函数。例如,void includeSetupPageInto(StringBuffer pageText)。对于转换,使用输出参数而非返回值会令人迷惑。

实际上,StringBuffer transform(StringBuffer in)要比StringBuffer transform(StringBuffer out)强,至少它遵循了转换的形式。

尽量避免布尔值的参数

布尔值的参数,是非常不好的做法(骇人听闻)。

这样做使得方法签名变得立刻复杂了。大声宣布本函数不止做一件事。如果参数为true将会这样做,参数为false将会那样做!

例如,与其render(Boolean isSuite),不如将函数分为renderForSuite()和renderForSingleTest()。

二元函数

两个参数的函数总会比一元函数难懂。例如,writeField(name)比writeField(outputStream,name)好懂。

有时候两个参数正好,例如,Point p = new Point(0,0)

如果可能,尽量把二元函数转换成一元函数。例如,把上面举例的writeField函数写成outputStream的成员之一,从而可以这样用:outputStream.writeField(name)。

或者,将outputStream写成当前类的成员变量,从而无需再传递。

又或者,分离出类似FieldWriter的新类,在其构造函数中采用outputStream,并包含一个write方法。

三元函数

三元函数就更难缠了,很多时候分辨不清那三个参数是做什么的,会经常搞混淆。

在需要写三元函数时,请仔细斟酌,是否能变成二元甚至一元函数。

参数对象

如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数该封装成类了。

例如:

Circle makeCircle(double x, double y, double radius)

可以改写为

Circle makeCircle(Point center, double radius)

从参数创建对象,从而减少参数数量,看似作弊,但实际上当一组参数被共同传递,往往就是该组参数属于一个共同的类型。

参数列表

有时候,需要向函数中传入数量可变的参数,该函数实际上是某种一元,二元或者三元函数。超过这个数量可能就犯错了。

例如:

void monad(Integer... args);

void dyad(String name, Integer... args);

void triad(String name, int count, Integer... args);

动词与关键字

函数和参数应该形成良好的动词/名词对形式。

例如write(name)挺好,但writeField(name)更好,它告诉我们,"name"是一个“field"

assertEqual(expected, actual)改为:assertExpectedEqualsActual(expected, actual)会更好些,因其减轻了记忆参数顺序的负担。

避免使用输出参数

输出参数经常令人迷惑,例如,

appendFooter(s),是把s添加到什么地方,还是把什么东西添加到s后面?s事输入参数还是输出参数?

从函数签名

public void appendFooter(StringBuffer report)

我们可以看出它是输出参数,但这就要被迫检查函数声明,可能会造成思路中断。

在使用面相对象编程时,对输出参数的大部分需求已经消失。例如,可以这样用:

report.appendFooter()

含义就非常清晰。

九,无副作用

函数承诺只做一件事,但有时还是会做其他被藏起来的事情,例如对自己类中的变量做出未能预期的改动,或者改动全局变量等。

无论哪种情况,都是具有破坏性,会导致古怪的时序耦合和顺序依赖。

例如以下函数:

public class UserValidator {
    private Cryptographer cryptographer;
    public boolean checkPassword(String userName, String password) { 
        User user = UserGateway.findByName(userName);
        if (user != User.NULL) {
            String codedPhrase = user.getPhraseEncodedByPassword(); 
            String phrase = cryptographer.decrypt(codedPhrase, password); 
            if ("Valid Password".equals(phrase)) {
                Session.initialize();
                return true; 
            }
        }
        return false; 
    }
}

表面上看,只是检查用户名和密码,并返回true或false,但它会有副作用:对Session.initialize()的调用。

而checkPassword函数,从函数名看,并未暗示它会初始化该次对话(session)。

因此,如果某个开发者用它检查用户名有效性时,就得冒抹除现有对话(session)数据的风险。

这一副作用造成了时序性耦合,即checkPassword只能在特定时刻使用,换言之,在初始化会话是安全的时候调用。

需要尽量避免时序性耦合。如果一定需要,应该在函数名称中说明。例如在本例中,可以重命名函数为:checkPasswordAndInitializeSession,虽然这还是违反了“只做一件事”的原则。

十,分隔指令与询问

函数要么做某件事情(指令),要么回答某件事情(询问),但二者不可得兼。

函数两样都干常会导致混乱。例如:

public boolean set(String attribute, String value);

该函数设置某个指定属性,如果成功则返回true。

则容易导致以下语句:

if (set("username", "unclebob")) ...

从读者的角度,这句话的意思很难理解。是问username的值是否以前已经设置为unclebob,还是正在设置?含义不清楚。

解决方案,可以把函数名取为:setAndCheckIfExists,但更好的方案是把指令和询问分隔开,例如改为:

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

十一,使用异常替代返回错误码

从指令式返回错误码轻微违反了指令与询问分隔的原则,它鼓励了在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.log("deleteReference from registry failed"); 
    }
} else {
    logger.log("delete failed"); return E_ERROR;
}

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

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

抽离Try/Catch代码块

Try/Catch代码块非常丑陋,搞乱了代码结构,把错误处理与正常流程混淆在一起。

最好把需要try和catch代码处理的部分,从主体部分抽离出来,另外形成函数:

public void delete(Page page) { 
    try {
        deletePageAndAllReferences(page); 
    }
    catch (Exception e) { 
        logError(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函数只与错误处理有关,看起来很清晰。

deletePageAndAllReferences只与完全删除一个页面有关,错误处理则被间隔。看起来就很好理解了。

错误处理本身就是一件事

函数应该只做一件事,而错误处理就是一件事。因此,处理错误的函数不应该做其他事情。

Error.java依赖磁铁

返回错误码,通常暗示某处有个枚举类型,包含了所有错误码。

这样的类就是一块依赖磁铁(dependency magnet),其他许多累都得导入和使用它。

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

当Error枚举修改时,所有其他的类都要重新编译和部署。这使得程序员不愿增加新的错误码,因为这样他们就得重新构建和部署所有东西。

使用异常类代替错误码,新的异常可以从异常类派生出来,无需重新编译活重新部署。这也是开放闭合原则(OCP)的一个范例。

十二,别重复自己

有时候,会有算法在不同的函数中被重复使用。

识别重复不容易,因重复会与其他代码混在一起,从而也不完全一样。

这样的重复还是会导致问题,因代码因此而臃肿,且当该处理代码改变时,有可能需要改动多处地方。

修正重复,会导致整个模块的可读性提高。

重复可能是软件中一切“邪恶”的根源。许多原则和实践,都是为了控制与消除重复而创建。

例如,全部数据库范式都是为消灭数据重复而服务。

面向对象编程将代码集中到基类,从而避免了冗余。

面向方面编程和面向组件编程,多少也都是消除重复的一种策略。

十三,如何写出整洁的函数

写代码就象写其他东西一样,想出什么就写什么,然后再打磨它。

初稿往往粗陋,需要反复斟酌推敲,直至达到心目中的样子。

先写出函数,这也许函数冗长而复杂,有太多问题:太多缩进,循环,长长的参数列表,随意的名称...但此时也要配上一套单元测试,覆盖每行代码。

然后打磨代码:分解函数、修改名称、消除重复、缩短方法、拆散类,同时要保证测试通过。

最后,遵循本章的原则,组装好这些函数。

没有人能一次性写好它们。

问题:

1, 你或你的公司在写函数时,遵守了哪些原则?

2, 每个函数不超过五行,是不是太激进了?你的看法是什么?

3, 我们写iOS程序时,经常把一些事件处理函数写在一起,例如viewDidLoad,viewWillAppear等等,但是按照这一章的写法,要把函数依照从上到下的阅读顺序排列,“每个函数后面都跟着下一个抽象层级的函数。”,你觉得这种方式合适吗?

alt text

图片来源:一九八四

如何加入Root Cause读书会:请加我的微信brainstudio,我建立了一个群,方便通知各位写读后感及讨论。 

Clean Code所有讨论章节链接

共2条回复
cnsoft 回复于 2015年10月25日 | 更新于 2015年10月25日

Mark先

函数有时候也有点困惑. 就是老是跳来跳去的. 不过现在 C++ 11 可以有 lamda 函数可以直接把回调写在一起 清晰多了.

写函数之前是做伪代码接口设计?

函数功能界定要想想 相关的增长性需求有多大.

自己做测试吗?

clean code 时要把无用的部分清除掉吗?

多人协作时 谁负责写通用模块呢? 还是各自写各自的 变成私藏货?

尼克徐 回复于 2015年10月25日

读到现在,我觉得这一章对我的触动最大。

那种对最小化函数行数的追求,对抽象层级的严格控制,处理参数的技巧,处理switch语句的技巧等等,都是金玉良言。

与之相比我以前写函数都太随意了,惭愧。

登录 或者 注册