Style guide and design principles

Style guide

This section describes the coding style rules that apply to our code and that we recommend you to use it also.

In some cases, our style guide diverges from the Julia style guide (Please read it!). All such cases will be explicitly noted and justified.

Our style guide adopts many recommendations from the Blue style guide.

Please read the Blue style guide before contributing to this package. If not following, your pull requests may not be accepted.

Info

The style guide is always a work in progress, and not all MillerIndices code follows the rules. When modifying MillerIndices, please fix the style violations of the surrounding code (i.e., leave the code tidier than when you started). If large changes are needed, consider separating them into another pull request.

Run JuliaFormatter

MillerIndices uses JuliaFormatter as an auto-formatting tool.

We use the options contained in .JuliaFormatter.toml.

To format your code, cd to the MillerIndices directory, then run:

julia> using Pkg
julia> Pkg.add("JuliaFormatter") Resolving package versions... Installed Crayons ──────────── v4.1.1 Installed OrderedCollections ─ v1.6.2 Installed Tokenize ─────────── v0.5.25 Installed URIs ─────────────── v1.5.0 Installed CSTParser ────────── v3.3.6 Installed DataStructures ───── v0.18.15 Installed CommonMark ───────── v0.8.12 Installed JuliaFormatter ───── v1.0.39 Installed Glob ─────────────── v1.3.1 Updating `~/work/MillerIndices.jl/MillerIndices.jl/docs/build/developers/Project.toml` [98e50ef6] + JuliaFormatter v1.0.39 Updating `~/work/MillerIndices.jl/MillerIndices.jl/docs/build/developers/Manifest.toml` [00ebfdb7] + CSTParser v3.3.6 [a80b9123] + CommonMark v0.8.12 [34da2185] + Compat v4.10.0 [a8cc5b0e] + Crayons v4.1.1 [864edb3b] + DataStructures v0.18.15 [c27321d9] + Glob v1.3.1 [682c06a0] + JSON v0.21.4 [98e50ef6] + JuliaFormatter v1.0.39 [bac558e1] + OrderedCollections v1.6.2 [69de0a69] + Parsers v2.7.2 [aea7be01] + PrecompileTools v1.2.0 [21216c6a] + Preferences v1.4.1 [0796e94c] + Tokenize v0.5.25 [5c2747f8] + URIs v1.5.0 [0dad84c5] + ArgTools v1.1.1 [56f22d72] + Artifacts [2a0f44e3] + Base64 [ade2ca70] + Dates [f43a241f] + Downloads v1.6.0 [7b1f6079] + FileWatching [b77e0a4c] + InteractiveUtils [b27032c2] + LibCURL v0.6.3 [76f85450] + LibGit2 [8f399da3] + Libdl [56ddb016] + Logging [d6f4376e] + Markdown [a63ad114] + Mmap [ca575930] + NetworkOptions v1.2.0 [44cfe95a] + Pkg v1.9.2 [de0858da] + Printf [3fa0cd96] + REPL [9a3f8284] + Random [ea8e919c] + SHA v0.7.0 [9e88b42a] + Serialization [6462fe0b] + Sockets [fa267f1f] + TOML v1.0.3 [a4e569a6] + Tar v1.10.0 [cf7118a7] + UUIDs [4ec0a83e] + Unicode [deac9b47] + LibCURL_jll v7.84.0+0 [29816b5a] + LibSSH2_jll v1.10.2+0 [c8ffd9c3] + MbedTLS_jll v2.28.2+0 [14a3606d] + MozillaCACerts_jll v2022.10.11 [83775a58] + Zlib_jll v1.2.13+0 [8e850ede] + nghttp2_jll v1.48.0+0 [3f19e933] + p7zip_jll v17.4.0+0 Precompiling project... OrderedCollections URIs Glob Crayons DataStructures Tokenize CommonMark CSTParser JuliaFormatter 9 dependencies successfully precompiled in 47 seconds. 8 already precompiled.
julia> using JuliaFormatter: format┌ Warning: Module JSON with build ID fafbfcfd-e781-05b1-0000-00726f1f92ec is missing from the cache. This may mean JSON [682c06a0-de6a-54ab-a142-c8b1cf79cde6] does not support precompilation but is imported by a module that does. @ Base loading.jl:1793 ┌ Warning: Module JSON with build ID fafbfcfd-e781-05b1-0000-00726f1f92ec is missing from the cache. This may mean JSON [682c06a0-de6a-54ab-a142-c8b1cf79cde6] does not support precompilation but is imported by a module that does. @ Base loading.jl:1793
julia> format("docs")ERROR: IOError: realpath("docs"): no such file or directory (ENOENT)
julia> format("src")ERROR: IOError: realpath("src"): no such file or directory (ENOENT)
julia> format("test")ERROR: IOError: realpath("test"): no such file or directory (ENOENT)
Info

