檢視原始碼 與程式碼相關的反模式

本文件概述與程式碼相關的潛在反模式,以及特定的 Elixir 慣用語和功能。

過度使用註解

問題

過度使用註解或註解不言自明的程式碼,會讓程式碼更難閱讀

範例

# Returns the Unix timestamp of 5 minutes from the current time
defp unix_five_min_from_now do
  # Get the current time
  now = DateTime.utc_now()

  # Convert it to a Unix timestamp
  unix_now = DateTime.to_unix(now, :second)

  # Add five minutes in seconds
  unix_now + (60 * 5)
end

重構

盡可能使用清楚且不言自明的函式名稱、模組名稱和變數名稱。以上面的範例來說,函式名稱清楚說明函式功能,因此不太需要在函式前面加上註解。程式碼也透過變數名稱和清楚的函式呼叫,說明運算。

可以像這樣重構上述程式碼

@five_min_in_seconds 60 * 5

defp unix_five_min_from_now do
  now = DateTime.utc_now()
  unix_now = DateTime.to_unix(now, :second)
  unix_now + @five_min_in_seconds
end

我們移除了不必要的註解。我們也加入了 @five_min_in_seconds 模組屬性,這額外提供了一個目的,為「神奇數字」60 * 5 命名,讓程式碼更清楚且更具表達性。

其他說明

Elixir 明確區分文件和程式碼註解。這門語言透過 @doc@moduledoc 等內建第一類支援文件。請參閱「撰寫文件」指南,取得更多資訊。

with 中複雜的 else 子句

問題

此反模式是指將所有錯誤子句扁平化成單一複雜 else 區塊的 with 陳述式。這種情況會損害程式碼的可讀性和可維護性,因為很難知道錯誤值來自哪個子句。

範例

以下為此反模式的範例,函式 open_decoded_file/1 從檔案讀取 Base64 編碼字串內容,並傳回已解碼的二進位字串。此函式使用 with 陳述式,需要處理兩個可能的錯誤,而所有錯誤都集中在單一複雜的 else 區塊中。

def open_decoded_file(path) do
  with {:ok, encoded} <- File.read(path),
       {:ok, decoded} <- Base.decode64(encoded) do
    {:ok, String.trim(decoded)}
  else
    {:error, _} -> {:error, :badfile}
    :error -> {:error, :badencoding}
  end
end

在上述程式碼中,不清楚 <- 左側的每個模式如何與其結尾的錯誤相關。 with 中的模式越多,程式碼就越不清楚,而且不相關的失敗重疊的可能性也越高。

重構

在這種情況下,與其將所有錯誤處理集中在單一複雜的 else 區塊中,最好在特定私有函式中標準化回傳類型。這樣一來,with 可以專注於成功案例,而錯誤會在發生時標準化,進而產生組織更佳且易於維護的程式碼。

def open_decoded_file(path) do
  with {:ok, encoded} <- file_read(path),
       {:ok, decoded} <- base_decode64(encoded) do
    {:ok, String.trim(decoded)}
  end
end

defp file_read(path) do
  case File.read(path) do
    {:ok, contents} -> {:ok, contents}
    {:error, _} -> {:error, :badfile}
  end
end

defp base_decode64(contents) do
  case Base.decode64(contents) do
    {:ok, decoded} -> {:ok, decoded}
    :error -> {:error, :badencoding}
  end
end

子句中的複雜萃取

問題

當我們使用多子句函式時,可以萃取子句中的值以供進一步使用,以及進行模式比對/防護檢查。此萃取本身並非反模式,但當您有在同一個函式的多個子句和多個參數中進行萃取時,就難以得知哪些萃取部分用於模式/防護,哪些僅用於函式本體內部。此反模式與無關的多子句函式有關,但有其自身的含意。它會以不同的方式損害程式碼可讀性。

範例

多子句函式 drive/1 正在萃取 %User{} 結構的欄位,以用於子句表達式 (age) 和函式本體 (name) 中。

def drive(%User{name: name, age: age}) when age >= 18 do
  "#{name} can drive"
end

def drive(%User{name: name, age: age}) when age < 18 do
  "#{name} cannot drive"
end

