3.3 Native Data Structures

Julia has several native data structures. They are abstractions of data that represent some form of structured data. We will cover the most used ones. They hold homogeneous or heterogeneous data. Since they are collections, they can be looped over with the for loops.

We will cover String, Tuple, NamedTuple, UnitRange, Arrays, Pair, Dict, Symbol.

When you stumble across a data structure in Julia, you can find methods that accept it as an argument with the methodswith function. In Julia, the distinction between methods and functions is as follows. Every function can have multiple methods like we have shown earlier. The methodswith function is nice to have in your bag of tricks. Let’s see what we can do with a String for example:

  1. first(methodswith(String), 5)
  1. [1] String(s::String) in Core at boot.jl:358
  2. [2] Symbol(s::String) in Core at boot.jl:489
  3. [3] ==(y::WeakRefStrings.WeakRefString, x::String) in WeakRefStrings at /home/runner/.julia/packages/WeakRefStrings/31nkb/src/WeakRefStrings.jl:51
  4. [4] ==(y::InlineString, x::String) in InlineStrings at /home/runner/.julia/packages/InlineStrings/HDs9F/src/InlineStrings.jl:341
  5. [5] ==(x::String, y::T) where T<:InlineString in InlineStrings at /home/runner/.julia/packages/InlineStrings/HDs9F/src/InlineStrings.jl:335

3.3.1 Broadcasting Operators and Functions

Before we dive into data structures, we need to talk about broadcasting (also known as vectorization) and the “dot” operator ..

We can broadcast mathematical operations like * (multiplication) or + (addition) using the dot operator. For example, broadcasted addition would imply a change from + to .+:

  1. [1, 2, 3] .+ 1
  1. [2, 3, 4]

It also works automatically with functions. (Technically, the mathematical operations, or infix operators, are also functions, but that is not so important to know.) Remember our logarithm function?

  1. logarithm.([1, 2, 3])
  1. [0.0, 0.6931471805599569, 1.0986122886681282]

3.3.2 Functions with a bang !

It is a Julia convention to append a bang ! to names of functions that modify one or more of their arguments. This convention warns the user that the function is not pure, i.e., that it has side effects. A function with side effects is useful when you want to update a large data structure or variable container without having all the overhead from creating a new instance.

For example, we can create a function that adds 1 to each element in a vector V:

  1. function add_one!(V)
  2. for i in eachindex(V)
  3. V[i] += 1
  4. end
  5. return nothing
  6. end
  1. my_data = [1, 2, 3]
  2. add_one!(my_data)
  3. my_data
  1. [2, 3, 4]

3.3.3 String

Strings are represented delimited by double quotes:

  1. typeof("This is a string")
  1. String

We can also write a multiline string:

  1. text = "
  2. This is a big multiline string.
  3. As you can see.
  4. It is still a String to Julia.
  5. "
  1. This is a big multiline string.
  2. As you can see.
  3. It is still a String to Julia.

But it is usually clearer to use triple quotation marks:

  1. s = """
  2. This is a big multiline string with a nested "quotation".
  3. As you can see.
  4. It is still a String to Julia.
  5. """
  1. This is a big multiline string with a nested "quotation".
  2. As you can see.
  3. It is still a String to Julia.

When using triple-backticks, the indentation and newline at the start is ignored by Julia. This improves code readability because you can indent the block in your source code without those spaces ending up in your string.

3.3.3.1 String Concatenation

A common string operation is string concatenation. Suppose that you want to construct a new string that is the concatenation of two or more strings. This is accomplished in Julia either with the * operator or the join function. This symbol might sound like a weird choice and it actually is. For now, many Julia codebases are using this symbol, so it will stay in the language. If you’re interested, you can read a discussion from 2015 about it at https://github.com/JuliaLang/julia/issues/11030.

  1. hello = "Hello"
  2. goodbye = "Goodbye"
  3. hello * goodbye
  1. HelloGoodbye

