Types in Julia¶
Primitive type: a type defined using the keyword
primitive type
. Objects of a primitive type have a given fixed memory size specified in the type definition. 📝Int64
,Bool
,Char
Composite type: a type defined using the keyword
struct
. Composite types consist of zero or more fields referencing other objects (primitive or composite type).📝Complex
,Rational
(fieldsre, im
andnum, den
, respectively),Tuple
Concrete type: primitive or composite type
Abstract type: a type defined using the keyword
abstract type
. Abstract types have no fields, and objects cannot be created (instantiated) based on them. In addition, they cannot be declared as children of a particular type. Also, abstract types include non-specific types.📝Number
,AbstractFloat
Modifiable type: a composite type defined using the keyword
mutable struct
. Modifiable types can associate their fields with objects other than those they were associated with during initialisation.📝String
,Dict
Unmodifiable type: all types except those defined with
mutable struct
.Parametric type: a family of (mutable or immutable) composite or abstract types with the same field names and type name without regard to parameter types. The defined type is then uniquely identified by the parametric type name and the type(s) of the parameter(s). 📝
Rational{Int8}(1,2)
, see belowAbstractArray{T,N}
,Array{T,N}
Source types: a type whose definition is contained in Julia Base or in the Julia standard library
Bit type: a primitive or immutable composite type whose fields are all bit types.
Singleton: an object created from a composite type consisting of zero fields. 📝
nothing
,missing
Container: a composite type (not necessarily modifiable) designed to reference a variable number of objects and provide methods to access, enumerate, and eventually modify references to other objects.
Primitive type¶
In spite of the fact that the documentation does not recommend using the primitive type
construct, I suggest you to start your acquaintance with types with primitive types.
This is done because here we will go down to the lowest level, where we will see how data will be represented in memory in the end.
As an example, let's introduce our "space-protected" Bool, which fills all possible available bit cells either 0
or 1
.
When creating a primitive type, you must explicitly specify how many bits are needed to store the type. (In our case 8
)
primitive type FilledBool 8 end
function FilledBool(x::Int)
if iszero(x)
reinterpret(FilledBool,0b00000000)
elseif x == 1
reinterpret(FilledBool,0b11111111)
else
error("В качестве параметров допустимы только 0 и 1")
end
end
Base.show(io :: IO, x :: FilledBool) = print(io, bitstring(x))
@show tr = FilledBool(1)
@show fls = FilledBool(0)
println("Regular Bool true: ", bitstring(true))
Let's check if our type is a bit type:
isbitstype(FilledBool)
The documentation says that instead of creating your own primitive types, it is better to make a wrapper over them in the form of a composite type. Let's get to know it better!
Composite type¶
Non-interchangeable composite type¶
It is important to realise that a composite type can compose of multiple fields, or one or zero fields. Unlike many other programming languages, where fields and methods are associated with an object, only fields and its constructor are attached to a composite type in Julia. The relationship between OOP and Julia is interestingly explained here.
But we will focus on types for now.
Let's say we have a type "Mountain". We specify 2 characteristics of objects of this type:
- year of conquest (the year can be positive or negative)
- mountain height (assume that all mountains are above sea level)
In immutable types, once they are created, the fields cannot be changed.
struct Mountain
first_ascent_year::Int16
height::UInt16
end
Everest = Mountain(1953,8848)
Int(Everest.height)
try
Everest.height = 9000 # нельзя менять значения полей Mountain
catch e
e
end
To have a closer look at how the structure is organised, you can use:
dump(Everest)
Each element type of an invariant structure Mountain element is bit, so the Mountain type is bit
@show sizeof(Mountain) # 2 поля по 2 байта = 4
isbitstype(Mountain)
Let's consider the case when the fields of the immutable structure are non-bit type.
The string is stored not as an array of Char
's elements, but as a pointer to the Char
's array.
Therefore, the size of the structure is 8 bytes (the size of the pointer) and the size of the string is 6 bytes.
(Although sizeof(Char)=4
, in case of ASCII they will take 1 byte)
struct City
name::String
end
Moscow = City("Moscow")
Moscow.name
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
If you want to use static strings, then
import Pkg.add; Pkg.add("StaticStrings")
using StaticStrings
struct StaticCity
name::StaticString{10}
end
Moscow = StaticCity(static"Moscow"10) # дополняется \0 до 10
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
Although we cannot change the string, this type is not bitwise.
That is, it is important to understand the difference between unmodifiable and bit types.
The unusual behaviour of the ismutable("123") function is explained here
@show isbitstype(City)
@show isbitstype(StaticCity);
We would like to note separately that an immutable type can have immutable fields of a mutable type.
As an analogy: Suppose we have a rope to which a balloon is tied, which we can change: stretch, inflate, fill with water. But we can't tear off the string and attach a green ball to it.
struct Student
name::String
grade::UInt8 # класс
grades::Vector{Int} # оценки
end
Alex = Student("Alex", 1, [5,5,5])
@show sizeof(Alex) # 8 + 1 + 8 = 17 => 24 округление до x % 8 == 0
pointer(Alex.grades)
push!(Alex.grades,4)
Alex.grades
@show pointer(Alex.grades)
As we can see, we change the elements of the vector, but not the pointer to its first element.
# разыменование указателя на вектор (1й элемент вектора)
unsafe_load(pointer(Alex.grades))
And if we want to change not the elements of the vector, but the pointer to the vector, an error will occur.
try
Alex.grades = [1, 2, 3] # здесь же мы хотим
catch e
e
end
Variable type¶
In the case of a changeable type, we can change the fields.
mutable struct MutableStudent
const name::String
grade::UInt8 # класс
grades::Vector{Int} # оценки
end
Peter = MutableStudent("Peter", 1, [5,5,5])
Peter.grade = 2
But there is a possibility to make some fields of a changeable structure immutable (constant). In this case, despite the fact that the structure is changeable, this field cannot be changed.
try
Peter.name = "Alex"
catch e
e
end
You can see how now we can change a vector to another vector:
@show pointer(Peter.grades)
@show Peter.grades = [2,2,2]
@show pointer(Peter.grades)
The difference between immutable struct and mutable struct with constant fields.¶
Despite the fact that fields of immutable struct and constant fields of mutable struct cannot be changed, there is a significant difference between objects of such types with the same fields.
In case of immutable type - objects with the same fields are literally one and the same object, because all objects with the same fields will be located at the same address.
In the case of mutable struct
each of the objects with the same constant fields will be located at its unique address.
struct Immutable
a::Int32
b::Int32
end
mutable struct ConstMutable
const a::Int32
const b::Int32
end
im_obj_1 = Immutable(1,2)
im_obj_2 = Immutable(1,2)
const_mut_obj_1 = ConstMutable(1,2)
const_mut_obj_2 = ConstMutable(1,2)
# === означает равенство и значений и адресов в памяти
@show im_obj_1 === im_obj_2
@show const_mut_obj_1 === const_mut_obj_2
Immutable structures may not be as convenient in terms of their use interface. But their advantage is their placement "on the stack". While modifiable structures are usually stored "on the heap".
println(@allocations (a = Immutable(3,4); b = Immutable(3,4)))
println(@allocations (a = ConstMutable(3,4); b = ConstMutable(3,4)))
However, this statement need not be taken literally.
So, for example, the compiler can make optimisations and not allocate memory for modifiable structures inside a function that will return a number rather than a modifiable structure:
function foo(x,y)
obj1 = Immutable(x,y)
obj2 = Immutable(y,x)
c = obj1.a + obj2.b
end
function bar(x,y)
obj1 = ConstMutable(x,y)
obj2 = ConstMutable(y,x)
c = obj1.a + obj2.b
end
println(@allocations foo(1,2))
println(@allocations bar(1,2))
Abstract type¶
What are abstract types for? Abstract types are needed to:
- group concrete types
- define interfaces for functions
- control the scope of creation of other classes using parameterisation (see below)
Grouping of specific types¶
Thanks to abstract types, type hierarchies can be organised.
Let's consider the classic and most understandable type - Number
.
Using A <: B
We can specify or check that the type A
is a subtype of . B
Int8 <: Integer || Int16 <: Integer
subtypes(Signed)
We can also work the other way round:
B :> A
indicates that B
is a supatype A
And the supertypes function returns a tuple of supertypes ordered from left to right in ascending order
supertypes(Int8)
But a more visually pleasing extension is the AbstractTrees package, which allows us to get a familiar picture.
using AbstractTrees
AbstractTrees.children(t::Type) = subtypes(t)
print_tree(Number) # здесь можно видеть типы и от Engee
However, I recommend launching print_tree(Any)
and diving into the marvellous world of Julia types))))
Abstract types and multiple dispatching¶
Finishing the reasoning about our numbers, we would like to note that it is logical that any two numbers can be added together.
That is why in promotion.jl is the following line:
+(x::Number, y::Number) = +(promote(x,y)...)
(using methods(+)
you can see what adds up to what and by what rules)
Although, for example, the least common multiple should be defined only for integers or rationals. We will discuss it at the end.
And for those who are tired of numbers, I suggest to return to our rams
abstract type Pet end
struct Dog <: Pet; name::String end
struct Cat <: Pet; name::String end
function encounter(a::Pet, b::Pet)
verb = meets(a, b)
println("$(a.name) встречает $(b.name) и $verb.")
end
meets(a::Dog, b::Dog) = "нюхает"
meets(a::Dog, b::Cat) = "гонится"
meets(a::Cat, b::Dog) = "шипит"
meets(a::Cat, b::Cat) = "мурлычит"
fido = Dog("Рекс")
rex = Dog("Мухтар")
whiskers = Cat("Матроскин")
spots = Cat("Бегемот")
encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)
The convenience is that we can not specify for each animal how it greets the other, but make a common interface "greetings" for animals.
meets(a::Pet, b::Pet) = "здоровается"
struct Cow <: Pet; name::String end
encounter(rex,Cow("Бурёнка"))
This doesn't work so conveniently in all languages. You can see more about this in the video from which this code was taken.
DataType¶
But before we get to the last section.
** Let's bring on the turmoil! **
And for now, let's just take a look at the strange beast-- DataType
123 isa Integer
Vector isa DataType || Dict isa DataType
Function isa DataType
Don't worry, explanations will be forthcoming.
Parameterised types¶
Both composite, abstract and even primitive types can be parameterised.
Let's start with the more obvious type:
Parameterised composite types¶
can be useful when it is important to preserve the logic of the structure, but the type of object fields can change.
For example, it is possible to guess what a complex number is:
struct Complex{T<:Real} <: Number
re::T
im::T
end
ci8 = Int8(1)+Int8(2)im
@show typeof(ci8)
sizeof(ci8)
cf64 = 1.5+2im
@show typeof(cf64)
sizeof(cf64)
As you can see, depending on what parameters we passed, we get objects of different types. They occupy different amounts of memory and may work differently.
Parameterised abstract types¶
An example of a parameterised abstract type is AbstractDict
abstract type AbstractDict{K,V} end
A dictionary in turn is:
mutable struct Dict{K,V} <: AbstractDict{K,V}
slots::Memory{UInt8}
keys::Memory{K}
vals::Memory{V}
...
...
end
This is necessary in order to implement efficient interfaces.
For example, the environment variable set ENV
is not a Dict, but it is an AbstractDict.
It is important that the ENV be parametric AbstractDict{String,String}
.
This is why parametric abstract types can be very convenient.
ENV isa Dict
ENV isa AbstractDict
FINALLY, ARRAYS!¶
It is only now, having reached parametric abstract types, that we can understand what arrays are.
Even though implementation arrays are written in C, we can see what they are
[1,2,3]
[1 2 3;
4 5 6;
7 8 9;]
and rand(3,3,3,3)
It's all about the definition of the type:
abstract type AbstractArray{T,N} end
N here could be denoted as
Abstract type AbstractArray{T,N<:Unsigned} end
Array <: DenseArray <: AbstractArray
Array{Int8,1}(undef,4)
Array{Int8,2}(undef,2,3)
Array{Int8,3}(undef,3,3,3)
And here is the answer to why range in Julia supports the array interface:
1:0.5:1000 isa StepRangeLen <: AbstractArray
I.e. parametric type is similar to templates in C++.
But it is important to understand the peculiarities of types in Julia¶
Parametric types in Julia are invariant.
Vector{Int} <: Vector{Real}
Vector{Int} <: Vector{<:Real}
Vector{Complex} <: Vector{<:Real}
Vector{Complex} <: Vector
This is where DataType can help us. DataType allows us to understand if a type is "declared".
All concrete types are DataType.
- most non-parametric types are DataTypes
abstract type Number end;
- If we have specified parameters, it is also a DataType.
To understand what a DataType is - it is easier to start from what is not a DataType.
Union{Int32,Char} isa DataType
Vector{<:Real} isa DataType # тоже своего рода "объединение всех векторов, чей тип является подтипом Real"
And how does it all apply ?¶
Multiple dispatching has the following priority:
- Concrete type
- abstract type
- Parameterised type
We finish by looking at how the arranged function of the greatest common multiple where there are "overloaded" functions:
#1
function lcm(a::T, b::T) where T<:Integer
#2
function lcm(x::Rational, y::Rational)
#3
lcm(a::Real, b::Real) = lcm(promote(a,b)...)
#4
lcm(a::T, b::T) where T<:Real = throw(MethodError(lcm, (a,b)))
For the case of integers, function 1 will be called
If we pass rational parameters, then function 2 will be called.
If we pass lcm(2, 2//3)
, function 3 will be called first and type promotion will take place. After that function 2 will be called.
But if we call lcm(2, 1.5) , then after type promotion we will get to 4 - the "template" version, where an error will be called.
This is similar to the rule
promote(2, 2//3)
See you soon!