Learning
VOL. XIII · NO. 22 · Elixir · 01 JAN 1970

Behaviour / Protocol / Macro:把多态写进代码结构

Elixir 编程 · 01 JAN 1970 · 8 min read · 1,487 words
· · ·

Elixir 是动态类型语言,但并不意味着没有"接口"。Behaviour、Protocol 和 Macro 是三种把多态写在结构里、让代码自我描述的工具。

本课目标
学完后,你应该能用 @callback 写一个 Behaviour,让多个模块共享契约;用 defprotocol/defimpl 做鸭子类型分发;用 defmacro 写一个能减少样板代码的 DSL;知道这三者在"什么时候用哪个"。

一、为什么需要”接口”

Elixir 不要求模块声明接口,但它提供三种机制来描述”这一类模块必须实现什么”:

  • Behaviour:静态回调合约,类似 Java 接口。多个模块必须实现相同的 @callback
  • Protocol:动态分发,按数据类型选择实现,类似 Go 的 interface。
  • Macro:编译期生成代码,把样板逻辑抽出来。

三者的本质区别:

工具时机分发依据
Behaviour编译期模块实现合约
Protocol运行期值的第一个参数类型
Macro编译期代码 AST

二、Behaviour:编译期合约

Behaviour 定义”必须实现哪些回调”。例如抽象一个存储后端:

defmodule MyApp.Storage do
  @callback put(key :: term(), value :: term()) :: :ok | {:error, term()}
  @callback get(key :: term()) :: {:ok, term()} | {:error, :not_found}

  @optional_callbacks get: 1
end

实现一个 PostgreSQL 版本:

defmodule MyApp.Storage.Postgres do
  @behaviour MyApp.Storage

  @impl true
  def put(key, value), do: Repo.insert(%Kv{key: key, value: value})

  @impl true
  def get(key) do
    case Repo.get_by(Kv, key: key) do
      nil -> {:error, :not_found}
      rec -> {:ok, rec.value}
    end
  end
end

@impl true 会让编译器在回调签名不一致时报警。编译期合约的好处:

  • 行为变更时,编译期直接报错。
  • IDE / Dialyzer 能基于 @callback 做静态分析。
  • 文档明确:调用方只依赖 behaviour,不知道实现类。
真实项目风格
在生产代码里,Behaviour 通常承担"插件接口"或"策略抽象"。比如 Oban.WorkerGenServerPlug 都是 Behaviour。

三、Behaviour 的运行时分发

Behaviour 本身不带分发逻辑,分发靠你自己写:

defmodule MyApp.Storage.Dispatcher do
  @backends [
    {"postgres", MyApp.Storage.Postgres},
    {"redis", MyApp.Storage.Redis}
  ]

  def call(backend, fun, args) do
    case Enum.find(@backends, fn {n, _} -> n == backend end) do
      {_, mod} -> apply(mod, fun, args)
      nil -> {:error, :unknown_backend}
    end
  end
end

这是 Elixir 里最常见的做法:Behaviour 定义合约,模块做配置(env / compile-time),调用方按配置选择实现。

四、Protocol:动态分发的多态

Protocol 的核心思想:“同一段调用,根据第一个参数的类型自动选实现”。

defprotocol MyApp.Renderable do
  def to_html(value)
end

defimpl MyApp.Renderable, for: BitString do
  def to_html(str), do: "<p>\#{escape(str)}</p>"
end

defimpl MyApp.Renderable, for: Integer do
  def to_html(int), do: "<span class=\"num\">\#{int}</span>"
end

defimpl MyApp.Renderable, for: List do
  def to_html(list), do: "<ul>\#{Enum.map_join(list, &MyApp.Renderable.to_html/1)}</ul>"
end

defimpl MyApp.Renderable, for: Any do
  def to_html(%{__struct__: mod} = struct), do: inspect(struct)
end

调用时不需要关心具体类型:

MyApp.Renderable.to_html("hello")   # "<p>hello</p>"
MyApp.Renderable.to_html(42)        # "<span class=\"num\">42</span>"
MyApp.Renderable.to_html([1, "x"])  # "<ul>...</ul>"
Protocol 性能
Protocol 在第一次调用时会查表并缓存,分发接近方法表查找,不要放在热点热路径的 record 字段上(每次新结构都会触发 fallback)。可以让 hot path 的结构显式 defimpl,绕开 Any。

五、什么时候用 Behaviour vs Protocol

  • Behaviour:抽象一组模块,多个模块实现同一接口,被配置选择。
  • Protocol:抽象一组数据类型,根据值的运行时类型分发。
  • 如果你的”多态”是对同一组函数让不同模块做不同事,用 Behaviour。
  • 如果你的”多态”是同一个函数对不同数据结构做不同事,用 Protocol。

