前面的几篇基本还是语言语法层面的内容,这一篇开始看 Julia 代码组织和包管理。

需要先说明一下:Julia 的模块系统、项目环境、包管理其实是三个相关但不完全相同的话题:

  • 模块(module):解决名字空间和代码组织问题;
  • 项目环境(environment):解决当前项目依赖哪些包的问题;
  • 包(package):解决代码如何发布、复用、安装的问题。

这几部分在 Julia 里结合得比较紧,但概念上最好还是分开理解。

概述

默认读者已经熟悉 Python 和 MATLAB,这里就不再从“什么是模块”这种层面展开,直接讨论 Julia 的实际组织方式。

如果只写单文件脚本,那么模块和包的存在感不会太强;但只要代码规模稍微大一点,就必须开始区分:

  • 哪些东西属于当前模块自己的名字空间;
  • 哪些代码只是拆文件,应该用 include
  • 哪些依赖来自外部包,应该交给项目环境和 Pkg 管理。

模块基础

为什么需要模块

如果所有函数、常量、类型都直接定义在全局作用域里,那么项目一大,很快就会出现名称冲突。

Julia 使用 module ... end 来创建模块,例如

1
2
3
4
5
module MyModule

greet(name) = println("Hello, $name")

end

模块本质上就是一个独立的全局作用域。

访问模块成员

模块里的名字通常需要通过限定名访问:

1
MyModule.greet("Julia")

这和 Python 的 module.func() 很像。

exportpublic

如果某些名字希望在 using 时自动带出来,可以使用 export

1
2
3
4
5
6
7
module MyModule

export greet

greet(name) = println("Hello, $name")

end

然后:

1
2
3
using .MyModule

greet("Julia")

如果没有 export,那么 using 之后也仍然要写限定名,或者显式导入名字。

一般来说,export 适合公共 API;内部实现细节不要随便导出。

在 Julia 1.11 及以上版本中,还可以使用 public 标记公共 API,而不把名字自动带入调用者命名空间。例如

1
2
3
4
5
6
7
module MyModule

public greet

greet(name) = println("Hello, $name")

end

public 只表达“这是公共接口”,而不承担 export 的命名空间导入效果。对于 Julia 1.12,这是官方文档已经明确支持的语法。

usingimport

这是 Julia 初学者经常会混淆的一组语法。

using

using X 的作用通常可以理解为:

  • 把模块 X 加载进来;
  • 当前作用域可以直接使用 X 这个模块名;
  • 还可以直接使用 X 导出的名字。

例如

1
2
3
using LinearAlgebra

norm([3, 4]) # 5.0

这里 norm 是导出的名字。

如果是包代码而不是临时 REPL 使用,官方文档更推荐显式列出依赖的名字,例如

1
using LinearAlgebra: LinearAlgebra, norm

这样更利于避免未来依赖升级时的命名冲突。

import

import X 也会加载模块,但默认不会把导出的名字直接带进来。使用时通常还是写限定名:

1
2
3
import LinearAlgebra

LinearAlgebra.norm([3, 4])

也可以显式指定名字:

1
2
3
import LinearAlgebra: norm

norm([3, 4])

此外 Julia 还支持用 as 重命名导入标识符,例如

1
import BenchmarkTools as BT

什么时候用 using,什么时候用 import

一个粗略经验:

  • 普通使用第三方包时,using 更方便;
  • 如果只想引入少量明确名字,或者要扩展别人模块里的函数方法,import 更清晰。

尤其是“给别人的函数增加新方法”时,更推荐显式 import 对应名字,例如

1
import Base: show

然后再写自己的 show 方法。Julia 1.12 的官方模块文档也明确强调了这一点:只有 import ModuleName: f 才允许不带模块前缀地为 f 增加新方法。

子模块与相对导入

在一个模块内部还可以继续嵌套子模块,例如

1
2
3
4
5
6
7
8
module ParentModule

module ChildModule
export hello
hello() = println("hello")
end

end

访问时可以写:

1
ParentModule.ChildModule.hello()

在模块内部进行相对导入时,经常看到带点号的写法:

1
2
using .ChildModule
import ..OtherModule: foo

这里:

  • . 表示当前模块;
  • .. 表示上一级模块。

这一点和 Python 的相对导入在感觉上有点像,但语法细节不一样。

include

include 的作用

Julia 没有像 Python 那样“一个文件天然就是一个模块”的规则。Julia 的常见组织方式是:

  • 一个包通常对应一个主模块;
  • 这个模块内部再通过 include("xxx.jl") 把其它文件读进来。

例如

1
2
3
4
5
6
module MyPkg

include("utils.jl")
include("solver.jl")

end

include 的语义更像“在当前模块作用域中执行这个文件里的代码”,而不是 Python 那种独立模块导入。

所以一个很重要的实践习惯是:

  • include 主要用于同一个模块内部拆文件;
  • using / import 用于引入模块。

不要把这两者混为一谈。

文件路径

在脚本中,通常会配合 @__DIR__ 使用:

1
include(joinpath(@__DIR__, "utils.jl"))

这样相对路径会更稳,不会受当前工作目录影响。

预编译与代码加载

Julia 在首次加载包时通常会有明显的编译和预编译开销。

