天天看點

代碼注釋的藝術,優秀代碼真的不需要注釋嗎?

前言

前天回家路上,有輛車強行插到前面的空位,司機大哥吐槽“加塞最可惡了”,我問“還有更可惡的嗎”,司機大哥淡定說道“不讓自己加塞的”。似乎和我們很類似,我們程式員屆也有這2件相輔相成的事:最讨厭别人不寫注釋,更讨厭讓自己寫注釋。

一段糟糕的代碼,往往大家最低的預期是把注釋寫清楚,最合理的做法通常應該對代碼做優化。如果我們将代碼真正做到了優秀,我們是否還需要注釋?

注釋的意義

; **************************************************************************
; * RAMinit Release 2.0 *
; * Copyright (c) 1989-1994 by Yellow Rose Software Co. *
; * Written by Mr. Leijun *
; * Press HotKey to remove all TSR program after this program *
; **************************************************************************
; Removed Softwares by RI:
; SPDOS v6.0F, WPS v3.0F
; Game Busters III, IV
; NETX ( Novell 3.11 )
; PC-CACHE
; Norton Cache
; Microsoft SmartDrv
; SideKick 1.56A
; MOUSE Driver
; Crazy (Monochrome simulate CGA program)
; RAMBIOS v2.0
; 386MAX Version 6.01      

注釋是對代碼的解釋和說明,本質目的是為了增強程式的可讀性與可解釋性。注釋會随着源代碼,在進入預處理器或編譯器處理後會被移除。這是雷布斯1994年寫的一段MASM彙編代碼,注釋與代碼整體結構都非常清晰。如果說代碼是為了讓機器讀懂我們的指令,那注釋完全就是為了讓我們了解我們自己到底發出了哪些指令。

争議與分歧

注釋的起源非常早,我們甚至已經查閱不到注釋的由來,但現在任何一種語言,甚至幾乎任何一種文本格式都支援各式各樣的注釋形式。

但如何使用注釋,其實一直是一個備受争論的話題。當我們接手一段‘祖傳代碼’時,沒有注釋的感覺簡直讓人抓狂,我們總是希望别人能提供更多的注釋。但軟體屆也有一段神話傳說,叫做『我的代碼像詩一樣優雅』。有注釋的代碼都存在着一些瑕疵,認為足夠完美的代碼是不需要注釋的。

壞代碼的救命稻草

The proper use of comments is to compensate for our failure to express ourself in code.

-- Robert C. Martin 《Clean Code》

譯:注釋的恰當用法是彌補我們在用代碼表達意圖時遭遇的失敗

Clean Code 的作者Robert C. Martin可以說是注釋的極力否定者了,他認為注釋是一種失敗,當我們無法找到不用注釋就能表達自我的方法時,才會使用注釋,任何一次注釋的使用,我們都應該意識到是自己表達能力上的失敗。

PH&V的系統架構師和負責人Peter Vogel,同樣也是一名堅定的注釋否定着,他發表了一篇文章 why commenting code is still bad 來表述為代碼添加注釋在某種程度上可能是必要的,但确實沒有價值。

事實上,我們也确實經曆着非常多無價值的注釋,以及完全應由代碼來承擔解釋工作的“職能錯位”的注釋。

零注釋

代碼注釋的藝術,優秀代碼真的不需要注釋嗎?

糟糕的代碼加上完全不存在的注釋,我喜歡稱呼它們為『我和上帝之間的秘密』,當然過2個月後也可以稱之為『上帝一個人的秘密』。

壓垮程式員最後一根稻草的,往往都是零注釋。可以沒有文檔,可以沒有設計,但如果沒有注釋,我們每一次閱讀都是災難性的。當我們抱怨它一行注釋都沒有時,其實我們是在抱怨我們很難了解代碼想要表達的含義,注釋是直接原因,但根本原因是代碼。

零注釋往往和壞代碼一起生活,“沒有注釋”的吐槽,其實本質上直擊的是那堆歪七扭八的英文字母,到底它們想表達什麼!

無用注釋

/**
 * returns the last day of the month
 * @return the last day of the month
 */
public Date getLastDayOfMonth(Date date) {
    Calendar calendar = new GregorianCalendar();
    calendar.setTime(date);
    calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
    return calendar.getTime();
}      

這是典型的廢話注釋,讀代碼時代碼本身就能很好的表達具體的含義,我們完全不需要看注釋,并且注釋也不會給我們提供更多有效的資訊。無用注釋或許是零注釋的另一個極端,我們擔心自己寫的代碼被人所吐槽,于是盡可能去補全注釋,當你為 getLastDayOfMonth() 補一段 get last day of month 的注釋時,恭喜你,你得到了雙倍的代碼。

