В этой статье я хочу рассказать про вкусные и полезные синтаксические плюшки Julia, которые должны подсластить горькую долю программиста.


Поехали!


image


Инфиксные операторы — это обычные функции


Инфиксные операции, такие как +, -, <, ==, in и другие (&& и || в этот список не входят, т.к. они не являются функциями из-за "короткозамкнутой" логики), являются обычными функциями (только со специальными правилами парсинга). Это значит, эти функции можно доопределять под свои типы данных.


Например, можно легко сделать тип данных для неупорядоченной пары объектов:


struct SetOfTwo{T}
     a::T
     b::T
     function SetOfTwo(a::A, b::B) where {A,B}
        if a != b
            T = promote_type(A, B)
            return new{T}(a, b)
        else
            throw(ArgumentError("Set elements must be distinct"))
        end
    end
end

Сравнивать эти структуры данных будем без учёта порядка:


Base.in(x, s::SetOfTwo) = x == s.a || x == s.b

Base.:(==)(s1::SetOfTwo, s2::SetOfTwo) = (s1.a in s2) && (s1.b in s2)

Механизм множественной диспетчеризации даёт возможность добавлять также сравнения с объектами других типов по необходимости:


Base.:(==)(s1::SetOfTwo, s2::AbstractSet) = length(s2) == 2 && s1.a in s2 && s1.b in s2
Base.:(==)(s1::AbstractSet, s2::SetOfTwo) = s2 == s1

В дополнение к этому есть ряд символов Юникода, которые парсер будет интерпретировать как инфиксные функции, если таковые определить. Поэтому при необходимости легко добавить, например, инфиксную запись для логической импликации:


julia> ?(a::Bool, b::Bool) = b || !a
? (generic function with 1 method)

julia> struct Торт end

julia> Хабр = Торт()

julia> 2 * 2 == 4 ? Хабр isa Торт
true

Префикс ! для отрицания любой функции


Тут всё просто: для любой функции fn, возвращающей логическое значение, !fn — это функция, которая на тех же аргументах возвращает противоположные значения.


julia> filter(!ismissing, [1, missing, missing, 3, 5, 8, missing])
4-element Array{Union{Missing, Int64},1}:
 1
 3
 5
 8

Протокол итерации


В Julia нет Си-подобного цикла for с произвольной инициализацией, условием выхода и операцией при переходе между итерациями. Единственный допустимый синтаксис для цикла for — это for x in collection ... end. Такое поведение кажется не слишком гибким, но на самом деле оно подталкивает к более структурированному подходу к итерации. Дело в том, что для любого типа объектов можно определить функцию iterate(collection[, state]), а цикл for раскрывается примерно в следующее:


for x in collection
    ...
end
    ?
let iter = iterate(collection)
while !isnothing(iter)
    x, state = iter
    ...
    iter = iterate(collection, state)
end

Ожидается, что функция iterate возвращает следующий элемент и следующее состояние итерации или nothing, если коллекция исчерпана.


Прелесть итерации в том, что она используется под капотом в реализации по умолчанию для ряда функций, таких как foreach (применить некоторое действие ко всем элементам коллекции), collect (собрать коллекцию в массив), in (проверить наличие элемента в коллекции) и др. Таким образом, определив функцию итерации, бесплатно получаем кучу всякого добра. Реализация по умолчанию, впрочем, может быть не оптимальной с точки зрения эффективности — та же проверка наличия элемента линейным поиском не всегда будет именно тем, что надо.


Если очень хочется, итераторы можно компоновать, популярные компоновки представлены в Base.Iterators из стандартной библиотеки. Можно делать и более весёлые вещи — например, представить последовательные приближения, генерируемые численным методом, как итерируемую коллекцию. Если этого не хватает, можно поразвлекаться с циклами, реализованными через интерфейс трансдьюсеров.


Comprehensions


julia> [x for x in 1:3] # массив
3-element Array{Int64,1}:
  1
  2
  3

julia> Float64[x^3 for x in 1:10 if iseven(x)] # явно типизированный массив
5-element Array{Float64,1}:
    8.0
   64.0
  216.0
  512.0
 1000.0

julia> Dict(string(s) => length(s) for s in split("Это я знаю и помню прекрасно")) # словарь
Dict{String,Int64} with 6 entries:
  "я"         => 1
  "помню"     => 5
  "знаю"      => 4
  "и"         => 1
  "прекрасно" => 9
  "Это"       => 3

В общем-то, примерно как в Python. Для сложных выражений Python будет поудобнее, но всё же с comprehension'ами явно лучше, чем без них.


Сами comprehension'ы — это итерируемые объекты, поэтому по ним можно проводить редукцию без лишних выделений памяти:


julia> @btime sum([x for x in 1:10])
  38.467 ns (1 allocation: 160 bytes)
55

julia> @btime sum(x for x in 1:10)
  1.552 ns (0 allocations: 0 bytes)
55

Естественно, всё, что нужно, чтобы коллекция coll работала в выражении вроде x for x in coll, — это определить для её типа функцию iterate. Поистину магическая функция.


Распаковка коллекций в именованные аргументы


Оператор ... может распаковать коллекцию в аргументы функции:


julia> min(1, 2, 3)
1

julia> min([1, 2, 3])
ERROR: MethodError: no method matching min(::Array{Int64,1})
  ...

julia> min([1, 2, 3]...)
1

Можно подумать, что аналогично можно распаковывать и словарь для подстановки именованных аргументов. На самом деле ситуация одновременно лучше и хуже. Хуже — в том, что просто так словарь не распакуешь:


julia> range_settings = Dict(:stop => 10, :step => 3)
Dict{Symbol,Int64} with 2 entries:
  :stop => 10
  :step => 3