A continuous integration check verifies that all PRs made to MillerIndices have passed the formatter.

The following sections outline extra style guide points that are not fixed automatically by JuliaFormatter.

Use the Julia extension for Visual Studio Code

Please use VS Code with the Julia extension to edit, format, and test your code. We do not recommend using other editors to edit your code for the time being.

This extension already has JuliaFormatter integrated. So to format your code, follow the steps listed here.

Design principles

We adopt some SciML design guidelines here. Please read it before contributing!

Consistency vs Adherence

According to PEP8:

A style guide is about consistency. Consistency with this style guide is important. Consistency within a project is more important. Consistency within one module or function is the most important.

But most importantly: know when to be inconsistent – sometimes the style guide just doesn't apply. When in doubt, use your best judgment. Look at other examples and decide what looks best. And don't hesitate to ask!

Community Contribution Guidelines

For a comprehensive set of community contribution guidelines, refer to ColPrac. A relevant point to highlight PRs should do one thing. In the context of style, this means that PRs which update the style of a package's code should not be mixed with fundamental code contributions. This separation makes it easier to ensure that large style improvement are isolated from substantive (and potentially breaking) code changes.

Open source contributions are allowed to start small and grow over time

If the standard for code contributions is that every PR needs to support every possible input type that anyone can think of, the barrier would be too high for newcomers. Instead, the principle is to be as correct as possible to begin with, and grow the generic support over time. All recommended functionality should be tested, any known generality issues should be documented in an issue (and with a @test_broken test when possible). However, a function which is known to not be GPU-compatible is not grounds to block merging, rather it is an encouragement for a follow-up PR to improve the general type support!

Generic code is preferred unless code is known to be specific

For example, the code:

julia> function f(A, B)
           for i in 1:length(A)
               A[i] = A[i] + B[i]
           end
       endf (generic function with 1 method)

would not be preferred for two reasons. One is that it assumes A uses one-based indexing, which would fail in cases like OffsetArrays and FFTViews. Another issue is that it requires indexing, while not all array types support indexing (for example, CuArrays). A more generic compatible implementation of this function would be to use broadcast, for example:

julia> function f(A, B)
           @. A = A + B
       endf (generic function with 1 method)

which would allow support for a wider variety of array types.

Internal types should match the types used by users when possible

If f(A) takes the input of some collections and computes an output from those collections, then it should be expected that if the user gives A as an Array, the computation should be done via Arrays. If A was a CuArray, then it should be expected that the computation should be internally done using a CuArray (or appropriately error if not supported). For these reasons, constructing arrays via generic methods, like similar(A), is preferred when writing f instead of using non-generic constructors like Array(undef,size(A)) unless the function is documented as being non-generic.

Trait definition and adherence to generic interface is preferred when possible

Julia provides many interfaces, for example:

Those interfaces should be followed when possible. For example, when defining broadcast overloads, one should implement a BroadcastStyle as suggested by the documentation instead of simply attempting to bypass the broadcast system via copyto! overloads.

