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:
Introduction,
Julia Compared to Other Languages,
What Makes Julia, Julia?,
Advantages of Julia,
znajdującymi się na głównej stronie dokumentacji Julia. Do strony możesz przejść klikając tutaj.
Spis treści
- Wstęp do języka programowania Julia
- Dlaczego stworzyliśmy Julia?
- Konfiguracja środowiska
- Inne zasoby
- Zmienne
- Liczby
- Wartości logiczne
- Porównywanie liczb
- Sterowanie przepływem
- Funkcje
- Tablice
- Rozgłaszanie
- Krotka
- Przydatne funkcje z biblioteki standardowej
- Ciągi znakowe
- Wypisywanie na standardowe wyjście
- Wykresy
- 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:
LinearAlgebra
,Random
GLMakie
CairoMakie
.
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:
Control+Enter
- wykonaj bieżącą linie kodu,Shift+Enter
- wykonaj bieżąco linie kodu i przejdź do następnej,Alt+Enter
- wykonaj cały plik.
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ć:
Control+Enter
- wykonaj bieżącą linie kodu,Shift+Enter
- wykonaj bieżąco linie kodu i przejdź do następnej,Alt+Enter
- wykonaj bieżącą komórkę kodu,Alt+Shift+Enter
- wykonaj bieżącą komórkę kodu i przejdź do następnej.
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.
Alternatywne kursy Julia
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.
Lista | słów | zarezerwowanych | przez | język | Julia |
---|---|---|---|---|---|
baremodule | begin | break | catch | const | continue |
do | else | elseif | end | export | false |
finally | for | function | global | if | import |
let | local | macro | module | quote | return |
struct | true | try | using | while |
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:
jakie są dostępne typy reprezentujące dany rodzaj liczby,
sposób reprezentacji binarnej liczb przez dany typ i ograniczenia wynikające z tego sposobu,
operacje które można wykonywać na wartościach danego typu.
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ą:
Typ
Int
(a właściweInt64
) reprezentujący liczbę całkowitą w kodzie uzupełnień do dwóch (U2). Przykłady literałów reprezentujących wartości typuInt64
:1
,-2
,10000
,-10_000
.
Typ
Float64
reprezentujący liczbę zmiennoprzecinkową podwójnej precyzji w standardzie IEEE 754. Przykłady literałów reprezentujących wartości typuFloat64
:1.0
,-2.0
,1e-3
,-1e+6
.
Typ
ComplexF64
reprezentujący liczbę zespoloną z wykorzystaniem dwóch liczb zmiennoprzecinkową podwójnej precyzjiFloat64
. W języku Julia nie istnieje literał, który będzie reprezentował wartośćComplex
, dlatego w tym przypadku trzeba zbudować odpowiednie wyrażenie. Przykłady wyrażeń generujących wartości typuComplexF64
:2.0 + im*3.5
,-7.0 * exp(-im*pi/9)
.
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żenie | Operacja | Opis |
---|---|---|
x + y | dodawanie | dodanie x do y |
x - y | odejmowanie | odjęcie y od x |
x * y | mnożenie | przemonżenie x przez y |
x / y | dzielenie | dzielenie x przez y |
x ÷ y | dzielenie bez reszty | równoważne z funckja div(x,y) |
x ^ y | potęgowanie | podnieś x do potęgi y |
x % y | reszta z dzielenia | równoważne z funckją rem(x,y) |
Przydatne funkcje dla liczb zespolonych:
abs(z)
- moduł liczby zespolonejz
,angle(z)
- argument (faza) liczby zespolonejz
,real(z)
- część rzeczywista liczby zespolonejz
,imag(z)
- część urojona liczby zespolonejz
.
W bibliotece standardowej są zdefiniowane pewne istotne stałe numeryczne, przykładowo takie jak,
im
- liczba urojona ,im^2 == -1
,pi
,π
- liczba .
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żenie | Operacja |
---|---|
!x | negacja |
x && y | koniunkcja |
x || y | alternatywa |
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żenie | Opis |
---|---|
x == y | prawda jeżeli x jest równe y . |
x != y | prawda jeżeli x jest różne od y . |
x < y | prawda jeżeli x jest mniejsze niż y . |
x <= y | prawda jeżeli x jest mniejsze niż lub równe y . |
x > y | prawda jeżeli x jest większe niż y . |
x >= y | prawda 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:
- wektor wyjściowy (do którego zapiszemy wynik),
- macierz przekształcenia liniowego,
- wektora wejściowy,
a samą operację możemy opisać następującym wzorem:
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 i losowych wartościach w macierzy i wektorze . 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:
funkcja
gmvm
(Python) - 94.609 ms,funkcja
cgmvm
(C) - 4.552 ms,funkcja
gmvm!
(Julia) - 1.491 ms.
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
.