常见问题

一般

Julia 是以某个人或某件事命名的吗?

不是。

为什么不将 Matlab/Python/R/… 代码编译成 Julia?

由于许多人熟悉其他动态语言的语法,并且已经用这些语言编写了大量代码,因此自然会想知道为什么我们没有将 Matlab 或 Python 前端插入 Julia 后端(或“转译”代码到 Julia)以获得 Julia 的所有性能优势,而无需程序员学习新语言。很简单,对吧?

基本问题是 Julia 的编译器没有什么特别之处:我们使用的是一种普通的编译器(LLVM),没有其他语言开发者不知道的“秘密武器”。事实上,Julia 的编译器在许多方面都比其他动态语言(例如 PyPy 或 LuaJIT)的编译器简单得多。Julia 的性能优势几乎完全来自其前端:其语言语义允许为编译器提供更多机会来生成高效的代码和内存布局,以获得良好的编写 Julia 程序。如果您尝试将 Matlab 或 Python 代码编译成 Julia,我们的编译器将受到 Matlab 或 Python 语义的限制,生成的代码不会比这些语言的现有编译器生成的代码更好(甚至可能更差)。语义的关键作用也是为什么一些现有的 Python 编译器(如 Numba 和 Pythran)只尝试优化语言的一小部分(例如,对 Numpy 数组和标量的运算),并且对于这部分,它们已经做得至少和我们对相同语义所能做到的同样好。从事这些项目的人非常聪明,并取得了惊人的成就,但将编译器改造到旨在解释执行的语言上是一个非常困难的问题。

Julia 的优势在于,良好的性能并不局限于一小部分“内置”类型和操作,并且可以编写适用于任意用户定义类型的高级类型通用代码,同时保持快速和内存高效。像 Python 这样的语言中的类型 simply don't provide enough information to the compiler for similar capabilities,因此一旦您使用这些语言作为 Julia 的前端,您就会陷入困境。

出于类似的原因,自动翻译到 Julia 通常也会生成不可读、缓慢、非惯用的代码,这对于从另一种语言进行本地 Julia 移植来说不是一个好的起点。

另一方面,语言互操作性非常有用:我们希望利用 Julia 中其他语言中现有的高质量代码(反之亦然)!实现此目的的最佳方法不是转译器,而是通过简单的跨语言调用功能。我们在这方面付出了很多努力,从内置的 ccall 本质函数(调用 C 和 Fortran 库)到连接 Julia 与 Python、Matlab、C++ 等的 JuliaInterop 包。

公共 API

Julia 如何定义其公共 API?

Julia Base 和标准库功能在 文档 中有描述,未标记为不稳定(例如实验性和内部)的部分受 SemVer 约束。如果函数、类型和常量未包含在文档中,则不属于公共 API,即使它们有文档字符串

有一个有用的未记录的函数/类型/常量。我可以使用它吗?

如果您使用非公共 API,更新 Julia 可能会破坏您的代码。如果代码是自包含的,将其复制到您的项目中可能是一个好主意。如果您想依赖复杂的非公共 API,尤其是在从稳定包中使用它时,最好打开一个 问题拉取请求 以开始讨论将其转换为公共 API 的问题。但是,我们并不反对尝试创建公开稳定公共接口的包,同时依赖 Julia 的非公共实现细节并缓冲不同 Julia 版本之间的差异。

文档不够准确。我可以依赖现有的行为吗?

请打开一个 问题拉取请求 以开始讨论将现有行为转换为公共 API 的问题。

会话和 REPL

如何在内存中删除对象?

Julia 没有类似于 MATLAB 的 clear 函数的功能;一旦在 Julia 会话(严格来说,在 Main 模块中)中定义了名称,它就会一直存在。

如果内存使用是您的关注点,您可以随时用消耗更少内存的对象替换对象。例如,如果 A 是一个您不再需要的千兆字节大小的数组,您可以使用 A = nothing 释放内存。内存将在下次垃圾回收器运行时释放;您可以使用 GC.gc() 强制执行此操作。此外,尝试使用 A 可能会导致错误,因为大多数方法都没有在 Nothing 类型上定义。

如何在会话中修改类型的声明?

也许您已经定义了一个类型,然后意识到需要添加一个新字段。如果您在 REPL 中尝试这样做,您会收到错误

ERROR: invalid redefinition of constant MyType

Main 模块中的类型无法重新定义。

虽然这在您开发新代码时可能不方便,但有一个很好的解决方法。模块可以通过重新定义来替换,因此,如果您将所有新代码包装在模块中,您可以重新定义类型和常量。您不能将类型名称导入到 Main 中,然后期望能够在那里重新定义它们,但您可以使用模块名称来解析作用域。换句话说,在开发过程中,您可能会使用类似这样的工作流程

include("mynewcode.jl")              # this defines a module MyModule
obj1 = MyModule.ObjConstructor(a, b)
obj2 = MyModule.somefunction(obj1)
# Got an error. Change something in "mynewcode.jl"
include("mynewcode.jl")              # reload the module
obj1 = MyModule.ObjConstructor(a, b) # old objects are no longer valid, must reconstruct
obj2 = MyModule.somefunction(obj1)   # this time it worked!
obj3 = MyModule.someotherfunction(obj2, c)
...

