Skip to main content

Chapter 12 Creating New Data Types in Julia

Although there are many standard Julia data types that are useful for Scientific Computing, we can make new types called a composite data type. We will start with developing Card and Hand types that simplify the poker hand simulations that we will see in Chapter 20. We will also look at a Polynomial data type and finally create a Root type that will help with the rootfinding from Chapter 10.

Section 12.1 Basics of Julia’s Composite Datatype

The composite data type in Julia is called a struct and consists of some number of fields of an existing type. A simple one is
struct MyStruct
  num::Integer
  str::String
end
which has the two fields num and str. We are using the standard naming convention of a struct in Pascal case, which starts with a capital letter and and other words capitalized. We can create an object
 1 
Although a Julia struct is not an object in the sense of an object-oriented language, the terminology is pervasive in the Julia community.
of type MyStruct by
m = MyStruct(13,"hello")
and we can access the fields of the struct with m.num and m.str. They will return 13 and "hello" respectively. Also, you can get an array of the names of the fields with fieldnames(MyStruct). Note that the fieldnames command needs the datatype, not a variable of the datatype. This example is not very realistic, but the rest of the chapter will include more-practical examples.
A helpful method associated with objects is the dump method. If we type dump(m) then we get:
MyStruct
  num: Int64 13
  str: String "hello"
showing the name of the type: MyStruct, the fields and their types as well as the values.
One cannot change the fields of a struct. If m is defined as above and you try m.str = "goodbye", then Julia will report the error:
setfield!: immutable struct of type MyStruct cannot be changed
This is exactly like trying to redefine a const. As we will see most of the examples in this text are these constant types of structs, you can make non-constant structs using the mutable keyword. For example:
mutable struct MutableStruct
  a::Float64
  b::Integer
end
and then define an object such as
s = MutableStruct(1,2)
Then the redefinition s.a=4.5 is allowed. Note: that if you try to do s.b=7.1, the you will get the error:
InexactError: Int64(7.1)
which happens because you are trying to assign the value 7.1 to an integer, since the field b is an integer.

Section 12.2 A Card datatype

In Chapter 20, we will use simulations to determine the probabilities of certain poker hands. Although the calculations can be done with built-in types, the result would be difficult to read and hard to understand. Here, we will create a Card datatype that will help clarify the code by creating a type with a rank (1 to 13 corresponding to Ace, 2, through 10, Jack, Queen, King) and a suit (1 to 4 corresponding to the suits ``spades’’, ``hearts’’, ``diamonds’’, ``clubs’’), which we define as the characters arrays:
ranks = ['A','2','3','4','5','6','7','8','9','T','J','Q','K']
suits = ['\u2660','\u2661','\u2662','\u2663']
where the suits are the unicode characters
 2 
for the suits. (Note: ranks and suits are arrays of characters (not strings). This is because they use single quotes.). The following datatype:
struct Card
  rank::Integer
  suit::Integer
end
will be used to store a Card. Note that the suits and ranks are each integers, where we uses the suits 1, 2, 3, and 4 (which will corresponds to the actual suits) and the ranks will be the integers 1 to 13.
To create a Card, just call the struct like a function with the given rank and suit, like c = Card(3,2), which will create a card of rank 3 and suit 2 (hearts). To access the fields of the type, we use dot notation. For example c.rank will return 3 and c.suit will return 2.
Notice that the result of the card is Card(3,2), which isn’t very human friendly-I’d rather see the actual rank and suit. Fortunately, a nice feature of Julia is to override the output using Base.show. For example,
Base.show(io::IO, c::Card) = print(io, "$(ranks[c.rank])$(suits[c.suit])")
where the arguments of Base.show should be of type IO and then the datatype. The output from Base.show should call print as above. The result will be the concatenation of the characters corresponding to the rank and suit of the card. Any of the forms in Chapter 2 that do string concatenation will work, I’m a fan of string interpolation. Try this out by typing c and you should get 3♡.
Note: this is different than just a print or println within a function, which is highly discouraged. This function is called whenever a card type is displayed.

Subsection 12.2.1 Constructors of new data types

