惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Hacker News - Newest:
Hacker News - Newest: "LLM"
Webroot Blog
Webroot Blog
S
Security @ Cisco Blogs
H
Heimdal Security Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
www.infosecurity-magazine.com
www.infosecurity-magazine.com
N
News and Events Feed by Topic
H
Hacker News: Front Page
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Application and Cybersecurity Blog
Application and Cybersecurity Blog
SecWiki News
SecWiki News
N
News | PayPal Newsroom
T
Tor Project blog
W
WeLiveSecurity
A
Arctic Wolf
Security Archives - TechRepublic
Security Archives - TechRepublic
S
Secure Thoughts
月光博客
月光博客
AWS News Blog
AWS News Blog
D
Docker
C
CERT Recently Published Vulnerability Notes
MyScale Blog
MyScale Blog
Google Online Security Blog
Google Online Security Blog
大猫的无限游戏
大猫的无限游戏
T
The Blog of Author Tim Ferriss
I
InfoQ
人人都是产品经理
人人都是产品经理
Recent Announcements
Recent Announcements
Google DeepMind News
Google DeepMind News
Hacker News: Ask HN
Hacker News: Ask HN
Blog — PlanetScale
Blog — PlanetScale
博客园 - 【当耐特】
Engineering at Meta
Engineering at Meta
Stack Overflow Blog
Stack Overflow Blog
Recorded Future
Recorded Future
罗磊的独立博客
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
T
The Exploit Database - CXSecurity.com
D
DataBreaches.Net
S
Security Affairs
WordPress大学
WordPress大学
T
Threatpost
Microsoft Security Blog
Microsoft Security Blog
V
Vulnerabilities – Threatpost
The Hacker News
The Hacker News
S
SegmentFault 最新的问题
B
Blog RSS Feed
Project Zero
Project Zero
P
Proofpoint News Feed

Yiming Chen

How "let it fail" leads to simpler code - Yiming Chen 我的 2021 - Yiming Chen How to Speak by Patrick Winston - Yiming Chen What I learned from implementing Combinators in 3 Elixir patterns - Yiming Chen Clippings of 2020 June & July - Yiming Chen CSSKatas: A Better Way to Sharpen Your CSS Skills - Yiming Chen Making Audit Logs on Hex.pm Public - Yiming Chen Clippings of 2020 May - Yiming Chen Book Review: The Goal - A Process of Ongoing Improvement - Yiming Chen
Stop using Behaviour to define interfaces, use Protocol! - Yiming Chen
Yiming Chen · 2021-07-19 · via Yiming Chen

I think Elixir developers (me included) often misuse Behaviour to define common interfaces.

For example, let's say we have a Weather interface:

defmodule Weather do
  @callback temperature(Location.t()) :: {:ok, Temperature.t()}
end

defmodule Weather.SourceA do
  @behaviour Weather

  @impl Weather
  def temperature(location), do: ...
end

Mox.defmock(Weather.Mock, for: Weather)

Then we need to inject a module into the caller function in order to achieve polymorphism:

def controller(weather_mod) do
  location = ...
  temp = weather_mod.temperature(location)
  ...
end

Something is off

This is a valid use case for Behaviour, but I always feel something is off here:

  1. The name behaviour / callback confused me a lot when I first learned about this design pattern in Elixir.
    Coming from a Object-Oriented background, what I want is just interface and implementation.
    > I can understand behaviour as a different way of saying interface.
    > But what's this callback non-sense?
  2. We need to pass the module around as variables in our application code.
    This adds more difficulties to tools like mix xref to analyze our code.
  3. The mocks defined by Mox are basically modules that have some global state.
    Which means that:
    1. We need to use global mock sometimes, or spend some efforts to allow a child process to use the private mock.
    2. We can't have 2 mocks with different expect-s or stub-s for the same Beahviour.
      For example, to test the following chain/2 function, we may need to call Mox.defmock twice, which is so weird that I guess no body would actually do it.

      defmodule Weather.Chainable do
        def chain(source1, source2) do
          fn location ->
            with {:ok, temp} <- source1.temperature(location) do
              {:ok, temp}
            else
              {:error, _} ->
                source2.temperature(location)
            end
          end
        end
      end
      
      Mox.defmock(Weather.Mock1, for: Weather)
      Mox.defmock(Weather.Mock2, for: Weather)
      
  4. A common pattern is to delegate function calls from behaviour module to callback modules:

    defmodule Weather do
      @callback temperature(Location.t()) :: {:ok, Temperature.t()}
    
      @weather_mod Application.get_env(:my_app, :weather_mod)
    
      defdelegate temperature(location), to: @weather_mod
    end
    

    And this pattern is so common that we have a library for it:
    https://github.com/sascha-wolf/knigge

