Синтаксические вкусности Julia

—

от автора

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

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

ссылка на оригинал статьи https://habr.com/ru/post/502428/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *