SZYMON M. WOŹNIAK

Wstęp do języka programowania Julia

Jest to krótki, ale wystarczający, wstęp do języka programowania Julia na potrzeby ćwiczeń laboratoryjnych o charakterze obliczeniowym. Celem tego wstępu jest przedstawienie minimalnego podzbioru składni języka, aby można było jak najszybciej być w stanie sprawnie implementować programy, które będą realizować proste obliczenia numeryczne.

Przed przystąpieniem do dalszej części, proponuję zapoznać się z sekcjami:

znajdującymi się na głównej stronie dokumentacji Julia. Do strony możesz przejść klikając tutaj.

Spis treści

  1. Wstęp do języka programowania Julia
  2. Dlaczego stworzyliśmy Julia?
  3. Konfiguracja środowiska
  4. Inne zasoby
  5. Zmienne
  6. Liczby
  7. Wartości logiczne
  8. Porównywanie liczb
  9. Sterowanie przepływem
  10. Funkcje
  11. Tablice
  12. Rozgłaszanie
  13. Krotka
  14. Przydatne funkcje z biblioteki standardowej
  15. Ciągi znakowe
  16. Wypisywanie na standardowe wyjście
  17. Wykresy
  18. Czy Julia jest szybsza niż C?

Dlaczego stworzyliśmy Julia?

We want a language that’s open source, with a liberal license. We want the speed of C with the dynamism of Ruby. We want a language that’s homoiconic, with true macros like Lisp, but with obvious, familiar mathematical notation like Matlab. We want something as usable for general programming as Python, as easy for statistics as R, as natural for string processing as Perl, as powerful for linear algebra as Matlab, as good at gluing programs together as the shell. Something that is dirt simple to learn, yet keeps the most serious hackers happy. We want it interactive and we want it compiled.

— Jeff Bezanson, Stefan Karpinski, Viral B. Shah, Alan Edelman, Why We Created Julia, 14 Luty 2012

Konfiguracja środowiska

Julie można pobrać z oficjalnej strony https://julialang.org/. Należy pobrać ostatnią stabilną wersję na twój ulubiony system operacyjny. Na dzień 2025.03.04 jest to wersja 1.11.3. Najprawdopodobniej powinieneś pobrać wersje na architekturę 64bit. Podczas procesu instalacji zaznacz, aby instalator dodał Julia do zmiennej PATH. Dla bardziej zaawansowanych użytkowników polecam juliaup, natomiast nie instaluj juliaup na uczelnianych komputerach.

Po udanej instalacji zapoznaj się z poniższym krótkim nagraniem:

REPL ma parę trybów, które warto znać. Przeczytaj sekcję The different prompt modes dostępną na stronie The Julia REPL, aby zapoznać się z tymi trybami.

Twoim zadaniem teraz będzie zainstalowanie następujących paczek:

Aby to zrobić zapoznaj się z sekcją Adding registered packages dostępną pod tym adresem.

Jeżeli udało Ci się zainstalować te cztery paczki (bądź wciąż się kompilują), to najwyższy czas przejść do konfiguracji zintegrowanego środowiska programistycznego, którym będzie do VS Code. Osobiście polecam darmową i wolną (ang. libre) wersje VSCodium.

Zapoznaj się z poniższym krótkim nagraniem, które pokazuje jak skonfigurować zintegrowane środowisko programistyczne, aby wspierało programowanie w języku Julia.

Z tego nagrania przede wszytki warto zapamiętać skróty klawiszowe:

Dodatkowo istnieje koncepcja komórki kodu, czyli logicznie wydzielonego bloku kodu, który można wykonać. Domyślnie komórki kodu wydziela się poprzez ##.

## Początek pierwszej komórki
a = 10
b = 20
c = 30
## Koniec pierwszej komórki, początek drugiej komórki

println(a, b, c, "!")
print("Wskazane jest oglądanie doggo dot jl w przyspieszonym tempie.")
## i tak dalej

Jeżeli w pliku istnieją komórki, to skróty klawiszowe, które warto zapamiętać:

Inne zasoby

Przed przejściem do właściwej części, wrzucam jeszcze parę odnośników z którymi można się zapoznać, natomiast nie trzeba.

Zmienne

Zmienna to ciąg znaków w programie komputerwoym do którego przyporządkowana jest jakaś wartość. Zmienne można definiować z wykorzystaniem operatora przypisania = (znak równa się). Poniżej parę przykładów, gdzie do zmiennych przypisujemy literały reprezentujące różne typy.

a = 1
b = 1.0
c = 'a'
d = "abc"

Pamiętaj, że każda zmienna ma zawsze jakiś konkretny typ. Żeby sprawdzić typ zmiennej, można skorzystać z funkcji typeof, co może przydać się czasem podczas analizy napisanego kodu.

typeof(a) # Int
typeof(b) # Float64
typeof(c) # Char
typeof(d) # String

Podczas definiowania zmiennej można wymusić typ, jaki dana zmienna może przechowywać, natomiast jest to zabieg, którego powinno się unikać i stosować go tylko w uzasadnionych przypadkach.

e::Int32 = 1
typeof(a) # Int64
typeof(e) # Int32

not_a_string::Float64 = "Chciałbym być podwójnie precyzyjną liczbą zmiennoprzecinkową."

Reguły dotyczące nazw zmiennych są bardzo podobne jak w innych popularnych językach programowania, więc nie będziemy się tutaj zbytnio w to zagłębiać. Przykładowo nazwa zmiennej nie może zaczynać się od cyfry, ale może zaczynać się od znaku podkreślenia.

4zmienna = 1 # Błąd składni
_zmienna = 1 # Brak błędu składni

Język rezerwuje pewien podzbiór słów, które stanowią słowa kluczowe, przez co nie mogą zostać wykorzystane jako nazwy zmiennych.

