Style Guide

Следующие разделы объясняют несколько аспектов идиоматического стиля кодирования на Julia. Ни одно из этих правил не является абсолютным; это всего лишь рекомендации, которые помогут вам ознакомиться с языком и выбрать среди альтернативных дизайнов.

Indentation

Используйте 4 пробела на уровень отступа.

Write functions, not just scripts

Написание кода в виде последовательности шагов на верхнем уровне — это быстрый способ начать решать проблему, но вы должны постараться разделить программу на функции как можно скорее. Функции более переиспользуемы и тестируемы, а также проясняют, какие шаги выполняются и каковы их входные и выходные данные. Более того, код внутри функций, как правило, выполняется гораздо быстрее, чем код на верхнем уровне, из-за особенностей работы компилятора Julia.

Также стоит подчеркнуть, что функции должны принимать аргументы, а не работать напрямую с глобальными переменными (за исключением констант, таких как pi).

Avoid writing overly-specific types

Код должен быть как можно более универсальным. Вместо того, чтобы писать:

Complex{Float64}(x)

лучше использовать доступные универсальные функции:

complex(float(x))

Вторая версия будет преобразовывать x в соответствующий тип, вместо того чтобы всегда использовать один и тот же тип.

Этот стиль особенно актуален для аргументов функций. Например, не объявляйте аргумент как тип Int или Int32, если на самом деле он может быть любым целым числом, представленным абстрактным типом Integer. На самом деле, во многих случаях вы можете вовсе опустить тип аргумента, если он не нужен для устранения неоднозначности с другими определениями методов, так как MethodError будет выброшено в любом случае, если передан тип, который не поддерживает ни одной из необходимых операций. (Это известно как duck typing.)

Например, рассмотрим следующие определения функции addone, которая возвращает один плюс свой аргумент:

addone(x::Int) = x + 1                 # works only for Int
addone(x::Integer) = x + oneunit(x)    # any integer type
addone(x::Number) = x + oneunit(x)     # any numeric type
addone(x) = x + oneunit(x)             # any type supporting + and oneunit

Последнее определение addone обрабатывает любой тип, поддерживающий oneunit (который возвращает 1 того же типа, что и x, что избегает нежелательной промоции типов) и функцию + с этими аргументами. Ключевое, что нужно понять, это то, что нет штрафа по производительности за определение только общего addone(x) = x + oneunit(x), потому что Julia автоматически скомпилирует специализированные версии по мере необходимости. Например, в первый раз, когда вы вызываете addone(12), Julia автоматически скомпилирует специализированную функцию addone для аргументов x::Int, при этом вызов oneunit будет заменен его встроенным значением 1. Таким образом, первые три определения addone выше полностью избыточны по сравнению с четвертым определением.

Handle excess argument diversity in the caller

Вместо:

function foo(x, y)
    x = Int(x); y = Int(y)
    ...
end
foo(x, y)

использовать:

function foo(x::Int, y::Int)
    ...
end
foo(Int(x), Int(y))

Это лучший стиль, потому что foo на самом деле не принимает числа всех типов; ему действительно нужны Int.

Одна из проблем заключается в том, что если функция по своей сути требует целых чисел, возможно, лучше заставить вызывающего решить, как нецелые числа должны быть преобразованы (например, округление вниз или вверх). Еще одна проблема заключается в том, что объявление более специфичных типов оставляет больше "пространства" для будущих определений методов.

Append ! to names of functions that modify their arguments

Вместо:

function double(a::AbstractArray{<:Number})
    for i in eachindex(a)
        a[i] *= 2
    end
    return a
end

использовать:

function double!(a::AbstractArray{<:Number})
    for i in eachindex(a)
        a[i] *= 2
    end
    return a
end

Julia Base использует эту конвенцию на протяжении всего и содержит примеры функций как с копированием, так и с модификацией (например, sort и sort!), а также другие, которые только модифицируют (например, push!, pop!, splice!). Обычно такие функции также возвращают модифицированный массив для удобства.

