Jdi na navigaci předmětu

03: Řídící struktury a Typový systém

Tento notebook je výukovým materiálem v předmětu BI-JUL.21 vyučovaném v zimním semestru akademického roku 2023/2024 Tomášem Kalvodou. Tvorba těchto materiálů byla podpořena NVS FIT.

Hlavní stránkou předmětu, kde jsou i další notebooky a zajímavé informace, je jeho Course Pages stránka.

versioninfo()
Julia Version 1.9.3
Commit bed2cd540a1 (2023-08-24 14:43 UTC)
Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: Linux (x86_64-linux-gnu)
  CPU: 8 × Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-14.0.6 (ORCJIT, skylake)
  Threads: 2 on 8 virtual cores

0. Úvodní poznámky (a.k.a. Rozcvička)

Než se pustíme do hlavních témat této lekce, tak si ukážeme základní prvky jazyka Julia (zejména co se syntaxe týče). K většině těchto témat se pak vrátíme podrobněji později během semestru. Nyní potřebujeme čtenáře zasvětit alespoň do úplných základů, jinak by nebyl další výklad tak zábavný.

Předpokládáme, že studenti již mají z předchozí lekce k dispozici funkční Julia instalaci.


0.1 Komentáře

Cokoliv za znakem # je ignorováno až do konce řádku.

Cokoliv mezi #= a =# je ignorováno i napříč více řádky.

Vyhodnocení kódu v následujících dvou buňkách proto nevygeneruje žádný výstup a v třetí buňce jsme skryli komentář přímo do argumentu jedné funkce (nakolik je toto vhodné je otázkou, je to ale možnost).