When Julia calls Card(3,2), actually, it executes a special function called a constructor, which creates an instance of the type, which we have called an object. Although Julia creates the basic constructor--that is the one that fills the fields of the type in the order defined, we may want some additional features. For example if we call Card(-10,78), then Julia returns
BoundsError: attempt to access 13-element Vector{Char} at index [-10]
and we got the error because on Base.show we are trying to access an array outside of it’s bounds. We just told Julia to make a new data type with two integers and Card(-10,78) is the new data type with two integers.
We can ensure that that the Cards only take the first argument as numbers between 1 and 13 and the second as between 1 and 4 by the updating the Card datatype as follows:
struct Card
  rank::Int
  suit::Int
  # construct a card based on the rank and suit
  function Card(r::Int,s::Int)
    1 <= r <=13  || throw(ArgumentError("The rank must be an integer between 1 and 13."))
    1 <= s <= 4  || throw(ArgumentError("The suit must be an integer between 1 and 4."))
    new(r,s)
  end
end
If you enter the above struct you should see the error:
invalid redefinition of type Card
Recall, from the beginning of this chapter, this is because a struct is fixed, much like if a constant is declared and then it is redefined. We want to replace the first struct with this one, so we will restart the kernel
 3 
Recall, in Juypter, select restart kernel at the top and if you are using VS Code, the restart button will do the same.
You will need to reenter all of the needed cells, like the one with the ranks and suits as well as the one that starts Base.show. In Chapter 23, we will put structs inside a module and not have to restart the kernel as you develop a new datatype.
Also note that on lines 6 and 7 we have used the standard Julia style for checking arguments, that is, give the range of valid choice and then with the short-circuit || throw the error.
After restarting the kernel and rerunning the important cells, if we call Card(-10,78), then we get
ArgumentError: The rank must be an integer between 1 and 13.
which is what we want and should notice that this error is what we said to do when the rank is not between 1 and 13. Although we haven’t covered handling errors yet, the throw function will send an error and we have the ability to catch and handle these errors gracefully if we want.
Some other things to note about this new definition of Card
  • There are still two members of the struct, rank and suit listed on lines 2 and 3.
  • There is a function defined on lines 5 through 9 now. This is what is called when Card is called with two integers and is called a constructor. Line 6 checks if the rank (for this function is called r) is between 1 and 13 and similarly on line 7 checks if the suits (called s) is between 1 and 4. If either of these are outside the bounds an error is thrown. We’ll talk specifically about what throw does later in the text.
  • Inside the the constructor, line 8 is new(r,s). This assigns the member rank the value r and suit the value s.
We would also like another constructor that will take an integer between 1 and 52 and returns the appropriate card. The following example will do this.
struct Card
  rank::Int
  suit::Int
  # construct a card based on the rank and suit
  function Card(r::Int,s::Int)
    1 <= r <=13  || throw(ArgumentError("The rank must be an integer between 1 and 13."))
    1 <= s <= 4  || throw(ArgumentError("The suit must be an integer between 1 and 4."))
    new(r,s)
  end
  # construct a card based on the number in a deck
  function Card(i::Int)
    1 <= i <= 52 || throw(ArgumentError("The argument must be an integer between 1 and 52"))
    new(mod1(i,13), div(i-1,13)+1)
  end
end
And if you get the error that you cannot redefine Card, then again, restart the kernel. The big difference with this definition is a second constructor function on lines 13-16 is another function with name Card. The important parts of this second constructor is
  • Line 11 is the Card constructor for a single integer.
  • Line 12 checks that the input i is between 1 and 52. If not an error is thrown.
  • Line 15 defines the new Card. First, the mod1 function performs modular division except that instead of mod(4,4)=0, then mod1(4,4)=4, it shifts the 0 remainder to remainder n. This is helpful for situations like this or accessing arrays that are 1-indexed.
  • The second part of the Card is div(i-1,13)+1. This will also shift values that have 0 modulus to the proper rank.
Remark 12.1.
Notice that we have 3 different versions of the Card type. In the first version, only the members were defined. There were no constructors. Often, this is all that is needed for a type/struct, however, if you need to do some checking on arguments, then you will need to write a constructor or two.
If you do use a constructor, define it as a function with the same name as the type (the case must be the same as well). The last line in constructor function should either be the new function that fills the member fields in the same order as listed in the struct or you can call other constructors. See additional information about constructors in the Julia Documentation

Subsection 12.2.2 Creating a Hand datatype

As we will see in Chapter 20, a hand is also helpful in playing card games, we will define a hand in the following way:
struct Hand
  cards::Vector{Card}
