Keyword List
Keyword lists are used a lot in Elixir. They are almost treated like a unique data type but they aren’t. This is a good time to introduce them because we will be using them in small ways throughout this course.
Contents
A compound type
You already know what a list in Elixir is:
my_list = [1, 2, 3, 4]
You already know what a tuple in Elixir is:
{:ok, "The answer is 42!"}
{2020, 1, 1}
A Keyword list is the combination of the two. It is a list of specially crafted tuples.
The tuple must be a two element tuple where the first element is the “key” and it must be an atom. The second element is the “value” and it can be anything.
# The structure
{:key, value}
# Examples
{:name, "Howard"}
{:level, :debug}
{:user, %User{}}
Elixir uses this so frequently, that there is a special syntax used to make it easy and elegant to work with. Check this out, when I write the Keyword list out like described above, Elixir formats the output in a different way.
[{:name, "Jane"}, {:age, 36}, {:awesome, true}]
#=> [name: "Jane", age: 36, awesome: true]
What just happened there? Try that out in IEx for yourself.
Can we write it the short way too? Yes!
[name: "John", age: 29, awesome: false]
#=> [name: "John", age: 29, awesome: false]
Just to prove to myself that this other way of writing it is still tuples, I can pull off the head of the list and see it by itself.
keywords = [name: "John", age: 29]
[first | _rest] = keywords
first
#=> {:name, "John"}
Yes. It really is a list of tuples. Cool!
Working with a Keyword list
A Keyword list is just a list of tuples so normal list processing works. However, because a Keyword list is used more specifically as a key-value data structure, the Keyword
module provides a number of functions that are helpful for working with a Keyword list.
Here are a couple key functions to know about.
- Keyword.fetch/2 – Return the value for a key, but return it in an
{:ok, value}
tuple. - Keyword.get/3 – Get the value for a key, provide a default value to return if not found.
Keyword lists used as options to functions
A very common usage of Keyword lists is to pass options to a function that may change the behavior. This is mostly how we will be using Keyword lists in this course.
Let’s see an example in the String module. The function String.split/3
declaration looks like this:
String.split(string, pattern, options \\ [])
Let’s see how we can use it. First, lets see a basic usage of the function, splitting a string on the colon character.
String.split("a:b:c:::f:g", ":")
#=> ["a", "b", "c", "", "", "f", "g"]
Notice that we didn’t provide any value to options
argument so the default value of []
was used. Now let’s use the :trim
option. It takes a boolean value. Setting it to true
removes empty strings from the results.
String.split("a:b:c:::f:g", ":", [trim: true])
#=> ["a", "b", "c", "f", "g"]
Passing a Keyword list as a set of options changed the behavior of the function.
Shorter syntax when used as option
Another special syntax is supported when our Keyword list is passed as the last argument to the function. The square brackets []
are optional! So our String.split
example can be written like this:
String.split("a:b:c:::f:g", ":", trim: true)
#=> ["a", "b", "c", "f", "g"]
Naming convention
When receiving a set of options as a Keyword list in your own function, a common naming convention for that argument is to call it opts
or options
. When looking at functions in the Elixir standard library, you will frequently see this convention used.
Default value
In the String.split/3
example, the default value for options was an empty list. (ex: options \\ []
). This is important. Your may be tempted to populate the list with the default values you want but this doesn’t work as you’d expect. Let’s write our own function to see what happens.
defmodule Testing do
def testing_options(opts \\ [trim: false, other_default: 10]) do
IO.puts(inspect opts)
end
end
Testing.testing_options()
#=> [trim: false, other_default: 10]
#=> :ok
# I want to override `:trim` to true
Testing.testing_options(trim: true)
#=> [trim: true]
#=> :ok
What happened? I wanted to override the :trim
option to true
. That desired value was set, but it took the entire Keyword list as a full replacement and discarded the other default values I wanted.
Elixir won’t merge the Keyword list we provide with the default one in the function. So what should we do? We can use Keyword.get/3
inside our function to get the desired value and fallback to the default.
defmodule Testing do
def testing_options(opts \\ []) do
want_trim = Keyword.get(opts, :trim, false)
other_default = Keyword.get(opts, :other_default, 10)
# [...function code...]
end
end
In the above example, I used Keyword.get/3
to pull out the desired values from the options argument and applied the desired default value if the caller didn’t provide an override.
Practice Exercises
Let’s apply what we covered here and give you a chance to create functions that take options as a Keyword list. You learn best by doing so take the time to become more familiar with it.
Exercise #1 – Rounding precision
Your team has tasked you with writing a function to round numbers to a desired decimal precision. By default, it should round to 4 decimal places. The function will be used in other contexts as well, so it should be configurable through an opts
argument that takes a Keyword list to specify a different level of precision as appropriate.
The function should take a :decimals
option. Default it to 4
.
The Float.round/2
function can perform the rounding work. See the documentation if needed.
The following mix test
command runs the tests for the describe block without needing to know the line number. Review the tests to see the expected behavior.
mix test test/keywords_test.exs --only describe:"rounded/2"
Exercise #2 – Refactor rounded/2
to handle nil
After putting your rounded/2
function into use, the team found another case they would like the function to handle. When the value used for the number of decimals was loaded from the database, they found there are situations when it is nil
. The following call does not give the desired result!
CodeFlow.Keywords.rounded(123.45678901, decimals: nil)
#=> ** (ArgumentError) precision is out of valid range of 0..15
#=> (elixir) lib/float.ex:268: Float.round/2
A test was added to show the failing code.
mix test test/keywords_test.exs --only describe:"refactoring rounded/2"
Why is this failing? Keyword.get/3
gets the value from the Keyword list. If the value is not in the list, then it uses the default. But if the value is explicitly stated in the list as nil
, it uses that value. Sometimes that’s the behavior you want. If the caller says to use nil
, then we do.
However, in this case, that is not the behavior we want. We want to treat nil
the same as “not specified”. You should note that the Keyword.get/3
function defaults nil
as the value returned. So the following two are functionally the same.
Keyword.get([], :decimals, nil)
#=> nil
Keyword.get([], :decimals)
#=> nil
Your task now is to refactor the solution to handle nil
the same as not specified. Make sure to check out the elegant solution below!
HINT: Remember that nil
is “falsey”.
Make sure the previous tests still pass after you refactored your code for the solution. We don’t want our refactor to create a regression!
Exercise #3 – Compute unit price
Your team has a new mission for you. You are to create a function that can take an %Item{}
struct which has a price
and a quantity
and compute the “unit price”. The quantity
is the number of units in the package. So the “unit price” is the price / quantity
. Sometimes the function will be used as part of a math operation and it should be returned as a float. Sometimes the function will be used to display the unit price and in that case it should display as money. Example: display as 5.10
instead of 5.1
.
The function should take a :mode
option. When the mode is :float
, return the value as a float. When the mode is :money
, convert it to a string representation with a hundreds precision. Default the mode to :float
.
The Erlang function float_to_binary/2
can be used for converting to a string. From Elixir you call it like this: :erlang.float_to_binary(value, options)
. Note it takes a Keyword list in options
and has an option called :decimals
that expects an integer.
The Item is defined in the downloaded project in CodeFlow.Schemas.Item
.
The following mix test
command runs the tests for the describe block without needing to know the line number. Review the tests to see the expected behavior.
mix test test/keywords_test.exs --only describe:"unit_price/2"
HINT: Use a case
statement to handle the different mode
values.
Revisiting IO.inspect/2
Now that you have spent some time with Keyword lists, let’s revisit that great debugging and insight tool IO.inspect/2
. Our original look at it only used the first argument but it takes two. The second argument is opts \\ []
. Hey, you know what to expect with that! You can pass a Keyword list with options to change the behavior of the function!
We want to use the :label
option.
Once you start sprinkling IO.inspect
calls around in your code, you can’t easily tell which one you are looking at in the console. The :label
option will, wait for it, label the output for us.
Let’s use a previous pipeline exercise to see what it does.
defmodule InspectTest do
def run do
" InCREASEd ProdUCtivitY is HEar? "
|> IO.inspect(label: "Original")
|> String.trim()
|> IO.inspect(label: "Trimmed")
|> String.capitalize()
|> IO.inspect(label: "Capitalized")
|> String.replace("hear", "here")
|> IO.inspect(label: "Replaced 'hear'")
|> String.replace("?", "!")
|> IO.inspect(label: "Replaced '?'")
end
end
InspectTest.run
#=> Original: " InCREASEd ProdUCtivitY is HEar? "
#=> Trimmed: "InCREASEd ProdUCtivitY is HEar?"
#=> Capitalized: "Increased productivity is hear?"
#=> Replaced 'hear': "Increased productivity is here?"
#=> Replaced '?': "Increased productivity is here!"
#=> "Increased productivity is here!"
Using the :label
option takes whatever text you provide as a label, outputs that followed by a :
, then outputs the inspected data. This is a very helpful little tweak that can make a big difference for you.
Note: IO.inspect/2
supports many options. The most commonly used will be :label
. If you want to dig deeper into what other options are available, see the Inspect.Opts
documentation
Keyword list compared to a Map
A Map is your go-to key-value data structure. After the Map, the next most common one is a Keyword list. Let’s consider some of the ways that one is better than the other for different needs.
- Maps are better for nested data structures.
- Maps can have string keys which works better for receiving user provided values. This avoids the potential Denial of Service attack from a malicious user exhausting your atom table limits. A Keyword list must have atom keys.
- Maps do not stay in the order you declare them. A Keyword list remains in the order you declared it.
- Pattern matching to destructure the data works better on a Map than a Keyword list. See example below:
# this works for a map
%{name: user_name} = %{name: "Howard", age: 30, score: 250}
user_name
#=> "Howard"
# this does NOT work for a Keyword list
[name: user_name] = [name: "Howard", age: 30, score: 250]
#=> ** (MatchError) no match of right hand side value: [name: "Howard", age: 30, score: 250]
- A Keyword list can contain duplicates and a Map cannot. At first, you might think that’s a bad thing. If you imagine using a Keyword list like you would a Map, then yeah, you don’t want that. However a Keyword list could be used to express a series of commands. Here’s an example:
[initial_value: 100, increment_by: 25, decrement_by: 90, increment_by: 10, increment_by: 22]
I could write a function that would process the series of commands and a Keyword list works well for that.
Recap
- A Keyword list is just a list of tuples that conforms to this pattern.
{:key, value}
- Keyword lists can be written like this:
[name: "Tim", points: 30]
- Keyword lists create a key-value pair data structure that:
- is ordered
- can contain duplicates
- Keyword lists are used for passing a set of options to a function.
- Keyword module has helpful functions for working with a Keyword list.
We also saw how we can pass a Keyword list to IO.inspect/2
and set a :label
that really helps identify debug output.
7 Comments
Comments are closed on this static version of the site.
Comments are closed
This is a static version of the site. Comments are not available.