Функции, связанные с вводом-выводом или использующие генераторы случайных чисел (RNG), являются заметными исключениями: поскольку эти функции почти неизбежно должны изменять ввод-вывод или RNG, функции, заканчивающиеся на !, используются для обозначения мутации в отличие от мутации ввода-вывода или продвижения состояния RNG. Например, rand(x) изменяет RNG, в то время как rand!(x) изменяет как RNG, так и x; аналогично, read(io) изменяет io, в то время как read!(io, x) изменяет оба аргумента.

Avoid strange type Unions

Типы, такие как Union{Function,AbstractString}, часто указывают на то, что некоторый дизайн мог бы быть более чистым.

Avoid elaborate container types

Обычно не очень полезно создавать массивы, подобные следующим:

a = Vector{Union{Int,AbstractString,Tuple,Array}}(undef, n)

В этом случае Vector{Any}(undef, n) лучше. Также для компилятора более полезно аннотировать конкретные использования (например, a[i]::Int), чем пытаться упаковать множество альтернатив в один тип.

Prefer exported methods over direct field access

Идиоматический код на Julia обычно рассматривает экспортированные методы модуля как интерфейс для его типов. Поля объекта обычно считаются деталями реализации, и пользовательский код должен обращаться к ним напрямую только в том случае, если это указано в API. Это имеет несколько преимуществ:

  • Разработчики пакетов имеют больше свободы в изменении реализации, не нарушая код пользователей.
  • Методы могут быть переданы в конструкции высшего порядка, такие как map (например, map(imag, zs)), а не [z.im for z in zs].
  • Методы могут быть определены для абстрактных типов.
  • Методы могут описывать концептуальную операцию, которая может быть общей для различных типов (например, real(z) работает с комплексными числами или кватернионами).

Система диспетчеризации Julia поощряет этот стиль, потому что play(x::MyType) определяет метод play только для этого конкретного типа, оставляя другим типам возможность иметь свою собственную реализацию.

Аналогично, неэкспортируемые функции обычно являются внутренними и подлежат изменениям, если в документации не указано иное. Имена иногда получают префикс (или суффикс) _, чтобы дополнительно указать на то, что что-то является "внутренним" или деталью реализации, но это не правило.

Примеры противоречий этому правилу включают NamedTuple, RegexMatch, StatStruct.

Use naming conventions consistent with Julia base/

  • модули и имена типов используют заглавные буквы и camel case: module SparseArrays, struct UnitRange.
  • функции пишутся строчными буквами (maximum, convert) и, когда это возможно, с несколькими словами, слитыми вместе (isequal, haskey). При необходимости используйте подчеркивания в качестве разделителей слов. Подчеркивания также используются для обозначения комбинации концепций (remotecall_fetch как более эффективная реализация fetch(remotecall(...))) или в качестве модификаторов.
  • функции, изменяющие по крайней мере один из своих аргументов, заканчиваются на !.
  • консистентность ценится, но избегайте сокращений (indexin, а не indxin), так как это затрудняет запоминание того, сокращены ли конкретные слова и как именно.

Если имя функции требует нескольких слов, подумайте, может ли оно представлять более одной концепции и может быть лучше разделено на части.

Write functions with argument ordering similar to Julia Base

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

  1. Аргумент функции. Помещение аргумента функции на первое место позволяет использовать do блоки для передачи многострочных анонимных функций.
  2. Поток ввода/вывода. Указание объекта IO в первую очередь позволяет передавать функцию таким функциям, как sprint, например, sprint(show, x).
  3. Входные данные, которые изменяются. Например, в fill!(x, v), x является объектом, который изменяется, и он появляется перед значением, которое будет вставлено в x.
  4. Тип. Передача типа обычно означает, что выходные данные будут иметь данный тип. В parse(Int, "1") тип идет перед строкой для разбора. Существует много таких примеров, где тип появляется первым, но полезно отметить, что в read(io, String) аргумент IO появляется перед типом, что соответствует порядку, изложенному здесь.
  5. Входные данные не изменяются. В fill!(x, v), v не изменяется и идет после x.
  6. Ключ. Для ассоциативных коллекций это ключ пары ключ-значение. Для других индексированных коллекций это индекс.
  7. Значение. Для ассоциативных коллекций это значение пары ключ-значение(й). В таких случаях, как fill!(x, v), это v.
  8. Все остальное. Любые другие аргументы.
  9. Varargs. Это относится к аргументам, которые могут быть перечислены бесконечно в конце вызова функции. Например, в Matrix{T}(undef, dims) размеры могут быть указаны как Tuple, например, Matrix{T}(undef, (1,2)), или как Vararg, например, Matrix{T}(undef, 1, 2).
  10. Именованные аргументы. В Julia именованные аргументы должны идти последними в определениях функций; они перечислены здесь для полноты.

