pic
Personal
Website

6b. Named Tuples and Dictionaries

PhD in Economics

Introduction

Our previous discussions on collections have centered around vectors and tuples. The current section expands on the subject, offering a more comprehensive analysis and introducing two new types of collections: named tuples and dictionaries.

Specifically, we'll show how to characterize collections through keys and values, methods for their manipulation, and approaches to transforming one collection into another.

Keys and Values

Most collections in Julia are characterized by keys, which serve as unique identifiers of their elements and have a corresponding value associated to each. [note] Not all collections represent maps of keys to values. For example, the type Set, which represents a group of unique unordered elements, doesn't have keys. For instance, the vector x = [2, 4, 6] has the indices 1, 2, 3 as its keys, and 2, 4, 6 are their respective values.

Keys are more general than indices—they encompass all the possible identifiers of a collection's elements (e.g., strings, numbers, or other objects), while indices exclusively employ integers as identifiers. To know the keys and values of a collection, Julia provides the functions keys and values.

The following code snippets illustrate the use of keys and values. The examples are based on vectors and tuples, whose keys are exclusively represented by indices. Importantly, neither keys nor values necessarily return a vector by default, requiring the function collect to get a vector form.

x = [4, 5, 6]
Output in REPL
julia>
collect(keys(x))
3-element Vector{Int64}:
 1
 2
 3

julia>
collect(values(x))
3-element Vector{Int64}:
 4
 5
 6
x = (4, 5, 6)
Output in REPL
julia>
collect(keys(x))
3-element Vector{Int64}:
 1
 2
 3

julia>
collect(values(x))
3-element Vector{Int64}:
 4
 5
 6

The Type 'Pair'

Collections of key-value pairs in Julia are represented by the type Pair{<key type>, <value type>}. While we won't directly work with objects of having this type, they form the basis for constructing dictionaries and named tuples.

A key-value pair can be created by using the operator => as in <key> => <value>. For instance, "a" => 1 represents a pair, where a is its key and 1 its corresponding value. In addition, we can create pairs through the function Pair(<key>, <value>), such that Pair("a",1) is equivalent. Moreover, given a pair x, we can access its key by x[1] or x.first, and its value by x[2] or x.second. All this is demonstrated below.

some_pair = ("a" => 1)      # or simply 'some_pair = "a" => 1'

some_pair = Pair("a", 1)    # equivalent
Output in REPL
julia>
some_pair
"a" => 1

julia>
some_pair[1]
"a"

julia>
some_pair.first
"a"
some_pair = ("a" => 1)      # or simply 'some_pair = "a" => 1'

some_pair = Pair("a", 1)    # equivalent
Output in REPL
julia>
some_pair
"a" => 1

julia>
some_pair[2]
1

julia>
some_pair.second
1

The Type 'Symbol'

The type used to represent keys can vary depending on the collection. One important type used as key is Symbol, which provides an efficient way to represent string-based keys. A symbol with name x is denoted :x, and can be created from strings using the function Symbol(<some string>). [note] This type also enables programmatic creation of variables. For example, it can be employed for creating new columns in the package DataFrames, which provides a table representation of data.

some_symbol = :x

some_symbol = Symbol("x")                       # equivalent
Output in REPL
julia>
some_symbol
:x
vector_symbols = [:x, :y]

vector_symbols = [Symbol("x"), Symbol("y")]     # equivalent
Output in REPL
julia>
vector_symbols
2-element Vector{Symbol}:
 :x
 :y

Named Tuples

Warning!
Tuples and named tuples should only be used for small collections. Using them for large collections can lead to slow operations, or even stack overflows that exceed the computer's processing capacity. In such situations, arrays remain the preferred choice.

Defining what constitutes small is challenging, and unfortunately there's no definitive answer. We can only indicate that collections with fewer than 10 elements are undoubtedly small, while those exceeding 100 elements clearly violate the definition.

Named tuples share several similarities with regular tuples, including their immutability. However, they also exhibit some differences. One of them is that the keys of named tuples are objects of type Symbol, unlike regular tuples that employ numerical indices.

Named tuples also differ syntactically, in that they must always be enclosed in parentheses ()—omitting them is not possible, unlike with regular tuples. Furthermore, when creating a named tuple with a single element, the notation requires either a trailing comma , after the element (similar to regular tuples) or a leading semicolon ; before the element. [note] The semicolon notation ; may seem odd, but it actually comes from the syntax for keyword arguments in functions.