As you can see, we are missing a space between hello and goodbye. We could concatenate an additional " " string with the *, but that would be cumbersome for more than two strings. That’s where the join function comes in handy. We just pass as arguments the strings inside the brackets [] and the separator:

  1. join([hello, goodbye], " ")
  1. Hello Goodbye

3.3.3.2 String Interpolation

Concatenating strings can be convoluted. We can be much more expressive with string interpolation. It works like this: you specify whatever you want to be included in your string with the dollar sign $. Here’s the example before but now using interpolation:

  1. "$hello $goodbye"
  1. Hello Goodbye

It even works inside functions. Let’s revisit our test function from Section 3.2.5:

  1. function test_interpolated(a, b)
  2. if a < b
  3. "$a is less than $b"
  4. elseif a > b
  5. "$a is greater than $b"
  6. else
  7. "$a is equal to $b"
  8. end
  9. end
  10. test_interpolated(3.14, 3.14)
  1. 3.14 is equal to 3.14

3.3.3.3 String Manipulations

There are several functions to manipulate strings in Julia. We will demonstrate the most common ones. Also, note that most of these functions accept a Regular Expression (regex) as arguments. We won’t cover Regular Expressions in this book, but you are encouraged to learn about them, especially if most of your work uses textual data.

First, let us define a string for us to play around with:

  1. julia_string = "Julia is an amazing open source programming language"
  1. Julia is an amazing open source programming language
  1. contains, startswith and endswith: A conditional (returns either true or false) if the second argument is a:

    • substring of the first argument

      1. contains(julia_string, "Julia")
      1. true
    • prefix of the first argument

      1. startswith(julia_string, "Julia")
      1. true
    • suffix of the first argument

      1. endswith(julia_string, "Julia")
      1. false
  2. lowercase, uppercase, titlecase and lowercasefirst:

    1. lowercase(julia_string)
    1. julia is an amazing open source programming language
    1. uppercase(julia_string)
    1. JULIA IS AN AMAZING OPEN SOURCE PROGRAMMING LANGUAGE
    1. titlecase(julia_string)
    1. Julia Is An Amazing Open Source Programming Language
    1. lowercasefirst(julia_string)
    1. julia is an amazing open source programming language
  3. replace: introduces a new syntax, called the Pair

    1. replace(julia_string, "amazing" => "awesome")
    1. Julia is an awesome open source programming language
  4. split: breaks up a string by a delimiter:

    1. split(julia_string, " ")
    1. SubString{String}["Julia", "is", "an", "amazing", "open", "source", "programming", "language"]

3.3.3.4 String Conversions

Often, we need to convert between types in Julia. To convert a number to a string we can use the string function:

  1. my_number = 123
  2. typeof(string(my_number))
  1. String

Sometimes, we want the opposite: convert a string to a number. Julia has a handy function for that: parse.

  1. typeof(parse(Int64, "123"))
  1. Int64

Sometimes, we want to play safe with these conversions. That’s when tryparse function steps in. It has the same functionality as parse but returns either a value of the requested type, or nothing. That makes tryparse handy when we want to avoid errors. Of course, you would need to deal with all those nothing values afterwards.

  1. tryparse(Int64, "A very non-numeric string")
  1. nothing

3.3.4 Tuple

Julia has a data structure called tuple. They are really special in Julia because they are often used in relation to functions. Since functions are an important feature in Julia, every Julia user should know the basics of tuples.

A tuple is a fixed-length container that can hold multiple different types. A tuple is an immutable object, meaning that it cannot be modified after instantiation. To construct a tuple, use parentheses () to delimit the beginning and end, along with commas , as delimiters between values:

  1. my_tuple = (1, 3.14, "Julia")
  1. (1, 3.14, "Julia")

Here, we are creating a tuple with three values. Each one of the values is a different type. We can access them via indexing. Like this:

  1. my_tuple[2]
  1. 3.14

We can also loop over tuples with the for keyword. And even apply functions to tuples. But we can never change any value of a tuple since they are immutable.

