· 6 years ago · Sep 26, 2019, 09:28 PM
1#+TITLE: Structs Our Friends
2
3* Introduction
4
5Just to get everyone up to speed; Structs are a great because they provide compile time
6checks. Compile-time checks are awesome as we can catch errors before we run our
7application in the run-time. Structs also allow us to make custom data types, and we can
8implement protocols that amongst other things allow us to tell Elixir how it should
9enumerate over our data type, or insert items into our data type using Protocols.
10
11They are by no means perfect. The compiler can only check the keys of a struct. To test
12the actual values we need special tools, such as dialyzer; but any kind of compile time
13checks are good in my book.
14
15As you might know a struct is part of a module, they really are simple maps with a special
16field called ~__struct__~. This can be seen by using the build in function ~is_map/1~ on a
17struct and using functions from the Map module; let's test this out using the ~%URI{}~
18struct from Elixir standard lib.
19
20#+BEGIN_SRC elixir
21 iex> uri = %URI{}
22 %URI{
23 authority: nil,
24 fragment: nil,
25 host: nil,
26 path: nil,
27 port: nil,
28 query: nil,
29 scheme: nil,
30 userinfo: nil
31 }
32 iex> is_map(uri)
33 true
34 iex> Map.keys(uri)
35 [:__struct__, :authority, :fragment, :host, :path, :port, :query, :scheme, :userinfo]
36#+END_SRC
37
38* Defining structs
39
40We can define our own structs using the ~defstruct/1~ macro in a module.
41
42#+BEGIN_SRC elixir
43 defmodule Person do
44 defstruct [:id, :name, age: 0]
45 end
46#+END_SRC
47
48As we can see we can define fields, and we can define default values. In our ~Person~
49example we would get a person with age zero if we don't specify a value when we use it to
50create a new person; ~nil~ is the default value if none is specified.
51
52#+BEGIN_SRC elixir
53iex> %Person{name: "Martin"}
54%Person{age: 0, id: nil, name: "Martin"}
55#+END_SRC elixir
56
57* Benefits of using structs for internal process state
58
59My favorite use of structs is to define the state of a process. This has a couple of
60benefits that I would like to address. Take the following example:
61
62#+BEGIN_SRC elixir
63defmodule Counter1 do
64 use GenServer
65
66 defstruct [:access_time, value: 0]
67
68 # Client API
69 def start_link(opts \\ []) do
70 GenServer.start_link(__MODULE__, opts)
71 end
72
73 def inc(pid, n) when is_integer(n), do: GenServer.call(pid, {:inc, n})
74
75 def count(pid), do: GenServer.call(pid, :count)
76
77 # Server callbacks
78 def init(_opts) do
79 {:ok, %__MODULE__{}}
80 end
81
82 def handle_call({:inc, n}, _from, state) when n > 0 do
83 %{value: new_value} =
84 new_state = %__MODULE__{
85 value: state.value + n,
86 access_time: DateTime.utc_now()
87 }
88
89 {:reply, new_value, new_state}
90 end
91
92 def handle_call(:count, _from, state) do
93 {:reply, state.value, %__MODULE__{access_time: DateTime.utc_now()}}
94 end
95end
96#+END_SRC
97
98This implement a GenServer that store a counter. We can increment it and we can ask it for
99its current count. Every time we access the counter we will update the last time it was
100accessed with the current time.
101
102#+BEGIN_SRC elixir
103 iex> {:ok, pid} = Counter1.start_link
104 {:ok, #PID<0.142.0>}
105 iex> Counter1.count(pid)
106 0
107 iex> Counter1.inc(pid, 5)
108 5
109#+END_SRC
110
111But we have a bug in our counter. Instead of reading on, try and see if you can spot it!
112
113#+BEGIN_SRC elixir
114 iex> {:ok, pid} = Counter1.start_link
115 {:ok, #PID<0.145.0>}
116 iex> Counter1.inc(pid, 5)
117 5
118 iex> Counter1.count(pid)
119 5
120 iex> Counter1.count(pid)
121 0
122 # If we ask for the count we will get the current count, but it will
123 # then be set to zero. Look at the Counter1 implementation again and
124 # see if you can spot why.
125#+END_SRC
126
127We overwrite the state every time we access it—and we get the default values. That bug is
128a nasty one, because it is subtle, and as such it is not wrong behavior so it manifest
129itself the next time we access the data—not where the error is happening.
130
131We can protect ourselves from these kinds of bugs by using the ~@enforce_keys~ module
132attribute. I have had great success setting a value that normally wouldn't get set and
133enforce that key. Then we can allow the compiler to help us. Observe the following example
134where I define an ~id~ field and enforce it; The first time I initialize the struct I will
135set it, and then the compiler will complain if I overwrite instead of updating the state!
136
137#+BEGIN_SRC elixir
138defmodule Counter2 do
139 use GenServer
140
141 @enforce_keys :id
142 defstruct [:id, :access_time, value: 0]
143
144 # Client API
145 def start_link(opts \\ []) do
146 GenServer.start_link(__MODULE__, opts)
147 end
148
149 def inc(pid, n) when is_integer(n), do: GenServer.call(pid, {:inc, n})
150
151 def count(pid), do: GenServer.call(pid, :count)
152
153 # Server callbacks
154 def init(_opts) do
155 <<id::integer-size(64)>> = :crypto.strong_rand_bytes(8)
156 {:ok, %__MODULE__{id: id}}
157 end
158
159 def handle_call({:inc, n}, _from, state) when n > 0 do
160 %{value: new_value} =
161 new_state = %__MODULE__{
162 state
163 | value: state.value + n,
164 access_time: DateTime.utc_now()
165 }
166
167 {:reply, new_value, new_state}
168 end
169
170 def handle_call(:count, _from, state) do
171 {:reply, state.value, %__MODULE__{state | access_time: DateTime.utc_now()}}
172 end
173end
174#+END_SRC
175
176* Updating state
177
178Another great thing for using structs as state is the ~struct!/1~ function. This can be
179used to initialize a struct with a set of values coming from an enumerable that produces
180values of two-tuples (key/value). I like to use this function in the ~init/1~ callback
181like so:
182
183#+BEGIN_SRC elixir
184 defmodule Person do
185 use GenServer
186
187 @enforce_keys [:id, :name]
188 defstruct [:id, :name, age: 0]
189
190 # Client API
191 def start_link(opts) do
192 GenServer.start_link(__MODULE__, opts)
193 end
194
195 def info(pid), do: GenServer.call(pid, :info)
196
197 def update(pid, info) when is_list(info), do: GenServer.cast(pid, {:update, info})
198
199 # Server callbacks
200 def init(opts) do
201 opts = Keyword.put_new_lazy(opts, :id, &generate_id/0)
202 {:ok, struct!(__MODULE__, opts)}
203 end
204
205 defp generate_id(), do: Enum.random(1..0xFFFF)
206
207 def handle_cast({:update, update}, state) do
208 {:noreply, struct!(state, update)}
209 end
210
211 def handle_call(:info, _from, state) do
212 {:reply, state, state}
213 end
214 end
215#+END_SRC
216
217This will allow us to pass in a keyword list setting any value defined in our struct. The
218run-time will complain if we set something that does not exist, and we can easily provide
219default values that are computed using the functions from the ~Keyword~-module. In our
220example we provide a computed default value for ~:id~ if it is not set; being able to set
221a specific id is helpful in testing.
222
223#+BEGIN_SRC elixir
224 iex> {:ok, pid} = Person.start_link([name: "Agner Krarup Erlang"])
225 {:ok, #PID<0.227.0>}
226 iex> Person.info(pid)
227 %Person{age: 0, id: 20375, name: "Agner Krarup Erlang"}
228 iex> Person.update(pid, [age: 52])
229 :ok
230 iex> Person.info(pid)
231 %Person{age: 52, id: 20375, name: "Agner Krarup Erlang"}
232#+END_SRC
233
234This way of updating internal state is certainly not for every use-case. Sometimes we
235would like to shield our state management a bit more.
236
237* Conclusion
238
239Using structs for internal state management has many benefits; we can let the compiler
240help us because we are more precise in our intent—preventing us from overwriting our state
241when we don't want to—and we can use ~struct/1~ and ~struct!/1~ in combination with
242Keyword lists to set and update the values.
243
244While the state should be private to the process and not leak to the outside, I still
245think it is beneficial to be able to peak into the state structure like this. It is hugely
246beneficial in testing, as we can make assertions on the state-struct and get notified on
247where we break our tests when the implementation change, and we can even help us when we
248inspect our process state using functions like ~:sys.get_state/1~.
249
250That was what I wanted to show today.