Jdi na navigaci předmětu

04: Funkce a metody, makra a metaprogramování

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 2024/2025 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()

1. Funkce a metody

V předchozí kapitole jsme si ukázali, jak se v Julia pracuje s typy. Asi jste si všimli, že na Julia typy a jejich hierarchii se lze dívat trochu jako na třídy v jiných programovacích jazycích.

Na rozdíl ale třeba od C++, Javy, Ruby, nebo Pythonu, nejsou instance tříd (objekty) vybaveny metodami. Koncept funkcí a metod je v Julia prakticky zcela oddělen od typů.


1.1 Multiple dispatch

V matematice se jeden symbol ++ používá k označení různých binárních operací. Všechny tyto operace mají jisté společné vlastnosti, které umožňují dívat se na danou operaci jako na sčítání. Tento abstraktní koncept sčítání by v Julia odpovídal funkci. Různé konkrétní způsoby sčítání objektů (nejen) stejných typů (čísla, matice,...) pak z pohledu Julia odpovídají metodám.

Jinak řečeno, Julia funkce pod jedním společným jménem sdružuje více metod, které se při volání použijí v závislosti na typech argumentů. Toto paradigma se označuje multiple dispatch (v kontrastu k single dispatch, kde volání je vázáno na konkrétní třídu).

Například zmíněná funkce + má v Julia následující metody (lehce extrémní příklad, ale názorný):

methods(+)

Každá z těchto 207 možností představuje konkrétní implementaci sčítání pro argumenty uvedené v signatuře. Zvídavý uživatel může jedním prostým kliknutím rovnou nahlédnou zdrojový kód.


Pozor na ne/jednoznačnost!

Při volání metody se Julia snaží vždy vybrat tu "nejspecifičtější" vzhledem k typovému systému. Například:

f(x::Integer) = 2 * x
f(x::Number) = 3 * x
f(1) # 1 "je" jak Integer tak Number a Integer <: Number
f(1.0) # 1.0 "je" jen Number
Integer <: Number

Samozřejmě hrozí nebezpečí nejednoznačnosti. Na takovou situaci nás naštěstí Julia upozorní.

f(x::Float64, y) = x + y
f(x, y::Float64) = x * y
f(1.0, 2)
f(1, 2.0)
@which f(1.0, 2)
f(1.0, 2.0)

Funkce f má aktuálně čtyři metody:

methods(f)

1.2 Definice metod: parametrické metody

Podobně jako typy mohly mít parametry, mohou mít parametry i metody. Můžeme tak mít různé varianty metody v závislosti na jejich argumentech.

function same_type(x::T, y::T) where {T}
    println(T)
    return true
end

function same_type(x, y)
    return false
end

Díky anotaci se první metoda použije pouze v případě, že jsou argumenty stejného typu. Pokud nejsou, zavolá se druhá "obecná" metoda.

same_type(1, 2)
same_type(1.0, 2.0)
same_type(1, 2.0)
same_type(Int64, Int32)
methods(same_type)

Dále v anotaci i můžeme omezit typ T samotný, nebo použít více typů:

function parametric_f(x::T, y::T, z::S) where { T <: Number, S <: AbstractString }
    return nothing
end

Tato metoda vyžaduje, aby první dva argumenty byly stejného "číselného" typu T a aby poslední argument byl podtypem typu AbstractString.


1.3 Příklady a cvičení

V předchozí lekci jsme definovali vlastní typ pro racionální čísla. Pojďme nyní zadefinovat sčítání a další operace i pro ně.

"""

    MyRational{T <: Integer} <: Number

_My_ home made rational number.
"""
struct MyRational{T <: Integer} <: Number
    num::T
    den::T
    
    function MyRational(num::T, den::T) where { T <: Integer }
        den == 0 && error("Zero denominator is forbidden by god!")
        if den < 0
            num *= -1
            den *= -1
        end
        
        # divide by common factors
        common = gcd(num, den)
        new{T}(div(num, common), div(den, common))
    end
end

Použili jsme ještě jednu novinku a tou je docstring před definicí typu. Tímto způsobem ho můžete použít i před definicí metod, typů, maker. Později k němu můžete přistupovat pomocí integrované nápovědy, nebo ho použít při generování dokumentace (touto problematikou se budeme zabývat později během semestru). Všimněte si, že v docstringu můžeme používat Markdown.