脚本

如何检查当前文件是否作为主脚本运行?

当使用 `julia file.jl` 运行文件作为主脚本时,您可能希望激活额外的功能,例如命令行参数处理。确定文件是否以这种方式运行的一种方法是检查 `abspath(PROGRAM_FILE) == @__FILE__` 是否为 `true`。

但是,建议不要编写既用作脚本又用作可导入库的文件。如果需要既可用作库又可用作脚本的功能,最好将其编写为库,然后将该功能导入到单独的脚本中。

如何在脚本中捕获 CTRL-C?

使用 `julia file.jl` 运行 Julia 脚本时,当您尝试使用 CTRL-C (SIGINT) 终止它时,不会抛出 InterruptException。为了在终止 Julia 脚本之前运行某些代码(无论是否由 CTRL-C 引起),请使用 atexit。或者,您可以使用 `julia -e 'include(popfirst!(ARGS))' file.jl` 来执行脚本,同时能够在 try 块中捕获 `InterruptException`。请注意,使用此策略,PROGRAM_FILE 将不会被设置。

如何使用 `#!/usr/bin/env` 将选项传递给 `julia`?

在所谓的 shebang 行中将选项传递给 `julia`,例如 `#!/usr/bin/env julia --startup-file=no`,在许多平台(BSD、macOS、Linux)上将不起作用,因为内核(与 shell 不同)不会在空格字符处分割参数。选项 `env -S` 提供了一个简单的解决方法,它可以在空格处将单个参数字符串拆分为多个参数,类似于 shell。

#!/usr/bin/env -S julia --color=yes --startup-file=no
@show ARGS  # put any Julia code here
注意

选项 `env -S` 出现在 FreeBSD 6.0(2005 年)、macOS Sierra(2016 年)和 GNU/Linux coreutils 8.30(2018 年)。

为什么 `run` 不支持 `*` 或管道来编写外部程序脚本?

Julia 的 run 函数直接启动外部程序,而不会调用 操作系统 shell(与 Python、R 或 C 等其他语言中的 `system("...")` 函数不同)。这意味着 `run` 不会执行 `*` 的通配符扩展("globbing"),也不会解释 shell 管道,如 `|` 或 `>`。

但是,您仍然可以使用 Julia 功能进行通配符扩展和管道。例如,内置的 pipeline 函数允许您链接外部程序和文件,类似于 shell 管道,而 Glob.jl 包 实现了与 POSIX 兼容的通配符扩展。

当然,您可以通过显式传递 shell 和命令字符串到 `run` 来通过 shell 运行程序,例如 `run(`sh -c "ls > files.txt"`) 来使用 Unix Bourne shell,但您通常应该更喜欢纯 Julia 脚本,例如 `run(pipeline(`ls`, "files.txt"))`。我们默认避免使用 shell 的原因是 使用 shell 运行程序很糟糕:通过 shell 启动进程速度慢,对特殊字符的引用很脆弱,错误处理很差,并且可移植性存在问题。(Python 开发人员也得出了 类似的结论)。

变量和赋值

为什么我从一个简单的循环中得到 `UndefVarError`?

您可能会有类似以下的代码:

x = 0
while x < 10
    x += 1
end

并注意到它在交互式环境(如 Julia REPL)中运行良好,但在您尝试在脚本或其他文件中运行它时会给出 `UndefVarError: `x` not defined`。发生这种情况的原因是 Julia 通常要求您**在本地作用域中明确地将变量赋值给全局变量**。

这里,`x` 是一个全局变量,`while` 定义了一个 局部作用域,而 `x += 1` 是在该局部作用域中对全局变量的赋值。

如上所述,Julia(1.5 或更高版本)允许您省略 REPL(以及许多其他交互式环境)中的 `global` 关键字,以简化探索(例如,复制粘贴函数中的代码以交互式运行)。但是,一旦您转到文件中的代码,Julia 就需要一种更有纪律性的方法来处理全局变量。您至少有三个选择

  1. 将代码放入函数中(以便 `x` 是函数中的局部变量)。通常,使用函数而不是全局脚本是良好的软件工程实践(在线搜索“为什么全局变量不好”以查看许多解释)。在 Julia 中,全局变量也很慢
  2. 将代码包装在 let 块中。(这使得 `x` 成为 `let ... end` 语句中的局部变量,再次消除了对 `global` 的需要)。
  3. 在赋值之前,在局部作用域中显式地将 `x` 标记为 `global`,例如编写 `global x += 1`。

更多解释可以在手册部分 关于软作用域 中找到。

函数

我将参数 `x` 传递给函数,在函数内部修改了它,但在外部,变量 `x` 仍然没有改变。为什么?

假设您这样调用一个函数:

julia> x = 10
10

julia> function change_value!(y)
           y = 17
       end
change_value! (generic function with 1 method)

julia> change_value!(x)
17

julia> x # x is unchanged!
10

在 Julia 中,变量 `x` 的绑定不能通过将 `x` 作为参数传递给函数来更改。在上述示例中调用 `change_value!(x)` 时,`y` 是一个新创建的变量,最初绑定到 `x` 的值,即 `10`;然后 `y` 重新绑定到常量 `17`,而外部作用域的变量 `x` 保持不变。

但是,如果 `x` 绑定到 `Array` 类型(或任何其他可变类型)的对象。从函数内部,您无法将 `x` “解绑”到此 Array,但您可以更改其内容。例如

julia> x = [1,2,3]
3-element Vector{Int64}:
 1
 2
 3

julia> function change_array!(A)
           A[1] = 5
       end
change_array! (generic function with 1 method)

julia> change_array!(x)
5

julia> x
3-element Vector{Int64}:
 5
 2
 3

这里我们创建了一个函数 `change_array!`,它将 `5` 赋值给传递的数组的第一个元素(在调用站点绑定到 `x`,并在函数内部绑定到 `A`)。请注意,在函数调用之后,`x` 仍然绑定到同一个数组,但该数组的内容发生了变化:变量 `A` 和 `x` 是指向同一个可变 `Array` 对象的不同绑定。

我可以在函数内部使用 `using` 或 `import` 吗?

不可以,您不允许在函数内部使用 `using` 或 `import` 语句。如果您想导入一个模块,但仅在特定函数或函数集中使用其符号,则有两个选择

  1. 使用 `import`

    import Foo
    function bar(...)
        # ... refer to Foo symbols via Foo.baz ...
    end

    这将加载模块 `Foo` 并定义一个变量 `Foo` 来引用该模块,但不会将模块中的任何其他符号导入到当前命名空间中。您可以通过限定名称 `Foo.bar` 等来引用 `Foo` 符号。

  2. 将您的函数包装在模块中

    module Bar
    export bar
    using Foo
    function bar(...)
        # ... refer to Foo.baz as simply baz ....
    end
    end
    using Bar

    这将导入 `Foo` 中的所有符号,但仅在模块 `Bar` 内部。

`...` 运算符的作用是什么?

`...` 运算符的两种用法:吸取和散布

许多 Julia 新手发现 `...` 运算符的使用令人困惑。`...` 运算符之所以令人困惑,部分原因在于它根据上下文表示不同的含义。

在函数定义中,`...` 将多个参数组合成一个参数

在函数定义的上下文中,`...` 运算符用于将许多不同的参数组合成一个参数。这种将多个不同参数组合成一个参数的 `...` 用法称为吸取。

julia> function printargs(args...)
           println(typeof(args))
           for (i, arg) in enumerate(args)
               println("Arg #$i = $arg")
           end
       end
printargs (generic function with 1 method)

julia> printargs(1, 2, 3)
Tuple{Int64, Int64, Int64}
Arg #1 = 1
Arg #2 = 2
Arg #3 = 3

如果 Julia 是一种更广泛地使用 ASCII 字符的语言,则吸取运算符可能会写成 `<-...` 而不是 `...`。

在函数调用中,`...` 将一个参数拆分为多个不同的参数

与在定义函数时使用 `...` 运算符表示将多个不同参数吸取成一个参数相反,`...` 运算符还用于在函数调用的上下文中导致单个函数参数被拆分为多个不同的参数。这种 `...` 的用法称为散布。

julia> function threeargs(a, b, c)
           println("a = $a::$(typeof(a))")
           println("b = $b::$(typeof(b))")
           println("c = $c::$(typeof(c))")
       end
threeargs (generic function with 1 method)

julia> x = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> threeargs(x...)
a = 1::Int64
b = 2::Int64
c = 3::Int64

如果 Julia 是一种更广泛地使用 ASCII 字符的语言,则散布运算符可能会写成 `...->` 而不是 `...`。

赋值的返回值是什么?

运算符 `=` 始终返回右侧,因此

julia> function threeint()
           x::Int = 3.0
           x # returns variable x
       end
threeint (generic function with 1 method)

julia> function threefloat()
           x::Int = 3.0 # returns 3.0
       end
threefloat (generic function with 1 method)

julia> threeint()
3

julia> threefloat()
3.0

同样地

julia> function twothreetup()
           x, y = [2, 3] # assigns 2 to x and 3 to y
           x, y # returns a tuple
       end
twothreetup (generic function with 1 method)

julia> function twothreearr()
           x, y = [2, 3] # returns an array
       end
twothreearr (generic function with 1 method)

julia> twothreetup()
(2, 3)

julia> twothreearr()
2-element Vector{Int64}:
 2
 3

类型、类型声明和构造函数

"类型稳定"是什么意思?

这意味着输出的类型可以从输入的类型预测。特别是,这意味着输出的类型不能根据输入的而变化。以下代码不是类型稳定的

julia> function unstable(flag::Bool)
           if flag
               return 1
           else
               return 1.0
           end
       end
unstable (generic function with 1 method)

它根据其参数的值返回 `Int` 或 Float64。由于 Julia 无法在编译时预测此函数的返回类型,因此使用它的任何计算都必须能够处理这两种类型的值,这使得生成快速的机器代码变得困难。

为什么 Julia 对某些看似合理的运算给出 `DomainError`?

某些运算在数学上是有意义的,但会导致错误

julia> sqrt(-2.0)
ERROR: DomainError with -2.0:
sqrt was called with a negative real argument but will only return a complex result if called with a complex argument. Try sqrt(Complex(x)).
Stacktrace:
[...]

这种行为是类型稳定性要求带来的一个不便的结果。以sqrt为例,大多数用户希望sqrt(2.0)返回一个实数,并且如果它产生复数1.4142135623730951 + 0.0im会感到不满意。人们可以编写sqrt函数,使其仅在传递负数时才切换到复数值输出(这是某些其他语言中sqrt的行为),但这样结果将不是类型稳定的,并且sqrt函数的性能会很差。

在这些情况下以及其他情况下,您可以通过选择一个输入类型来获得想要的结果,该类型传达了您愿意接受输出类型的意愿,结果可以在其中表示。

julia> sqrt(-2.0+0im)
0.0 + 1.4142135623730951im

如何约束或计算类型参数?

参数化类型的参数可以保存类型或位值,类型本身选择如何使用这些参数。例如,Array{Float64, 2}由类型Float64参数化以表达其元素类型,并由整数值2参数化以表达其维度数。在定义自己的参数化类型时,您可以使用子类型约束来声明某个参数必须是某个抽象类型或先前类型参数的子类型(<:)。但是,没有专门的语法来声明参数必须是给定类型的——也就是说,您不能直接声明维度之类的参数isa Intstruct定义中,例如。同样,您不能对类型参数进行计算(包括简单的加法或减法)。相反,这些类型的约束和关系可以通过额外的类型参数来表达,这些类型参数在类型的构造函数中计算和强制执行。

例如,考虑

struct ConstrainedType{T,N,N+1} # NOTE: INVALID SYNTAX
    A::Array{T,N}
    B::Array{T,N+1}
end

用户希望强制第三个类型参数始终为第二个加一。这可以通过一个显式类型参数来实现,该参数由内部构造函数方法检查(在其中可以与其他检查结合使用)

struct ConstrainedType{T,N,M}
    A::Array{T,N}
    B::Array{T,M}
    function ConstrainedType(A::Array{T,N}, B::Array{T,M}) where {T,N,M}
        N + 1 == M || throw(ArgumentError("second argument should have one more axis" ))
        new{T,N,M}(A, B)
    end
end

此检查通常没有成本,因为编译器可以省略对有效具体类型的检查。如果第二个参数也是计算出来的,则提供一个外部构造函数方法来执行此计算可能更有利。

ConstrainedType(A) = ConstrainedType(A, compute_B(A))

为什么Julia使用原生机器整数算术?

Julia 对整数计算使用机器算术。这意味着Int值的范围是有界的,并且在任一端都会环绕,因此整数的加法、减法和乘法可能会溢出或下溢,从而导致一些一开始可能令人不安的结果。

julia> x = typemax(Int)
9223372036854775807

julia> y = x+1
-9223372036854775808

julia> z = -y
-9223372036854775808

julia> 2*z
0

显然,这与数学整数的行为相去甚远,您可能会认为对于高级编程语言来说,将其暴露给用户并非理想之选。然而,对于效率和透明度至关重要的数值工作,其他选择更糟糕。

可以考虑的另一种方案是检查每个整数运算是否溢出,并在溢出的情况下将结果提升到更大的整数类型,例如Int128BigInt。不幸的是,这会在每个整数运算(例如递增循环计数器)中引入主要开销——它需要发出代码来执行算术指令后的运行时溢出检查,并分支以处理潜在的溢出。更糟糕的是,这会导致涉及整数的每个计算都是类型不稳定的。正如我们上面提到的,类型稳定性对于有效生成高效代码至关重要。如果您不能依靠整数运算的结果是整数,那么就不可能像C和Fortran编译器那样生成快速、简单的代码。

这种方法的一个变体是避免类型不稳定的出现,即将IntBigInt类型合并为单个混合整数类型,该类型在结果不再适合机器整数大小时在内部更改表示。虽然这在Julia代码级别表面上避免了类型不稳定,但它只是通过将所有相同的问题强加给实现此混合整数类型的C代码来掩盖问题。这种方法可以实现,并且在许多情况下甚至可以变得非常快,但它也有一些缺点。一个问题是整数和整数数组的内存表示不再与C、Fortran和其他使用原生机器整数的语言使用的自然表示相匹配。因此,要与这些语言互操作,我们最终仍然需要引入原生整数类型。整数的任何无界表示都不能具有固定数量的位,因此不能与固定大小的插槽一起内联存储在数组中——大整数的值始终需要单独的堆分配存储。当然,无论使用多么巧妙的混合整数实现,总会有性能陷阱——性能意外下降的情况。复杂的表示、缺乏与C和Fortran的互操作性、无法在不使用额外堆存储的情况下表示整数数组以及不可预测的性能特征使得即使是最巧妙的混合整数实现对于高性能数值工作来说也是一个糟糕的选择。

使用混合整数或提升到BigInt的另一种方法是使用饱和整数算术,其中添加到最大整数的值使其保持不变,反之亦然,从最小整数的值减去。这正是Matlab™所做的。

>> int64(9223372036854775807)

ans =

  9223372036854775807

>> int64(9223372036854775807) + 1

ans =

  9223372036854775807

>> int64(-9223372036854775808)

ans =

 -9223372036854775808

>> int64(-9223372036854775808) - 1

ans =

 -9223372036854775808

乍一看,这似乎足够合理,因为9223372036854775807比-9223372036854775808更接近9223372036854775808,并且整数仍然以自然的方式使用固定大小表示,这与C和Fortran兼容。然而,饱和整数算术存在严重问题。第一个也是最明显的问题是,这不是机器整数算术的工作方式,因此实现饱和运算需要在每个机器整数运算后发出指令以检查下溢或溢出,并用typemin(Int)typemax(Int)替换结果,视情况而定。仅此一项就将每个整数运算从一个快速指令扩展到六个指令,可能包括分支。哎哟。但情况变得更糟——饱和整数算术不是结合的。考虑这个Matlab计算

>> n = int64(2)^62
4611686018427387904

>> n + (n - 1)
9223372036854775807

>> (n + n) - 1
9223372036854775806

这使得编写许多基本整数算法变得困难,因为许多常用技术依赖于机器加法溢出结合的事实。考虑使用表达式(lo + hi) >>> 1在Julia中查找整数lohi之间的中点。

julia> n = 2^62
4611686018427387904

julia> (n + 2n) >>> 1
6917529027641081856

看到了吗?没问题。这是2^62和2^63之间正确的中间点,尽管n + 2n是-4611686018427387904。现在在Matlab中试试

>> (n + 2*n)/2

ans =

  4611686018427387904

糟糕。向Matlab添加>>>运算符无济于事,因为在添加n2n时发生的饱和已经破坏了计算正确中间点所需的信息。

缺乏结合性不仅对无法依赖它的程序员来说是不幸的,而且它还会破坏编译器可能想要做的几乎任何优化整数算术的事情。例如,由于Julia整数使用正常的机器整数算术,因此LLVM可以积极地优化像f(k) = 5k-1这样简单的函数。此函数的机器代码仅此而已

julia> code_native(f, Tuple{Int})
  .text
Filename: none
  pushq %rbp
  movq  %rsp, %rbp
Source line: 1
  leaq  -1(%rdi,%rdi,4), %rax
  popq  %rbp
  retq
  nopl  (%rax,%rax)

函数的实际主体是一个leaq指令,它可以同时计算整数乘法和加法。当f内联到另一个函数中时,这更有益

julia> function g(k, n)
           for i = 1:n
               k = f(k)
           end
           return k
       end
g (generic function with 1 methods)

julia> code_native(g, Tuple{Int,Int})
  .text
Filename: none
  pushq %rbp
  movq  %rsp, %rbp
Source line: 2
  testq %rsi, %rsi
  jle L26
  nopl  (%rax)
Source line: 3
L16:
  leaq  -1(%rdi,%rdi,4), %rdi
Source line: 2
  decq  %rsi
  jne L16
Source line: 5
L26:
  movq  %rdi, %rax
  popq  %rbp
  retq
  nop

由于对f的调用被内联,因此循环体最终只是一个leaq指令。接下来,考虑如果我们将循环迭代次数固定会发生什么

julia> function g(k)
           for i = 1:10
               k = f(k)
           end
           return k
       end
g (generic function with 2 methods)

julia> code_native(g,(Int,))
  .text
Filename: none
  pushq %rbp
  movq  %rsp, %rbp
Source line: 3
  imulq $9765625, %rdi, %rax    # imm = 0x9502F9
  addq  $-2441406, %rax         # imm = 0xFFDABF42
Source line: 5
  popq  %rbp
  retq
  nopw  %cs:(%rax,%rax)

因为编译器知道整数加法和乘法是结合的,并且乘法对加法是分配的——饱和算术都不是这样——它可以将整个循环优化为仅乘法和加法。饱和算术完全破坏了这种优化,因为结合性和分配性在每次循环迭代中都可能失败,导致结果因失败发生在哪个迭代而异。编译器可以展开循环,但不能将多个操作在代数上简化为更少的等效操作。

让整数算术静默溢出的最合理的替代方案是在任何地方执行检查算术,在加法、减法和乘法溢出时引发错误,生成不正确的数值。在这篇博文中,Dan Luu 分析了这一点,发现与其这种方法在理论上应该具有的微不足道的成本相比,它最终会产生巨大的成本,因为编译器(LLVM 和 GCC)无法优雅地优化围绕添加的溢出检查。如果这在未来得到改进,我们可以考虑在Julia中默认为检查整数算术,但目前,我们必须忍受溢出的可能性。

同时,可以通过使用外部库(如SaferIntegers.jl)来实现溢出安全的整数运算。请注意,如前所述,使用这些库会显着增加使用检查整数类型的代码的执行时间。但是,对于有限的使用,这远不如将其用于所有整数运算那样成为问题。您可以关注此处讨论的状态此处

远程执行期间UndefVarError的可能原因是什么?

如错误所示,远程节点上出现UndefVarError的直接原因是该名称的绑定不存在。让我们探讨一些可能的原因。

julia> module Foo
           foo() = remotecall_fetch(x->x, 2, "Hello")
       end

julia> Foo.foo()
ERROR: On worker 2:
UndefVarError: `Foo` not defined
Stacktrace:
[...]

闭包x->x包含对Foo的引用,由于节点2上没有Foo,因此会抛出UndefVarError

Main之外的其他模块下的全局变量不会按值序列化到远程节点。只发送一个引用。创建全局绑定的函数(除Main下之外)可能会导致稍后抛出UndefVarError

julia> @everywhere module Foo
           function foo()
               global gvar = "Hello"
               remotecall_fetch(()->gvar, 2)
           end
       end

julia> Foo.foo()
ERROR: On worker 2:
UndefVarError: `gvar` not defined
Stacktrace:
[...]

在上面的示例中,@everywhere module Foo在所有节点上定义了Foo。但是对Foo.foo()的调用在本地节点上创建了一个新的全局绑定gvar,但在节点2上未找到该绑定,导致出现UndefVarError错误。

请注意,这并不适用于在Main模块下创建的全局变量。Main模块下的全局变量会被序列化,并且在远程节点上的Main下创建新的绑定。

julia> gvar_self = "Node1"
"Node1"

julia> remotecall_fetch(()->gvar_self, 2)
"Node1"

julia> remotecall_fetch(varinfo, 2)
name          size summary
––––––––– –––––––– –––––––
Base               Module
Core               Module
Main               Module
gvar_self 13 bytes String

这并不适用于functionstruct声明。但是,绑定到全局变量的匿名函数会被序列化,如下所示。

julia> bar() = 1
bar (generic function with 1 method)

julia> remotecall_fetch(bar, 2)
ERROR: On worker 2:
UndefVarError: `#bar` not defined
[...]