Remember functions that return multiple values back in Section 3.2.4.2? Let’s inspect what our add_multiply function returns:

  1. return_multiple = add_multiply(1, 2)
  2. typeof(return_multiple)
  1. Tuple{Int64, Int64}

This is because return a, b is the same as return (a, b):

  1. 1, 2
  1. (1, 2)

So, now you can see why they are often related.

One more thing about tuples. When you want to pass more than one variable to an anonymous function, guess what you would need to use? Once again: tuples!

  1. map((x, y) -> x^y, 2, 3)
  1. 8

Or, even more than two arguments:

  1. map((x, y, z) -> x^y + z, 2, 3, 1)
  1. 9

3.3.5 Named Tuple

Sometimes, you want to name the values in tuples. That’s when named tuples comes in. Their functionality is pretty much same as tuples: they are immutable and can hold any type of value.

The construction of named tuples is slightly different from that of tuples. You have the familiar parentheses () and the comma , value separator. But now you name the values:

  1. my_namedtuple = (i=1, f=3.14, s="Julia")
  1. (i = 1, f = 3.14, s = "Julia")

We can access named tuple’s values via indexing like regular tuples or, alternatively, access by their names with the .:

  1. my_namedtuple.s
  1. Julia

To finish our discussion of named tuples, there is one important quick syntax that you’ll see a lot in Julia code. Often Julia users create a named tuple by using the familiar parenthesis () and commas ,, but without naming the values. To do so you begin the named tuple construction by specifying first a semicolon ; before the values. This is especially useful when the values that would compose the named tuple are already defined in variables or when you want to avoid long lines:

  1. i = 1
  2. f = 3.14
  3. s = "Julia"
  4. my_quick_namedtuple = (; i, f, s)
  1. (i = 1, f = 3.14, s = "Julia")

3.3.6 Ranges

A range in Julia represents an interval between start and stop boundaries. The syntax is start:stop:

  1. 1:10
  1. 1:10

As you can see, our instantiated range is of type UnitRange{T} where T is the type inside the UnitRange:

  1. typeof(1:10)
  1. UnitRange{Int64}

And, if we gather all the values, we get:

  1. [x for x in 1:10]
  1. [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

We can also construct ranges for other types:

  1. typeof(1.0:10.0)
  1. StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}

Sometimes, we want to change the default interval step size behavior. We can do that by adding a step size in the range syntax start:step:stop. For example, suppose we want a range of Float64 from 0 to 1 with steps of size 0.2:

  1. 0.0:0.2:1.0
  1. 0.0:0.2:1.0