?MyRational

Pokud chceme definovat novou metodu funkce, která je definována externě (v jiném modulu, o nich později), musíme ji explicitně importovat nebo musíme uvést celé její jméno. První možnost:

import Base.+

+(p::MyRational{T}, q::MyRational{T}) where { T <: Integer } =
    MyRational(p.num * q.den + q.num * p.den, p.den * q.den)

Pojďme ji hned s nadšením otestovat.

p = MyRational(1, 2)
q = MyRational(2, 3)

p + q

Toto je sice správný výsledek, ale esteticky není uspokojivý. Více oku lahodící vypisování našich racionálních čísel můžeme zajistit pomocí přidání metody k funkci show (viz Custom Pretty-printing):

# bez explicitního `import Base.show`

Base.show(io::IO, q::MyRational{T}) where { T <: Integer } =
    print(io, q.num, "/", q.den)

Potom dostaneme:

p + q
MyRational(2, -3)

Pro úplnost můžeme jednoduše definovat násobení (binární operátor *) a opačný prvek (unární operátor -) a odčítání (binární operátor -). Všimněte si, jak v definici odčítání -- pěkně v souladu s matematickou definicí -- použijeme pouze sčítání a definici opačného prvku.

import Base.-, Base.*

*(p::MyRational{T}, q::MyRational{T}) where { T <: Integer } = MyRational(p.num * q.num, p.den * q.den)
-(p::MyRational{T}) where { T <: Integer}                    = MyRational(-one(T), one(T)) * p
# nebo: = MyRational(-p.num, p.den)
-(p::MyRational{T}, q::MyRational{T}) where { T <: Integer } = p + (-q)

Otestujme správnou funkčnost algebraických operací mezi našimi racionálními čísly:

println("p = ", p, ", q = ", q)

p * q
-p
p - q

Cvičení: Inverze a mocnění v Q\mathbb{Q}

Naše racionální čísla bychom ještě chtěli umocňovat, i na záporné exponenty (speciáně na 1-1, tedy invertovat). Dokážete vyřešit jak na to?

p ^ 3
@which p ^ 3
p ^ (-1)
MyRational{T}(n::T) where { T <: Integer } = MyRational(n, one(T))
p ^ (-1)
Base.:/(p::MyRational{T}, q::MyRational{T}) where { T <: Integer } = MyRational(p.num * q.den, p.den * q.num)
p ^ (-1)
Base.inv(q::MyRational{T}) where { T <: Integer } = MyRational(q.den, q.num)
inv(MyRational(3, 5))
-inv(MyRational(-3, 5))
p ^ (-2)
p ^ (-1)
inv(MyRational(0, 1))

Racionální čísla jsou v Julia k dispozici i bez našeho typu jako typ Rational{T}. Jako konstruktor můžeme využít i dvojité lomítko:

2 // 3
4 // 2

Poznámka: Julia Rational umí vytvořit exaktní reprezentaci strojového čísla (které z definice je vždy racionální číslo):

