How Julia works?

How Julia works?

Type system

Until now, we have not used type annotations, but Julia has a rich type system. Julia is an optionally- and dynamically-typed programming language. That means that you can change the type of a variable...

a = 10
typeof(a)
Int64
a = true
typeof(a)
Bool

... and that type annotations are optional. You can use type annotations to:

a::Bool # type assertion
true
a::Int
# function_name(arg::ArgumentType)::ReturnType = function_body
first_character(str::AbstractString)::Char = str[1]
first_character (generic function with 1 method)
character = first_character("ABC")
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
character = first_character(10_000)

Type hierarchy

Any Julia object has a type that belongs to a fully connected type graph. There are abstract and concrete types. Concrete types are final, i.e. they cannot have subtypes, while abstract types can have multiple subtypes but only one supertype.

using JuliaForBioinformatics
show_type_tree(Number)
Any
    Number
        Complex
        Real
            AbstractFloat
                BigFloat
                Float16
                Float32
                Float64
            AbstractIrrational
                Irrational
            FixedPointNumbers.FixedPoint
                FixedPointNumbers.Fixed
                FixedPointNumbers.Normed
            ForwardDiff.Dual
            Integer
                Bool
                Signed
                    BigInt
                    Int128
                    Int16
                    Int32
                    Int64
                    Int8
                Unsigned
                    UInt128
                    UInt16
                    UInt32
                    UInt64
                    UInt8
            Rational
            Ratios.SimpleRatio
            StatsBase.TestStat

In Julia, all values are instances of the abstract type Any.

The functions supertype and subtypes are useful to navegate the type graph.

supertype(Real)
Number
subtypes(Real)

You can use isa to test if an object is of a given type

isa("I'm a string", String)
true

And the subtype operator <: to test if a type is a subtype of another

String <: AbstractString
true

You can also use Union of types, for example, if the possible types don't share a meaningful supertype

String <: Union{AbstractString, AbstractChar}
true

Multiple dispatch

We can define multiple methods for a function by using different method signatures by indicating the argument types using :: or <:.

For example we are going to define 3 methods for the function say_my_type:

say_my_type(x) = println(x, " is a ", typeof(x))
# say_my_type(x) is the same that say_my_type(x::Any)

say_my_type(x::Real) = println(x, " is a Real number of type ",  typeof(x))
say_my_type(x::Float64) = println(x, " is a Float64 number")
say_my_type (generic function with 3 methods)

When the function is called, Julia selects the method with the most specific method signature.

say_my_type('A') # 'A' is a Char, a subtype of Any
say_my_type(2) # 2 is an Int, a subtype of Real
say_my_type(2.0)
A is a Char
2 is a Real number of type Int64
2.0 is a Float64 number

say_my_type(x::Real) can also be written using the where keyword as

say_my_type(x::T) where {T <: Real} = println(x, " is a Real number of type ",  T)
say_my_type (generic function with 3 methods)
methods(say_my_type)
3 methods for generic function say_my_type:

Parametric types

Julia types can have parameters. We have already used parametric types, one of them is Array:

three_d_array = zeros(Int, 4, 3, 2)
4×3×2 Array{Int64,3}:
[:, :, 1] =
 0  0  0
 0  0  0
 0  0  0
 0  0  0

[:, :, 2] =
 0  0  0
 0  0  0
 0  0  0
 0  0  0
typeof(three_d_array)
Array{Int64,3}

Julia Arrays take two parameters, the type of the elements stored in the array and the array dimensions.

This allows to write specific methods depending on those parameters

say_my_type(x::Array{T, 1}) where {T} = println(x, " is vector with ",  T, " elements")
say_my_type(x::Array{T, 2}) where {T} = println(x, " is matrix with ",  T, " elements")
say_my_type (generic function with 5 methods)
say_my_type(Rational[0.5, 1, 1.5])
say_my_type(Float64[1 3 5; 2 4 6])
Rational[1//2, 1//1, 3//2] is vector with Rational elements
[1.0 3.0 5.0; 2.0 4.0 6.0] is matrix with Float64 elements

Exercise 1

Add a method to say_my_type that prints the number of unique values of an array of characters or strings and its dimensions. Hint: You can use the unique function.

# ... println(x, " is a text array with ... dimensions and ... unique values of type ...

That means that the function call:

test_array = ['a', 'b', 'b']
say_my_type(test_array)

Should print something like:

['a', 'b', 'b'] is a text array with 1 dimensions and 2 unique values of type Char

Which method is being used?

You can use the @which macro to ask Julia which method is being used

@which say_my_type(2 + 0im)
@which say_my_type(2.0)

Julia compiler

Julia uses Just-in-time (JIT) compilation to achieve close to C performance. After selecting the most specific method, Julia (generally) compiles the method for the particular argument types.

For this reason, the first time a function is called, it is compiled (slow). If you call the same function a second time with the same argument types, it will use the already compiled code (fast).

@time sum(1:10_000)
50005000
@time sum(1:10_000)
50005000

While compilation times can be annoying sometimes, this mechanism allows Julia generality, composability and its capacity to generate efficient code for user-defined types. That's mean that you do not need to use built-in types or functions or to code some parts in C/Fortran to get a good performance like in other high-level languages.

Also, Julia represents its own code as a Julia data structure. This allows a program to transform and generate its own code, using macros and generated functions, for example, and powerful reflection capabilities to explore the internals of a program. You can read the metaprogramming section of the manual to learn more about this topic.

##?@elapsed
macroexpand(Main, :(@elapsed sum(1:10_000)))
quote
    #= util.jl:212 =#
    local #64#t0 = (Base.time_ns)()
    #= util.jl:213 =#
    local #65#val = sum(1:10000)
    #= util.jl:214 =#
    ((Base.time_ns)() - #64#t0) / 1.0e9
end

@which 1.0 + 2.0
@which 1 + 2
@code_lowered 1.0 + 2.0
@code_typed 1.0 + 2.0
@code_llvm 1.0 + 2.0
@code_native 1.0 + 2.0

Julia can do extra optimizations inside functions, e.g. constant propagation:

f() = 1.0 + 2.0
@code_typed f()

Exercise 2

Modify the sum_numbers function to make it type stable by using zero and eltype.

To avoid performance issues for using a global variable we are going to define it as a constant using the const keyword.

const rand_matrix = rand(4, 4)
4×4 Array{Float64,2}:
 0.172628  0.911579  0.144012  0.894589
 0.768832  0.295764  0.748298  0.267925
 0.668007  0.178661  0.557616  0.748756
 0.290388  0.176055  0.374053  0.42154 
function sum_numbers(numbers)
    total = 0
    for value in numbers
        total += value
    end
    total
end
sum_numbers (generic function with 1 method)

Check that you don't have any warning in the output of @code_warntype:

@code_warntype sum_numbers(rand_matrix)

This page was generated using Literate.jl.