ListasłówzarezerwowanychprzezjęzykJulia
baremodulebeginbreakcatchconstcontinue
doelseelseifendexportfalse
finallyforfunctionglobalifimport
letlocalmacromodulequotereturn
structtruetryusingwhile

W nazwie zmiennej mogą występować symbole Unicode, jak na przykład znaki alfabetu greckiego, czy też emoji.

_α = 1.0
d_β = 2.0
Γ_1 = 3.0
😄 = 4.0

W REPL (Read-Eval-Print-Loop) i edytorach tekstu, które wspierają pisanie programów w języku Julia, takie symbole można w szybki sposób wprowadzać poprzez wpisanie odpowiedniego kodu symbolu poprzedzonego odwróconym ukośnikiem i naciśnięcie klawisza tabulacji.

\pi<TAB>
\beta<TAB>
\Gamma<TAB>
\:smile:<TAB>

Spis wszystkich takich znaków można znaleźć w dokumentacji Julia, w rozdziale Unicode input. Niektóre symbole, jak na przykład π, mają specjalnie znacznie i coś więcej powiemy o nich w następnej sekcji.

Przydane rozdziały z oficjalnego podręcznika języka programowania Julia:

Liczby

O liczbach w danym języku programowania, powinno się wiedzieć o co najmniej trzech rzeczach, aby móc swobodnie realizować obliczenia numeryczne, to znaczy:

Skupimy się tutaj tylko na pierwszej i trzeciej pozycji z powyższej listy, ponieważ sposoby reprezentacji liczb przez wartości binarne, to materiał na inną okazję. Najbardziej istotnymi typami numerycznymi będą:

W bibliotece standardowej istnieją oczywiście też inne typy numeryczne, natomiast raczej nie będziesz miał potrzeby, aby z nich korzystać. Drzewo hierarchii standardowych typów numerycznych wygląda następująco:

Number  (Abstract Type)
├─ Complex
└─ Real  (Abstract Type)
   ├─ AbstractFloat  (Abstract Type)
   │  ├─ Float16
   │  ├─ Float32
   │  ├─ Float64
   │  └─ BigFloat
   ├─ Integer  (Abstract Type)
   │  ├─ Bool
   │  ├─ Signed  (Abstract Type)
   │  │  ├─ Int8
   │  │  ├─ Int16
   │  │  ├─ Int32
   │  │  ├─ Int64
   │  │  ├─ Int128
   │  │  └─ BigInt
   │  └─ Unsigned  (Abstract Type)
   │     ├─ UInt8
   │     ├─ UInt16
   │     ├─ UInt32
   │     ├─ UInt64
   │     └─ UInt128
   ├─ Rational
   └─ AbstractIrrational  (Abstract Type)
      └─ Irrational

Co można robić z liczbami? Oczywiście wykonywać na nich operacje. Poniższa tabela przedstawia spis podstawowych operacji, w tym działań arytmetycznych, które można wykonywać na liczbach.

WyrażenieOperacjaOpis
x + ydodawaniedodanie x do y
x - yodejmowanieodjęcie y od x
x * ymnożenieprzemonżenie x przez y
x / ydzieleniedzielenie x przez y
x ÷ ydzielenie bez resztyrównoważne z funckja div(x,y)
x ^ ypotęgowaniepodnieś x do potęgi y
x % yreszta z dzieleniarównoważne z funckją rem(x,y)

Przydatne funkcje dla liczb zespolonych:

W bibliotece standardowej są zdefiniowane pewne istotne stałe numeryczne, przykładowo takie jak,

Przydane rozdziały z oficjalnego podręcznika języka programowania Julia:

Wartości logiczne

Typ Bool to typ reprezentujący binarną wartość logiczną, która może przyjąć wartość prawda (literał true), bądź fałsz (literał false). Wartości logiczne mają głownie zastosowanie przy instrukcjach warunkowych, które zostaną przedstawione w jednej z kolejnych sekcji. Podstawowe operacje na wartościach logicznych, które można wykorzystać do budowania wyrażeń logicznych to:

WyrażenieOperacja
!xnegacja
x && ykoniunkcja
x || yalternatywa

Kolejność wykonywania działań logicznych: negacja, koniunkcja, alternatywa. Zawsze można też skorzystać z nawiasów, aby wymusić pożądaną kolejność. Przykład wyrażenia logicznego:

!(true && false) || true

Porównywanie liczb

Wyrażenia logicznie często buduje się z wykorzystaniem operacji porównywania dwóch wartości. Poniższa tabela przestawia dostępne operacja porównania dwóch wartości liczbowych.

WyrażenieOpis
x == yprawda jeżeli x jest równe y.
x != yprawda jeżeli x jest różne od y.
x < yprawda jeżeli x jest mniejsze niż y.
x <= yprawda jeżeli x jest mniejsze niż lub równe y.
x > yprawda jeżeli x jest większe niż y.
x >= yprawda jeżeli x jest większe niż lub równe y.

Przykład wyrażenia logicznego wykorzystującego operacje porównania:

!(a == c) && ((a > b) || !(a >= b) || (a < b)) && !((a <= b) && !(a <= c))

Jaka jest wartość tego wyrażenia dla zmiennych a=1, b=2.0, c=1.0?

Do sprawdzenia niedokładnej równości dwóch liczb zmiennoprzecinkowych powinno wykorzystywać się funkcję isapprox.

Sterowanie przepływem

Sterowanie przepływem, na potrzeby tego wstępu, to zbiór instrukcji, które pozwalają na warunkowe wykonywanie lub powtarzanie bloków instrukcji. Interesują nas tutaj w zasadzie dwie rzeczy: instrukcje warunkowe oraz pętle. Omówimy sobie dwie instrukcje warunkowe: if i ?: (ternary operator), oraz dwie pętle: while i for.

Operator warunkowy ?: ma następującą składnię:

warunek ? "zwróć tą wartość jeżeli prawda" : "zwróć tą wartość jeżeli fałsz"

Parę przykładów wykorzystania tego operatora:

x = 1.5

x+1 == 2.5 ? "prawda" : "fałsz" # Zwraca "prawda"
x^2 == 3.1 ? "prawda" : "fałsz" # Zwraca "fałsz"


# bardziej złożony przykład w którym przydałoby się wprowadzić nawiasy
# aby zwiększyć czytelność
x = x > 1.0 ? x^2 > 2 ? x-1 : 10x : 2x == 10.0 ? 33.0 : 10^x

Druga instrukcja warunkowa, to instrukcja if, która pozwala na odrobinę więcej czytelności w bardziej rozbudowanych wyrażeniach. Instrukcji if mogą akompaniować, o ile jest taka potrzeba, również instrukcje elseif i else. Składnia jest standardowa, nie ma co tutaj za bardzo się rozwodzić, więc przejdźmy od razu do trzech przykładów, które pokazują trzy warianty wykorzystania tych instrukcji.

a = 1
if _WARUNEK # Jeżeli _WARUNEK jest prawdziwy to wykonaj ten blok kodu
  a += 1
end
@show a
a = 1
if _WARUNEK # Jeżeli _WARUNEK jest prawdziwy to wykonaj ten blok kodu
  a += 1
else # Jeżeli _WARUNEK jest fałszywy to wykonaj ten blok kodu
  a -= 1
end
@show a
a = 1
if _WARUNEK # Jeżeli _WARUNEK jest prawdziwy to wykonaj ten blok kodu
  a += 1
elseif _WARUNEK2 # Jeżeli poprzednie warunki są fałszywe,
  a *= 10        # ale ten nie, to wykonaj ten blok kodu
elseif _WARUNEK3 # Jeżeli poprzednie warunki są fałszywe,
  a /= 10        # ale ten nie, to wykonaj ten blok kodu
else # Jeżeli wszystkie warunki są fałszywe to wykonaj ten blok kodu
  a -= 1
end
@show a

Jeżeli jest taka potrzeba, to oczywiście można umieścić dowolną liczbę instrukcji warunkowych elseif w ramach takiego wyrażenia.

Pętla while służy do warunkowego powtarzania bloku instrukcji. Blok instrukcji jest powtarzany, dopóki warunek pętli jest spełniony. Warunek jest sprawdzany przed każdym wykonaniem bloku instrukcji. Przykład:

i = 0
while i < 10
  @show i
  i += 1
end

Pętla for służy głównie do iterowania po iterowalnych strukturach i wykonania bloku instrukcji dla aktualnie wygenerowanego elementu przez iterator. Dla zobrazowania, przykładową strukturą, po której możliwa jest iteracja, będzie tablica [3, 1, 4], natomiast do omówienia tablic przejdziemy w kolejnych sekcjach. Przykładowe wykorzystane pętli for poniżej.

a = 1
for i in 1:10
  a += i
  @show i, a
end

Skupmy teraz uwagę na wyrażeniu 1:10, które to tworzy iterowalną strukturę UnitRange{Int64}, która zwraca kolejne liczby od 1 do 10, gdy się po niej iteruje. W ogólności wyrażenie start:krok:stop generuje iterator, który będzie zwracał wartości wypisane przez poniższy kod:

i = start
while i <= stop
    @show i
    i += krok
end

Jeżeli w wyrażeniu start:krok:stop nie podamy wartości krok, czyli wywołamy wyrażenie start:stop, to wartość domyślna krok = 1. Do generowania takich iteratorów można wykorzystać także funkcja range, która może być czasem wygodniejsza niż wyrażenie start:krok:stop. Zapoznaj się z dokumentacją tej funkcji poprzez wpisanie w REPL ?range i sam oceń.

Oczywiście dostępne są także dwie specjalne instrukcje break oraz continue, które to można umieścić w ciele pętli. Dla przypomnienia wywołanie instrukcji break natychmiast przerwie wykonywanie pętli, natomiast wywołanie instrukcji continue natychmiast wywołuje przejście do następnej iteracji pętli.

Inne przydane funkcje dla pętli for, z którymi polecam się zapoznać to enumerate oraz zip.

Wszystkie elementy z iterowalnych struktur, bądź generatorów, można zebrać do tablicy z wykorzystaniem funkcji collect. Zastanów się dlaczego domyślnie się tego nie robi.

A = 1:100
x = collect(A)

Przydane rozdziały z oficjalnego podręcznika języka programowania Julia:

Funkcje

W języku programowania Julia, funkcje możemy definiować na trzy sposoby. Jako że każdy z tych sposób może być przydatny, to omówmy sobie wszystkie. Zacznijmy od omówienia sobie najbardziej ogólnego przypadku na poniższym przykładzie.

function foo(a, b, c=10.0; klucz1, klucz2=1)
  return klucz1 > klucz2 ? a*b*c : a+b+c
end

Definicje funkcji zaczynamy od słowa kluczowego function. Następnie powinna pojawić się nazwa funkcji, w przykładzie jest to foo. W nawiasach podajemy dowolną ilość argumentów, które funkcja będzie przyjmować. W przykładzie mamy trzy argumenty pozycyjne: a, b, c oraz dwa argumenty przez słowo kluczowe: klucz1, klucz2. Argumenty przez słowo kluczowe są oddzielone od argumentów pozycyjnych znakiem ;. Różnica między tymi dwoma rodzajami argumentów występuje podczas wywołania funkcji. Argumenty przez słowo kluczowe trzeba nazwać podczas wywołania funkcji i kolejność ich podawania nie ma znaczenia. W przypadku argumentów pozycyjnych nie podajemy ich nazw i kolejność ma znaczenie. Dodatkowo każdy argument może przyjąć wartość domyślną poprzez operator przypisania wartości. Co przekłada się na to, że jeżeli nie podamy danego argumentu podczas wywołania, to przyjmuje on wtenczas domyślną wartość (o ile jest podana). Wartość zwracana przez funkcje jest wskazana przez instrukcję return. Przeanalizuj poniższe przykłady wywołania funkcji foo.