代碼優于注釋

"Comments Do Not Make Up for Bad Code"

-- Robert C.Martin 《Clean Code》

譯:注釋不能美化糟糕的代碼

當需要為一段代碼加上注釋時,說明代碼已經不能很好的表達意圖,于是大家開始為這段代碼添加注釋。Robert C.Martin在 Clean Code 中提出一個觀點:注釋不能美化糟糕的代碼。能用代碼表達的直接用代碼表達,不能用代碼表達的,你再想想,如何能用代碼表達。

複雜的代碼最直接的表現就是不夠直覺、難以了解,加上注釋後往往會清晰很多,但你是願意看這段代碼

// 判斷是否活躍使用者
if((customer.getLastLoginTime().after(dateUtils.minusDays(new Date(),15)) && customer.getCommentsLast30Days() > 5) 
    || orderService.countRecentDaysByCustomer(customer,30) > 1)      

還是這段代碼?

if(customer.isActive())      

糟糕代碼的存在,通常是我們寫注釋的常見動機之一。這種試圖粉飾可讀性差的代碼的注釋稱之為『拐杖式注釋』,即使大名鼎鼎的JDK,也存在這樣的拐杖式注釋。

public synchronized void setFormatter(Formatter newFormatter) {
    checkPermission();
    // Check for a null pointer
    newFormatter.getClass();
    formatter = newFormatter;
}      

這是取自JDK java.util.logging.Handler類的setFormatter方法,作者為了不讓空指針異常下傳,提前做一次空指針檢查。沒有這段注釋我們完全不知道遊離的這句 newFormatter.getClass() 到底要做什麼,這段注釋也充分表達了作者自己也知道這句代碼難以了解,是以他加上了注釋進行說明。但我們完全可以用 Objects.requireNonNull() 來進行替代。同樣的代碼作用,但可讀性可了解性大不一樣,JDK裡的這段代碼,确實讓人遺憾。

注釋否定論

"If our programming languages were expressive enough, or if we had the talent to subtly wield those languages to express our intent, we would not need comments very much—perhaps not at all."

-- Robert C.Martin 《Clean Code》

譯:若程式設計語言足夠有表達力,或者我們長于用這些語言來表達意圖,就不那麼需要注釋--也許根本不需要

通過代碼進行闡述,是注釋否定論的核心思想。當你花功夫來想如何寫注釋,讓這段代碼更好的表達含義時,我們更應該重構它,通過代碼來解釋我們的意圖。每一次注釋的編寫,都是對我們代碼表達能力上的差評,提升我們的歸納、表達、解釋能力,更優于通過注釋來解決問題。當代碼足夠優秀時,注釋則是非必須的。并且需求在不斷調整,代碼一定會随之變動,但注釋可能慢慢被人遺忘,當代碼與注釋不比對時,将是更大的災難。

軟體設計的烏托邦

曾經我的确對優秀的代碼不斷鑽研,對代碼本身所蘊含的能量無比堅信。如同當科學代替鬼神論走上曆史舞台時,即使存在有科學解釋不了,我們依然堅信隻是科學還需要發展。當代碼别人無法了解時,我會認為是我表述不夠精準,抽象不夠合理,然後去重構去完善。

有一次給老闆review代碼,當時老闆提出,“你的代碼缺缺少注釋”,我說不需要注釋,代碼就能自解釋。于是老闆現場讀了一段代碼,“query-customer-list 查詢客戶”、“transfer-customer-to-sales 分發客戶到銷售”、“check-sales-capacity 檢查銷售庫容”,每一個類每一個函數,一個單詞一個單詞往外蹦時,你會發現好像确實都能讀懂,于是老闆回了一個“好吧”。

美麗的烏托邦

"'good code is self-documenting' is a delicious myth"

-- John Ousterhout《A Philosophy of Software Design》

譯:‘好的代碼自解釋’是一個美麗的謊言

在軟體設計中,總有一些軟體工程師所堅信的詩和遠方,有的是大洋彼岸的美好國度,有的或許是虛無缥缈的理想烏托邦。John Ousterhout教授在 A Philosophy of Software Design 中提到一個觀念,‘好的代碼自解釋’是一個美麗的謊言。