To construct a named tuple, each element must be specified in the format <key> = <value>, such as a = 10. Alternatively, you can use a pair <key with Symbol type> => <value>, as in :a => 10. Once a named tuple nt is created, you can access its element a by using either nt[:a] or nt.a.

The following code snippets illustrate all this.

# all 'nt' are equivalent
nt = (  a=10, b=20)
nt = (; a=10, b=20)

nt = (  :a => 10, :b => 10)
nt = (; :a => 10, :b => 10)

Output in REPL
julia>
nt
(a = 10, b = 20)

julia>
nt.a
10

julia>
nt[:a] #alternative way to access 'a'
10

# all 'nt' are equivalent
nt = (  a=10,)
nt = (; a=10 )

nt = (  :a => 10,)
nt = (; :a => 10 )

#not 'nt =  (a = 10)'  -> this is interpreted as 'nt = a = 10'
#not 'nt = (:a => 10)' -> this is interpreted as a pair

Output in REPL
julia>
nt
(a = 10, )

julia>
nt.a
10

julia>
nt[:a] #alternative way to access 'a'
10

Remark
Note that we can also employ the functions keys and values here.

Code

nt = (a=10, b=20)

Output in REPL
julia>
collect(keys(nt))
2-element Vector{Symbol}: :a :b

julia>
values(nt)
(10, 20)

Distinction Between The Creation Of Tuples and Named Tuples

It's possible to create named tuples based on individual variables. For instance, given variables x = 10 and y = 20, you can define nt = (; x, y). This creates a named tuple with keys x and y, and corresponding values 10 and 20.

The semicolon ; is crucial in this construction, as it distinguishes named tuples from regular tuples. Omitting it, as in nt = (x, y), would result in a regular tuple instead.

x = 10
y = 20

nt  = (; x, y)
tup = (x, y)

Output in REPL
julia>
nt
(x = 10, y = 20)

julia>
tup
(10, 20)

x = 10


nt  = (; x)
tup = (x, )

Output in REPL
julia>
nt
(x = 10,)

julia>
tup
(10,)

Dictionaries

Dictionaries are collections of key-value pairs, exhibiting three distinctive features:

  • The keys of dictionaries can be any object: strings, numbers, or other objects (e.g., vectors, tuples) are all possible.

  • Dictionaries are mutable: you can modify, add, and remove elements.

  • Dictionaries are unordered: keys don't have any order attached.

Dictionaries are created through the function Dict, with each argument denoting key-value pairs through the notation <key> => <value>.

some_dict = Dict(3 => 10, 4 => 20)
Output in REPL
julia>
dict
Dict{Int64, Int64} with 2 entries:
  4 => 20
  3 => 10

julia>
dict[1]
10
dict = Dict("a" => 10, "b" => 20)
Output in REPL
julia>
dict
Dict{String, Int64} with 2 entries:
  "b" => 20
  "a" => 10

julia>
dict["a"]
10
some_dict = Dict(:a => 10, :b => 20)
Output in REPL
julia>
dict
Dict{Symbol, Int64} with 2 entries:
  :a => 10
  :b => 20

julia>
dict[:a]
10
some_dict = Dict((1,1) => 10, (1,2) => 20)
Output in REPL
julia>
dict
Dict{Tuple{Int64, Int64}, Int64} with 2 entries:
  (1, 2) => 20
  (1, 1) => 10

julia>
dict[(1,1)]
10

As regular dictionaries are unordered, accessing their elements doesn't follow any pattern. The following example illustrates this, where a vector collects the keys of a dictionary. [note] The package OrderedCollections addresses this, by offering a special dictionary called OrderedDict. It behaves pretty similarly to regular dictionaries, including its syntax.

some_dict = Dict(3 => 10, 4 => 20)

keys_from_dict = collect(keys(some_dict))
Output in REPL
julia>
keys_from_dict
2-element Vector{Int64}:
 4
 3
some_dict = Dict("a" => 10, "b" => 20)

keys_from_dict = collect(keys(some_dict))
Output in REPL
julia>
keys_from_dict
2-element Vector{String}:
 "b"
 "a"
some_dict = Dict(:a => 10, :b => 20)

keys_from_dict = collect(keys(some_dict))
Output in REPL
julia>
keys_from_dict
2-element Vector{Symbol}:
 :a
 :b