julia> range(1, range_settings...)
ERROR: MethodError: no method matching range(::Int64, ::Pair{Symbol,Int64}, ::Pair{Symbol,Int64})
  ...

Лучше — в том, что если всё делать правильно, то распаковывать можно отнюдь не только словари. Правильно — это поставить перед распаковываемой коллекцией точку с запятой, а не просто запятую. Тогда можно распаковать любую коллекцию, состоящую из пар символ-значение:


julia> range_settings = Dict(:stop => 10, :step => 3)
Dict{Symbol,Int64} with 2 entries:
  :stop => 10
  :step => 3

julia> range(1; range_settings...)
1:3:10

julia> range_settings = [:stop => 10, :step => 3]
2-element Array{Pair{Symbol,Int64},1}:
 :stop => 10
 :step => 3

julia> range(1; range_settings...)
1:3:10

julia> range_settings = Set(range_settings)
Set{Pair{Symbol,Int64}} with 2 elements:
  :step => 3
  :stop => 10

julia> range(1; range_settings...)
1:3:10

Рекомендуемый вариант — использовать именованный кортеж. В этом случае, в отличие от всех предыдущих, аргументы могут быть подставлены на этапе компиляции, если их значения будут известны.


julia> range_settings = (stop = 10, step = 3)
(stop = 10, step = 3)

julia> range(1; range_settings...)
1:3:10

Распаковка кортежей в аргументах


Если аргумент функции — это кортеж известной длины, — то в списке аргументов его можно записать как кортеж имён, и обращаться в теле функции к элементам кортежа по этим именам, а не по индексам. Например:


# без распаковки
function crossproduct1(p1::NTuple{3, Real}, p2::NTuple{3, Real})
    return p1[2]*p2[3] - p1[3]*p2[2], p1[3]*p2[1] - p1[1]*p2[3], p1[1]*p2[2] - p1[2]*p2[1]
end

# с распаковкой
function crossproduct2((x1, y1, z1)::NTuple{3, Real}, (x2, y2, z2)::NTuple{3, Real})
    return y1 * z2 - y2 * z1, z1 * x2 - x1 * z2, x1 * y2 - y1 * x2
end

Broadcast


Дописывание точки после любой функции превращает её в broadcasted-версию, которая к массивам применяется поэлементно, делает объединение циклов (loop fusion), автоматически приводит массивы к одинаковым рангам и т.п.


Какие-то языки умеют делать объединение циклов для частых операций (типа сложения / вычитания, умножения / деления, возведения в степень, тригонометрических операций) и для специфических типов данных. Штука в том, что broadcast позволяет его сделать для любой функции, неважно, встроенная она или определена пользователем. Единственное неудобство — программист должен явно указать, где он хочет применять "векторизованную" версию, а где обычную. На практике это не особо мешает, впрочем. Поскольку весь "векторизованный" синтаксис в конечном счёте является сахаром к выражению broadcast(f, collection), то, перегрузив broadcast под collection конкретного типа, программист получает общий механизм для удобной записи векторизованных операций.


Например, из встроенных типов можно применять broadcast к скалярам, массивам и кортежам. И, конечно, же, можно их мешать в любой комбинации.


julia> parse.(Int, ("3", "14", "15")).^(3, 2, 1)
(27, 196, 15)

julia> (x -> x / 5).(parse.(Float64, ["92", "65", "36"]))
3-element Array{Float64,1}:
 18.4
 13.0
  7.2

julia> parse.(Int, ("3", "14", "15")).^(3, 2, 1) .+ (x -> x / 5).(parse.(Float64, ["92", "65", "36"]))
3-element Array{Float64,1}:
  45.4
 209.0
  22.2

Синтаксис do


Формально: выражение внутри do-блока оборачивается в анонимную функцию и передаётся первым аргументом в функцию, которая записана перед ним. То есть запись


foo(args...) do x
    do_something
end

преобразуется к


foo(x -> do_something, args...)

Что это даёт?


Во-первых, удобную запись анонимных функций для передачи в какой-нибудь map или accumulate:


# было
map(x -> begin
            a, b, c = x[1], x[2], x[3]
            return a - (b + c) / 2, b - (a + c) / 2, c - (a + b) / 2
         end,
    [A, B, C])

# стало
map([A, B, C]) do x
    a, b, c = x[1], x[2], x[3]
    return a - (b + c) / 2, b - (a + c) / 2, c - (a + b) / 2
end

Во-вторых, легко делать аналог with ... as ... из Python. Например, стандартная функция open может принять первым аргументом функцию, в этом случае функция применяется к открытому файлу:


open(f -> println(readline(f)), "myfile.txt", "r")

# или
open("myfile.txt", "r") do io
    firstline = readline(io)
    println(firstline)
end

Благодаря множественной диспетчеризации, функция open перегружена для случая, когда первым аргументом является функция, и гарантирует, что в таком случае файл будет закрыт, когда работа с ним закончена:


function open(f::Function, args...)
    io = open(args...)
    try
        f(io)
    finally
        close(io)
    end
end

Кроме удобства написания и читаемости, этот синтаксис добавляет ещё одно соглашение по организации кода — если аргументом метода является функция, то её настоятельно рекомендуется ставить первой в списке аргументов для удобства передачи через do-блок. Это означает, что если вы любите функции высшего порядка и часто ими пользуетесь, вам нужно помнить чуть меньше об их сигнатурах — функциональный аргумент в подавляющем большинстве случаев будет первым, как и рекомендовано.


Надеюсь, статья была полезной, и вы узнали, как можно сделать код менее монотонным и лучше читаемым.