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

Инфиксные операторы — это обычные функции
Инфиксные операции, такие как +, -, <, ==, 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-блок. Это означает, что если вы любите функции высшего порядка и часто ими пользуетесь, вам нужно помнить чуть меньше об их сигнатурах — функциональный аргумент в подавляющем большинстве случаев будет первым, как и рекомендовано.
Надеюсь, статья была полезной, и вы узнали, как можно сделать код менее монотонным и лучше читаемым.
