变量的作用域
变量的作用域是变量在代码中可见的范围。变量作用域有助于避免变量命名冲突。这个概念很直观:两个函数都可以称为x
的参数,但这两个x
所指的不是同一件事。类似地,在许多其他情况下,不同的代码块可以使用相同的名称而不引用相同的东西。当相同的变量名指向或不指向同一事物时,这种规则称为作用域规则。本章将对它们进行详细说明。
语言中的某些构造引入了范围块,在这些范围块内,规定了某些变量集的作用域。变量的作用域不能是源代码的任意一组集合,相反,它将总是与这些块中的一个相关。在Julia中有两种主要类型的作用域,全局作用域和局部作用域。后者可以嵌套。在Julia中,构造的“硬作用域”和“软作用域”之间也有区别,它们会影响是否允许使用同名变量隐藏一个全局变量。
作用域构造
引入作用域块的构造有:
构造 | 作用域类型 | 允许的位置 |
---|---|---|
module ,baremodule | 全局 | 全局 |
struct | 局部(软) | 全局 |
for ,while ,try | 局部(软) | 全局,局部 |
macro | 局部(硬) | 全局 |
函数,do 块,let 块,推导,生成器 | 局部(硬) | 全局,局部 |
值得注意的是,这个表中没有begin
块和if
块,它们没有引入新的作用域。这三种类型的作用域遵循不同的规则,下面将对此进行解释。
Julia使用词法作用域,这意味着函数的作用域不是继承自调用者的作用域,而是继承自定义函数的作用域。例如,在以下代码中,foo
中的x
引用了其模块Bar
的全局作用域中的x
:
julia> module Bar x = 1 foo() = x end;
而不是使用foo
的作用域中的x
:
julia> import .Bar
julia> x = -1;
julia> Bar.foo()
1
因此,词法作用域意味着特定代码段中的变量所引用的内容可以单独从其出现的代码中推断出来,而不依赖于程序的执行方式。嵌套在另一个作用域中的作用域可以“看到”包含它的所有外部作用域中的变量。但是,外部作用域不能看到内部作用域中的变量。
全局作用域
每个模块都引入一个新的全局作用域,它与所有其他模块的全局作用域分开——不存在包含所有全局作用域的全局作用域。模块可以通过using
或import
语句,或通过使用点表示法的授权访问,将其他模块的变量引入其作用域,即每个模块都是一个所谓的命名空间,也是一个将名称与值关联起来的一级数据结构。注意,虽然可以在外部读取变量绑定的值,但只能在它们所属的模块内更改它们。作为一个安全门,你总是可以在模块内执行代码来修改一个变量。这避免了模块绑定在外部不通过调用eval
而直接对代码进行修改。
julia> module A a = 1 # a global in A's scope end;
julia> module B module C c = 2 end b = C.c # can access the namespace of a nested global scope # through a qualified access import ..A # makes module A available d = A.a end;
julia> module D b = a # errors as D's global scope is separate from A's end;
ERROR: UndefVarError: a not defined
julia> module E import ..A # make module A available A.a = 2 # throws an error end;
ERROR: cannot assign variables in other modules
注意,交互提示符(又名REPL)位于模块Main
的全局作用域内。
局部作用域
大多数代码块都会引入一个新的局部作用域(完整列表见上表)。一些编程语言要求在使用新变量之前会显式地声明它们。在Julia中也可以使用显式声明:在任何局部作用域中,不管外部作用域中是否已经存在名为x
的变量,写入local x
就在该作用域中声明了一个新的局部变量。然而,像这样声明每个新的局部变量有点冗长和乏味,因此,与许多其他语言一样,Julia通过在局部作用域中对新变量赋值,从而隐式地将该变量声明为一个新的局部变量。大多数情况下,这是相当直观的,但与许多凭直觉行事的事情一样,细节要比从表面看起来的要微妙得多。
当x = <value>
发生在局部作用域中时,Julia应用以下规则,根据赋值表达式发生的位置和x
在该位置已经引用的内容来决定表达式的含义:
现有的局部变量:如果
x
已经是一个局部变量,则对现有的局部变量x
赋值。硬作用域:如果
x
还不是一个局部变量,并且赋值发生在硬作用域构造中(例如,在let
块、函数或宏体、推导或生成器中),则在赋值的作用域中创建一个名为x
的新局部变量。软作用域:如果
x
还不是一个局部变量,并且所有包含赋值的作用域结构都是软作用域(循环、try
/catch
块或struct
块),则行为取决于是否定义了全局变量x
:如果全局
x
未定义,则在赋值范围内创建一个名为x
的新局部变量。如果定义了全局
x
,赋值被认为是歧义的:在非交互式环境中(文件,
eval
),会打印一个歧义警告,并创建一个新的局部变量。在交互式环境中(REPL, notebook),全局变量
x
被赋值。
你可能会注意到,在非交互式环境中,硬作用域和软作用域的行为是相同的,只不过在软作用域中,当一个隐式局部变量(即没有使用local x
声明)对全局变量造成屏蔽时,会打印一个警告。在交互式环境中,为了方便起见,规则遵循更复杂的启发式。下面的示例将深入讨论这一点。
既然已经知道了规则,让我们看一些例子。假设每个示例都是在一个全新的REPL会话中计算的,这样每个代码段中的惟一全局变量就是在该代码块中分配的全局变量。
我们将从一个优雅而明确的情况开始——在硬作用域内赋值,在本例中是一个函数体,此时不存在同名的局部变量:
julia> function greet() x = "hello" # new local println(x) end
greet (generic function with 1 method)
julia> greet()
hello
julia> x # global
ERROR: UndefVarError: x not defined
在greet
函数内部,赋值x = "hello"
使x
成为函数作用域中的一个新的局部变量。有两个相关的事实:赋值发生在局部作用域中,并且没有现有的局部x
变量。因为x
是局部的,所以是否存在一个全局变量x
并不重要。例如,在定义和调用greet
之前,我们先定义了x = 123
:
julia> x = 123 # global
123
julia> function greet() x = "hello" # new local println(x) end
greet (generic function with 1 method)
julia> greet()
hello
julia> x #global
123
由于greet
中的x
是局部的,全局x
的值(或不存在这样的值)不受调用greet
的影响。硬作用域规则并不关心名为x
的全局变量是否存在:在硬作用域中对x
的赋值是局部的(除非x
在其中被显示地声明为全局的)。
我们要考虑的下一个明确的情况是,已经有一个名为x
的局部变量,在这种情况下,x = <value>
总是赋值给这个已经存在的局部变量x
。下面的函数sum_to
计算从1到n的数字之和:
julia> function sum_to(n) s = 0 # new local for i = 1:n s = s + i # assign existing local end return s # same local end
sum_to (generic function with 1 method)
与前面的示例一样,在sum_to
的顶部对s
的第一次赋值将导致s
成为函数体中的一个新的局部变量。for
循环在函数作用域中有自己的内部局部作用域。当s = s + i
出现时,s
已经是一个局部变量,因此赋值更新了现有的s
,而不是创建一个新的局部变量。我们可以通过调用REPL中的sum_to
来测试:
julia> sum_to(10)
55
julia> s
ERROR: UndefVarError: s not defined
因为s
是sum_to
的本地函数,调用函数对全局变量s
没有影响。我们也可以看到在for
循环中更新s = s + i
必定更新了由初始化s = 0
创建的s
,因为我们得到了从整数1到10的正确的和55。
让我们先深入了解一下for
循环体有它自己的作用域这一事实,我们可以写一个稍微更复杂的变体sum_to_def
,在更新s
之前将和s + i
保存在变量t
中:
julia> function sum_to_def(n) s = 0 # new local for i = 1:n t = s + i # new local `t` s = t # assign existing local `s` end return s, @isdefined(t) end
sum_to_def (generic function with 1 method)
julia> sum_to_def(10)
(55, false)
这个版本像以前一样返回s
,但它也使用@isdefined
宏返回一个布尔值,显示函数的最外层局部作用域中是否定义了一个名为t
的局部变量。如你所见,在for
循环体之外没有定义t
。这还是因为硬作用域规则:由于对t
的赋值发生在函数内部,这引入了硬作用域,赋值导致t
在它出现的局部作用域内成为一个新的局部变量,即在循环体内部。即使有一个名为t
的全局变量,也不会有什么区别——硬作用域规则不会受到全局作用域中情况的影响。
让我们来看看软作用域规则所涵盖的一些更模糊的情况。我们将通过将greet
和sum_to_def
函数的主体提取到软作用域环境中来研究这个问题。首先,让我们把greet
的主体放到for
循环中——它是软的,而不是硬的——然后在REPL中计算它:
julia> for i = 1:3 x = "hello" # new local println(x) end
hello hello hello
julia> x
ERROR: UndefVarError: x not defined
由于在执行for
循环时没有定义全局x
,因此软作用域规则的第一种情况将被应用,x
将被创建为for
循环的局部变量,因此在循环执行后全局x
仍未定义。接下来,让我们考虑将sum_to_def
的主体提取到全局作用域,将其参数固定为n = 10
:
s = 0
for i = 1:10
t = s + i
s = t
end
s
@isdefined(t)
这段代码做了什么的?这是个棘手的问题。答案是“视情况而定”。如果以交互方式输入此代码,则其行为与在函数体中相同。但是,如果代码出现在文件中,它会打印一个歧义警告,并抛出一个未定义变量的错误。让我们先看看它在REPL中的工作情况:
julia> s = 0 # global
0
julia> for i = 1:10 t = s + i # new local `t` s = t # assign global `s` end
julia> s # global
55
julia> @isdefined(t) # global
false
REPL通过判断是否定义了同名的全局变量来确定循环内的赋值是赋值给全局变量还是创建新的局部变量,这种行为近似于函数体内的行为。如果存在同名的全局变量,则赋值操作会更新它。如果不存在全局变量,则赋值将创建一个新的局部变量。在这个例子中,我们看到了这两种情况:
没有全局变量
t
,所以t = s + i
创建一个新的t
,它是for
循环的局部变量。有一个全局变量
s
,所以s = t
赋值给它。
第二条解释了为什么循环的执行会改变s
的全局值,第一条解释了为什么在循环执行后t
仍然是未定义的。现在,让我们对相同的代码进行改造,模拟代码在文件中的情况,然后执行:
julia> code = """ s = 0 # global for i = 1:10 t = s + i # new local `t` s = t # new local `s` with warning end s, # global @isdefined(t) # global """;
julia> include_string(Main, code)
┌ Warning: Assignment to `s` in soft scope is ambiguous because a global variable by the same name exists: `s` will be treated as a new local. Disambiguate by using `local s` to suppress this warning or `global s` to assign to the existing global variable. └ @ string:4 ERROR: LoadError: UndefVarError: s not defined in expression starting at string:2
这里我们使用include_string
来执行代码,就好像它是文件的内容一样。我们也可以将代码保存到文件中,然后在该文件上调用include
——结果将是相同的。如你所见,这与在REPL中的结果有很大的不同。让我们来分析一下这里发生了什么:
在执行循环之前,全局变量
s
被定义为值0。赋值
s = t
发生在软作用域中——任何函数体或其他硬作用域构造之外的for
循环中。因此,适用于软作用域规则的第二种情况,并且赋值是不明确的,因此会发出警告。
继续执行,使
s
成为for
循环体的局部变量。由于
s
是for
循环的局部变量,当t = s + i
被求值时,它是未定义的,从而导致错误。计算到此结束,但如果到达
s
和@isdefined(t)
,它将返回0
和false
。
这演示了作用域的一些重要方面:在作用域中,每个变量只能有一种含义,并且该含义与表达式的顺序无关。在循环中表达式s = t
的存在导致s
是循环的局部变量,这意味着当它出现在t = s + i
的右边时,它也是局部变量,即使该表达式首先出现并首先执行。你或许会认为循环第一行的s
可以是全局的,循环第二行的s
可以是本地的,但实际上是不可能的,因为这两行在同一个作用域块,在给定的作用域,每个变量只能有一种含义。
关于软作用域
现在,我们已经介绍了所有的局部作用域规则,但是在结束本节之前,应该说明一下为什么在交互和非交互环境中处理模糊的软作用域情况是不同的。人们可能会问两个明显的问题:
为何不在所有地方都像在REPL中那样的工作呢?
为何不在所有地方都像在文件中那样工作并去掉警告呢?
在Julia≤0.6,所有全局作用域的工作方式都如同当前的REPL一样:当x = <value>
发生在一个循环(或try
/catch
,或struct
体)内,但在函数体(或let
块或推导)以外,x
在循环内是否为局部的取决于是否定义了全局变量x
。这种行为具有直观和方便的优点,因为它尽可能接近函数体内部的行为。特别地,当调试函数的行为时,它使得在函数体和REPL之间来回移动代码变得很容易。然而,它也有一些缺点。首先,这是一种相当复杂的行为:多年来,许多人对这种行为感到困惑,并抱怨它既复杂又难以解释和理解。其次,可以说更糟糕的是,它不利于“大规模”编程。当你在一个地方看到像这样的一小段代码时,很清楚会发生什么:
s = 0
for i = 1:10
s += i
end
显然,其目的是修改现有的全局变量s
,不然会是什么呢?然而,并非所有现实世界的代码都是如此简短或清晰。我们发现像下面这样的代码经常大量的出现:
x = 123
# much later
# maybe in a different file
for i = 1:10
x = "hello"
prinntln(x)
end
# much later
# maybe in yet another file
# or maybe back in the first one where `x = 123`
y = x + 234
现在就不是很明确应该发生什么。因为x + "hello"
是一个方法错误,它的意图似乎是让x
在for
循环中是局部的。但是运行时的值和当时存在的方法不能用来确定变量的作用域。在Julia≤0.6的行为方式下,会存在有人先写了for
循环,它可以很好的工作,但后来当别人在远处(可能在不同的文件中)添加了一个新的全局变量,代码突然改变了含义,它可能会显式地终止,更糟的是,有可能默默地做错误的事情。这种“幽灵般的远距离行动”是优秀的程序语言设计应该避免的。
因此,在Julia 1.0中,简化了作用域规则:在任何局部作用域中,对一个不属于局部变量的名称赋值将创建一个新的局部变量。这完全消除了软作用域的概念,并消除了幽灵行动的可能性。软作用域的删除使大量的bug得到了暴露和修复,这也证明了选择删除它是正确的。但也存在一些不便,如下面的代码所示:
s = 0
for i = 1:10
global s += i
end
看到全局注释了吗?显然,这种情况是不能容忍的。但严格地说,这种需要global
的顶层代码存在两个主要问题:
不方便将函数体中的代码复制并粘贴到REPL中进行调试——你必须在调试时添加
global
注释,然后在拷回函数时删除它们。初学者会在编写这类代码时漏掉
global
,并对代码不能工作感到莫名其妙——他们得到的错误是s
未被定义,这似乎对碰巧犯这种错误的人没有任何启发效果。
从Julia 1.5开始,这段代码在交互式环境中(如REPL或Jupyter笔记本)不需要global
注释(就像Julia 0.6),在文件和其他非交互式环境中,它打印出非常直接的警告:
在软作用域中对
s
的赋值是不明确的,因为存在同名的全局变量:s
将被视为一个新的局部变量。通过使用local s
来消除该警告,或使用global s
来赋值给现有的全局变量来消除歧义。
这既解决了上述两个问题,又保留了1.0中“大规模编程”好处的行为:全局变量对可能遥远的代码含义没有幽灵影响。在REPL中复制粘贴调试工作良好,初学者也不会遇到任何问题,任何时候,如果有人忘记了一个global
注释,或者不小心在一个软作用域中用一个局部隐藏了一个现有的全局变量(虽然听起来很奇怪),他们就会得到一个清晰的警告。
这种设计的一个重要属性是,任何在文件中执行而没有警告的代码在新的REPL中都将以相同的方式执行。另一方面,如果你使用一个REPL会话并将其保存到文件中,如果它的行为与在REPL中不同,那么你将得到一个警告。
Let块
与对局部变量的赋值不同,let
语句在每次运行时分配新的变量绑定。赋值会修改现有值的位置,let
会创建新的位置。这种差异通常并不重要,只有在变量通过闭包存活的时间超过其作用域的情况下才会显现。let
语法接受逗号分隔的一系列赋值和变量名:
julia> x, y, z = -1, -1, -1;
julia> let x = 1, z println("x: $x, y: $y") # x is local variable, y the global println("z: $z") # errors as z has not been assigned yet but is local end
x: 1, y: -1 ERROR: UndefVarError: z not defined
赋值是按顺序执行的,在左边的新变量被引入之前,右边的每个变量都在作用域内求值。因此,写let x = x
是有意义的,因为两个x
变量是不同的,有不同的存储空间。下面是一个需要let
行为的例子:
julia> Fs = Vector{Any}(undef, 2); i = 1;
julia> while i <= 2 Fs[i] = () -> i global i += 1 end
julia> Fs[1]()
3
julia> Fs[2]()
3
在这里,我们创建并存储了两个返回变量i
的闭包。然而,它始终是同一个变量i
,因此两个闭包的行为是相同的。我们可以使用let
为i
创建一个新的绑定:
julia> Fs = Vector{Any}(undef, 2); i = 1;
julia> while i <= 2 let i = i Fs[i] = () -> i end global i += 1 end
julia> Fs[1]()
1
julia> Fs[2]()
2
由于begin
构造不引入新的作用域,所以使用零参数let
只引入一个新的作用域块而不创建任何新绑定是很有用的:
julia> let local x = 1 let local x = 2 end x end
1
因为let
引入了一个新的作用域块,所以内部的局部变量x
与外部的局部变量x
是不同的。
循环和推导
在循环和推导式中,在其主体作用域中引入的新变量会在每次循环迭代时重新分配,就像循环主体被一个let
块包围一样,如下例所示:
julia> Fs = Vector{Any}(undef, 2);
julia> for j = 1:2 Fs[j] = ()->j end
julia> Fs[1]()
1
julia> Fs[2]()
2
for
循环或推导迭代变量总是一个新变量:
julia> function f() i = 0 for i = 1:3 # empty end return i end;
julia> f()
0
然而,有时可以重用现有的局部变量作为迭代变量。这可以通过添加关键字outer
方便地完成:
julia> function f() i = 0 for outer i = 1:3 # empty end return i end;
julia> f()
3
常量
变量的一个常见用法是给特定的、不变的值命名。这样的变量只被赋值一次。这个意图可以通过使用const
关键字传递给编译器:
julia> const e = 2.718;
julia> const pi = 3.142;
可以在一个const
语句中声明多个变量:
julia> const a, b = 1, 2
(1, 2)
const
声明只能在全局作用域中的全局变量上使用。编译器很难优化涉及全局变量的代码,因为它们的值(甚至它们的类型)几乎在任何时候都可能发生改变。如果全局变量不变,添加const
声明就可以解决这个性能问题。
局部常量则不同的。编译器能够自动确定局部变量何时为常量,因此不需要声明局部常量,实际上目前也不支持局部常量。
特殊的顶层赋值,例如由function
和struct
关键字执行的赋值,默认情况下是常量。
注意,const
只影响变量绑定。变量可以绑定到一个可变对象(比如数组),并且该对象仍然可以被修改。另外,当试图给一个被声明为常量的变量赋值时,可能会出现以下情况:
- 如果新值的类型与常量的类型不同,则抛出一个错误:
julia> const x = 1.0
1.0
julia> x = 1
ERROR: invalid redefinition of constant x
- 如果新值的类型与常量相同,则会打印警告:
julia> const y = 1.0
1.0
julia> y = 2.0
WARNING: redefinition of constant y. This may fail, cause incorrect answers, or produce other errors. 2.0
- 如果赋值不会导致变量值的改变,则不会给出消息:
julia> const z = 100
100
julia> z = 100
100
最后一条规则适用于不可变对象,只要值不发生改变,变量的重新绑定实际上是被忽略的,例如:
julia> const s1 = "1"
"1"
julia> s2 = "1"
"1"
julia> pointer.([s1, s2], 1)
2-element Vector{Ptr{UInt8}}: Ptr{UInt8} @0x00007f50963238d8 Ptr{UInt8} @0x00007f5096323938
julia> s1 = s2
"1"
julia> pointer.([s1, s2], 1)
2-element Vector{Ptr{UInt8}}: Ptr{UInt8} @0x00007f50963238d8 Ptr{UInt8} @0x00007f5096323938
然而,对于可变对象,会按预期打印警告:
julia> const a = [1]
1-element Vector{Int64}: 1
julia> a = [1]
WARNING: redefinition of constant a. This may fail, cause incorrect answers, or produce other errors. 1-element Vector{Int64}: 1
请注意,尽管有时可以,但强烈建议不要更改const
变量的值,除非在交互使用时为了方便而故意为之。更改常量可能会导致各种问题或意外行为。例如,如果一个方法引用了一个常量,并且在更改该常量之前已经编译过了,那么它将会继续使用原来的值:
julia> const x = 1
1
julia> f() = x
f (generic function with 1 method)
julia> f()
1
julia> x = 2
WARNING: redefinition of constant x. This may fail, cause incorrect answers, or produce other errors. 2
julia> f()
1