When interface functions are missing, these should be added to Base Julia or an interface package, like ArrayInterface.jl. Such traits should be declared and used when appropriate. For example, if a line of code requires mutation, the trait ArrayInterface.ismutable(A) should be checked before attempting to mutate, and informative error messages should be written to capture the immutable case (or, an alternative code which does not mutate should be given).

One example of this principle is demonstrated in the generation of Jacobian matrices. In many scientific applications, one may wish to generate a Jacobian cache from the user's input u0. A naive way to generate this Jacobian is J = similar(u0,length(u0),length(u0)). However, this will generate a Jacobian J such that J isa Matrix.

Macros should be limited and only be used for syntactic sugar

Macros define new syntax, and for this reason they tend to be less composable than other coding styles and require prior familiarity to be easily understood. One principle to keep in mind is, "can the person reading the code easily picture what code is being generated?". For example, a user of Soss.jl may not know what code is being generated by:

@model (x, α) begin
    σ ~ Exponential()
    β ~ Normal()
    y ~ For(x) do xj
        Normal(α + β * xj, σ)
    end
    return y
end

and thus using such a macro as the interface is not preferred when possible. However, a macro like @muladd is trivial to picture on a code (it recursively transforms a*b + c to muladd(a,b,c) for more accuracy and efficiency), so using such a macro for example:

julia> @macroexpand(@muladd k3 = f(t + c3 * dt, @. uprev + dt * (a031 * k1 + a032 * k2)))
:(k3 = f((muladd)(c3, dt, t), (muladd).(dt, (muladd).(a032, k2, (*).(a031, k1)), uprev)))

is recommended. Some macros in this category are:

Some performance macros, like @simd, @threads, or @turbo from LoopVectorization.jl, make an exception in that their generated code may be foreign to many users. However, they still are classified as appropriate uses as they are syntactic sugar since they do (or should) not change the behavior of the program in measurable ways other than performance.

Errors should be caught as high as possible, and error messages should be contextualized for newcomers

Whenever possible, defensive programming should be used to check for potential errors before they are encountered deeper within a package. For example, if one knows that f(u0,p) will error unless u0 is the size of p, this should be caught at the start of the function to throw a domain specific error, for example "parameters and initial condition should be the same size".

Subpackaging and interface packages is preferred over conditional modules via Requires.jl

Requires.jl should be avoided at all costs. If an interface package exists, such as ChainRulesCore.jl for defining automatic differentiation rules without requiring a dependency on the whole ChainRules.jl system, or RecipesBase.jl which allows for defining Plots.jl plot recipes without a dependency on Plots.jl, a direct dependency on these interface packages is preferred.

Otherwise, instead of resorting to a conditional dependency using Requires.jl, it is preferred one creates subpackages, i.e. smaller independent packages kept within the same GitHub repository with independent versioning and package management. An example of this is seen in Optimization.jl which has subpackages like OptimizationBBO.jl for BlackBoxOptim.jl support.

Some important interface packages to know about are:

Functions should either attempt to be non-allocating and reuse caches, or treat inputs as immutable

Mutating codes and non-mutating codes fall into different worlds. When a code is fully immutable, the compiler can better reason about dependencies, optimize the code, and check for correctness. However, many times a code making the fullest use of mutation can outperform even what the best compilers of today can generate. That said, the worst of all worlds is when code mixes mutation with non-mutating code. Not only is this a mishmash of coding styles, it has the potential non-locality and compiler proof issues of mutating code while not fully benefiting from the mutation.

Out-Of-Place and Immutability is preferred when sufficient performant

Mutation is used to get more performance by decreasing the amount of heap allocations. However, if it's not helpful for heap allocations in a given spot, do not use mutation. Mutation is scary and should be avoided unless it gives an immediate benefit. For example, if matrices are sufficiently large, then A*B is as fast as mul!(C,A,B), and thus writing A*B is preferred (unless the rest of the function is being careful about being fully non-allocating, in which case this should be mul! for consistency).

