Scientific computing

(for the rest of us)

Types

In this module, we will look at one of the most important concept in Julia: types. Types are, to be really imprecise, the way a programming language thinks about a value. A lot of problems arise from the fact that programming languages are very opinionated.

Let’s think about $2$, a real number that exists somewhere on the numbers line, roughly two steps to the right of 0:

2.0
2.0

We can also think about $2$, a real number that exists somewhere on the numbers line, exactly two steps to the right of 0:

2
2

These are different numbers. Well, not really. Not mathematically anyways, since as we expect:

2.0 == 2
true

So $2.0$ and $2$ are equal, but they’re actually not equal-equal. They’re equal-ish:

2.0 === 2
false

In the previous modules, we have discussed == as an equality comparison operator. This new operator, === (the = symbol repeated three times) is a “distinguishability” operator. What does this means? It lets us know that there exists a program able to make a difference between 2 and 2.0. The reason why such a program exists is types.

typeof(2.0)
Float64
typeof(2)
Int64

The construct 2.0 is a floating point number using 64 bits of memory; 2 is an integer using 64 bits of memory. These are very different objects: there is nothing existing between 2 and 3 (the two nearest integers), but there’s an infinite number of things between 2.0 and 3.0.

The last point is not actually quite true. Because bits are finite resource, there is a finite number of steps between 2.0 and 3.0, but we assume that it is large enough that we can cross our fingers and hope for the best. Larger representations of floating point numbers are available.

Why do types matter so much? In a sense, it is because they give the compiler (or the interpreter) some valuable information as to what it should expect. A feature of Julia is that we can annotate every variable (even non const) with a type, which will ensure that this variable will never store data from a different type.

two::Float64 = 2.0
2.0
typeof(two)
Float64
The best practice is still to have be variables declared outside of functions be constants (i.e. const a = 2.0), because it gives more guarantees to the compiler and produces more efficient code. In a learning context, it does make things easier to not write everything within functions, and in this case annotating variables with a type provides some degree of protection as well as (maybe, modest) performance improvements.

Why does this matter? The answer is simple – as much as we like to think of 2.0+1 and 2.0+1.0 as the same operation, they are very different to a computer. Specifically, although they write almost the same, they turn out to be transformed to different code. In Julia, we can use the @code_llvm macro, from InteractiveUtils, to look at the way the code we write is transformed into compiler instructions. This sounds like a lot of information (and it is, although when optimizing code it is often required to use these macros), but this will nicely illustrate the possible issue.

Let’s start with the naive 2.0+1:

import InteractiveUtils
InteractiveUtils.@code_llvm two + 1
;  @ promotion.jl:422 within `+`
define double @"julia_+_3183"(double %0, i64 signext %1) #0 {
top:
; ┌ @ promotion.jl:393 within `promote`
; │┌ @ promotion.jl:370 within `_promote`
; ││┌ @ number.jl:7 within `convert`
; │││┌ @ float.jl:159 within `Float64`
      %2 = sitofp i64 %1 to double
; └└└└
;  @ promotion.jl:422 within `+` @ float.jl:409
  %3 = fadd double %2, %0
  ret double %3
}

As you see, there are a lot of lines about promotion, which is to say, about representing a variable as another type. What happens if we use 2.0+1.0 (note that we can generate a one of the correct type using the one function):

InteractiveUtils.@code_llvm two + one(two)
;  @ float.jl:409 within `+`
define double @"julia_+_3185"(double %0, double %1) #0 {
top:
  %2 = fadd double %0, %1
  ret double %2
}

The code is much smaller, and notably has no promotion. We have gained some valuable execution time by using two variables with correct types. Another useful macro is @code_warntype. For example, this will show the promotion step:

InteractiveUtils.@code_warntype 2 + 1.0
MethodInstance for +(::Int64, ::Float64)
  from +(x::Number, y::Number) @ Base promotion.jl:422
Arguments
  #self#::Core.Const(+)
  x::Int64
  y::Float64
Body::Float64
1 ─ %1 = Base.:+::Core.Const(+)
│   %2 = Base.promote(x, y)::Tuple{Float64, Float64}
│   %3 = Core._apply_iterate(Base.iterate, %1, %2)::Float64
└──      return %3

But the correctly typed version will simply show the addition:

InteractiveUtils.@code_warntype 2.0 + 1.0
MethodInstance for +(::Float64, ::Float64)
  from +(x::T, y::T) where T<:Union{Float16, Float32, Float64} @ Base float.jl:409
Static Parameters
  T = Float64
Arguments
  #self#::Core.Const(+)
  x::Float64
  y::Float64
Body::Float64
1 ─ %1 = Base.add_float(x, y)::Float64
└──      return %1

Recall that when we created the variable two, we annotated it with the type Float64. This is, in a way, a good protection against over-writing this variable with a value that has a different type.

We can experiment with over-writing two – in order to do so safely, we will use a try block, which we will look at in a few more modules. For now, just trust us.

try
    two = 2
catch err
    @warn "I cannot perform this operation"
else
    @info "The variable two is now $(two)"
end
┌ Warning: Assignment to `two` in soft scope is ambiguous because a global variable by the same name exists: `two` will be treated as a new local. Disambiguate by using `local two` to suppress this warning or `global two` to assign to the existing global variable.
└ @ ~/work/ScientificComputingForTheRestOfUs/ScientificComputingForTheRestOfUs/dist/content/01_fundamentals/06_types.md:2
[ Info: The variable two is now 2.0

Why is this working? Well, let’s have a look at the type of two now:

typeof(two)
Float64

Because it is possible to turn 2 into 2.0, Julia will do it here, and the type annotation (two must be a Float64) is still satisfied. Transforming a variable into another type is something we can do manually (and in fact, have to do fairly frequently). For example, we might want to be very cheap (efficient) with memory, and represent 2.0 as an unsigned integer on 8 bits:

convert(UInt8, two)
0x02

But what if instead of 2, we try to store $2i+0$ (a complex number) in our original variable?

try
    global two = 2im + 0
catch err
    @warn "I cannot perform this operation"
else
    @info "The variable two is now $(two)"
end
┌ Warning: I cannot perform this operation
└ @ Main.var"##235" ~/work/ScientificComputingForTheRestOfUs/ScientificComputingForTheRestOfUs/dist/content/01_fundamentals/06_types.md:4

This time, the assignment is failing because there is no automatic way to turn a complex number into a floating point number. Therefore, storing $2i+0$ in two would break the requirement that two is of the Float64 type!

We are using global two to refer to the variable two, because the scoping rules (i.e. which part of the code are allowed to access which variables) say that, outside of a function, variables in a loop or a try block belong to this structure. This works differently within a function, where the variables defined within the function are accessible everywhere within this function.

Understanding types, which comes through a lot of practice, is key to accessing some of Julia’s most interesting features, notably the dispatch system. In the following modules, we will introduce a lot more types, and see how they are organized in types hierarchies, and how we can use this information to refine the behavior of our functions. Remember that you can always inspect the type of a variable using typeof.