Style Guide
Write functions, not just scripts.
Writing code in a series of steps at the top level is a quick way to start solving a problem, but you should try to divide the program into functions as quickly as possible. Functions are better reusable and testable, and they also clarify which actions are performed and what their inputs and outputs are. Moreover, code inside functions tends to run much faster than top-level code, due to the way the Julia compiler works.
It is also worth emphasizing that functions should accept arguments, and not work directly with global variables (with the exception of type constants pi
).
Avoid writing too specific types.
The code should be as universal as possible. Instead of writing:
Complex{Float64}(x)
it is better to use the universal functions available.
complex(float(x))
The second version will convert x
to the appropriate type, and not always to the same type.
This style point is especially relevant for function arguments. For example, do not declare an argument as having type Int
or Int32
, if it really can be any integer expressed with an abstract type Integer
. In fact, in many cases, you may not specify the type of the argument at all, unless it is needed to distinguish it unambiguously from other definitions of the method, since in any case an error will occur. MethodError
, if a type is passed that does not support any of the required operations. (This is called https://en.wikipedia.org/wiki/Duck_typing [implicit typing].)
For example, consider the following definitions of the addone
function, which returns a unit and its argument.
addone(x::Int) = x + 1 # Работает только для целочисленного типа
addone(x::Integer) = x + oneunit(x) # Любой целочисленный тип
addone(x::Number) = x + oneunit(x) # Любой числовой тип
addone(x) = x + oneunit(x) # Любой тип, поддерживающий + и единицу
The last definition of addone' handles any type that supports `oneunit' (which returns 1 in the same type as `x
, which avoids unwanted type promotion) and the function +
with these arguments. It is important to understand that defining only the generic addone(x) = x + oneunit(x)
does not entail a reduction in performance, since Julia will automatically compile specialized versions as needed. For example, when the addone(12)
function is called for the first time, Julia will automatically compile the specialized addone
function for the x::Int
arguments, while the oneunit
function call will be replaced by the built-in value 1
. Therefore, the first three definitions of addone
given above completely duplicate the fourth definition.
Handle an excessive variety of arguments on the calling side.
Instead of:
function foo(x, y)
x = Int(x); y = Int(y)
...
end
foo(x, y)
use this one:
function foo(x::Int, y::Int)
...
end
foo(Int(x), Int(y))
This style is better suited because the foo
function doesn’t actually accept numbers of all types — it needs integer types (Int
).
One of the problems here is that if a function inherently requires integers, it may be better for the caller to decide for himself how non-integers should be converted (for example, using the floor or ceiling method). Another problem is that declaring more specific types leaves more space for future method definitions.
Add !
to the names of functions that change their arguments
Instead of:
function double(a::AbstractArray{<:Number})
for i in eachindex(a)
a[i] *= 2
end
return a
end
use this one:
function double!(a::AbstractArray{<:Number})
for i in eachindex(a)
a[i] *= 2
end
return a
end
Julia Base uses this convention everywhere and contains examples of functions that have both copying and changing forms (for example, sort
and sort!
), as well as functions that only change (for example, push!
, pop!
, splice!
). Such functions can return a modified array for convenience.
Functions related to I/O or the use of random number generators (RNG) are important exceptions: Since these functions are almost always supposed to modify the I/O or RNG data, functions ending with !
are used to indicate a change other than modification of the I/O data or advancement of the RNG state. For example, rand(x)
modifies RNG, whereas rand!(x)
modifies both RNG and x'. Similarly, `read(io)
modifies io
, whereas read!(io, x)
modifies both arguments.
Avoid complex container types
It is usually not very convenient to build arrays as follows.
a = Vector{Union{Int,AbstractString,Tuple,Array}}(undef, n)
In this case, it is better to use Vector'.{Any}(undef, n)
. In addition, it is more useful for the compiler to annotate specific use cases (for example, a[i]::Int
) than to try to pack many alternatives into one type.
Use exported methods instead of direct access to the field.
Idiomatic Julia code should usually treat the exported methods of a module as an interface to its types. Object fields are usually considered implementation details, and user code should access them directly only if it is declared as an API. This has several advantages.
-
Package developers can more freely change the implementation without breaking the user’s code.
-
Methods can be passed in higher-order constructs, such as
map
(for example,map(imag, zs)
), and not[z.im for z in zs]
. -
Methods can be defined based on abstract types.
-
Methods can describe a conceptual operation that can be common to different types (for example, 'real(z)` works with complex numbers or quaternions).
Julia’s dispatching system supports this style because play(x::MyType)
defines the 'play` method only for this particular type, leaving other types with their own implementation.
In addition, non-exported functions are usually internal and can be changed unless otherwise specified in the documentation. Names are sometimes prefixed (or suffixed) with _
to additionally indicate that some part is internal or an implementation detail, but this is not the rule.
Here are counterexamples of this rule: NamedTuple
, RegexMatch
, StatStruct
.
Use the naming conventions agreed with Julia base/
-
The names of modules and types use capital letters and camelCase: `module SparseArrays', `struct UnitRange'.
-
Lower case is used for functions (
maximum
,convert
) and, when they are readable, several words in their names are written together (isequal
,haskey
). If necessary, use underscores as word separators. Underscores are also used to indicate combinations of concepts (remotecall_fetch
as a more efficient implementation offetch(remotecall(…))+
) or as modifiers. -
Functions that change at least one of their arguments end with
!
. -
Brevity is appreciated, but avoid abbreviations (
indexin
, notindxin
), as it becomes difficult to remember if specific words are abbreviated, and if so, how exactly.
If a function name requires several words, consider whether it can represent more than one concept and whether it would be better to divide it into parts.
Write functions with an argument order similar to Julia Base.
As a rule, the Base library uses the following order of function arguments, depending on the situation.
-
Function argument. Placing the function argument first allows the use of keyword blocks.
do
for passing multi-line anonymous functions. -
Input/output stream. Specifying the
IO
object first allows passing a function to functions such assprint
, e.g.sprint(show, x)
. -
Mutable input data. For example, in the function
fill!(x, v)
x
is a mutable object, and it appears before the value to be inserted intox
. -
Type. Passing a type usually means that the output will have a given type. In the function
parse(Int, "1")the `
type comes before the analyzed string. There are many similar examples where the type appears first, but it is worth noting that in `read(io, String) theIO
argument appears before the type, which corresponds to the order described here. -
Immutable input data. In the
fill!' function(x, v)
v
_ does not change and comes afterx
. -
Key. For associative collections, this is the key of the key-value pair(s). For other indexed collections, this is the index.
-
Value. For associative collections, this is the value of the key-value pair(s). In functions like
fill!(x, v)
, this isv
. -
Other. All other arguments.
-
Variable number of arguments. This applies to arguments that can be endlessly enumerated at the end of a function call. For example, in
Matrix{T}(undef, dims)
measurements can be specified as -
Named arguments. In Julia, named arguments should always come last in function definitions. They are given here for the sake of completeness.
The vast majority of functions will not accept any of the listed arguments. The numbers simply indicate the priority that must be respected for all applicable function arguments.
Of course, there are a few exceptions. For example, in the function convert
the type must always be the first. In the function `setindex!The ` value comes before the indexes, so that indexes can be provided as a variable number of arguments.
If you adhere to this general order as strictly as possible when developing the API, it is likely that users of your functions will receive a more consistent interface.
Do not overuse the use of ...
Specifying function arguments in this way can be addictive. Instead of [a..., b...]
, just use [a; b]
, which already concatenates arrays. Method collect(a)
is better [a...]
, but since a
is already iterable, it is often even better not to touch it and not convert it to an array.
Make sure that the constructors return an instance of their type.
When the method 'T(x)` is called for type T
, it is usually expected to return a value of type T. Definition constructor that returns an unexpected type can lead to confusing and unpredictable behavior.
julia> struct Foo{T}
x::T
end
julia> Base.Float64(foo::Foo) = Foo(Float64(foo.x)) # Не определяйте методы подобным образом
julia> Float64(Foo(3)) # Должно возвращаться `Float64`
Foo{Float64}(3.0)
julia> Foo{Int}(x) = Foo{Float64}(x) # Не определяйте методы подобным образом
julia> Foo{Int}(3) # Должно возвращаться `Foo{Int}`
Foo{Float64}(3.0)
To keep code clear and ensure type consistency, always design constructors so that they return an instance of the type they are supposed to create.
Do not use unnecessary static parameters.
Function signature:
foo(x::T) where {T<:Real} = ...
it should be written in the following format:
foo(x::Real) = ...
especially if the T
is not used in the function body. Even if T
is used, it can be replaced with the function typeof(x)
, if it is convenient. This does not affect the performance in any way. Please note that this is not a general warning regarding static parameters, but only a remark about using them where they are not needed.
Also note that container types may require type parameters in function calls. For more information, see Avoid fields with abstract containers.
Avoid confusion about whether something is an instance or a type.
Sets of definitions like the following are confusing.
foo(::Type{MyType}) = ...
foo(::MyType) = foo(MyType)
Decide whether the concept in question will be written as MyType
or `MyType()', and stick to that.
The preferred style is to use default instances, and add methods that include Type'.{MyType}
should be added later if they become necessary to solve some problems.
If the type is actually an enumeration, it should be defined as a single type (ideally an immutable structure type or a primitive type), and the enumeration values should be its instances. You can check the validity of values using constructors and transformations. This construction is preferable to the one in which enumeration becomes an abstract type, and values become subtypes.
Don’t overuse the use of macros
Keep in mind the cases when a macro can actually be a function.
Function call eval
inside a macro is a particularly dangerous warning signal. It means that the macro will only work when called at the top level. If such a macro is written as a function, it will naturally have access to the run-time values it needs.
Do not use unsafe operations at the interface level.
If you have a type that uses its own pointer:
mutable struct NativeType
p::Ptr{UInt8}
...
end
do not write definitions like the following:
getindex(x::NativeType, i) = unsafe_load(x.p, i)
The problem is that users of this type can write x[i]
without realizing that this operation is unsafe and will then be prone to memory errors.
Such a function should either check the operation to ensure its safety, or have unsafe
somewhere in its name to warn the calling parties.
Do not overload the methods of the base container types
You can write definitions like the following.
show(io::IO, v::Vector{MyType}) = ...
In this case, vectors with a specific new element type will be displayed. Although it seems tempting, this situation should be avoided. The problem is that users expect a well-known type like Vector()
to behave in a certain way, and over-tuning its behavior can make it difficult to work with it.
Avoid "type piracy"
"Type piracy" refers to the practice of extending or redefining methods in Base or other packages for types that you did not define. In extreme cases, this can cause Julia to crash (for example, if extending or redefining your method results in invalid input data being passed to `ccall'). Type piracy can complicate the justification of code correctness and lead to incompatibilities that are difficult to predict and diagnose.
Let’s say, for example, that you want to define character multiplication in a module.
module A
import Base.*
*(x::Symbol, y::Symbol) = Symbol(x,y)
end
The problem is that now any other module using Base'.*
, will also see this definition. Since the symbol (Symbol
) is defined in Base and used by other modules, this may unexpectedly change the behavior of unrelated code. There are several alternatives here, including using a different function name or enclosing characters (Symbol
) in a different type that you define.
Sometimes related packages may engage in type piracy to separate functions from definitions, especially if the packages were developed by collaborating authors and the definitions can be reused. For example, one package may provide some types useful for working with colors; another package may define methods for these types that allow conversions between color spaces. Another example would be a package acting as a thin wrapper for some C code, which another package could then pirate to implement a higher-level API suitable for Julia.
Don’t write a simple anonymous function x->f(x)
for the named function f
Since higher-order functions are often called using anonymous functions, it is easy to conclude that this is desirable or even necessary. But any function can be passed directly, without being enclosed in an anonymous function. Instead of writing map(x->f(x), a)
, write map(f, a)
:
If possible, avoid using floating-point numbers for numeric literals in generic code.
If you are writing universal code that processes numbers and that can be expected to work with a large number of different numeric type arguments, try using numeric type literals that will minimally affect the arguments when advancing. Examples:
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
whereas:
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
As you can see, in the second version, where we used the literal Int
, the type of the input argument was preserved, but in the first one it was not. This happens because, for example, promote_type(Int, Float64) == Float64
, and when multiplying, promotion is performed. Similarly, literals Rational
are less destructive to types than literals Float64
, but more destructive than `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
Thus, if possible, use the literals Int
with Rational{Int}
for literal non-integers to simplify code usage.