Большинство функций не будут принимать каждый вид аргумента, перечисленный выше; числа просто обозначают приоритет, который следует использовать для любых применимых аргументов функции.

Конечно, есть несколько исключений. Например, в convert тип всегда должен идти первым. В setindex! значение идет перед индексами, чтобы индексы могли быть предоставлены как varargs.

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

Don't overuse try-catch

Лучше избегать ошибок, чем полагаться на их ловлю.

Don't parenthesize conditions

Джулия не требует скобок вокруг условий в if и while. Напишите:

if a == b

вместо:

if (a == b)

Don't overuse ...

Сплайсинг аргументов функции может быть затягивающим. Вместо [a..., b...] используйте просто [a; b], который уже объединяет массивы. collect(a) лучше, чем [a...], но поскольку a уже является итерируемым, часто даже лучше оставить его как есть и не преобразовывать в массив.

Ensure constructors return an instance of their own type

Когда метод T(x) вызывается для типа T, обычно ожидается, что он вернет значение типа T. Определение constructor, который возвращает неожидаемый тип, может привести к запутанному и непредсказуемому поведению:

julia> struct Foo{T}
           x::T
       end

julia> Base.Float64(foo::Foo) = Foo(Float64(foo.x))  # Do not define methods like this

julia> Float64(Foo(3))  # Should return `Float64`
Foo{Float64}(3.0)

julia> Foo{Int}(x) = Foo{Float64}(x)  # Do not define methods like this

julia> Foo{Int}(3)  # Should return `Foo{Int}`
Foo{Float64}(3.0)

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

Don't use unnecessary static parameters

Подпись функции:

foo(x::T) where {T<:Real} = ...

должно быть написано как:

foo(x::Real) = ...

вместо этого, особенно если T не используется в теле функции. Даже если T используется, его можно заменить на typeof(x), если это удобно. Разницы в производительности нет. Обратите внимание, что это не общее предостережение против статических параметров, а только против случаев, когда они не нужны.

Обратите внимание, что типы контейнеров, в частности, могут требовать параметры типа в вызовах функций. См. FAQ Avoid fields with abstract containers для получения дополнительной информации.

Avoid confusion about whether something is an instance or a type

Наборы определений, подобные следующим, вызывают путаницу:

foo(::Type{MyType}) = ...
foo(::MyType) = foo(MyType)

Решите, будет ли концепция в вопросе написана как MyType или MyType(), и придерживайтесь этого.

Предпочтительный стиль заключается в том, чтобы по умолчанию использовать экземпляры, и только позже добавлять методы, касающиеся Type{MyType}, если они станут необходимыми для решения некоторых задач.

Если тип фактически является перечислением, его следует определить как единственный (желательно неизменяемый) тип структуры или примитивный тип, при этом значения перечисления являются его экземплярами. Конструкторы и преобразования могут проверять, являются ли значения допустимыми. Этот подход предпочтителен по сравнению с тем, чтобы делать перечисление абстрактным типом, а "значения" — подтипами.

Don't overuse macros

Будьте внимательны к тому, когда макрос на самом деле может быть функцией.

Вызов eval внутри макроса является особенно опасным предупреждающим знаком; это означает, что макрос будет работать только при вызове на верхнем уровне. Если такой макрос будет написан как функция, он естественно получит доступ к необходимым значениям времени выполнения.