雖然上述範例很小,且未構成反模式,但它是混合萃取和模式比對的範例。在 drive/1 較為複雜、有更多子句、參數和萃取的情況下,將難以一眼看出哪些變數用於模式/防護,哪些則否。

重構

如下所示,針對此反模式的可能解決方案是在參數中僅萃取與模式/防護相關的變數,只要您有許多參數或多個子句即可。

def drive(%User{age: age} = user) when age >= 18 do
  %User{name: name} = user
  "#{name} can drive"
end

def drive(%User{age: age} = user) when age < 18 do
  %User{name: name} = user
  "#{name} cannot drive"
end

動態原子建立

問題

Atom 是 Elixir 基本類型,其值為其自身名稱。原子通常用於識別資源或表達作業狀態或結果。動態建立原子本身並非反模式;然而,原子不會被 Erlang 虛擬機器垃圾回收,因此此類型的值會在軟體的整個執行期間存在於記憶體中。Erlang VM 預設將應用程式中可以存在的原子數量限制為 1_048_576,這已足夠涵蓋程式中定義的所有原子,但嘗試透過動態建立為「洩漏原子」的應用程式提供早期限制。

基於這些原因,當開發人員無法控制在軟體執行期間將建立多少原子時,建立動態原子可能會被視為反模式。這種無法預測的情況可能會導致軟體因過度使用記憶體或甚至達到最大可能原子數而產生意外行為。

範例

想像一下您正在實作將字串值轉換為原子的程式碼。這些字串可能是從外部系統接收的,可能是作為應用程式要求的一部分,或作為對應用程式的回應的一部分。這種動態且無法預測的情況會構成安全風險,因為這些不受控的轉換可能會觸發記憶體不足錯誤。

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: String.to_atom(status), message: message}
  end
end
iex> MyRequestHandler.parse(%{"status" => "ok", "message" => "all good"})
%{status: :ok, message: "all good"}

當我們使用 String.to_atom/1 函數動態建立原子時,它基本上會取得在我們的系統中建立任意原子的潛在存取權,導致我們失去對遵守 BEAM 所建立限制的控制。有人可能會利用這個問題建立足夠的原子來關閉系統。

重構

為了消除這個反模式,開發人員必須透過將字串對應到原子來執行明確轉換,或將 String.to_atom/1 的使用替換為 String.to_existing_atom/1。明確轉換可以如下執行

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: convert_status(status), message: message}
  end

  defp convert_status("ok"), do: :ok
  defp convert_status("error"), do: :error
  defp convert_status("redirect"), do: :redirect
end
iex> MyRequestHandler.parse(%{"status" => "status_not_seen_anywhere", "message" => "all good"})
** (FunctionClauseError) no function clause matching in MyRequestHandler.convert_status/1

透過明確列出所有支援的狀態,您可以保證只會發生有限數量的轉換。傳遞無效狀態會導致函數子句錯誤。

另一種方法是使用 String.to_existing_atom/1,它只會在原子已存在於系統中時將字串轉換為原子

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: String.to_existing_atom(status), message: message}
  end
end
iex> MyRequestHandler.parse(%{"status" => "status_not_seen_anywhere", "message" => "all good"})
** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not an already existing atom

在這種情況下,只要狀態未在系統中任何地方定義為原子,傳遞未知狀態就會引發錯誤。但是,假設 status 可以是 :ok:error:redirect,您要如何保證這些原子存在?您必須確保這些原子存在於 String.to_existing_atom/1 被呼叫的同一個模組中。例如,如果您有這段程式碼

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: String.to_existing_atom(status), message: message}
  end

  def handle(%{status: status}) do
    case status do
      :ok -> ...
      :error -> ...
      :redirect -> ...
    end
  end
end

所有有效的狀態都定義為同一個模組內的原子,這樣就足夠了。如果你想要明確一點,你也可以有一個列出它們的函式

def valid_statuses do
  [:ok, :error, :redirect]
end

不過,請記住使用模組屬性或在函式外定義模組主體中的原子是不夠的,因為模組主體只會在編譯期間執行,它不一定是執行時載入的編譯模組的一部分。

長參數清單

問題