foo(1, 2, 3, klucz1=10, klucz2=20)
foo(1, 2, klucz1=10)
foo(1, klucz2=10) # czemu coś tutaj nie bangla?

Akurat przykładowa funkcja foo jest dość prosta i można wykorzystać tutaj drugi (jednolinijkowy) sposób definiowania funkcji, który jest następujący:

bar(a, b, c=10.0; klucz1, klucz2=1) = klucz1 > klucz2 ? a + b * c : a * b + c

Istnieje możliwość jawnej specyfikacji typów dla argumentów oraz wartości zwracanej przez funkcje. Składnia jest jak w przykładzie poniżej, natomiast nie trzeba tego robić, a wręcz nie jest to zalecane.

suma_int(a::Int, b::Int)::Int = a + b

sum_int(1, 2)   # 3
sum_int(1, 2.0) # błąd wykonania

Jawna specyfikacja typów ma zastosowanie przykładowo do wdrażania polimorfizmu funkcji (metody) z wykorzystaniem mechanizmu multiple dispatch. Tematyka metod szerzej opisana jest w rozdziale Methods z dokumentacji Julia, natomiast znajomość tego zagadnienia nie jest bardzo istotna z punktu tego wstępu.

W języku Julia, funkcja jest typem pierwszoklasowym, co oznacza w bardzo dużym skrócie, że można przekazywać funkcję jako argument do innej funkcji, jak i to, że funkcja może zwracać funkcję. Dla przykładu zdefiniujemy funkcję transform3, która przekształca liczbę 3 z wykorzystaniem funkcji będącej pierwszym argumentem pozycyjnym. Następnie wywołajmy funkcję transform3 z wyrażeniem x -> x^2 jako pierwszy argument.

function transform3(f::Function)
    f(3)
end

transform3(x -> x^2) # Podnosimy wartość 3 do kwadratu

Wyrażenie x -> x^2 tworzy funkcję anonimową (funkcja bez nazwy). Funkcje anonimowe często wykorzystuję się jako argument dla funkcji wyższego rzędu.

Ostatnim konceptem, który wprowadzimy jest domknięcie. Domknięcie w dużym uproszczeniu, to taka funkcja do której doklejona jest nielotna pamięć, z której to tylko ta funkcja może korzystać. Przeanalizuj poniższy kod funkcji make_fancy_generator, która zwraca funkcje (domknięcie), która generująca kolejne wartości według pewnej procedury.

function make_fancy_generator(f::Function)::Function
    x = 0.0
    return function(krok = 1.0)
        x += f(krok)
        return x
    end
end

g = make_fancy_generator(x -> sqrt(x))
@show g()
@show g()
for i = 1:10
    @show g(i)
end

# bonus
dump(g) # sprawdź co siedzi w środku tego bytu :)

Przydane rozdziały z oficjalnego podręcznika języka programowania Julia:

Tablice

Tablica (ang. array), to taka struktura danych, która reprezentuje skończony i uporządkowanego zbioru elementów, który można modyfikować (ang. mutable). Tablice w odróżnieniu od list (ang. linked list) zajmują ciągłą przestrzeń w pamięci (przynajmniej na poziomie pamięci wirtualnej). Elementami tablicy w zasadzie może być dowolna wartość, natomiast z punktu widzenia obliczeń numerycznych, interesują nas głównie tablice, które zawierają w sobie liczby tego samego typu. Aby stworzyć tablicę, należy zbudować wyrażenie z wykorzystaniem nawiasów kwadratowych, przykładowo:

julia> a = [1, 2, 3]
> 3-element Vector{Int64}:
 1
 2
 3

W rezultacie otrzymaliśmy wartość, która jest typu Vector{Int64}. I tak typ Vector to jest właśnie jednowymiarowa tablica, a w zasadzie alias na parametryczny typ Array.

julia> Vector
> Vector (alias for Array{T, 1} where T)

Typ Int64, który pojawił się w nawiasach {} mówi jakiego typu elementy przechowuje ten Vector. Nazwa Vector nie jest przypadkowa i wrócimy do omówienia tego później, natomiast na początku skupmy się na jedno wymiarowych tablicach.

Jeżeli w wyrażeniu znajdą się literały o różnych typach, to interpreter spróbuje rzutować je do wspólnego typu (o ile jest to możliwe).

julia> b = [1.0, 2, 3]
> 3-element Vector{Float64}:
 1.0
 2.0
 3.0

Jeżeli nie jest to możliwe, to powstanie tablica przechowująca elementy najbliższego wspólnego nadtypu wszystkich elementów. Często jedynym wspólnym nadtypem jest Any, który oznacza co do zasady dowolny typ.

julia> b = [1.0, 2, 3, "tekst"]
> 4-element Vector{Any}:
 1.0
 2
 3
  "tekst"

Jak stworzyć pustą jednowymiarową tablicę? Przykładowo w taki sposób:

a = []           # Pusta tablica przechowująca elementy o dowolnym typie `Any`
b = Any[]        # To samo co `a = []`, tylko bardziej jawnie
c = Int[]        # Pusta tablica przechowująca elementy o typie `Int`
d = Float64[]    # Pusta tablica przechowująca elementy o typie `Float64`
e = ComplexF64[] # Pusta tablica przechowująca elementy o typie `ComplexF64`

Podstawowe operacje na jednowymiarowych tablicach:

push!(a, 3)        # Dodaj na koniec tablcy wartość `3`
last = pop!(a)     # Usuń ostatni element tablicy i zwróc go
append!(a, [4, 5]) # Do tablicy `a` doklej tablice `[4, 5]`
length(a)          # Zwróć ilość elementów w tablicy

W języku Julia istnieje konwencja w której to funkcje, których nazwa kończy się znakiem !, modyfikują argument funkcji. Przeważnie modyfikowany jest wtenczas pierwszy argument pozycyjny.

Aby dostać się bezpośrednio to i-tego elementu tablicy, można wykorzystać nawiasy kwadratowe.

arr = [1, 2, 3, 4]
arr[2] # Zwraca drugi element tablicy a
arr[2] = 10 # Przypisanie do drugiego elementu nowej wartości

W języku programowania Julia pierwszy element tablicy (czy w ogólności uporządkowanych kolekcji) ma indeks 1. Więc specyfikujemy pozycję w tablicy, a nie przesunięcie względem pierwszego elementu jak to ma miejsce przykładowo w języku C. Jeżeli potrzebujesz operować na przesunięciach, to zawsze możesz napisać wyrażenie w następujący sposób a[1+i], gdzie i to przesunięcie.

Jeżeli chcemy wybrać podzbiór elementów z tablicy, to można skorzystać z iteratora start:krok:stop albo wyspecyfikować indeksy przez tablicę zawierającą dodatnie liczby całkowite. Zapoznaj się i przeanalizuj poniższe przykłady.

a = [1, 2, 3, 4, 5, 6, 7, 8, 9]
a[1:2:9]        # Zwróci elementy o nieparzystych indeksach
a[begin:2:end]
a[3:7]          # Zwróć wycinek tablicy
a[9:-1:1]       # Zwróci tablice w odwróconej kolejności
a[end:-1:begin] # Tak też zadziała
a[[3, 8]]       # Zwróć 3 i 8 element tablicy

No i tyle o tablicach jednowymiarowych. Przejdźmy do tablic wielowymiarowych.

Tablice dwuwymiarowe można stworzyć następującą składnią:

A = [1 2; 3 4]

W rezultacie, jak może się domyślić, powstał macierz, czyli typ Matrix, będący także aliasem na parametryczny typ Array.

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

Dla tablic trzy wymiarowych lub więcej, nie ma już specjalnych aliasów i tworzenie ich w taki sposób jest już wysoce niepraktyczne. Przeważnie korzysta się wtenczas z funkcji do alokacji tablicy o danym rozmiarze jak zeros. Natomiast omówmy sobie składnie tego wyrażenia. Elementy w wierszach oddzielamy odstępem (spacja), natomiast kolejne wiersze oddzielamy średnikiem ;. Możemy w tym stylu zbudować też macierz z wektorów. Przeanalizuj, co generuje poniższy kod.

a = [3, 2, 1]

A = [a a]
b = [a; a]
C = [A; b b]

Zasady dostępu do elementów z tablic wielowymiarowych są analogiczne jak w przypadku tablic jednowymiarowych. Jedyna różnica jest podanie indeksu (bądź iteratora) dla kolejnych wymiarów.

A = [1 2 3; 4 5 6; 7 8 9]
A[1,3] # Który element zostanie zwróci?
A[1:2, 2:3] # Jaka macierz zostanie zwrócona?
A[[1,3], [2, 3]] # A tutaj czego się spodziewasz?

No dobra, to, w jakim celu jednowymiarowa tablica jest typu Vector, a nie po prostu Array? Powód jest prosty, zmienne typu Vector traktowane są jako wektory, a oznacza to, że co do zasady można na nich wykonywać podstawowe operacje znane z algebry liniowej.

a = [3, 2, 1]
b = [1, 2, 3]
c = adjoint(a)  # Sprzężenie hermitowskie (transpozycja i sprzężenie zespolone)
c = a'          # Sprzężenie hermitowskie można wykonać też w ten sposób
c = transpose(a)# To jest sama transpozycja
d = 3*a + 2*b   # Liniowa kombinacja wektorów
e = a' * b      # Iloczyn skalarny dwóch wektorów
f = a  * b'     # Iloczyn zewnętrzny dwóch wektorów

Podobnie jest w przypadku tablic dwuwymiarowych, które są traktowane jako macierze.

A = [1 2 3; 4 5 6; 7 8 9]
b = [1, 2, 3]
c = A*b       # Przekształcenie liniowe
d = b'*A*b    # Forma kwadratowa
e = A' * A    # Mnożenie macierzowe

Natomiast błąd zwróci takie wyrażenie jak [1, 2, 3] + 3, bo nie da się dodać do wektora wartości skalarnej, tak jak nie da się dodać do macierzy wektora. Można to rozwiązać przez mechanizm rozgłaszania, o którym będzie krótko w następnej sekcji.

Zapoznaj się, co robią następujące funkcje, bo na pewno się przydadzą:

zeros(4)
zeros(ComplexF64, 6, 4)
zeros(ComplexF64, (5, 10))

size(zeros(4, 8))
length(zeros(4, 8))
reshape(zeros(ComplexF64, 4, 8), 2, 16)

eltype(zeros(4, 8))
eltype(zeros(ComplexF64, 4, 8))

ones(4)
ones(ComplexF64, 4, 4)
ones(ComplexF64, (4, 4))

rand(4)
rand(ComplexF64, 4, 4)
rand(ComplexF64, (4, 4))

randn(4)
randn(ComplexF64, 4, 4)
randn(ComplexF64, (4, 4))

Przydane rozdziały z oficjalnego podręcznka języka programowania Julia:

Rozgłaszanie