Rational(0.3)
Float64(Rational(0.3) - 3 // 10)
Rational(0.5)

Cvičení: Komplexní čísla

Vytvořte vlastní typ MyComplex{T} modelující komplexní čísla a vybavte ho standardními metodami sčítání +, odčítání -, násobení *, dělení / a inverze inv.

struct MyComplex{T <: Real} <: Number
    re::T
    im::T
end

Base.show(io::IO, z::MyComplex{T}) where { T <: Real } =
    if z.im >= 0
        print(io, z.re, " + ", z.im, "ı")
    else
        print(io, z.re, " - ", -z.im, "ı")
    end
z = MyComplex(1, 2)
z = MyComplex(-1, 2)
z = MyComplex(1, -2)
import Base.*, Base.+, Base.-, Base.inv, Base./

*(u::MyComplex{S}, v::MyComplex{T}) where {S, T} = nothing # FIX ME

+(u::MyComplex{S}, v::MyComplex{T}) where {S, T} = nothing

-(u::MyComplex{T}) where {T} = nothing

-(u::MyComplex{S}, v::MyComplex{T}) where {S, T} = nothing

function inv(u::MyComplex{T}) where {T}
    # ...
    return nothing
end

/(u::MyComplex{S}, v::MyComplex{T}) where {S, T} = nothing
w = MyComplex(3, 4)
z
z + w
z * w
inv(z) * MyComplex(1.0, -2.0)
MyComplex(1.0, -2.0) / MyComplex(1.0, -2.0)
z / z

Cvičení: Modulární multiplikativní grupa

Pro prvočíslo pNp \in \mathbb{N} tvoří množina {0,1,,p1}\{0,1,\ldots,p-1\} s operací násobení modulo pp grupu (tzv. modulární multiplikativní grupa; značí se Zp×\mathbb{Z}_p^{\times}, nebo GF(p)GF(p)^*). O pp pak mluvíme jako o modulu.

Vytvořte v Julia typ, který bude parametrizovaný typem integeru a modulem pp, a bude modelovat výše uvedenou strukturu. Vhodně zadefinujte operaci *, inv a zajistěte pěkný výpis objektů tohoto typu. Definujte metodu modulus vracící modul. Doplňte následující šablonu:

import Base.*, Base.inv, Base.show
using Primes

"""
Modular Multiplicative Group (MMG).
"""
struct MMG{T <: Integer, P} <: Number
    value::T
    
    function MMG(value::T, modulus::T) where { T <: Integer }
        isprime(modulus) || error("modulus ($modulus) has to be prime!")

        my_value = mod(value, modulus)
        iszero(my_value) && error("0 is not acceptable!")
        
        new{T, modulus}(my_value)
    end
end

modulus(u::MMG{T, P}) where { T <: Integer, P } = P

function *(a::MMG{T, P}, b::MMG{T, P}) where { T <: Integer, P }
    MMG(mod(a.value * b.value, P), P)
end

function inv(a::MMG{T, P}) where { T <: Integer, P }
    _, u, _ = gcdx(a.value, P)
    MMG(mod(u, P), P)
end

show(io::IO, a::MMG) = print(io, "|", a.value, "|_$(modulus(a))")

Následuje několik ukázek použití.

a = MMG(3, 11); b = MMG(5, 11); c = MMG(5, 13)
typeof(a)
modulus(a)
a * b
# toto by šlo ještě vylepšit..., výchozí chování.
b * c
inv(a)
a * inv(a)
c * inv(c)
inv(c)
b ^ 123
MMG(0, 3)

1.4 Definice metod: poziční argumenty

Už víme, jak v definici metody anotovat typy argumentů a návratové hodnoty. Pro připomenutí:

F(x::Int64)::Float64 = x / (abs(x) + 1)
F(1)
F(0)

Ovšem:

F("0")

Asi je jasné, jak definovat metodu s více pozičními argumenty. V této části lekce si ukážeme, jak argumentům přiřazovat výchozí hodnoty, jak definovat metody s proměnlivým počtem argumentů, či jak definovat metody s argumenty ve tvaru keyword=value, u nichž nezávisí na pořadí.

U všech níže uvedených způsobů předání argumentů lze uvést i typovou anotaci.


Nepovinné argumenty / výchozí hodnoty

V definici funkce lze pomocí operátoru = přiřadit "posledním" (od zadu, jinak by zápis nebyl jednoznačně interpretovatelný) argumentům výchozí hodnoty, které poté při volání není potřeba vypisovat.

Například:

h(x, y=2) = x + y
h(1)
h(1, 1)

I v tomto zápisu lze případně anotovat typ proměnné y pomocí ::.


Operátor ..., "varargs"

Metody mohou mít přirozeně více argumentů. Následující funkce má právě tři poziční argumenty:

g(x, y, z) = x + y + z

Argumenty jí můžeme předat buď explicitně jeden po jednom, nebo je předat v tuple a použít ... operátor.

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

g(t...)

Ale pozor, následující volání selže. g nemá definovánu metodu, která by si poradila s tuplem na vstupu.

g(t)

Operátor ... můžeme použít i v definici funkce samotné. Umožňuje nám pak vytvořit funkci mající měnící se počet argumentů (variable number of arguments, "varargs").

Následující funkce má jeden nebo více pozičních argumentů:

H(x, args...) = println(args)
H(1)
()
(,) # <- blbost
(1)
(1,)
H(1, 2)
typeof((2))
typeof((2,))
H(1, 2, 3)

Zamyslete se nad následujícími třemi ukázkami.

H(1, ("a", "b", "c"))
H(1, ("a", "b", "c")...)
H(1, "a", "b", "c")

Typický reprezentant:

println(1, 2, 3, "Ahoj!")

Argumenty tvaru keyword=value (keyword arguments, "kwargs")

Jakmile počet argumentů přeroste jistou hranici (pro mě někde kolem 3), tak může být vhodné místo pozičního předávání argumentů použít zadávání pomocí klíče a hodnoty u kterých poté nezávisí na pořadí. Těmto argumentům také lze přiřazovat výchozí hodnoty. Takovéto argumenty od těch pozičních oddělíme středníkem ; v signatuře funkce:

G(x, y; operator=+) = operator(x, y)
G(1, 2)
G(2, 3, operator=*)
G(2, 3, *)

Klíči nemusí být přiřazena výchozí hodnota, ale tuto možnost asi často nepoužijeme.

Julia podporuje i neomezený počet těchto argumentů. Uvažme lehce esoterickou funkci s následující signaturou:

J(args...; kwargs...) = println(kwargs)
J(1, 2, a=1, b="x")

V proměnné kwargs je poté "slovník", kde pod symbolem odpovídajícím klíči uložena předávána hodnota.

function kwargs_example(; kwargs...)
    println(kwargs[:a])
end
kwargs_example(a=1)
kwargs_example(b=10)
function ♋(x)
    return x + 1
end
♋(x=1)

1.5 Návratová hodnota

V dosavadních příkladech metod jsme vždy vraceli hodnotu naposledy vyhodnoceného výrazu, často byl dokonce jenom jeden.

V mnoha situacích ale chceme vrátit hodnotu i z jiného místa, než konce těla. K tomu nepřekvapivě slouží klíčové slovo return.

function func(x::Integer)
    # some funny stuff
    for j = 1:10
        j >= x && return j
    end
    
    return 42
end
    
func(-10)
func(3)
func(15)

Pomocí tuplů můžeme vracet i "více" hodnot.

function func_tuple(x)
    return (x, x^2)
end

Pak je vhodné výsledek rovnou přiřadit do dvou proměnných:

a, b = func_tuple(10)
a
b

Nebo můžeme samozřejmě přijmout celý tuple.

c = func_tuple(10)

c

2. Makra a metaprogramování

Pod metaprogramováním máme na mysli schopnost programu generovat, či modifikovat, svůj zdrojový kód. Makra v Julia jsou inspirována Lispem. Nejde jen o pouhé textové transformace jako v případě maker v C/C++. V Julia má programátor přímý přístup k vnitřní reprezentaci zdrojového kódu.

Ukažme si, jak Julia zpracovává zdrojový kód. Na počátku máme řetězec. Například:

source = "1 + 2"

Co s tímto zdrojovým kódem udělá Julia parser? Vytvoří objekt typu Expr:

ex = Meta.parse(source)
typeof(ex)

Objekt typu Expr obsahuje v zásadě dvě informace:

ex.head # symbol udávající význam
ex.args # argumenty

Přehledně lze tyto informace vypsat pomocí metody dump:

dump(ex)

Interpretace tohoto příkladu je nasnadě. Reprezentuje volání (call) metody + s Int64 argumenty 1 a 2.

Tyto výrazy lze přirozeně vytvářet i přímo pomocí konstruktoru Expr. Za chvilku si ale ukážeme další způsoby, jak výrazy vytvářet, tento by nebyl příliš efektivní.

myex = Expr(:call, :+, 1, 2)
ex == myex

Výrazy dohromady vytváří stromovou strukturu (AST -- abstract syntax tree), lze je do sebe zanořovat:

dump(Meta.parse("(1 + 2) / 3"))

A konečně, výrazy můžeme finálně vyhodnotit pomocí metody eval.

eval(ex)
eval(myex)

Dalším způsobem vytváření objektů typu Expr je quoting (kód v uvozovkách :-)). Toho lze docílit dvěma způsoby. Pro menší výrazy se hodí zápis pomocí :, za kterou v závorce uvedeme Julia výraz:

myex2 = :((1 + 2) / 3)
dump(:(f(1+1)))

Proměnné se ve výrazu uloží pod symboly, ne pod svými hodnotami!

x = 1
dump(:(x + 1))

Pokud bychom chtěli použít skutečně hodnotu proměnné, pak k tomu můžeme použít interpolační symbol $:

x = 1
dump(:($x + 1))

Pro větší výrazy můžeme použít quote blok.

ex = quote
    for j = 1:10
        println(j)
    end
end
typeof(ex)
dump(ex)

2.1 Makra

Makra akceptují jako argumenty výrazy (Expr), literály nebo symboly a vrací výraz (Expr). Makra definujeme pomocí klíčového slova macro. Ve zdrojovém kódu poté makra používáme s prefixem @. Makra se aplikují při parsování zdrojového kódu, před jeho odesláním kompilátoru.

Ukažme si nejprve, jak je v AST reprezentováno voláni funkce.

f(a, b) = a + b
dump(Meta.parse("f(x, y)"))

Následující makro vypíše argumenty volání funkce.

macro show_args(expr::Expr)
    println("Arguments: ", expr.args[2:end])
end

Například:

@show_args f(1, 2)
x = 42; y = 11

@show_args f(x, y)
dump(:(f(x, y)))
dump(:(f(1, 2)))

