Julia 学习笔记——5. 变量作用域与类型系统
Julia 的作用域和类型系统的设计非常规范化。相对于 Python 或 MATLAB,Julia 在这两部分会明显显得更“讲规矩”。
这一篇主要关注两个问题:
- 变量名在什么地方可见,什么时候会新建局部变量;
- Julia 的类型系统到底是怎样组织的,平时写代码应该关注到什么程度。
如果只从“写脚本”的角度看,Julia 和 Python 确实有很多相似之处;但一旦开始关心性能、分派和泛型代码,Julia 的作用域和类型系统就会立刻变成核心内容。
变量作用域
基本概念
Julia 使用词法作用域(lexical scope),也就是一个变量在函数内部能否被访问,取决于函数定义的位置,而不是调用的位置。
例如
1 | x = 10 |
这里 f() 可以访问到外部定义的 x,是因为 x 在 f 定义处可见。
需要注意,Julia 中并不是所有语句都会引入新的作用域:
function、let、struct、宏、推导式等会引入新的局部作用域;for、while、try也会引入局部作用域;if和begin不会引入新的作用域。
例如
1 | if true |
而下面的循环则不同
1 | for i = 1:3 |
全局作用域与局部作用域
在 REPL、脚本顶层、模块顶层定义的变量,通常都处于某种全局作用域中。
函数体内部通常是局部作用域,例如
1 | x = 1 |
这里函数内部的 x 是一个新的局部变量,不会修改外部全局变量。
如果确实要在函数内部修改全局变量,需要显式使用 global,例如
1 | x = 1 |
但是一般不建议这样写。
和 Python 一样,频繁依赖可变全局变量会让代码更难维护;和 Python 不同的是,这还会明显影响 Julia 的性能推断。
soft scope 和 hard scope
Julia 的作用域规则里一个比较绕的点是 soft scope(软作用域)和 hard scope(硬作用域)。
简单理解:
function、let、推导式等属于 hard scope;- 顶层的
for、while、try往往属于 soft scope。
这套机制主要是为了让 REPL 交互更顺手,但也导致 REPL 与脚本顶层代码在某些边缘场景上表现不同。
最典型的问题如下
1 | x = 0 |
在 REPL 里,这样的代码通常可以按你直觉工作;但是在脚本文件里,Julia 可能会提示作用域歧义,要求你显式写出 global x = x + i 或者把逻辑包进函数里。
所以实际建议非常简单:
- 小型交互实验可以直接在 REPL 写;
- 正式代码尽量放进函数中;
- 不要依赖顶层循环里对全局变量的隐式修改。
local、global 和 let
可以用 local 明确声明局部变量,用 global 明确声明全局变量。
例如
1 | x = 1 |
let 语句会显式创建一个新的局部作用域,经常用来绑定临时变量,或者避免闭包共享同一个外部变量,例如
1 | x = 10 |
闭包与循环变量
Julia 支持闭包,也就是函数可以捕获定义时可见的外部变量。
例如
1 | function make_adder(x) |
这和 Python 的闭包行为整体上比较类似。
不过在循环中创建闭包时,仍然建议谨慎处理捕获变量。例如
1 | funcs = [] |
Julia 在这类场景下通常比 Python 更自然,因为循环本身就形成了新的局部绑定;但如果代码更复杂,还是推荐用 let 显式固定住当前值。
类型系统概述
动态语言,但不是“无类型”
Julia 是动态语言,意思是变量本身不需要预先声明类型,类型检查很多时候发生在运行期。
但 Julia 绝对不是“只在运行时随便兜底”的那类脚本语言。恰恰相反,Julia 的类型系统非常完整:
- 每个值都有明确类型;
- 类型之间有清晰的继承关系;
- 多态分派依赖参数类型;
- 编译器会大量利用类型信息做优化。
例如
1 | typeof(1) # Int64 |
抽象类型与具体类型
Julia 的类型大致分为两类:
- 抽象类型(abstract type):用于描述一类对象,不直接创建实例;
- 具体类型(concrete type):可以直接创建值的类型。
例如 Integer 是抽象类型,Int64 是具体类型。
1 | Int64 <: Integer # true |
但是
1 | Integer(3) # error |
因为抽象类型本身不能实例化。
类型层次
Julia 的类型体系本质上是一棵树,根节点是 Any。
简单示意如下
1 | Any |
可以用 supertype() 查看父类型,例如
1 | supertype(Int64) # Signed |
也可以用 <: 判断是否为子类型关系。
Any、Union 与 Nothing
Any 是所有类型的公共父类型,因此任何值都可以放进 Vector{Any} 这种容器里,例如
1 | a = Any[1, "hello", 3.14] |
这样很灵活,但通常会损失性能,因为数组元素类型不再具体。
Julia 还支持联合类型 Union,用于表示“可以是几种类型中的任意一种”,例如
1 | Union{Int64, String} |
一个很常见的模式是
1 | Union{Nothing, Int} |
这表示一个值要么是整数,要么是 nothing。它相当于 Python 里“可能是 int,也可能是 None” 的意思。
不变性与参数化类型
Julia 的参数化类型语法非常常见,例如
1 | Vector{Int64} |
这里的 {...} 不是泛型模板实例化的语法糖,而是 Julia 类型系统的核心组成部分。
需要特别注意一点:Julia 的参数化类型默认是不变的(invariant)。
例如
1 | Vector{Int64} <: Vector{Integer} # false |
这和很多人第一次看到时的直觉不一样。虽然 Int64 <: Integer 为真,但 Vector{Int64} 并不是 Vector{Integer} 的子类型。
如果想表达“元素类型是 Integer 的某个子类型的向量”,常见写法是
1 | Vector{<:Integer} |
或者在函数里写成
1 | function sum_ints(x::Vector{<:Integer}) |
类型断言与类型标注
Julia 可以在变量、参数、返回值等位置做类型标注,但语义并不完全相同。
例如变量类型标注
1 | x::Int64 = 1 |
如果赋值值不满足要求,Julia 会尝试转换;无法转换时则报错。
也可以在表达式后使用类型断言
1 | (1 + 2)::Int64 |
这表示“我断言这个表达式的结果应该是 Int64”。如果不满足,会抛出类型错误。
需要强调的是:
- 类型标注可以帮助代码表达意图;
- 但在 Julia 中,滥用局部变量类型标注通常没什么意义;
- 真正重要的是保持代码类型稳定,而不是到处手工写类型。
自定义类型
struct 与 mutable struct
Julia 没有传统意义上的 class 体系,主要提供的是结构体。
不可变结构体
1 | struct Point2D |
默认情况下,struct 是不可变的,字段不能直接修改:
1 | p.x = 10.0 # error |
如果需要可变对象,则使用 mutable struct
1 | mutable struct Counter |
和 Python 的 class 相比,Julia 这种设计更加克制:
- 数据结构是数据结构;
- 方法是独立定义并通过多重分派关联上去;
- 不强调“把方法塞进类里面”。
参数化类型
自定义类型同样可以参数化,例如
1 | struct Point{T} |
还可以进一步加约束
1 | struct RealPoint{T<:Real} |
这样就能限制参数类型只能是 Real 的子类型。
抽象类型的定义
也可以自定义抽象类型,用于组织自己的类型层次,例如
1 | abstract type AbstractShape end |
然后给不同具体类型分别定义方法:
1 | area(s::Circle) = pi * s.r^2 |
这正是 Julia 常见的设计方式。
类型系统对性能的影响
尽量避免未类型化的全局变量
Julia 的性能问题,很多都和类型信息能否稳定推断有关。最常见的坑就是全局变量。
例如
1 | x = 1.0 |
这里 x 是全局变量,编译器往往不能像处理局部常量那样放心优化。
更推荐的写法是:
- 把数据作为参数传入函数;
- 或者把不会变的全局量写成
const。
例如
1 | const SCALE = 2.0 |
类型稳定
所谓类型稳定,简单说就是:函数的返回值类型最好能从输入类型稳定推断出来。
例如下面这个函数不太理想
1 | function test(x) |
它可能返回 Int64,也可能返回 Float64。
更好的写法是保持返回类型一致,例如
1 | function test2(x) |
或者
1 | test3(x) = x > 0 ? one(Float64) : one(Float64) |
当然这只是示意。实际代码里应该根据语义设计返回值类型,而不是机械地“全都转成浮点数”。
用 @code_warntype 辅助分析
Julia 提供了很强的代码分析工具,例如
1 | test(1) |
它可以帮助查看编译器是否顺利推断出了稳定类型。这个工具对于定位性能问题非常有帮助,只是刚入门时没必要过度沉迷。
小结
如果只记几个最实用的结论,那么大概是:
- 把主要计算逻辑写进函数,而不是堆在全局作用域;
- 不要依赖脚本顶层
for循环去隐式修改全局变量; struct默认不可变,想改字段要用mutable struct;Vector{Int}不是Vector{Integer}的子类型;- 写 Julia 时真正重要的是类型稳定,而不是疯狂手写类型标注。
