Julia (ohjelmointikieli)

Julia on ohjelmointikieli, jota on kehitetty erityisesti tieteelliseen laskentaan, tavoitteenaan yhdistää hitaiden dynaamisten kielten helppokäyttöisyys ja perinteisten staattisten kielten suorituskyky. 

Julian keskeisin piirre ja ohjelmointitapa on multiple dispatch eli funktion koko tyyppijälki määrittää, mitä toteutusta eli metodia tietystä funktiosta kutsutaan. Monissa muissa kielissä metodit kuuluvat yhdelle objektille, eli vain ensimmäisen parametrin (esim. self) tyypillä on merkitystä (engl. single dispatch).

Esimerkit

Fibonacci 
function fibonacci(n)
  f = [0, 1]
  # Julia-listojen indeksit alkavat ykkösestä.
  n in [0, 1] && return f[n + 1]
  # Ajon fibonacci(n = 2) tulos tulee listan indeksiin 3, jne. 
  for i = 3:(n + 1)
    # Huutomerkkiin päättyvät funktiot muokkaavat parametrejaan.
    append!(f, f[i - 1] + f[i - 2])
  end
  # end-sana viittaa listan vimeiseen indeksiin.
  return f[end]
end

Puolitushaku 
# Get sorted and query arrays from a file.
file = open("data.txt") 
lines = readlines(file)
# Dot-syntax iterates collections automatically.
sorted = parse.(Int, split(lines[3]))
query = parse.(Int, split(lines[4]))
close(file)

"Binary search for integers."
function search_index(query::Integer, sorted::Vector{Int})
        left_i = 1
        right_i = length(sorted)
        while left_i <= right_i
                middle_i = floor(Int64, (left_i + right_i) / 2)
                if sorted[middle_i] < query 
                        left_i = middle_i + 1
                elseif sorted[middle_i] > query 
                        right_i = middle_i - 1
                else
                        return middle_i
                end
        end
        return -1
end

# Ref wraps "sorted" so that it isn't iterated over. 
indeces = search_index.(query, Ref(sorted))
print.(indeces, " ")
println()
# Output is the indeces like "4 -1 2 1 5 2"

Tilastollinen analyysi 
# Tuo Pkg-moduuli näkymään.
import Pkg

# Luo projekti nykyiseen työkansioon.
# Tehdään usein interaktiivisesti Pkg-REPL-tilassa.
Pkg.activate(".")

# Lisää tarvittavat paketit projektiin.
Pkg.add("Chain")
Pkg.add("CSV")
Pkg.add("DataFrames")
Pkg.add("Gadfly")
Pkg.add("PrettyTables")
Pkg.add("Cairo")
Pkg.add("Fontconfig")

# Lisää moduulit näkymään. 
import Cairo
import Fontconfig

# Lisää moduulit ja jaetut (export) nimet näkymään.
using Chain
using CSV
using DataFrames
using Statistics
using PrettyTables
using Gadfly

# Luo DataFrame arvo.
species_data = DataFrame(
    price = ["High", "Moderate", "Low"], 
    species = ["setosa", "virginica", "versicolor"]
)

# Käytä @chain-makroa vähentämään toistoa.
# Makro ottaa ketjun ensimmäisen argumentin ja 
# anonyymin funktion (begin), jossa makro vie tuloksen 
# automaattisesti seuraavaan funktioon.
data = @chain "iris.csv" begin
    CSV.File()
    DataFrame()
    # DataFrame-paketissa voidaan käyttää Symbol-syntaksia
    # viittaamaan taulukon muuttujiin.
    stack([:petal_width, :petal_length])
    unstack(:variable, :value, allowduplicates = true)
    sort(:species)
    unique([:petal_width, :petal_length])
    select([:petal_width, :petal_length, :species])
    disallowmissing(:species)
    # => operaattori luo Pair-tyypin objektin, joka tässä edustaa
    # muunnoksia kuten ":input => funktio => :output".
    transform(
        [:petal_width, :petal_length] => ByRow(*) => :petal_area
    )
    leftjoin(species_data, on = :species)
end

petal_means = @chain data begin
    groupby(:species)
    combine(:petal_area => mean => :mean_petal_area)
end

pretty_table(petal_means)

petal_correlations = @chain data begin
    groupby(:species)
    transform([:petal_width, :petal_length] => cor => :pearson_petal)
end

pretty_table(petal_correlations)

