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.
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:
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
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
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]
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)
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)
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 do
–end
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
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)
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
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")
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
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]]
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
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.
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!
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
?
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 =#)])])])
See constructors in Julia docs. More on this in Unit 6.
See conversion and promotion in Julia docs