Actually, I'm not the only one who feels this oddness:

This use is almost entirely the inverse of how I usually see behaviors being used in Elixir.

Here, they tend to define a public interface, and are often used to separate out pure code from impure code: by defining an interface for a client to a third-party system, for example.

— Quinn Wilton (@wilton_quinn) June 24, 2021

Pure interfaces (Protocol) vs. Impure interfaces (Behaviour)

Inspired by the thread above, I think I've found the reason why defining common interfaces via Behaviour feels off to me:
Because the whole Behaviour mechanism from Erlang/OTP was designed to define impure interfaces, rather than pure interfaces.

Pure interfaces
Pure interface function don't have any logic inside it.
All it does is to dispatch the function call to an implementation module.
Like the Inspect.inspect/2 protocol function.
Impure interfaces

Impure interface function not only dispatches the function call, but also adds more features upon this function call.

The best example would be GenServer:
First of all, GenServer is an interface for sure.
We can think of callbacks like handle_info, handle_call, handle_cast as common interfaces,
i.e. all the callback modules need to implement these interfaces.
And GenServer would do all the dynamic dispatching for us.

But are we using GenServer just for polymorphism?
I guess every Elixir/Erlang developer would answer "NO".
The real power of GenServer is complex async logic encapsulated inside GenServer.call, GenServer.cast, etc..
Because these logic are so common, they get extracted as shared functions like GenServer.call.
Every callback module we write leverages these common logic.
So that we don't need to care how to handle the async mess, and can just write our business logic in a procedural way.

Note here "impure" is just a neutral adjective.
(I really hope I could've found a better adjective-pair than pure/impure.)
I don't mean that impure interfaces are worse than pure interfaces.
(As impure functions are not worse or better than pure functions.)
From the GenServer example, I even believe that impure interfaces are way better than pure interfaces.
Because every time we define a callback module, we are reusing the logic encapsulated in impure interfaces.
This means that every line of code we add to the impure interface would be extremely cost-effective, because the effect of that code is amplified by the number of callback modules we have.

So I believe that defining common interface with Behaviour is just scratching the surface of the power of Behaviour.
We are misusing a powerful design pattern for defining impure interfaces, and we just use it to define pure interfaces.
What we really should do is to extract as many common logic to the behaviour module, in a way that the callback modules can be deadly simple.
(Based on this argument, defdelegate from a behaviour module to a callback module is definitely an anti-pattern.)

Finally, if you really need to define a common interface, then I believe the right way to do it in Elixir is Protocol.
As it was explained in the official doc:

A protocol specifies an API that should be defined by its implementations.

Then you may find Promox useful as it helps you define mocks for Protocols easily in your tests :).

Objext: From Protocol to Behaviour

Now you may notice a gap between Protocol and Behaviour:
What if I start with a Protocol that defines an interface, but then I want to extract more common logic to the interface functions, to make them more powerful like GenServer.call?

The most common solution in Elixir is to define another module (Enum) besides the Protocol module (Enumerable).
But I find it hard to name the Protocol module already, not to say I need to name 2 modules.
(Naming is hard in software development, right?)1

Besides the difficulty of naming 2 modules, I still believe a separate module should be unnecessary.
To me, Protocol and Behaviour are both ways to define interfaces.
The only difference is the amount of common logic at the interface level:

  • Protocol has no common logic, it's just pure interfaces
  • Behaviour has a ton of common logic, we should be able to grow pure interfaces into this kind of complex interfaces.

Given this reason, I made Objext, which allows you to define interfaces in Elixir easily.
(plus some other goodies like fully-encapsulated data structure and Promox-like mock features.)

Takeaways

If anything you can take away from this blog post, I hope is this:
be aware of what kind of interface you are going to define,

  1. use Protocol for pure interfaces
  2. use Behaviour for impure interfaces
  3. grow pure interfaces to impure interfaces when possible

These tips can be achieved with Protocol and Behaviour with some dedicated discipline.
If you find that cumbersome, you may find Promox and Objext to be useful.
Please give them try and let me know how they compare to Mox and Protocol.