# Gadfly-paketti on Grammar of Graphics -toteutus visualisointiin.
petal_plot = plot(
    data, 
    Geom.point,
    x = :petal_length, 
    y = :petal_width,
    color = :species,
    Guide.xlabel("Petal Length"),
    Guide.ylabel("Petal Width"),
    Guide.colorkey(title = "Species"),
    # -> luo nimettömän funktion. Tässä se palauttaa aina "red".
    Theme(
        discrete_highlight_color = x -> "red", 
        default_color = "white"
    )
)

image_file = PNG("iris-petal-plot.png", 10cm, 10cm)
draw(image_file, petal_plot)

Käyttöaiheet 

Lineaarialgebra on yksi numeerisen laskennan keskeisistä alueista, joten tähän löytyy hyvä tuki Julian perus- ja standardikirjastosta (using LinearAlgebra):
 AbstractArray ja tämän alatyypit mahdollistavat moniulotteiset taulukot suoraan Base-paketista
 Monipuolinen vektori- [1, 2, 3] ja matriisisyntaksi [1 2 3; 4 5 6] 
 Matriisioperaattorit +, -, *, /, ⋅, × 
 Jälki tr(M), determinantti det(M) ja kääntäminen inv(M)
 Eigen-arvot eigvals(M), eigen-vektorit eigvecs(M) ja faktorointi factorize(M)
 Matriisityypit kuten Symmetric, Hermitian ja Tridiagonal
 Faktorointityypit kuten BunchKaufman, LU ja QR
 Lyhennelmät [x*y for x=1:5, y=5:10], jotka tekevät taulukon
 Generaattorit (x^5 for x=1:10), jotka voidaan iteroida

Käyttö 

Julia-komentoriviohjelman voi ladata kielen virallisilta web-sivuilta valmiina ajotiedostona ohjeiden mukaan. julia --help näyttää saatavilla olevat asetukset ja julia käynnistää REPL-konsolin. Komentoriviä voidaan käyttää poistumatta REPL-tilasta ;-komennolla ja paketinhallintatilaan päästään ]-komennolla. ]? näyttää kaikki Pkg-pakettienhallinnan REPL-tilan komennot, jotka vastaavat Pkg-paketin funktioita. REPL-ohjelmasta poistutaan <CTRL>-D. Julia-kääntäjää käytetään yhdelle lähdetiedostolle yksinkertaisesti julia hei-wikipedia.jl. (Tämä Julia-kääntäjä toimii ns. JAOT-periaatteella (engl. just-ahead-of-time) eli Julia-koodi käännetään tyypitetyn ja LLVM-välikoodin kautta konekielelle, mutta tämä tapahtuu ajon aikana eikä erillisessä käänösvaiheessa.)

Julia REPL mahdollistaa interaktiivisemman ohjelmointitavan, missä kehitettävät ohjelmat ovat omassa moduulissaan ja tiedostossaan, testit ovat omassa moduulissaan ja tiedostossaan, testit ajetaan Julia-konsolissa tyylillä include("testit.jl") ja tämän lisäksi tehdään interaktiivista testaamista konsolissa. Kun interaktiivisesti löydetään jotain uutta testaamisen arvoista, tämä voidaan lisätä testimoduuliin. Muuten kehityksen aikana tehdään moduuleihin muutoksia palautteen mukaan ja toistetaan kehityssilmukkaa. Revise-paketti mahdollistaa päivitettyjen ohjelmien testaamisen käynnistämättä Juliaa uudelleen.

Pluto.jl on Julialla Julialle kehitetty, Jupyter-työkirjojen kaltainen web-selaimeen ja ajettaviin soluihin perustuva kehitysympäristö, joka mahdollistaa interaktiivisen kirjallisen ohjelmoinnin (engl. literate programming). Työkirjat ovat aina toistettavia, sillä Pluto automatisoi pakettienhallinnan import- ja using-lauseiden perusteella.

Rakenne

Modularisointi 

Julian Pkg-paketti mahdollistaa vakioitujen ympäristöjen luomisen, pääosin activate ja add/rm funktioidensa avulla. Tietty pakettiversio voidaan vaatia syntaksilla Pkg.add("Nimi@1.3") (semanttinen versiointi) tai Pkg.add("Nimi#master") (Git). Myös suora URL toimii, jos kyseinen paketti ei ole käytetyssä rekisterissä. Nämä komennot muokkaavat kahta projektikansion juuressa olevaa tiedostoa: Project.toml (metadata) ja Manifest.toml (riippuvuudet). Pkg.instantiate asentaa halutun Julia-ympäristön uudelleen näiden tiedostojen pohjalta. Julia-koodin lisäksi muiden riippuvuuksien käsittelemiseksi voidaan käyttää Artifacts.toml-tiedostoa, jonka avulla kunkin riippuvuuden paikallinen tiedostopolku saadaan käyttöön projektin Julia-koodissa syntaksilla polku = artifact"nimi" (engl. non-standard string literal).

