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"}
]
end
The 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 Objext
should 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 reusableterms
along side your interface module. Inside theseterms
, you can use your familiarExUnit.Case.describe/2
andExUnit.Case.test/3
macro to define tests. Then you can reuse theseterms
in each implementation module's test. -
Mockable
Once you have an interface defined, you can use
Objext.Mock
to 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
Queue
module. 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
buildo
macro to return a new objext (with the same "class"), and use thematcho
macro 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
Queue
implementation. You can define the interfaces and the implementations in the sameQueue
module. 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
terms
for theQueue
interface. So any newQueue
implementations 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
Queue
interface: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
*.Protocol
and*.Object
under 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)