Julia 的作用域和类型系统的设计非常规范化。相对于 Python 或 MATLAB,Julia 在这两部分会明显显得更“讲规矩”。

这一篇主要关注两个问题:

  • 变量名在什么地方可见,什么时候会新建局部变量;
  • Julia 的类型系统到底是怎样组织的,平时写代码应该关注到什么程度。

如果只从“写脚本”的角度看,Julia 和 Python 确实有很多相似之处;但一旦开始关心性能、分派和泛型代码,Julia 的作用域和类型系统就会立刻变成核心内容。

变量作用域

基本概念

Julia 使用词法作用域(lexical scope),也就是一个变量在函数内部能否被访问,取决于函数定义的位置,而不是调用的位置。

例如

1
2
3
4
5
6
7
x = 10

function f()
return x
end

f() # 10

这里 f() 可以访问到外部定义的 x,是因为 xf 定义处可见。

需要注意,Julia 中并不是所有语句都会引入新的作用域:

  • functionletstruct、宏、推导式等会引入新的局部作用域;
  • forwhiletry 也会引入局部作用域;
  • ifbegin 不会引入新的作用域。

例如

1
2
3
4
5
if true
a = 1
end

a # 1

而下面的循环则不同

1
2
3
4
5
for i = 1:3
b = i
end

b # 在脚本或函数中通常不可见

全局作用域与局部作用域

在 REPL、脚本顶层、模块顶层定义的变量,通常都处于某种全局作用域中。

函数体内部通常是局部作用域,例如

1
2
3
4
5
6
7
8
9
x = 1

function g()
x = 2
return x
end

g() # 2
x # 1

这里函数内部的 x 是一个新的局部变量,不会修改外部全局变量。

如果确实要在函数内部修改全局变量,需要显式使用 global,例如

1
2
3
4
5
6
7
8
x = 1

function update_global!()
global x = 100
end

update_global!()
x # 100

但是一般不建议这样写。

和 Python 一样,频繁依赖可变全局变量会让代码更难维护;和 Python 不同的是,这还会明显影响 Julia 的性能推断。

soft scope 和 hard scope

Julia 的作用域规则里一个比较绕的点是 soft scope(软作用域)和 hard scope(硬作用域)。

简单理解:

  • functionlet、推导式等属于 hard scope;
  • 顶层的 forwhiletry 往往属于 soft scope。

这套机制主要是为了让 REPL 交互更顺手,但也导致 REPL 与脚本顶层代码在某些边缘场景上表现不同。

最典型的问题如下

1
2
3
4
5
x = 0

for i = 1:3
x = x + i
end

在 REPL 里,这样的代码通常可以按你直觉工作;但是在脚本文件里,Julia 可能会提示作用域歧义,要求你显式写出 global x = x + i 或者把逻辑包进函数里。

所以实际建议非常简单:

  • 小型交互实验可以直接在 REPL 写;
  • 正式代码尽量放进函数中;
  • 不要依赖顶层循环里对全局变量的隐式修改。

local、global 和 let

可以用 local 明确声明局部变量,用 global 明确声明全局变量。

例如

1
2
3
4
5
6
7
8
9
x = 1

function demo()
local x = 2
return x
end

demo() # 2
x # 1

let 语句会显式创建一个新的局部作用域,经常用来绑定临时变量,或者避免闭包共享同一个外部变量,例如

1
2
3
4
5
6
7
8
9
10
x = 10

let x = 20
println(x)
end

println(x)

# 20
# 10

闭包与循环变量

Julia 支持闭包,也就是函数可以捕获定义时可见的外部变量。

例如

1
2
3
4
5
6
function make_adder(x)
y -> x + y
end

add3 = make_adder(3)
add3(10) # 13

这和 Python 的闭包行为整体上比较类似。

不过在循环中创建闭包时,仍然建议谨慎处理捕获变量。例如

1
2
3
4
5
6
7
8
9
funcs = []

for i = 1:3
push!(funcs, () -> i)
end

funcs[1]() # 1
funcs[2]() # 2
funcs[3]() # 3

Julia 在这类场景下通常比 Python 更自然,因为循环本身就形成了新的局部绑定;但如果代码更复杂,还是推荐用 let 显式固定住当前值。

类型系统概述

动态语言,但不是“无类型”

Julia 是动态语言,意思是变量本身不需要预先声明类型,类型检查很多时候发生在运行期。

但 Julia 绝对不是“只在运行时随便兜底”的那类脚本语言。恰恰相反,Julia 的类型系统非常完整:

  • 每个值都有明确类型;
  • 类型之间有清晰的继承关系;
  • 多态分派依赖参数类型;
  • 编译器会大量利用类型信息做优化。

例如

1
2
3
typeof(1)      # Int64
typeof(1.0) # Float64
typeof("abc") # String

抽象类型与具体类型

Julia 的类型大致分为两类:

  • 抽象类型(abstract type):用于描述一类对象,不直接创建实例;
  • 具体类型(concrete type):可以直接创建值的类型。

例如 Integer 是抽象类型,Int64 是具体类型。

1
2
Int64 <: Integer   # true
Integer <: Real # true

但是

1
Integer(3) # error

因为抽象类型本身不能实例化。

类型层次

Julia 的类型体系本质上是一棵树,根节点是 Any

简单示意如下

