Julia 学习笔记——8. 模块与包
前面的几篇基本还是语言语法层面的内容,这一篇开始看 Julia 代码组织和包管理。
需要先说明一下:Julia 的模块系统、项目环境、包管理其实是三个相关但不完全相同的话题:
- 模块(module):解决名字空间和代码组织问题;
- 项目环境(environment):解决当前项目依赖哪些包的问题;
- 包(package):解决代码如何发布、复用、安装的问题。
这几部分在 Julia 里结合得比较紧,但概念上最好还是分开理解。
概述
默认读者已经熟悉 Python 和 MATLAB,这里就不再从“什么是模块”这种层面展开,直接讨论 Julia 的实际组织方式。
如果只写单文件脚本,那么模块和包的存在感不会太强;但只要代码规模稍微大一点,就必须开始区分:
- 哪些东西属于当前模块自己的名字空间;
- 哪些代码只是拆文件,应该用
include; - 哪些依赖来自外部包,应该交给项目环境和
Pkg管理。
模块基础
为什么需要模块
如果所有函数、常量、类型都直接定义在全局作用域里,那么项目一大,很快就会出现名称冲突。
Julia 使用 module ... end 来创建模块,例如
1 | module MyModule |
模块本质上就是一个独立的全局作用域。
访问模块成员
模块里的名字通常需要通过限定名访问:
1 | MyModule.greet("Julia") |
这和 Python 的 module.func() 很像。
export 和 public
如果某些名字希望在 using 时自动带出来,可以使用 export:
1 | module MyModule |
然后:
1 | using .MyModule |
如果没有 export,那么 using 之后也仍然要写限定名,或者显式导入名字。
一般来说,
export适合公共 API;内部实现细节不要随便导出。
在 Julia 1.11 及以上版本中,还可以使用 public 标记公共 API,而不把名字自动带入调用者命名空间。例如
1 | module MyModule |
public 只表达“这是公共接口”,而不承担 export 的命名空间导入效果。对于 Julia 1.12,这是官方文档已经明确支持的语法。
using 与 import
这是 Julia 初学者经常会混淆的一组语法。
using
using X 的作用通常可以理解为:
- 把模块
X加载进来; - 当前作用域可以直接使用
X这个模块名; - 还可以直接使用
X导出的名字。
例如
1 | using LinearAlgebra |
这里 norm 是导出的名字。
如果是包代码而不是临时 REPL 使用,官方文档更推荐显式列出依赖的名字,例如
1 | using LinearAlgebra: LinearAlgebra, norm |
这样更利于避免未来依赖升级时的命名冲突。
import
import X 也会加载模块,但默认不会把导出的名字直接带进来。使用时通常还是写限定名:
1 | import LinearAlgebra |
也可以显式指定名字:
1 | import LinearAlgebra: norm |
此外 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 | module ParentModule |
访问时可以写:
1 | ParentModule.ChildModule.hello() |
在模块内部进行相对导入时,经常看到带点号的写法:
1 | using .ChildModule |
这里:
.表示当前模块;..表示上一级模块。
这一点和 Python 的相对导入在感觉上有点像,但语法细节不一样。
include
include 的作用
Julia 没有像 Python 那样“一个文件天然就是一个模块”的规则。Julia 的常见组织方式是:
- 一个包通常对应一个主模块;
- 这个模块内部再通过
include("xxx.jl")把其它文件读进来。
例如
1 | module MyPkg |
include 的语义更像“在当前模块作用域中执行这个文件里的代码”,而不是 Python 那种独立模块导入。
所以一个很重要的实践习惯是:
include主要用于同一个模块内部拆文件;using/import用于引入模块。
不要把这两者混为一谈。
文件路径
在脚本中,通常会配合 @__DIR__ 使用:
1 | include(joinpath(, "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.json或pyproject.toml;Manifest.toml类似于 lock 文件。
通常不需要手动修改 Manifest.toml。
激活环境
在 REPL 的 pkg 模式中:
1 | pkg> activate . |
也可以在普通 Julia 代码中:
1 | using Pkg |
激活后,当前目录下的项目环境就会成为依赖解析的主要上下文。
添加和删除依赖
在 pkg 模式中:
1 | pkg> add JSON |
其中:
add添加依赖;rm删除依赖;status查看当前环境;instantiate根据Project.toml/Manifest.toml还原环境。
如果只是使用项目,通常 instantiate 很关键。
更完整的运行时和环境管理可以看单独那篇 julia-note-runtime-env.md。
Julia 包的基本结构
一个标准 Julia 包通常大致长这样:
1 | MyPkg/ |
其中 src/MyPkg.jl 通常是主入口,例如
1 | module MyPkg |
如果代码较多,再在 src/ 下拆多个文件并用 include 组织。
生成包骨架
最简单的方式通常是使用 Pkg.generate:
1 | using Pkg |
不过实际开发里,很多人会使用更加完整的模板工具来生成包结构。
标准库与第三方包
Julia 自带了一部分标准库,例如:
LinearAlgebraStatisticsRandomPrintfPkgTest
这些不一定默认加载,但通常随 Julia 发行版一起提供。
第三方包则需要通过 Pkg.add 安装,例如:
1 | using Pkg |
和 Python 的习惯不同,Julia 的包安装、环境解析、预编译基本都由 Pkg 这一套统一完成。
测试
Julia 自带 Test 标准库,可以直接写测试。
最简单的例子:
1 | using Test |
对于标准包结构,通常把测试写在 test/runtests.jl 中,然后执行:
1 | using Pkg |
模块扩展与方法扩展
Julia 的一个强大之处在于:你可以为已有类型添加新方法,也可以为自己的类型扩展别的模块里的通用函数。
例如为自定义类型扩展 Base.show:
1 | import Base: show |
这里推荐使用 import Base: show,这样语义最明确。
不过这类扩展也要克制:
- 只扩展真正属于该函数语义范围内的方法;
- 不要做“类型盗版”(type piracy),也就是同时对“别人的函数 + 别人的类型”乱加方法。
所谓 type piracy,大致是这种情况:
- 函数不是你定义的;
- 类型也不是你定义的;
- 你却在自己包里给这两者的组合额外加方法。
这很容易污染别人的行为,通常应避免。
Julia 调用 Python
在 Julia 的实际使用中,如何调用其它语言尤其是 Python 是一个很重要的需求,目前常见有两种方案:
- PyCall.jl,搭配
Conda.jl - PythonCall.jl,搭配
CondaPkg.jl
前者是更传统、更早期的方案,后者是相对更新的方案。
为什么会需要 Python 互操作
原因通常有几个:
- 某个成熟工具链只存在于 Python 生态;
- Julia 本身库还不够丰富;
- 临时迁移项目时,不可能一次性把所有 Python 代码都改写完。
这也是 Julia 在现实使用中经常被提到的一点:语言本身很强,但生态完整度仍然在持续完善。
基本思路
无论哪种桥接方案,本质上都在解决三个问题:
- 如何找到 Python 解释器;
- 如何管理 Python 依赖;
- Julia 和 Python 对象之间如何转换。
例如用 PythonCall.jl 时,通常可以这样写:
1 | using PythonCall |
当然,真正在项目里使用时,还要把 Python 环境版本和依赖管理好,否则很容易变成一团糟。
小结
Julia 的模块和包管理整体上不算难,关键是先把几件事分清楚:module 负责名字空间,include 负责拆文件,using / import 负责引入别人的模块,项目环境则交给 Project.toml 和 Manifest.toml。这些概念一旦混在一起,后面就会越写越乱。
