Nic Lin's Blog

喜歡在地上滾的工程師

如何提升你的程式可讀性之實務技巧(二)

這個篇章會有比較大量的程式碼範例,看起來會比較累一些。

因為能夠提升可讀性的方法族繁不及被載,所以也不會有全部的方法,我整理了一些我自己平常開發比較常使用的一些心法和技巧與你分享。

這裡我會舉正反例子來表達可讀性的差異,但因為可讀性畢竟是抽象的,沒有絕對的對與錯,只有適不適合而已。

然而,通常只要記住以下這些觀念,寫出來的東西都不至於會太難以理解。

文章的篇幅主要會分成三個部分

  1. 提升可讀性能夠帶來的實際幫助
  2. 以程式碼示範提升可讀性好用的技巧 <- 本篇
  3. 以測試、開源等不同角度去提升可讀性的方法

  1. 觀念
    • 文字比數字好
    • 一次只做一件事
    • 永遠把自己當新手
    • 填克漏字
    • 勇於展現錯誤
  2. 命名
    • 化簡為繁
    • 規格不該出現在這
    • 兩個恰恰好
    • 讓包裝取代混亂
  3. 流程
    • Early Return
    • 縮排只要一層
    • 請使用正邏輯
  4. 註解
    • 記錄想法,不要寫廢話
    • 使用標籤

1. 觀念

a. 文字比數字好

有很多時間會有狀態管理的場景,如果能用文字處理就請不要用數字。

我們先看只用數字的做法

# 沒有人知道狀態為 5 是什麼意思,為了要知道這個數字意義,我還必須去找定義
update(status: 5)

那麼用文字呢?是不是提升可讀性了

# 你可以定義一個常數,將數字可以以文字展現
PEDNING_STATUS = 5

# 即使我沒有看到常數定義,在這裡我們可以直接透過文字猜到,這個狀態即將更新為「pending」
update(status: PEDNING_STATUS)

在很多程式語言中,通常有很多消滅魔鬼數字的方法。

以 Ruby on Rails 中來說 enum 就是一個非常好用的方法,可以在程式中以 String 語意化的方式顯示,但在 database 中使用 integer 減少儲存空間及效率。

在其他語言例如 golang / React 的部分,我會以定義常數的方式來作為狀態語意化的管理。

b. 一次只做一件事

單一功能原則(Single responsibility principle)這個是物件導向設計五原則 SOLID 的其中一個,雖然有些界線不是很好理解,但大方向是不變的,就是盡可能的保持「單純」。

在你的 function / method 裡,盡可能秉持做一件事而不是一堆事的原則,如果要做一堆事,應該把他做成一個 Service

當一個方法有太多的職責時也意味著

  1. 耦合多事務
  2. 有各種結果及錯誤
  3. 不易閱讀

這樣會造成日後難以閱讀及 debug,

舉例來說

# 一個 function 超過一件職責的範例
def download_orders_csv_and_send_email
  # 先將 CSV 製作出來
 orders_csv = CSV.generate(headers: true) do |csv|
   # write csv row
 end
 
 # 然後塞進 ZIP 做壓縮
 stringio = Zip::ZipOutputStream::write_buffer do |zip|
    zip << orders_csv
 end
 
 # 轉成 binary data
 file = stringio.sysread

 # 然後發送信件
 UserMailer.send_csv_to_user(user, file)
end

這個 function 看似沒問題,一樣能達成業務邏輯,但其中的問題在於,這裡的實作細節環環相扣,在往後堆疊新的邏輯時,會變成一個高聚合的方法,也變得難以測試。

比較建議的方法是,將一整個邏輯拆成服務(Service),然後將每一個小任務分別放置一個 function,讓 function 「盡量」維持單一職責

class OrdersExportService
 # 初始化方法
  def initialize(user)
    @user = user
    @file = ""
  end

 # 目前唯一公開能夠呼叫的方法
  def download
    generate_export_file!
    notify_to_user(user, file)
  end

  private

  # 這個 method 只負責建立「壓縮好的 CSV 檔案」
  def generate_export_file!
   orders_csv = CSV.generate(headers: true) do |csv|
     # write csv row
   end

   stringio = Zip::ZipOutputStream::write_buffer do |zip|
      zip << orders_csv
   end
 
   @file = stringio.sysread
  end

  # 這個 method 只負責通知用戶
  def notify_to_user
    UserMailer.send_csv_to_user(user, file)
  end
end

這樣子做有什麼好處?

首先,你的公開 function 具有更高的可讀性,我可以光看 generate_export_file!notify_to_user 就知道會有「兩件事」發生,而且語意上很明確,我如果需要理解實作細節,也可以很容易找到。

再來,因為發送信件這件事情,被包裝成 private function 了,用了中性命名 notify_to_user,也意味著有更高的擴充性。

未來如果要新增「簡訊」通知,是不是我只要在 notify_to_user 裡面添加實作細節就可以了?

def notify_to_user
  UserMailer.send_csv_to_user(user, file)
  SendSMSService.call!(user.phone)
end

而這樣 notify_to_user 也符合單一職責的原則

他的任務只有一個,就是如命名所敘述的「通知用戶」

c. 永遠把自己當新手

當你寫出來的功能,發上了 Pull Request,在請別人 review 之前,請換個角度思考,然後自己先 review 過一遍。

如果你是個新來的工程師,能不能快速看懂這些程式碼大概在做些什麼?有多少地方需要解釋才能看的懂?

需要解釋的部分是否寫了清楚的註解?

請把自己想像成一個文字創作者,你正在寫一本書,讀者會不會因為你省略、簡寫、專業術語導致無法有良好的閱讀理解。

寫完之後,永遠把自己當作一個初出茅廬的工程師,以一個閱讀者的角度去思考,而不是把自己當作 compiler,認為只要夠厲害就看的懂。

就以執行結果一致來說。

  • 高手寫出來的程式能夠讓新手、高手都看的懂
  • 偽高手寫出來的程式只有自己能看的懂

d. 填克漏字

在邏輯或畫面撰寫時,通常我會先在程式碼裡面假想這裡可能出現的 function,這感覺跟填克漏字很像,所以步驟會是先思考這裡程式碼要長怎樣才會好讀,然後在去定義實作方法。

以現在要寫一個新的 CSS class 給 HTML element 的話,在腦海裡我會覺得大概是這樣

<div class="_____">
</div>

然後我會開始思考命名,如果是用戶的大頭貼,那應該就會是

<div class="user-avatar">
</div>

這時候我才會去實際定義 .user-avatar 裡面的屬性顏色

這過程我認為就很像是在填克漏字。

繼續看範例

假設現在要添加使用者 VIP 徽章,一般來說直覺式的寫法,會將邏輯放在畫面裡面。

<div class="user-info">
  <% if user.level == 1 %>
    <span class="level-1-badge">1</span>
  <% elsif user.level == 2 %>
    <span class="level-2-badge">2</span>
  <% end %>
</div>

但這裡可以先反過來想,你希望這塊程式碼應該長成什麼樣子?

以我的角度,我會希望能夠放一個方法,幫我把 badge(徽章) 做顯示,我只要負責調用就好。

<div class="user-info">
  <%= render_user_badge(user) %>
</div>

所以我命名了一個尚未實作的方法 render_user_badge,預期他的行為是傳遞 user 進去後,能夠返回一個 html 元素。

這時候我們在去實作方法 render_user_badge,如此一來

  1. 增加 View 的可讀性,一看就知道這裡只要餵 user 給這個 helper,就會幫我顯示徽章
  2. 因為拆成了方法,也基本遵循了單一職責原則的設計

e. 勇於展現錯誤

有些程式語言有捕捉例外(Error expection)的語法可以使用,但使用不當很容易變成掩蓋錯誤,將未知的 Bug 藏於程式碼內,會讓人很難以追蹤真正的錯誤原因

begin
 # do something
rescue => error
  return 
end

可以從例子裡面看到,當 error 被捕捉時,直接被 return 。

這對調用者來說,他可能會不懂為什麼會被 return,仔細查也沒有 Log 被打印出來。

也就是說,這段捕捉例外的程式撰寫者,並不清楚自己要如何處理錯誤,只是想掩蓋自己都不知道會不會發生的錯誤,讓程式看起來得以運行。

這會讓 debug 變的非常困難,所以建議

  1. 只捕捉自己知道的例外
  2. 錯誤要有明確的處理(發通知、寫 Log)
  3. 如果不知道怎麼處理,就讓他噴掉,至少還比較容易發現 Bug

2. 命名

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

命名通常很難,但也不要因為有大神說了很難就亂命名,基本還是有一些方法可以避免出現不好的命名

a. 化簡為繁

不必要的簡寫對閱讀沒有幫助。

當你看到 ua.lock 時,你不會明白這是什麼意思。

但如果你看到 user_account.lock 就可以第一時間猜到是「使用者帳戶要被鎖定」的操作

現在的編輯器都有自動補全功能,並不會因為你多打幾個字就有所謂的「浪費時間」,因為你多打的時間永遠比不上往後閱讀所造成困惑的時間。

b. 規格不該出現在這

除非這是一個常見的單位或規格,例如 1 天、24 小時等等,不然不要輕易把規格放在命名裡面

假設有一個方法命名為 lock_3_hours,如果需求突然要改成 5 小時,是不是也要把命名改成 lock_5_hours

可以取比較中性的命名例如 lock_with_time,然後把要鎖定的時間用參數傳遞,或是設定預設值,不要輕易的在命名上直接的綁定規格

DEFAULT_LOCK_HOUR = 3.hours

# 沒傳遞參數就用預設值,如果有,就用傳遞的時間
def lock_with_hour(time: DEFAULT_LOCK_HOUR)
  # do something
end

c. 兩個恰恰好

命名的單字使用的越多,通常表示負責的事情也越多。

這裡會建議,盡量保持在 2 個字左右的命名會是比較理想的設計。

# 做兩件事
def saveAndUploadPost
end

# 只做一件事情
def savePost
end

def uploadPost
end

# 如果把 Post 獨立成一個模組,可以更精準的只用一個動詞
module Post
  def save
  end

  def upload
  end
end

d. 讓包裝取代混亂

正則表達式的效率會比自己寫方法判斷來的快,所以有些工程師非常喜歡用,也適合拿來比較複雜的文字比對。

但這個東西在閱讀上就不是那麼容易了,除了不直覺外,通常寫了也很難會去修改,因為很可能差一個符號但意思差之千里。

如果真要使用,這裡建議請你將複雜的表達式寫下註解或是直接用常數處理。

