1. 程式人生 > >寫高質量的程式碼

寫高質量的程式碼

  有人曾經說過我們正在臨近程式碼的終點。很快程式碼就會自動生產出來,不需要再人工編寫。程式設計師完全沒用了,因為商務人士 可以從規約直接生成程式碼。除非我們的人工智慧達到真的可以像人一樣思考,否則說這些都是扯淡!就目前來看我們在未來好多好多年還是無法拋棄掉程式碼, 因為程式碼呈現了需求的細節。在某些層面上,這些細節無法被忽略或者抽象,必須明確之。將需求明確到機器可以執行的細節程度,就是程式設計所要做的事。而這種規約正是程式碼。   我相信我們每個人都見過糟糕的程式碼, 看這些程式碼一般都令人痛苦不堪,到處都暗藏的沼澤地,一不留神就會陷入深淵。那麼為什麼要寫這樣糟糕的程式碼呢?是想快點完成?還是趕時間?都有可能。或許你覺得自己要幹好所需的時間不夠;假使花時間清理程式碼,老闆就會大發雷霆。或許你只是不耐煩再搞這套程式,期望早點結束。或許你看了看自己承諾要做的其他事,意識到需要儘快完成手頭上的工作,好接著完成下一件工作。雖然每個人的理由大概不會相同,但我相信這種事我們都幹過。我們都曾經瞟過一眼自己親手造成的混亂,決定棄之不顧,走向新的一天。我們都曾經看到自己的爛程式居然能執行,然後斷言能執行的爛程式總比什麼都沒有強。但當我們自己看到別人寫的爛程式時,肯定都說過這麼爛的程式碼還不如干掉自己重新寫。我們都曾經說過有朝一日再回頭清理。但是對於終日忙碌的程式設計師來說:稍後等於永遠不。因此我們必須時刻對自己嚴格要求,不僅僅只是按照需求完成開發任務,時刻保持程式碼的整潔,否則都最後肯定是自作自受。現在踩得坑,很多都是以前蹦躂留下的。

1. 有意義的命名

  我們的程式碼其實就是無數的類名,方法名和變數名的一個大集合。如果隨心所欲的亂起名字,那麼看你程式碼的人將相當痛苦,想必就是自己過一段時間來看自己的程式碼, 也得不會那麼容易吧。好的命名使人讀你的程式碼像是讀文章一樣簡單明瞭,通過名字其他人就可以知道你的程式碼的業務邏輯是什麼,不必再花費時間在看程式碼的具體實現細節。
  • 類名使用UpperCamelCase風格,必須遵從駝峰形式。但是一常見的英文縮寫除外。例如: TCP/ DTO / AO /  UID等
  • 方法名、引數名、成員變數、區域性變數都統一使用lowerCamelCase風格,必須遵從駝峰形式。
  • 常量命名全部大寫,單詞間用下劃線隔開,力求語義表達完整清楚,不要嫌名字長。
  • 杜絕完全不規範的縮寫,避免望文不知義。例如: AbstractClass“縮寫”命名成AbsClass;condition“縮寫”命名成 condi,此類隨意縮寫嚴重降低了程式碼的可閱讀
  • 介面類中的方法和屬性不要加任何修飾符號,保持程式碼的簡潔性。
  • 類中布林型別的變數,都不要加is字首。雖然在Hybris中不會引起錯誤,但是看看生成的get方法為isIsXXXXX,生成的set方法為setIsXXXXXX,不易閱讀。
  • 杜絕使用讓人產生誤解的名字。例如: 之前我使用過一個叫做getConsultantForUID的一個方法,但是返回值確實一個CustomerData。
  上述這一些規則的最終目的只有一個,就是讓人更容易閱讀和理解你寫的程式碼。我知道起一個恰當的命名確實是一件費時費力的事,但是花費一些時間在這些命名上確實是有意義的,這將大大提高之後維護這些程式碼人的工作效率, 避免一些錯誤的產生。