对于使用者来说,常见现象是:

  • 第一次 using SomePackage 比较慢;
  • 第二次会快很多;
  • 第一次真正调用某些新类型组合的方法时,还可能继续触发编译。

这也是 Julia 所谓 TTFX(time to first execution)/ TTFP(time to first plot) 之类讨论的来源。

从体验上看,这和 Python 那种“解释器直接跑字节码”的感觉差别很大。

项目管理

Julia 的项目环境通常由两个文件描述:

  • Project.toml:项目声明,包括名称、uuid、依赖等;
  • Manifest.toml:依赖解析结果和锁定信息。

作用大致相当于:

  • Project.toml 类似于 package.jsonpyproject.toml
  • Manifest.toml 类似于 lock 文件。

通常不需要手动修改 Manifest.toml

激活环境

在 REPL 的 pkg 模式中:

1
pkg> activate .

也可以在普通 Julia 代码中:

1
2
using Pkg
Pkg.activate(".")

激活后,当前目录下的项目环境就会成为依赖解析的主要上下文。

添加和删除依赖

在 pkg 模式中:

1
2
3
4
pkg> add JSON
pkg> rm JSON
pkg> status
pkg> instantiate

其中:

  • add 添加依赖;
  • rm 删除依赖;
  • status 查看当前环境;
  • instantiate 根据 Project.toml / Manifest.toml 还原环境。

如果只是使用项目,通常 instantiate 很关键。

更完整的运行时和环境管理可以看单独那篇 julia-note-runtime-env.md

Julia 包的基本结构

一个标准 Julia 包通常大致长这样:

1
2
3
4
5
6
7
MyPkg/
├── Project.toml
├── Manifest.toml
├── src/
│ └── MyPkg.jl
└── test/
└── runtests.jl

其中 src/MyPkg.jl 通常是主入口,例如

1
2
3
4
5
6
7
module MyPkg

export greet

greet(name) = println("Hello, $name")

end

如果代码较多,再在 src/ 下拆多个文件并用 include 组织。

生成包骨架

最简单的方式通常是使用 Pkg.generate

1
2
using Pkg
Pkg.generate("MyPkg")

不过实际开发里,很多人会使用更加完整的模板工具来生成包结构。

标准库与第三方包

Julia 自带了一部分标准库,例如:

  • LinearAlgebra
  • Statistics
  • Random
  • Printf
  • Pkg
  • Test

这些不一定默认加载,但通常随 Julia 发行版一起提供。

第三方包则需要通过 Pkg.add 安装,例如:

1
2
using Pkg
Pkg.add("Plots")

和 Python 的习惯不同,Julia 的包安装、环境解析、预编译基本都由 Pkg 这一套统一完成。

测试

Julia 自带 Test 标准库,可以直接写测试。

最简单的例子:

1
2
3
4
5
6
using Test

@test 1 + 1 == 2
@testset "basic" begin
@test sqrt(4) == 2
end

对于标准包结构,通常把测试写在 test/runtests.jl 中,然后执行:

1
2
using Pkg
Pkg.test()

模块扩展与方法扩展

Julia 的一个强大之处在于:你可以为已有类型添加新方法,也可以为自己的类型扩展别的模块里的通用函数。

例如为自定义类型扩展 Base.show

1
2
3
4
5
6
7
8
9
10
import Base: show

struct Point2D
x::Float64
y::Float64
end

function show(io::IO, p::Point2D)
print(io, "Point2D(", p.x, ", ", p.y, ")")
end

这里推荐使用 import Base: show,这样语义最明确。

不过这类扩展也要克制:

  • 只扩展真正属于该函数语义范围内的方法;
  • 不要做“类型盗版”(type piracy),也就是同时对“别人的函数 + 别人的类型”乱加方法。

所谓 type piracy,大致是这种情况:

  • 函数不是你定义的;
  • 类型也不是你定义的;
  • 你却在自己包里给这两者的组合额外加方法。

这很容易污染别人的行为,通常应避免。

Julia 调用 Python

在 Julia 的实际使用中,如何调用其它语言尤其是 Python 是一个很重要的需求,目前常见有两种方案:

前者是更传统、更早期的方案,后者是相对更新的方案。

为什么会需要 Python 互操作

原因通常有几个:

  • 某个成熟工具链只存在于 Python 生态;
  • Julia 本身库还不够丰富;
  • 临时迁移项目时,不可能一次性把所有 Python 代码都改写完。

这也是 Julia 在现实使用中经常被提到的一点:语言本身很强,但生态完整度仍然在持续完善。

基本思路

无论哪种桥接方案,本质上都在解决三个问题:

  • 如何找到 Python 解释器;
  • 如何管理 Python 依赖;
  • Julia 和 Python 对象之间如何转换。

例如用 PythonCall.jl 时,通常可以这样写:

1
2
3
4
using PythonCall

math = pyimport("math")
math.sqrt(9) # 3.0

当然,真正在项目里使用时,还要把 Python 环境版本和依赖管理好,否则很容易变成一团糟。

小结

Julia 的模块和包管理整体上不算难,关键是先把几件事分清楚:module 负责名字空间,include 负责拆文件,using / import 负责引入别人的模块,项目环境则交给 Project.tomlManifest.toml。这些概念一旦混在一起,后面就会越写越乱。