Don't expose unsafe operations at the interface level

Если у вас есть тип, который использует нативный указатель:

mutable struct NativeType
    p::Ptr{UInt8}
    ...
end

не пишите определения, подобные следующим:

getindex(x::NativeType, i) = unsafe_load(x.p, i)

Проблема в том, что пользователи этого типа могут писать x[i], не осознавая, что операция небезопасна, и затем подвержены ошибкам памяти.

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

Don't overload methods of base container types

Возможно написать определения, подобные следующим:

show(io::IO, v::Vector{MyType}) = ...

Это обеспечит пользовательское отображение векторов с конкретным новым типом элемента. Хотя это и заманчиво, этого следует избегать. Проблема в том, что пользователи будут ожидать, что известный тип, такой как Vector(), будет вести себя определенным образом, и чрезмерная настройка его поведения может усложнить работу с ним.

Avoid type piracy

"Пиратство типов" относится к практике расширения или переопределения методов в Base или других пакетах для типов, которые вы не определили. В крайних случаях вы можете вызвать сбой в Julia (например, если ваше расширение метода или переопределение приводит к передаче недопустимого ввода в ccall). Пиратство типов может усложнить рассуждения о коде и может привести к несовместимостям, которые трудно предсказать и диагностировать.

В качестве примера, предположим, что вы хотите определить умножение на символы в модуле:

module A
import Base.*
*(x::Symbol, y::Symbol) = Symbol(x,y)
end

Проблема в том, что теперь любой другой модуль, который использует Base.*, также увидит это определение. Поскольку Symbol определен в Base и используется другими модулями, это может неожиданно изменить поведение несвязанного кода. Здесь есть несколько альтернатив, включая использование другого имени функции или оборачивание Symbol в другой тип, который вы определяете.

Иногда связанные пакеты могут заниматься пиратством типов, чтобы отделить функции от определений, особенно когда пакеты были разработаны совместно авторами и когда определения могут быть повторно использованы. Например, один пакет может предоставлять некоторые типы, полезные для работы с цветами; другой пакет может определять методы для этих типов, которые позволяют выполнять преобразования между цветовыми пространствами. Другим примером может быть пакет, который действует как тонкая обертка для некоторого кода на C, который другой пакет может затем использовать для реализации более высокого уровня, удобного для Julia API.

Be careful with type equality

Вы обычно хотите использовать isa и <: для тестирования типов, а не ==. Проверка типов на точное равенство обычно имеет смысл только при сравнении с известным конкретным типом (например, T == Float64), или если вы действительно, действительно знаете, что делаете.

Don't write a trivial anonymous function x->f(x) for a named function f

Поскольку функции высшего порядка часто вызываются с анонимными функциями, легко сделать вывод, что это желательно или даже необходимо. Но любую функцию можно передать напрямую, не "оборачивая" её в анонимную функцию. Вместо того чтобы писать map(x->f(x), a), напишите map(f, a).

Avoid using floats for numeric literals in generic code when possible

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

Например,

julia> f(x) = 2.0 * x
f (generic function with 1 method)

julia> f(1//2)
1.0

julia> f(1/2)
1.0

julia> f(1)
2.0

пока

julia> g(x) = 2 * x
g (generic function with 1 method)

julia> g(1//2)
1//1

julia> g(1/2)
1.0

julia> g(1)
2

Как вы можете видеть, во второй версии, где мы использовали литерал Int, был сохранен тип входного аргумента, в то время как в первой версии этого не произошло. Это связано с тем, что, например, promote_type(Int, Float64) == Float64, и продвижение происходит с помощью умножения. Аналогично, литералы Rational менее разрушительны для типа, чем литералы Float64, но более разрушительны, чем Int:

julia> h(x) = 2//1 * x
h (generic function with 1 method)

julia> h(1//2)
1//1

julia> h(1/2)
1.0

julia> h(1)
2//1

Таким образом, используйте Int литералы, когда это возможно, с Rational{Int} для литералов нецелых чисел, чтобы упростить использование вашего кода.