Name-Value Pairs - Introducing Elixir: Getting Started in Functional Programming (2014)

Introducing Elixir: Getting Started in Functional Programming (2014)

Chapter 7. Name-Value Pairs

Tuples and lists are powerful tools for creating complex data structures, but there are two key pieces missing from the story so far. Tuples are relatively anonymous structures. Relying on a specific order and number of components in tuples can create major maintenance headaches. Lists have similar problems: the usual approaches to list processing in Elixir assume that lists are just a sequence of (often) similar parts.

Sometimes you want to call things out by name instead of number, or pattern match to a specific location. Elixir has many different options for doing just that.

NOTE

Maps and structs appeared late in Elixir’s development. They layer directly on features Erlang introduced in R17. In the long run, maps and structs will probably become the key pieces to know, but you may need the rest for compatibility with older Erlang code.

Keyword Lists

Sometimes you need to process lists of tuples containing two elements that can be considered as a “key and value” pair, where the key is an atom. Elixir displays them in keyword list format, and you may enter them in that format as well:

iex(1)> planemo_list = [{:earth, 9.8}, {:moon, 1.6}, {:mars, 3.71}]

[earth: 9.8, moon: 1.6, mars: 3.71]

iex(2)> atomic_weights = [hydrogen: 1.008, carbon: 12.011, sodium: 22.99]

[hydrogen: 1.008, carbon: 12.011, sodium: 22.99]

iex(3)> ages = [david: 59, simon: 40, cathy: 28, simon: 30]

[david: 59, simon: 40, cathy: 28, simon: 30]

A keyword list is always sequential and can have duplicate keys. Elixir’s Keyword module lets you access, delete, and insert values via their keys.

Use Keyword.get/3 to retrieve the first value in the list with a given key. The optional third argument to Keyword.get provides a default value to return in case the key is not in the list. Keyword.fetch!/2 will raise an error if the key is not found. The Keyword.get_values/2 will return all the values for a given key:

iex(5)> Keyword.get(atomic_weights, :hydrogen)

1.008

iex(6)> Keyword.get(atomic_weights, :neon)

nil

iex(7)> Keyword.get(atomic_weights, :carbon, 0)

12.011

iex(8)> Keyword.get(atomic_weights, :neon, 0)

0

iex(9)> Keyword.fetch!(atomic_weights, :neon)

** (KeyError) key not found: :neon

(elixir) lib/keyword.ex:164: Keyword.fetch!/2

iex(10)> Keyword.get_values(ages, :simon)

[40,30]

You can use Keyword.has_key?/2 to see if a key exists in the list:

iex(11)> Keyword.has_key?(atomic_weights, :carbon)

true

iex(12)> Keyword.has_key?(atomic_weights, :neon)

false

To add a new value, use Keyword.put_new/3. If the key already exists, its value remains unchanged:

iex(13)> weights2 = Keyword.put_new(atomic_weights, :helium, 4.0026)

[helium: 4.0026, hydrogen: 1.008, carbon: 12.011, sodium: 15.999]

iex(14)> weights3 = Keyword.put_new(weights2, :helium, -1)

[helium: 4.0026, hydrogen: 1.008, carbon: 12.011, sodium: 22.99]

To replace a value, use Keyword.put/3 If the key doesn’t exist, it will be created. If it does exist, all entries for that key will be removed and the new entry added:

iex(15)> ages2 = Keyword.put(ages, :chung, 19)

[chung: 19, david: 59, simon: 40, cathy: 28, simon: 30]

iex(16)> ages3 = Keyword.put(ages2, :simon, 22)

[simon: 22, chung: 19, david: 59, cathy: 28]

NOTE

All of these functions are copying lists or creating new modified versions of a list. As you’d expect in Elixir, the original list remains untouched.

If you want to delete all entries for a key, use Keyword.delete/2; to delete only the first entry for a key, use Keyword.delete_first/2:

iex(17)> ages2

[chung: 19, david: 59, simon: 40, cathy: 28, simon: 30]

iex(18)> ages4 = Keyword.delete(ages2, :simon)

[chung: 19, david: 59, cathy: 28]

Lists of Tuples with Multiple Keys

