11. Structs and Objects#

We have seen a number of different types in Julia, such as Int64, Float64, Array{Float64,2}, etc. Now we will learn how to create new types, how to use them to store data, and how to define new functions (methods) and operators on them. This is closely related to the concept of object orientation in other computer languages.

11.1. Composite types#

The keyword struct is used to create a so-called composite type, that is, a new user-defined type. The type is given a name, and a list of fields (or attributes) which are names that will be used as variables to store data for objects of the new type.

As an example, we create a new type named MyPoly for storing and operating on polynomials. The fields will be an array of coefficients c, and a character var to denote the name of the dependent variable.

A variable name can also be followed by :: and a type. This will enforce this type and give an error otherwise, which can be useful to ensure the new composite type is used correctly. In our example, we specify that c must be of type Vector (however we do not specify which types of numbers in the vector). We also specify that var must be a Char.

struct MyPoly
    c::Vector
    var::Char
end

The coefficients c will represent the polynomial in the standard monomial form:

\[ p(x) = \sum_{k=0}^d c_{n-k}x^k \]

where \(c\) is the array of coefficients, \(d\) is the polynomial degree, and \(n\) is the length of the array \(c\). Note that we allow for \(n\) to be greater than the number of required coefficients \(d+1\), and that the coefficients are stored in reverse order (that is, highest exponents come first).

With this definition, we can create a so-called instance of the type (or object) by providing the values for each of the fields. For example, the polynomial \(p(x) = 3x^2 - 5x + 2\) can be created as:

p = MyPoly([3,-5,2], 'x')
MyPoly([3, -5, 2], 'x')

Note that by default, Julia will print the object using the type name and a list of the field values. Later we will override this and create a specialized output function.

You can define other ways to specify (or initialize) a polynomial using a so-called constructor, which is a function that is called when a type is initiated. For example, if you want to allow for creating a polynomial using only a coefficient array c but using the default value x for the var field, you can add a so-called outer constructor as shown below:

MyPoly(c) = MyPoly(c, 'x')
MyPoly

At this point, the struct p does not do anything besides simply storing the variables c and var. They can be accessed using a . notation:

p.c
3-element Vector{Int64}:
  3
 -5
  2
p.var
'x': ASCII/Unicode U+0078 (category Ll: Letter, lowercase)

However, structs in Julia are immutable which means that once created, you can not change their contents. This is different from many other language, and there are some good reasons for this design. If you need to change the variables in a struct, or even add new fields, simply use the keyword mutable struct instead of struct. However, here we will stay with a standard struct, since in our example we will only modify the polynomials coefficients c. This is an exception which actually is allowed, since the variable c is itself a mutable object and its content can be changed.

For example, if you want to change the polynomial to \(p(x) = -x^3 + 3x^2 - 5x + 2\) you can not change the actual array in c:

p.c = [-1,3,-5,2]  # Error: Immutable struct

but you can change the content in the existing array c:

resize!(p.c, 4)
p.c[1:4] = [-1,3,-5,2]
p
MyPoly([-1, 3, -5, 2], 'x')

Of course, another option is to simply create a new instance of the MyPoly type:

p = MyPoly([-1,3,-5,2], p.var)
MyPoly([-1, 3, -5, 2], 'x')

11.2. Functions on types#

We can easily define functions on new types, by passing the objects as arguments or return values. For example, we can easily define a function that multiplies a polynomial \(p(x)\) by \(x\) in the following way:

function times_x(p)
   return MyPoly(vcat(p.c,0), p.var) 
end

times_x(p)
MyPoly([-1, 3, -5, 2, 0], 'x')

However, this function should only work if you pass a MyPoly type to it. In Julia you can write functions that operate differently on different types. A function which defines the behavior for a specific combination or number of arguments is called a method.

A function which is specialized for a certain type can be created using a type declaration with the :: operator. As an example, we create a degree function to find the degree \(d\). The implementation is straight-forward, we simply search the for the first (highest degree) non-zero coefficient. Note that we define the degree of the zero polynomial to be -1.

