ComPhy

Мова Програмування Julia

[стаття для тих, хто вже має трохи досвіду]

Julia – це динамічна мова програмування загального призначення з убудованою підтримкою паралельних та наукових обчислень. Синтаксично не складніше за Python, за швидкістю як C, ідейно логічна. Її перевага, окрім швидкості, в тому, що код на Julia виглядає математично, а писати нею просто приємно. Ідеальна мова для обчислювальних експериментів, тому їй і варто приділити увагу. До того ж, вона вміє дуже просто взаємодіяти з Python, C та Fortran.

Перші кроки

Найпростіший варіянт установити Julia – це завантажити редактор Atom (під будь-яку ОС) та додаток Juno до нього*. Власне, сам редактор завантажується елементарно з сайту, а процес установлення Juno маю на меті показати. Альтернативно, ви можете завантажити Julia з офіційного сайту і користуватися іншими редакторами коду замість Atom. У цілому, встановити Julia перед встановленням Juno – теж непогана ідея.

* – На жаль, було повідомлено про скасування подальших оновлень таких інструментів як Atom і Juno, але це все ще гарний спосіб установити мову Julia й працювати з нею. Я оновлю цю сторінку, коли буде альтернатива, або коли сформуються оновлювані копії цих програм від користувачів.

У середовищі редактора Atom натискаємо Ctrl+, (контроль+кома), далі у відритій вкладці налаштувань:

screenshot

Далі в рядку пошуку друкуємо "Juno" й завантажуємо "uber-juno":

screenshot

Чекаємо, поки все завантажиться й перезапускаємо Atom. Інтерфейс редактору має трохи змінитись (див. скріншот нижче)

screenshot

1) REPL – інтерактивний консольний режим. Можна вводити невеличкі фрагменти коду по одному, вони будуть запускатись і обчислюватись тут. Фактично, використовувати можна як науковий калькулятор.
2) Каталог проєктів, відкриває папки та допомагає орієнтуватися, лівіше знаходиться додаткова панель інструментів.
3) Основне поле, де редагуються файли. При першому запуску тут може бути відкрити окрема вкладка з демонстраційною сторінкою, можете поставити ґалочку, аби вона не відкривалася надалі.
4) Додаткові вкладки (Workspace, Documentation, Plots), їх можна злити з вкладками файлів. У першій будуть з'являтися всі створені змінні, константи та функції з їх значеннями. Друга призначена для пошуку документації для вбудованих або підключених пакетів чи функцій, працює навіть офлайн. Третя – місце для відображення графіків та малюнків, створених кодом.

Для демонстрації різних варіянтів запуску коду, спочатку варто запустити Julia. Поставте курсор в REPL, натисніть клавішу "Ентер". Перший запуск може зайняти деякий час, адже деякі компоненти мають довстановитися. У цілому, є два методи запуску коду: в інтерактивному режимі та запуск коду з файлу. Для прикладу візьмемо код println("Вітаємо в Julia"), що друкує текст "Вітаємо в Julia". Альтернативно, можете спробувати рядок cos(π/2) або cos(pi/2), який власне рахує косинус від π/2 (отримаєте дуже маленьке число близьке до нуля).

Після того як ви побачите julia> в REPL можете вставити код прямо туди й натиснути ентер. Під вашим рядком введення отрмаєте відповідь і керунок перенесеться знову на рядок вводу.

        julia> println("Вітаємо в Julia")
        Вітаємо в Julia

        julia> cos(pi/2)
        6.123233995736766e-17

Для запуску коду з файлу, потрібно створити новий файл коду Джулії (Ctrl+N), такі файли зберігаються з розширеннями .jl. Після того як ви створили файл, можете вставити декілька рядків коду та зберегти його. Поставивши курсор на рядок з кодом і натиснувши Ctrl+Enter ви запустите цей рядок коремо й отримаэте результат або поруч, або в REPL. Аби запустити весь файл разом, натисніть Ctrl+Shift+Enter.

Базовий синтаксис

Перейдемо до синтаксису.

x = 10 # ціла числова змінна x, що дорівнює 10
a = "hello" # текстова змінна а, що дорівнює "hello"
с = 'ї' # символьна змінна c, що дорівнює символу 'ї' (одинарні лапки)
variable3 = 4.333 # дробова змінна variable3, що дорівнює 4.333
complex1 = 1+2im # комплексна числова змінна complex1, що дорівнює 1+2i
a = complex1 # тепер змінна а дорівнює complex1, що дорівнює 1+2i

Зверніть увагу, що будь-який текст на рядку після # або відділений на багатьох рядках так: #= текст =# абсолютно ігнорується і є коментарем.

Основні арифметичні та алгебраїчні операції позначаються в цілому природнім чином: + - * / ^ % sqrt cos sin tan

Для задання функцій є два різних синтаксиси (насправді більше, однак зосередимося на цих виразах). Перший більш математичний, але не підходить для задання складних функцій.

# ====
f(x) = x^2 - 4x + 17 # квадратична функція f(x)
G(x, y) = -y^2 - x*y + x^2 - 4x + 2 # функція G(x, y) від двох змінних

# виклик функцій
f(1)
G(2, 3.5) + G(100, 1+2im)
# ====

Задання функцій в один рядок.

# ====
function f(x)
x^2 - 4x + 17
end

function G(x, y)
return -y^2 - x*y + x^2 - 4x + 2
end

# виклик функцій
f(1)
G(2, 3.5) + G(100, 1+2im)
# ====

Кодове слово return для позначення значення, яке повертатиме функція опціональне в мові Julia. За замовчуванням, результат роботи останнього рядку повертається.

# ====
foo(x, y) = x + y # перший метод foo

# другий метод foo
function foo(x::Number)
x^2
end

# третій метод foo
function foo(str::String)
return str*" - заданий рядок"
end
# ====

Одна з потужних особливостей мови Julia це те, що можна задати одній функції декілька методів, а компілятор сам підбере необхідний при виклику функції.

В останньому прикладі ліворуч перший варіянт foo спрацює при передачі foo будь-яких двох арґументів. Другий варіянт foo спрацює, якщо передати foo якесь одне число в якості арґумента (повернеться квадрат оцього числа). Третій - якщо передати текстовий рядок (повернеться той самий рядок із текстом " - заданий рядок" укінці).

Тепер поглянемо на умовні блоки та цикли. Як можна бачити з блоку створення фукнцій function ... end, блоки коду (окремі фрагменти) мають закінчуватися на кодове слово end. Аналогічно й із блоками умов if ... end, if ... else ... end, if ... elseif ... else ... end:

# ====
if a == b # якщо a рівне b
...
elseif a == c # інакше, при a рівне c
...
else # інакше
...
end

Підблоки elseif та else - опціональні, а блоків elseif може бути скільки потрібно підряд.

Далі наведено приклади циклів - конструкцій, що повторюють блоки коду, допоки виконується певна умова.

# ====
while a < b # поки a менше b
...
a = ...# потрібно змінювати a, аби цикл не був нескінченним
end

Підблоки elseif та else - опціональні, а блоків elseif може бути скільки потрібно підряд.

# ====
for i in a:b # повторити для всіх i з проміжку від a до b
...
end

Фактично a:b формує перелік чисел від a до b з кроком 1. Можна змінити крок на довільне k a:k:b. Альтернативно, можна замінити на довільний перелік (вектор, кортеж, множина тощо). Наприклад, вектор з трьох чисел [5.3, -100, pi].

Математика й масиви

Тепер до синтаксичних та функціональних переваг мови. Вирази вищої математики тут виглядають природньо й математично (як скажімо й один зі способів задання функцій). Та спершу розберемося з векторами, матрицями й масивами загалом. Власне, в Julia існує таке поняття як масив - впорядкований за індексами набір даних. Технічно, масиви мають містити однотипні елементи, однак, якщо ви зробите масив з 0 елементів (порожній масив, тип елементів неможливо встановити) або декількох елементів несумісного типу, то компілятор підбере тип масиву автоматично як Any - будь-що. Взагалі, в Julia доволі розвинена система типізації даних, одна з найкращих серед сучасних мов, однак зараз не про неї. Нам лише потрібно знати, що для типізації масиву компілятор обирає "мінімальний" тип, який зможе описати всі елементи масиву.

        julia> arr = ["текст", 10.5] # одновимірний масив (вектор) arr типу Any
        2-element Vector{Any}:
        "текст"
        10.5

        julia> arr = [0, 10] # вектор цілочисельного типу
        2-element Vector{Int64}:
        0
        10

        julia> arr = [0.0, 10] # вектор десятичного чисельного типу
        2-element Vector{Float64}:
        0.0
        10.0

        julia> arr = Float64[0, 10] # явно вказуємо тип, аби отримати вектор з Float64, а не з Int
        2-element Vector{Float64}:
        0.0
        10.0

        julia> arr[1] # беремо перший елемент з arr
        0.0
      

Вектор у Julia - це одновимірний масив, тобто це масив, де в кожного елементу один індекс. Матриця - двовимірний масив.

        julia> A = [1. 2
                    2  1]
        2×2 Matrix{Float64}:
        1.0  2.0
        2.0  1.0

        julia> A = [1. 2; 2 1] # альтернативний спосіб
        2×2 Matrix{Float64}:
        1.0  2.0
        2.0  1.0

        julia> A[1, 1] # беремо перший елемент з A
        1.0
      

Julia має вбудоване множення векторів та матриць, ба більше, загалом багато вбудованих оперцій з лінійної алгебри:

          julia> A*arr # множення на вектор arr
          2-element Vector{Float64}:
          20.0
          10.0

          julia> A*[1 0 0; 0 1 0] # помножимо A на якусь іншу неквадратну матрицю
2×3 Matrix{Float64}: 1.0 2.0 0.0 2.0 1.0 0.0 julia> A' # транспонуємо (але матриця симетрична) 2×2 adjoint(::Matrix{Float64}) with eltype Float64: 1.0 2.0 2.0 1.0 julia> A'A # транспонуємо й помножимо на саму A 2×2 Matrix{Float64}: 5.0 4.0 4.0 5.0 julia> 2A # або A*2 - помножимо на константу 2 2×2 Matrix{Float64}: 2.0 4.0 4.0 2.0 julia> A^2 # A у квадраті (сама на себе) 2×2 Matrix{Float64}: 5.0 4.0 4.0 5.0 julia> A^0.5 # корінь квадратний з A (комплексна матриця) 2×2 LinearAlgebra.Symmetric{ComplexF64, Matrix{ComplexF64}}: 0.866025+0.5im 0.866025-0.5im 0.866025-0.5im 0.866025+0.5im

Як бачимо з останнього обчислення \(\sqrt{A}\) в Джулії передбачені навіть такі високоматематичні операції як взяття матричного кореня. Так само тут передбачені й взяття матричних синусу, косинусу, експоненти й так далі. До речі, можна помітити, що такі складні математичні операції займають певний час для виконання в середовищі. Це факт, однак 99% цього часу - компіляція функцій. Тобто створення самої функції як такої в машинному коді, до цього Джулія знає лише про сам факт існування таких методів. Після компіляції (що для Julia еквівалентно першому виклику функції) всі функції працюють миттєво (спробуйте повторити дію й результат отримаєте набагато швидше, навіть трохи змінивши вхідні дані). Більше про це нижче в "Компіляція та швидкість".

         julia> julia> sin(A)
          2×2 Matrix{Float64}:
           -0.350175   0.491295
            0.491295  -0.350175

          julia> cos(A)
          2×2 Matrix{Float64}:
           -0.224845  -0.765147
           -0.765147  -0.224845

          julia> exp(A)
          2×2 Matrix{Float64}:
           10.2267    9.85883
            9.85883  10.2267
      

А що як нам потрібно піднести до степеня чи порахувати якусь функцію не від усієї матриці, а від кожного її елемента, поелементно? Це робиться дуже просто за допомогою крапки. Аналогічно це працює, до речі, не тільки для матриць, а й для масивів будь-якого вигляду.

        julia> A.^2 # всі елементи A у квадраті 
        2×2 Matrix{Float64}:
         1.0  4.0
         4.0  1.0

        julia> sin.(A) # синус від усіх елементів A
        2×2 Matrix{Float64}:
         0.841471  0.909297
         0.909297  0.841471

        julia> p(x) = x^2 - 2x + 1 # задаємо довліьну функцію p(x)
        p (generic function with 1 method)

        julia> p.(A) # запускаємо p(x) на всіх елементах A
        2×2 Matrix{Float64}:
         0.0  1.0
         1.0  0.0

        julia> A .+ 1 == A .+ [1 1; 1 1] # поелементне додавання числа й поелементне додавання іншої матриці такого ж розміру (рівні в даному випадку)
        true

        julia> 2arr == arr .* 2 # еквівалентні множення вектора на скаляр та поелементне множення масиву
        true
      

Для інших речей з лінійної алгебри, на кшталт оператора ідентичности, розкладів матриць тощо, потрібно підключити відповідну бібліотеку.

         julia> using LinearAlgebra

         julia> A*I
          2×2 Matrix{Float64}:
           1.0  2.0
           2.0  1.0

          julia> svd(A) # singular value decomposition (розклад матриці A)
          SVD{Float64, Float64, Matrix{Float64}}
          U factor:
          2×2 Matrix{Float64}:
           -0.707107  -0.707107
           -0.707107   0.707107
          singular values:
          2-element Vector{Float64}:
           2.9999999999999996
           1.0
          Vt factor:
          2×2 Matrix{Float64}:
           -0.707107  -0.707107
            0.707107  -0.707107
      

Графіки та бібліотеки

Раз уже ми говоримо про якісь бібліотеки чи пакети (тобто додатки до самої Джулії), то варто показати, як встановлюються додаткові пакети. Зазвичай це робиться двома рядками коду в самому середовищі Джулії.

        julia> import Pkg; Pkg.add("ІМ'Я ПАКЕТУ")

        julia> import Pkg; Pkg.add("Plots") # наприклад установимо Plots
      

Надалі, після встановлення, можна просто писати в програмах using Plots та користуватися тим, що додає цей пакет. А конкретно, Plots дозволяє малювати графіки й діяграми. Наприклад, створіть файл з наступним кодом (раджу запускати його порядково, аби зрозуміти що відбувається з кожним кроком)

        using Plots # підключаємо Plots

        xs = -4:0.1:4 # робимо перелік чисел від -4 до 4 з кроком 0.1
        cosx = cos.(xs) # рахуємо від усіх чисел переліку xs косинус та зберігаємо в cosx
        sinx = sin.(xs) # рахуємо від усіх чисел переліку xs синус та зберігаємо в sinx

        plot(xs, cosx, label="cos(x)") # малюємо графік косинуса, на легенді діяграми пишемо "cos(x)"
        plot!(xs, sinx, label="sin(x)") # додаємо графік синуса на ту саму діяграму, на легенді діяграми пишемо "sin(x)"
      

plot - функція, що приймає два одновимірних масиви однакового розміру, які задають набір точок графіку (ікси та ігреки, умовно). додатково можна передати ім'я функції для позначення. Для того аби додати графік на попередній, а не створювати абсолютно нову картинку, використовується plot! (у цілому, знаком оклику позначаються функції, що можуть змінювати значення зовні себе).

Насправді, в бібліотеці Plots суть купа інших речей. З її допомогою можна відмалювати майже будь-що.

Компіляція та швидкість

Джулія – JIT-компільована мова (Just In Time Compilation - вчасна компіляція). У поєднанні з її парадигмою множинної диспетчеризації або мультиметоду (multiple dispatch, про це вже йшлося в особливостях синтаксису задання функцій) це призводить до цікавих особливостей та швидкісних переваг над, наприклад, Python. Як тільки ми захочемо використати певну функцію чи її метод (варіянт), компілятор скомпілює її, й подальші використання цієї функції такого вигляду будуть миттєвими. До того як ми захочемо використовувати таку функцію й уперше викличемо її, навіть якщо ми задали її в коді явно, скомпільованою (тобто переведеною в машинний код та готовою до швидкого використання програмно) вона не буде.

