风格指南

以下部分解释了惯用 Julia 编码风格的几个方面。这些规则都不是绝对的;它们只是为了帮助您熟悉该语言并帮助您在备选设计中进行选择的建议。

缩进

每个缩进级别使用 4 个空格。

编写函数,而不仅仅是脚本

将代码编写为一系列顶层步骤是快速开始解决问题的一种方法,但您应该尽快尝试将程序划分为函数。函数更易于重用和测试,并阐明正在执行的步骤以及它们的输入和输出是什么。此外,由于 Julia 编译器的运作方式,函数内部的代码往往比顶层代码运行速度快得多。

同样值得强调的是,函数应该接受参数,而不是直接对全局变量进行操作(除了像 pi 这样的常量)。

避免编写过于具体的类型

代码应该尽可能通用。与其编写

Complex{Float64}(x)

最好使用可用的泛型函数

complex(float(x))

第二个版本会将 x 转换为适当的类型,而不是始终是相同的类型。

此风格要点与函数参数特别相关。例如,如果函数参数实际上可以是任何整数,则不要将其声明为 IntInt32 类型,而应使用抽象类型 Integer 来表示。事实上,在许多情况下,您可以完全省略参数类型,除非需要区分其他方法定义,因为如果传递的类型不支持任何必需的操作,则无论如何都会抛出 MethodError。(这被称为 鸭子类型。)

例如,考虑以下 addone 函数的定义,该函数返回其参数加 1

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(它返回与 x 相同类型的 1,避免不必要的类型提升)和具有这些参数的 + 函数的类型。需要认识到的关键是,定义通用 addone(x) = x + oneunit(x)不会产生任何性能损失,因为 Julia 会根据需要自动编译专门的版本。例如,第一次调用 addone(12) 时,Julia 会自动为 x::Int 参数编译一个专门的 addone 函数,并将对 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 = 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 在整个过程中都使用了此约定,并包含了具有复制和修改形式的函数示例(例如,sortsort!),以及其他仅修改的函数(例如,push!pop!splice!)。对于此类函数,通常也为了方便起见返回修改后的数组。

与 IO 相关的函数或使用随机数生成器 (RNG) 的函数是值得注意的例外:由于这些函数几乎总是必须修改 IO 或 RNG,因此以 ! 结尾的函数用于表示除了修改 IO 或推进 RNG 状态之外的修改。例如,rand(x) 会修改 RNG,而 rand!(x) 会修改 RNG 和 x;类似地,read(io) 会修改 io,而 read!(io, x) 会修改两个参数。

避免奇怪的类型 Union

诸如 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 方法,让其他类型有自己的实现。

类似地,非导出函数通常是内部函数,并且可能会发生更改,除非文档另有说明。名称有时会在前面(或后面)加上_前缀(或后缀)以进一步表明某些内容是“内部的”或实现细节,但这并非规则。

此规则的反例包括NamedTupleRegexMatchStatStruct

使用与Julia base/一致的命名约定

  • 模块和类型名称使用大写字母和驼峰命名法:module SparseArraysstruct UnitRange
  • 函数使用小写字母(maximumconvert),并且在可读的情况下,将多个单词合并在一起(isequalhaskey)。如有必要,请使用下划线作为单词分隔符。下划线也用于表示概念的组合(remotecall_fetch 作为fetch(remotecall(...)) 的更高效实现)或作为修饰符。
  • 修改至少一个参数的函数以!结尾。
  • 简洁性很重要,但避免使用缩写(indexin 而不是indxin),因为很难记住是否以及如何缩写特定单词。

如果函数名称需要多个单词,请考虑它是否可能代表多个概念,以及是否可以更好地将其拆分为多个部分。

编写参数顺序类似于Julia Base的函数

作为一般规则,Base 库对函数使用以下参数顺序,视情况而定。

  1. 函数参数。将函数参数放在第一位允许使用do块传递多行匿名函数。

  2. I/O 流。首先指定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)),或者作为Varargs给出,例如Matrix{T}(undef, 1, 2)

  10. 关键字参数。在 Julia 中,关键字参数无论如何都必须放在函数定义的最后;出于完整性考虑,这里列出了它们。

绝大多数函数不会采用上面列出的所有类型的参数;这些数字仅仅表示应该对函数的任何适用参数使用的优先级。

当然也有一些例外。例如,在convert中,类型应该始终放在第一位。在setindex!中,值出现在索引之前,以便可以将索引作为可变参数提供。

在设计 API 时,尽可能遵守此一般顺序可能会为您的函数用户提供更一致的体验。

不要过度使用try-catch

最好避免错误,而不是依赖于捕获错误。

不要对条件使用括号

Julia 不需要在ifwhile中的条件周围使用括号。编写

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 中定义的,并且由其他模块使用,因此这可能会意外地改变无关代码的行为。这里有几个替代方案,包括使用不同的函数名称,或将Symbols 包装在您定义的另一种类型中。

有时,耦合的包可能会参与类型盗版以将功能与定义分开,尤其是在包由协作作者设计时,以及当定义可重用时。例如,一个包可能提供一些用于处理颜色的有用类型;另一个包可以为这些类型定义方法,这些方法可以实现颜色空间之间的转换。另一个例子可能是充当某些 C 代码的薄包装的包,另一个包可能会对其进行盗版以实现更高级别的、Julia 友好的 API。

小心使用类型相等性

您通常希望使用isa<:来测试类型,而不是==。检查类型的精确相等通常仅在与已知具体类型进行比较时才有意义(例如T == Float64),或者如果您确实非常清楚自己在做什么。

不要为命名函数f编写一个简单的匿名函数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} 字面量,以便更容易使用您的代码。