To není přesně to co bychom asi chtěli (i když...). Pokud chceme vypsat i hodnoty proměnných, musíme trochu zapracovat....

macro show_args(expr::Expr)
    println("Arguments: ")
    
    for arg in expr.args[2:end]
        if typeof(arg) == Symbol
            println(arg, " = ", eval(:($arg)))
        else
            println(arg)
        end
    end
end
@show_args f(1, 2)
@show_args f(x, y)
@show_args f(1, x)

Občas pomocí maker chceme modifikovat prostředí, v kterém se volají. K tomu můžeme použít metodu esc. V prvním případě je x pouze lokální proměnná a nemá vztah ke "globální" proměnné x.

macro zerox()
    return :(x = 0)
end

macro zerox2()
    return esc(:(x = 0))
end
x = 42
@zerox
x
@zerox2
x

Psaní maker nemusí být úplně jednoduché. Více o této problematice se můžet dozvědět v dokumentaci.

Ve zbytku této části si ukážeme některá užitečná makra a zkusíme pár vlastních maker vytvořit. Výčet není zdaleka vyčerpávající a jde spíše o ochutnávku. Některým z těchto partií se ještě budeme věnovat později během semestru.


2.2 @which

Jak je již bylo zmíněno, pod jedním symbolem "funkce" se může skrývat mnoho a mnoho metod. Občas nemusí být úplně jasné, která z nich se vlastně volá. Makro @which nám umožňuje dohledat o kterou metodu se v konkrétním případě jedná.

@which 2^3
@which 2.0^3.0
g1(x::Int64) = x + 1
g1(x::Float64) = x - 1
@which g1(1.0)
@which g1(1)

2.3 @debug, @info, @warn, @error

Tato makra slouží k informování uživatele, lze i kontrolvat na jaké úrovni se logování provádí (k tomu slouží modul Logging, kterému se budeme věnovat při probírání standardní knihovny).

@debug "???"
@info "Hi!"
@warn "Beware!"
@error "Unable to compute!"

2.4 @time, @timed a @timev

Pomocí těchto maker můžeme měřit dobu běhu programu. Jde o jednodušší variantu makra @benchmark z balíčku BenchmarkTools.jl. Tato tři makra se liší pouze způsobem výstupu.

a = rand(1_000);
a[1:10]
@time sort(a)

d jako dictionary:

@timed sort(a)

v jako verbose, čili podrobnější:

@timev sort(a)

2.5 @inbounds a @simd

Makro @inbound "vypne" kontrolování používání správných indexů polí. Makro @simd umožňuje kompilátoru větši možnosti optimalizace for cyklu, viz dokumentaci.

a = [1,2,3,4]
a[2]
a[5]
using BenchmarkTools
function f1(a::Vector{Float64}, n)
    val = zero(eltype(a))
    for j = 1:n
        val += a[j]
    end
    return val