我們可以通過選擇更好的變量名,更準确的類與方法,更合理的繼承與派生來減少注釋,但盡快如此,我們還是有非常多的資訊無法直接通過代碼來表達。這裡的資訊,或許不單單隻是業務邏輯與技術設計,可能還包括了我們的觀感,我們的體驗,我們的接納程度以及第一印象帶來的首因效應。

好代碼的最佳僚機

You might think the purpose of commenting is to 'explain what the code does', but that is just a small part of it.The purpose of commenting is to help the reader know as much as the writer did.

譯:你可能以為注釋的目的是“解釋代碼做了什麼”,但這隻是其中很小一部分,注釋的目的是盡量幫助讀者了解得和作者一樣多

-- Dustin Boswell《The Art of Readable Code》

如同John Ousterhout教授一樣,The Art of Readable Code 的作者Dustin Boswell,也是一個堅定的注釋支援者。與Robert C.Martin類似,Dustin Boswell同樣認為我們不應該為那些從代碼本身就能快速推斷的事實寫注釋,并且他也反對拐杖式注釋,注釋不能美化代碼。

但Dustin Boswell認為注釋的目的不僅解釋了代碼在做什麼,甚至這隻是一小部分,注釋最重要的目的是幫助讀者了解得和作者一樣多 。編寫注釋時,我們需要站在讀者的角度,去想想他們知道什麼,這是注釋的核心。這裡有非常多的空間是代碼很難闡述或無法闡述的,配上注釋的代碼并非就是糟糕的代碼,相反有些時候,注釋還是好代碼最棒的僚機。

更精準表述

There are only two hard things in Computer Science: cache invalidation and naming things.

-- Phil Karlton

譯:計算機科學中隻有兩個難題:緩存失效和命名 

Martin Fowler在他的 TwoHardThings 文章中引用了Phil Karlton的一段話,命名一直都是一件非常難的事情,因為我們需要将所有含義濃縮到幾個單詞中表達。很早之前學Java,接觸到很長的類名是ClassPathXmlApplicationContext。可能有人認為隻要能将含義準确地表達出來,名字長一些無所謂。那如果我們需要有一段處理有關“一帶一路”的内容,那我們的代碼可能是這樣的 

public class TheSilkRoadEconomicBeltAndThe21stCenturyMaritimeSilkRoad {


}      

他非常準确的表達了含義,但很明顯這不是我們期望的代碼。但如果我們輔以簡單的注釋,代碼會非常清晰,說明了簡稱,也說明了全意,表述更精準。

/**
 * 一帶一路
 * 絲綢之路經濟帶和21世紀海上絲綢之路
 */
public class OneBeltOneRoad {


}      

代碼層次切割

函數抽取是我們經常使用且成本最低的重構方法之一,但并非銀彈。函數并非抽得越細越好,如同分布式系統中,并非無限的堆機器讓每台機器處理的資料越少,整體就會越快。過深的嵌套封裝,會加大我們的代碼閱讀成本,有時我們隻需要有一定的層次與結構幫助我們了解就夠了,盲目的抽取封裝是無意義的。

/**
 * 客戶清單查詢
 */
public List queryCustomerList(){
    // 查詢參數準備
    UserInfo userInfo = context.getLoginContext().getUserInfo();
    if(userInfo == null || StringUtils.isBlank(userInfo.getUserId())){
        return Collections.emptyList();
    }
    LoginDTO loginDTO = userInfoConvertor.convertUserInfo2LoginDTO(userInfo);
    // 查詢客戶資訊
    List<CustomerSearchVO> customerSearchList = customerRemoteQueryService.query(loginDTO);
    Iterable<CustomerSearchVO> it = customerSearchList.iterator();
    // 排除不合規客戶
    while(it.hasNext()){
        CustomerSearchVO customerSearchVO = it.next(); 
        if(isInBlackList(customerSearchVO) || isLowQuality(customerSearchVO)){
            it.remove();
        }
    }
    // 補充客戶其他屬性資訊
    batchFillCustomerPositionInfo(customerSearchList);
    batchFillCustomerAddressInfo(customerSearchList);
}      

其實細看每一處代碼,都很容易讓人了解。但如果是一版沒有注釋的代碼,可能我們會有點頭疼。缺少結構缺少分層,是讓我們大腦第一感觀覺得它很複雜,需要一次性消化多個内容。通過注釋将代碼層次進行切割,是一次抽象層次的劃分。同時也不建議大家不斷去抽象私有方法,這樣代碼會變得非常割裂,并且上下文的背景邏輯、參數的傳遞等等,都會帶來額外的麻煩。

母語的力量