If you had created the list of atomic weights with tuples that included both the element name and its chemical symbol, you could use either the first or second element in the tuple as a key:

iex(1)> atomic_info = [{:hydrogen, :H, 1008}, {:carbon, :C, 12.011},

...(1)> {:sodium, Na, 22.99}]

[{:hydrogen,:H,1.008},{:carbon,:C,12.011},{:sodium,:Na,22.99}]

If you have data structured this way, you can use the List.keyfind/4, List.keymember?/3, List.keyreplace/4, List.keystore/4, and List.keydelete/3 functions to manipulate the list. Each of these functions takes the list as its first argument. The second argument is the key you want to find, and the third argument is the position within the tuple that should be used as the key, with 0 as the first element:

iex(1)> atomic_info = [{:hydrogen, :H, 1.008}, {:carbon, :C, 12.011},

...(1)> {:sodium, Na, 22.99}]

[{:hydrogen, :H, 1008}, {:carbon, :C, 12.011}, {:sodium, Na, 22.99}]

iex(2)> List.keyfind(atomic_info, :H, 1)

{:hydrogen, :H, 1.008}

iex(3)> List.keyfind(atomic_info, :carbon, 0)

{:carbon, :C, 12.011}

iex(4)> List.keyfind(atomic_info, :F, 1)

nil

iex(5)> List.keyfind(atomic_info, :fluorine, 0, {})

{}

iex(6)> List.keymember?(atomic_info, :Na, 1)

true

iex(7)> List.keymember?(atomic_info, :boron, 0)

false

iex(8)> atomic_info2 = List.keystore(atomic_info, :boron, 0,

...(8)> {:boron, :B, 10.081})

[{:hydrogen, :H, 1008}, {:carbon, :C, 12.011}, {:sodium, Na, 22.99},

{:boron, :B, 10.081}]

iex(9)> atomic_info3 = List.keyreplace(atomic_info2, :B, 1,

...(9)> {:boron, :B, 10.81})

[{:hydrogen, :H, 1008}, {:carbon, :C, 12.011}, {:sodium, Na, 22.99},

{:boron, :B, 10.81}]

iex(10)> atomic_info4 = List.keydelete(atomic_info3, :fluorine, 0)

[{:hydrogen, :H, 1008}, {:carbon, :C, 12.011}, {:sodium, Na, 22.99},

{:boron, :B, 10.81}]

iex(11)> atomic_info5 = List.keydelete(atomic_info3, :carbon, 0)

[{:hydrogen, :H, 1008}, {:sodium, Na, 22.99}, {:boron, :B, 10.81}]

Lines 2 and 3 show that you can search the list by chemical name (position 0) or symbol (position 1). By default, trying to find a key that doesn’t exist returns nil (line 4), but you may return any value you choose (line 5). Lines 6 and 7 show the use of List.keymember?.

To add new values, you must give a complete tuple as the last argument, as shown in line 8. The value for the atomic weight of boron was deliberately entered incorrectly. Line 9 uses List.keyreplace to correct the error.

NOTE

You can also use List.keyreplace to replace the entire tuple. If you wanted to replace boron with zinc, you would have typed:

iex(9)> atomic_info3 = List.keyreplace(atomic_info2, :B, 1, {:zinc, :Zn,

65.38})

Lines 10 and 11 show what happens when you use List.keydelete on an entry that is not in the list and on one that is in the list.

Hash Dictionaries

If you know that your keys will be unique, you can create a hash dictionary (HashDict), which is an associative array. Hash dictionaries aren’t really lists, but I am including them in this chapter because all of the functions that you have used with a Keyword list will work equally well with a HashDict. The advantage of a HashDict over a Keyword list is that it works well for large amounts of data. In order to use a hash dictionary, you must explicitly create it with the HashDict.new function:

iex(1)>planemo_hash = Enum.into([earth: 9.8, moon: 1.6, mars: 3.71],

HashDict.new())

#HashDict<[earth: 9.8, mars: 3.71, moon: 1.6]>

iex(2)> HashDict.has_key?(planemo_hash, :moon)

true

iex(3)> HashDict.has_key?(planemo_hash, :jupiter)

false

