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.

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.

Comments are closed

This is a static version of the site. Comments are not available.

7 Comments

  1. Jaison Vieira on November 7, 2021 at 7:04 am

    I found an issue with the test `refactoring rounded/2`. The test case is setting the key :decimals to nil. Instead it should set the opts parameter to nil.

    This:
    assert 1.2346 == Keywords.rounded(1.2345678, decimals: nil)

    Should be:
    assert 1.2346 == Keywords.rounded(1.2345678, nil)

    • Jaison Vieira on November 7, 2021 at 7:14 am

      Or did I miss anything here? Because setting decimals key to nil makes no difference here to using the default value.

      This works on all cases and I don’t need to add the logical OR:

      decimals = Keyword.get(opts, :decimal, 4)
      
      • Mark Ericksen on November 7, 2021 at 12:57 pm

        setting decimals key to nil makes no difference here to using the default value.

        That’s right. The problem description here is “When the value used for the number of decimals was loaded from the database, they found there are situations when it is nil.” Part of the challenge in creating practice exercises is making up a somewhat realistic situation. This situation (I admit it’s contrived!), is assuming that the number of decimals that we’ll round to is a value returned from the database. This could be like an account configuration setting.

        The point is that the number decimals value may be nil. So it would be nice if the function could use the default setting when it gets a nil. If the value used for the decimals is nil, then this code does NOT give us what we want. It’s because the nil was explicitly provided, so it will be used instead of the default value of 4 that we want.

        opts = [decimals: nil]
        decimals = Keyword.get(opts, :decimals, 4)
        decimals
        #=> nil
        

        We want it to use the default value of 4 when it’s nil. So this logical OR does that.

        opts = [decimals: nil]
        decimals = Keyword.get(opts, :decimals) || 4
        decimals
        #=> 4
        

        This is attempting to show a common pattern used in Elixir for dealing with keyword lists as options and how we may need to do something special with a nil value and why.

        So to answer your first question, this is intentionally passing the decimals: nil into the function. It’s saying, “lets not worry about changing the WAY we call the function based on the value, but have 1 way to call it and make it handle the possible different input values.” Going one step further, it’s trying to show a simple and elegant way to do that.

        I hope that helps!

        • Jaison Vieira on November 8, 2021 at 9:19 pm

          Gotcha!!! It makes total sense now. I was overlooking based on the test cases instead of looking as a real world scenario.
          Thank you so much!

  2. Maksym Kosenko on April 3, 2022 at 7:52 am

    Great explained the topic!!!! 👍 👍 👍

  3. Maksym Kosenko on April 3, 2022 at 9:03 am

    1. About exercise 3 >>

    %Item{} = item doesn’t work in my env, it gives an error like:

    ** (CompileError) lib/keywords.ex:19: Item.__struct__/0 is undefined, cannot expand struct Item. Make sure the struct name is correct. If the struct name exists and is correct but it still cannot be found, you likely have cyclic module usage in your code
        (stdlib 3.17.1) lists.erl:1358: :lists.mapfoldl/3
    

    2. :erlang.float_to_binary … doesn’t work either ¯\_ (ツ)_/¯

    3. The working solution which passes all the test in my environment is the following:

    def unit_price(item, opts \\ []) do
        unit_price = item.price / item.quantity
        case Keyword.get(opts, :mode) do
          :money -> Float.to_string(unit_price, decimals: 2)
          _ -> unit_price
        end
      end
    

    About my env:

    asdf current
    returns 
    elixir          1.12.2-otp-24
    
    • Mark Ericksen on April 4, 2022 at 6:30 am

      The struct error is likely from a missing or incorrect alias. It’s saying, “I don’t know what the ‘Item’ struct is.” Check for the alias.

      If you open IEx in a terminal and try the following you’ll see you should be :erlang.float_to_binary.

      :erlang.float_to_binary(1.2121, decimals: 1)     
      "1.2"
      
      Float.to_string(1.2121, decimals: 1)               
      warning: Float.to_string/2 is deprecated. Use :erlang.float_to_binary/2 instead
        iex:1
      
      $ asdf current
      elixir          1.13.0
      erlang          24.1
      

Comments are closed on this static version of the site.