变量的作用域

变量的作用域是变量在代码中可见的范围。变量作用域有助于避免变量命名冲突。这个概念很直观:两个函数都可以称为x的参数,但这两个x所指的不是同一件事。类似地,在许多其他情况下,不同的代码块可以使用相同的名称而不引用相同的东西。当相同的变量名指向或不指向同一事物时,这种规则称为作用域规则。本章将对它们进行详细说明。

语言中的某些构造引入了范围块,在这些范围块内,规定了某些变量集的作用域。变量的作用域不能是源代码的任意一组集合,相反,它将总是与这些块中的一个相关。在Julia中有两种主要类型的作用域,全局作用域和局部作用域。后者可以嵌套。在Julia中,构造的“硬作用域”和“软作用域”之间也有区别,它们会影响是否允许使用同名变量隐藏一个全局变量。

作用域构造

引入作用域块的构造有:

构造作用域类型允许的位置
modulebaremodule全局全局
struct局部(软)全局
forwhiletry局部(软)全局,局部
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

因此,词法作用域意味着特定代码段中的变量所引用的内容可以单独从其出现的代码中推断出来,而不依赖于程序的执行方式。嵌套在另一个作用域中的作用域可以“看到”包含它的所有外部作用域中的变量。但是,外部作用域不能看到内部作用域中的变量。

全局作用域

每个模块都引入一个新的全局作用域,它与所有其他模块的全局作用域分开——不存在包含所有全局作用域的全局作用域。模块可以通过usingimport语句,或通过使用点表示法的授权访问,将其他模块的变量引入其作用域,即每个模块都是一个所谓的命名空间,也是一个将名称与值关联起来的一级数据结构。注意,虽然可以在外部读取变量绑定的值,但只能在它们所属的模块内更改它们。作为一个安全门,你总是可以在模块内执行代码来修改一个变量。这避免了模块绑定在外部不通过调用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在该位置已经引用的内容来决定表达式的含义:

  1. 现有的局部变量:如果x已经是一个局部变量,则对现有的局部变量x赋值。

  2. 硬作用域:如果x还不是一个局部变量,并且赋值发生在硬作用域构造中(例如,在let块、函数或宏体、推导或生成器中),则在赋值的作用域中创建一个名为x的新局部变量。

  3. 软作用域:如果x还不是一个局部变量,并且所有包含赋值的作用域结构都是软作用域(循环、try/catch块或struct块),则行为取决于是否定义了全局变量x

    • 如果全局x未定义,则在赋值范围内创建一个名为x的新局部变量。

    • 如果定义了全局x,赋值被认为是歧义的:

      • 在非交互式环境中(文件,eval),会打印一个歧义警告,并创建一个新的局部变量。

      • 在交互式环境中(REPL, notebook),全局变量x被赋值。

你可能会注意到,在非交互式环境中,硬作用域和软作用域的行为是相同的,只不过在软作用域中,当一个隐式局部变量(即没有使用local x声明)对全局变量造成屏蔽时,会打印一个警告。在交互式环境中,为了方便起见,规则遵循更复杂的启发式。下面的示例将深入讨论这一点。

既然已经知道了规则,让我们看一些例子。假设每个示例都是在一个全新的REPL会话中计算的,这样每个代码段中的惟一全局变量就是在该代码块中分配的全局变量。

我们将从一个优雅而明确的情况开始——在硬作用域内赋值,在本例中是一个函数体,此时不存在同名的局部变量:

julia> function greet()
           x = "hello"  # new local
           println(x)
       endgreet (generic function with 1 method)
julia> greet()hello
julia> x # globalERROR: UndefVarError: x not defined

greet函数内部,赋值x = "hello"使x成为函数作用域中的一个新的局部变量。有两个相关的事实:赋值发生在局部作用域中,并且没有现有的局部x变量。因为x是局部的,所以是否存在一个全局变量x并不重要。例如,在定义和调用greet之前,我们先定义了x = 123

julia> x = 123  # global123
julia> function greet() x = "hello" # new local println(x) endgreet (generic function with 1 method)
julia> greet()hello
julia> x #global123

由于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
       endsum_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> sERROR: UndefVarError: s not defined

因为ssum_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)
       endsum_to_def (generic function with 1 method)
julia> sum_to_def(10)(55, false)

这个版本像以前一样返回s,但它也使用@isdefined宏返回一个布尔值,显示函数的最外层局部作用域中是否定义了一个名为t的局部变量。如你所见,在for循环体之外没有定义t。这还是因为硬作用域规则:由于对t的赋值发生在函数内部,这引入了硬作用域,赋值导致t在它出现的局部作用域内成为一个新的局部变量,即在循环体内部。即使有一个名为t的全局变量,也不会有什么区别——硬作用域规则不会受到全局作用域中情况的影响。