在像 Elixir 這樣的函式語言中,函式傾向於明確地接收所有輸入並傳回所有相關的輸出,而不是依賴於突變或副作用。隨著函式複雜度的增加,它們需要處理的參數數量可能會增加,到函式的介面變得混淆且容易在使用期間發生錯誤的程度。

範例

在以下範例中,loan/6 函式接收了太多參數,導致其介面混淆,並潛在地導致開發人員在呼叫此函式時引入錯誤。

defmodule Library do
  # Too many parameters that can be grouped!
  def loan(user_name, email, password, user_alias, book_title, book_ed) do
    ...
  end
end

重構

為了解決這個反模式,可以將相關參數使用鍵值資料結構進行分組,例如映射、結構,甚至在可選參數的情況下使用關鍵字清單。這有效地減少了參數數量,而鍵值資料結構則為呼叫者增加了清晰度。

對於這個特定的範例,loan/6 的參數可以分組成兩個不同的映射,從而將其元數減少為 loan/2

defmodule Library do
  def loan(%{name: name, email: email, password: password, alias: alias} = user, %{title: title, ed: ed} = book) do
    ...
  end
end

在某些情況下,具有太多參數的函式可能是私有函式,這讓我們在如何分隔函式參數方面有更大的彈性。對於這種情況,一個可能的建議是將參數分成兩個映射(或元組):一個映射保留可能變化的資料,另一個映射保留不會變化的資料(唯讀)。這給了我們一個機械選項來重構程式碼。

其他時候,一個函式可能合法地接收六個或更多完全不相關的參數。這可能表示函式嘗試執行太多操作,最好將其分解成多個函式,每個函式負責整體責任的一小部分。

命名空間入侵

問題

此反模式在套件作者或函式庫定義其「命名空間」以外的模組時會出現。函式庫應將其名稱用作其所有模組的「前綴」。例如,名為 :my_lib 的套件應在其 MyLib 命名空間中定義其所有模組,例如 MyLib.UserMyLib.SubModuleMyLib.ApplicationMyLib 本身。

這很重要,因為 Erlang VM 一次只能載入一個模組實體。因此,如果有多個函式庫定義相同的模組,則由於此限制,它們彼此不相容。透過始終使用函式庫名稱作為前綴,它可以避免由於唯一前綴而產生的模組名稱衝突。

範例

在撰寫另一個函式庫的延伸時,此問題通常會出現。例如,假設您正在撰寫一個套件,將驗證新增至 Plug,稱為 :plug_auth。您必須避免在 Plug 命名空間中定義模組

defmodule Plug.Auth do
  # ...
end

即使 Plug 目前未定義 Plug.Auth 模組,它也可能在未來新增此類模組,這最終會與 plug_auth 的定義衝突。

重構

由於套件的名稱為 :plug_auth,因此它必須在 PlugAuth 命名空間中定義模組

defmodule PlugAuth do
  # ...
end

其他說明

此反模式有少數已知的例外

  • 根據設計,通訊協定實作定義在通訊協定命名空間下

  • 在某些情況下,命名空間擁有者可能會允許例外。例如,在 Elixir 本身中,您定義 自訂 Mix 任務,方法是將它們置於 Mix.Tasks 命名空間下,例如 Mix.Tasks.PlugAuth

  • 如果您是 plugplug_auth 的維護者,則您可以允許 plug_auth 使用 Plug 命名空間定義模組,例如 Plug.Auth。但是,您有責任避免或管理未來可能發生的任何衝突

非斷言式地圖存取

問題

在 Elixir 中,可以從 Map(關鍵值資料結構)存取值,無論是靜態或動態。

當預期地圖中存在某個鍵時,必須使用 map.key 表示法存取它,讓開發人員(和編譯器)清楚了解該鍵必須存在。如果鍵不存在,則會引發例外(在某些情況下也會引發編譯器警告)。這也稱為靜態表示法,因為鍵在撰寫程式碼時已知。

當一個鍵是可選時,必須改用 map[:key] 符號。這樣一來,如果所告知的鍵不存在,就會傳回 nil。這是動態符號,因為它也支援動態鍵存取,例如 map[some_var]