Kun ympäristö koostuu useista paketeista, niin paketti muodostuu yleensä useiden moduulien hierarkiasta. Moduulit module Nimet ... end luovat uuden globaalin nimiavaruuden, johon tuodaan nimiä import ja using lauseilla ja lähdetiedostoja include()-kutsulla ja josta paljastetaan nimiä export-lauseilla (vain using ottaa export-lauseet huomioon). Paikalliseen moduuliin viittaamiseksi tarvitaan pistesyntaksia using .Nimi, koska pisteetön syntaksi viittaa Julia-paketteihin.

Juliassa on moduulin luoma globaali näkymä ja tämän sisällä ns. heikkoja ja vahvoja paikallisia näkymiä. if..else ja begin lauseet eivät luo ollenkaan uutta näkymää, kun taas struct, for, while ja try lauseet luovat paikallisen näkymän, jossa globaalien nimien uudelleenkäyttö on kielletty (heikko näkymä). Funktiot ja makrot luovat vahvan paikallisen näkymän eli ulkonäkymien nimiin sijoittaminen on sallittua eikä muokkaa niitä. Kullakin paikallisella näkymällä on kuitenkin lukuoikeus sen ulkonäkymissä määriteltyihin muuttujiin. Esim. funktiot perivät arvot määrittelympäristöstään, eli esim. tietyn moduulin funktiot voivat lukea samoja globaaleja muuttujia moduulistaan ilman erityisiä avainsanoja.

Tyypit 

Julian tyyppijärjestelmä on dynaaminen, nominatiivinen, parametrinen ja yhtenäinen. Kaikilla rakenteilla on jokin tyyppi ajon aikana. 

Kielen mukana tulevia tietotyyppejä (typeof()) ovat muun muassa seuraavat:
 AbstractString, AbstractChar ja näiden Unicode-UTF-8-alityypit String ja Char. Substring{String} edustaa tekstiviipaletta. Unicode UTF-8 sisältää joitakin monitavuisia merkkejä, joten turvalliseen indeksointiin voidaan käyttää nextind()-, prevind()- tai eachindex()-funktioita. 
 Number on kaikkien numeroiden (Int64, Real) ylätyyppi.
 Missing ja tämän ainoa arvo missing. Numeerisissa ohjelmissa missing tekee tuloksesta epävarman, joten odotettu tulos on yleensä missing. Tämä ei pidä välttämättä paikkaansa muissa tilanteissa, jolloin voidaan käyttää esim. Missings.passmissing tai Base.skipmissing funktioita. Puuttuvia sisältävälle kokoelmalle voidaan kutsua myös collect().
 Kaikki funktiot ovat omaa tyyppiänsä ja Function-tyypin alatyyppejä. Funktioita voidaan syöttää funktioihin, käsitellä funktioissa ja palauttaa funktioista muiden arvotyyppien tapaan.

Uusi yhdistelmätyyppi luodaan sanoilla struct Nimi ... end. Instansseja voidaan muokata vain, kun on käytetty mutable struct Nimi ... end, ellei tyypin alkiota olla määritelty sanalla const. Tyyppejä voidaan yhdistää myös tyylillä MaybeInt = Union{Int, Nothing}. Tyyppiparametreja käytetään syntaksilla struct Tyyppi{T} ... end, mikä määrittelee kerralla joukon tyyppejä. Parametrina olevien tyyppien joukko voidaan rajoittaa alatyypeihin tyylillä Tyyppi{T<:YlinTyyppi}. Tyyppi voidaan myös parametrisoida vain osittain syntaksilla Array{T,1} where T. Abstrakteja tyyppejä abstract type Tyyppi <: Supertyyppi end taas ei voida käyttää suoraan vaan ne täytyy ensin konkretisoida. Tämän jälkeen abstraktia tyyppiä voidaan käyttää viittaamaan kaikkiin tämän alatyyppeihin. (Juliassa ei ole olioperusteisemmille kielille tyypillistä perimismekanismia.)

Luodut tyyppiobjektit toimivat itse alustajinaan struct Tyyppi ... end; t = Tyyppi(...). Tämä alustaja on tavallinen funktio, joten sille voidaan määritellä useita metodeja Tyyppi(...) = .... Tyypin alustajaa voidaan käyttää myös tyypin omassa määritelmässä erityisen new()-funktion kautta. Alustettujen arvojen tyyppiä voidaan selvissä tapauksissa muuttaa convert(Tyyppi, arvo)- tai promote(arvo, arvo)-funktiolla. (Numeroita edustaville String-arvoille ("2.54") löytyy oma parse()-funktionsa.) 

