程式碼可讀性
這是一篇結合專案程式碼與《編寫可讀藝術的程式碼》一書結合的讀書筆記
程式碼應當易於理解
《編寫可讀藝術的程式碼》這本書告訴我們程式碼應該寫的容易理解 ,我更喜歡作者的另一個說法是使別人用最短的時間理解你的程式碼
不知道大家有沒有想過,什麼樣的程式碼是好的,當我們忙於業務開發的時候可以停下來思考一下這個問題?當我看到這個問題的時候,腦海中的第一個想法就是肯定是將程式碼寫的越少越好 。
Part 1 表面層次的改進
把資訊裝到名字裡
選擇直接的名字,寫簡單明瞭的註釋,把程式碼寫的整潔,格式更好,說白了就是讓他人僅通過類名,方法名以及變數名,就可以知道他們的作用 ,或者說大概能瞭解它們的作用,這要求我們起的名字的含義要足夠清晰 ,明確 ,不能太過於含糊。
一、使用專業,明確,具體的名字,避免使用模糊,抽象,具有二義性的名字
通常我們會定義getXXX()
方法名,表示表獲取資料,get
這個名字並沒有表達出更多的資訊,你並不知道是從網路上獲取或者是資料庫上,又或者是從快取中獲取資料,可以考慮用fetch
,download
,find
,search
等含義更明確,更形象的單詞替代。
1、getXxx() --> findXxx()
我個人對於get 的理解是得到一個數據,這個資料應該是一個類的簡單屬性,不用經過複雜的計算,例如 JavaBean 的get 操作。
Picasso.with(this).load(info.getIvArrearsReasonImageRes()).into(imgArrear); //通過該方法獲取一個圖片資源,然後交由 Picasso 幫我們載入 info.getIvArrearsReasonImageRes();
初看這個方法我會以為這個圖片資源是info
物件的一個屬性,以下是這個方法的全部內容:
public int getIvArrearsReasonImageRes() { if (type == 1) { return R.drawable.debt_1; } else if (type == 2) { return R.drawable.debt_2; } else if (type == 3) { return R.drawable.debt_3; } else if (type == 5) { return R.drawable.debt_5; } else if (type == 7) { return R.drawable.debt_7; } else if (type == 9) { return R.drawable.debt_9; } else if (type == 20) { return R.drawable.debt_20; } else { return R.drawable.debt_other; } }
這個方法會根據info
物件中的int
型別的屬性type 值
來判斷應該返回哪個圖片資原始檔,所以我將方法名更改為findArrearsReasonImageRes()
,這樣你可能在看到這個方法時候會有一個預期。(內心PS:我只要知道 get 一個圖片資源就好了,誰管你內部是怎麼返回給我。
)
再來看看下面一個方法
private int getCurSmsId() { if (smsList != null && !ListUtils.isEmpty(smsList.getSmsList())) { for (SmsTemplateItem item : smsList.getSmsList()) { if (item.isSelected) { return item.id; } } } return -1; }
這個方法是用來獲取當前選中簡訊模板的ID,也是很普通的迴圈查詢,我更願意把它命名為findSelectedId()
,之所以沒有加上模板這個單詞是因為這個方法本身就是被簡訊模板業務物件所呼叫。
2、orders() --> parseOrders() 或者 parseOrderArray()
toDetailPage(assign.getTask_Id(), assign.orders(), isInRefresh ? POSITION_REFRESH : POSITION_MY_TASK);
這個方法接收三個引數,其中第二個引數表示從assign
物件中獲取Order
型別的陣列,以下是該方法的全部內容:
public List<Order> orders() { if (!TextUtils.isEmpty(orders)) { return Json.fromJsons(orders, Order.class); } else { return null; } }
orders是一串原始的的 Json 字串,當它不為空的時候,我們會將它解析成對應型別的陣列並返回,最初在不知道這個內部邏輯的情況下,我以為ordres
是assign
中的屬性,並且在assign
的整個生命週期中,我們會對它內部持有的 Order 陣列(我以為 Orders 是陣列)進行修改,由於我們只是將Json
字串解析成對應的物件型別而已,無論我們對解析後的物件如何操作,都不會對原始Json
字串產生任何影響,這個方法給我們造成了不小的麻煩,後來我們將它的名字修改為parseOrders()/parseOrderArray()
3、distanceBetweenUs() --> computeDistanceBetweenUs()
這個例子也很直白 distanceBetweenUs(),返回我們之間的距離,但是實際上程式碼的內部是通過地圖 SDK 進行耗時的計算得到的。我想將它改為 computeDistanceBetweenUs() 應該會合適一些,至少看到這個名字,你會思考它會不會耗時。
4、start() 和 stop() --> create() 和 destroy()
public interface Presenter { void start(); void stop(); //... }
這個介面表示 MVP 模式中的 P層(Presenter),由於 Presenter 需要和我們的生命週期進行繫結,所以我們會在 P 層中提供對應的生命週期方法,然而上述程式碼中的 start() 和 stop() 兩個方法是在 onCreate() 和 onDestroy() 方法中被呼叫的,這很可能會產生誤解,因此我將它們改成了onCreate()
和onDestroy()
二、避免空泛的名字
1、temp
private static SpannableString getRMBSpannableString(double rmb, String color, String typefaceSpan) { if (rmb <= 0) return null; String temp = "¥" + Strings.priceRoundFloor(rmb); SpannableString spannableString = new SpannableString(temp); spannableString.setSpan(new ForegroundColorSpan(Color.parseColor(color)), 0, temp.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); // new TypefaceSpan("monospace") 保證 ¥ 符號顯示 2橫 spannableString.setSpan(new TypefaceSpan(typefaceSpan), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); return spannableString; }
這個方法是用來獲取一個格式化的人民幣字串,在程式碼中間出現了兩個變數temp
和spannableString
沒有具體的含義,我選擇這兩個變數名的原因是因為我的懶惰,完全沒有考慮到可讀性
2、retval
private int getTotalPackageNum() { int retval = 0; for (CarloadLuggageItem item : packageListItems) { retval += item.getPackage_num(); } return retval; }
retval
意思是返回值 return value,這段程式碼的中心是求和操作,retval 代表包裹的總數量,我們可以選擇命名為totalPackageNum
可能會體現出更多的資訊
3、迴圈迭代器
for (int i = 0; clubs.length; i++) { for (int j = 0; clubs.[i].members.lenght; j++) { for (int k = 0; users.length; k++) { if (clubs[i].members[k] == users[j]){ do something... } } } }
你可能很難發現clubs[i].members[k] == users[j]
這段程式碼,我們標錯了陣列的下標,如果不使用i j k
作為陣列的下標而是結合變數名如if (clubs[ci].members[mj] == users[uk])
的方式,會更容易發現問題
4、給變數名加上重要的細節和特殊的格式
- 超時時間單位: int timeOut--int timeOutSecs
- 建立時間單位: long createTime--long createTimeMills
- 原始的 Json 字串:String orders--String rawJsonOrders
- 快取單位: long cacheSize--cacheMb
- ...
5、抽象的名字
- SmartSerialExecutor: 智慧的序列執行緒池,實際並沒有很智慧,也沒有能夠知道這個執行緒池有什麼特色
- BitmapCrop:Bitmap 裁剪,實際上內部程式碼僅僅是提供一個圓形 Bitmap
- ...
6、作用域大的變數名字更長
- RefreshListOrderItemView.java Method: addOrderItems
- ActivityTaskUnFinished.java Filed: order
三、程式碼格式
- 使用一致的佈局,讓讀者很快就習慣這種風格
- 讓相似的程式碼看上去相似
- 把相關的程式碼分組,形成程式碼塊
1、有意義的順序
我們都使用過 Dialog,它大概由,標題,副標題,內容,確認按鈕,取消按鈕這幾部分組成,我們在自定義 Dialog 的時候可以按照 Dialog 展示的順序來定義屬性:
public static class CustomDialog { private String title; private String subTitle; private String content; private String negativeButton; private String positiveButton; }
如果我們隨意的,無序的定義這些屬性,在屬性很多的情況下使用起來可能會有一些混亂,如果屬性定義的順序和設計稿的順序序一致,在使用起來就很舒暢
2、程式碼分段
public void loadPic(ScreenAd ad) { final String linkUrl = ad.getLink_url(); final int countDown = ad.getCount_down(); String displayUrl = ad.getDisplay_url(); if (TextUtils.isEmpty(displayUrl)) { return; } int screenAdWidth = ad.getWidth(); int screenAdHeight = ad.getHeight(); if (screenAdWidth <= 0 || screenAdHeight <= 0) { return; } initAdUI(screenAdWidth, screenAdHeight); Picasso.with(getActivity()).load(displayUrl).fit().into(ivA); }
通過空格對程式碼進行分段,這樣每一段都有各自的邏輯處理,如果沒有空格進行分段的話,程式碼就會變得不夠清晰。
3、團隊風格
團隊風格很重要,當團隊人數增加後,如果每個人都按照自己喜愛的風格來程式設計,整個工程對於他人來說將變得更難理解。比如我們的初始工程中大部分的 Activity 命名方式都是ActivityBusiness ,儘管我習慣於業務在前的命名方式,但我仍然遵循專案中之前的命名方式。
Part 2 簡化迴圈和邏輯
一、使控制流易讀
把條件,迴圈以及其他對控制流的改變做的越“自然”越好。運用一種方式使讀者不用停下來重讀你的程式碼
控制流語句指程式碼中的條件判斷,迴圈等語句。大量的複雜的if,switch, for 迴圈 等語句會使程式碼產生大量的分支,縮排,巢狀,變得混亂。我們的目標是讓這些控制流語句變得容易閱讀,容易理解。
二、條件語句中引數的順序
按照我們平時口語的表達習慣,一般你會說“我的身高高於 180 cm” 而不是“180 cm 沒有我的身高高” ,我們習慣將變數放在左側,而將常量放在右側。
final int id = AwsomeDaemonService.getId(); if (Transporter.LOGIN_INFORMATION_LOSS == id) { Toasts.shortToast(R.string.login_information_loss_prompt); return; }
這段程式碼的意思,判斷當前使用者的資訊是否丟失,如果丟失的話,則 Toast 提示使用者。我們比較語句寫的是Transporter.LOGIN_INFORMATION_LOSS == id
中文直譯過來就是使用者資訊丟失狀態是當前使用者的狀態,我們在腦海中還需要將它轉換為使用者當前的狀態是丟失狀態。
將比較語句改為if (id == Transporter.LOGIN_INFORMATION_LOSS)
可讀性會更好些。
1、if else 語句塊的順序
-
先處理正邏輯,用
if(debug)
而不是if(!debug)
- 先處理簡單的情況
- 先處理危險的情況
以上是書中給出的關於 if/else 書寫順序的建議,這些建議之間會有衝突,需要具體情況具體對待。
先處理正邏輯
if (!TextUtils.equals(response.getContent(), "0")) { vRedPoint.setVisibility(View.VISIBLE); } else { vRedPoint.setVisibility(View.GONE); }
我在專案中找到一些程式碼,對於 if/else 程式碼塊處理的程式碼行數相差無幾,上面的例子用來控制一個 View 是否需要隱藏,即使先寫負邏輯,也沒有覺得有什麼問題。
if (Transporter.get().isSleep()) { ivEmpty.setImageResource(R.drawable.close_assign_gray); tipTV.setText("休息一下,勞逸結合"); vGoHotMap.setVisibility(View.GONE); } else { ivEmpty.setImageResource(R.drawable.empty_picking_up_order); tipTV.setText("暫無訂單\n開啟地圖,前往訂單多的區域"); vGoHotMap.setVisibility(View.VISIBLE); }
根據騎士的開工收工狀態,來切換一些文案內容的顯示,這段程式碼是正邏輯,但是即使我將寫成負邏輯,我想也沒有什麼關係。
從中文表達邏輯上來說,我們可能更習慣先處理正邏輯在處理負邏輯,但是在它們的程式碼塊行數沒有明顯區別的時候,我覺得處理順序沒有什麼影響
先處理簡單的情況
先處理 if/else 語句塊中簡單的那部分
if (order.supplierDistanceBetweenYou() <= 0) { tvDistanceBetweenYou.setText("計算中"); order.supplierDistanceBetweenYou(new AddressUtil.WalkDistanceListener() { @Override public void onWalkDistanceSearched(int distance) { if (isDetached()) return; long orderId = (Long) tvDistanceBetweenYou.getTag(); if (orderId == order.getId()) { order.setDistanceBetweenYouAndSupplier(distance); tvDistanceBetweenYou.setText(Strings.formatDistanceWithMax(distance)); mapPresenter.addDistanceTip(distance); } } @Override public void onSearchFailed() { if (isDetached() || tvDistanceBetweenYou == null) return; long orderId = (Long) tvDistanceBetweenYou.getTag(); if (orderId == order.getId()) { float[] results = new float[1]; Location.distanceBetween(PhoneInfo.lat, PhoneInfo.lng, order.getSupplier_lat(), order.getSupplier_lng(), results); float distance = results[0]; order.setDistanceBetweenYouAndSupplier(distance); tvDistanceBetweenYou.setText(distance == 0 ? "..." : Strings.formatDistanceWithMax(distance)); mapPresenter.addDistanceTip(distance); } } }); } else { mapPresenter.addDistanceTip(order.supplierDistanceBetweenYou()); }
上面程式碼會從 order 資訊中獲取一個距離資訊,如果距離 <= 0 的話就需要一系列複雜的計算,然而 else 語句塊中的程式碼只有一行,很有可能這個時候,我們滿屏都是 if 語句塊中的程式碼,很容易忽略掉 else 語句塊的處理,這個時候我們不妨將先處理距離 > 0 的情況,這樣 if/else 的程式碼塊都可以在一屏之間看見,處理了簡單的邏輯之後,可以更專注在複雜的邏輯中。
先處理有趣的情況
if (subscribedTypes != null) { for (Class<?> eventType : subscribedTypes) { unsubscribeByEventType(subscriber, eventType); } typesBySubscriber.remove(subscriber); } else { Log.w(TAG, "Subscriber to unregister was not registered before: " + subscriber.getClass()); }
這是 EventBus 解除註冊中的一段程式碼,相比較於 else 語句中的列印日誌,明顯我們更專注 if 語句中的邏輯。
2、三目運算子
記得在我最開始寫程式碼的時候,特別喜歡用三目運算子,只用一行程式碼就可以替代原有的 if/else 結構
if/else 結構:
if (conditions){ return XXX; }else { return YYY; }
三目運算子結構:
conditions ? XXX : YYY;
如果實際情況有這麼簡單,當然可以寫成三目運算子的情況,然而你很有可能面臨這樣的情況:
height = (height <= availableHeight) ? (width * rawPicHeight / rawPicWidth) : rootHeight - iconHeight;
這段程式碼根據某個條件來計算 height 的值,這裡三目運算子已經不是從簡單的兩個值中做出選擇,而是為了將所有的程式碼擠進一行裡,導致可讀性變差。
使用 if/else 格式來改寫上述程式碼:
if (height <= availableHeight) { height = (width * rawPicHeight / rawPicWidth); } else { height = rootHeight - iconHeight; }
相對於追求最小化程式碼行數,一個更好的度量方法是最小化理解它所需要的時間
三、最小化巢狀
我們的專案中由於各種各樣的原因,肯定會有一些巢狀很深的程式碼,多層巢狀的程式碼難以理解,會加深我們的思維棧,每當多處理一層巢狀,我們腦海中的思維棧就加深了一層,因此我們要儘量減少程式碼中的巢狀。
if (Transporter.isLogin()) { //是否需要展示實地培訓的綠色按鈕 if (transporter.canApplyOfflineTraining()) { ViewUtils.visible(vFieldTraining); } if (transporter.isMissionCompleted()) { ViewUtils.gone(vTiro); TiroHelper.getInstance().destroyBottomDialog(); } else{ if (isNeedCheckTiroNew) { //檢測新手體驗單是否展示 TiroHelper.getInstance().checkNewTiroDialogIsShowing(this); getTransporterDetails(); } } } else { //如果未登陸,直接隱藏收工按鈕 ViewUtils.gone(vAssign); ViewUtils.gone(vFieldTraining); ViewUtils.gone(vTiro); isNeedCheckTiroNew = false; TiroHelper.getInstance().destroyBottomDialog(); }
這是我從 ActivityMain.java 中抽出並簡化的一段程式碼,在最初我們可能只有接單的 if/else 結構
if (Transporter.isLogin()) { //do something } else { //do something }
後來需求變更要求我們在使用者登入的情況下,新增一些判斷,我們自然而然的就寫成了多巢狀的形式,我們可以通過提前返回來減少巢狀
if (!Transporter.isLogin()) { //如果未登陸,直接隱藏收工按鈕 ViewUtils.gone(vAssign); ViewUtils.gone(vFieldTraining); ViewUtils.gone(vTiro); isNeedCheckTiroNew = false; TiroHelper.getInstance().destroyBottomDialog(); return; } //是否需要展示實地培訓的綠色按鈕 if (transporter.canApplyOfflineTraining()) { ViewUtils.visible(vFieldTraining); } if (transporter.isMissionCompleted()) { ViewUtils.gone(vTiro); TiroHelper.getInstance().destroyBottomDialog(); return; } if (isNeedCheckTiroNew) { //檢測新手體驗單是否展示 TiroHelper.getInstance().checkNewTiroDialogIsShowing(this); getTransporterDetails(); }
減少迴圈內的巢狀
for (CarloadLuggageItem checkItem : result.getDetails()) { if (!checkItem.isAvailable()) { for (int i = 0; i < packageListItems.size(); i++) { CarloadLuggageItem item = packageListItems.get(i); if (TextUtils.equals(item.getJd_order_no(), checkItem.getJd_order_no())) { item.setIs_passed(CarloadLuggageItem.UNPASS); bindFailList.add(new CheckFailItem(item.getJd_order_no(), item.getPackage_num())); break; } } } }
這也是之前專案中的程式碼,用來更改本地資料的成功或者失敗的狀態,我們依然可以通過提前返回來減少一層巢狀:
for (CarloadLuggageItem checkItem : result.getDetails()) { if (checkItem.isAvailable()) continue; for (int i = 0; i < packageListItems.size(); i++) { CarloadLuggageItem item = packageListItems.get(i); if (TextUtils.equals(item.getJd_order_no(), checkItem.getJd_order_no())) { item.setIs_passed(CarloadLuggageItem.UNPASS); bindFailList.add(new CheckFailItem(item.getJd_order_no(),item.getPackage_num())); break; } } }
- 在寫一個比較的時候,把變數寫在左側,把常量寫在右側更好一些
- 重新排列 if/else 語句中的語句塊,通常來講,先處理正確的/簡單的/有趣的情況
- 三目運算子有可能導致程式碼的可讀性變差,可以用更整潔的方式替代它
- 巢狀的程式碼塊需要更加集中注意力去理解,儘量將它改寫成線性的程式碼
- 可以通過提早返回來減少程式碼巢狀,讓程式碼變得更整潔
四、拆分超長的表示式
大多數人腦只能同時思考 3-4 件事情,如果程式碼,表示式太長,就會超出大腦思考的併發數,這樣的程式碼就會變得難以理解,因此我們需要將超長的表示式拆分容易理解的小程式碼塊
1、解釋變數
我們可以通過額外引入一個變數-解釋變數 ,讓它來表示一個小一點的子表示式。
if (ListUtils.isEmpty(order.getDisplay_tags()) //標籤不為空 && TextUtils.isEmpty(order.getOrigin_mark()) && TextUtils.isEmpty(order.getOrigin_mark_icon()) && TextUtils.isEmpty(order.getOrigin_mark_no())) { targetView.setVisibility(View.GONE); } else { targetView.setVisibility(View.VISIBLE); }
if 語句中的條件表示式很長,但是它其實就要表達一個意思,需不需要顯示 Tag,我們可以定義一個解釋變數,用來解釋這個條件表示式
boolean isNeedShowTag = order.getDisplay_tags()) && TextUtils.isEmpty(order.getOrigin_mark()) && TextUtils.isEmpty(order.getOrigin_mark_icon()) && TextUtils.isEmpty(order.getOrigin_mark_no()) if (isNeedShowTag) { targetView.setVisibility(View.VISIBLE); } else { targetView.setVisibility(View.GONE); }
2、總結變數,重複程式碼
即使一個表示式很簡單,你可以直接看出它的含義,也可以把它裝入一個新的變數中-總結變數 ,用一個很短的名字,來代替一大段程式碼,易於理解和思考
if (order.getFetchType() == Order.FETCH_TYPE_FROM_PACKAGE) { //同城速遞集包取件 getDadaApiV1().fetchCityExpressList(getActivity(), order, id); } ... 省略 if (order.getFetchType() != Order.FETCH_TYPE_FROM_PACKAGE) { EventBus.getDefault().post(new ForcePickUpEvent()); }
程式碼組要是來判斷該筆訂單是否是同城速遞訂單,然後針對不同的情況進行處理。我們可以通過增加一個總結變數來表達的更清楚。
boolean isSameCityExpress = order.getFetchType() == Order.FETCH_TYPE_FROM_PACKAGE; if (isSameCityExpress){ do something } if (!isSameCityExpress){ do something }
有的時候,我們的程式碼中會有一些重複的程式碼,我們也可以將這些重複程式碼提取出來,用一個變數來表示
if (order.getOrder_status() == Order.ORDER_STATUS_PICKUP) { OrderOperation.getInstance().dispatching(getActivity(),order); } else if (order.getOrder_status() == Order.ORDER_STATUS_DISPATCHING) { OrderOperation.getInstance().finish(getActivity(), true, order, null,finishCode); } else if (order.getOrder_status() == Order.ORDER_STATUE_FINISHED){ ... }
order.getOrder_status()
會重複出現多次,它表示獲取訂單的狀態,我們可以定義一個變數來表示它int orderStatus = order.getOrder_status()
3、短路操作
有的時候我們可以使用短路行為來使程式碼變得更簡潔。
例如我們 ListUtils 工具類中的方法。
public static <V> boolean isEmpty(List<V> sourceList) { return (sourceList == null || sourceList.size() == 0); } public static <V> boolean isNotEmpty(List<V> sourceList) { return (sourceList != null && sourceList.size() > 0); }
很好的利用短路邏輯,不然你可能需要寫多個 if 語句
public static <V> boolean isEmpty(List<V> sourceList) { if (sourceList == null) { return true; } if (sourceList.size() == 0) { return true; } return false; }
五、變數與可讀性
為什麼說對變數的草率運用會讓程式變得更難理解?
- 變數越多,就會越難追蹤它們的動向
- 變數的作用域越大,就需要追蹤它的動向越久
1、減少變數
沒有價值的臨時變數,多餘的中間變數,我們都可以通過一些方式來消除它。
沒有價值的臨時變數
沒有起到解釋或者總結的作用,並且只用到了一次,一般來說,在定義臨時變數的時候,預計是在之後會使用到它,但實際上沒有使用到它,也沒有將它刪除。
/** * 獲取訂單卡片的小費 */ public static double getOrderTips(OrderTaskInfo item) { Order order = item.getFirstOrder(); double displayOrderTips = order.getTips(); ... }
可以看到我們定義了一個 order 物件,這個 order 物件只用到了一次,沒有什麼特殊的價值,我們完全可以移除這個物件double displayOrderTips = item.getFirstOrder().getTips();
減少中間結果
int selectPosition for (int i = 0; i < adapter.size(); i++) { if (adapter.get(i).isSelected()) { selectPosition = i; break; } } return adapter.get(selectPosition);
這段程式碼是找出資料來源中的選中項,並且返回它,我們可以通過在迴圈體中直接返回選中項,從而消除 selectPosition 這個變數
for (int i = 0; i < adapter.size(); i++) { Object object = adapter.get(i); if (object.isSelected()) { return object; } }
2、減小每個變數的作用域
變數的作用域越小越好,儘量將變數移動到一個對其他程式碼可見性低的地方。