some_dict = Dict((1,1) => 10, (1,2) => 20)

keys_from_dict = collect(keys(some_dict))
Output in REPL
julia>
keys_from_dict
2-element Vector{Tuple{Int64, Int64}}:
 (1, 2)
 (1, 1)

Creating Named Tuples and Dictionaries

Both dictionaries and named tuples can be constructed using other collections as their source. This is feasible as long as the source collection possesses a key-value structure. In the following, we show how several collections are employed to create dictionaries in particular.

vector = [10, 20] # or tupl = (10,20)



dict = Dict(pairs(vector))
Output in REPL
julia>
dict
Dict{Int64, Int64} with 2 entries:
  2 => 20
  1 => 10
keys_for_dict   = [:a, :b]
values_for_dict = [10, 20]


dict = Dict(zip(keys_for_dict, values_for_dict))
Output in REPL
julia>
dict
Dict{Symbol, Int64} with 2 entries:
  :a => 10
  :b => 20
keys_for_dict   = (:a, :b)
values_for_dict = (10, 20)


dict = Dict(zip(keys_for_dict, values_for_dict))
Output in REPL
julia>
dict
Dict{Symbol, Int64} with 2 entries:
  :a => 10
  :b => 20
nt_for_dict = (a = 10, b = 20)



dict = Dict(pairs(nt_for_dict))
Output in REPL
julia>
dict
Dict{Symbol, Int64} with 2 entries:
  :a => 10
  :b => 20
keys_for_dict      = (:a, :b)
values_for_dict    = (10, 20)
vector_keys_values = [(keys_for_dict[i], values_for_dict[i]) for i in eachindex(keys_for_dict)]

dict = Dict(vector_keys_values)
Output in REPL
julia>
dict
Dict{Symbol, Int64} with 2 entries:
  :a => 10
  :b => 20

Similarly, we can define a named tuple using other collections as its source.

a = 10
b = 20


nt = (; a, b)
Output in REPL
julia>
nt
(a = 10, b = 20)
keys_for_nt   = [:a, :b]
values_for_nt = [10, 20]


nt = (; zip(keys_for_nt, values_for_nt)...)
Output in REPL
julia>
nt
(a = 10, b = 20)
keys_for_nt   = [:a, :b]
values_for_nt = [10, 20]


nt = NamedTuple(zip(keys_for_nt, values_for_nt))
Output in REPL
julia>
nt
(a = 10, b = 20)
keys_for_nt   = (:a, :b)
values_for_nt = (10, 20)


nt = NamedTuple(zip(keys_for_nt, values_for_nt))
Output in REPL
julia>
nt
(a = 10, b = 20)
keys_for_nt        = [:a, :b]
values_for_nt      = [10, 20]
vector_keys_values = [(keys_for_nt[i], values_for_nt[i]) for i in eachindex(keys_for_nt)]

nt = NamedTuple(vector_keys_values)
Output in REPL
julia>
nt
(a = 10, b = 20)
dict = Dict(:a => 10, :b => 20)



nt = NamedTuple(vector_keys_values)
Output in REPL
julia>
nt
(a = 10, b = 20)

Destructuring (OPTIONAL)

Previously, we've demonstrated how to create a named tuple nt using variables as its source. This approach automatically defines the key-value pairs of the named tuple by each variable's name and value.

Next, we show that we are also able to perform the opposite operation: extracting values from a named tuple and assigning them to individual variables. This process is known as destructuring, and allows users to "unpack" a collection's values into separate variables.

To make the section self-contained, we start by reviewing destructuring for regular tuples. This will provide a foundational understanding before exploring destructuring named tuples. Both approaches enable the creation of variables with specific values, although they cater to distinct situations.

Destructuring Collections Through Tuples

Given a collection list with two elements, destructuring allows the user to create variables x and y with the values of list. This requires running x,y = list, with the tuple on the left-hand side of =. The following examples illustrate this operation, according to several objects taken as list.

list = [3,4]

x,y  = list

Output in REPL
julia>
x
3

julia>
y
4

list = 3:4

x,y  = list

Output in REPL
julia>
x
3

julia>
y
4

list = (3,4)

x,y  = list

Output in REPL
julia>
x
3

julia>
y
4

list = (a = 3, b = 4)

x,y  = list

Output in REPL
julia>
x
3

julia>
y
4