让我们来看看软作用域规则所涵盖的一些更模糊的情况。我们将通过将greetsum_to_def函数的主体提取到软作用域环境中来研究这个问题。首先,让我们把greet的主体放到for循环中——它是软的,而不是硬的——然后在REPL中计算它:

julia> for i = 1:3
           x = "hello" # new local
           println(x)
       endhello
hello
hello
julia> xERROR: 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   # global0
julia> for i = 1:10 t = s + i # new local `t` s = t # assign global `s` end
julia> s # global55
julia> @isdefined(t) # globalfalse

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循环体的局部变量。

  • 由于sfor循环的局部变量,当t = s + i被求值时,它是未定义的,从而导致错误。

  • 计算到此结束,但如果到达s@isdefined(t),它将返回0false

这演示了作用域的一些重要方面:在作用域中,每个变量只能有一种含义,并且该含义与表达式的顺序无关。在循环中表达式s = t的存在导致s是循环的局部变量,这意味着当它出现在t = s + i的右边时,它也是局部变量,即使该表达式首先出现并首先执行。你或许会认为循环第一行的s可以是全局的,循环第二行的s可以是本地的,但实际上是不可能的,因为这两行在同一个作用域块,在给定的作用域,每个变量只能有一种含义。

关于软作用域

现在,我们已经介绍了所有的局部作用域规则,但是在结束本节之前,应该说明一下为什么在交互和非交互环境中处理模糊的软作用域情况是不同的。人们可能会问两个明显的问题:

  1. 为何不在所有地方都像在REPL中那样的工作呢?

  2. 为何不在所有地方都像在文件中那样工作并去掉警告呢?

在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"是一个方法错误,它的意图似乎是让xfor循环中是局部的。但是运行时的值和当时存在的方法不能用来确定变量的作用域。在Julia≤0.6的行为方式下,会存在有人先写了for循环,它可以很好的工作,但后来当别人在远处(可能在不同的文件中)添加了一个新的全局变量,代码突然改变了含义,它可能会显式地终止,更糟的是,有可能默默地做错误的事情。这种“幽灵般的远距离行动”是优秀的程序语言设计应该避免的。

因此,在Julia 1.0中,简化了作用域规则:在任何局部作用域中,对一个不属于局部变量的名称赋值将创建一个新的局部变量。这完全消除了软作用域的概念,并消除了幽灵行动的可能性。软作用域的删除使大量的bug得到了暴露和修复,这也证明了选择删除它是正确的。但也存在一些不便,如下面的代码所示:

s = 0
for i = 1:10
    global s += i
end

看到全局注释了吗?显然,这种情况是不能容忍的。但严格地说,这种需要global的顶层代码存在两个主要问题:

  1. 不方便将函数体中的代码复制并粘贴到REPL中进行调试——你必须在调试时添加global注释,然后在拷回函数时删除它们。

  2. 初学者会在编写这类代码时漏掉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 endx: 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,因此两个闭包的行为是相同的。我们可以使用leti创建一个新的绑定:

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
       end1

因为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声明就可以解决这个性能问题。

局部常量则不同的。编译器能够自动确定局部变量何时为常量,因此不需要声明局部常量,实际上目前也不支持局部常量。

特殊的顶层赋值,例如由functionstruct关键字执行的赋值,默认情况下是常量。

注意,const只影响变量绑定。变量可以绑定到一个可变对象(比如数组),并且该对象仍然可以被修改。另外,当试图给一个被声明为常量的变量赋值时,可能会出现以下情况:

  • 如果新值的类型与常量的类型不同,则抛出一个错误:
julia> const x = 1.01.0
julia> x = 1ERROR: invalid redefinition of constant x
  • 如果新值的类型与常量相同,则会打印警告:
julia> const y = 1.01.0
julia> y = 2.0WARNING: redefinition of constant y. This may fail, cause incorrect answers, or produce other errors. 2.0
  • 如果赋值不会导致变量值的改变,则不会给出消息:
julia> const z = 100100
julia> z = 100100

最后一条规则适用于不可变对象,只要值不发生改变,变量的重新绑定实际上是被忽略的,例如:

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 = 11
julia> f() = xf (generic function with 1 method)
julia> f()1
julia> x = 2WARNING: redefinition of constant x. This may fail, cause incorrect answers, or produce other errors. 2
julia> f()1