function degree(p::MyPoly)
    ix1 = findfirst(p.c .!= 0)
    if ix1 == nothing
        return -1
    else
        return length(p.c) - ix1
    end
end
degree (generic function with 1 method)
println(degree(MyPoly([0,0,0,0,0])))
println(degree(MyPoly([0,0,0,0,1])))
println(degree(MyPoly([0,0,0,1,0])))
println(degree(MyPoly([1,0,0,0,0])))
-1
0
1
4

By specializing the degree function to the MyPoly type, we can now use the same function name for other combinations or types, that is, to implement other methods. This is also called overloading.

function degree(p::Int)
    println("degree function called with Int argument")
end

function degree(p)
    println("degree function called with any other argument")
end

degree(1.234)
degree([1,2])
degree(-123)
degree(MyPoly([1,2,3]))
degree function called with any other argument
degree function called with any other argument
degree function called with Int argument
2

11.3. Customized printing#

Julia provides a special function show in the Base package, which can be overloaded to change how objects of a new type are printed. For our polynomials, instead of showing the array of coefficients c and the name of the independent variable var, we will write it as a polynomial in standard math notation.

The details of this functions do not matter much, it mostly needs to deal with certain special cases to print polynomials correctly. The main point is that it will only be called for objects of type MyPoly.

function Base.show(io::IO, p::MyPoly)
    d = degree(p)
    print(io, "MyPoly: ")
    for k = d:-1:0
        coeff = p.c[end-k]
        if coeff == 0 && d > 0
            continue
        end
        if k < d
            if isa(coeff, Real)
                if coeff > 0
                    print(io, " + ")
                else
                    print(io, " - ")
                end
                coeff = abs(coeff)
            else
                print(io, " + ")
            end
        end
        if isa(coeff, Real)
            print(io, coeff)
        else
            print(io, "($coeff)")
        end
        if k == 0
            continue
        end
        print(io, "⋅", p.var)
        if k > 1
            print(io, "^", k)
        end
    end
end
p
MyPoly: -1⋅x^3 + 3⋅x^2 - 5⋅x + 2
MyPoly([-1.234,0,0,0,4.321], 's')
MyPoly: -1.234⋅s^4 + 4.321

11.4. Callable objects#

One basic operation to perform on a polynomial is evaluation, that is, for a given number \(x\) compute \(p(x)\). We will implement this using Horner’s rule:

\[\begin{split} \begin{align} p(x) &= a_nx^n + a_{n-1} x^{n-1} + \cdots + a_0 \\ &= ((a_n + xa_{n-1})x + \cdots)x + a_0 \end{align} \end{split}\]

While we could implement this in a function with a new name, for example, polyval, Julia allows the definition of a method which behaves like a function evaluating the polynomial:

function (p::MyPoly)(x)
    d = degree(p)
    v = p.c[end-d]
    for cc = p.c[end-d+1:end]
        v = v*x + cc
    end
    return v
end
println(p(1))
println(p(1.234))
println(p.([1,-2,1.3]))  # Note: Broadcasting automatically defined
-1
-1.4808129039999995
[-1.0, 32.0, -1.6270000000000002]

11.5. Plotting#

Using the evaluation functionality, we can easily implement plotting of polynomials. Again we reuse the name plot which is already used in the PyPlot package, but we specialize it to our MyPoly type:

using PyPlot
function PyPlot.plot(p::MyPoly, xlim=[-2,2])
    xx = collect(range(xlim[1], xlim[2], length=100))
    plot(xx, p.(xx))
    xlabel(string(p.var))
end
p = MyPoly([1,1,-5,-5,4,4])
plot(p)
p
../../_images/72d622bb0f5ca440befd6afdab7efcbbb8b0d87aeb89df5555252787309e5dfe.png
MyPoly: 1⋅x^5 + 1⋅x^4 - 5⋅x^3 - 5⋅x^2 + 4⋅x + 4

11.6. Operator overloading#