Rozgłaszanie (ang. broadcasting) to prosty mechanizm, który ma za zadanie rozwiązać następujący problem. Jeżeli mam funkcję abs(z), która oblicza moduł liczby zespolonej z, a chciałbym obliczyć moduł dla każdej liczby zespolonej w tablicy zt = randn(ComplexF64, 100), to jak to mogę zrobić? Na przykład pętlą for, ale wygodniejszym rozwiązaniem jest rozgłoszenie funkcji abs na elementy tablicy zt w następujący sposób abs.(zt). Symbol . (kropka) jest operatorem rozgłaszania. Więc jeżeli chcemy dodać skalara do wektora, to musimy rozgłosić skalar na elementy wektora.

a = [1, 2, 3]
b = a .+ 1

Dwóch wektorów kolumnowych nie da się przez siebie przemnożyć, natomiast możemy przemnożyć przez siebie odpowiednie elementy.

a = [1, 2, 3]
b = a * a  # Błąd
b = a .* a # Sukces

Wracając do przykładu z początku tej sekcji.

z = randn(ComplexF64, 100); # Wylosuj 100 liczb zespolonych
zz = abs2.(z) # Oblicz moduł kwadrat z każdej liczby wektora

Sam mechanizm rozgłaszania jest trochę bardziej skomplikowany, niż zostało to tutaj przedstawione, natomiast takie zastosowanie rozgłaszania powinno wystarczyć na potrzeby implementacji prostych obliczeń.

Przydane rozdziały z oficjalnego podręcznka języka programowania Julia:

Krotka

Krotka (ang. tuple), to taka trochę jednowymiarowa tablica, tyle że jak się ją stworzy, to nie można zmieniać wartość jej elementów (ang. immutable type). Aby stworzyć krotkę, trzeba wykorzystać nawiasy okrągłe. Przykład

krotka = (1, 2, 3)   # Stwórz krotkę i przypisz ją do zmiennej
krotka[1]            # Dobierz się do pierwszego elementu
a, b, c = krotka     # Można łatwo i szybko rozpakować sobie krotkę
@show typeof(krotka) # Zauważ że typ krotki jest bardzo konkretny
krotka[2] = 55       # Tego nie można tego zrobić

Krotki występują przy okazji zwracania wielu wartości z funkcji.

function foobar(a, b)
  c = a*b
  d = a^b
  return c, d # Tutaj zwracana jest krotka, mimo że nie ma nawiasów
end

prod, power = foobar(10, 4) # A tutaj krotka jest od razu rozpakowana

Czy też podczas iterowania pętlą for z wykorzystaniem funkcji enumerate

for (i, x) in enumerate([11, 22, 33, 44])
  @show i
  @show x
end

Przydatne funkcje z biblioteki standardowej

Opisywanie każdej funkcji z biblioteki standardowej mija się z celem, dlatego poniżej została przedstawiona lista funkcji, które przydadzą Ci się podczas pisania programów. Nie oznacza to, że możesz korzystać tylko i wyłącznie z tego podzbioru. Przeanalizuj co robi każda z tych funkcji, zapoznaj się z pomocą do tych funkcji.

N, R = 10, 3
a, b, c, d = 1.9, 2.7, 3.5, 4.3
z = 1.0 + im*1.0
T = [a, b, c, d]

# Operacja na liczbach całkowitych
div(N, R)    # Dzielenie całkowite
rem(N, R)    # Reszta z dzielenia
divrem(N, R) # Dzielenie i reszta za jednym razem

# Podstawowe funkcje matematyczne
sqrt(d)  # Pierwiastek kwadratowy liczby x
exp(d)   # Eksponenta liczy x
cos(d)   # Cosinus kąta x (rad)
sin(d)   # Sinus kąta x (rad)
sinc(d)  # Funkcja sinc z liczby x
log(d)   # Logarytm naturalny z liczby x
log2(d)  # Logarytm binarny z liczby x
log10(d) # Logarytm dziesiętny z liczby x

abs(z)   # Moduł liczby zespolone
angle(z) # Argument liczby zespolonej
real(z)  # Część rzeczywista
imag(z)  # Część urojona

floor(Int, d) # Podłoga z liczby x (rzutowany na typ Int)
ceil(Int, d)  # Sufit z liczby x (rzutowany na typ Int)

max(a, b, c, d) # Wartość maksymalna argumentów
min(a, b, c, d) # Wartość minimalna argumentów

# Funkcje dla tablic
length(T)   # Ilość elementów w tablicy
size(T)     # Wymiar tablicy
sum(T)      # Suma wszystkich elementów
maximum(T)  # Maksymalna wartość w tablicy
minimum(T)  # Minimalna wartość w tablicy
argmax(T)   # Indeks maksymalne wartość w tablicy
argmin(T)   # Indeks minimalne wartość w tablicy

# Alokowanie tablicy o zadanym wymiarze
# Wymiarów można dodawać w opór. Wymiar można alternatywnie podać w postaci krotki.
# Domyślnie typ Float64, można też podać inny jako pierwszy argument.
zeros(N)                    # Wektor o długości N o typie Float64
zeros(N, N)                 # Macierz o wymiarze N x N o typie Float64
zeros(N, N, N)              # Tablica o wymiarze N x N x N o typie Float64
zeros(ComplexF64, N)        # Wektor o długości N o typie ComplexF64
zeros(ComplexF64, N, N)     # Macierz o wymiarze N x N o typie ComplexF64
zeros(ComplexF64, N, N, N)  # Tablica o wymiarze N x N x N o typie ComplexF64

# Iterator
start, krok, stop = 10, 0.1, 100
start:stop
start:krok:stop
range(start, stop; step=krok)
range(start, stop; length=100)

Ciągi znakowe

Do reprezentacji ciągów znakowych służy typ String. Typ String obsługuje znaki Unicode, który to koduje znaki z wykorzystaniem nawet 4 bajtów. W porównaniu do kodowania ASCII, które wykorzystuje zawsze tylko 1 bajt. W rezultacie ciągi znakowe w Julia nie są implementowane w postaci tablic znaków Vector{Char}. Dodatkowo typ String jest niezmienny, czyli nie można modyfikować wartości tego typu.