Tietylle tyypille määritellään metodeja Juliassa tavallisten funktioiden tapaan function f(x::Tyyppi) ... end. Metodi ei kuitenkaan kuulu ainoastaan kyseiselle tyypille vaan kutsuttava metodi valitaan koko funktion tyyppijäljen perusteella (engl. multiple dispatch). Funktionkin tyyppijälki voidaan parametrisoida tyylillä funktio(x::T, y::T, z::T) where {T} ... end (eli tämä metodi valitaan, kun kolmen argumentin tyypit ovat samoja). Näin myös objekteista itsestään voidaan tehdä kutsuttavia (ns. funktoreita) antamalla niiden tyypille metodi tyylillä function (x::Tyyppi)() ... end. Olemassaolevien rajapintojen toteuttaminen tapahtuu toteuttamalla tarvittavien funktioiden metodit – uudesta tyypistä voidaan tehdä muun muassa iteroitava, indeksoitava ja listamainen määrittelemällä sille metodit muutamista erityisistä funktioista kuten iterate, getindex ja size.

Funktiot 

Funktiot määritellään function nimi(x) ... end tai yhdellä rivillä nimi(x) = .... Nimettömille funktioille voidaan käyttää syntaksia (x, y) -> ... tai function (x) ... end tai x -> begin ... end. funktio(y) do x ... end tekee anonyymin funktion do-lauseen avulla ja syöttää sen edellä olevan funktion ensimmäiseksi argumentiksi. Funktion voi ns. vektorisoida eli kutsua jonkin kokoelman jokaiselle alkiolle käyttämällä pistesyntaksia kuten x .= sin.(y) (tai sama pistemakrolla @. x = sin(y), kun kaikki operaatiot vektorisoidaan). Funktioiden yhdistäminen on mahdollista muun muassa putkioperaattorilla x = 1:3 .|> sum |> sqrt.

Funktioiden parametrit ovat Juliassa uusia muuttujia, mutta ne vain viittaavat annettuun arvoon eli kopioita ei synny. Funktiot voidaan tyypittää tyylillä function funktio(x::Tyyppi=0, args...; y::Tyyppi="oletus", kwargs...)::Tyyppi end, mutta yleensä Julian tyypinpäättelyn vuoksi korkeintaan parametrien tyyppien merkitseminen on tarpeen. Funktio voi hyväksyä vaihtelevan määrän parametreja tyylillä funktio(x,y,z...), missä z on Tuple funktion sisällä tai mikä tahansa iteroitava tyyppi funktion ulkona. Funktioiden tulisi palauttaa vain yhden tyypin arvoja, jolloin tulostyyppi on Julia-kääntäjän pääteltävissä. Tyyppien merkitseminen voi kuitenkin parantaa koodin luettavuutta. Destrukturointi onnistuu taas tyylillä a, b, _ = iteraattori(x). 

Sivuvaikutusten takia kutsutut funktiot palauttavat käytännön mukaan nothing. Parametrejaan muuttavien funktioiden nimi päättyy huutomerkkiin teejotain!. Funktioiden nimeämiskäytäntö on ilman alaviivoja kuten teenytjotain(), kun tämä vain on luettavissa – muussa tapauksessa sanat erotetaan alaviivalla. 

Juliassa myös kaikki operaattorit ovat funktioita: esim. x[i] kutsuu getindex, [1 2 3] kutsuu hcat ja x.nimi = y kutsuu setproperty!.

Metaohjelmointi 

Metaohjelmointi onnistuu suoraan käsittelemällä Julia-ohjelmien abstraktia syntaksipuuta. Tämä mahdollistaa täyden metaohjelmoinnin. Julia-koodin tyyppi on aluksi tyyppiä String, joka voidaan jäsentää Meta.parse(koodi) lauseketyypin Expr arvoksi. Lausekkeita voidaan määritellä myös syntaksilla :( 1 + 2 + $numero ) ja usean rivin lausekkeita syntaksilla quote ... end. Kaksoispisteellä tehdään myös Symbol-tyypin arvoja :nimi (engl. string interning). Lausekkeet voidaan lopuksi ajaa eval-funktiolla. Lausekkeita voidaan siis käsitellä normaaleissa Julia-funktioissa, mutta nämä funktiot ajetaan vasta ajon aikana, kun taas metaohjelmoinnissa on yleensä tehokkaampaa tuottaa halutut lausekkeet jo käännösvaiheessa. 