iex(4)> HashDict.get(planemo_hash, :jupiter)

nil

iex(5)> HashDict.get(planemo_hash, :jupiter, 0)

0

iex(6)> planemo_hash2 = HashDict.put_new(planemo_hash, :jupiter, 99.9)

#HashDict<[moon: 1.6, mars: 3.71, jupiter: 99.9, earth: 9.8]>

iex(7)> planemo_hash3 = HashDict.put_new(planemo_hash2, :jupiter, 23.1)

#HashDict<[moon: 1.6, mars: 3.71, jupiter: 99.9, earth: 9.8]>

iex(8)> planemo_hash4 = HashDict.put(planemo_hash3, :jupiter, 23.1)

#HashDict<[moon: 1.6, mars: 3.71, jupiter: 23.1, earth: 9.8]>

iex(9)> planemo_hash5 = HashDict.delete(planemo_hash4,:saturn)

#HashDict<[moon: 1.6, mars: 3.71, jupiter: 23.1, earth: 9.8]>

iex(10)> planemo_hash6 = HashDict.delete(planemo_hash4, :jupiter)

#HashDict<[moon: 1.6, mars: 3.71, earth: 9.8]>

Line 6 deliberately sets Jupiter’s gravity to an incorrect value. Line 7 shows that HashDict.put_new/2 will not update an existing value; line 8 shows that HashDict.put will update existing values. Line 9 shows that attempting to delete a nonexistent key from a hash dictionary leaves it unchanged.

From Lists to Maps

Keyword lists are a convenient way to address content stored in lists by key, but underneath, Elixir is still walking through the list. That might be OK if you have other plans for that list requiring walking through all of it, but it can be unnecessary overhead if you’re planning to use keys as your only approach to the data.

The Erlang community, after dealing with these issues for years, added a new set of tools, maps, to R17. (The initial implementation is partial but will get you started.) Elixir simultaneously added support for the new feature, with, of course, a distinctive Elixir syntax.

Creating Maps

The simplest way to create a map is to use %{} to create an empty map:

iex(1)> new_map = %{}

%{}

Frequently, you’ll want to create maps with at least some initial values. Elixir offers two ways to do this. You use the same %{} syntax, but put some extra declarations inside:

iex(2)> planemo_map = %{:earth => 9.8, :moon => 1.6, :mars => 3.71}

%{earth: 9.8, mars: 3.71, moon: 1.6}

The map now has keys that are the atoms :earth, :moon, and :mars, pointing to the values 9.8, 1.6, and 3.71, respectively. The nice thing about this syntax is that you can use any kind of value as the key. It’s perfectly fine, for example, to use numbers for keys:

iex(3)> number_map=%{2 => "two", 3 => "three"}

%{2 => "two", 3 => "three"}

However, atoms are probably the most common keys, and Elixir offers a more concise syntax for creating maps that use atoms as keys:

iex(4)> planemo_map_alt = %{earth: 9.8, moon: 1.6, mars: 3.71}

%{earth: 9.8, mars: 3.71, moon: 1.6}

The responses created by IEx in response to lines 2 and 4 are identical, and Elixir itself will use the more concise syntax if appropriate.

Updating Maps

If the strength of a planemo’s gravitational field changes, you can easily fix that with:

iex(7)> altered_planemo_map = %{planemo_map | earth: 12}

%{earth: 12, mars: 3.71, moon: 1.6}

or:

iex(8)> altered_planemo_map = %{planemo_map | :earth => 12}

%{earth: 12, mars: 3.71, moon: 1.6}

You can update multiple key-value pairs if you want, with syntax like %{planemo_map | earth: 12, mars:3} or %{planemo_map | :earth ⇒ 12, :mars ⇒ 3}.

You may also want to add another key-value pair to a map. You can’t, of course, change the map itself, but the Dict.put_new library function can easily create a new map that includes the original plus an extra value:

iex(7)> extended_planemo_map = Dict.put_new( planemo_map, :jupiter, 23.1)

%{earth: 9.8, jupiter: 23.1, mars: 3.71, moon: 1.6}

The Dict library lets you treat maps much like HashDict if you want that style of access, too.

Reading Maps