其實上述例子,我們更易閱讀,還有一個重要的原因,那就是母語的力量。我們天然所經曆的環境與我們每天所接觸到的事物,讓我們對中文與英文有完全不一樣的感受。我們代碼的編寫本質上是一個将我們溝通中的“中文問題”,翻譯成“英文代碼”來實作的過程。而閱讀代碼的人在做得,是一件将“英文代碼”翻譯成“中文表述”的事情。而這之中經過的環節越多,意思變味越嚴重。

TaskDispatch taskDispatch = TaskDispatchBuilder.newBuilder().withExceptionIgnore().build();
taskDispatch
        // 外貿資訊
        .join(new FillForeignTradeInfoTask(targetCustomer, sourceInfo))
        // 國民經濟行業、電商平台、注冊資本
        .join(new FillCustOutterInfoTask(targetCustomer, sourceInfo))
        // 客戶資訊
        .join(new FillCustomerOriginAndCategoryTask(targetCustomer, sourceInfo))
        // 客戶擴充資訊
        .join(new FillCustExtInfoTask(targetCustomer, sourceInfo))
        // 收藏屏蔽資訊
        .join(new FillCollectStatusInfoTask(targetCustomer, sourceInfo, loginDTO()))
        // 詳情頁跳轉需要的标簽資訊
        .join(new FillTagInstanceTask(targetCustomer, sourceInfo, loginDTO()))
        // 客戶資訊完整度分數
        .join(new FillCustomerScoreTask(targetCustomer, sourceInfo))
        // 潛客分層完整度
        .join(new FillCustomerSegmentationTask(targetCustomer, sourceInfo))
        // 填充操作資訊
        .join(new FillOperationStatusTask(targetCustomer, sourceInfo, loginDTO))
        // 認證狀态
        .join(new FillAvStatusTask(targetCustomer, loginDTO))
        // 客戶位址群組織
        .join(new FillCompanyAddressTask(targetCustomer, loginDTO))
        // 違規資訊
        .join(new FillPunishInfoTask(targetCustomer, sourceInfo))
        // 填充客戶黑名單資訊
        .join(new FillCustomerBlackStatusTask(targetCustomer, sourceInfo))
        // 填充客戶意願度
        .join(new FillCustIntentionLevelTask(targetCustomer, sourceInfo));
        // 執行
        .execute();      

這是一段補齊客戶全資料資訊的代碼,雖然每一個英文我們都看得懂,但我們永遠隻會第一眼去看注釋,就因為它是中文。并且也因為有這些注釋,這裡非常複雜的業務邏輯,我們同樣可以非常清晰的了解到它做了哪些,分哪幾步,如果要優化應該如何處理。這裡也建議大家寫中文注釋,注釋是一種說明,越直覺越好。

注釋的真正歸屬

複雜的業務邏輯

// Fail if we're already creating this bean instance:
// We're assumably within a circular reference.
if (isPrototypeCurrentlyInCreation(beanName)) {
    throw new BeanCurrentlyInCreationException(beanName);
}
// Check if bean definition exists in this factory.
BeanFactory parentBeanFactory = getParentBeanFactory();
if (parentBeanFactory != null && !containsBeanDefinition(beanName)) {
    // Not found -> check parent.
    String nameToLookup = originalBeanName(name);
    if (args != null) {
        // Delegation to parent with explicit args.
        return parentBeanFactory.getBean(nameToLookup, args);
    }
    else {
        // No args -> delegate to standard getBean method.
        return parentBeanFactory.getBean(nameToLookup, requiredType);
    }
}      

這是Spring中的一段擷取bean的代碼,spring作為容器管理,擷取bean的邏輯也非常複雜。對于複雜的業務場景,配上必要的注釋說明,可以更好的了解相應的業務場景與實作邏輯。

截取自:

org.springframework.beans.factory.support.AbstractBeanFactory#doGetBean

晦澀的算法公式

/**
 * Returns the value obtained by reversing the order of the bits in the
 * two's complement binary representation of the specified {@code long}
 * value.
 */
public static long reverse(long i) {
    // HD, Figure 7-1
    i = (i & 0x5555555555555555L) << 1 | (i >>> 1) & 0x5555555555555555L;
    i = (i & 0x3333333333333333L) << 2 | (i >>> 2) & 0x3333333333333333L;
    i = (i & 0x0f0f0f0f0f0f0f0fL) << 4 | (i >>> 4) & 0x0f0f0f0f0f0f0f0fL;
    i = (i & 0x00ff00ff00ff00ffL) << 8 | (i >>> 8) & 0x00ff00ff00ff00ffL;
    i = (i << 48) | ((i & 0xffff0000L) << 16) |
        ((i >>> 16) & 0xffff0000L) | (i >>> 48);
    return i;
}      

