UQ MATH2504
Programming of Simulation, Analysis, and Learning Systems
(Semester 2 2021)

This is an OLDER SEMESTER.
Go to current semester


Main MATH2504 Page

Unit 1 | Unit 2 | Unit 3 | Unit 4 | Unit 5 | Unit 6 | Unit 7

Unit 4: More language features for software architecture

In the previous three units we explored basics of programming and computation (Unit 1), algorithms and data structures (Unit 2), and data files and numerics (Unit 3). In this unit we take a deeper and more thorough approach at basic Julia language features.

The most important language features that we explore include the type system, user-defined types, and multiple-dispatch. As we do this, we'll also consider several additional minor features. The notes below often refer to the Julia documentation.

A few more bits from Julia

We have seen Julia functions from the start, e.g.

f(x) = x^2
f (generic function with 1 method)

or,

f(x::Int)::Int = x^2
f (generic function with 2 methods)

or,

f(x::T) where T <: Number = x^2
f (generic function with 3 methods)

or, in longer form,

function f(x::T) where T <: Number
    return x^2
end
f (generic function with 3 methods)

The Julia documentation of functions provides a rich description of all of the details. We now overview a few special features that were perhaps not evident from Units 1–3. There are also links to the documentation for it:

Return nothing

You may have a function that does not have a return value. For example:

function hello()
    println("Hello class")
    return nothing
end

x = hello()
@show x;
Hello class
x = nothing

Argument destructing

You can specify the names of arguments in a tuple. For example:

f((x,y)) = x + y
make_pair() = (rand(1:5), rand(1:5))
f(make_pair())
4

Varargs functions

Sometimes functions have a variable number of arguments

using Roots

function polynomialGenerator(a...)
    n = length(a)-1
    poly =  function(x)
                return sum([a[i+1]*x^i for i in 0:n])
            end
    return poly
end

polynomial = polynomialGenerator(1,3,-10)
zero_vals = find_zeros(polynomial,-10,10)
println("Zeros of the function f(x): ", zero_vals)
Zeros of the function f(x): [-0.19999999999999998, 0.5]

Optional arguments

You can have optional arguments (or default values):

using Distributions
my_density(x::Float64, μ::Float64 = 0.0, σ::Float64 = 1.0) = exp(-(x-μ)^2/(2σ^2) ) / (σ*√(2π)) 
x = 1.5
@show pdf(Normal(),x), my_density(x)
@show pdf(Normal(0.5),x), my_density(x,0.5);
(pdf(Normal(), x), my_density(x)) = (0.12951759566589174, 0.129517595665891
74)
(pdf(Normal(0.5), x), my_density(x, 0.5)) = (0.24197072451914337, 0.2419707
2451914337)

Keyword arguments

Arguments following the ; character in the function definition or the function call are called keyword arguments. These are named as they are used.

my_density(x::Float64 ; μ::Float64 = 0.0, σ::Float64 = 1.0) = exp(-(x-μ)^2/(2σ^2) ) / (σ*√(2π)) 
@show pdf(Normal(0.0,2.5),x), my_density(x,σ=2.5)
(pdf(Normal(0.0, 2.5), x), my_density(x, σ = 2.5)) = (0.13328984115671988, 
0.13328984115671988)
(0.13328984115671988, 0.13328984115671988)
function my_very_flexible_function(x::Number;kwargs...)
    println(kwargs)
    #....
end

my_very_flexible_function(2.5,a=1,b="two",c=:three)
Base.Iterators.Pairs{Symbol, Any, Tuple{Symbol, Symbol, Symbol}, NamedTuple
{(:a, :b, :c), Tuple{Int64, String, Symbol}}}(:a => 1, :b => "two", :c => :
three)

Do Block Syntax for Function Arguments

As you know, you can pass functions as arguments and sometimes you can use an anonymous function for that. For example,

using Random
Random.seed!(0)
data = rand(Int,100)
filter((x)->x%10 == 0,data)
3-element Vector{Int64}:
 3640623078648571650
 5725288495339109730
 7191202105033452710

What if the anonymous function has more lines of code?

using Primes

filter((x)->begin
                if !isprime(x%100)
                    return x < 30
                else
                    return x > 50
                end
            end
            ,data)