當你使用 map[:key] 存取一個在 map 中永遠存在的鍵時,你會讓開發人員和編譯器更難理解程式碼,因為他們現在需要假設鍵可能不存在。這種不匹配也可能讓追蹤特定錯誤更困難。如果鍵意外遺失,你會讓 nil 值在系統中傳播,而不是在 map 存取時引發錯誤。

範例

函數 plot/1 會嘗試繪製一個圖形來表示笛卡兒平面中一個點的位置。這個函數會接收一個 Map 類型的參數,其中包含點的屬性,它可以是 2D 或 3D 笛卡兒座標系統中的點。這個函數使用動態存取來擷取 map 鍵的值

defmodule Graphics do
  def plot(point) do
    # Some other code...
    {point[:x], point[:y], point[:z]}
  end
end
iex> point_2d = %{x: 2, y: 3}
%{x: 2, y: 3}
iex> point_3d = %{x: 5, y: 6, z: 7}
%{x: 5, y: 6, z: 7}
iex> Graphics.plot(point_2d)
{2, 3, nil}
iex> Graphics.plot(point_3d)
{5, 6, 7}

假設我們想要繪製 2D 和 3D 點,預期會有上述行為。但是,如果我們忘記傳遞一個包含 :x:y 的點會發生什麼事?

iex> bad_point = %{y: 3, z: 4}
%{y: 3, z: 4}
iex> Graphics.plot(bad_point)
{nil, 3, 4}

上述行為是意外的,因為我們的函數不應該使用沒有 :x 鍵的點。這會導致細微的錯誤,因為我們現在可能會傳遞 nil 給另一個函數,而不是提早引發錯誤。

重構

要移除這個反模式,我們必須根據我們的需求使用動態 map[:key] 語法和靜態 map.key 符號。我們預期 :x:y 永遠存在,但 :z 不會。下一個程式碼說明了 plot/1 的重構,移除這個反模式

defmodule Graphics do
  def plot(point) do
    # Some other code...
    {point.x, point.y, point[:z]}
  end
end
iex> Graphics.plot(point_2d)
{2, 3, nil}
iex> Graphics.plot(bad_point)
** (KeyError) key :x not found in: %{y: 3, z: 4} # <= explicitly warns that
  graphic.ex:4: Graphics.plot/1                  # <= the :x key does not exist!

總的來說,使用 map.keymap[:key] 編碼了關於資料結構的重要資訊,讓開發人員可以清楚了解他們的意圖。請參閱 MapAccess 模組文件,以取得更多資訊和範例。

重構這個反模式的另一種方法是使用模式比對,定義 2d 與 3d 點的明確子句

defmodule Graphics do
  # 3d
  def plot(%{x: x, y: y, z: z}) do
    # Some other code...
    {x, y, z}
  end

  # 2d
  def plot(%{x: x, y: y}) do
    # Some other code...
    {x, y}
  end
end

模式比對在同時比對多個鍵以及其值時特別有用。

另一個選項是使用結構。預設情況下,結構只支援靜態存取其欄位。在這種情況下,你可以考慮為 2D 和 3D 點定義結構

defmodule Point2D do
  @enforce_keys [:x, :y]
  defstruct [x: nil, y: nil]
end

一般來說,當在模組之間共用資料結構時,結構很有用,但代價是在這些模組之間新增編譯時間相依性。如果模組 A 使用模組 B 中定義的結構,如果結構 B 中的欄位變更,就必須重新編譯 A

其他說明

這個反模式以前稱為 Accessing non-existent map/struct fields

非斷言模式比對

問題

總的來說,Elixir 系統由許多受監督的程序組成,因此錯誤的影響會侷限在單一程序中,不會傳播到整個應用程式。一個監督程序會偵測到失敗的程序,報告它,並可能重新啟動它。當開發人員撰寫防禦性或不精確的程式碼時,就會出現這個反模式,這種程式碼可能會傳回未規劃的不正確值,而不是透過模式比對和防護程式以斷言風格進行編程。

範例

函式 get_value/2 會嘗試從 URL 查詢字串的特定金鑰中萃取值。由於它並非使用模式比對來實作,因此 get_value/2 永遠會傳回一個值,而不論在呼叫中傳遞為參數的 URL 查詢字串格式為何。有時傳回的值會是有效的。不過,如果在呼叫中使用了格式意外的 URL 查詢字串,get_value/2 會從中萃取出不正確的值

defmodule Extract do
  def get_value(string, desired_key) do
    parts = String.split(string, "&")

    Enum.find_value(parts, fn pair ->
      key_value = String.split(pair, "=")
      Enum.at(key_value, 0) == desired_key && Enum.at(key_value, 1)
    end)
  end
end
# URL query string with the planned format - OK!
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "lab")
"ASERG"
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "university")
"UFMG"
# Unplanned URL query string format - Unplanned value extraction!
iex> Extract.get_value("name=Lucas&university=institution=UFMG&lab=ASERG", "university")
"institution"   # <= why not "institution=UFMG"? or only "UFMG"?

重構

若要移除這個反模式,get_value/2 可以透過使用模式比對來重新整理。因此,如果使用了意外的 URL 查詢字串格式,函式會崩潰,而不是傳回無效的值。以下所示的這個行為,讓客戶端可以決定如何處理這些錯誤,而且不會在萃取出意外值時,給人程式碼運作正常的錯誤印象

defmodule Extract do
  def get_value(string, desired_key) do
    parts = String.split(string, "&")

    Enum.find_value(parts, fn pair ->
      [key, value] = String.split(pair, "=") # <= pattern matching
      key == desired_key && value
    end)
  end
end
# URL query string with the planned format - OK!
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "name")
"Lucas"
# Unplanned URL query string format - Crash explaining the problem to the client!
iex> Extract.get_value("name=Lucas&university=institution=UFMG&lab=ASERG", "university")
** (MatchError) no match of right hand side value: ["university", "institution", "UFMG"]
  extract.ex:7: anonymous fn/2 in Extract.get_value/2 # <= left hand: [key, value] pair
iex> Extract.get_value("name=Lucas&university&lab=ASERG", "university")
** (MatchError) no match of right hand side value: ["university"]
  extract.ex:7: anonymous fn/2 in Extract.get_value/2 # <= left hand: [key, value] pair

Elixir 和模式比對推廣了一種自信的程式設計風格,在這種風格中,您可以處理已知的情況。一旦出現意外的場景,您可以根據實際範例決定適當地處理它,或得出結論,說明場景確實無效,而且例外狀況是理想的選擇。

case/2 是 Elixir 中的另一個重要結構,它透過比對特定模式來協助我們撰寫自信的程式碼。例如,如果函式傳回 {:ok, ...}{:error, ...},請優先明確比對這兩個模式

case some_function(arg) do
  {:ok, value} -> # ...
  {:error, _} -> # ...
end

特別是,請避免只比對 _,如下所示

case some_function(arg) do
  {:ok, value} -> # ...
  _ -> # ...
end

比對 _ 在意圖上較不明確,而且如果 some_function/1 在未來新增傳回值,它可能會隱藏錯誤。

其他說明

這個反模式以前稱為 臆測假設

非自信真值

問題

Elixir 提供了真值的概念:nilfalse 被視為「假值」,而所有其他值都是「真值」。語言中的許多結構,例如 &&/2||/2!/1,會處理真值和假值。使用這些運算子並非反模式。不過,在所有運算元都預期為布林值時使用這些運算子,可能是反模式。

範例

此反模式最簡單的狀況出現在條件式中,例如

if is_binary(name) && is_integer(age) do
  # ...
else
  # ...
end

假設 &&/2 的兩個運算元都是布林值,則程式碼比必要的更泛用,且可能不清楚。

重構

若要移除此反模式,我們可以用 and/2or/2not/1 分別取代 &&/2||/2!/1。這些運算子會確認至少第一個引數是布林值

if is_binary(name) and is_integer(age) do
  # ...
else
  # ...
end

在使用 Erlang 程式碼時,此技術可能特別重要。Erlang 沒有真值性的概念。它從不傳回 nil,而是其函式可能會在 Elixir 開發人員會傳回 nil 的地方傳回 :error:undefined。因此,若要避免意外將 :undefined:error 解釋為真值,您可能偏好使用 and/2or/2not/1,特別是在與 Erlang API 介接時。