julia> anon_bar  = ()->1
(::#21) (generic function with 1 method)

julia> remotecall_fetch(anon_bar, 2)
1

解决“方法不匹配”问题:参数类型不变性和MethodError

为什么声明foo(bar::Vector{Real}) = 42然后调用foo([1])不起作用?

正如您尝试后将会看到的,结果是一个MethodError

julia> foo(x::Vector{Real}) = 42
foo (generic function with 1 method)

julia> foo([1])
ERROR: MethodError: no method matching foo(::Vector{Int64})

Closest candidates are:
  foo(!Matched::Vector{Real})
   @ Main none:1

Stacktrace:
[...]

这是因为Vector{Real}不是Vector{Int}的超类型!您可以使用类似foo(bar::Vector{T}) where {T<:Real}(或者如果函数体中不需要静态参数T,则使用简写形式foo(bar::Vector{<:Real}))的方法解决此问题。T是一个通配符:您首先指定它必须是Real的子类型,然后指定函数接受一个元素类型为该类型的Vector。

同样的问题也适用于任何复合类型Comp,而不仅仅是Vector。如果Comp声明了一个类型为Y的参数,那么另一个类型为X<:Y的参数的类型Comp2不是Comp的子类型。这就是类型不变性(相比之下,Tuple在其参数中是类型协变的)。有关这些内容的更多解释,请参阅参数复合类型

为什么Julia使用*进行字符串连接?为什么不使用+或其他符号?

反对使用+主要论点是字符串连接不具有交换性,而+通常用作交换运算符。虽然Julia社区认识到其他语言使用不同的运算符,并且*对于某些用户来说可能不熟悉,但它传达了某些代数特性。

请注意,您还可以使用string(...)连接字符串(以及转换为字符串的其他值);类似地,可以使用repeat代替^来重复字符串。 插值语法对于构建字符串也很有用。

包和模块

"using"和"import"有什么区别?

"using"和"import"之间存在一些差异(参见模块部分),但有一个重要的差异一开始可能并不直观,而且从表面上看(即语法上)它似乎很小。当使用using加载模块时,您需要说function Foo.bar(...来用新方法扩展模块Foo的函数bar,但是使用import Foo.bar,您只需要说function bar(...,它就会自动扩展模块Foo的函数bar

之所以要为其提供单独的语法,是因为您不希望意外地扩展一个您不知道存在的函数,因为这很容易导致错误。这在使用像字符串或整数这样的常见类型的函数方法时最有可能发生,因为您和另一个模块都可能定义一个方法来处理这种常见类型。如果您使用import,那么您将用您的新实现替换另一个模块对bar(s::AbstractString)的实现,这很容易做一些完全不同的事情(并破坏所有/许多将来使用模块Foo中依赖于调用bar的其他函数)。

空值和缺失值

Julia 中的“空”,“空值”或“缺失值”是如何工作的?

与许多语言(例如 C 和 Java)不同,Julia 对象默认情况下不能为“空”。当引用(变量、对象字段或数组元素)未初始化时,访问它会立即抛出错误。可以使用isdefinedisassigned函数检测这种情况。

有些函数仅用于其副作用,不需要返回值。在这些情况下,约定是返回nothing值,它只是Nothing类型的单例对象。这是一种普通的类型,没有字段;除了这种约定和REPL不为它打印任何内容之外,它没有什么特别之处。一些原本没有值的语言结构也会产生nothing,例如if false; end

对于只有在某些情况下才存在类型为T的值x的情况,可以使用Union{T, Nothing}类型作为函数参数、对象字段和数组元素类型,相当于其他语言中的NullableOptionMaybe。如果值本身可以是nothing(特别是当TAny时),则Union{Some{T}, Nothing}类型更合适,因为x == nothing表示不存在值,而x == Some(nothing)表示存在值为nothing的值。 something函数允许解包Some对象,并使用默认值代替nothing参数。请注意,编译器能够在使用Union{T, Nothing}参数或字段时生成高效的代码。

要以统计意义上的缺失数据(R中的NA或SQL中的NULL)表示,请使用missing对象。有关更多详细信息,请参阅缺失值部分。

在某些语言中,空元组(())被认为是空值的规范形式。但是,在julia中,最好将其视为一个碰巧包含零个值的普通元组。

空(或“底部”)类型,写成Union{}(一个空联合类型),是一种没有值且没有子类型(除了自身)的类型。您通常不需要使用此类型。

内存

xy是数组时,为什么x += y会分配内存?

在Julia中,x += y在降低过程中会被替换为x = x + y。对于数组,这意味着它会分配一个新的数组来存储结果,而不是将结果存储在与x在内存中的相同位置。如果您希望修改x,请使用x .+= y来单独更新每个元素。

虽然这种行为可能会让一些人感到惊讶,但这种选择是有意的。主要原因是Julia中存在不可变对象,它们一旦创建就不能更改其值。实际上,数字是不可变对象;语句x = 5; x += 1不会修改5的含义,它们会修改绑定到x的值。对于不可变对象,更改值的唯一方法是重新赋值。

为了进一步说明,请考虑以下函数

function power_by_squaring(x, n::Int)
    ispow2(n) || error("This implementation only works for powers of 2")
    while n >= 2
        x *= x
        n >>= 1
    end
    x
end

在像x = 5; y = power_by_squaring(x, 4)这样的调用之后,您将得到预期的结果:x == 5 && y == 625。但是,现在假设*=在与矩阵一起使用时,改为修改左侧。将有两个问题

  • 对于一般的方阵,A = A*B不能在没有临时存储的情况下实现:A[1,1]在您完成右侧使用它之前就被计算并存储在左侧。
  • 假设您愿意为计算分配一个临时变量(这将消除使*=就地工作的大部分意义);如果您利用了x的可变性,那么此函数对于可变输入和不可变输入的行为将不同。特别是,对于不可变的x,调用后您将拥有(通常)y != x,但对于可变的x,您将拥有y == x

由于支持泛型编程被认为比通过其他方法(例如,使用广播或显式循环)可以实现的潜在性能优化更重要,因此像+=*=这样的运算符通过重新绑定新值来工作。

异步IO和并发同步写入

为什么对同一流的并发写入会导致输出混合?

虽然流式I/O API是同步的,但底层实现是完全异步的。

考虑以下内容的打印输出

julia> @sync for i in 1:3
           @async write(stdout, string(i), " Foo ", " Bar ")
       end
123 Foo  Foo  Foo  Bar  Bar  Bar

这是因为,虽然write调用是同步的,但在等待I/O的那部分完成时,每个参数的写入会让步给其他任务。

printprintln在调用期间“锁定”流。因此,在上述示例中将write更改为println会导致

julia> @sync for i in 1:3
           @async println(stdout, string(i), " Foo ", " Bar ")
       end
1 Foo  Bar
2 Foo  Bar
3 Foo  Bar

您可以使用ReentrantLock锁定写入,如下所示

julia> l = ReentrantLock();

julia> @sync for i in 1:3
           @async begin
               lock(l)
               try
                   write(stdout, string(i), " Foo ", " Bar ")
               finally
                   unlock(l)
               end
           end
       end
1 Foo  Bar 2 Foo  Bar 3 Foo  Bar

数组

零维数组和标量有什么区别?

零维数组是形式为Array{T,0}的数组。它们的行为类似于标量,但存在重要的差异。它们值得特别提及,因为它们是一个特殊情况,在数组的通用定义下是有逻辑意义的,但一开始可能有点不直观。以下行定义了一个零维数组

julia> A = zeros()
0-dimensional Array{Float64,0}:
0.0

在此示例中,A是一个可变容器,包含一个元素,可以通过A[] = 1.0设置,并通过A[]检索。所有零维数组的大小都相同(size(A) == ()),并且长度相同(length(A) == 1)。特别是,零维数组不是空的。如果您觉得这很难以理解,这里有一些想法可能有助于理解Julia的定义。

  • 零维数组是“点”对应于向量“线”和矩阵“平面”。就像一条线没有面积(但仍然表示一组事物)一样,一个点没有长度或任何维度(但仍然表示一个事物)。
  • 我们将prod(())定义为1,数组中的元素总数是大小的乘积。零维数组的大小为(),因此其长度为1
  • 零维数组本身没有可以索引的维度——它们只是A[]。我们可以对它们应用与所有其他数组维度相同的“尾随一”规则,因此您确实可以像A[1]A[1,1]等那样索引它们;请参阅省略和额外的索引

理解与普通标量之间的差异也很重要。标量不是可变容器(即使它们是可迭代的并且定义了诸如lengthgetindex之类的内容,例如1[] == 1)。特别是,如果将x = 0.0定义为标量,则尝试通过x[] = 1.0更改其值将是错误的。可以通过fill(x)将标量x转换为包含它的零维数组,反之,可以通过a[]将零维数组a转换为包含的标量。另一个区别是,标量可以参与线性代数运算,例如2 * rand(2,2),但使用零维数组fill(2) * rand(2,2)进行类似运算则会出错。

为什么我的 Julia 线性代数运算基准测试结果与其他语言不同?

您可能会发现像

using BenchmarkTools
A = randn(1000, 1000)
B = randn(1000, 1000)
@btime $A \ $B
@btime $A * $B

这样的线性代数基本构建块的简单基准测试与 Matlab 或 R 等其他语言相比可能会有所不同。

由于此类操作只是相关 BLAS 函数的非常薄的包装器,因此差异的原因很可能是

  1. 每种语言使用的 BLAS 库,

  2. 并发线程的数量。

Julia 编译并使用自己的 OpenBLAS 副本,线程当前限制为8(或您的核心数)。

修改 OpenBLAS 设置或使用不同的 BLAS 库(例如英特尔 MKL)编译 Julia 可能可以提高性能。您可以使用MKL.jl,这是一个使 Julia 的线性代数使用英特尔 MKL BLAS 和 LAPACK 而不是 OpenBLAS 的包,或者搜索讨论论坛以获取有关如何手动设置此项的建议。请注意,英特尔 MKL 无法与 Julia 捆绑在一起,因为它不是开源的。

计算集群

如何在分布式文件系统中管理预编译缓存?

在使用共享文件系统的 高性能计算 (HPC) 设施中的 Julia 时,建议使用共享存储库(通过JULIA_DEPOT_PATH环境变量)。从 Julia v1.10 开始,功能相似的多个工作进程上使用相同存储库的 Julia 进程将通过 pidfile 锁进行协调,以便仅在一个进程上花费时间进行预编译,而其他进程则等待。预编译过程将指示进程何时正在预编译或等待另一个正在预编译的进程。如果是非交互式,则消息将通过@debug发出。

但是,由于二进制代码的缓存,从 v1.9 开始的缓存拒绝更加严格,用户可能需要适当地设置JULIA_CPU_TARGET环境变量以获得可在整个 HPC 环境中使用的单个缓存。

Julia 版本

我是否应该使用 Julia 的稳定版、LTS 版或 nightly 版?

Julia 的稳定版是 Julia 发布的最新版本,这是大多数人想要运行的版本。它具有最新的功能,包括改进的性能。Julia 的稳定版根据SemVer版本控制为 v1.x.y。在作为候选版本测试几周后,大约每 4-5 个月就会发布 Julia 的新的次要版本,对应于新的稳定版本。与 LTS 版本不同,稳定版本通常不会在发布另一个稳定版本后接收错误修复。但是,升级到下一个稳定版本始终是可能的,因为每个 Julia v1.x 版本都将继续运行为早期版本编写的代码。

如果您正在寻找一个非常稳定的代码库,则可能更喜欢 Julia 的 LTS(长期支持)版本。Julia 当前的 LTS 版本根据 SemVer 版本控制为 v1.6.x;此分支将继续接收错误修复,直到选择新的 LTS 分支,此时 v1.6.x 系列将不再接收常规错误修复,并且除最保守的用户外,所有用户都建议升级到新的 LTS 版本系列。作为软件包开发者,您可能更喜欢为 LTS 版本开发,以最大化可以使用您的软件包的用户数量。根据 SemVer,为 v1.0 编写的代码将继续适用于所有未来的 LTS 和稳定版本。一般来说,即使以 LTS 为目标,也可以在最新的稳定版本中开发和运行代码,以利用改进的性能;只要避免使用新功能(例如添加的库函数或新方法)。

如果您想利用语言的最新更新,并且不介意今天提供的版本偶尔无法正常工作,则可能更喜欢 Julia 的 nightly 版本。顾名思义,nightly 版本的发布大约每天都会进行(取决于构建基础设施的稳定性)。一般来说,nightly 版本是相当安全的——您的代码不会自燃。但是,它们可能存在偶尔的回归或问题,这些问题只有在更彻底的预发布测试后才能发现。您可能希望针对 nightly 版本进行测试,以确保在发布版本之前捕获影响您用例的此类回归。

最后,您还可以考虑自己从源代码构建 Julia。此选项主要适用于那些在命令行中感到舒适或有兴趣学习的人。如果这描述了您,您可能也有兴趣阅读我们的贡献指南

这些下载类型的链接可以在下载页面上找到,网址为https://julia-lang.cn/downloads/。请注意,并非所有版本的 Julia 都适用于所有平台。

如何在更新 Julia 版本后转移已安装软件包的列表?

每个 Julia 的次要版本都有自己的默认环境。因此,在安装新的 Julia 次要版本后,您使用先前次要版本添加的软件包默认情况下将不可用。给定 Julia 版本的环境由.julia/environments/中与版本号匹配的文件夹中的Project.tomlManifest.toml文件定义,例如.julia/environments/v1.3

如果您安装了新的 Julia 次要版本,例如1.4,并且希望在其默认环境中使用与先前版本(例如1.3)相同的软件包,则可以将1.3文件夹中的Project.toml文件内容复制到1.4。然后,在新 Julia 版本的会话中,通过键入键]进入“包管理模式”,并运行命令instantiate

此操作将从复制的文件中解析出一组与目标 Julia 版本兼容的可行软件包,并在合适的情况下安装或更新它们。如果您不仅要复制软件包集,还要复制先前 Julia 版本中使用的版本,则应在运行 Pkg 命令instantiate之前复制Manifest.toml文件。但是,请注意,软件包可能会定义受 Julia 版本更改影响的兼容性约束,因此您在1.3中具有的确切版本集可能不适用于1.4