Elixir from the ground up

Tuples and lists

Two containers, two jobs: a tuple holds a few values that travel together, a list holds any number of values in order. Lists are chains, which is why growing one at the front is cheap.

Part 4 of 12

Beginner15 min readBack to Courses

A couple of values, together

A tuple is written with braces: {3, 4}. It holds a fixed number of values, and the meaning lives in the position — the first item is the x, the second is the y. {:ok, 42} is a tuple too, and it is the reason so much of the standard library answers with one: the atom says which case you are in, the second slot carries the value. The atom is the tag and the value is the payload; that shape is common enough that a case or a function head can match it in one line.

You can reach into a tuple with elem(tuple, 0), but that is rarely the best way. Tuples are usually taken apart by matching: {x, y} = point names both parts in one line, which is clearer about what each slot means. A tuple’s size is part of its value, so a pattern that expects three items will not match a two-item tuple, and that refusal is usually what you want.

Any number of values, in order

A list is written with brackets: [3, 1, 2]. Order matters and length is part of the list, so lists hold the things you would count: readings, names, orders. length counts them, hd gives the first, tl gives everything after it, and ++ joins two lists into a new one.

The function names look odd until you see the shape underneath. A list in Elixir is not a box of slots — it is a chain of small links, each holding one value and a pointer to the rest. hd and tl are not conveniences over that shape, they are the shape: head and tail. That is why they are cheap and why there is no last that costs the same: the end of the chain is as far away as the list is long.

Cheap at the front, expensive in the middle

Growing a list at the front is instant: take a new value and a pointer to the existing list. [0 | [1, 2]] is a three-link chain that shares the whole of the older list. Growing at the end with ++ walks the entire list first, so a few joins are fine and a loop of joins is not. The cost is not the new value; it is the walk to the end, and a loop that joins at every step repeats that walk once per item.

That single fact explains a habit you will meet in the recursion chapter: Elixir programs tend to build a list backwards, one item at the front at a time, and then turn it around once at the end. It is cheaper, and it is not a trick — it is following the shape of the data.

Tuples and lists side by side

# A tuple: a fixed group of values.
point = {3, 4}
IO.inspect(elem(point, 0))
IO.inspect(point)

# A list: any number of values, in order.
numbers = [3, 1, 2]
IO.inspect(hd(numbers))
IO.inspect(tl(numbers))
IO.inspect(length(numbers))
IO.inspect(numbers ++ [4])
IO.inspect([1, 2, 3] -- [2])
IO.inspect(Enum.sort(numbers))

A point, and a row of numbers

Run it and check which lines print brackets and which print braces. Then add a fifth number to the list, take the first two items off with a match, and print the list reversed with a function from Enum.

Output
Click "Run" to execute your code

A list of whole numbers can print as text: IO.inspect([104, 105]) shows 'hi', because a list of character codes is exactly how Erlang and Elixir store an older kind of text. It is the same list either way — add a number well outside the printing range and the brackets come back.

Two costs hidden in the syntax

++ joins two lists by copying the left one, and -- removes the first occurrence of each item on the right. Both walk the list they are given, which is cheap once and expensive in a loop. length walks too, so a function that calls it on every step of a walk is doing the same work twice. Joining a short list to a long one is cheaper than the other way round, because the left side is the one copied.

A tuple knows its size without walking: tuple_size({1, 2, 3}) is a constant-time answer, because the size is part of the tuple. A list does not store its length, which is the price of being able to share its tail with another list. Reach for a tuple when the count is fixed, and a list when it is not. A tuple is also the right shape for a fixed answer such as {:ok, value}, where the number of parts is the meaning and never changes.

elem(tuple, 1) is the second item, not the first, because counting starts at zero. [1 | 2] builds an improper list that most list functions refuse; the tail of a list must be a list. And -- removes one copy per item, so [1, 1, 2] -- [1] still holds a 1.

Try these next

  • Make a tuple of a name and an age, match it apart, and print both values.
  • Add three items to the front of a list with | and check that the original is unchanged.
  • Remove one duplicate from a list with -- and compare the lengths before and after.

A shopping basket

Run it and check that adding coffee left the original basket alone. Then take one item off the front, add two at the front, and print the basket with a duplicate removed.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

Which container suits three readings that arrived in order?