1. 程式人生 > >Elixir超程式設計-第六章 能力越大,責任也越大(樂趣也越大)

Elixir超程式設計-第六章 能力越大,責任也越大(樂趣也越大)

Elixir超程式設計-第六章 能力越大,責任也越大(樂趣也越大)

我們已經揭開了 Elixir 超程式設計的神祕面紗。我們從基礎開始一路走來。這一路,我們深入 Elixir 內部,相信同我一樣,你會對語言本身的語法及習慣用法有全新的認識。稍安勿躁,我們再回顧下這些技巧和方法,跳出 Elixir 巨集系統外,討論下如何避免一些常見陷阱。遵從超程式設計好的一面會讓你寫出易編寫,易維護,易擴充套件的程式碼。

何時何地才適合使用巨集

Elixir 語言本身即構建在巨集上,因此你可以很容易想到你所編寫的每個程式庫實際上都需要用到巨集。當然我們不是要討論這個。我們應該在只有常規的函式定義難以解決問題的特殊情況下才使用巨集。無論何時一旦你的程式碼試圖使用 defmacro,停下來捫心自問你真的需要用程式碼生成才能解決問題嗎。有時程式碼生成必不可少,但有時我們用常規函式完全可以取代巨集。

某些情況下判斷是否選擇巨集相對容易。比如程式中的分支語句,這需要訪問 AST 表示式,因此巨集必不可少。試試看在 if 語句的實現中我們能不能用函式替代巨集,就像我們前面用巨集實現的那樣。