這是JDK中Long類中的一個方法,為reverse方法添加了足夠多的注釋。對于幾乎沒有改動且使用頻繁的底層代碼,性能的優先級會高于可讀性。在保證高效的同時,注釋幫助我們彌補了可讀性的短闆。

截取自java.lang.Long#reverse

不明是以的常量

/**
 * The bin count threshold for using a tree rather than list for a
 * bin.  Bins are converted to trees when adding an element to a
 * bin with at least this many nodes. The value must be greater
 * than 2 and should be at least 8 to mesh with assumptions in
 * tree removal about conversion back to plain bins upon
 * shrinkage.
 */
static final int TREEIFY_THRESHOLD = 8;      

這是JDK中HashMap的一個常量因子,記錄由連結清單轉向紅黑樹的連結清單長度門檻值,超過該長度則連結清單轉為紅黑樹。這裡記錄了一個8,不僅記錄了該常量的用途,也記錄了為什麼我們定義這個值。經常我們會發現我們代碼中存在一個常量等于3、等于4,有時我們不知道這些3和4是幹什麼的,有時我們不知道為什麼是3和4。

截取自java.util.HashMap#TREEIFY_THRESHOLD

意料之外的行為

for (int i = 0; i < 3; i++) {
    // if task running, invoke only check result ready or not
    Result result = bigDataQueryService.queryBySQL(sql, token);
    if (SUCCESS.equals(result.getStatus())) {
        return result.getValue();
    }
    Thread.sleep(5000);
}      

代碼及注釋所示為每5秒check一下是否有結果傳回,遠端服務将觸發與擷取放在了一個接口。沒有注釋我們可能認為這段代碼有問題,代碼表現的含義更像是每5秒調用一次,而非每5秒check一次。為意料之外的行為添加注釋,可以減少對代碼的誤解讀,并向讀者說明必要的背景及邏輯資訊。

接口對外API

/**
 * <p>Checks if a CharSequence is empty (""), null or whitespace only.</p>
 * <p>Whitespace is defined by {@link Character#isWhitespace(char)}.</p>
 * StringUtils.isBlank(null)      = true
 * StringUtils.isBlank("")        = true
 * StringUtils.isBlank(" ")       = true
 * StringUtils.isBlank("bob")     = false
 * StringUtils.isBlank("  bob  ") = false
 *
 * @param cs  the CharSequence to check, may be null
 * @return {@code true} if the CharSequence is null, empty or whitespace only
 */
public static boolean isBlank(final CharSequence cs) {
    final int strLen = length(cs);
    if (strLen == 0) {
        return true;
    }
    for (int i = 0; i < strLen; i++) {
        if (!Character.isWhitespace(cs.charAt(i))) {
            return false;
        }
    }
    return true;
}      

我們經常使用的StringUtils工具類中的isBlank方法,寫了非常詳情的注釋,不僅包括方法的邏輯,入參的含義,甚至還包括具體示例。我們平常定義的二方庫中的HSF、HTTP接口定義,同樣需要有清晰詳盡的注釋,這裡的注釋甚至經常會多過你的代碼。

截取自org.apache.commons.lang3.StringUtils#isBlank

法律檔案資訊

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */      

與法律相關的注釋,在開源軟體庫中較經常遇到。涉及到一些版權及著作聲明時,我們需要在源檔案頂部放置法律相關注釋。當然,我們不需要将所有法律資訊寫到注釋中,如例子中的跳鍊,引用一份标準的外部文檔,會是一個更好的選擇。

寫在最後

注釋并不會妨礙你寫出優雅簡潔的代碼,它隻是程式固有的一部分而已。我們不用過分在意我們的代碼是否可以脫離注釋,也不需要強調因為我們的代碼符合什麼原則,滿足什麼約定,是以代碼是優秀的注釋是備援的。代碼是一門藝術,并不會因為滿足三規九條它就一定完美,因為藝術,是不可衡量的。

參閱書籍《A Philosophy of Software Design》:https://www.amazon.com/-/zh/dp/173210221X/ref=sr_1_1

《Clean Code》:https://baike.baidu.com/item/代碼整潔之道/9226259

《The Art of Readable Code》:https://github.com/niexiaolong/niexiaolong.github.io/blob/master/the-art-of-readable-code.pdf