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