代码风格指南

代码风格指南

接下来的部分将介绍如何写出具有 Julia 风格的代码。当然,这些规则并不是绝对的,它们只是一些建议,以便更好地帮助你熟悉这门语言,以及在不同的代码设计中做出选择。

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

一开始解决问题的时候,直接从最外层一步步写代码的确很便捷,但你应该尽早地将代码组织成函数。函数有更强的复用性和可测试性,并且能更清楚地让人知道哪些步骤做完了,以及每一步骤的输入输出分别是什么。此外,由于 Julia 编译器特殊的工作方式,写在函数中的代码往往要比最外层的代码运行地快得多。

此外值得一提的是,函数应当接受参数,而不是直接使用全局变量进行操作(pi 等常数除外)。

类型不要写得过于具体

代码应该写得尽可能通用。例如,下面这段代码:

Complex{Float64}(x)

更好的写法是写成下面的通用函数:

complex(float(x))

第二个版本会把 x 转换成合适的类型,而不是某个写死的类型。

这种代码风格与函数的参数尤其相关。例如,当一个参数可以是任何整型时,不要将它的类型声明为 IntInt32,而要使用抽象类型(abstract type)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

最后一种定义可以处理所有支持 oneunit (返回和 x 相同类型的 1,以避免不需要的类型提升(type promotion))以及 + 函数的类型。这里的关键点在于,定义通用的 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 = 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!)。为了方便起见,这类函数通常也会把修改后的数组作为返回值。

避免使用奇怪的 Union 类型

使用 Union{Function,AbstractString} 这样的类型的时候通常意味着设计还不够清晰。

避免复杂的容器类型

像下面这样构造数组通常没有什么好处:

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

这种情况下,Vector{Any}(undef, n)更合适些。此外,相比将所有可能的类型都打包在一起,直接在使用时标注具体的数据类型(比如:a[i]::Int)对编译器来说更有用。

使用和 Julia base/ 文件夹中的代码一致的命名习惯

如果一个函数名需要多个单词,请考虑这个函数是否代表了超过一个概念,是不是分成几个更小的部分更好。

使用与 Julia Base 中的函数类似的参数顺序

一般来说,Base 库使用以下的函数参数顺序(如适用):

  1. 函数参数. 函数的第一个参数可以接受 Function 类型,以便使用 do blocks 来传递多行匿名函数。

  2. I/O stream. 函数的第一个参数可以接受 IO 对象,以便将函数传递给 sprint 之类的函数,例如 sprint(show, x)

  3. 在输入参数的内容会被更改的情况下. 比如,在 fill!(x, v) 中,x 是要被修改的对象,所以放在要被插入 x 中的值前面。

  4. Type. 把类型作为参数传入函数通常意味着返回值也会是同样的类型。 在 parse(Int, "1") 中,类型在需要解析的字符串之前。 还有很多类似的将类型作为函数第一个参数的例子,但是同时也需要注意到例如 read(io, String) 这样的函数中,会把 IO 参数放在类型的更前面,这样还是保持着这里描述的顺序。

  5. 在输入参数的内容不会被更改的情况下. 比如在 fill!(x, v) 中的被修改的 v,会放在 x 之后传入。

  6. Key. 对于关联集合来说,指的是键值对的键。 对于其它有索引的集合来说,指的是索引。

  7. Value. 对于关联集合来说,指的是键值对的值。 在类似于 fill!(x, v) 的情况中,指的是 v

  8. Everything else. 任何的其它参数。

  9. Varargs. 指的是在函数调用时可以被无限列在后面的参数。 比如在 Matrix{T}(uninitialized, dims) 中,维数(dims)可以作为 Tuple 被传入(如 Matrix{T}(uninitialized, (1,2))),也可以作为可变参数(Vararg,如 Matrix{T}(uninitialized, 1, 2)

  10. Keyword arguments. 在 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() 这样熟知的类型以某种方式表现,但过度自定义的行为会让使用变得更难。

避免类型盗版

“类型盗版”(type piracy)指的是扩展或是重定义 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},这样可以让代码变得更容易使用。