Elixir lets you extract information from maps through pattern matching. The same syntax works whether you’re matching in a variable line or in a function clause. Need the gravity for earth?

iex(12)> %{earth: earth_gravity} = planemo_map

%{earth: 9.8, mars: 3.71, moon: 1.6}

iex(13)> earth_gravity

9.8

If you ask for a value from a key that doesn’t exist, you’ll get an error. (If you need to pattern match “any map,” just use the empty map, %{}.)

From Maps to Structs

One shortcoming of tuples, keyword lists, and maps is that they are fairly unstructured. When you use tuples, you are responsible for remembering the order in which the data items occur in the tuple. With keyword lists and maps, you can add a new key at any time or misspell a key name, and Elixir will not complain. Elixir structs overcome these problems. They are based on maps, so the order of key-value pairs doesn’t matter, but a struct also keeps track of the key names and makes sure you don’t use invalid keys.

Setting Up Structs

Using structs requires telling Elixir about them with a special declaration. You use a defstruct declaration (actually a macro, as you’ll see later) inside of a defmodule declaration:

defmodule Planemo do

defstruct name: :nil, gravity: 0, diameter: 0, distance_from_sun: 0

end

That defines a struct named Planemo, containing fields named name, gravity, diameter, and distance_from_sun with their default values. This declaration creates structs for different towers for dropping objects:

defmodule Tower do

defstruct location: "", height: 20, planemo: :earth, name: ""

end

Creating and Reading Structs

Find these in _ch07/ex1-struct, compile them in IEx, and you can start using the structs to store data. As you can see on line 3, creating a new struct with empty {} applies the default values, while specifying values as shown on line 4 overrides the defaults:

iex(1)> c("planemo.ex")

[Planemo]

iex(2)> c("tower.ex")

[Tower]

iex(3)> tower1 = %Tower{}

%Tower{height: 20, location: "", name: "", planemo: :earth}

iex(4)> tower2 = %Tower{location: "Grand Canyon"}

%Tower{height: 20, location: "Grand Canyon", name: "", planemo: :earth}

iex(5)> tower3 = %Tower{location: "NYC", height: 241, name: "Woolworth Building"}

%Tower{height: 241, location: "NYC", name: "Woolworth Building",

planemo: :earth}

iex(6)> tower4 = %Tower{location: "Rupes Altat 241", height: 500,

...(6)> planemo: :moon, name: "Piccolini View"}

%Tower{height: 500, location: "Rupes Altat 241", name: "Piccolini View",

planemo: :moon}

iex(7)> tower5 = %Tower{planemo: :mars, height: 500,

...(7)> name: "Daga Vallis", location: "Valles Marineris"}

%Tower{height: 500, location: "Valles Marineris", name: "Daga Vallis",

planemo: :mars}

iex(8)> tower5.name

"Daga Vallis"

These towers (or at least drop sites) demonstrate a variety of ways to use the record syntax to create variables as well as interactions with the default values:

§ Line 3 just creates tower1 with the default values. You can add real values later.

§ Line 4 creates a tower2 with a location, but otherwise relies on the default values.

§ Line 5 overrides the default values for location, height, and name, but leaves the planemo alone.

§ Line 6 overrides all of the default values.

§ Line 7 replaces all of the default values, and also demonstrates that it doesn’t matter in what order you list the name/value pairs. Elixir will sort it out.

Once you have values in your structs, you can extract the values using the dot notation shown on line 8, which may be familiar from other programming languages.

Pattern Matching Against Structs

Since structures are maps, pattern matches against structures work in exactly the same way as they do for maps.

iex(9)> %Tower{planemo: p, location: where} = tower5

%Tower{height: 500, location: "Valles Marineris", name: "Daga Vallis",

planemo: :mars}

iex(10)> p

:mars

iex(11)> where

"Valles Marineris"

Using Structs in Functions

You can pattern match against structures submitted as arguments. The simplest way to do this is to just match against the record, as shown in Example 7-1, which is in ch07/ex2-struct-match.

Example 7-1. A method that pattern matches a complete record

defmodule StructDrop do

def fall_velocity(t) do

fall_velocity(t.planemo, t.height)