2.方法

  對於方法來說重要的一定是短,單一職權,不要做過多的事。我在之前專案見到幾百行程式碼的方法以及上千行程式碼的類。我特別尤其討厭那種我滾動了幾下也沒有到頭的方法, 看了後邊忘了前邊,來來回回反覆的滾動來檢視程式碼,看這些程式碼使人抓狂。
  • 每個方法應該只做一件事,做好這件事,只做這一件事。這樣可以是複雜的事情變得簡單化,程式碼也更加清晰明瞭。
  • 方法的引數不要太多,一般如果超過三個引數我們就需要考慮封裝引數物件。
  • 不要重複自己,不要寫重複的程式碼。重複不僅後期程式碼難以維護,而且是很多Bug的源頭。在之前的一個專案,做一件事的方法我曾經見過4種以上的實現,這些重複的邏輯分佈在不同的層級中,不僅維護困難而且導致與這個邏輯相關的功能Bug不斷。
  • 引數中不要傳遞標識引數如True或者False,這樣不僅醜陋不堪,而且使方法簽名立刻變得複雜,我們需要銘記方法只做一件事。
  • 抽離Try/Catch,將錯誤處理邏輯與業務邏輯分離,錯誤處理本就是另外一件事。

 3.註釋

關於註釋,我想說是清晰明瞭的高質量程式碼根本就不需要寫註釋,如果你打算通過寫註釋來向別人解釋你的程式碼,請在這之前考慮能否通過優化程式碼,通過程式碼來闡述你的意思而不是註解。保證方法和類的短小再加上有意義的命名,那麼別人看我們的程式碼一目瞭然,根本就不需要註釋。

  • 儘量不要寫//TODO 如果非寫不可,請加上作者名,和預計需要完成的時間,不然到了後期別人根本不知道這是誰寫的,到底需不需要完成或者刪除。
  • 如果寫了註解,便要維護這個註解,不然改了程式碼,但是不改註解,這就是在挖坑。
  • 不需要的程式碼就直接刪除,不要註解掉了事,這會使別人產生困惑。
  • 介面過時必須加@Deprecated註解,並清晰地說明採用的新介面或者新服務是什麼。

 4.錯誤處理

  當我們討論錯誤處理時,就一定要提及那些容易引發錯誤的做法。最常見的就是Null判斷。我們可以想象得到我們的專案中有多少檢查Null值得判斷,有多少Bug是因為Null導致的。下面是我之前改的一個Bug:
public void removeUserGroup(String userGroupCode) {
        B2BCustomerModel b2bCustomer = (B2BCustomerModel)getUserService().getCurrentUser();
        UserGroupModel userGroup = getContactService().getUserGroupByCode(userGroupCode);
        Set<ContactModel> contacts = userGroup.getContacts();//throw a NPE
    }
        
    public UserGroupModel getUserGroupByCode(final B2BCustomerModel consultant, String code) {
        UserGroupModel userGroupModel = consultant.getContactGroups().stream()
                .filter(c -> StringUtils.equals(code, c.getCode())).findFirst().orElse(null);
        return userGroupModel;
     }
當時在執行contactGroup.getContacts()的時候出現了NPE, 我們一般的想法就是那就在加一個非空判斷不就行了嗎。但是這個Bug的真正原因卻是因為我們在之前處理userGroupCode的時候程式碼出現了錯誤,導致傳入了一個錯誤的userGroupCode。如果修改這個Bug的人並沒有注意到這一點的話,就只是簡單的添加了一個非空判斷,這就使這個問題隱藏的更深。但是如果當時getUserGroupByCode的方法寫的如下:
public UserGroupModel getUserGroupByCode(final B2BCustomerModel consultant, String code) {
    UserGroupModel userGroupModel = consultant.getContactGroups().stream()
                .filter(c -> StringUtils.equals(code, c.getCode())).findFirst().orElse(null);
  if(userGroupModel == null){
    throw new UserGroupNotFoundException(String.format("User Group[%s] not found", code))
  }
   return userGroupModel;
}
修改Bug的人很明顯的就可以看到這個錯誤是因為userGroupCode傳入錯誤導致的,並且找到這個Bug的真正原因。方法返回Null, 基本上是在給自己增加工作量,也是在給呼叫者添亂。只要有一處沒有檢查Null,應用程式就會失控。

5.控制語句

  在高併發場景中避免使用“等於”作為中斷或者退出的條件。如果併發控制沒有處理好,容易產生等值判斷被擊穿的情況,可以使用大於或者小於的區間判斷條件來替代。 不要在判斷條件中寫入執行復雜業務邏輯的程式碼,將複雜業務邏輯的執行結果賦值給一個有意義的布林變數名,可以大大提高程式碼的可閱讀性。如下程式碼:
if ((isAfterPickUpTime && openCloseDayPart != null
      && !DAY_PART_STATUS_INSIDE.equals(openCloseDayPart.getDayPartStatus()))
      || (openCloseDayPart != null && DAY_PART_STATUS_ENDINGSOON.equals(openCloseDayPart.getDayPartStatus()))){..............}
這段程式碼對於閱讀它的人來說簡直是一種折磨。可以改成如下程式碼來提高閱讀性:
boolean productIsAvailableInDayPart = (isAfterPickUpTime && openCloseDayPart != null
      && !DAY_PART_STATUS_INSIDE.equals(openCloseDayPart.getDayPartStatus()))
      || (openCloseDayPart != null && DAY_PART_STATUS_ENDINGSOON.equals(openCloseDayPart.getDayPartStatus()));
if(productIsAvailableInDayPart ){..............}
  迴圈體中執行的語句要考慮程式碼效能,儘量將定義物件、變數、獲取資料庫連線、try-catch等與迴圈變數無關的業務邏輯移至迴圈體外。   物件應該在其需要使用的範圍內在建立,例如:
for (final String code : pendingPromotionCodes){
   final PromotionSourceRuleModel sourceRule = commercePromotionService.getPromotionSourceRuleByCode(code);
   if (sourceRule != null) {
      final MyOfferData myOfferData = offerConverter.convert(sourceRule);
      if (commercePromotionService.isPromotionAvailable(sourceRule.getCode())) {
         pendingOffers.add(myOfferData);
      }
   }
}
當sourceRule不為空時offerConverter.convert(sourceRule)總會執行,但是我們只是在它有效的時候才需要執行它,因此可以做如下改變:
for (final String code : pendingPromotionCodes){
   final PromotionSourceRuleModel sourceRule = commercePromotionService.getPromotionSourceRuleByCode(code);
   if (sourceRule != null) {
      if (commercePromotionService.isPromotionAvailable(sourceRule.getCode())) {
          final MyOfferData myOfferData =offerConverter.convert(sourceRule);
         pendingOffers.add(myOfferData);
      }
   }
}

6.集合處理

  ArrayList的subList結果不可以強轉成ArrayList,否則會丟擲ClassCastException異常。subList 返回的是 ArrayList 的內部類 SubList,並不是 ArrayList而是ArrayList 的一個檢視,對於SubList子列表的所有操作最終會反映到原列表上。在subList場景中,高度注意對原集合元素的增加或刪除,均會導致子列表的遍歷、增加、刪除產生ConcurrentModificationException 異常。   使用工具類Arrays.asList()把陣列轉換成集合時,不能使用其修改集合相關的方法,它的add/remove/clear方法會丟擲UnsupportedOperationException異常。asList的返回物件是一個Arrays內部類,並沒有實現集合的修改方法。Arrays.asList體現的是介面卡模式,只是轉換介面,後臺的資料仍是陣列。   集合泛型定義時,在JDK7及以上,使用diamond語法或全省略。菱形泛型,即diamond,直接使用<>來指代前邊已經指定的型別。如下:
// <> diamond方式
HashMap<String, String> userCache = new HashMap<>(16);
// 全省略方式
ArrayList<User> users = new ArrayList(10);
  使用集合轉陣列的方法,必須使用集合的toArray(T[] array),傳入的是型別完全一樣的陣列,大小就是list.size()。使用toArray帶參方法,入參分配的陣列空間不夠大時,toArray方法內部將重新分配記憶體空間,並返回新陣列地址;如果陣列元素個數大於實際所需,下標為[ list.size() ]的陣列元素將被置為null,其它陣列元素保持原值,因此最好將方法入引數組大小定義與集合元素個數一致。直接使用toArray無參方法存在問題,此方法返回值只能是Object[]類,若強轉其它型別陣列將出現ClassCastException錯誤。   集合初始化時,指定集合初始值大小。例如HashMap使用HashMap(int initialCapacity) 初始化。initialCapacity = (需要儲存的元素個數 / 負載因子) + 1。注意 負載因子(即loader factor)預設為 0.75,如果 暫時無法 確定 初始值大小,請設定為 16(即預設值)。假如一個HashMap需要 放置 1024個元素, 由於 沒有設定容量 初始大小,隨著元素不斷增加容 量 7次被迫擴大, resize需要重建 hash表,嚴重影響效能。   Map類集合K/V能不能儲存null值的情況,如下表格:
集合類 key value super 說明
Hashtable 不允許為null 不允許為null Dictionary 執行緒安全
ConcurrentHashMap 不允許為null 不允許為null AbstractMap 鎖分段技術(JDK8:CAS)
TreeMap 不允許為null 允許為null AbstractMap 執行緒不安全
HashMap 允許為null 允許為null AbstractMap

執行緒不安全

合理利用好集合的有序性(sort)和穩定性(order),避免集合的無序性(unsort)和不穩定性(unorder)帶來的負面影響。有序性是指遍歷的結果是按某種比較規則依次排列的。穩定性指集合每次遍歷的元素次序是一定的。例如:ArrayList是order/unsort;HashMap是unorder/unsort;TreeSet是order/sort。

7.併發處理

執行緒池不要使用 Executors去建立,而是通過ThreadPoolExecutor的方式,這樣的處理方式讓寫的人更加明確執行緒池執行規則,避資源耗盡風險。Executors返回的執行緒池物件的弊端 如下 :  
  • FixedThreadPool和SingleThreadPool執行緒的等待佇列長度為Integer.MAX_VALUE,可能會堆積大量請求,從而導致OOM。
  • CachedThreadPool和ScheduledThreadPool的執行緒最大數量為Integer.MAX_VALUE,可能會建立大量請求,從而導致OOM。
  高併發時,同步呼叫應該去考量鎖的效能損耗。能用無鎖資料結構,就不要用鎖;能鎖區塊,就不要鎖整個方法體;能用物件鎖,就不要用類鎖。儘可能使加鎖的程式碼塊工作量儘可能的小。 對多個資源、資料庫表、物件同時加鎖時,需要保持一致的加鎖順序,否則可能會造成死鎖。執行緒一需要對錶A、B、C依次全部加鎖後才可以進行更新操作,那麼執行緒二的加鎖順序也必須是A、B、C,否則可能出現死鎖。 併發修改同一記錄時,避免更新丟失,需要加鎖。要麼在應用層加鎖,要麼在快取加鎖,要麼在資料庫層使用樂觀鎖,使用version作為更新依據。 使用CountDownLatch進行非同步轉同步操作,每個執行緒退出前必須呼叫countDown方法,執行緒執行程式碼注意catch異常,確保countDown方法被執行到,避免主執行緒無法執行至await方法,直到超時才返回結果。子執行緒丟擲異常堆疊,不能在主執行緒try-catch到。 避免Random例項被多執行緒使用,雖然共享該例項是執行緒安全的,但會因競爭同一例項導致的效能下降。Random例項包括java.util.Random 的例項或者 Math.random()的方式。在JDK7之後,可以直接使用API ThreadLocalRandom,而在 JDK7之前,需要編碼保證每個執行緒持有一個例項。 在併發場景下,通過雙重檢查鎖(double-checked locking)實現延遲初始化的優化問題隱患(可參考 The "Double-Checked Locking is Broken" Declaration),推薦解決方案中較為簡單一種(適用於JDK5及以上版本),將目標屬性宣告為 volatile型。如下:
public class DoubleCheckedDemo {
    private DoubleCheckedDemo(){}
    private static volatile DoubleCheckedDemo doubleCheckedDemo = null;
    public static DoubleCheckedDemo getInstance()
    {
        if (doubleCheckedDemo == null){
            synchronized (DoubleCheckedDemo.class){
                if(doubleCheckedDemo == null)
                {
                    doubleCheckedDemo = new DoubleCheckedDemo();
                }
            }
        }
        return doubleCheckedDemo;
    }
}

  volatile解決多執行緒記憶體不可見問題。對於一寫多讀,是可以解決變數同步問題,但是如果多寫,同樣無法解決執行緒安全問題。如果是count++操作,使用如下類實現:AtomicInteger count = new AtomicInteger(); count.addAndGet(1); 如果是JDK8,推薦使用LongAdder物件,比AtomicLong效能更好(減少樂觀鎖的重試次數)。

HashMap在容量不夠進行resize時由於高併發可能出現死鏈,導致CPU飆升,在開發過程中可以使用其它資料結構或加鎖來規避此風險。

參考資料:

  程式碼整潔之道 Clean Code

  https://www.jianshu.com/p/59f5a5cbdf7c

集合類