# 不看上下文,這樣寫真的很難理解啊
if (/\A[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*\z/).match(name)
  # do something
end

如果用常數處理,會不會比較容易閱讀呢?

# 至少把他整理出來,能夠用文字表達
USERNAME_PATTERN = /\A[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*\z/

if USERNAME_PATTERN.match(name)
 # do something
end

3. 流程

a. early return

這是一個非常好用的技巧可以幫助閱讀。

概念是讓程式碼盡早的完成任務,避免過深的巢狀導致閱讀的不易

一般來說我們會用 if / else 來完成邏輯,如下範例:

if user.has_money?
  # 顯示資產列表的實作
else
  return "沒錢"
end

如果用 early return 的技巧,你可以這樣寫

return "沒錢" if !user.has_money?

# 顯示資產列表的實作

在用戶沒錢的時候直接返回指定字串,在閱讀上也會清爽很多,因為由上而下的視線能夠馬上知道

「哦,原來只要沒有錢就會拿到這個字串,那有錢的情況就一定會執行後面的實作了!」

b. 縮排只要一層

越深的巢狀我們常戲稱為「波動拳」,有難以閱讀的狀況,因為你很難在視線上能夠對齊整個程式碼邏輯區塊,常常會看到這行不曉得到底在哪一層,然後還要思考這層能夠透過判斷式進來又是什麼條件。

在上述提到的 early return 技巧有另一種說法,叫做 Guard Clause,可以避免多巢狀帶來的可讀性問題。

當巢狀超過兩層就會有比較難以閱讀的情況,例如以下

if server !== nil
  server = getServer()

  if client !== nil
    client = getClient()

    if current !== nil
      processRequest(current)
    end
  end
end

可能這樣看起來你會覺得還好,但如果每一個 if 進去都有十行左右的程式碼時,就會非常難以理解。

所以請盡量保持只有一層的縮排。

如果用 Guard Clause 的做法就會變成

if server == nil
  return
end

server = getServer()

if client == nil
  return
end

client = getClient()

if current == nil
  return
end

processRequest(current)

當你從上讀到下的時候,不會因為巢狀被迫需要在視線上的左右移動及如何在腦海中對齊這些程式碼做閱讀。

但如果用這個技巧,可以從上讀到下去瞭解整個流程。

甚至可以在做一下簡單的重構

if (server == nil || client == nil || current == nil)
  return
end

processRequest(current)

是不是看起來更清爽,也能夠一目了然這裡的邏輯。

c. 請使用正邏輯

在判斷的時候,請盡可能使用正邏輯,而不是反邏輯。

正邏輯會更容易被閱讀,因為沒有人喜歡看雙重否定句之類的語句,例如「是否不同意不舉行選舉」。

# 這裡用了 if not,就是一種反邏輯,會讓人有需要思考反應的空間
if !user.signed_in?
  "尚未登入"
else
  "已登入"
end

# 其實你可以直接用正邏輯
if user.signed_in?
  "已登入"
else
  "尚未登入"
end

再來看一個案例,如果在 method 上的命名已經是反邏輯了,再加上 not,也是很讓人困擾

# not + not 沒有沒驗證(有夠難讀..)
if !phone.not_confirm?
  "已經驗證"
else
  "尚未驗證"
end

# 改不了 method 也請直接用正邏輯
if phone.not_confirm?
  "尚未驗證"
else
  "已經驗證"
end

這裡結合一下 early return + 正邏輯的寫法

一般來說我們可以判斷使用者如果存在,就執行某些事

# 當使用者存在,就做某些事
if user.present?
  # do something
end

可以換個角度想,是不是使用者為空,就乾脆直接 return ?

# 如果使用者為空就 return,通過的話才做某些事
return if user.blank?
# do something

當巢狀很多時,可以慢慢拆開,用這些技巧就不用多寫一層巢狀了。

4. 註解

我們在閱讀書籍時,可能會看到一些專業術語或是特殊用詞,導致我們可能無法直接看懂上下文。

這時候書籍旁都會有註解,讓你能夠對內容有更精準的閱讀理解。

我認為(好程式) > (壞程式 + 好註解)

a. 記錄想法,不要寫廢話

註解是幫助理解,不是製造更多困惑。

我們可以預期讀者會有一些疑問,把這些疑問透過註解回答。

所以要替讀者設想,他讀到這裡可能會不明白為何有這個參數或設定?

也許是當時的時空場景共同決策出來的,請把原因寫清楚,也方便自己日後考古。

可以的話,也可以附上當時查詢資料的連結。

記錄想法

  • 這裡的效能可能不太好,也許需要對欄位做非正規化
  • 這是產生 K 線需要的快取
  • 因為時程比較趕,所以將參數直接寫在流程內,建議之後拉出來做 configure

不要寫沒有意義的廢話

  • 到時候在改吧
  • 一定要設定成 0.73 不然會噴
  • 哈哈,這裡加了一條有趣的規則

b. 使用標籤

  • TODO
  • FIXME
  • HACK
  • XXX

現在很多開發工具都可以針對這些通用標籤做處理,日後可以統一快速搜尋相同的標籤來做改善處理。

所以可以習慣用標籤的方式寫下註解

  • TODO: 代表未實現的功能
  • FIXME: 表示目前有一些 bug 需要修正
  • HACK: 某些原因硬幹做出來的
  • XXX: 就是他媽的這段是怎樣,通常代表可以重寫或是沒人敢動了

小結

總結以上的部分如下

  1. 觀念
    • 文字比數字好
    • 一次只做一件事
    • 永遠把自己當新手
    • 填克漏字
    • 勇於展現錯誤
  2. 命名
    • 化簡為繁
    • 規格不該出現在這
    • 兩個恰恰好
    • 讓包裝取代混亂
  3. 流程
    • Early Return
    • 縮排只要一層
    • 請使用正邏輯
  4. 註解
    • 記錄想法,不要寫廢話
    • 使用標籤

以上這些範例說明,是我自己整理出來比較常用到的幾招技巧與心法,但每個程式語言有各自不同的社群規範等等,這個會再下一篇章節和你說明分享。

本系列其他文章

comments powered by Disqus