Objext helps you define fully-encapsulated data structures (so called "objexts"). Then Objext allows you to define a common interfaces for these "objexts". Objext will dynamically dispatch the interfaces function calls to implementation functions (just like Protocol does). Finally, these data structures and interfaces defined by Objext are fully compatible with existing Protocols and Behaviours.
The package can be installed by adding objext to your list of dependencies in mix.exs:
def deps do
[
{:objext, "~> 0.1"}
]
endThe docs can be found at https://hexdocs.pm/objext.
Objext is designed to help you define your Abstract Data Types, with an easy to use API. To achieve this objective, Objext keeps the following goals in mind:
-
Encapsulated
The data structure defined with
use Objextshould be 100% opaque to the other modules. I hope this feature can guide you to design data structures as Abstract Data Types (ADTs) in Elixir. ADTs are defined solely by what public functions can operate on them and what would happen/return when calling these functions. ADTs' internal structures are just implementation details and can be refactored in one place. -
Incremental
With Objext, you can start designing your system from outside in. Solidify your public API at interface level first, then add implementation modules.
You can also design your system from ground up. Incrementally refactor a normal Elixir module to an interface module plus an implementation module. And then add more implementation modules.
See the Example section below for more details.
-
Easy-to-test
An ADT is not defined by its internal data structure but the behaviours of its public functions (
terms). So we need to test if an implementation follows theseterms. Objext lets you define reusabletermsalong side your interface module. Inside theseterms, you can use your familiarExUnit.Case.describe/2andExUnit.Case.test/3macro to define tests. Then you can reuse thesetermsin each implementation module's test. -
Mockable
Once you have an interface defined, you can use
Objext.Mockto create mocks for this interface. So you can simplify your test for those code that depends on this interface. -
Compatible with existing tooling/ecosystem
Elixir and Erlang ecosystem have already provided us many powerful tools. Elixir compiler emits warnings if you forget to implement a callback function. Dialyzer emits warnings if you peek into an opaque type. So Objext won't reinvent these wheels again. Instead, Objext will leverage these existing tools to provide the best developer experience.
Plus, Objext allows you to define implementations for existing Protocols (e.g.
Inspect) and Behaviours (e.g.Access). So you don't need to migrate from Protocol to Objext overnight.
- At first, you may only have one
Queuemodule. And you can just play with the public APIs until they are stable.defmodule Queue do def new(), do: [] def enqueue(queue, item), do: queue ++ [item] def dequeue([]), do: {:empty, []} def dequeue([item | rest]), do: {item, rest} end
- When you feel the APIs are quite stable, you can converting it to an Objext module so it's now opaque to the other modules.
The cost of this encapsulation is that you need to use the
buildomacro to return a new objext (with the same "class"), and use thematchomacro to match the internal state of this "class" of objexts.defmodule Queue do use Objext def new(), do: buildo([]) def enqueue(matcho(queue), item), do: buildo(queue ++ [item]) def dequeue(matcho([]) = this), do: {:empty, this} def dequeue(matcho([item | rest])), do: {item, buildo(rest)} end
- Then you may need to introduce a new
Queueimplementation. You can define the interfaces and the implementations in the sameQueuemodule. And all the existing (client) code should just work as expected.defmodule Queue do use Objext, implements: [Queue] use Objext.Interface definterfaces do def enqueue(queue, item) def dequeue(queue) end def new(), do: buildo([]) def enqueue(matcho(queue), item), do: buildo(queue ++ [item]) def dequeue(matcho([]) = this), do: {:empty, this} def dequeue(matcho([item | rest])), do: {item, buildo(rest)} end
- And then you can gradually extracting the old implementation to a separated module.
defmodule Queue do use Objext.Interface definterfaces do def enqueue(queue, item) def dequeue(queue) end end defmodule ListQueue do use Objext, implements: [Queue] def new(), do: buildo([]) def enqueue(matcho(queue), item), do: buildo(queue ++ [item]) def dequeue(matcho([]) = this), do: {:empty, this} def dequeue(matcho([item | rest])), do: {item, buildo(rest)} end
- Meanwhile, you may reuse the existing test cases to define
termsfor theQueueinterface. So any newQueueimplementations can be assured to pass the same test suites.defmodule Queue do use Objext.Interface definterfaces do def enqueue(queue, item) def dequeue(queue) end defterms subjects: [:queue] do describe "enqueue |> dequeue" do test "first in first out" do q1 = queue() |> Queue.enqueue(1) |> Queue.enqueue(2) assert {1, q2} = Queue.dequeue(q1) assert {2, q3} = Queue.dequeue(q2) assert {:empty, ^q3} = Queue.dequeue(q3) end end end end defmodule ListQueueTest do use ExUnit.Case, async: true use Objext.Case, for: Queue, subjects: [queue: ListQueue.new()] end
- Finally, you can introduce a new module that implements the
Queueinterface:defmodule ErlQueue do use Objext, implements: [Queue] def new() do buildo(:queue.new()) end def enqueue(matcho(state), item) do buildo(:queue.in(item, state)) end def dequeue(matcho(state)) do case :queue.out(state) do {{:value, item}, new_state} -> {item, buildo(new_state)} {:empty, new_state} -> {:empty, buildo(new_state)} end end end defmodule ErlQueueTest do use ExUnit.Case, async: true use Objext.Case, for: Queue, subjects: [queue: ErlQueue.new()] end
- Put Internal modules like
*.Protocoland*.Objectunder Objext namespace (avoid polluting user namespaces) - Boundary-like compile time check for encapsulation violations
- Eliminate the needs of delegating to protocols (simpler internal structure, better performance)