Руководство по стилю
В следующих разделах объясняются некоторые аспекты стилистических рекомендаций для кодирования на языке Julia. Ни одно из этих правил не является абсолютным; они представляют собой лишь рекомендации, призванные помочь вам познакомиться с языком и выбрать между альтернативными вариантами дизайна.
Отступы
Используйте 4 пробела на каждый уровень отступа.
Пишите функции, а не просто скрипты
Написание кода в виде последовательности шагов на верхнем уровне — быстрый способ начать решение проблемы, но вы должны постараться разделить программу на функции как можно скорее. Функции более многократно используемы и поддаются тестированию, и они проясняют выполняемые шаги, а также их входные и выходные данные. Кроме того, код внутри функций, как правило, выполняется намного быстрее, чем код на верхнем уровне, из-за того, как работает компилятор Julia.
Также следует подчеркнуть, что функции должны принимать аргументы вместо непосредственного использования глобальных переменных (кроме констант, таких как pi).
Избегайте использования чрезмерно специфичных типов
Код должен быть максимально обобщенным. Вместо записи:
Complex{Float64}(x)
лучше использовать имеющиеся обобщенные функции:
complex(float(x))
Второй вариант преобразует x в соответствующий тип вместо всегда одного и того же типа.
Этот аспект стиля особенно актуален для аргументов функций. Например, не объявляйте аргумент как тип Int или Int32, если он действительно может быть любым целым числом, выраженным с помощью абстрактного типа Integer. На самом деле, во многих случаях вы можете вообще опустить тип аргумента, если он не нужен для разбора различных определений методов, поскольку ошибка MethodError будет выбрасываться в любом случае, если тип, переданный в функцию, не поддерживает необходимые операции. (Это известно как типизация по поведению.)
Например, рассмотрим следующие определения функции 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 выше полностью избыточны по отношению к четвертому определению.
Обработка избыточного разнообразия аргументов у вызывающей функции
Вместо:
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.
Одна проблема здесь заключается в том, что если функция изначально требует целых чисел, то, возможно, лучше заставить вызывающую функцию решить, как преобразовать нецелые числа (например, отбрасывание дробной части или округление вверх). Другая проблема заключается в том, что объявление более специфичных типов оставляет больше «места» для будущих определений методов.
Добавление ! к именам функций, которые изменяют свои аргументы
Вместо:
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) изменяет оба аргумента.
Избегайте странных объединений типов
Типы, такие как 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 используются следующие порядки аргументов для функций, если применимо:
Аргумент функции. Размещение аргумента функции в начале позволяет использовать блоки
doдля передачи многострочных анонимных функций.Поток ввода-вывода. Указание объекта
IOвначале позволяет передавать функцию таким функциям, какsprint, напримерsprint(show, x).Мутируемый ввод. Например, в
fill!(x, v),x— это объект, который изменяется, и он предшествует значению, которое должно быть вставлено вx.Тип. Передача типа обычно означает, что вывод будет иметь заданный тип. В
parse(Int, "1")тип предшествует строке для парсинга. Существует много подобных примеров, где тип указывается первым, но полезно отметить, что вread(io, String)аргументIOпредшествует типу, что соответствует порядку, указанному здесь.Немутируемый ввод. В
fill!(x, v),vне изменяется, и он следует заx.Ключ. Для ассоциативных коллекций это ключ пары(ей) ключ-значение. Для других индексированных коллекций это индекс.
Значение. Для ассоциативных коллекций это значение пары(ей) ключ-значение. В таких случаях, как
fill!(x, v), этоv.Всё остальное. Все остальные аргументы.
Varargs. Это относится к аргументам, которые могут быть перечислены неограниченно в конце вызова функции. Например, в
Matrix{T}(undef, dims), размеры могут быть заданы какTuple, напримерMatrix{T}(undef, (1,2)), или какVararg, напримерMatrix{T}(undef, 1, 2).Аргументы ключевых слов. В Julia аргументы ключевых слов должны стоять в конце определения функции; они указаны здесь для полноты.
Большинство функций не будут принимать каждый из перечисленных типов аргументов; числа просто обозначают приоритет, который должен использоваться для любых применимых аргументов функции.
Конечно, есть несколько исключений. Например, в convert тип всегда должен быть первым. В setindex! значение предшествует индексам, чтобы индексы можно было предоставить как varargs.
При разработке API, насколько это возможно, следование этому общему порядку обеспечит пользователям ваших функций более согласованный опыт.
Не злоупотребляйте try-catch
Лучше избегать ошибок, чем полагаться на их перехват.
Не заключайте условия в скобки
Julia не требует скобок вокруг условий в if и while. Пишите:
if a == b
вместо:
if (a == b)
Не злоупотребляйте ...
Вставка аргументов функций может быть увлекательной. Вместо [a..., b...], используйте просто [a; b], которое уже склеивает массивы. collect(a) лучше, чем [a...], но поскольку a уже итерируемый, часто даже лучше оставить его без изменения и не преобразовывать в массив.
Конструкторы должны возвращать экземпляр своего собственного типа
Когда метод T(x) вызывается для типа T, обычно ожидается, что он вернёт значение типа T. Определение конструктора, возвращающего неожиданный тип, может привести к путанице и непредсказуемому поведению:
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)
Для сохранения ясности кода и обеспечения согласованности типов всегда проектируйте конструкторы таким образом, чтобы они возвращали экземпляр типа, который они должны создавать.
Не используйте ненужные статические параметры
Подпись функции:
foo(x::T) where {T<:Real} = ...
должна быть написана как:
foo(x::Real) = ...
вместо, особенно если T не используется в теле функции. Даже если T используется, его можно заменить на typeof(x), если это удобно. Разницы в производительности нет. Обратите внимание, что это не общее предостережение против статических параметров, а только против случаев, когда они не нужны.
Обратите также внимание, что типы контейнеров, в частности, могут нуждаться в параметрах типа в вызовах функций. См. часто задаваемые вопросы Избегайте полей с абстрактными контейнерами для получения дополнительной информации.
Избегайте путаницы, является ли что-то экземпляром или типом
Следующие наборы определений вызывают путаницу:
foo(::Type{MyType}) = ...
foo(::MyType) = foo(MyType)
Решите, будет ли рассматриваемый концепт записан как MyType или MyType(), и придерживайтесь этого.
Предпочтительный стиль — использовать экземпляры по умолчанию и добавлять методы, связанные с Type{MyType}, только если они необходимы для решения каких-либо проблем.
Если тип фактически является перечислением, он должен быть определён как единый (в идеале неизменяемый struct или примитив) тип, а значения перечисления должны быть экземплярами этого типа. Конструкторы и преобразования могут проверять, являются ли значения допустимыми. Этот дизайн предпочтительнее, чем создание перечисления в качестве абстрактного типа, где «значения» являются подтипами.
Не злоупотребляйте макросами
Помните, когда макрос действительно может быть функцией вместо этого.
Вызов 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, который другой пакет затем может использовать для реализации более высокого уровня API, дружественного Julia.
Будьте осторожны с равенством типов
В целом, для проверки типов вы хотите использовать isa и <:, а не ==. Проверка типов на точное равенство обычно имеет смысл только при сравнении с известным конкретным типом (например, T == Float64) или если вы действительно, действительно знаете, что делаете.
Не пишите тривиальную анонимную функцию x->f(x) для именованной функции f
Поскольку высокоуровневые функции часто вызываются с анонимными функциями, легко сделать вывод, что это желательно или даже необходимо. Но любая функция может быть передана напрямую, без «обертывания» в анонимную функцию. Вместо написания 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} для литералов нецелых чисел, чтобы упростить использование вашего кода.
© 2009–2024 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.11/manual/style-guide/