2 + 2 # this line is commented out
1 + 1
2
#= this
stuff is
IGNORED
=#
sin(1.0 #= radians, obviously! =#)
0.8414709848078965

0.2 Čísla, řetězce, pravdivostní hodnoty

Často potřebujeme pracovat s čísly, řetězci a pravdivostními hodnotami. V tomto aspektu se Julia příliš neliší od ostatních programovacích jazyků.

Číselné literály mají standardní očekávaný tvar:

# Integer
123
123
# Float
12.2
12.2
12.0
12.0

Ale můžeme využívat i zkrácený tvar

# Float
.1
0.1
# Float
1.
1.0

nebo "vědecký" tvar

# Float
1e-10
1.0e-10

K zápisu komplexních čísel je potřeba mít možnost vyjádřit imaginární část komplexního čísla. Toho docílíme přidáním im k číselnému literálu:

10 + 5im
10 + 5im
3. + 1im
3.0 + 1.0im
0im
0 + 0im
1im
0 + 1im

Řetězce uvozujeme pomocí dvojitých uvozovek:

"Hello!"
"Hello!"

Ke spojování řetězců se používá operátor *. K několikanásobnému opakování jednoho řetězce pak exponenciace pomocí operátoru ^.

"Budiž" * " " * "světlo"
"Budiž světlo"

Pozor, plus se takto nechová.

"A" + "B"
MethodError: no method matching +(::String, ::String)
String concatenation is performed with * (See also: https://docs.julialang.org/en/v1/manual/strings/#man-concatenation).

Closest candidates are:
  +(::Any, ::Any, ::Any, ::Any...)
   @ Base operators.jl:578


Stacktrace:
 [1] top-level scope
   @ In[17]:1
("Budiž" * " " * "světlo") ^ 5
"Budiž světloBudiž světloBudiž světloBudiž světloBudiž světlo"
"~" ^ 25
"~~~~~~~~~~~~~~~~~~~~~~~~~"

Jednoduché uvozovky pojmou jenom jeden znak (char).

'a'
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
'ab'
syntax: character literal contains multiple characters

Stacktrace:
 [1] top-level scope
   @ In[21]:1

Regulární výrazy se také zapisují do složených závorek, pouze jsou navíc prefixovány písmenkem r. (Julia používá khihovnu PCRE.)

r"[abc]+"
r"[abc]+"

Pravdivostní hodnoty zapisujeme true (pravda) a false (nepravda).

K dispozici dále máme standardní binární operátory (sčítání +, násobení *, dělení /), závorky a logické operátory (and && a or || a negaci !). K umocňování se používá symbol stříšky ^.

2^8
256

Jednou Julia zajímavostí, která stojí za zmínku v tento okamžik, je zkrácené psaní součinů bez znaménka *, podobně jak jsme tomu zvyklí v matematické syntaxi. K přiřazení hodnoty do proměnné se standardně používá rovnítka.

x = 10

5x + 2(10 + x)
90

Pozor na pořadí, cifra na konci názvu se už jako násobení neinterpretuje.

x5
UndefVarError: `x5` not defined

U zkráceného zápisu stojkového čísla je také nutné násobení uvést explicitně.

5. * x
50.0
5.x
syntax: numeric constant "5." cannot be implicitly multiplied because it ends with "."

Stacktrace:
 [1] top-level scope
   @ In[27]:1

Druhou zajímavostí může být způsob psaní víceřádkových výrazů. V jiných jazycích je k tomu potřeba umístit speciální znak na konec řádku (typicky /). V Julia stačí řádek zakončit binárním operárorem, jako v

1 + 2 +
    3 + 4
10

nebo

true ==
  false
false

Další zajímavostí může být chování dělení /. I u integerů dělitelných beze zbytku vždy vyústní ve float:

4 / 2
2.0
div(4, 2)
2
1 / 3
0.3333333333333333

Vedle symbolu / pro dělení můžeme použít k stejnému účelu i \. Toho se využívá zejména při práci s maticemi (násobení inverzí z dané strany).

1 \ 3
3.0

Je dobré se ale vyhnout nepřehledným výrazům jako například (/ a \ mají stejnou prioritu, ale jsou zleva asociativní, tj. výrazy a X b X c, kde X je \ nebo / jsou interpretovány jako (a X b) X c):

1 / 2 \ 3
6.0
(1 / 2) \ 3
6.0
1 / (2 \ 3)
0.6666666666666666

Pokud potřebujeme celočíselné dělení, použijeme k tomu metodu div.

div(4, 2)
2
div(1, 3)
0

K výpočtu zbytku po celočíselném dělení máme k dispozici operátor % nebo metodu mod (dále je zde mod1 a mod2pi):

5 % 3
2
mod(5, 3)
2
mod1(3.3, 2.0)
1.2999999999999998
mod2pi(7.0)
0.7168146928204135

Případně můžeme pracovat v exaktní aritmetice racionálních čísel.

# Rational
4 // 2
2//1
1 // 3 + 2 // 5
11//15

Během semestru se k této problematice vrátíme podrobněji.


0.3 Symboly

Vedle řetězců ještě narazíme na symboly, v podstatě pojmenované hodnoty. Kterými se často předávájí různé požadavky/parametry, třeba metodám pro vykreslování funkcí. Symbol je uvozený dvojtečkou za níž následují písmena, čísla, nebo podtržítka.

:my_symbol_no_1
:my_symbol_no_1

0.4 Pole, tuply, slovníky

Vícerozměrným polím se budeme věnovat velmi podrobně v páté lekci. V tento okamžik si ukažme několik způsobů jak jednoduše vytvářet vektory (jednorozměrné pole) a matice (dvourozměrné pole). K zápisu polního literálu se používá hranatých závorek. Prvky můžeme oddělovat čárkami, středníky, nebo bílými znaky. Všimněte si rozdílu v rozměrech:

[1, 2, 3, 4, 5]
5-element Vector{Int64}:
 1
 2
 3
 4
 5
[1 2 3 4 5]
1×5 Matrix{Int64}:
 1  2  3  4  5
[1, 2.4]
2-element Vector{Float64}:
 1.0
 2.4
[1, 3.4, "Ahoj!"]
3-element Vector{Any}:
 1
 3.4
  "Ahoj!"

Další řádek matice můžeme oddělit koncem řádku.

[
    1.0 2.0 3.0
    4.0 5.0 6.0
]
2×3 Matrix{Float64}:
 1.0  2.0  3.0
 4.0  5.0  6.0

Nebo lze k tomuto účelu použít středník. Pozor na rozměry, následující pokus z dobrých důvodů selže!

[ 1 2 3; 4 5 ]
ArgumentError: argument count does not match specified shape (expected 6, got 5)

Stacktrace:
 [1] hvcat(::Tuple{Int64, Int64}, ::Int64, ::Vararg{Int64})
   @ Base ./abstractarray.jl:2113
 [2] top-level scope
   @ In[51]:1

Následující kód vytvoří čtvercovou matici.

A = [1 2; 3 4]
2×2 Matrix{Int64}:
 1  2
 3  4

Pokud jste vytvářeli matice v Pyhtonu (Numpy), tak by vás možná napadlo použít "vnořené listy". To v Julia ovšem bude mít jiný význam, dostanete vektor (jednorozměrné pole), které má jako prvky matice:

[[1 2], [3 4]]
2-element Vector{Matrix{Int64}}:
 [1 2]
 [3 4]

Tento objekt se už nechová jako matice, nebude na něm správně fungovat maticové násobení a další metody lineární algebry!

Během semestru se různým metodám vytváření matic budeme věnovat. Toho, čeho jsme se snažili dosáhnout v buňce výše, bychom dosáhli pomocí metody vcat (existuje i hcat):

vcat([1 2], [3 4])
2×2 Matrix{Int64}:
 1  2
 3  4

Pole jsou měnitelné (mutable) objekty, jsou to kontejnery nesoucí objekty jistého druhu (typu, viz níže), které můžeme modifikovat. K prvkům pole přistupujeme pomocí hranatých závorek, ve výchozím stavu jsou indexovány od jedné. Řádkové a sloupcové indexy mají stejný význam jako v matematice.

A[1, 2] = 999 # první řádek, druhý sloupec
A
2×2 Matrix{Int64}:
 1  999
 3    4

Tuple (tento termín do češtiny překládat nebudeme -- nejblíže by k němu měla "uspořádaná nn-tice") je v podstatě neměnná (immutable) verze jednorozměrného pole. K jejímu literálnímu zápisu slouží kulaté závorky.

() # prázdný tuple
()

Pozor:

(1,) # tuple s jedním prvkem
(1,)
(1)  # jakýsi výraz, jehož hodnota je 1
1

Tuple obsahující tři prvky.

t = (1, 2, 3)
(1, 2, 3)

Prvky tuple se indexují také pomocí hranaté závorky a index běží od 11. Prvky tuple nelze modifikovat.

t[1] = 10
MethodError: no method matching setindex!(::Tuple{Int64, Int64, Int64}, ::Int64, ::Int64)

Stacktrace:
 [1] top-level scope
   @ In[60]:1

Oproti tomu:

a = [1, 2]

a[1] = 10

a
2-element Vector{Int64}:
 10
  2

Poslední základní, a často používanou, datovou strukturou je slovník (dictionary). Ten explicitně můžeme vytvořit následujícím způsobem

d = Dict(1 => "Hello!", 2 => "Goodbye!")
Dict{Int64, String} with 2 entries:
  2 => "Goodbye!"
  1 => "Hello!"
d[1]
"Hello!"
d[2]
"Goodbye!"
d[3] = "Hello?"

d
Dict{Int64, String} with 3 entries:
  2 => "Goodbye!"
  3 => "Hello?"
  1 => "Hello!"

Všimněte si, že u tohoto slovníku d jsou typy hodnot klíčů a hodnot omezené (na integery, resp. řetězce). Pokud chceme mít slovník, který pojme prakticky cokoliv, můžeme toho dosáhnout takto:

d2 = Dict{Any, Any}()
Dict{Any, Any}()
d2[2] = "Ahoj!"
d2["a"] = 42im
d2
Dict{Any, Any} with 2 entries:
  2   => "Ahoj!"
  "a" => 0+42im
d2[2.0] = "Ha!"
d2
Dict{Any, Any} with 2 entries:
  2.0 => "Ha!"
  "a" => 0+42im
d2[2]
"Ha!"

Ooops.


0.5 Funkce, resp. metody

Funkcemi, resp. metodami, se budeme ve větší hloubce zabývat v příští (čtvrté) lekci.

Nyní si alespoň uveďme dva základní způsoby, jak definovat nové funkce (ve skutečnosti metody, z pohledu Julia). První je jednořádkový:

f(#= arguments =#) = #= expression =#

Druhý víceřádkový, vhodnější pro funkce s větším tělem:

function f(#= arguments =#)
    #= body =#
end

Dále můžeme vytvořit anonymní funkci a tu přiřadit do proměnné (lambda funkce):

f = (#= arguments =#) -> #= expression =#

Například tedy můžeme definovat:

f(x, y) = x + y

function g(x)
    x + 1
end

h = (x, y) -> x + y + 1;

Tyto funkce pak můžeme zkusmo vyhodnotit na několika vstupech.

f(1, 2)
3
g(1.2345)
2.2344999999999997
h(5, 6)
12

Návratová hodnota je hodnota naposledy vyhodnoceného výrazu, nebo ji lze explicitně předat pomocí klíčového slova return.

Dále vedle standardního způsobu zápisu volání pomocí závorek (prefixová notace) můžeme použít operátoru |> k postfixovému zápisu volání (v Mathematica //):

1.0 |> sin
0.8414709848078965
sin(1.0)
0.8414709848078965

Tuto operaci lze řetězit, případně i rozepsat pro přehlednost na více řádků.

2 |> x -> x^2 |> sqrt
2.0

Pozor, známý symbol | má význam bitového OR.

1 | 2
3

0.6 Metoda println

V Jupyteru/JupyterLabu se při vyhodnocení buňky vypíše hodnota naposledy vyhodnoceného výrazu. Pokud chceme vypsat více hodnot, můžeme k tomu použít metodu println. Pokud program spouštíme z příkazové řádky, pak se tento výstup vypisuje na standardní výstup. Všimněte si i grafického znázornění tohoto chování u levého okraje buňky:

println(1)
println(2)
1 + 2
1
2
3

Pro hrubé vypisování různých hlášení a informací se hodí řetězcová interpolace, pro kterou se v Julia využívá symbol $:

x = 10

println("The value of x is $x")
println("The square root of x is $(sqrt(x))")
The value of x is 10
The square root of x is 3.1622776601683795

Alternativně println akceptuje více argumentů, které spojuje bez mezer:

x = 42

println("And the answer is: ", x)
And the answer is: 42

Později během semestru se budeme zabývat modulem Logging, který umožňuje podrobnější vypisování informací a hlášení o chybách. Dále je ve standardní knihovně k dispozici modul Printf poskytující metodu printf umožňující kontrolovat formátování výstupu (zaokrouhlování, počet cifer atd.).


0.7 Unicode znaky

Ve zdrojovém kódu Julia programu lze používat unicode znaky. K jejich snadnému zápisu lze použít LaTeX syntaxi (minimálně v Jupyteru/JupyterLabu, REPL i VSCode).

Napište LaTeX makro a poté stiskněte klávesu TAB.

K řadě "built in" metod, nebo i konstant, lze tímto způsobem přistupovat. Například:

π
π = 3.1415926535897...
π3.141592653897
true
4
2.0

V některých případech tento přístup může být užitečný, ale mělo by se ho používat spíše poskrovnu.

Lehce zajímavě lze u symbolů používaných pro binární operace následně používat i infixovou notaci!

⊕(x, y) = mod(x + y, 5)
⊕ (generic function with 1 method)
23
0
43
2

0.8 Konstanty

Konstanty definujeme pomocí klíčového slova const.

const AlmostPi = 3.0
3.0

Jejich hodnoty nelze měnit.

AlmostPi = 1
invalid redefinition of constant AlmostPi

Stacktrace:
 [1] top-level scope
   @ In[88]:1

0.9 Středník

Pomocí středníku můžeme umístit více příkazů na jeden řádek a/nebo potlačit výstup.

a = 1; b = 2
2
a + b;

Cvičení

  1. Výše jsme viděli, že číslo π\pi odpovídá Julia konstantě se stejným symbolem. Jak je to s Eulerovým číslem?
  2. Pod jakými názvy najdete v Julia trigonometrické funkce, exponenciální funkce a logaritmus?
  3. Napadá vás nějaký základní konstrukt, vám dobře známý z jiných programovacích jazyků, který jsme nezmínili?
  4. Experimentálně prozkoumejte, jak se Julia chová vůči přetečení. Máme k dispozici i datové typy pro práci s čísli s většími rozsahy/přesností?

1. Řídící struktury

Nyní se pustíme do prvního tématického bloku této lekce a probereme různé imperativní prvky jazyka Julia.

V této části by asi pro studenty, kteří již s programováním přišli do styku, nemělo být nic zásadně překvapivého a proto bude tento výklad spíše stručnější.


1.1 Podmínky (if-elseif-else)

Anatomie podmínky má nepřekvapivou strukturu:

if boolean_condition_1
    # ...
    # evaluates if boolean_condition_1 is true")
    # ...
elseif boolean_condition_2
    # ...
    # evaluates if boolean_condition_1 is false AND boolean_condition_2 is false
    # ...
# ...
# possibly more elseifs
# ...
else
    # ...
    # evaluates if both boolean_condition_1 AND boolean_condition_2 are false
    # ...
end

If blok vrací hodnotu posledního vyhodnoceného výrazu.

Pozor! Výrazy použité v podmínkách skutečně musí mít hodnotu true nebo false. Následující experiment nedopadne dobře:

if 1
    println("Yay!")
end
TypeError: non-boolean (Int64) used in boolean context

Stacktrace:
 [1] top-level scope
   @ In[1]:1

Tuto chybu použijeme i k vysvětlení na první pohled možná kryptické informace In[74]:1 v červeném chybovém výpisu. Julia nám zde naznačuje, že prvotní chyba nastala v buňce 25 na řádku 1.


"Ternární if"

Dále je nám k dispozici "ternární operátor" ?:, jeho struktura je opět standardní a asi jste se s ní setkali i v jiných jazycích:

boolean_condition ? #= expression evaluated when true =# : #= expression evaluated when false =#

Cvičení

Absolutní hodnota reálného čísla je typicky definována předpisem x={x,x0,x,x<0.|x| = \begin{cases} x, & x \geq 0, \\ -x, & x < 0. \end{cases} Definujte odpovídající metodu my_abs v Julia.

my_abs(x) = x < 0 ? -x : x
my_abs (generic function with 1 method)

Podle očekávání dostaneme:

println(my_abs(10))
println(my_abs(-1.1))
println(my_abs(π))
10
1.1
π

Následující pokus ovšem zcela oprávněně selže:

my_abs("wtf")
MethodError: no method matching isless(::String, ::Int64)

Closest candidates are:
  isless(::AbstractString, ::AbstractString)
   @ Base strings/basic.jl:345
  isless(::AbstractFloat, ::Real)
   @ Base operators.jl:179
  isless(::Real, ::Real)
   @ Base operators.jl:421
  ...


Stacktrace:
 [1] <(x::String, y::Int64)
   @ Base ./operators.jl:343
 [2] my_abs(x::String)
   @ Main ./In[2]:1
 [3] top-level scope
   @ In[4]:1

Jak se s podobnými situacemi vypořádat si ukážeme v následující části této lekce o typech.


Logické operátory

Pro vytváření logických výrazů využitelných například v if podmínkách lze standardně použít závorky a operátory && (and) a || (or). Při vyhodnocení těchto výrazů se Julia snaží vyhodnotit co nejméně podvýrazů, tj. např v false && whatever se whatever nevyhodnocuje (short circuit evaluation). Následující dělení nulou nám projde nepovšimnuto:

isodd(2) && 1 / 0
false

Toho se občas v Julia používá ke kompaktnímu vyjádření podmínky:

iseven(2) && println("It is even!")
It is even!

Hodnota výrazu println("It is even!") je nothing, což je v těchto výrazech akceptovatelné a má za následek také hodnotu nothing, konkrétně:

true && nothing
false || nothing

Alternativně lze použít operátory & a |, které ale vždy vyhodnocují svoje argumenty. Pozor, tyto operátory mají také význam bitového and a or. Pro bitový xor je v infixové notaci k dispozici operátor ⊻ (LaTeX makro \xor).

1 & 2
0
2 | 1
3
23
1

A dělení nulou nám projde i teď :-D.

true & 1 / 0
Inf
true and true
syntax: extra token "and" after end of expression

Stacktrace:
 [1] top-level scope
   @ In[6]:1

1.2 Složené výrazy / bloky

Julia umožňuje vyhodnocování výrazů složit do bloků. Hodnotou bloku je hodnota naposledy vyhodnoceného výrazu. K dispozici máme víceřádkové bloky uvozené mezi begin a end:

u = 12

z = begin
    x = u + 1
    y = 2
    x + y
end

z
15

Nebo jednořádkové bloky, kde jednotlivé výrazy oddělujeme středníky ;. Závorky nejsou vždy nutné, ale značně zlepšují čitelnost:

z = (x = 1; y = 2; x + y)

z
3

1.3 Cykly (while a for)

Máme k dispozici v zásadě dva způsoby opakovaného vyhodnocování výrazů: for a while cyklus.


while cyklus

Tělo while cyklu se vyhodnocuje dokud podmínka za klíčovým slovem while vrací true:

j = 1

while j <= 3
    println(j)
    j += 1
end

println("---")
println(j)
1
2
3
---
4

for cykly a rozsahy (range)

Anatomie for cyklu je opět nepřekvapivá:

for j = iterator
  # ...
  # do stuff with j
  # ...
end

Zde iterator je cokoliv implementující Julia iterační protokol. Nejčastěji rozsah (range; budeme se jim věnovat později v lekci o polích) start:end, tuple, nebo pole (i vícerozměrné).

Místo symbolu = můžeme použít i slovíčko in nebo dokonce symbol (LaTeX \in). Konkrétní volba je spíše otázkou vkusu. Následuje několik elementárních ukázek.

for j = 1:3
    println(j)
end
1
2
3

V rozsahu lze případně i měnit délku kroku.

for j in 1:2:5
    println(j)
end
1
3
5

Můžeme pak snadno iterovat i v opačném pořadí.

for j = 5:-1:0
    println(j)
end
5
4
3
2
1
0

A konečně, příklad iterace přes prvky jednorozměrného pole, s drobnou reklamou na další téma této lekce.

for x ∈ [1, π, "string", :symbol]
    println(typeof(x))
end
Int64
Irrational{:π}
String
Symbol

K urychlení přechodu k dalšímu prvku můžeme použít klíčové slovo continue. Například, následující kód je zvrhlý způsob vypsání několika malých sudých čísel.

for n = 1:10
    isodd(n) && continue
    
    println(n)
end
2
4
6
8
10

Hodnoty range nemusí být nutně celočíselné, koncová hodnota se pak může dopočítat:

0.1:0.1:6.0
0.1:0.1:6.0
[x for x in 0.1:0.5:6.0] # k této syntaxi se ještě dostaneme
12-element Vector{Float64}:
 0.1
 0.6
 1.1
 1.6
 2.1
 2.6
 3.1
 3.6
 4.1
 4.6
 5.1
 5.6

Cvičení: Naivní test prvočíselnosti

Implementujte "naivní" test prvočíselnosti: Má-li přirozené číslo nn netriviálního dělitele, pak tento nutně leží mezi 22 a n\sqrt{n} (včetně). (Proč?)

function my_test(n)
    k = 2
    while k^2 <= n
        if n % k == 0
            return false
        end

        k += 1
    end

    return true
end
my_test (generic function with 1 method)

Použití je pak nasnadě:

my_test(4)
false
my_test(13)
true
my_test(9)
false
for k in 2:sqrt(5)
    println(k)
end
2.0
sqrt(4)
2.0

Cvičení: Řetězové zlomky

Mějme (n+1)(n+1)-tici (a0,a1,,an)(a_0,a_1,\ldots,a_{n}). Následující výraz nazýváme (ukončeným) řetězovým zlomkem

a0+1a1+1a2+1a3++1an.a_0 + \frac{1}{a_1 + \frac{1}{a_2 + \frac{1}{a_3 + \ddots + \frac{1}{a_n}}}}.

Implementujte metodu, která spočte hodnotu tohoto zlomku.

function my_cf(seq)
    acc = seq[end]
    
    for k = (length(seq)-1):-1:1
        acc = seq[k] + 1 / acc
    end

    return acc
end
my_cf (generic function with 1 method)
my_cf([2])
2
my_cf([1, 2])
1.5

Jaké hodnoty vám připomínají následující?

my_cf([2, 1, 2, 1, 1, 4, 1, 1, 6, 1, 1, 8, 1, 1, 10])
2.7182818284454013
my_cf([2, 1, 2, 1])
2.75
my_cf([3, 7, 15, 1, 292, 1, 1, 1, 2, 1, 3, 1, 14, 2, 1, 1, 2, 2, 2, 2, 1, 84, 2, 1, 1])
3.141592653589793
my_cf([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
1.618034447821682

2. Typový systém

Za hlavní znaky Julia považuji její typový systém (tím se budeme zabývat zde) a multiple dispatch (kterým se budeme zabývat hned v příští lekci).

  • Julia patří mezi dynamicky typované jazyky. Typy objektů mohou tedy být známy teprve až v okamžiku běhu programu.

  • Julia ale umožňuje používat typové anotace a tím (nejen) pomoci LLVM kompilátoru při optimalizaci kódu. Dále je pomocí typových anotací možné vytvářet více metod jedné funkce, které mohou vykonávat jinou činnost podle typu argumentů. Tímto způsobem lze vlastně volit vždy nejvhodnější algoritmus pro danou situaci. Toto je zásadní prvek návrhu Julia, tzv. multiple dispatch.

  • V Julia mezi typy existuje explicitní hierarchie. Julia typy mohou být dále parametrizované jinými typy.

Hned na začátku této části lekce upozorněme na metodu typeof, pomocí které může zvídavý programátor zjistit typ objektu. Například můžeme prozkoumat typy objektů, s kterými jsme pracovali v úvodu této lekce.

typeof(1)
Int64
typeof(1.0)
Float64
typeof(1im)
Complex{Int64}
typeof(2.0 + 4.0im)
ComplexF64 (alias for Complex{Float64})

V tomto případě jde o parametrický typ! Za chvilku se jim budeme věnovat podrobněji.

typeof(Inf)
Float64
typeof("Hello!")
String
typeof([1.0, 2.0])
Vector{Float64} (alias for Array{Float64, 1})
typeof([1 2; 3 4])
Matrix{Int64} (alias for Array{Int64, 2})
typeof(:symbol)
Symbol
typeof(π)
Irrational{:π}

2.1 Abstraktní a konkrétní typy

Abstraktní typy jsou, abstraktní... Každý abstraktní typ má svůj "supertyp" a může mít více "podtypů". Vztah "být podtypem" je tranzitivní.

Nelze vytvořit objekt abstraktního typu, tyto typy tedy slouží jen jako vrcholy v hierarchické stromové struktuře typového systému. Na vrcholu této hierarchie je typ Any, ten je supertypem všech typů. Význam abstraktních typů je efektivně následující: lze pomocí nich sdružit "příbuzné" typy a lze pomocí nich kontrolovat, která metoda funkce se ve finále zavolá (multiple dispatch).

Deklarace abstraktního typu je jednoduchá (v prvním případě bude supertypem typ Any):

abstract type #=type_name=# end
abstract type #=type_name=# <: #=supertype=# end

Typickým příkladem abstraktních typů mohou být následující číselné typy (ukázka ze zdrojového kódu Julia, velká část je napsaná přímo v Julia):

abstract type Number end
abstract type Real     <: Number end
abstract type AbstractFloat <: Real end
abstract type Integer  <: Real end
abstract type Signed   <: Integer end
abstract type Unsigned <: Integer end

Pomocí operátoru <: lze navíc i testovat, zda daný typ je podtypem jiného typu.

Real <: Number
true
Real <: Any
true

Možná překvapivě, z čistě matematického pohledu -- nepleťte <: s inkluzí odpovídajících množin:

Real <: Complex
false
Complex <: Number
true

Konkrétní typy, tj. typy, které již lze instanciovat, nemohou mít další konkrétní podtypy. Mezi konkrétní typy patří například různé číselné typy jako Int64 nebo Float64.

Int64 <: Real
true
Int64 <: AbstractFloat
false

Přirozeně se nabízí otázka, jakého typu je typ?

typeof(Int64)
DataType
typeof(DataType)
DataType
DataType <: Any
true

Užitečná také může být metoda supertypes, resp. supertype, která nám odhalí supertypy daného typu.

supertypes(Float64)
(Float64, AbstractFloat, Real, Number, Any)
supertype(Float64)
AbstractFloat
supertypes(String)
(String, AbstractString, Any)

Na "druhou stranu" máme subtypes se zřejmým významem.

subtypes(AbstractFloat)
4-element Vector{Any}:
 BigFloat
 Float16
 Float32
 Float64

2.2 Primitivní typy

Primitivní typy jsou konkrétní typy, které lze považovat za dále datově nedělitelné. Tj. objektům těchto typů v paměti náleží data, která nelze dále dělit (mějte na mysli binární typy jako Int64 nebo Float64). Běžný uživatel pravděpodobně nebude mít potřebu definovat vlastní primitivní typy, i když by mohl. Podrobněji se jim zde věnovat nebudeme.

V Julia jsou například strojová čísla s různým počtem bitů definována následovně:

primitive type Float16 <: AbstractFloat 16 end
primitive type Float32 <: AbstractFloat 32 end
primitive type Float64 <: AbstractFloat 64 end

primitive type Bool <: Integer 8 end
primitive type Char <: AbstractChar 32 end

primitive type Int8    <: Signed   8 end
primitive type UInt8   <: Unsigned 8 end
primitive type Int16   <: Signed   16 end
primitive type UInt16  <: Unsigned 16 end
primitive type Int32   <: Signed   32 end
primitive type UInt32  <: Unsigned 32 end
primitive type Int64   <: Signed   64 end
primitive type UInt64  <: Unsigned 64 end
primitive type Int128  <: Signed   128 end
primitive type UInt128 <: Unsigned 128 end

Příkladem konkrétního typu, který není primitivní, jsou například datové typy modelující komplexní čísla.


2.3 Vsuvka: Deklarace typu pomocí operátoru ::

Operátor :: má v Julia dva významy.

Za každým výrazem (expression) lze pomocí :: deklarovat, jaký typ má mít jeho hodnota. Kompilátor tuto informaci bere do úvahy a často tak můžeme odhalit nesprávné chování našeho programu ještě před jeho spuštěním (resp. vyhodnocením výrazu).

Ukažme si to na následujících příkladech. Nejprve kontrola typu hodnoty výrazu:

x = 1

(x + 2)::Int64 # součet je Int64
3
x = 2.0

(x + 2)::Int64 # součet by sice přirozeně šlo převést na Int64, ale není to Int64
TypeError: in typeassert, expected Int64, got a value of type Float64

Stacktrace:
 [1] top-level scope
   @ In[94]:3
x = 2.5

(x + 3)::Int64 # zde ani není šance na převod
TypeError: in typeassert, expected Int64, got a value of type Float64

Stacktrace:
 [1] top-level scope
   @ In[95]:3

Pokud :: použijeme za symbolem lokální proměnné na levé straně přiřazení, tak tím deklarujeme typ její hodnoty, který nelze měnit.

Pokud hodnota daného typu není, tak se ji Julia nejprve pokusí na požadovaný typ převést.

function F(x)
    y::Int64 = 10x - 3
    
    return y
end
F (generic function with 1 method)
F(2) # aritmetika probíhá v Int64
17
F(0.5) # výsledek lze přirozeně převést na Int64
2
F(2.22)
InexactError: Int64(19.200000000000003)

Stacktrace:
 [1] Int64
   @ ./float.jl:900 [inlined]
 [2] convert
   @ ./number.jl:7 [inlined]
 [3] F(x::Float64)
   @ Main ./In[96]:2
 [4] top-level scope
   @ In[99]:1

Podobně lze deklarovat i typ návratové hodnoty funkce. Zde platí podobné poznámky jako u deklarace typu lokální proměnné.

function g(x)::Int64
    return 2x
end
g (generic function with 1 method)
g(10)
20
g(4.0)
8

Všimněte si, že:

2 * 4.0
8.0

Konečně, selhávající příklad:

g(3.33)
InexactError: Int64(6.66)

Stacktrace:
 [1] Int64
   @ ./float.jl:900 [inlined]
 [2] convert
   @ ./number.jl:7 [inlined]
 [3] g(x::Float64)
   @ Main ./In[100]:2
 [4] top-level scope
   @ In[104]:1

V další sekci se pomocí tohoto operátoru deklaruje typ atributu složeného typu. Dále na tento operátor narazíme při uvádění typu argumentu funkce (resp. metody), k tomu se budeme věnovat přímo v lekci o funkcích (resp. metodách).


2.4 Složené typy

S tímto typem typu (heh!) přijde běžný uživatel pravděpodobně více do styku. Nejspíše bude vlastní složené typy sám definovat.

Složený typ definujeme pomocí klíčového slova struct a uvedeme, jaké atributy (případně jakých typů) má. Neuvedení typu je ekvivalentní uvedení ::Any:

struct #= Type name =#
    #= field name =#::Type
    # ...
end

Pozor, objekty tohoto typu jsou neměnitelné (immutable). Lze ovšem případně měnit jejich měnitelné (mutable) atributy.

K vytvoření mutable typu stačí použít mutable struct místo struct.

Instanci objektu vytvoříme pomocí konstuktoru, který má stejné jméno jako typ a jako argumenty bere popořadě v definici uvedené atributy. K atributům daného objektu pak přistupujeme pomocí operátoru ..

Následuje jednoduchý příklad ilustrující zmíněné skutečnosti:

struct ImmutableType
    a::Integer
end

mutable struct MutableType
    a::Integer
    b::String
end
typeof(ImmutableType)
DataType
x = ImmutableType(1)
println(x.a)

x.a = 10

println(x.a)
1
setfield!: immutable struct of type ImmutableType cannot be changed

Stacktrace:
 [1] setproperty!(x::ImmutableType, f::Symbol, v::Int64)
   @ Base ./Base.jl:38
 [2] top-level scope
   @ In[108]:4
x = MutableType(1, "Ahoj!")
println(x.a)
println(x.b)

x.a = 10

println(x.a)
1
Ahoj!
10
typeof(x)
MutableType
MutableType(1, 1)
MethodError: Cannot `convert` an object of type Int64 to an object of type String

Closest candidates are:
  convert(::Type{String}, ::String)
   @ Base essentials.jl:298
  convert(::Type{T}, ::T) where T<:AbstractString
   @ Base strings/basic.jl:231
  convert(::Type{T}, ::AbstractString) where T<:AbstractString
   @ Base strings/basic.jl:232
  ...


Stacktrace:
 [1] MutableType(a::Int64, b::Int64)
   @ Main ./In[105]:6
 [2] top-level scope
   @ In[111]:1

2.5 Parametrické typy

V definici abstraktního i složeného typu lze využívat parametry, které mohou být typy, nebo integery či symboly. Toto chování jsme už viděli například u polí, kde matice s Int64 prvky byla typu Matrix{Int64}, což je alias pro Array{Int64, 2}. Tímto způsobem můžeme specifikovat, jaké objekty je pole, resp. matice, schopno pojmout.

Parametrizaci složeného typu uvedeme v složených závorkách za jménem typu. V této definici není hodnota typu T nijak omezena:

struct ParametricType{T}
    a::T
end

Výchozí konstruktor (o nich více později) je metoda stejného jména jako typ. Všimněte si, že parametrický typ nemusíme explicitně uvádět, Julia ho dokáže odvodit z hodnoty argumentu.

ParametricType(10)
ParametricType{Int64}(10)
ParametricType(2.5)
ParametricType{Float64}(2.5)
ParametricType([1 2; 3 4])
ParametricType{Matrix{Int64}}([1 2; 3 4])
ParametricType("String")
ParametricType{String}("String")
ParametricType(ParametricType)
ParametricType{UnionAll}(ParametricType)

Ale pokud potřebujeme, můžeme typový parametr explicitně uvést, a tím požadované chování vynutit.

ParametricType{Float64}(2)
ParametricType{Float64}(2.0)

ParametricType{T} je podtypem ParametricType. Pokud je T podtypem S, pak ParametricType{T} není podtypem ParametricType{S}!

ParametricType{Int64} <: ParametricType
true
Int64 <: Integer
true
ParametricType{Int64} <: ParametricType{Integer}
false

Ve výše uvedené definici typu ParametricType{T} není typ T nijak omezen, může být zcela libolný. To je často nevhodné. Pomocí operátoru <: můžeme zafixovat supertyp typu T:

struct ParametricType2{T <: Number}
    a::T
end

Následující typy a jejich instance poté přicházejí v úvahu:

ParametricType2{Float64}
ParametricType2{Float64}
ParametricType2(10)
ParametricType2{Int64}(10)
ParametricType(1im)
ParametricType{Complex{Int64}}(0 + 1im)

Následující typy a jejich instance už ale nejsou validní, uvedené typy už nejsou podtypy typu Number.

ParametricType2{Array}
TypeError: in ParametricType2, in T, expected T<:Number, got Type{Array}

Stacktrace:
 [1] top-level scope
   @ In[126]:1
ParametricType2("Hello!")
MethodError: no method matching ParametricType2(::String)

Closest candidates are:
  ParametricType2(::T) where T<:Number
   @ Main In[122]:2


Stacktrace:
 [1] top-level scope
   @ In[127]:1
ParametricType2{String}("Hi!")
TypeError: in ParametricType2, in T, expected T<:Number, got Type{String}

Stacktrace:
 [1] top-level scope
   @ In[128]:1

Parametrické typy samozřejmě mohou mít více typových parametrů, stačí je oddělit čárkami. Například:

struct ExampleType{T <: Number, S <: String}
    a::T
    b::S
end
ExampleType(1, "a")
ExampleType{Int64, String}(1, "a")

Typový parametr nemusí nutně být další typ, může to být například i Integer. Typickým příkladem využití této možnosti jsou různé typy polí, například matice (všimněte si dvojky!):

Matrix
Matrix (alias for Array{T, 2} where T)

Jako hodnota parametrického typu může sloužit i symbol. Toho se v Julia využívá třeba pro vyjádření matematických konstant:

typeof(π)
Irrational{:π}
typeof(ℯ)
Irrational{:ℯ}
Irrational{:π}()
π = 3.1415926535897...
Irrational{:ℯ}()
ℯ = 2.7182818284590...

2.6 Sjednocení typů a aliasy

Přirozeně můžeme vytvářet typy reprezentující sjednocení dvou a více typů. Máme-li typy S a T, pak instance Union{T, S} mohou být typu T nebo S. Dále je možné pro komplikovanější typy (například velká sjednocení) vytvářet aliasy pomocí jednoduchého přiřazení.

Ukažme si oba tyto koncepty na jednoduchém příkladě:

const Numeric = Union{Int64, Float64}
Union{Float64, Int64}
Numeric
Union{Float64, Int64}
Float64 <: Numeric
true
Int64 <: Numeric
true
String <: Numeric
false
Numeric(1)
1
typeof(Numeric(1))
Int64
Numeric(1.0)
1.0
typeof(Numeric(1.0))
Float64

2.7 Cvičení / Příklad Q\mathbb{Q}

Jako zcela elementární příklad si ukažme vlastní implementaci typu modelujícího množinu racionálních čísel. K tomuto příkladu se vrátíme i v budoucí lekci.

Zopakujme, že objekt daného typu vytvoříme pomocí konstruktoru. Výchozí konstruktor má stejné jméno jako typ a bere popořadě jako argumenty jednotlivé atributy složeného typu. Často potřebujeme definovat vlastní konstruktor, který třeba na základě zadaných hodnot dopočítá další atributy, nebo provede nějakou jejich úpravu či kontrolu.

K tomu máme dvě možnosti, první jsou vnitřní konstruktory, uvedené v těle struct (případně mutable struct), které nelze později měnit a lze tak pomocí nich například vynutit podmínky, které musí objekty našeho typu splňovat. Všimněte si speciální metody new, na kterou lze pohlížet jako na výchozí konstruktor.

struct MyRational{T <: Integer} <: Number
    num::T
    den::T
    
    function MyRational(num::T, den::T) where { T <: Integer }
        if den == 0
            error("Zero denominator is forbidden by god (or nature)!")
        end
        
        # divide by common factors
        common = gcd(num, den)
        new{T}(div(num, common), div(den, common))
    end
end

Naše první "racionální číslo":

q = MyRational(10, 2)
q
MyRational{Int64}(5, 1)

Nulou nepodělíš!

MyRational(1, 0)
Zero denominator is forbidden by god (or nature)!

Stacktrace:
 [1] error(s::String)
   @ Base ./error.jl:35
 [2] MyRational(num::Int64, den::Int64)
   @ Main ./In[145]:7
 [3] top-level scope
   @ In[147]:1

Dále (i později po definici typu) můžeme definovat vnější konstruktory, což jsou obyčejné metody, které použijí některý z vniřních konstruktorů.

# V podstatě kanonické vnoření celých čísel do racionálních.
MyRational(n::Integer) = MyRational(n, 1)

MyRational(4)
MyRational{Int64}(4, 1)

Na tomto místě znovu zdůrazněme, že objekty takto zavedeného typu nejsou měnitelné (jsou immutable):

println(q.num)
q.num = 10
5
setfield!: immutable struct of type MyRational cannot be changed

Stacktrace:
 [1] setproperty!(x::MyRational{Int64}, f::Symbol, v::Int64)
   @ Base ./Base.jl:38
 [2] top-level scope
   @ In[149]:2

Přirozeně se nabízí otázka, jak zadefinovat algebraické operace mezi těmito racionálními čísly. Tím se přesně budeme zabývat v přístí lekci.


Řešení některých příkladů

Poznámka k příkladu s absolutní hodnotou.

my_abs(x) = if x >= 0
                x
            else x < 0
                -x
            end

# OR

my_abs(x) = x >= 0 ? x : -x

Poznámka k příkladu s řetězovými zlomky.

function my_cf(seq)
    value = seq[end]
    for k = (length(seq)-1):-1:1
        value = seq[k] + 1 / value
    end
    
    return value
end

Reference

Další detaily můžete nalézt zejména v kapitolách Control Flow, Types a Constructors dokumentace Julia.

Upozorňujeme studenty na zajímavou stránku OEIS.