Operators such as + can also be overloaded for new types, by specializing the function definition. These operators are so fundamental to the language itself that they are part of the Base package. This special package is automatically included by Julia, so there is no need to call using Base before using any of its functionality or specify Base.func_name() when calling one of its functions. However, when overloading a function in this package, we must still specify that the function is a part of this package with the usual syntax Base.func_name().

Adding polynomials is of course easy, we simply add the coefficients. However, for our implementation we first need to make sure the coefficient vectors are long enough to contain the sum:

function Base.:+(p1::MyPoly, p2::MyPoly)
    if p1.var != p2.var
        error("Cannot add polynomials of different independent variables.")
    end
    d1 = length(p1.c)
    d2 = length(p2.c)
    d = max(d1,d2)
    c = [fill(0, d-d1); p1.c] + [fill(0, d-d2); p2.c]
    return MyPoly(c, p1.var)
end
println(p)
println(p + p)
println(p + MyPoly([1.1,2.2]))
println(p + MyPoly([1], 's')) # will trigger our error message
MyPoly: 1⋅x^5 + 1⋅x^4 - 5⋅x^3 - 5⋅x^2 + 4⋅x + 4
MyPoly: 2⋅x^5 + 2⋅x^4 - 10⋅x^3 - 10⋅x^2 + 8⋅x + 8
MyPoly: 1.0⋅x^5 + 1.0⋅x^4 - 5.0⋅x^3 - 5.0⋅x^2 + 5.1⋅x + 6.2
Cannot add polynomials of different independent variables.

Stacktrace:
 [1] error(s::String)
   @ Base ./error.jl:35
 [2] +(p1::MyPoly, p2::MyPoly)
   @ Main ./In[19]:3
 [3] top-level scope
   @ In[20]:4

Subtraction is easiest done by overloading the - operator and reusing the implementation of +:

function Base.:-(p1::MyPoly, p2::MyPoly)
    return p1 + MyPoly(-p2.c)
end

Similarly with scalar multiplication, we overload *. Note that we do not specify the type of the first scalar argument a, it is assumed that it is a regular number (not a MyPoly) object. We also define multiplication in the reverse order, by reusing the same function (since we know that it commutes).

function Base.:*(a, p::MyPoly)
    newc = a * p.c
    return MyPoly(newc, p.var)
end

function Base.:*(p::MyPoly, a)
    return a*p
end

Using the overloaded operators +, -, and * (for scalars), we can perform many polynomial operations:

p1 = 0.4p
p2 = p1 - .3*MyPoly([-2,1,1])
p3 = -1p2 + p
plot.([p1,p2,p3]);
../../_images/b39ef28f3631e2a43eea7bada28b8789055cf0b92b0703359c8f6b13e0102d18.png

11.7. Generic programming#

In the examples above we have specialized many functions and operators to work in a specific way for objects of type MyPoly. However, note the advantages of not limiting the type of a variable or function argument to be of a certain type. For example, in the definition of MyPoly we did not specify that the coefficients c should be of e.g. integer or floating point types (in fact our examples used both). This means our functions work perfectly fine also for rational coefficients and arguments:

p = MyPoly([1, -2//3, 6//7])
MyPoly: 1//1⋅x^2 - 2//3⋅x + 6//7
p.([1, -7//2])
2-element Vector{Rational{Int64}}:
   25//21
 1297//84

and for complex:

p = MyPoly([1, im, -1, -im, 1])
MyPoly: (1 + 0im)⋅x^4 + (0 + 1im)⋅x^3 + (-1 + 0im)⋅x^2 + (0 - 1im)⋅x + (1 + 0im)
p.([0, im, -1 - im])
3-element Vector{Complex{Int64}}:
  1 + 0im
  5 + 0im
 -2 + 1im

or for BigFloat:

p = MyPoly(collect(-1.5:3.5))
MyPoly: -1.5⋅x^5 - 0.5⋅x^4 + 0.5⋅x^3 + 1.5⋅x^2 + 2.5⋅x + 3.5
p(BigFloat(-π))
405.2722682884305251107738095974542912801020079018409933037640550322547603287204