58-element Vector{Int64}:
 -2004355065646768275
 -6843449288206370606
 -3419878991166138404
  7559148743495526041
  6556297324844843707
  1674144697531991967
 -4316814169512269664
 -8566717264987958856
 -3814199109255923546
  4246921982487543371
                    ⋮
  7582463876227236671
 -3531855380846283706
  6660448973372785767
 -1356401950252495367
 -2955699732840981137
 -7566613702907667148
 -1299547244443061841
  8784568492196736573
 -4634303470182715775

We can use the doend syntax instead:

filter(data) do x
                if !isprime(x%100)
                    return x < 30
                else
                    return x > 50
                end
            end
58-element Vector{Int64}:
 -2004355065646768275
 -6843449288206370606
 -3419878991166138404
  7559148743495526041
  6556297324844843707
  1674144697531991967
 -4316814169512269664
 -8566717264987958856
 -3814199109255923546
  4246921982487543371
                    ⋮
  7582463876227236671
 -3531855380846283706
  6660448973372785767
 -1356401950252495367
 -2955699732840981137
 -7566613702907667148
 -1299547244443061841
  8784568492196736573
 -4634303470182715775

Function composition and piping

Just a bit of different syntax for function calls:

π/4 |> cos |> acos |> (x)->4x
3.141592653589793

Here is a function just like identity:

ii(x) = (cos  acos)(x) #\circ + [TAB]
ii(π/4), π/4
(0.7853981633974483, 0.7853981633974483)

Dot Syntax for Vectorizing Functions

You already know the broadcast operator. It uses the broadcast function.

x_range = 0:0.5:π
cos.(x_range)
7-element Vector{Float64}:
  1.0
  0.8775825618903728
  0.5403023058681398
  0.0707372016677029
 -0.4161468365471424
 -0.8011436155469337
 -0.9899924966004454

See the docs for a discussion of performance of broadcasting.

You can also use the macro @.

x_range = 0:0.5:π
@. cos(x_range + 2)^2
7-element Vector{Float64}:
 0.17317818956819406
 0.6418310927316131
 0.9800851433251829
 0.8769511271716524
 0.4272499830956933
 0.0444348690576615
 0.08046423546177377

More items from control flow

You are already very familiar with conditional statements (if, elseif, else), with loops (for and while), with short circuit evaluation, and with many other variants. For example continue and break.

More control flow details are here: control flow in Julia docs.

One additional thing to know about is exception handling.

function my_2_by_2_inv(A::Matrix{Float64})
    size(A) == (2,2) || error("This function only works for 2x2 matrices")
    d = A[1,1]*A[2,2] - A[2,1]*A[1,2]
    d  0 && throw(ArgumentError("matrix is singular or near singular")) #\approx + [TAB]
    return [A[2,2] -A[1,2]; -A[2,1] A[1,1]]/d
end

my_2_by_2_inv(rand(3,3))
ERROR: This function only works for 2x2 matrices
my_2_by_2_inv([ones(2) ones(2)])
ERROR: ArgumentError: matrix is singular or near singular
using LinearAlgebra

Random.seed!(0)
A = rand(2,2)
A_inv = my_2_by_2_inv(A)
@assert A_inv*A  I
Random.seed!(0)
for _  1:10 #\in + [TAB]
    A = float.(rand(1:5,2,2))
    try
        my_2_by_2_inv(A)
    catch e
        println(e)
    end
end
ArgumentError("matrix is singular or near singular")
ArgumentError("matrix is singular or near singular")

An exception may be caught way down the call stack:

A = ones(2,2)
f(mat) = 10*my_2_by_2_inv(A)
g(mat) = f(mat .+ 3)
h(mat) = 2g(mat)
h(A)
ERROR: ArgumentError: matrix is singular or near singular
ERROR: ArgumentError: matrix is singular or near singular
Stacktrace:
 [1] my_2_by_2_inv(A::Matrix{Float64})
   @ Main ~/git/mine/ProgrammingCourse-with-Julia-SimulationAnalysisAndLearningSystems/markdown/lecture-unit-4.jmd:5
 [2] f(mat::Matrix{Float64})
   @ Main ./REPL[33]:1
 [3] g(mat::Matrix{Float64})
   @ Main ./REPL[34]:1
 [4] h(mat::Matrix{Float64})
   @ Main ./REPL[35]:1
 [5] top-level scope
   @ REPL[36]:1
