Immutable data is a foundation of abstraction - it localizes program computation. So immutable data makes concurrency easy & decreases bugs. All data in Erlang/Elixir is immutable. We are happy to use functional programming techniques, worrying about nothing on parallel programming.
To lookup in a nested data is easy. When there is a map below :
v = %{
x: [
{:ok, a: 1, b: 2},
{:ok, a: 3, b: 4},
]
}
Following codes are same.
1 = elem(Enum.at(v.x, 0), 1)[:a]
1 = elem(Enum.at(v[:x], 0), 1)[:a]
1 = (v.x |> Enum.at(0) |> elem(1))[:a]
1 = v |> Map.get(:x) |> Enum.at(0) |> elem(1) |> Keyword.get(:a)
1 = v |> Access.get(:x) |> Enum.at(0) |> elem(1) |> Access.get(:a)
1 = get_in(v.x, [Access.at(0), Access.elem(1)])[:a]
1 = get_in(v, [:x, Access.at(0), Access.elem(1), :a])
We can use pattern matching too.
1 = with %{x: [{:ok, a: a, b: _} | _]} = v, do: a
1 = case v, do: (%{x: [{:ok, a: a, b: } | ]} -> a)
true = match?(%{x: [{:ok, a: 1, b: } | ]}, v)
But it's hard to UPDATE a nested data.
v_2 = Map.update!(v, :x, fn x ->
List.update_at(x, 0, fn {:ok, k} ->
{:ok, Keyword.update!(k, :a, &(&1 + 1))}
end)
end)
1 = elem(Enum.at(v.x, 0), 1)[:a]
2 = elem(Enum.at(v_2.x, 0), 1)[:a]
Why this's hard? The nested callback functions is complecated. It's not composable, so we can't split this into simple parts. Is there no way?
Let's see some examples in other languages.
Data in JavaScript isn't immutable. But after React & Redux comes, JavaScript loves immutability. Immer is a nice library to update nested data with immutability.
mweststrate/immer: Create the next immutable state by mutating the current one
import produce from "immer";
const v = {
x: [
["ok", { a: 1, b: 2 }],
["ok", { a: 3, b: 4 }],
],
};
const v2 = produce(v, (v) => {
v.x[0][1].a += 1;
});
assert.equal(v.x[0][1].a, 1);
assert.equal(v2.x[0][1].a, 2);
Data in JavaScript is mutable, so it has short syntax to update data. Immer use it. It Proxy the data, listen all mutation operations & create a new data. This's the way in a mutable language to update nested data with immutability.
Haskell is close to Elixir because data in Haskell is immutable. Haskell has a nice mechanism - Lens.
lens: Lenses, Folds and Traversals
microlens: A tiny lens library with no dependencies. If you're writing an app, you probably want microlens-platform, not this.
Lens is a set of functions of composable getter & setter.
{-# LANGUAGE TemplateHaskell #-}
import Lens.Micro
import Lens.Micro.TH
data Item = Item { _a :: Integer, _b :: Integer } deriving (Show)
makeLenses ''Item
data V = V { _x :: [(String, Item)] } deriving (Show)
makeLenses ''V
v = V {
_x = [
("ok", Item { _a = 1, _b = 2 }),
("ok", Item { _a = 3, _b = 4 })
]
}
main = do
print $ v ^?! to _x . ix 0 . _2 . to _a
print $ v ^?! x . ix 0 . _2 . a
print $ v & x . ix 0 . _2 . a .~ 2
print $ v & x . ix 0 . _2 . a %~ (+ 1)
print $ v & (x . ix 0 . _2 . a) +~ 1
Lens is just a function, so we can compose them & create custom Lens (makeLenses
dose).
Clojure - a neighbor of Elixir
In ancient days Elixir was born from Clojure. So we look at that. Clojure is a functional language which is based on immutable data.
get-in
, assoc-in
& update-in
are the basic tools to manipulate nested data.
(def v
{:x [["ok" {:a 1, :b 2}] ["ok" {:a 3, :b 4}]]})
((((v :x) 0) 1) :a)
(:a (((:x v) 0) 1))
(:a (get (get (:x v) 0) 1))
(-> v :x (get 0) (get 1) :a)
(get-in v [:x 0 1 :a])
(let [{:keys [x]} v [[_ {:keys [a]}]] x] a)
(update v :x (fn [x] (update x 0 (fn [i] (update i 1 (fn [j] (assoc j :a 2)))))))
(assoc-in v [:x 0 1 :a] 2)
(update v :x (fn [x] (update x 0 (fn [i] (update i 1 (fn [j] (update j :a #(+ 1 %))))))))
(update-in v [:x 0 1 :a] #(+ 1 %))
Note assoc & update. Nested callbacks are transformed into a sequence of keys [:x 0 1 :a]
. The sequence is just a data, so we can compose them.
Elixir has Access
!
Like get-in
, assoc-in
& update-in
in Clojure, Elixir has get_in/2
, put_in/3
, update_in/3
& pop_in/2
.
v = %{x: [{:ok, a: 1, b: 2}, {:ok, a: 3, b: 4}]}
1 = get_in(v, [:x, Access.at(0), Access.elem(1), :a])
%{x: [{:ok, a: 2, b: 2}, {:ok, a: 3, b: 4}]} =
put_in(v, [:x, Access.at(0), Access.elem(1), :a], 2)
%{x: [{:ok, a: 2, b: 2}, {:ok, a: 3, b: 4}]} =
update_in(v, [:x, Access.at(0), Access.elem(1), :a], &(&1 + 1))
{1, %{x: [{:ok, b: 2}, {:ok, a: 3, b: 4}]}} =
pop_in(v, [:x, Access.at(0), Access.elem(1), :a])
These are family of Access module. These take a "path", a sequence of keys & functions. Map & Keyword takes a key. List needs Access.at/1
. Tuple needs Access.elem/1
.
%{x: 2} = put_in(%{x: 1}, [:x], 2)
%{x: 2} = put_in(%{x: 1}[:x], 2)
%{x: 2} = put_in(%{x: 1}.x, 2)
[x: 2] = put_in([x: 1], [:x], 2)
[x: 2] = put_in([x: 1][:x], 2)
%{x: 1, y: 2} = put_in(%{x: 1}[:y], 2)
[y: 2, x: 1] = put_in([x: 1][:y], 2)
%{x: 2} = put_in(%{x: 1}, [Access.key!(:x)], 2)
try do
put_in(%{x: 1}, [Access.key!(:y)], 2)
rescue
err in KeyError -> err
end
try do
put_in(%{x: 1}.y, 2)
rescue
err in KeyError -> err
end
%{x: 1, y: %{z: 2}} = put_in(%{x: 1}, [Access.key(:y, %{}), :z], 2)
[2] = put_in([1], [Access.at(0)], 2)
nil = get_in([1], [Access.at(1)])
{2} = put_in({1}, [Access.elem(0)], 2)
You'll find that accessing Struct causes an error. Struct isn't accessible in default. But we can use Access.key!/1
& Access.key/2
.
defmodule Example do
defstruct x: 1
end
defmodule Main do
def main do
%Example{x: 2} = put_in(%Example{}, [Access.key!(:x)], 2)
%Example{x: 2} = put_in(%Example{}, [Access.key(:x)], 2)
end
end
Main.main
One of the best feature of Access is Access.all/0
& Access.filter/1
. These reduce nested Enum calls. Access.all/0
traverses a List. Access.filter/1
traverses a List & filter them.
%{x: [%{a: 2}, %{a: 3}]} =
update_in(%{x: [%{a: 1}, %{a: 2}]}.x, fn x -> for %{a: a} <- x, do: %{a: a + 1} end)
%{x: [%{a: 2}, %{a: 3}]} =
update_in(%{x: [%{a: 1}, %{a: 2}]}, [:x, Access.all(), :a], &(&1 + 1))
require Integer
%{x: [%{a: 1}, %{a: 1}]} = update_in(
%{x: [%{a: 1}, %{a: 2}]}.x,
fn x ->
x
|> Enum.reduce(
[],
fn
%{a: a}, accm when rem(a, 2) == 0 -> [%{a: div(a, 2)} | accm]
i, accm -> [i | accm]
end
)
|> Enum.reverse
end
)
%{x: [%{a: 1}, %{a: 1}]} = update_in(
%{x: [%{a: 1}, %{a: 2}]},
[:x, Access.filter(&Integer.is_even(&1.a)), :a],
&div(&1, 2)
)
There are 2 ways to extend Access.
- Create an Access fun
@impl Access
Create an Access fun
We can create functions like Access.*/*
by ourselves. It's called "access_fun".
access_fun(data, get_value) ::
get_fun(data, get_value) | get_and_update_fun(data, get_value)
access_fun is an union of get_fun & get_and_update_fun.
get_fun(data, get_value) ::
(:get, data, (term() -> term()) -> {get_value, new_data :: container()})
get_and_update_fun(data, get_value) ::
(:get_and_update, data, (term() -> term()) ->
{get_value, new_data :: container()} | :pop)
Let's create one. In a following situation,
v = %{x: [{:ok, a: 1, b: 2}, {:ok, a: 3, b: 4}]}
1 = get_in(v, [V.a(1), :a])
%{x: [{:ok, a: 2, b: 2}, {:ok, a: 3, b: 4}]} = put_in(v, [V.a(1), :a], 2)
%{x: [{:ok, a: 2, b: 2}, {:ok, a: 3, b: 4}]} = update_in(v, [V.a(1), :a], &(&1 + 1))
the access_fun will be this.
defmodule V do
def a(a), do: fn command, data, next -> a(command, data, a, next) end
defp a(:get, data, a, next) do
data.x
|> Enum.find(fn
{:ok, x} -> x[:a] == a
_ -> false
end)
|> elem(1)
|> next.()
end
defp a(:get_and_update, data, a, next) do
{{:ok, x}, i} =
data.x
|> Enum.with_index
|> Enum.find({{:ok, nil}, nil}, fn
{{:ok, x}, _} -> x[:a] == a
_ -> false
end)
case next.(x) do
{get, update} ->
data = put_in(data, [:x, Access.at(i), Access.elem(1)], update)
{get, data}
:pop ->
if is_nil(i) do
data
else
{_, data} = pop_in(data, [:x, Access.at(i), Access.elem(1)])
data
end
end
end
end
access_fun pros :
- It can traverse all data.
- Separate from other Access behaviour.
- You can write complex behaviour.
access_fun cons :
- It has many boilerplate.
- Too complex.
When you just want a short cut of Access path, you can implement a Access behaviour.
@impl Access
Access behaviour provides more fluent syntax for users. When a struct is, you should implement Access like this.
defmodule V do
defstruct x: []
@behaviour Access
@impl Access
def fetch(v, key) do
case path_to(v, key) do
nil -> :error
path -> {:ok, get_in(v, path)}
end
end
@impl Access
def get_and_update(v, key, fun) do
case path_to(v, key) do
nil -> {nil, v}
path -> get_and_update_in(v, path, &fun.(&1))
end
end
@impl Access
def pop(v, {, } = key) do
case path_to(v, key) do
nil -> {nil, v}
path -> pop_in(v, path)
end
end
defp path_to(v, {:a, a}) do
with i when not is_nil(i) <-
Enum.find_index(v.x, fn
{:ok, x} -> x[:a] == a
_ -> false
end) do
[Access.key!(:x), Access.at(i), Access.elem(1)]
else
_ -> nil
end
end
end
Easy. Then you can call this like natively supported syntax.
defmodule Main do
def main do
v = %V{x: [{:ok, a: 1, b: 2}, {:ok, a: 3, b: 4}]}
1 = v[{:a, 1}][:a]
%V{x: [{:ok, a: 2, b: 2}, {:ok, a: 3, b: 4}]} =
put_in(v[{:a, 1}][:a], 2)
%V{x: [{:ok, a: 2, b: 2}, {:ok, a: 3, b: 4}]} =
update_in(v[{:a, 1}][:a], &(&1 + 1))
{1, %V{x: [{:ok, b: 2}, {:ok, a: 3, b: 4}]}} =
pop_in(v, [{:a, 1}, :a])
end
end
Main.main
Access is composable & extensible abstraction to get & update deep nested data. Let's enjoy functional programming on Elixir!