Документация Engee

Руководство по стилю

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

Отступ

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

Пишите функции, а не только скрипты

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

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

Избегайте написания слишком специфических типов

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

Complex{Float64}(x)

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

complex(float(x))

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

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

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

addone(x::Int) = x + 1                 # Работает только для целочисленного типа
addone(x::Integer) = x + oneunit(x)    # Любой целочисленный тип
addone(x::Number) = x + oneunit(x)     # Любой числовой тип
addone(x) = x + oneunit(x)             # Любой тип, поддерживающий + и единицу

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

Обрабатывайте избыточное разнообразие аргументов на вызывающей стороне

Вместо:

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).

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

Добавляйте ! к именам функций, которые изменяют свои аргументы

Вместо:

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

используйте:

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

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

Избегайте странных объединений типов

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

Избегайте сложных типов контейнеров

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

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

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

Используйте экспортированные методы вместо прямого доступа к полю

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

  • Разработчики пакетов могут более свободно изменять реализацию, не нарушая код пользователя.

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

  • Методы можно определять на основе абстрактных типов.

  • Методы могут описывать концептуальную операцию, которая может быть общей для различных типов (например, real(z) работает с комплексными числами или кватернионами).

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

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

Вот контрпримеры этого правила: NamedTuple, RegexMatch, StatStruct.

Используйте соглашения об именовании, согласованные с Julia base/

  • В именах модулей и типов используются прописные буквы и CamelCase. module SparseArrays, struct UnitRange.

  • Для функций используется нижний регистр (maximum, convert) и, когда они удобочитаемы, несколько слов в их названии пишутся слитно (isequal, haskey). При необходимости используйте символы подчеркивания в качестве разделителей слов. Символы подчеркивания также используются для обозначения сочетания понятий (remotecall_fetch как более эффективная реализация fetch(remotecall(...))) или в качестве модификаторов.

  • Функции, изменяющие хотя бы один из своих аргументов, заканчиваются на !.

  • Краткость ценится, однако избегайте сокращений (indexin, а не indxin), так как становится трудно запомнить, сокращаются ли конкретные слова и, если да, как именно.

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

Пишите функции с порядком следования аргументов, подобным 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. Переменное количество аргументов. Это относится к аргументам, которые могут бесконечно перечисляться в конце вызова функции. Например, в Matrix{T}(undef, dims) измерения могут быть указаны как кортеж (Tuple), например Matrix{T}(undef, (1,2)), или как переменное количество аргументов (Vararg), например Matrix{T}(undef, 1, 2).

  10. Именованные аргументы. В Julia именованные аргументы в любом случае должны стоять на последнем месте в определениях функций. Здесь они приведены для полноты картины.

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

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

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

Не злоупотребляйте обработкой исключений

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

Не заключайте условия в скобки

В Julia не требуется заключать в скобки условия в if и while. Напишите:

if a == b

вместо:

if (a == b)

Не злоупотребляйте использованием ...

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

Не используйте ненужные статические параметры

Сигнатура функции:

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

должна быть написана в следующем виде:

foo(x::Real) = ...

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

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

Избегайте путаницы по поводу того, является ли что-то экземпляром или типом

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

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

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

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

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

Не злоупотребляйте использованием макросов

Помните о случаях, когда макрос на самом деле может быть функцией.

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

Не используйте небезопасные операции на уровне интерфейса

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

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

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

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

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

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

Не перегружайте методы базовых типов контейнеров

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

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

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

Избегайте "пиратства типов"

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

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

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

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

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

Будьте осторожны с равенством типов

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

Не пишите функции типа x->f(x)

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

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

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

Например,

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} для литеральных нецелых чисел, чтобы упростить использование кода.