檢視原始碼 元程式反模式

此文件概述與元程式相關的潛在反模式。

大量程式碼產生

問題

此反模式與產生過多程式碼的巨集有關。當巨集產生大量程式碼時,會影響編譯器和/或執行時期的工作方式。原因在於 Elixir 可能必須多次展開、編譯和執行程式碼,這將使編譯變慢,並使產生的編譯人工製品更大。

範例

想像一下您正在為 Web 應用程式定義路由器,其中您可能有 get/2 等巨集。在每次呼叫巨集(可能數百次)時,get/2 內的程式碼將會展開並編譯,這可能會產生大量的程式碼。

defmodule Routes do
  defmacro get(route, handler) do
    quote do
      route = unquote(route)
      handler = unquote(handler)

      if not is_binary(route) do
        raise ArgumentError, "route must be a binary"
      end

      if not is_atom(handler) do
        raise ArgumentError, "handler must be a module"
      end

      @store_route_for_compilation {route, handler}
    end
  end
end

重構

為了移除此反模式,開發人員應簡化巨集,將其部分工作委派給其他函式。如下所示,透過將 quote/1 內的程式碼封裝在函式 __define__/3 中,我們減少了每次呼叫巨集時展開和編譯的程式碼,並改為呼叫函式來執行大部分工作。

defmodule Routes do
  defmacro get(route, handler) do
    quote do
      Routes.__define__(__MODULE__, unquote(route), unquote(handler))
    end
  end

  def __define__(module, route, handler) do
    if not is_binary(route) do
      raise ArgumentError, "route must be a binary"
    end

    if not is_atom(handler) do
      raise ArgumentError, "handler must be a module"
    end

    Module.put_attribute(module, :store_route_for_compilation, {route, handler})
  end
end

不必要的巨集

問題

巨集 是強大的元程式機制,可用於 Elixir 中來擴充語言。雖然使用巨集本身並非反模式,但此元程式機制僅應在絕對必要時使用。每當使用巨集時,但可以使用函式或其他現有的 Elixir 結構來解決相同的問題,程式碼就會變得不必要地更複雜且更難以閱讀。由於巨集更難以實作和推理,因此不加選擇地使用它們可能會損害系統的演進,降低其可維護性。

範例

MyMath 模組實作 sum/2 巨集,以執行接收兩個數字作為參數的總和。雖然此程式碼沒有語法錯誤,並且可以正確執行以取得預期的結果,但它不必要地更複雜。透過將此功能實作為巨集而不是傳統函式,程式碼變得不那麼清楚

defmodule MyMath do
  defmacro sum(v1, v2) do
    quote do
      unquote(v1) + unquote(v2)
    end
  end
end
iex> require MyMath
MyMath
iex> MyMath.sum(3, 5)
8
iex> MyMath.sum(3 + 1, 5 + 6)
15

重構

為了移除此反模式,開發人員必須使用更簡單撰寫和理解的結構(例如命名函式)來取代不必要的巨集。以下所示的程式碼是先前範例重構的結果。基本上,sum/2 巨集已轉換為傳統的命名函式。請注意,require/2 呼叫不再需要

defmodule MyMath do
  def sum(v1, v2) do # <= The macro became a named function
    v1 + v2
  end
end
iex> MyMath.sum(3, 5)
8
iex> MyMath.sum(3+1, 5+6)
15

use 取代 import

問題

Elixir 有 import/1alias/1use/1 等機制,用於建立模組間的相依性。使用這些機制實作的程式碼本身並非程式碼異味。然而,import/1alias/1 指令具有詞彙範圍,且僅協助模組呼叫其他模組的函式,use/1 指令具有較廣的範圍,這可能會造成問題。

use/1 指令允許模組將任何類型的程式碼注入另一個模組,包括傳播相依性。如此一來,使用 use/1 指令會讓程式碼更難閱讀,因為要了解在參照模組時會發生什麼事,必須具備參照模組的內部詳細資料。

範例

以下顯示的程式碼是此反模式的範例。它定義了三個模組:ModuleALibraryClientAppClientApp 透過 use/1 指令重複使用 Library 的程式碼,但不知道其內部詳細資料。這讓 ClientApp 的作者更難視覺化其模組中現在有哪些模組和功能可用。更糟的是,Library 也匯入了 ModuleA,而 ModuleA 定義了一個 foo/0 函式,與 ClientApp 中定義的區域函式衝突

defmodule ModuleA do
  def foo do
    "From Module A"
  end
end
defmodule Library do
  defmacro __using__(_opts) do
    quote do
      import Library
      import ModuleA  # <= propagating dependencies!
    end
  end

  def from_lib do
    "From Library"
  end
end
defmodule ClientApp do
  use Library

  def foo do
    "Local function from client app"
  end

  def from_client_app do
    from_lib() <> " - " <> foo()
  end
end

當我們嘗試編譯 ClientApp 時,Elixir 會偵測到衝突並擲出下列錯誤

error: imported ModuleA.foo/0 conflicts with local function
  └ client_app.ex:4:

重構

若要移除此反模式,我們建議函式庫作者避免提供 __using__/1 回呼,只要能用 alias/1import/1 指令取代即可。在以下程式碼中,我們假設 use Library 已不再可用,且 ClientApp 已用這種方式重新整理,這樣一來,程式碼會更清楚,且先前顯示的衝突已不存在

defmodule ClientApp do
  import Library

  def foo do
    "Local function from client app"
  end

  def from_client_app do
    from_lib() <> " - " <> foo()
  end
end
iex> ClientApp.from_client_app()
"From Library - Local function from client app"

其他說明

在需要執行模組匯入和別名設定以外的操作時,提供 use MyModule 可能有其必要性,因為它提供了 Elixir 生態系統中一個常見的擴充點。

因此,為了提供指引和明確性,我們建議函式庫作者在他們的 @moduledoc 中包含一個告誡區塊,說明 use MyModule 如何影響開發人員的程式碼。舉例來說,GenServer 文件大綱中說明了

use GenServer

當您 use GenServer 時,GenServer 模組會設定 @behaviour GenServer 並定義一個 child_spec/1 函式,因此您的模組可以用作監控樹中的子模組。

可以將此摘要視為程式碼生成的「營養標示」。務必只列出對模組公開 API 所做的變更。例如,如果 use Library 設定了一個名為 @_some_module_info 的內部屬性,而這個屬性從未打算公開,請避免在營養標示中記錄它。

為方便起見,產生上述告誡區塊的標記符號如下

> #### `use GenServer` {: .info}
>
> When you `use GenServer`, the `GenServer` module will
> set `@behaviour GenServer` and define a `child_spec/1`
> function, so your module can be used as a child
> in a supervision tree.