iex> defmodule ControlFlow do
...>   def if(expr, do: block, else: else_block) do
...>     case expr do
...>       result when result in [nil, false] -> else_block
...>       result -> block
...>     end
...>   end
...> end
{:module, ControlFlow,
<<70, 79, 82, 49, 0, 0, 5, 120, 66, 69, 65, 77, 69, 120, 68, ...

iex> ControlFlow.if true do
...>   IO.puts "It's true!"
...> else
...>   IO.puts "It's false!"
...> end
It's true!
It's false

出了啥事?兩條 IO.puts 語句都執行了,因為在執行時我們將其作為引數傳遞給了 if 函式。在這裡我們就只能使用巨集,只有巨集才能在編譯時將表示式轉換成 case 語句,才能避免在執行時傳入的兩個子句都被執行。有時判斷是否選擇巨集就沒那麼明顯了。

在建立 Phoenix (一個 Elixir web 框架)程式時,我使用了巨集來表述 router 層。這裡 Phoenix router 中的巨集幹了兩件事。一是提供了一套簡單好用的 routing DSL。二是它在內部建立了很多子句,免去了使用者手工編寫的麻煩。我們從更高的角度來看下 router 生成器生成的一些程式碼。然後我們討論下對巨集的利弊權衡。

這裡是一個最小化的 Phoenix router,它會將請求路由至 controller 模組:

defmodule MyRouter do
  use Phoenix.Router

pipeline :browser do
    plug :accepts, ~w(html)
    plug :fetch_session
  end

  scope "/" do
    pipe_through :browser

    get "/pages", PageController, :index
    get "/pages/:page", PageController, :show
    resources "/users", UserController do
      resources "/comments", CommentController
    end
  end
end

在 MyRouter 編譯完成後,Phoenix 會在模組中生成如下的函式頭:

defmodule MyRouter do
  ...
  def match(conn, "GET", ["pages"])
  def match(conn, "GET", ["pages", page])
  def match(conn, "GET", ["users", "new"])
  def match(conn, "POST", ["users"])
  def match(conn, "PUT", ["users", id])
  def match(conn, "PATCH", ["users", id])
  def match(conn, "DELETE",["users", id])
  def match(conn, "GET", ["users", user_id, "comments"])
  def match(conn, "GET", ["users", user_id, "comments", id, "edit"])
  def match(conn, "GET", ["users", user_id, "comments", id])
  def match(conn, "GET", ["users", user_id, "comments", "new"])
  def match(conn, "POST", ["users", user_id, "comments"])
  def match(conn, "PUT", ["users", user_id, "comments", id])
  def match(conn, "PATCH", ["users", user_id, "comments", id])
  def match(conn, "DELETE",["users", user_id, "comments", id])
end

Phoenix router 使用 get,post,resources 巨集將 HTTP DSL 轉化成一系列的 match/3 函式定義。我選擇使用巨集來實現 Phoenix 的 router,是經過反覆權衡的,routing DSL 不光是提供了一套高階的 API 用來路由 HTTP 請求,它還有效地消除了一大堆需要手工編寫的模板程式碼。這樣做的代價是程式碼生成部分的程式會比較複雜,可好處是用巨集編寫程式碼太清晰漂亮了。

選擇巨集一定要在便捷性和複雜性間做好平衡。在 Phoenix 中的巨集我就力求採用最簡潔的方法。呼叫者會相信程式碼是最簡的最快的。這是你同你的程式碼呼叫者之間的隱含約定。

最重要的超程式設計原則就是一定要保持簡單。你要小心的在保持程式碼威力,易於使用,以及內部實現的複雜性之間走鋼絲,力求保持平衡。接下來你會看到如何保持簡單,以及那些危害要極力迴避。

避開常見陷阱

工具越鋒利,越容易傷到自己。在我的 Elixir 程式設計生涯中,我時常會回想起一些會對程式碼帶來巨大傷害的疏忽,其實很容易避免。讓我們看看有何辦法讓你不要陷入到自己編織的程式碼生成的陷阱中。

能用 import 就不要用 use

新鮮出爐的超程式設計新手一個最為常見的錯誤就是將 use 當成一種從其他模組 mix in 混入函式的方法。這種想法可能來自於其他語言,在其他語言中可以通過mix-in的方式將方法和函式從一個模組匯入到另一個模組中,他們也認為理應如此。在 Elixir 中,看上去似乎還真像那麼回事,但這是陷阱啊。

這裡有一個 StringTransforms 模組,定義了一大堆字串轉換函式。你可能會期望在模組間共享這些函式,因此可能會如下編碼:

defmodule StringTransforms do
  defmacro __using__(_opts) do
    quote do
      def title_case(str) do
        str
        |> String.split(" ")
        |> Enum.map(fn <<first::utf8, rest::binary>> ->
          String.upcase(List.to_string([first])) <> rest
        end)
        |> Enum.join(" ")
      end

      def dash_case(str) do
        str
        |> String.downcase
        |> String.replace(~r/[^\w]/, "-")
      end
      # ... hundreds of more lines of string transform functions
    end
  end
end

defmodule User do
  use StringTransforms

  def friendly_id(user) do
    dash_case(user.name)
  end
end

iex> User.friendly_id(%{name: "Elixir Lang"})
"elixir-lang

第2行,通過 using 巨集定義來容納 title_case 以及 dash_case 等字串轉換函式的 quoted 表示式。在第24行,User 模組中通過 use StringTransforms 將這些函式注入到當前上下文。第27行,在 friendly_id 函式內部就可以呼叫 dash_case 了。執行正常,但錯的離譜。

這裡,我們濫用了 use 來將 title_case, dash_case 等函式注入到另一個函式。它確實能工作,但我們根本不需要注入程式碼。Elixir 的 import 已經提供了所有的功能。我們刪除所有程式碼生成部分,重構 StringTransforms:

defmodule StringTransforms do
  def title_case(str) do
    str
    |> String.split(" ")
    |> Enum.map(fn <<first::utf8, rest::binary>> ->
      String.upcase(List.to_string([first])) <> rest
    end)
    |> Enum.join(" ")
  end
  def dash_case(str) do
    str
    |> String.downcase
    |> String.replace(~r/[^\w]/, "-")
  end
  # ...
end
defmodule User do
  import StringTransforms
  def friendly_id(user) do
    dash_case(user.name)
  end
end

iex> User.friendly_id(%{name: "Elixir Lang"})
"elixir-lang"

我們刪除了 using 塊,在 User 模組中使用 import 來共享函式。import 提供了前一版本的全部功能,而我們只需要在 StringTransforms 模組中定義常規函式就行了。如果僅僅是為了混入函式功能,我們絕對不要使用 use 巨集。import 方式就可以達到這個目的,而且無需生成程式碼。即便是在確實需要用 use 生成程式碼的情況下,也應該控制好只注入必須的程式碼,其餘部分還是要採用 import 普通函式的方式。

避免注入過多的程式碼

很多人犯的一個常見錯誤就是讓程式碼生成做了太多太多的東西。你應該仔細衡量事物的兩面性,你應該知道使用巨集是為了解決問題。這個錯誤在於你可能會無限榨取 quote 程式碼塊,甚至往裡面注入了幾百行的程式碼。這會使你的程式碼碎片化,完全無法除錯。無論何時注入程式碼,你都應該儘可能地將任務轉派到呼叫者上下文的外部去執行。通過這種方式,你的程式庫程式碼封閉在你的程式庫中,只注入很小的一部分基礎程式碼,用來將呼叫者上下文外部的呼叫引入到程式庫中。

為便於理解,我們回想下在“是否選擇 DSL”一章中提到的 email 程式庫。儘管它不是一個很好的 DSL 樣板,我們還是假設下如何通過一個巨集擴充套件庫來實現它。這個程式庫需要將 send_email 函式注入到呼叫者的模組中,然後這個函式被定義成傳送各種不同型別的訊息。send_mail 函式會使用 email 使用者的配置資訊來連線郵件伺服器。我們隨時都會用到這個資訊,你首先必須在 use 程式碼塊中傳遞這個引數。

defmodule Emailer do
  defmacro __using__(config) do
    quote do
      def send_email(to, from, subject, body) do
        host = Dict.fetch!(unquote(config), :host)
        user = Dict.fetch!(unquote(config), :username)
        pass = Dict.fetch!(unquote(config), :password)

        :gen_smtp_client.send({to, [from], subject}, [
          relay: host,
          username: user,
          password: pass
        ])
      end
    end
  end
end

在一個客戶端的 MyMailer 模組中我們如何使用這個庫呢:

defmodule MyMailer do
  use Emailer, username: "myusername",
               password: "mypassword",
               host: "smtp.example.com"

  def send_welcome_email(user) do
    send_email user.email, "[email protected]", "Welcome!", """
    Welcome aboard! Thanks for signing up...
    """
  end
end

初看上去,程式碼還不錯。你將 send_mail 注入到了呼叫者的模組中,內容不過是幾行手工程式碼。但是你又掉到陷阱裡了。這裡的問題是,你將配置檔案的註冊資訊儲存下來,而且直接在注入程式碼中將明細資訊發給了一個 email。這會導致你的實現細節都洩露給了外部呼叫你模組的所有人。這會使你的程式更難測試。

讓我們改寫庫,在呼叫者上下文以外轉派任務實現郵件傳送:

defmodule Emailer do
  defmacro __using__(config) do
    quote do
      def send_email(to, from, subject, body) do
        Emailer.send_email(unquote(config), to, from, subect, body)
      end
    end
  end
  def send_email(config, to, from, subject, body) do
    host = Dict.fetch!(config, :host)
    user = Dict.fetch!(config, :username)
    pass = Dict.fetch!(config, :password)

    :gen_smtp_client.send({to, [from], subject}, [
      relay: host,
      username: user,
      password: pass
    ])
  end
end

注意一下我們是如何推送所有的業務邏輯,以及又是如何將傳送郵件的任務發回給 Emailer 模組的?注入的 send_email/4 函式立即將任務轉派出去,並將呼叫者的配置作為引數單獨傳給它。這裡微妙的差別就在於我們的實現變成了在庫模組中定義的普通函式。你的對外 API 完全不變,但是現在你完全可以直接測試你的 Emailer.send_email/5 函數了。另外一個好處就是現在堆疊跟蹤只會跟蹤到你的 Emailer 模組,而不會是跟蹤到呼叫者模組中那堆讓人費解的生成程式碼。

這個修改也讓庫的呼叫更直接,無需在另外一個模組中使用了。這樣對測試非常友好,對僅僅只是想快速傳送個郵件的呼叫者也更為友好。現在傳送郵件簡單到,無非就是呼叫 Emailer.send_email 函式而已:

[username: "myusername", password: "mypassword", host: "smtp.example.com"]
|> Emailer.send_email("[email protected]", "[email protected]", "Hi!", "")

只要你在生成程式碼時堅持採用這個任務分發的思想,你的程式碼就會乾淨整潔,易於測試,除錯也更友好。

Kernel.SpecialForms:瞭解身處的環境以及限制

Elixir 語言是一種超級容易擴充套件的語言,即便如此它也有些特例絕對不容觸碰。瞭解這些特例是什麼,它們存在的意義將更有助於你在擴充套件語言時劃清你的界限。這也有助於你對程式碼在何處執行的跟蹤。

Kernel.SpecialForms 模組定義了一組結構體,絕對不能修改。它們組成了語言本身的基本構成,以及包含了一些巨集如 alias,case,{},<<>>等等。SpecialForms 模組還包含了一系列偽變數,其包含了編譯時的環境資訊。有一些變數你可能已經很熟悉了,比如 MODULEDIR。下面這些 SpecialForms 定義的偽變數不能被重繫結或是覆蓋:

  • __ENV__:返回一個 Macro.ENV 結構體,包含當前環境資訊
  • __MODULE__:返回當前模組名稱,型別為 atom,等價於 __ENV__.module
  • __DIR__:返回當前目錄
  • __CALLER__:返回呼叫者環境資訊,型別為 Macro.ENV 結構體

__ENV__變數在任何時候都可以訪問,__CALLER__只能在巨集內部呼叫,用來返回呼叫者環境。這些變數一般都在超程式設計時使用。前面幾張學過的__before_compile__鉤子,就只接受__ENV__結構作為唯一引數。在註冊鉤子時可以提供重要的環境資訊。

我們在 iex 裡面看看__ENV__結構,以及它包含的各種資訊:

iex(1)> __ENV__.file
"iex"

iex(2)> __ENV__.line
2

iex(3)> __ENV__.vars
[]

iex(4)> name = "Elixir"
"Elixir"

iex(5)> version = "~> 1.0"
"~> 1.0"

iex(6)> __ENV__.vars
[name: nil, version: nil]

iex(7)> binding
[name: "Elixir", version: "~> 1.0"]

在 iex 裡面你都能看到,Elixir會跟蹤環境所在檔案以及行號。在程式程式碼中,這裡就會是程式碼所在的檔案及行號。這在堆疊跟蹤以及一些特定的錯誤處理中很有用,因為你可以在程式的任何地方訪問呼叫者的環境資訊。你還會看到Elixir跟蹤當前環境的繫結變數,這通過__ENV__.vars訪問。要注意這不同於 binding 巨集,這個巨集是返回所有的繫結變數跟他們的值,而 vars 是跟蹤變數上下文。這是因為變數值在執行時是動態變化的,因此環境變數只能跟蹤哪個變數被綁定了,以及在那繫結的。

Elixir 中還有一小部分是不能觸碰的,只是一些特殊格式以及環境上下文。面對這些不斷延伸的領域,我們已經能看到種種陷阱埋伏。但作為一個超程式設計的有為青年,我們應該知道何時該盡己所能,將超程式設計推向極限。

扭曲現有規則

例行官方警告完畢。我們回想下我們說過 Elixir 將程式世界變成一個遊樂場。規則就是用來打破的。因此讓我們來闖闖灰色地帶,在 Elixir 中有時濫用巨集是很值得的,下面我們來嘗試下扭曲 Elixir 的語法。

濫用有效的 Elixir 語法

重寫 AST 來改變當前 Elixir 表示式的含義,對大多數人可能是夢魘。但在某些情況下,這是一個非常強大的工具。想想 Elixir 的 Ecto 庫,這是一個數據庫包裹器,集成了一套查詢語言。讓我們看看 Ecto 查詢長啥樣,以及它是如何濫用 Elixir 語法。你無需瞭解 Ecto;只需要能夠領會下面查詢語句的意思就行:

query = from user in User,
      where: user.age > 21 and user.enrolled == true,
    select: user

Ecto 在內部會將上述完全有效的 Elixir 表示式轉化成一個 SQL 字串。他濫用了 in,and,==,以及 > 用來構建 SQL 表示式,這些東西原本是 Elixir 的有效表示式哦。這是對巨集極其優雅的運用。Ecto 讓你能夠用 Elixir 原生語法構建查詢,能夠對 SQL 中的繫結變數進行適當的型別轉換。而其他的語言中,如果要整合一套查詢語言,就必須在語言只上另外構建一套完整的新語法。使用 Elixir,我們可以用巨集來改變常規 Elixir 程式碼,使其能夠很好的表現 SQL。

Ecto 是個非常龐大的專案,可以另外寫本書了,但我們要探討的是我們可以如何編寫類似的庫。我們來分析下上面的查詢語句 quoted 後長啥樣。在 iex 中嘗試下不同形式,琢磨下我們可以用前面學到的哪些 AST 技巧來實現它,比如 Macro.postwalk。

iex> quote do
...>   from user in User,
...>     where: user.age > 21 and user.enrolled == true,
...>    select: user
...> end
{:from, [],
  [{:in, [context: Elixir, import: Kernel],
    [{:user, [], Elixir}, {:__aliases__, [alias: false], [:User]}]},
  [where: {:and, [context: Elixir, import: Kernel],
    [{:>, [context: Elixir, import: Kernel],
   [{{:., [], [{:user, [], Elixir}, :age]}, [], []}, 21]},
{:==, [context: Elixir, import: Kernel],
    [{{:., [], [{:user, [], Elixir}, :enrolled]}, [], []}, true]}]},
  select: {:user, [], Elixir}]]}

看看上面 Ecto 查詢的 AST,我們知道利用巨集可以濫用 Elixir 語法,很有趣,也很有用。要匹配 AST 中不同的操作符,如 :in,:==,等等,我們需要在編譯時將對應片段解析成 SQL 表示式。巨集允許將任何有效的 Elixir 表示式轉換成你想要的形式。你要慎重使用這項技術,因為賦予語言不同的含義將導致在不同語境下的理解上的困惑。可對於 Ecto 之類的庫,它不需要藉助任何語言外部的東西,僅僅在 Elixir 上構建了一個新層,這種技術正是威力巨大。

效能優化

另一個你需要扭曲超程式設計規則的灰色地帶就是為了效能優化。巨集能夠讓你在執行時優化程式碼,有時候這需要注入海量的程式碼,比通常情況下多得多。我們在前面幾章構建 Translator 庫時就這樣幹過。我們通過在呼叫者模組中注入大量的函式頭,用編譯時的字串拼接,替代了執行時的正則匹配,從而優化了字串解析。為了快速執行,我們不得不生成了海量程式碼,但為了效能優化,引入更多的複雜性也完全值得。如果你使用前面學到的技術組織超程式設計,你也能夠寫出快速,清晰,易維護的程式碼。

日積月累

我已經見識過一些非常聰明的想法,它們採用了一些非常不負責任的巨集程式碼,我永遠不會把他們用到我的產品當中。最好的學習就是實踐。不要被本書貫穿始終的條條框框還有這一章描述的嚴重後果嚇到你,放開手腳大膽地探索 Elixir 的巨集系統。編寫一些任性的程式碼,試驗它,從中獲得樂趣。用你得到的知識來啟迪你在生產環境中做出設計上的決斷。

各種試驗性的想法可能是無窮無盡的,我這有幾個瘋狂的想法刺激一下你。還記得任意 quoted 的表示式都是有效的 Elixir 程式碼嗎?你能利用這一事實編寫一個自然語言測試框架嗎?

下面是有效的 Elixir 程式碼:

the answer should be between 3 and 5
the list should contain 10
the user name should resemble "Max"

你不信能實現?在 iex 裡面試試 quote 這些表示式:

iex> quote do
...> the answer should be between 3 and 5
...> the list should contain 10
...> the user name should resemble "Max"
...> end |> Macro.to_string |> IO.puts
(
the(answer(should(be(between(3 and 5)))))
the(list(should(contain(10))))
the(user(name(should(resemble("Max")))))
)
:ok

你可以解析這些自然語言宣告的 AST 格式,因此可以在背後悄悄地將其轉換成斷言。你能寫出來嗎?也許不能。但你能從中學到更多的 Elixir 巨集系統的知識,以及更多的樂趣嗎?絕對能。

構建未來

下一步幹什麼呢?是時候回過頭來,構建下 Elixir 軟體開發的未來了!現在你已經有足夠的技能來錘鍊語言,編寫強力的工具同世界分享。Elixir 和 Erlang 子系統已足夠成熟(The programming landscape is ripe for disruption by the power that Elixir and the Erlang ecosystem bring to the table。看不懂就亂翻了)。走出去解決真正感興趣的問題,不過一定要記得玩的開心(have fun)。