If you want to “materialize” a range into a collection, you can use the function collect:

  1. collect(1:10)
  1. [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

We have an array of the type specified in the range between the boundaries that we’ve set. Speaking of arrays, let’s talk about them.

3.3.7 Array

In its most basic form, arrays hold multiple objects. For example, they can hold multiple numbers in one-dimension:

  1. myarray = [1, 2, 3]
  1. [1, 2, 3]

Most of the time you would want arrays of a single type for performance issues, but note that they can also hold objects of different types:

  1. myarray = ["text", 1, :symbol]
  1. Any["text", 1, :symbol]

They are the “bread and butter” of data scientist, because arrays are what underlies most of data manipulation and data visualization workflows.

Therefore, Arrays are an essential data structure.

3.3.7.1 Array Types

Let’s start with array types. There are several, but we will focus on the two most used in data science:

  • Vector{T}: one-dimensional array. Alias for Array{T, 1}.
  • Matrix{T}: two-dimensional array. Alias for Array{T, 2}.

Note here that T is the type of the underlying array. So, for example, Vector{Int64} is a Vector in which all elements are Int64s, and Matrix{AbstractFloat} is a Matrix in which all elements are subtypes of AbstractFloat.

Most of the time, especially when dealing with tabular data, we are using either one- or two-dimensional arrays. They are both Array types for Julia. But, we can use the handy aliases Vector and Matrix for clear and concise syntax.

3.3.7.2 Array Construction

How do we construct an array? In this section, we start by constructing arrays in a low-level way. This can be necessary to write high performing code in some situations. However, in most situations, this is not necessary, and we can safely use more convenient methods to create arrays. These more convenient methods will be described later in this section.

The low-level constructor for Julia arrays is the default constructor. It accepts the element type as the type parameter inside the {} brackets and inside the constructor you’ll pass the element type followed by the desired dimensions. It is common to initialize vector and matrices with undefined elements by using the undef argument for type. A vector of 10 undef Float64 elements can be constructed as:

  1. my_vector = Vector{Float64}(undef, 10)
  1. [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

For matrices, since we are dealing with two-dimensional objects, we need to pass two dimension arguments inside the constructor: one for rows and another for columns. For example, a matrix with 10 rows and 2 columns of undef elements can be instantiated as:

  1. my_matrix = Matrix{Float64}(undef, 10, 2)
  1. 10×2 Matrix{Float64}:
  2. 6.4e-323 6.93224e-310
  3. 1.04e-322 6.93224e-310
  4. 6.93224e-310 1.5e-322
  5. 6.93224e-310 1.5e-322
  6. 1.1e-322 6.93224e-310
  7. 1.24e-322 6.93224e-310
  8. 6.93224e-310 1.53e-322
  9. 6.93224e-310 1.8e-322
  10. 1.3e-322 6.93224e-310
  11. 1.43e-322 6.93224e-310

We also have some syntax aliases for the most common elements in array construction:

  • zeros for all elements being initialized to zero. Note that the default type is Float64 which can be changed if necessary:

    1. my_vector_zeros = zeros(10)
    1. [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
    1. my_matrix_zeros = zeros(Int64, 10, 2)
    1. 10×2 Matrix{Int64}:
    2. 0 0
    3. 0 0
    4. 0 0
    5. 0 0
    6. 0 0
    7. 0 0
    8. 0 0
    9. 0 0
    10. 0 0
    11. 0 0
  • ones for all elements being initialized to one:

    1. my_vector_ones = ones(Int64, 10)
    1. [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    1. my_matrix_ones = ones(10, 2)
    1. 10×2 Matrix{Float64}:
    2. 1.0 1.0
    3. 1.0 1.0
    4. 1.0 1.0
    5. 1.0 1.0
    6. 1.0 1.0
    7. 1.0 1.0
    8. 1.0 1.0
    9. 1.0 1.0
    10. 1.0 1.0
    11. 1.0 1.0

For other elements, we can first instantiate an array with undef elements and use the fill! function to fill all elements of an array with the desired element. Here’s an example with 3.14 (\(\pi\)):

  1. my_matrix_π = Matrix{Float64}(undef, 2, 2)
  2. fill!(my_matrix_π, 3.14)
  1. 2×2 Matrix{Float64}:
  2. 3.14 3.14
  3. 3.14 3.14

We can also create arrays with array literals. For example, here’s a 2x2 matrix of integers:

  1. [[1 2]
  2. [3 4]]
  1. 2×2 Matrix{Int64}:
  2. 1 2
  3. 3 4

Array literals also accept a type specification before the [] brackets. So, if we want the same 2x2 array as before but now as floats, we can do so:

  1. Float64[[1 2]
  2. [3 4]]
  1. 2×2 Matrix{Float64}:
  2. 1.0 2.0
  3. 3.0 4.0

It also works for vectors:

  1. Bool[0, 1, 0, 1]
  1. Bool[0, 1, 0, 1]

You can even mix and match array literals with the constructors:

  1. [ones(Int, 2, 2) zeros(Int, 2, 2)]
  1. 2×4 Matrix{Int64}:
  2. 1 1 0 0
  3. 1 1 0 0
  1. [zeros(Int, 2, 2)
  2. ones(Int, 2, 2)]
  1. 4×2 Matrix{Int64}:
  2. 0 0
  3. 0 0
  4. 1 1
  5. 1 1
  1. [ones(Int, 2, 2) [1; 2]
  2. [3 4] 5]
  1. 3×3 Matrix{Int64}:
  2. 1 1 1
  3. 1 1 2
  4. 3 4 5

Another powerful way to create an array is to write an array comprehension. This way of creating arrays is better in most cases: it avoids loops, indexing, and other error-prone operations. You specify what you want to do inside the [] brackets. For example, say we want to create a vector of squares from 1 to 10:

  1. [x^2 for x in 1:10]
  1. [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

They also support multiple inputs:

  1. [x*y for x in 1:10 for y in 1:2]
  1. [1, 2, 2, 4, 3, 6, 4, 8, 5, 10, 6, 12, 7, 14, 8, 16, 9, 18, 10, 20]

And conditionals:

  1. [x^2 for x in 1:10 if isodd(x)]
  1. [1, 9, 25, 49, 81]

As with array literals, you can specify your desired type before the [] brackets:

  1. Float64[x^2 for x in 1:10 if isodd(x)]
  1. [1.0, 9.0, 25.0, 49.0, 81.0]

Finally, we can also create arrays with concatenation functions. Concatenation is a standard term in computer programming and means “to chain together.” For example, we can concatenate strings with “aa” and “bb” to get “aabb”:

  1. "aa" * "bb"

aabb

And, we can concatenate arrays to create new arrays:

  • cat: concatenate input arrays along a specific dimension dims

    1. cat(ones(2), zeros(2), dims=1)
    1. [1.0, 1.0, 0.0, 0.0]
    1. cat(ones(2), zeros(2), dims=2)
    1. 2×2 Matrix{Float64}:
    2. 1.0 0.0
    3. 1.0 0.0
  • vcat: vertical concatenation, a shorthand for cat(...; dims=1)

    1. vcat(ones(2), zeros(2))
    1. [1.0, 1.0, 0.0, 0.0]
  • hcat: horizontal concatenation, a shorthand for cat(...; dims=2)

    1. hcat(ones(2), zeros(2))
    1. 2×2 Matrix{Float64}:
    2. 1.0 0.0
    3. 1.0 0.0

3.3.7.3 Array Inspection

Once we have arrays, the next logical step is to inspect them. There are a lot of handy functions that allow the user to have an insight into any array.

It is most useful to know what type of elements are inside an array. We can do this with eltype:

  1. eltype(my_matrix_π)
  1. Float64

After knowing its types, one might be interested in array dimensions. Julia has several functions to inspect array dimensions:

  • length: total number of elements

    1. length(my_matrix_π)
    1. 4
  • ndims: number of dimensions

    1. ndims(my_matrix_π)
    1. 2
  • size: this one is a little tricky. By default it will return a tuple containing the array’s dimensions.

    1. size(my_matrix_π)
    1. (2, 2)

    You can get a specific dimension with a second argument to size. Here, the the second axis is columns

    1. size(my_matrix_π, 2)
    1. 2

3.3.7.4 Array Indexing and Slicing

Sometimes, we want to inspect only certain parts of an array. This is called indexing and slicing. If you want a particular observation of a vector, or a row or column of a matrix, you’ll probably need to index an array.

First, we will create an example vector and matrix to play around:

  1. my_example_vector = [1, 2, 3, 4, 5]
  2. my_example_matrix = [[1 2 3]
  3. [4 5 6]
  4. [7 8 9]]

Let’s start with vectors. Suppose that you want the second element of a vector. You append [] brackets with the desired index inside:

  1. my_example_vector[2]
  1. 2

The same syntax follows with matrices. But, since matrices are 2-dimensional arrays, we have to specify both rows and columns. Let’s retrieve the element from the second row (first dimension) and first column (second dimension):

  1. my_example_matrix[2, 1]
  1. 4

Julia also has conventional keywords for the first and last elements of an array: begin and end. For example, the second to last element of a vector can be retrieved as:

  1. my_example_vector[end-1]
  1. 4

This also works for matrices. Let’s retrieve the element of the last row and second column:

  1. my_example_matrix[end, begin+1]
  1. 8

Often, we are not only interested in just one array element, but in a whole subset of array elements. We can accomplish this by slicing an array. It uses the same index syntax, but with the added colon : to denote the boundaries that we are slicing through the array. For example, suppose we want to get the 2nd to 4th element of a vector:

  1. my_example_vector[2:4]
  1. [2, 3, 4]

We could do the same with matrices. Particularly with matrices if we want to select all elements in a following dimension we can do so with just a colon :. For example, to get all the elements in the second row:

  1. my_example_matrix[2, :]
  1. [4, 5, 6]

You can interpret this with something like “take the 2nd row and all the columns.”

It also supports begin and end:

  1. my_example_matrix[begin+1:end, end]
  1. [6, 9]

3.3.7.5 Array Manipulations

There are several ways we could manipulate an array. The first would be to manipulate a singular element of the array. We just index the array by the desired element and proceed with an assignment =:

  1. my_example_matrix[2, 2] = 42
  2. my_example_matrix
  1. 3×3 Matrix{Int64}:
  2. 1 2 3
  3. 4 42 6
  4. 7 8 9

Or, you can manipulate a certain subset of elements of the array. In this case, we need to slice the array and then assign with =:

  1. my_example_matrix[3, :] = [17, 16, 15]
  2. my_example_matrix
  1. 3×3 Matrix{Int64}:
  2. 1 2 3
  3. 4 42 6
  4. 17 16 15

Note that we had to assign a vector because our sliced array is of type Vector:

  1. typeof(my_example_matrix[3, :])
  1. Vector{Int64} (alias for Array{Int64, 1})

The second way we could manipulate an array is to alter its shape. Suppose that you have a 6-element vector and you want to make it a 3x2 matrix. You can do this with reshape, by using the array as the first argument and a tuple of dimensions as the second argument:

  1. six_vector = [1, 2, 3, 4, 5, 6]
  2. three_two_matrix = reshape(six_vector, (3, 2))
  3. three_two_matrix
  1. 3×2 Matrix{Int64}:
  2. 1 4
  3. 2 5
  4. 3 6

You can convert it back to a vector by specifying a tuple with only one dimension as the second argument:

  1. reshape(three_two_matrix, (6, ))
  1. [1, 2, 3, 4, 5, 6]

The third way we could manipulate an array is to apply a function over every array element. This is where the “dot” operator ., also known as broadcasting, comes in.

  1. logarithm.(my_example_matrix)
  1. 3×3 Matrix{Float64}:
  2. 0.0 0.693147 1.09861
  3. 1.38629 3.73767 1.79176
  4. 2.83321 2.77259 2.70805

The dot operator in Julia is extremely versatile. You can even use it to broadcast infix operators:

  1. my_example_matrix .+ 100
  1. 3×3 Matrix{Int64}:
  2. 101 102 103
  3. 104 142 106
  4. 117 116 115

An alternative to broadcasting a function over a vector is to use map:

  1. map(logarithm, my_example_matrix)
  1. 3×3 Matrix{Float64}:
  2. 0.0 0.693147 1.09861
  3. 1.38629 3.73767 1.79176
  4. 2.83321 2.77259 2.70805

For anonymous functions, map is usually more readable. For example,

  1. map(x -> 3x, my_example_matrix)
  1. 3×3 Matrix{Int64}:
  2. 3 6 9
  3. 12 126 18
  4. 51 48 45

is quite clear. However, the same broadcast looks as follows:

  1. (x -> 3x).(my_example_matrix)
  1. 3×3 Matrix{Int64}:
  2. 3 6 9
  3. 12 126 18
  4. 51 48 45

Next, map works with slicing:

  1. map(x -> x + 100, my_example_matrix[:, 3])
  1. [103, 106, 115]

Finally, sometimes, and specially when dealing with tabular data, we want to apply a function over all elements in a specific array dimension. This can be done with the mapslices function. Similar to map, the first argument is the function and the second argument is the array. The only change is that we need to specify the dims argument to flag what dimension we want to transform the elements.

For example, let’s use mapslices with the sum function on both rows (dims=1) and columns (dims=2):

  1. # rows
  2. mapslices(sum, my_example_matrix; dims=1)
  1. 1×3 Matrix{Int64}:
  2. 22 60 24
  1. # columns
  2. mapslices(sum, my_example_matrix; dims=2)
  1. 3×1 Matrix{Int64}:
  2. 6
  3. 52
  4. 48

3.3.7.6 Array Iteration

One common operation is to iterate over an array with a for loop. The regular for loop over an array returns each element.

The simplest example is with a vector.

  1. simple_vector = [1, 2, 3]
  2. empty_vector = Int64[]
  3. for i in simple_vector
  4. push!(empty_vector, i + 1)
  5. end
  6. empty_vector
  1. [2, 3, 4]

Sometimes, you don’t want to loop over each element, but actually over each array index. We can use the eachindex function combined with a for loop to iterate over each array index.

Again, let’s show an example with a vector:

  1. forty_twos = [42, 42, 42]
  2. empty_vector = Int64[]
  3. for i in eachindex(forty_twos)
  4. push!(empty_vector, i)
  5. end
  6. empty_vector
  1. [1, 2, 3]

In this example, the eachindex(forty_twos) returns the indices of forty_twos, namely [1, 2, 3].

Similarly, we can iterate over matrices. The standard for loop goes first over columns then over rows. It will first traverse all elements in column 1, from the first row to the last row, then it will move to column 2 in a similar fashion until it has covered all columns.

For those familiar with other programming languages: Julia, like most scientific programming languages, is “column-major.” Column-major means that the elements in the column are stored next to each other in memory13. This also means that iterating over elements in a column is much quicker than over elements in a row.

Ok, let’s show this in an example:

  1. column_major = [[1 3]
  2. [2 4]]
  3. row_major = [[1 2]
  4. [3 4]]

If we loop over the vector stored in column-major order, then the output is sorted:

  1. indexes = Int64[]
  2. for i in column_major
  3. push!(indexes, i)
  4. end
  5. indexes
  1. [1, 2, 3, 4]

However, the output isn’t sorted when looping over the other matrix:

  1. indexes = Int64[]
  2. for i in row_major
  3. push!(indexes, i)
  4. end
  5. indexes
  1. [1, 3, 2, 4]

It is often better to use specialized functions for these loops:

  • eachcol: iterates over an array column first

    1. first(eachcol(column_major))
    1. [1, 2]
  • eachrow: iterates over an array row first

    1. first(eachrow(column_major))
    1. [1, 3]

3.3.8 Pair

Compared to the huge section on arrays, this section on pairs will be brief. Pair is a data structure that holds two objects (which typically belong to each other). We construct a pair in Julia using the following syntax:

  1. my_pair = "Julia" => 42
  1. "Julia" => 42

The elements are stored in the fields first and second.

  1. my_pair.first
  1. Julia
  1. my_pair.second
  1. 42

But, in most cases, it’s easier use first and last14:

  1. first(my_pair)
  1. Julia
  1. last(my_pair)
  1. 42

Pairs will be used a lot in data manipulation and data visualization since both DataFrames.jl (Section 4) or Makie.jl (Section 6) take objects of type Pair in their main functions. For example, with DataFrames.jl we’re going to see that :a => :b can be used to rename the column :a to :b.

3.3.9 Dict

If you understood what a Pair is, then Dict won’t be a problem. For all practical purposes, Dicts are mappings from keys to values. By mapping, we mean that if you give a Dict some key, then the Dict can tell you which value belongs to that key. keys and values can be of any type, but usually keys are strings.

There are two ways to construct Dicts in Julia. The first is by passing a vector of tuples as (key, value) to the Dict constructor:

  1. name2number_map = Dict([("one", 1), ("two", 2)])
  1. Dict{String, Int64} with 2 entries:
  2. "two" => 2
  3. "one" => 1

There is a more readable syntax based on the Pair type described above. You can also pass Pairs of key => values to the Dict constructor:

  1. name2number_map = Dict("one" => 1, "two" => 2)
  1. Dict{String, Int64} with 2 entries:
  2. "two" => 2
  3. "one" => 1

You can retrieve a Dict’s value by indexing it by the corresponding key:

  1. name2number_map["one"]
  1. 1

To add a new entry, you index the Dict by the desired key and assign a value with the assignment = operator:

  1. name2number_map["three"] = 3
  1. 3

If you want to check if a Dict has a certain key you can use keys and in:

  1. "two" in keys(name2number_map)
  1. true

To delete a key you can use either the delete! function:

  1. delete!(name2number_map, "three")
  1. Dict{String, Int64} with 2 entries:
  2. "two" => 2
  3. "one" => 1

Or, to delete a key while returning its value, you can use pop!:

  1. popped_value = pop!(name2number_map, "two")
  1. 2

Now, our name2number_map has only one key:

  1. name2number_map
  1. Dict{String, Int64} with 1 entry:
  2. "one" => 1

Dicts are also used for data manipulation by DataFrames.jl (Section 4) and for data visualization by Makie.jl (Section 6). So, it is important to know their basic functionality.

There is another useful way of constructing Dicts. Suppose that you have two vectors and you want to construct a Dict with one of them as keys and the other as values. You can do that with the zip function which “glues” together two objects (just like a zipper):

  1. A = ["one", "two", "three"]
  2. B = [1, 2, 3]
  3. name2number_map = Dict(zip(A, B))
  1. Dict{String, Int64} with 3 entries:
  2. "two" => 2
  3. "one" => 1
  4. "three" => 3

For instance, we can now get the number 3 via:

  1. name2number_map["three"]
  1. 3

3.3.10 Symbol

Symbol is actually not a data structure. It is a type and behaves a lot like a string. Instead of surrounding the text by quotation marks, a symbol starts with a colon (:) and can contain underscores:

  1. sym = :some_text
  1. :some_text

We can easily convert a symbol to string and vice versa:

  1. s = string(sym)
  1. some_text
  1. sym = Symbol(s)
  1. :some_text

One simple benefit of symbols is that you have to type one character less, that is, :some_text versus "some text". We use Symbols a lot in data manipulations with the DataFrames.jl package (Section 4) and data visualizations with the Makie.jl package (Section 6).

3.3.11 Splat Operator

In Julia we have the “splat” operator ... which is used in function calls as a sequence of arguments. We will occasionally use splatting in some function calls in the data manipulation and data visualization chapters.

The most intuitive way to learn about splatting is with an example. The add_elements function below takes three arguments to be added together:

  1. add_elements(a, b, c) = a + b + c
  1. add_elements (generic function with 1 method)

Now, suppose that we have a collection with three elements. The naïve way to this would be to supply the function with all three elements as function arguments like this:

  1. my_collection = [1, 2, 3]
  2. add_elements(my_collection[1], my_collection[2], my_collection[3])
  1. 6

Here is where we use the “splat” operator ... which takes a collection (often an array, vector, tuple, or range) and converts it into a sequence of arguments:

  1. add_elements(my_collection...)
  1. 6

The ... is included after the collection that we want to “splat” into a sequence of arguments. In the example above, the following are the same:

  1. add_elements(my_collection...) == add_elements(my_collection[1], my_collection[2], my_collection[3])
  1. true

Anytime Julia sees a splatting operator inside a function call, it will be converted on a sequence of arguments for all elements of the collection separated by commas.

It also works for ranges:

  1. add_elements(1:3...)
  1. 6

3.3 Native Data Structures - 图1 Support this project
CC BY-NC-SA 4.0 Jose Storopoli, Rik Huijzer, Lazaro Alonso