1
2
3
4
5
6
7
8
9
10
11
12
13
Any
├─ Number
│ ├─ Real
│ │ ├─ Integer
│ │ │ ├─ Signed
│ │ │ │ └─ Int64
│ │ │ └─ Unsigned
│ │ ├─ AbstractFloat
│ │ │ └─ Float64
│ │ └─ Rational
│ └─ Complex
└─ AbstractString
└─ String

可以用 supertype() 查看父类型,例如

1
2
supertype(Int64)   # Signed
supertype(Signed) # Integer

也可以用 <: 判断是否为子类型关系。

AnyUnionNothing

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
2
3
Vector{Int64}
Dict{String, Int64}
Tuple{Int64, String}

这里的 {...} 不是泛型模板实例化的语法糖,而是 Julia 类型系统的核心组成部分。

需要特别注意一点:Julia 的参数化类型默认是不变的(invariant)

例如

1
Vector{Int64} <: Vector{Integer}  # false

这和很多人第一次看到时的直觉不一样。虽然 Int64 <: Integer 为真,但 Vector{Int64} 并不是 Vector{Integer} 的子类型。

如果想表达“元素类型是 Integer 的某个子类型的向量”,常见写法是

1
Vector{<:Integer}

或者在函数里写成

1
2
3
function sum_ints(x::Vector{<:Integer})
return sum(x)
end

类型断言与类型标注

Julia 可以在变量、参数、返回值等位置做类型标注,但语义并不完全相同。

例如变量类型标注

1
x::Int64 = 1

如果赋值值不满足要求,Julia 会尝试转换;无法转换时则报错。

也可以在表达式后使用类型断言

1
(1 + 2)::Int64

这表示“我断言这个表达式的结果应该是 Int64”。如果不满足,会抛出类型错误。

需要强调的是:

  • 类型标注可以帮助代码表达意图;
  • 但在 Julia 中,滥用局部变量类型标注通常没什么意义;
  • 真正重要的是保持代码类型稳定,而不是到处手工写类型。

自定义类型

structmutable struct

Julia 没有传统意义上的 class 体系,主要提供的是结构体。

不可变结构体

1
2
3
4
5
6
struct Point2D
x::Float64
y::Float64
end

p = Point2D(1.0, 2.0)

默认情况下,struct 是不可变的,字段不能直接修改:

1
p.x = 10.0 # error

如果需要可变对象,则使用 mutable struct

1
2
3
4
5
6
mutable struct Counter
value::Int
end

c = Counter(0)
c.value = 10

和 Python 的 class 相比,Julia 这种设计更加克制:

  • 数据结构是数据结构;
  • 方法是独立定义并通过多重分派关联上去;
  • 不强调“把方法塞进类里面”。

参数化类型

自定义类型同样可以参数化,例如

1
2
3
4
5
6
7
struct Point{T}
x::T
y::T
end

Point(1, 2) # Point{Int64}(1, 2)
Point(1.0, 2.0) # Point{Float64}(1.0, 2.0)

还可以进一步加约束

1
2
3
4
struct RealPoint{T<:Real}
x::T
y::T
end

这样就能限制参数类型只能是 Real 的子类型。

抽象类型的定义

也可以自定义抽象类型,用于组织自己的类型层次,例如

1
2
3
4
5
6
7
8
9
10
abstract type AbstractShape end

struct Circle <: AbstractShape
r::Float64
end

struct Rectangle <: AbstractShape
w::Float64
h::Float64
end

然后给不同具体类型分别定义方法:

1
2
area(s::Circle) = pi * s.r^2
area(s::Rectangle) = s.w * s.h

这正是 Julia 常见的设计方式。

类型系统对性能的影响

尽量避免未类型化的全局变量

Julia 的性能问题,很多都和类型信息能否稳定推断有关。最常见的坑就是全局变量。

例如

1
2
3
4
5
x = 1.0

function f()
return x + 1
end

这里 x 是全局变量,编译器往往不能像处理局部常量那样放心优化。

更推荐的写法是:

  • 把数据作为参数传入函数;
  • 或者把不会变的全局量写成 const

例如

1
2
3
const SCALE = 2.0

f(x) = SCALE * x

类型稳定

所谓类型稳定,简单说就是:函数的返回值类型最好能从输入类型稳定推断出来。

例如下面这个函数不太理想

1
2
3
4
5
6
7
function test(x)
if x > 0
return 1
else
return 1.0
end
end

它可能返回 Int64,也可能返回 Float64

更好的写法是保持返回类型一致,例如

1
2
3
4
5
6
7
function test2(x)
if x > 0
return 1.0
else
return 1.0
end
end

或者

1
test3(x) = x > 0 ? one(Float64) : one(Float64)

当然这只是示意。实际代码里应该根据语义设计返回值类型,而不是机械地“全都转成浮点数”。

@code_warntype 辅助分析

Julia 提供了很强的代码分析工具,例如

1
@code_warntype test(1)

它可以帮助查看编译器是否顺利推断出了稳定类型。这个工具对于定位性能问题非常有帮助,只是刚入门时没必要过度沉迷。

小结

如果只记几个最实用的结论,那么大概是:

  • 把主要计算逻辑写进函数,而不是堆在全局作用域;
  • 不要依赖脚本顶层 for 循环去隐式修改全局变量;
  • struct 默认不可变,想改字段要用 mutable struct
  • Vector{Int} 不是 Vector{Integer} 的子类型;
  • 写 Julia 时真正重要的是类型稳定,而不是疯狂手写类型标注。