end
which is just an array of cards. (Note: there is nothing here that specifies that the Hand has to be 5 cards, but that could be included by doing some error checking in the constructor). An example hand would be
h1 = Hand([Card(2,3),Card(12,1),Card(10,1),Card(10,4),Card(5,2)])
and again, since this looks a bit ugly, we can define a Base.show method for a hand:
Base.show(io::IO,h::Hand) = print(io, "[$(join(h.cards,", "))]")
Then evaluating h1 should now return [2♢, Q♠, T♠, T♣, 5♡].
We will return to this in Chapter 20 where we will use this datatype to help with simulations.
A note about the Base.show function for the Hand. As with the Base.show for the Card type, the arguments are something of type IO and then something of type Hand. The function then calls print with the second argument a String. The main part of this is the join command which takes an array of strings and joins (recall this from Section 6.10) them separated by the second argument, ", ". The rest of this just surrounds them by square brackets.

Section 12.3 Polynomials: A parametric data type

Recall that a polynomial is the sum of nonnegative powers of \(x\) with constant coefficients. That is anything of the form:
\begin{equation*} P(x) = a_0 + a_1 x + a_2 x^2 + \cdots + a_n x^n \end{equation*}
and we may want to solve problems with them and it may be helpful to have a data type of this form. We can represent any polynomial by the coefficients. For example, the following will construct a datatype that is a polynomial with integer coefficients
 4 
As well will see in Chapter 40, a Vector is shorthand or an alias for a 1D array
:
struct Polynomial
  coeffs::Vector{Int64}
end
We can then represent the polynomial \(P(x) = 2-x+3x^2\) with
p = Polynomial([2,-1,3])
Let’s say that we want to construct methods to add, subtract, multiply two polynomials and perform a scalar multiply as well. However, if we want to allow Polynomials with coefficients other than integer coefficients, such as rational or real or complex, then we would need to write a set of functions to do this for each datatype, which is 1) painfully dull, 2) hard to maintain since there are many copies of the same function and 3) unnecessary because Julia has a nice feature called a parametric type.
We could define the Polynomial as
struct Polynomial{T}
  coeffs::Vector{T}
end
which has Polynomial that has a type T. This now allows with one definition to have a polynomial of integers, floats, complex and rationals. However, this would also allow us to define a polynomial of strings or Cards, which doesn’t make any sense. We can restrict the coefficients to number types with
struct Polynomial{T <: Number}
  coeffs::Vector{T}