end

function f2(a::Vector{Float64}, n)
    val = zero(eltype(a))
    @simd for j = 1:n
        @inbounds val += a[j]
    end
    return val
end

function f3(a::Vector{Float64}, n)
    val = zero(eltype(a))
     @inbounds for j = 1:n
        val += a[j]
    end
    return val
end
a = rand(10^8);
@benchmark f1($a, 10^8)
@benchmark f2($a, 10^8)
@benchmark f1($a, 10^6)
@benchmark f2($a, 10^6)
@benchmark f3($a, 10^6)

2.6 @test a @testset

Tato makra z modulu Test nám umožňují přehledně testovat náš kód. Pomocí @testset můžeme sdružit více testů dohromady a pojmenovat je (bere řetězec a blok). Druhé makro testuje, jestli výraz je pravdivý nebo nepravdivý. Například:

using Test

@testset "isodd method" begin
    @test isodd(3) == true
    @test isodd(2) == false
end

Dále máme k dispozici @test_throws pro testování vyvolání výjimky.


2.6 @code_native

a.k.a "We need to go deeper..."

function g(x::Int64)
    return x + 1
end
@code_native g(2)
using ProgressMeter
@showprogress for i in 1:50
    sleep(0.1)
end
@showprogress dt=1 desc="Computing..." for i in 1:50
    sleep(0.1)
end

Cvičení

Vytvořte makro @dotimes, které zadaný výraz provede několikrát za sebou. Přesněji @dotimes n body provede body přesně nkrát.

macro dotimes(n, body)
  quote
    for i = 1:$n
      $body
    end
  end
end
@dotimes 2 println("Hi!")
body = 1
n = 3

@dotimes 2 println("Hi!")
body
n

Cvičení: generování kódu

Definujme vlastní "číselný" typ:

struct MyNumber <: Number
    value::Float64
end

Vygenerujte kód, který zadefinuje funkce sin, cos, log, exp pro tento typ.

for func in [:sin, :cos, :log, :exp]
    eval(:(Base.$func(x::MyNumber) = MyNumber($func(x.value))))
end
sin(MyNumber(0.3))
cos(MyNumber(0.3))
exp(MyNumber(0.3))
log(MyNumber(0.3))

Cvičení: Sledování průběhu for cyklu

Vyvořte makro, které bude zobrazovat jednoduchý průběh vyhodnocování for cyklu. Tj.

@progress for j = 1:n
    # ...
end

bude efektivně

for j = 1:n
    # ...
    println(j)
end

Případně se můžete pokusit výpis i více zkrášlit.

Zde jde samozřejmě o cvičení práce s makry. Julia jinak má poměrně excelentní balíček ProgressMeter.jl poskytující přesně tuto funkcionalitu.

macro progress(expr)
    # ....
    return expr
end
@progress for j=1:5
    sleep(1)
end

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

Inverze.

import Base.inv

inv(p::MyRational{T}) where { T <: Integer } = MyRational(p.den, p.num)
inv(p)
p^(-3)

Modulární multiplikativní grupa.

import Base.*, Base.inv, Base.show
using Primes

struct MMG{T <: Integer, P} <: Number
    value::T
    
    function MMG(value::T, modulus::T) where { T <: Integer }
        isprime(modulus) || error("Modulus has to be prime!")
        
        new{T, modulus}(mod(value, modulus))
    end
end

modulus(u::MMG{T, P}) where { T <: Integer, P } = P

function *(a::MMG{T, P}, b::MMG{T, P}) where { T <: Integer, P }
    return MMG(mod(a.value * b.value, P), P)
end

function inv(a::MMG{T, P}) where { T <: Integer, P }
    # d = u * a + v * P
    d, u, v = gcdx(a.value, P)
    
    return MMG(mod(u, P), P)
end

show(io::IO, u::MMG) = print(io, u.value)

Makro dotimes.

macro dotimes(n, body)
  quote
    for i = 1:$n
      $body
    end
  end
end

Průběh for cyklu.

macro progress(expr)
    push!(expr.args[2].args, :(println(j)))
    return expr
end

Reference

V oficiální dokumentaci této problematice odpovídají sekce Methods a Macros.