Do stworzenia literału String wykorzystujemy ", przykładowo "literał". Do stworzenia literału Char wykorzystujemy ', przykładowo '🐙'.

# Stwórz pusty ciąg znakowy
a = ""
@show a

# Przyłącz do ciągu `a` drugi ciąg znaków
a = a * "Już " * "nie taki pusty 🐙"
@show a

# Wypisz podciąg
@show a[3:10]

Istnieje możliwość interpolacji danego ciągu znakowego z wykorzystaniem zmiennej, czyli wepchnięcie wartości tej zmiennej do ciągu znakowego. Do interpolacji służy składnia $(nazwa_zmiennej) w ciągu znakowym. Przykład:

A = 10.0

tekst = "Napięcie w punkcie A wynosi $(x) [V]"

Interpolacja wartości danego typu do ciągu znakowego, możliwa jest o ile istnieje metoda do konwersji danego typu na String.

Aby przekształcić String w tablice Char (tzn. Vector{Char}) można skorzystać z funkcji collect.

s = "Pies 🐕 chciał łapać żółty krążek. 🫨"
tablica_znakow = s |> collect

Iterowanie po każdym indeksie tablicy s, jak w poniższy sposób może generować błędy. Zastanów się dlaczego.

for i = 1:length(s)
    @show s[i]
end

Typ String jest iterowalny, więc można po prostu wykorzystać do tego pętle for.

for i = s # Po String można iterować
    @show i
end

W ogólności wskazane jest korzystanie z dedykowanej funkcji eachindex. Funkcja służy do wygenerowanie każdego (poprawnego) indeksu danej struktury.

for i in eachindex(s)
    @show s[i]
end

Wypisywanie na standardowe wyjście

a = "to jest"
b = "przykład"
x = randn(4, 4)

# Wypisz podane wartości
print(a, b, x)

# Wypisz podane wartości ze znakiem nowej linii na końcu
println(a, b, x)

# Wyświetl ładnie wartość (tylko jeden argument)
display(x)

# Można też korzystać z makra @show
@show a b x

Wykresy

Istnieje co najmniej parę sensowych bibliotek do generowania wykresów w ekosystemie Julii, natomiast skupimy się wyłącznie na Makie. Makie jest wysokopoziomową biblioteką, która co do zasady definiuje ujednolicony interfejs programistyczny między różnymi silnikami renderującymi. My zainteresujemy się silnikiem Cairo (grafika wektorowa) i OpenGL. Moduł Makie dla silnik renderującego Cairo to CaiorMakie.jl, natomiast dla silnika renderującego OpenGL to GLMakie.jl. Paczki nie są domyślnie dostępne w dystrybuowanej wersji Julia, więc należy je zainstalować z wykorzystaniem wbudowanego menadżera pakietów. Aby załadować paczkę CairoMakie.jl do użytku, należy wywołać instrukcję using CairoMakie. CairoMakie.jl jest wspierane przez rozszerzenie julialang w VSCode i będzie automatycznie wyświetlało wygenerowane obrazki w okienku, w VSCode. Podstawy Makie, które będą wystarczające na potrzeby zajęć, omówione są w Makie.org - Basic Tutorial. Proszę się zapoznać z tym materiałem. Poniżej parę przepisów jak rysować wykresy w Makie.

using CairoMakie

fs = 100
dt = 1/fs
t_1, t_2 = 0.0, 1.0
t = range(t_1, t_2; step=dt)
x = sin.(2*pi*10*t)

lines(t, x)
using CairoMakie

fs = 256
dt = 1/fs
t_1, t_2 = 0.0, 1.0
t = range(t_1, t_2; step=dt)
x = sin.(2*pi*10*t)

scatter(t, x)
using CairoMakie

fs = 256
dt = 1/fs
t_1, t_2 = 0.0, 1.0
t = range(t_1, t_2; step=dt)
x = sin.(2*pi*10*t)

lines(t, x)
scatter!(t, x)
xlims!(0.2, 0.3)
ylims!(-2.2, 2.2)
current_figure()
using CairoMakie

fs = 256
dt = 1/fs
t_1, t_2 = 0.0, 1.0
t = range(t_1, t_2; step=dt)
x = sin.(2*pi*10*t)

fig = Figure(size=(800, 400))
ax1 = Axis(fig[1,1], aspect=1)
ax2 = Axis(fig[1,2], aspect=1)
lines!(ax1, t, x)
scatter!(ax2, t, x)
current_figure()
using CairoMakie

fs = 256
dt = 1/fs
t_1, t_2 = 0.0, 1.0
t = range(t_1, t_2; step=dt)
x = sin.(2*pi*10*t)

fig = Figure(size=(800, 400))
ax1 = Axis(fig[1,1], aspect=1)
ax2 = Axis(fig[1,2], aspect=1)

lines!(ax1, t, x; label="sinus kreski")
scatter!(ax2, t, x; label="sinus kropki")

axislegend(ax1)
axislegend(ax2)

xlims!(ax1, 0.2, 0.3)
ylims!(ax1, -2.2, 2.2)

xlims!(ax2, 0.2, 0.3)
ylims!(ax2, -2.2, 2.2)
current_figure()
using CairoMakie

x = 0:10
y = 10:40

fig = Figure(size=(800, 400))
ax = Axis(fig[1,1], aspect=1)

heatmap!(ax, x, y, rand(length(x),length(y)))
current_figure()

Czy Julia jest szybsza niż C?