Similarly, when defining types, using struct is preferred to mutable struct unless mutating the struct is a common occurrence. Even if mutating the struct is a common occurrence, see whether using Setfield.jl is sufficient. The compiler will optimize the construction of immutable structs, and thus this can be more efficient if it's not too much of a code hassle.

Tests should attempt to cover a wide gamut of input types

Code coverage numbers are meaningless if one does not consider the input types. For example, one can hit all the code with Array, but that does not test whether CuArray is compatible! Thus, it's always good to think of coverage not in terms of lines of code but in terms of type coverage. A good list of number types to think about are:

  • Float64
  • Float32
  • Complex
  • Dual
  • BigFloat

Array types to think about testing are:

When in doubt, a submodule should become a subpackage or separate package

Keep packages to one core idea. If there's something separate enough to be a submodule, could it instead be a separate well-tested and documented package to be used by other packages? Most likely yes.

Globals should be avoided whenever possible

Global variables should be avoided whenever possible. When required, global variables should be constants and have an all uppercase name separated with underscores (e.g. MY_CONSTANT). They should be defined at the top of the file, immediately after imports and exports but before an __init__ function. If you truly want mutable global style behavior you may want to look into mutable containers.

Type-stable and Type-grounded code is preferred wherever possible

Type-stable and type-grounded code helps the compiler create not only more optimized code, but also faster to compile code. Always keep containers well-typed, functions specializing on the appropriate arguments, and types concrete.

Closures should be avoided whenever possible

Closures can cause accidental type instabilities that are difficult to track down and debug; in the long run it saves time to always program defensively and avoid writing closures in the first place, even when a particular closure would not have been problematic. A similar argument applies to reading code with closures; if someone is looking for type instabilities, this is faster to do when code does not contain closures. Furthermore, if you want to update variables in an outer scope, do so explicitly with Refs or self defined structs. For example,

map(Base.Fix2(getindex, i), vector_of_vectors)

is preferred over

map(v -> v[i], vector_of_vectors)

or

[v[i] for v in vector_of_vectors]

Numerical functionality should use the appropriate generic numerical interfaces

While you can use A\b to do a linear solve inside a package, that does not mean that you should. This interface is only sufficient for performing factorizations, and so that limits the scaling choices, the types of A that can be supported, etc. Instead, linear solves within packages should use LinearSolve.jl. Similarly, nonlinear solves should use NonlinearSolve.jl. Optimization should use Optimization.jl. This allows the full generic choice to be given to the user without depending on every solver package (effectively recreating the generic interfaces within each package).

Functions should capture one underlying principle

Functions mean one thing. Every dispatch of + should be "the meaning of addition on these types". While in theory you could add dispatches to + that mean something different, that will fail in generic code for which + means addition. Thus, for generic code to work, code needs to adhere to one meaning for each function. Every dispatch should be an instantiation of that meaning.

Internal choices should be exposed as options whenever possible

Whenever possible, numerical values and choices within scripts should be exposed as options to the user. This promotes code reusability beyond the few cases the author may have expected.

Prefer code reuse over rewrites whenever possible

If a package has a function you need, use the package. Add a dependency if you need to. If the function is missing a feature, prefer to add that feature to said package and then add it as a dependency. If the dependency is potentially troublesome, for example because it has a high load time, prefer to spend time helping said package fix these issues and add the dependency. Only when it does not seem possible to make the package "good enough" should using the package be abandoned. If it is abandoned, consider building a new package for this functionality as you need it, and then make it a dependency.

Prefer to not shadow functions

Two functions can have the same name in Julia by having different namespaces. For example, X.f and Y.f can be two different functions, with different dispatches, but the same name. This should be avoided whenever possible. Instead of creating MyPackage.sort, consider adding dispatches to Base.sort for your types if these new dispatches match the underlying principle of the function. If it doesn't, prefer to use a different name. While using MyPackage.sort is not conflicting, it is going to be confusing for most people unfamiliar with your code, so MyPackage.special_sort would be more helpful to newcomers reading the code.