Задавши функцію та не вказавши чітко тип значень, які вона приймає, наприклад так f(x, y) = x * y, а не так f(x::Int64, y::Int64)::Int64 = x * y, ми зможемо використовувати її автоматично й ефективно на будь-яких данних, адже компілятор буде компілювати для всіх типів даних новий сумісний варіянт f щойно нам знадобиться використати її певним чином. Швидкість від відсутности типізації ми не втрачаємо, а тільки здобуваємо універсальність. Тобто, викликавши f(1, 2) ми спричинемо компіляцію чогось типу f(x::Int64, y::Int64)::Int64 = x * y для цілих чисел, а наступні виклики, скажімо, f(400, -19) чи f(0, 0) будуть працювати швидко. Можемо викликати f("hello", "world"), спричинивши компіляцію альтернативи f(x::String, y::String)::String = x * y, яка буде склеювати два рядки в один (множення двох рядків у Джулії робить саме це).

        julia> f(x, y) = x * y # задаємо загальну функцію
        f (generic function with 1 method)

        julia> f(1, 2) # спричиняємо компіляцію варіянту f(x, y) для цілих чисел
        2

        julia> f(-1, 20) # запускаться вже скомпільований варіянт
        -20

        julia> f("hello", "world") # аналогічно спричиняємо компіляцію варіянту f(x, y) для рядків
        "helloworld"

        julia> f("a", "b")
        "ab"
      

Щоправда, тут наша функція занадто проста, щоб ми відчули час компіляції та пришвидшення коду. Тому пропоную спробувати ще раз із складнішою функцією: матричний синус.

        julia> A = [1. 0; 0 3]; B = [2. 7; -1 4]; # задаємо якісь різні матриці одного типу

        julia> sin(A) # відчутно довга компіляція доволі складного різновиду функції sin
        2×2 Matrix{Float64}:
         0.841471  0.0
         0.0       0.14112

        julia> sin(B) # неймовірно швидкий запуск sin на іншій матриці схожого типу
        2×2 Matrix{Float64}:
         3.1465   -16.262
         2.32315   -1.4998
      

Можна навіть подивитися на окремі скомпільовані методи функцій за допомогою спеціяльних макросів (вважайте що макроси – це просто спеціяльні слова мови Джулія, що починаються з @): @code_native – переглянути машинний код функції; @code_llvm – переглянути LLVM код функції (передує машинному в компіляції, тобто більш абстрактний та читабельний код); @code_typed, @code_lowered – іще більш високорівневий та читабельний формат коду. Детальніше англійською тут.

      julia> @code_native f(1,2) # подивитися код скомпільований для цілих чисел
      julia> @code_native f("a", "b") # подивитися код скомпільований для рядків
      

Важливо: код, що не загорнутий у функції в Julia не компілюється! Це означає, що він виконуватиметься кожного разу за не дуже швидкий час, там не можна строго типізувати змінні, а важкий і повторюваний код буде суттєво вповільнувати програму. Тому все, що можна згрупувати до функцій; усе, що повторюється, варто загортати до функцій та викликати.

До речі, ще один корисний макрос (так, нам тут доводиться розглядати декілька часткових випадків макросів, не кажучи про їх загалом) – @inbounds. Перед поясненням, що саме він робить варто пояснити ще дещо. У високорівневих мовах (як-от Python чи Julia) вирази індексації типу a[i] ховають під собою не просто взяття елементу за індексом, а ще й перевірку чи існує цей індекс у масиві взагалі. Це, звісно, корисно, щоб не натрапити на страшну помилку, однак така перевірка забирає відносно багато часу, особливо, коли таких "взяттів за індексом" у нас дуже багато. В мовах як С чи С++ такої перевірки немає. Julia всюди хоче наздоганяти С за швидкістю, саме тому для цього існує @inbounds, що вимикає такі перевірки. Використовувати його бажано тільки, коли точно знаємо, що наш індекс не "вилетить за межі масиву". Ставиться цей макрос перед циклом for, що пробігається по індексу:

        @inbounds for i in 1:length(a) # швидко пройдемо всі елементи масиву а 
          println(a[i])
        end
      

Щоправда, конкретний код вище багато сенсу, окрім демонстрації синтаксису не має, адже в даному випадку можна просто зробити наступний аналогічний цикл:

        for el in a
          println(el)
        end
      

Можна багато чого сказати про ефективність коду Джулії, однак це виходить за межі оглядової статті. Якщо казати про загальні зауваження щодо ефективного коду, що не залежать від мови програмування, то раджу звернутися до теорії, порад, та й загалом статті про комп'ютерні науки англійською.

website by Atell Krasnopolski