end

def fall_velocity(:earth, distance) whendistance >= 0 do

:math.sqrt(2 * 9.8 * distance)

end

def fall_velocity(:moon, distance) whendistance >= 0 do

:math.sqrt(2 * 1.6 * distance)

end

def fall_velocity(:mars, distance) whendistance >= 0 do

:math.sqrt(2 * 3.71 * distance)

end

end

This uses a pattern match that will match only Tower records, and puts the record into a variable t. Then, like its predecessor in Example 3-8, it passes the individual arguments to fall_velocity/2 for calculations, this time using the record syntax:

iex(13)> c("struct_drop.ex")

[StructDrop]

iex(14)> StructDrop.fall_velocity(tower5)

60.909769331364245

iex(15)> StructDrop.fall_velocity(tower1)

19.79898987322333

The StructDrop.fall_velocity/1 function shown in Example 7-2 pulls out the planemo field and binds it to the variable planemo. It pulls out the height field and binds it to distance. Then it returns the velocity of an object dropped from that distance just like earlier examples throughout this book.

You can also extract the specific fields from the structure in the pattern match, as shown in Example 7-2, which is in ch07/ex3-struct-components.

Example 7-2. A method that pattern matches components of a structure

defmodule StructDrop do

def fall_velocity(%Tower{planemo: planemo, height: distance}) do

fall_velocity(planemo, distance)

end

def fall_velocity(:earth, distance) whendistance >= 0 do

:math.sqrt(2 * 9.8 * distance)

end

def fall_velocity(:moon, distance) whendistance >= 0 do

:math.sqrt(2 * 1.6 * distance)

end

def fall_velocity(:mars, distance) whendistance >= 0 do

:math.sqrt(2 * 3.71 * distance)

end

end

You can take the Tower structures you have created and feed them into this function, and it will tell you the velocity resulting from a drop from the top of that tower to the bottom.

Finally, you can pattern match against both the fields and the structure as a whole. Example 7-3, in ch07/ex4-struct-multi, demonstrates using this mixed approach to create a more detailed response than just the fall velocity.

Example 7-3. A method that pattern matches the whole record as well as components of a record

defmodule StructDrop do

def fall_velocity(t = %Tower{planemo: planemo, height: distance}) do

IO.puts("From #{t.name}'s elevation of #{distance} meters on #{planemo},")

IO.puts("the object will reach #{fall_velocity(planemo, distance)} m/s")

IO.puts("before crashing in #{t.location}")

end

def fall_velocity(:earth, distance) whendistance >= 0 do

:math.sqrt(2 * 9.8 * distance)

end

def fall_velocity(:moon, distance) whendistance >= 0 do

:math.sqrt(2 * 1.6 * distance)

end

def fall_velocity(:mars, distance) whendistance >= 0 do

:math.sqrt(2 * 3.71 * distance)

end

end

NOTE

It is possible to have a variable whose name is the same as a field name; in the previous example, the planemo field was assigned to a variable also named planemo.

If you pass a Tower structure to StructDrop.fall_velocity/1, it will match against individual fields it needs to do the calculation and match the whole record into t so that it can produce a more interesting if not necessarily grammatically correct report:

iex(16)> StructDrop.fall_velocity(tower5)

From Daga Vallis's elevation of 500 meters on mars,

the object will reach 60.90976933136424520399 m/s

before crashing in Valles Marineris

:ok

iex(17)> StructDrop.fall_velocity(tower3)

From Woolworth Building's elevation of 241 meters on earth,

the object will reach 68.72845116834803036454 m/s

before crashing in NYC

:ok

Adding Behavior to Structs

Elixir lets you attach behavior to structures (and, in fact, any type of data) with protocols. For example, you may want to test to see if a structure is valid or not. Clearly, the test for what is a valid structure varies from one type of structure to another. For example, you may consider a Planemovalid if its gravity, diameter, and distance from the sun are nonnegative. A Tower is valid if its height is nonnegative and it has a non-nil value for a planemo.

Example 7-4 shows the definition of a protocol for testing validity. The files for this example are in ch07/ex5-protocol.

Example 7-4. Defining a protocol for valid structures

defprotocol Valid do