end
which defines the coefficients to be a Vector of type T and T from the first line is any subtype of Number, which is what the notation <: Number means. Recall that Chapter 3 explain the data typing system and how to determine subtypes of a type. If a struct is created in a way like this to depend on a type, it is called a parametric type.
For example, using the above definition of Polynomial, we can define the following
poly1=Polynomial([1, 2, 3])
poly2=Polynomial([1.0, 2.0, 3.0])
poly3=Polynomial([2//3, 3//4, 5//8])
poly4=Polynomial([im, 2+0im, 3-2im, -im])
poly5=Polynomial([n for n=1:6])
and the result of the last will be Polynomial{Int64}([1, 2, 3, 4, 5, 6]).
The beginning of the output, Polynomial{Int64} explains that it is a type polynomial. The Int64 within the Polynomial type explains what type the coefficients have. If you enter the variable name, like poly3, then you will see the type of the coefficient (type Rational{Int64}) for that variable as well.
It would be nice if the result looked like a polynomial. In this case, we can use the Base.show command.
function Base.show(io::IO, p::Polynomial)
  print(io, mapreduce(i -> "$(p.coeffs[i]) x^$(i-1)", (str, term) -> "$str + $term " , 1:length(p.coeffs)))
end
Now, if we reevaluate poly5, then Julia returns 1 x^0 + 2 x^1 + 3 x^2 + 4 x^3 + 5 x^4 + 6 x^5. The Base.show command is complicated, so let’s break this down a bit
  • The first argument is of type IO. Note that we also call the argument io. It doesn’t matter what it is called, but we’ll always use io in this book. The second argument is of type Polynomial.
  • The output as before is a print with first argument io and second argument a string using the mapreduce function.
  • Recall from section Section 7.2 that the mapreduce function takes an array and two functions and returns a single value. In this case, our array is the integers from 1 to the length of the coeffs field of the struct (or more precisely the range 1:length(p.coeffs)).
  • The first function is the mapping function. It takes the indices of the array and in case produces the term of the polynomial.
  • The second function of the mapreduce function performs a concatentation. The current string is str, which is the cumulating string and term is the result of the mapping function. This concatenation is just that using string interpolation.
  • The array that is used for the reduce is the UnitStep 1:length(p.coeffs) instead of the coefficients themselves. This allows the powers of x to be generated.
  • This isn’t perfect. For example, if there are negative coefficients, then you’ll see a +- for a term. This can be taken care of with another ternary if-then-else. Also, it would be nice to eliminate the x^0 term (because it is just 1 as well as instead of writing x^1 write x, but this is just a little icing). Lastly, if you have a term with a 0 coefficient, you will see it and it might be nice to just skip writing the term.
  • Note that there are many other ways to write this Base.show for a polynomial. One way would be to use a for loop to go through the polynomials. I used a mapreduce to show another example of how to use this function in practical way.

Subsection 12.3.1 Adding Two Polynomials

Another nice thing that we’d like to do is have an add command and use the symbol + for this. However, if we enter poly1+poly2, then we get the error:
MethodError: no method matching +(::Polynomial{Int64}, ::Polynomial{Float64})
The function `+` exists, but no method is defined for this combination of argument types.
To create a function that adds two polynomials, we first need to do the following:
import Base.+
and then define how to add two polynomials with:
function +(p1::Polynomial{<: Number}, p2::Polynomial{<: Number})
  Polynomial(p1.coeffs+p2.coeffs)
end
and then we can rerun the function + above. This is now a function that adds two polynomials. Note that the coefficients do not need to be the same type, so there are two types, T and S available. If we now enter poly1+poly2, the result is 2.0 x^0 + 4.0 x^1 + 6.0 x^2.
Note that this now a polynomial with floating point coefficients.
Check Your Understanding 12.2.
Similar to the function above, write functions that
(a)
subtract two polynomials (you’ll need to import Base.-)
(b)
multiply a polynomial by a constant. (you’ll need to import Base.*)
(c)
multiply two polynomials. Note: remember to multiply polynomials you need to distribute (aka FOIL), not just multiply the coefficients.

Subsection 12.3.2 Evaluating the polynomial

Another helpful function is to actually evaluate the polynomial. The basic way to do this is to sum the terms of the polynomial. A possible way to write this is using the reduce function that we saw in Section 7.2.
function eval(poly::Polynomial{<:Number}, x::Number)
  reduce((val,i) -> val + poly.coeffs[i]*x^(i-1), 1:length(poly.coeffs))
end
and now if we evaluate poly1 when x=-2, we enter eval(poly1,-2) and get 9.
An alternative to this is to use Horner’s form of the polynomial that we saw in Subsection 28.2.1:
function eval(poly::Polynomial{<:Number}, x::Number)
  reduce((val,c) -> x*val+c, reverse(poly.coeffs))
end
We have used reduce for each of these evaluation methods. Let’s dive into these a bit more
  • The definition of the function is identical in both methods. Recall that since Polynomial is a parametric type (that is, it has multiple subtypes) and we want the type to be a subtype of Number.
  • In the first eval method, the function is the terms themselves, poly.coeffs[i]*x^(i-1) and like the Base.show function above, the array used in 1:length(poly.coeffs) not the coefficients. This allows the use of the powers.
  • In the section method (Horner’s method), again we use reduce however is much simpler. First, the innermost part of the method is a_{n-1} + a_n * x, and this is exactly the function used above, where val is the cumulative value going through the coefficients. Also, note that we needed to reverse the coefficient array because it’s important that we go inside out--that is, the last coefficient is used first.
  • Reduce also has an init option that we didn’t use here. This is because initially these are both 0 and that is the default.

Section 12.4 A Rootfinding datatype

The last example of a new data type will be related to finding the root of a function. We explored this in Chapter 10. One major issue with that function is that if Newton’s method did not find the root, it wasn’t clear if it was a root or just that the algorithm stopped because it reached 10 steps. There was no way that you (the user) knew which case occurred. We will use a struct to store the information about the rootfinding in this section which then conveys what happens.
In Section 10.4, we developed the following function for solving Newton’s method:
using ForwardDiff
function newton(f::Function, x0::Number; tol::Real = 1e-6, max_steps = 10)
  for _ = 1:max_steps
    dx = f(x0)/ForwardDiff.derivative(f, x0)
    x0 -= dx
    abs(dx) > tol && return x0
  end
  x0
end
Although if the function has a root and the method converges to it, then this method will probably find it, however, just running it, we don’t know if it has exceeded the total number of steps defaulted to 10 or specified or reached the root.
We are going to define a struct to deliver more information as follows:
struct Root
  root::Float64    #  approximate value of the root
  x_eps::Float64   #  estimate of the error in the x variable
  f_eps::Float64   #  function value at the root f(root)
  num_steps::Int   #  number of steps the method used
  converged::Bool  #  whether or not the stopping criterion was reached
  max_steps::Int   #  the maximum number of steps allowed
end
and then return an object of type Root. Thus we will replace the function newton above with
function newton(f::Function, x0::Number; tol::Real = 1e-6, max_steps = 10)
  tol > 0 || throw(ArgumentError("The parameter tol much be positive"))
  max_steps > 0 || throw(ArgumentError("The parameter max_steps much be positive"))
  local dx
  for i = 1:max_steps
    dx = f(x0)/ForwardDiff.derivative(f, x0)
    x0 -= dx
    abs(dx) < 1e-6 && return Root(x0, dx, f(x0), i, true, max_steps)
  end
  Root(x0, dx, f(x0), max_steps, false, 10)
end
and note that we store extra information that is returned. Note that are a few main changes. First, we need to declare dx outside the loop because it is needed below. Secondly, we added the index of the loop to be the variable i since we need it for the number of steps returned. Lastly, we return the Root object in two different ways. Inside the loop we test if the dx value is within tol, the we return the Root with all of the values.
If we reach the end of the for loop, the last line is reached, which means Newton’s method doesn’t converge. Let’s run Newton’s method on \(f(x)=x^2-2\) as an example by evaluating
 newton(x->x^2-2,1) 
returns Root(1.4142135623730951, 1.5947429102833119e-12, 4.440892098500626e-16, 5, true, 10), which again gives a lot of information, but perhaps not very easy to read since you would need to know each of the fields and what each means. We could then create a Base.show function to help with readability:
function Base.show(io::IO,r::Root)
  str = r.converged ? """The root is approximately x̂ = $(r.root)
    An estimate for the error is $(r.x_eps)
    with f(x̂) = $(r.f_eps)
    which took $(r.num_steps) steps""" :
    """The root was not found within $(r.max_steps) steps.
    Currently, the root is approximately x̂ = $(r.root).
    An estimate for the error is $(r.x_eps)
    with f(x̂) = $(r.f_eps)."""
  print(io,str)
end
which will print out different information if it converges or not. Notice also that we have used the string interpolation $( ) explained in Chapter 2, however need to include the parentheses around these variables because we want the field to be looked up. (You can remove the () around one of the variables to see what happens--there will be an error.)
If we now run Newton’s method on \(f(x)=x^2-2\) with newton(x->x^2-2,1) then we get:
The root is approximately x̂ = 1.4142135623730951
An estimate for the error is 1.5947429102833119e-12
with f(x̂) = 4.440892098500626e-16
which took 5 steps
and then if we run it on a function that does not have a root. Recall in Chapter 10, we ran Newton’s method on \(f(x)=x^2+1\text{.}\) Since \(x^2\) is nonnegative then the function \(x^2+1\) is positive and so never is zero. If we enter newton(x->x^2+1,1.1) then the result is
The root was not found within 10 steps.
Currently, the root is approximately x̂ = 0.030421004027873844.
An estimate for the error is 1.0004626117382218
with f(x̂) = 1.0009254374860639.
And now it’s clear that Newton’s method did not converge within 10 steps and there is information about the estimated root and it’s error.

Check Your Understanding 12.3.

Adapt the Root struct and the newton function to include the following:
(a)
Extend the Root struct to include an array of the x values that save all of the steps of Newton’s method. (Recall that you will need to restart the kernel to change the struct).
(b)
Store the x values that Newton’s method iterates through and then assign them to the Root datatype. You will need to update the newton method. Before the while loop, set the array equal to the initial point x1 and each time in the while loop perform, push the new value of x1 to the end of the array.
(c)
Write a showSteps function that takes in an object of type Root and prints out a table of the x values.