In addition to destructuring all elements in list as above, we can also selectively destructure a subset of elements. When working with tuples, destructuring assigns values in a sequential order, without allowing any values to be skipped. As a result, if you're not interested in a particular element, you must explicitly indicate it. This requires assigning _ as a variable name, which signals that no assignment should be performed for that value.

list = [3,4,5]

(x,)    = list

Output in REPL
julia>
x
3

list = [3,4,5]

x,y     = list

Output in REPL
julia>
x
3

julia>
y
4

list = [3,4,5]

_,_,z   = list                  # _ skips the assignment of that value

Output in REPL
julia>
z
5

list = [3,4,5]

x,_,z   = list                  # _ skips the assignment of that value

Output in REPL
julia>
x
3

julia>
z
5

Named Tuples Destructuring

Consider now the case where list is a named tuple. Destructuring named tuples offers greater flexibility compared to regular tuples: you can extract key-value pairs without adhering to the order of elements and without explicitly identifying the values to be skipped.

To illustrate this, let's first consider destructuring a named tuple nt through a tuple on the left-hand side of the assignment. This will serve as a baseline for further discussions.

nt      = (; name1 = 10, name2 = 20, name3 = 30)

x, y, z = nt

Output in REPL
julia>
x
10

julia>
y
20

julia>
z
30

nt      = (; name1 = 10, name2 = 20, name3 = 30)

x, y    = nt

Output in REPL
julia>
x
10

julia>
y
20

nt      = (; name1 = 10, name2 = 20, name3 = 30)

_, _, z = nt        # _ is notation to indicate we don't want the first two values of 'nt'

Output in REPL
julia>
z
30

Instead of using a tuple on the left-hand side, let's now utilize a named tuple. This makes it possible to specify the variables to be created by referencing their corresponding keys. The variable created will then be assigned the value associated with that key, regardless of the order in which you specify each variable.

nt = (; name1 = 10, name2 = 20, name3 = 30)

(; name2, name1) = nt                   # names in any order

Output in REPL
julia>
name1
10

julia>
name3
30

nt = (; name1 = 10, name2 = 20, name3 = 30)

(; name3)               = nt

Output in REPL
julia>
name3
30

Remark
Be careful with mixing tuples and named tuples in destructuring. The following example shows the potential issue that could arise otherwise.

nt = (; name1 = 10, name2 = 20, name3 = 30)

 name2, name1    = nt            # variables defined according to POSITION
(name2, name1)   = nt            # alternative notation

Output in REPL
julia>
name2
10

julia>
name1
20

nt = (; name1 = 10, name2 = 20, name3 = 30)

(; name2, name1) = nt            # variables defined according to NAME
 ; name2, name1  = nt            # alternative notation

Output in REPL
julia>
name1
10

julia>
name2
20

The same caveat applies to a single-variable assignment.

nt = (; name1 = 10, name2 = 20)

(name2,)  = nt            # variable defined according to POSITION -> only first element

Output in REPL
julia>
name2
10

nt = (; name1 = 10, name2 = 20)

(; name2) = nt            # variable defined according to NAME

Output in REPL
julia>
name2
20

An Application of Destructuring

Destructuring named tuples is particularly useful for passing multiple parameters to a function. If you need to repeatedly call a function like this, defining a named tuple to store all relevant parameters proves highly efficient. In this way, the named tuple serve as a single argument passed directly to the function. Then, inside the function, you can extract the specific parameters needed for each operation.

The approach eliminates the need for repetitively specifying a named tuple when a parameter is used, resulting in cleaner and more concise code.

parameters_list = (; β = 3, δ = 4, ϵ = 5)

# function 'foo' only uses 'β' and 'δ' 
function foo(x, parameters_list) 
    x * parameters_list.δ + exp(parameters_list.β) / parameters_list.β
end

Output in REPL
julia>
foo(2, parameters_list)
14.695

parameters_list = (; β = 3, δ = 4, ϵ = 5)

# function 'foo' only uses 'β' and 'δ' 
function foo(x, δ, β) 
    x * δ + exp(β) / β
end

Output in REPL
julia>
foo(2, parameters_list.β, parameters_list.δ)
14.695

parameters_list = (; β = 3, δ = 4, ϵ = 5)

# function 'foo' only uses 'β' and 'δ' 
function foo(x, parameters_list)
    (; β, δ) = parameters_list

    x * δ + exp(β) / β
end

Output in REPL
julia>
foo(2, parameters_list)
14.695