@doc "Returns true if data is considered nominally valid"

def valid?(data)

end

The interesting line here is the def valid?(data); it is, in essence, an incomplete function definition. Every data type whose validity you want to test will have to provide a complete function with the name valid?, so let’s add some code to the definition of the Planemo structure:

defmodule Planemo do

defstruct name: :nil, gravity: 0, diameter: 0, distance_from_sun: 0

end

defimpl Valid, for: Planemo do

def valid?(p) do

p.gravity >= 0 && p.diameter >= 0 &&

p.distance_from_sun >= 0

end

end

Let’s test that out right now. Some of the output lines have been split for ease of reading:

iex(1)> c("valid_protocol.ex")

[Valid]

iex(2)> c("planemo.ex")

[Valid.Planemo, Planemo]

iex(3)> p = %Planemo{}

%Planemo{diameter: 0, distance_from_sun: 0, gravity: 0, name: nil}

iex(4)> Valid.valid?(p)

true

iex(5)> p2 = %Planemo{name: :weirdworld, gravity: -2.3}

%Planemo{diameter: 0, distance_from_sun: 0, gravity: -2.3, name: :weirdworld}

iex(6)> Valid.valid?(p2)

false

iex(7)> c("tower.ex")

[Tower]

iex(8)> t = %Tower{}

%Tower{height: 20, location: "", name: "", planemo: :earth}

iex(9)> c("valid_protocol.ex")

** (Protocol.UndefinedError) protocol Valid not implemented for

%Tower{height: 20, location: "", name: "", planemo: :earth}

valid_protocol.ex:1: Valid.impl_for!/1

valid_protocol.ex:3: Valid.valid?/1

Lines 3 and 4 show the creation and testing of a valid Planemo; lines 5 and 6 show the results for an invalid one. Line 9 shows that you cannot test a Tower structure for validity yet, as the valid? function has not yet been implemented. Here is the updated code for the Tower, which you can find in ch07/ex6-protocol:

defmodule Tower do

defstruct location: "", height: 20, planemo: :earth, name: ""

end

defimpl Valid, for: Tower do

def valid?(%Tower{height: h, planemo: p}) do

h >= 0 && p != nil

end

end

Here is the test:

iex(10)> c("tower.ex")

tower.ex:1: warning: redefining module Tower

[Valid.Tower, Tower]

iex(11)> Valid.valid?(t)

true

iex(12)> t2 = %Tower{height: -2, location: "underground"}

%Tower{height: -2, location: "underground", name: "", planemo: :earth}

iex(13)> Valid.valid?(t2)

false

Adding to Existing Protocols

When you inspect a Tower, you get rather generic output:

iex(1)> t3 = %Tower{location: "NYC", height: 241, name: "Woolworth Building"}

%Tower{height: 241, location: "NYC", name: "Woolworth Building",

planemo: :earth}

iex(2)> inspect t3

"%Tower{height: 241, location: \"NYC\", name: \"Woolworth Building\", planemo: :earth}"

Wouldn’t it be nice to have better-looking output? You can do this by implementing the Inspect protocol for Tower structures. Example 7-5 shows the code to add to tower.ex; you will find the source in ch07/ex7-inspect:

Example 7-5. Implementing the inspect protocol for the Tower structure

defimpl Inspect, for: Tower do

import Inspect.Algebra

def inspect(item, _options) do

metres = concat(to_string(item.height), "m:")

msg = concat([metres, break, item.name, ",", break,

item.location, ",", break,

to_string(item.planemo)])

end

end

The Inspect.Algebra module implements “pretty printing” using an algebraic approach (hence the name). In the simplest form, it puts together documents that may be separated by optional line breaks (break) and connected with concat. Every place that you put a break in a document is replaced by a space, or, if there is not enough space on the line, a line break.

The inspect/2 function takes the item you want to inspect as its first argument. The second argument is a structure that lets you specify options that give you greater control on how inspect/2 produces its output.

The first concat puts the height and abbreviation for metres together without any intervening space. The second concat connects all the items in the list, so the function returns a string containing the pretty-printed document:

iex(3)> inspect t3

"241m: Woolworth Building, NYC, earth"