Na potrzeby tej sekcji przyjmiemy sobie prostą definicję szybkości języka programowania. Języka programowania X, będzie szybszy niż język programowania Y, jeżeli czas potrzebny na realizacje obliczeń Z z wykorzystaniem algorytmu A, będzie mniejszy dla języka programowania X niż dla języka programowania Y, przy założeniu, że obliczenia są realizowane na tej samej jednostce obliczeniowej. Dla ścisłości, przez język programowania powinno się rozumieć kompilator lub interpreter danego języka.

W tak przyjętej definicji, możemy spodziewać się, że język programowania C jest szybki, bo to w zasadzie taka przyjemna nakładka na assembler. Na drugiem biegunie szybkości ustawimy sobie bardzo popularny i lubiany język Python, który powiedzmy, że jest wolny, bo jest interpretowany. Nie jest to do końca prawdą, bo powodów, które sprawiają, że Python jest wolny jest więcej i niekoniecznie jest to powiązane z interpreterem.

Potrzebujemy w tym momencie zdefiniować algorytm, który pozwoli nam porównać szybkość wykonywania obliczeń numerycznych w języku C, Python oraz Julia. Dodatkowo, algorytm powinien być prosty i badać narzut na realizacje najprostszych operacji obliczeniowych, takich jak dodawanie i mnożenie. Dobrym wyborem będzie przekształcenie liniowe wektora, które jest podstawowa operacją z algebry linowej. Dane do tego algorytmu są następujące:

a samą operację możemy opisać następującym wzorem:

[y]i=j=1N([A]i,j[x]j). [y]_i = \sum_{j=1}^N \Big( [A]_{i,j} \cdot [x]_j \Big) .

Implementacja programów, które realizują dokładnie te same obliczenia, odpowiednio w Python, C oraz Juila, znajdują się poniżej.

# Python
def gmvm(y, A, x):
    N = len(y)
    for i in range(N):
        for j in range(N):
            y[i] += A[i][j] * x[j]
// C
void cgmvm(double *y, double *A, double *x, int N) {
    for(int i = 0; i < N; i++) {
        for(int j = 0; j < N; j++) {
            y[i] += A[i+j*N] * x[j];
        }
    }
}
# Julia
function gmvm!(y, A, x)
    for i = eachindex(y), j = eachindex(x)
        y[i] += A[i,j] * x[j]
    end
end

Przed przejściem do pomiarów, skompilujmy program w języku C do formatu biblioteki współdzielonej z wykorzystaniem poniższego polecenia w systemie operacyjnym Linux:

$ gcc -fpic -shared gmvm.c -o libgmvm.so

Pomiar szybkości wykonamy dla przestrzeni wektorowej o wymiarze N=1000N = 1000 i losowych wartościach w macierzy AR1000×1000A \in \mathbb{R}^{1000 \times 1000} i wektorze xR1000x \in \mathbb{R}^{1000}. Do wykonana pomiarów w języku Python, wykorzystamy poniższy skrypt:

import time
from random import gauss

def gmvm(y, A, x):
    N = len(y)
    for i in range(N):
        for j in range(N):
            y[i] += A[i][j] * x[j]

N = 1000
y = [gauss() for _ in range(N)]
A = [[gauss() for _ in range(N)] for _ in range(N)]
x = [gauss() for _ in range(N)]

K = 100
t0 = time.time_ns()
for _ in range(K):
    gmvm(y, A, x)
t1 = time.time_ns()
print((t1 - t0)/K/1e9)

W wyniki wykonania tego skryptu, na standardowym wyjściu, pojawi się średni czas wykonywania funkcji gmvm w języku Python, w sekundach. Pomiar czasu dla języka C wykonamy z poziomu programu napisanego w języku Julia, który to oferuje wywoływanie funkcji z języka C w prosty sposób. Do pomiarów czasu wykonywania w języku Julia wykorzystamy makro @btime z paczki BenchmarkTools.jl, które oszacuje czas wykonywania funkcji. Program w języku Julia, który zostanie wykorzystany do oszacowania czasu wykonywania obliczeń znajduje się poniżej.

using Libdl
using BenchmarkTools

function gmvm!(y, A, x)
    for i = eachindex(y), j = eachindex(x)
        y[i] += A[i,j] * x[j]
    end
end

N = 1000
A = randn(Float64, N, N);
x = randn(Float64, N);
y_gmvm, y_c = zeros(N), zeros(N)

RCd = Ref{Cdouble}

@btime @ccall "./libgmvm.so".cgmvm(y_c::RCd, A::RCd, x::RCd, N::Cint)::Cvoid;
@btime gmvm!(y_gmvm, A, x);

W wyniki wykonania tego programu, na standardowym wyjściu, pojawią się dwa czasy wykonywania funkcji. Pierwszy dla implementacji w języku C, drugi dla implementacji w języku Julia. Wyniki, które zostały uzyskane, prezentują się następujące:

Na podstawie otrzymanych wyników, można by dojść do wniosku, że Julia jest językiem znacznie szybszym od C, natomiast nie jest to co do zasady prawda, ponieważ Julia domyślnie wykonuje szereg optymalizacji podczas kompilacji kodu do kodu maszynowego. Dodajmy do polecenia gcc, flagę -O3, która wykona szereg optymalizacji podczas procesu kompilacji programu napisanego w języku C:

$ gcc -O3 -fpic -shared gmvm.c -o libgmvm.so

Po ponownym przeprowadzeniu pomiarów z wykorzystaniem zoptymalizowanej biblioteki współdzielonej, funkcja cgmvm uzyskuje czas 1.498 ms, który jest porównywalny z czasem osiągniętym przez Julia. Można by wręcz powiedzieć, że oba te czasy są takie same, biorąc pod uwagę brak determinizmu przy wykonywaniu tych obliczeń.

Podsumowując, w języku Julia istnieje możliwość tworzenia programów, tak aby były one porównywalnie szybkie, jak język C lub inne niskopoziomowe języki programowania, przy czym w języku Julia można pisać programy na poziomie abstrakcji znanym z języka Python.