基于 Function clause 的子模块拆分设计

前言

有过 Erlang/Elixir 的经验(或其它某些函数式语言)的开发者应该知道,通过对函数子句参数上的模式匹配能很好的拆分逻辑,且有一定的解耦作用。包括大量的内置模块都是如此设计的,例如 GenServer、Agent 等。
在需要让不同的参数(值)应用到不同的逻辑分支时,它令我们不需要像其它语言那样在一个函数内写一个庞大的 switch 代码块,而是以更加“功能单元模块化”的方式路由到不同的函数中处理。这有些类似于静态语言中的函数重载,当然它比函数重载强大得多得多,因为函数的路由条件基于模式匹配。

即便如此,基于模式匹配的函数子句并不能很好的拆分庞大条件分支下的代码,反而因为单纯的横向扩展了大量函数而造成模块膨胀。本文要讨论的便是解决这个现象。

函数子句

在 Erlang 中有明确的 Function clause 这个概念,指的是在同一个模块内具有相同名称的函数声明,其每一个函数的参数都将作为“模式”。它能导致调用处根据参数动态匹配而发起不同位置的函数子句调用,也就是说不同的参数可能进入不同的函数处理。

函数子句例子

either_or_both(true, _) ->
    true;
either_or_both(_, true) ->
    true;
either_or_both(false, false) ->
    false.

上面这段 Erlang 代码定义了三个函数子句,它们都是 either_or_both。如果你第一个参数传递 true,会调用第一个子句;如果你第一个参数不是 true 但第二个参数是 true 那么会调用第二个子句;如果是两个 false 则会进入第三个子句处理。

就像我第一段话提到的,它们就像一个 switch 代码块,通过不同的参数进入不同的逻辑分支。当然这种做法比 switch 代码块要优雅得多:一方面是模式匹配比传统语言中的 switch 等值匹配要强大数倍,另一方面方面是将逻辑拆分成函数是对功能单元的模块化,switch 则会造成糟糕的代码嵌套。

在 Elixir 没有明确提到“函数子句”这个词(至少我没有从文档中看到),但是 Elixir 和 Erlang 很多方面是一脉相承的,自然也具备这个相同特性。

注意:在 Erlang 中函数子句需要定义在一起,用分号结束(最后一个函数子句用句号)。但是在 Elixir 中没有这些限制,不过仍然建议将函数子句定义在一起,即中间不要插其它函数或内容的定义。

如果你基础没那么差的话,应该明白了,OTP 中的很多行为模式都需要用函数子句。例如实现一个 GenServer,你可以添加无数个 handle_cast 函数用于处理消息,通常将第一个原子参数当做最主要的匹配数据。

指令消息

假设我们要做一个机器人,它服务于某些 IM 平台,需要根据用户的消息(即输入)来执行不同的行为。这其实是一个很常见的需求,Telegram/Discord 等平台都开放这种用法,甚至在 QQ 或微信上也能通过破解通信协议的方式做出这类功能的机器人程序(这不重要)。

我们定义一个消息消费方,然后在接收消息的函数中处理或者忽略

defmodule Customer do
  def receive_message(msg) do
    case msg do
      <<"cmd", ".", data::binary>> ->
        fill_invocation = String.split(data, " ")
        flag = hd(fill_invocation)
        args = tl(fill_invocation)

        # 准备调用函数处理指令
      _ ->
        :ignore
    end
  end
end

在上面的代码中,receive_message 函数匹配了消息前缀,如果是 cmd. 开头则被认为是一个指令,接着获取到 flag(功能名称) 和 args(功能的具体参数)进而准备分发处理。

我们定义一个 handle_flag 函数,并在注释处补充上具体的函数调用代码

  # ……

    # 准备调用函数处理指令
    flag
    |> String.to_atom()
    |> handle_flag(args)

  # ……

  def handle_flag(:help, _) do
    IO.puts("Here is help")
  end


  # ……

我们将 flag 转换为原子,然后调用 handle_flag 函数。在 handle_flag 函数的定义中,我们匹配第一个参数为 :help,忽略第二个参数(按照正常逻辑,help 指令无需参数)。

我们模拟发送消息

Customer.receive_message("cmd.help")

成功的进入了 handle_flag 函数处理(输出 Here is help 到屏幕)。

如果我们将 help 改为别的,结果不言而喻:出现了 “no function clause matching” 的错误,因为我们仅仅定义了一个 handle_flag 子句,不匹配的都会由于匹配不到函数而调用失败。

PS:所以正确来讲你应该定义一个将参数都用占位符忽略的 handle_flag 函数,但这不是本文的重点。

接着我们又定义了一个处理指令 send 的函数,并接受一个参数

  def handle_flag(:send, [username | _]) do
    IO.puts("Send message to #{username}")
  end

调用这个指令

Customer.receive_message("cmd.send 小明")

输出结果

Send message to 小明

我们发现,无论我们怎么新增功能指令,只需要扩展 handle_flag 函数即可,无需改动消息的指令路由代码块(也就是 receive_message 函数内部)。

基于函数子句来路由消息指令,实现了无侵入的功能扩展,这确实是很优雅的。但是,就像我第一句话提到的,随着函数子句数量的增加 Customer 模块迟早会严重膨胀,我们急需一种将函数子句迁移到其它模块的手段,并且最好是一个子句一个模块。显然,常规来讲这无法做到,因为在同一个模块中存在的才是函数子句,不同模块的只能通过手动指定调用。

拆分模块

更新中……