六、Macro:编译期生成代码

Macro 看起来很吓人,但实战里大多数 Macro 只是”用 AST 模板拼出重复代码”。一个典型例子:用宏定义命令:

defmodule MyApp.CLI do
  defmacro command(name, opts \\ [], do: body) do
    quote do
      def unquote(:"handle_#{name}")(unquote_splicing(opts)), do: unquote(body)
    end
  end
end

defmodule MyApp.Commands do
  import MyApp.CLI

  command :start, [args] do
    IO.puts("starting with \#{inspect(args)}")
  end

  command :stop, [] do
    IO.puts("stopping")
  end
end

MyApp.Commands.handle_start(%{port: 8080})  # "starting with %{port: 8080}"

调用 command :start, [args] do ... end 时,宏把它展开成:

def handle_start(args) do
  IO.puts("starting with \#{inspect(args)}")
end

宏的本质:把样板代码用 AST 模板展开。它不是 OOP 的”宏”,而是编译器钩子。

Macro 的卫生
quote 默认会卫生化变量名(避免和外层冲突)。如果需要把外层变量注入内层,用 var!(name);需要按变量注入整个模式时,用 unquote(var)永远不要在宏里读 module attribute 然后写 Module.put_attribute,会让 AST 错位。

七、Macro 的真实用法

  • 测试断言assert foo == bar 是一个宏,编译时把 == 替换成结构化比较。
  • 路由 DSLget "/users/:id" 编译成 Plug.Router 的 match 子句。
  • 配置 DSLconfig :my_app, :foo, [...] 把 KV 写入 application env。
  • Behaviour helperuse Oban.Worker 同时做”注入代码 + 标记为 Behaviour 实现”。

你写的项目里,大多数”宏”都可以用普通函数或 use 解决。只有当你重复写同一段 AST 三次以上,才考虑抽出宏。

八、综合:一个完整的示例

下面是一个把 Behaviour、Protocol、Macro 都用上的简单例子:

# 1) Behaviour 定义"必须实现"
defmodule MyApp.Notifier do
  @callback send(channel :: atom(), message :: String.t()) :: :ok | {:error, term()}
end

# 2) 两个实现
defmodule MyApp.Notifier.Slack do
  @behaviour MyApp.Notifier
  @impl true
  def send(channel, message), do: Slack.post(channel, message)
end

defmodule MyApp.Notifier.Log do
  @behaviour MyApp.Notifier
  @impl true
  def send(_channel, message), do: Logger.info(message)
end

# 3) Protocol 让任意值可以"被格式化"
defprotocol MyApp.Formattable do
  def format(value)
end

defimpl MyApp.Formattable, for: Integer, do: def(format(v), do: "\#{v}")
defimpl MyApp.Formattable, for: Float,   do: def(format(v), do: :erlang.float_to_binary(v, decimals: 2))

# 4) Macro 给命令自动生成 docstring
defmodule MyApp.CLI.DSL do
  defmacro task(name, desc, do: body) do
    quote do
      @doc unquote(desc)
      def unquote(:"task_#{name}")(), do: unquote(body)
    end
  end
end

这三种机制一起用:Behaviour 描述谁必须实现什么,Protocol 描述任意类型能怎么用,Macro 描述怎么写更少代码

九、陷阱

  • Behaviour 不是分发器:它只定义合约,调用方要自己选择实现。
  • Protocol Any 太慢:在热点路径给结构体显式写 defimpl
  • Macro 不要写得像 OOP:它没有”运行时副作用”,展开发生在编译期。
  • @impl true 一定要写:否则改回调签名时编译器不会报错。
  • 避免 Macro 套 Macro:三层 Macro 之后没人能读懂,包括你自己。

十、测验

1

Behaviour 的核心作用是?

2

Protocol 分发的依据是?

3

@impl true 的主要好处是?

4

下列哪种情况更应该用 Behaviour 而不是 Protocol?

5

Macro 主要发生在哪一期?

6

什么时候适合抽出宏?

7

quote 默认会做什么?

8

defimpl Protocol, for: Any 的潜在问题是?

9

use Oban.Worker 同时做了哪些事?

10

哪种机制适合做"插件式后端选择"?

**下一步:**Behaviour / Protocol 是一切”插件系统”和”适配器”的底层机制。下一课把它们和运行时状态结合起来:GenServer + ETS + Code.compile_file 做热重载。