try 
    h(A)
catch e
    println(e)
end
ArgumentError("matrix is singular or near singular")

Variable scope

See variables and scoping in Julia docs.

data = [1, 2, 3]
s = 0
β, γ = 2, 1

for i in 1:length(data)
    global s    #This usage of the `global` keyword is not needed in Jupyter
                #But elsewhere without it:
                #ERROR: LoadError: UndefVarError: s not defined
    s += β*data[i]
    data[i] *= -1 #Note that we didn't need 'global' for data
end
#print(i)       #Would cause ERROR: LoadError: UndefVarError: i not defined
@show data
@show s

function sum_data(β)
    s = 0           #try adding the prefix global
    for i in 1:length(data)
        s += β*(data[i] + γ)
    end
    return s
end
@show sum_data(β/2)
@show s
data = [-1, -2, -3]
s = 12
sum_data(β / 2) = -3.0
s = 12
12

Julia uses Lexical scoping:

function my_function()
    x = 10
    function my_function_inside_a_function()
        @show x
    end
    return my_function_inside_a_function
end

x = 20
f_ret = my_function()
f_ret();
x = 10

The use of outer:

function f()
    i = 0
    for i = 1:3
        # empty
    end
    return i
end
f()
0
function f()
    i = 0
    for outer i = 1:3
        # empty
    end
    return i
end
f()
3

Types and the Type System

See types in the Julia docs.

Everything has a type:

typeof(2.3)
Float64
typeof(2.3f0)
Float32
typeof(2)
Int64
typeof(23//10)
Rational{Int64}
typeof(2 + 3im)
Complex{Int64}
typeof(2.0 +3im)
ComplexF64 (alias for Complex{Float64})
typeof("Hello!")
String
typeof([1,2,3])
Vector{Int64} (alias for Array{Int64, 1})
typeof([1,2,3.0])
Vector{Float64} (alias for Array{Float64, 1})
typeof([1.0,2,"three"])
Vector{Any} (alias for Array{Any, 1})
typeof(1:3)
UnitRange{Int64}
typeof([1 2; 3 4])
Matrix{Int64} (alias for Array{Int64, 2})
typeof(Float64)
DataType
typeof(:Hello)
Symbol

There is also a type hierarchy (a tree). At the top of the tree is the type Any. All types have a supertype (the supertype of Any is Any). Types that are not leafs of the tree have subtypes. Some types are abstract while others are concrete. One particularly distinctive feature of Julia's type system is that concrete types may not subtype each other: all concrete types are final and may only have abstract types as their supertypes.

x = 2.3
@show typeof(x)
@show supertype(Float64)
@show supertype(AbstractFloat)
@show supertype(Real)
@show supertype(Number);
@show supertype(Any);
typeof(x) = Float64
supertype(Float64) = AbstractFloat
supertype(AbstractFloat) = Real
supertype(Real) = Number
supertype(Number) = Any
supertype(Any) = Any

There is an is a relationship:

isa(2.3, Number)
true
isa(2.3, String)
false
2.3 isa Float64
true
my_type = Float64
@show isabstracttype(my_type)
@show isconcretetype(my_type);
isabstracttype(my_type) = false
isconcretetype(my_type) = true
my_type = Real
@show isabstracttype(my_type)
@show isconcretetype(my_type);
isabstracttype(my_type) = true
isconcretetype(my_type) = false

Some types are not abstract nor concrete:

my_type = Complex
@show isabstracttype(my_type)
@show isconcretetype(my_type);
isabstracttype(my_type) = false
isconcretetype(my_type) = false
help?> Complex
search: Complex complex ComplexF64 ComplexF32 ComplexF16 precompile __precompile__ componentwise_logpdf

  Complex{T<:Real} <: Number

  Complex number type with real and imaginary part of type T.

  ComplexF16, ComplexF32 and ComplexF64 are aliases for Complex{Float16}, Complex{Float32} and Complex{Float64} respectively.

Let's walk down from Number:

using InteractiveUtils: subtypes

function type_and_children(type, depth::Int = 0)
    if !isconcretetype(type)
        #Non-concrete
        print("-"^depth, type, ": non-concrete")
        #Types that are not concrete can be abstract or not
        if isabstracttype(type) 
            println(", abstract")
            for c in subtypes(type)
                type_and_children(c,depth+1)
            end
        else
             println(", non-abstract")
             @assert isempty(subtypes(type))
        end
    else
        #Concrete
        @assert isempty(subtypes(type))
        println("-"^depth, type, ": concrete")
    end
end

type_and_children(Number)
Number: non-concrete, abstract
-Complex: non-concrete, non-abstract
-Real: non-concrete, abstract
--AbstractFloat: non-concrete, abstract
---BigFloat: concrete
---Float16: concrete
---Float32: concrete
---Float64: concrete
--AbstractIrrational: non-concrete, abstract
---Irrational: non-concrete, non-abstract
--Integer: non-concrete, abstract
---Bool: concrete
---Signed: non-concrete, abstract
----BigInt: concrete
----Int128: concrete
----Int16: concrete
----Int32: concrete
----Int64: concrete
----Int8: concrete
---Unsigned: non-concrete, abstract
----UInt128: concrete
----UInt16: concrete
----UInt32: concrete
----UInt64: concrete
----UInt8: concrete
--Rational: non-concrete, non-abstract
--StatsBase.PValue: concrete
--StatsBase.TestStat: concrete

A type can be mutable or not (immutable). Variables/data of the immutable types are typically stored on the stack. Variables/data of mutable types are typically allocated and stored on the heap.

x = 7
@show ismutable(x)

x = [7]
@show ismutable(x);
ismutable(x) = false
ismutable(x) = true

When you pass a mutable variable to a function, the function can change the value. When you pass an immutable variable, if you try to change it, Julia will just make another instance.

f(z::Int) = begin z = 0 end
f(z::Array{Int}) = begin z[1] = 0 end

x = 1
@show typeof(x)
@show isimmutable(x)
println("Before call by value: ", x)
f(x)
println("After call by value: ", x,"\n")

x = [1]
@show typeof(x)
@show isimmutable(x)
println("Before call by reference: ", x)
f(x)
println("After call by reference: ", x)
typeof(x) = Int64
isimmutable(x) = true
Before call by value: 1
After call by value: 1

typeof(x) = Vector{Int64}
isimmutable(x) = false
Before call by reference: [1]
After call by reference: [0]

Making copies, copy and deepcopy:

println("Immutable:")
a = 10
b = a
b = 20
@show a;
Immutable:
a = 10
println("\nNo copy:")
a = [10]
b = a
b[1] = 20
@show a;
No copy:
a = [20]
println("\nCopy:")
a = [10]
b = copy(a)
b[1] = 20
@show a;
Copy:
a = [10]
println("\nShallow copy:")
a = [[10]]
b = copy(a)
b[1][1] = 20
@show a;
Shallow copy:
a = [[20]]
println("\nDeep copy:")
a = [[10]]
b = deepcopy(a)
b[1][1] = 20
@show a;
Deep copy:
a = [[10]]

Defining your Own Types

You can define your own types. Any "serious" programming task would almost always merit that you do that.

In object oriented languages (e.g. C++, Java, Python) types are typically called classes. A class (in such a language) will have both definitions of data and actions, typically called variables and methods respectively. An instance of a class would be called an object.

Julia is not object oriented. It rather provides a different paradigm based on multiple-dispatch (which we describe below). Nevertheless, there are user defined types, called structs (structures). The name comes from C.

struct Person #Notice the convention of using capital letters for the first letter of a struct
    height::Float64
    weight::Float64
    name::String
end

person = Person(179.8, 78.6, "Miriam") #A struct comes with a constructor function

@show typeof(person)

@show person.height; #The fields of a struct are accessed via "." - not to be confused with "." used for broadcasting.
@show person.weight;
@show person.name;
typeof(person) = Person
person.height = 179.8
person.weight = 78.6
person.name = "Miriam"
@show ismutable(person)
person.weight = 85.4 #gained some weight - but this will generate and error
ismutable(person) = false
ERROR: setfield! immutable struct of type Person cannot be changed

Here is a mutable struct

mutable struct MutablePerson
    height::Float64
    weight::Float64
    name::String
end

person = MutablePerson(179.8, 78.6, "Miriam")
person.weight = 85.4
println(person)
MutablePerson(179.8, 85.4, "Miriam")

Note: You typically cannot redefine a type during the same Julia session. One workaround for that is to use the Revise.jl package. We won't use it just yet.

struct MyStruct
    x::Int
end

struct MyStruct #Will generate an ERROR because redefining a struct
    x::Int
    y::Float64
end
ERROR: invalid redefinition of constant MyStruct

You can define abstract types (they won't have any fields) and a whole type hierarchy:

abstract type Animal end

abstract type Mammal <: Animal end
abstract type Reptile <: Animal end

struct Human <: Mammal
    height::Float64
    weight::Float64
    religious::Bool
end    

struct Dog <: Mammal
    height::Float64
    weight::Float64
end

struct FlexDog{T <: Real} <: Mammal
    height::T
    weight::T
end

struct Crocodile <: Reptile
    length::Float64
    weight::Float64
    type::Symbol #Expect to be :salt_water or :fresh_water
end

type_and_children(Animal)
Animal: non-concrete, abstract
-Mammal: non-concrete, abstract
--Dog: concrete
--FlexDog: non-concrete, non-abstract
--Human: concrete
-Reptile: non-concrete, abstract
--Crocodile: concrete

As stated above, the function that creates an instance of the type is called the constructor. Every concrete type comes with a default constructor.

methods(Crocodile)
# 2 methods for type constructor:
tick_tock = Crocodile(2.3, 204,:salt_water)
Crocodile(2.3, 204.0, :salt_water)

You can also create other constructor methods:

function Crocodile(type::Symbol)
    if type == :salt_water
        return Crocodile(2.0,200,:salt_water) #average salt water croc
    elseif type == :fresh_water
        return Crocodile(1.5,150,:fresh_water) #average fresh water croc
    else
        error("Can't make crocodile of type $type")
    end
end

methods(Crocodile)
# 3 methods for type constructor:
Crocodile(:salt_water)
Crocodile(2.0, 200.0, :salt_water)
Crocodile(:fresh_water)
Crocodile(1.5, 150.0, :fresh_water)
Crocodile(:ice_water) #will generate an error
ERROR: Can't make crocodile of type ice_water

A bit more on constructors will come later.

Notice we had the parameteric type FlexDog:

dash_my_dog = FlexDog(2,4)
@show typeof(dash_my_dog)

lassy_your_dog = FlexDog(2.3f0,5.7f0)
@show typeof(lassy_your_dog)

my_dog_array = FlexDog{UInt16}[]
typeof(dash_my_dog) = FlexDog{Int64}
typeof(lassy_your_dog) = FlexDog{Float32}
FlexDog{UInt16}[]
my_dog_array = FlexDog{Complex}[] #Will not work because Complex is not a Real
ERROR: TypeError: in FlexDog, in T, expected T<:Real, got Type{Complex}

With multiple dispatch we can get a form of polymorphism:

animal_noise(animal::Dog) = println("woof")
animal_noise(animal::Human) = println("hello")
animal_noise(animal::Crocodile) = println("chchch")

animals = [Crocodile(:salt_water), Human(2.4,2.3,false), Crocodile(:salt_water), Dog(5.3,2.5)]
animal_noise.(animals);
chchch
hello
chchch
woof
methods(animal_noise)
# 3 methods for generic function animal_noise:

We can even handle the FlexDog:

animal_noise(animal::Union{Dog,FlexDog}) = println("woof")
push!(animals, FlexDog{Int16}(2,4))
animal_noise.(animals);
chchch
hello
chchch
woof
woof

Methods and Multiple Dispatch

See methods in Julia docs.

If there is one key attribute to Julia it is multiple dispatch as we have just seen above.

function my_f(x::Int)
    println("My integer is $x")
end

function my_f(x::Float64)
    println("My floating point number is $x")
end

my_f(2)
my_f(2.5)
println(methods(my_f))
My integer is 2
My floating point number is 2.5
# 2 methods for generic function "my_f":
[1] my_f(x::Int64) in Main at /Users/uqjnazar/git/mine/ProgrammingCourse-wi
th-Julia-SimulationAnalysisAndLearningSystems/markdown/lecture-unit-4.jmd:2
[2] my_f(x::Float64) in Main at /Users/uqjnazar/git/mine/ProgrammingCourse-
with-Julia-SimulationAnalysisAndLearningSystems/markdown/lecture-unit-4.jmd
:6

It is worthwhile to watch this video about the philosphy of multiple dispatch. Some of the content of the video may be a bit advanced, but then towards the end of the course it is worth to listen to it again and see what makes sense and what not yet.

Defining more methods for existing functions

Almost any operation in julia is a function. For example,

@which 2+3
+(x::T, y::T) where T<:Union{Int128, Int16, Int32, Int64, Int8, UInt128, UInt16, UInt32, UInt64, UInt8} in Base at int.jl:87

There are many methods for +:

methods(+) |> length
271

So what if we had our own type and wanted to have + for it, and say an integer.

struct PlayerScore
    player_name::String
    score::Int
end

me = PlayerScore("Johnny", 22)
PlayerScore("Johnny", 22)
me += 10 #will generate an error since `+` for me and an integer is not defined and `+` is used in `+=`
ERROR: MethodError: no method matching +(::PlayerScore, ::Int64)
Closest candidates are:
  +(::Any, ::Any, !Matched::Any, !Matched::Any...) at operators.jl:560
  +(!Matched::T, ::T) where T<:Union{Int128, Int16, Int32, Int64, Int8, UInt128, UInt16, UInt32, UInt64, UInt8} at int.jl:87
  +(!Matched::ChainRulesCore.Tangent{P, T} where T, ::P) where P at /Users/uqjnazar/.julia/packages/ChainRulesCore/GciYT/src/differential_arithmetic.jl:162
  ...

So let's define it:

import Base: + #we do this to let Julia know we will add more methods to `+`

function +(ps::PlayerScore, n::Int)::PlayerScore
    return PlayerScore(ps.player_name, ps.score + n)
end

me += 10;
me
PlayerScore("Johnny", 32)

You can do this for every operation and function you want (and makes sense). What if we wanted "pretty printing"?

import Base: show

show(io::IO, ps::PlayerScore) = print(io,"Score for $(ps.player_name) =  $(ps.score)")

println("We have a some score: $me. Pretty good!")
We have a some score: Score for Johnny =  32. Pretty good!

Some examples...

Lets consider an example where we want to collect data and also have quick running statistics. E.g.

using Random, Statistics
Random.seed!(0)
get_new_data() = 100*rand() #This will in practice be anything...

data = Float64[]

function collect_and_use_data(data)
    for t in 1:100 #This will be much bigger 

        #Periodically we collect new data
        new_data_point = get_new_data()
        push!(data,new_data_point)

        #Peridoically we look at summary statistics
        if t%20 == 0
            println("-------")
            println("Mean: ", mean(data))
            println("Max: ", maximum(data))
        end
    end
end

collect_and_use_data(data)
-------
Mean: 51.50597449923474
Max: 97.32164043865109
-------
Mean: 47.78212843231425
Max: 97.32164043865109
-------
Mean: 49.16317609399804
Max: 97.32164043865109
-------
Mean: 49.774896078767526
Max: 97.32164043865109
-------
Mean: 50.79616397016217
Max: 97.65501230411475

Think of this scenario repeating often and for t being big... so you don't want to recompute the mean and max every time.

mutable struct RunningStatsData
    data::Vector{Float64}
    mean::Float64
    max::Float64
    RunningStatsData() = new([],NaN,NaN)
end

function show_summary(rsd::RunningStatsData)
        println("Mean: ", rsd.mean)
        println("Max: ", rsd.max)
end

data = RunningStatsData()
show_summary(data)
Mean: NaN
Max: NaN

We can now make specifics method for push!, max, and mean for this new type:

Random.seed!(0)

import Base: push!, maximum
import Statistics: mean

maximum(rsd::RunningStatsData) = rsd.max
mean(rsd::RunningStatsData) = rsd.mean

function push!(rsd::RunningStatsData,data_point)
    #Insert the new datapoint
    push!(rsd.data, data_point)

    # Update the maximum
    if data_point > rsd.max || isnan(rsd.max)
        rsd.max = data_point
    end

    # Update the mean
    n = length(rsd.data)
    if n == 1 
        rsd.mean = data_point #If first data point just set it
    else
        rsd.mean = (1/n)*data_point + ((n-1)/n) * rsd.mean #if more than one data point then do running average.
    end
end

data = RunningStatsData()
collect_and_use_data(data)
-------
Mean: 51.50597449923474
Max: 97.32164043865109
-------
Mean: 47.78212843231426
Max: 97.32164043865109
-------
Mean: 49.163176093998025
Max: 97.32164043865109
-------
Mean: 49.77489607876752
Max: 97.32164043865109
-------
Mean: 50.79616397016216
Max: 97.65501230411475

Going a bit more generic we could have also had,

mutable struct FlexRunningStatsData{T <: Number}
    data::Vector{T}
    mean::Float64
    max::T
    FlexRunningStatsData{T}() where T = new([],NaN,typemin(T))
end

maximum(rsd::FlexRunningStatsData) = rsd.max
mean(rsd::FlexRunningStatsData) = rsd.mean

function push!(rsd::FlexRunningStatsData,data_point)
    #Insert the new datapoint
    push!(rsd.data, data_point)

    # Update the maximum
    if data_point > rsd.max || isnan(rsd.max)
        rsd.max = data_point
    end

    # Update the mean
    n = length(rsd.data)
    if n == 1 
        rsd.mean = data_point #If first data point just set it
    else
        rsd.mean = (1/n)*data_point + ((n-1)/n) * rsd.mean #if more than one data point then do running average.
    end
end

Random.seed!(0)
get_new_data() = rand(0:10^4) 

data = FlexRunningStatsData{Int}()
collect_and_use_data(data)
-------
Mean: 5499.899999999998
Max: 9863
-------
Mean: 4969.8
Max: 9863
-------
Mean: 5139.483333333332
Max: 9926
-------
Mean: 5104.750000000001
Max: 9926
-------
Mean: 5103.690000000002
Max: 9926
@which mean(data)
mean(rsd::FlexRunningStatsData) in Main at /Users/uqjnazar/git/mine/ProgrammingCourse-with-Julia-SimulationAnalysisAndLearningSystems/markdown/lecture-unit-4.jmd:10

But there is a problem with the above. Should we have done <: Number or <: Real? What is T was Complex?

Using structs for data structures

Random.seed!(0)

struct Node
    id::UInt16
    friends::Vector{Node}
    Node() = new(rand(UInt16), [])
    Node(friend::Node) = new(rand(UInt16),[friend])
end

"""
Makes 'n` children to node, each with a single friend
"""
function make_children(node::Node, n::Int, friend::Node)
    for _ in 1:n
        new_node = Node(friend)
        push!(node.friends, new_node)
    end
end

root = Node()
make_children(root, 3, root)
for node in root.friends
    make_children(node, 2,root)
end
root
Node(0x1e40, Node[Node(0x8052, Node[Node(#= circular reference @-4 =#), Nod
e(0x5190, Node[Node(#= circular reference @-6 =#)]), Node(0xaeea, Node[Node
(#= circular reference @-6 =#)])]), Node(0x6399, Node[Node(#= circular refe
rence @-4 =#), Node(0xe900, Node[Node(#= circular reference @-6 =#)]), Node
(0x8113, Node[Node(#= circular reference @-6 =#)])]), Node(0x5c35, Node[Nod
e(#= circular reference @-4 =#), Node(0x5edf, Node[Node(#= circular referen
ce @-6 =#)]), Node(0x5518, Node[Node(#= circular reference @-6 =#)])])])

More to be covered as part of Unit 6:

See constructors in Julia docs. More on this in Unit 6.

See conversion and promotion in Julia docs

See interfaces in Julia docs