Käännösaikaiseen metaohjelmointiin tarvitaan makroja. Makrot sijoittavat argumenttinsa suoraan tuloksena tuotettuun lausekkeeseen, joka käännetään sitten normaalin Julia-koodin tapaan. Makrot määritellään tyylillä macro nimi() ... end ja kutsutaan syntaksilla @ajamakro x y tai @ajamakro(x, y) (tai yhdelle literaalitaulukolle @ajamakro[1, 2, 3]). Makro saa argumenttinsa vain lausekkeina Expr, symboleina :nimi tai literaaleina. Koska Julia on dynaamisesti tyypittävä, makrot eivät tiedä saamiensa arvojen tyyppejä. Makrot noudattelevat muuten paljolti samoja sääntöjä kuin funktiot.

Normaalin funktion ja makron lisäksi voidaan määritellä myös ns. tuotettu funktio tyylillä @generated function nimi() ... end, joka asettuu ominaisuuksiltaan funktion ja makron välille. Tällainen funktio ajetaan vasta, kun argumenttien tyypit tiedetään, mutta ennen kuin funktio on käännetty. Makrojen tapaan nämä erityisfunktiot lisäävät abstraktiokykyä ja suorityskykyä siirtämällä laskentaa ajovaiheesta käännösvaiheeseen.

Rinnakkaisohjelmointi 

@async-makro tekee annetusta lausekkeesta asynkronisesti ajettavan Task-tyypin objektin, jolle voidaan kutsua schedule(), wait() ja fetch(). Vastaavasti @sync-makron avulla voidaan odottaa kaikkien makron sisältämien @async-lausekkeiden valmistumista. Task-objekteja voidaan luoda myös käyttämällä @task-makroa tai tyypin alustajaa Task(() -> x). Channel on taas ikäänkuin rinnakkainen versio generaattorista; kanavan tuottaja kutsuu put-funktiota ja kuluttaja take-funktiota tai iteroi kanavaa muiden iteroitavien tyyppien mukaan. 

Julia käyttää oletuksena yhtä säiettä (julia --threads=1). Julia ei myöskään varmista muistiturvallisuutta (vrt. Rust (ohjelmointikieli)) vaan tämä on ohjelmoijan vastuulla. Esim. datakisavirheiden ehkäisemiseksi tulee käyttää lukkomekanismia, kuten ReentrantLock(), lock() ja unlock(), tai ns. atomisia arvoja, kuten Threads.Atomic{Tyyppi}(arvo) ja Threads.atomic_add!(). Threads-paketti määrittelee helppokäyttöisen @threads-makron, joka tekee annetusta silmukkalausekkeesta automaattisesti monisäikeisen.

julia -p x tekee paikallisen klusterin, jossa on annettu määrä prosesseja, ja julia --machine-file f tekee usean koneen klusterin käyttämällä salasanatonta ssh-viestintää näiden välillä. Distributed-paketti mahdollistaa hajautetun laskennan useissa prosesseissa ja perustuu etäreferensseihin (Future ja RemoteChannel) ja etäkutsuihin (@spawnat :any ..., remotecall()). Future-tyypille kutsutaan lopulta fetch() tuloksen saamiseksi pääprosessiin. Lähdetiedosto voidaan lisätä jokaiseen prosessiin tyylillä @everywhere include("Nimi.jl") tai tekemällä tästä Julia-paketti. Prosesseja voidaan lisätä, poistaa ja muokata funktioilla kuten addprocs ja rmprocs. Esimerkiksi DistributedArrays-paketin DArray on taulukon hajautettu versio ja standardikirjaston SharedArray mahdollistaa hajautettujen osasten jakamisen useiden prosessien välillä. 

Grafiikkaprosessoreiden käyttöön löytyy tukea muun muassa paketeista CUDA.jl ja MPI.jl.

Muut 

Dokumentointi onnistuu Juliassa tekstiliteraaleilla kohteena olevan määritelmän yläpuolella (engl. docstring). Teksti voi sisältää Markdown- ja LateX-merkintöjä.

Julian tärkeimpiin siirrännän työkaluihin kuuluvat ensinnäkin stdin- ja stdout-virrat, jotka tukevat print, show, read ja write funktioita. open-funktio tekee tiedostonimestä IOStream-objektin ja close-sulkee virran, jos open kutsuttiin ilman funktioargumenttia. Sockets-paketti määrittelee TCP-viestintään pääfunktiot listen, accept, connect ja close.

Lähteet

Aiheesta muualla 

 Julia-kielen kotisivu 
 Julia-kielen lähdekoodi 

Oliopohjaiset ohjelmointikielet
Vuoden 2012 ohjelmistot