Behaviour / Protocol / Macro:把多态写进代码结构
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,不知道实现类。
Oban.Worker、GenServer、Plug 都是 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>"
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 的”宏”,而是编译器钩子。
quote 默认会卫生化变量名(避免和外层冲突)。如果需要把外层变量注入内层,用 var!(name);需要按变量注入整个模式时,用 unquote(var)。永远不要在宏里读 module attribute 然后写 Module.put_attribute,会让 AST 错位。七、Macro 的真实用法
- 测试断言:
assert foo == bar是一个宏,编译时把==替换成结构化比较。 - 路由 DSL:
get "/users/:id"编译成Plug.Router的 match 子句。 - 配置 DSL:
config :my_app, :foo, [...]把 KV 写入 application env。 - Behaviour helper:
use 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 之后没人能读懂,包括你自己。
十、测验
Behaviour 的核心作用是?
Protocol 分发的依据是?
@impl true 的主要好处是?
下列哪种情况更应该用 Behaviour 而不是 Protocol?
Macro 主要发生在哪一期?
什么时候适合抽出宏?
quote 默认会做什么?
defimpl Protocol, for: Any 的潜在问题是?
use Oban.Worker 同时做了哪些事?
哪种机制适合做"插件式后端选择"?
**下一步:**Behaviour / Protocol 是一切”插件系统”和”适配器”的底层机制。下一课把它们和运行时状态结合起来:GenServer + ETS + Code.compile_file 做热重载。