8 作用域

变量的作用域是某个变量可见的范围。 同名的变量使得问题变得复杂, 变量作用域使得同名的不同变量能够区分开来。

变量作用域都是某些程序结构的范围内, 比如一个函数定义范围,而不是任意的一段程序行的范围。

有两种主要的作用域:

  • 全局作用域
  • 局部作用域,可以嵌套,分为:
    • 软局部作用域
    • 硬局部作用域

全局作用域适用于模块(module)内,baremodule内, 或者在命令行环境内。 每个模块有自己的全局变量, 命令行运行的程序相当于main模块。

for, while, 自省(comprehensions),try-catch-finally, let结构内部构成软局部作用域。 而begin复合语句,if结构,不构成局部作用域。

硬局部作用域是函数定义,struct, macro中。

同一个作用域内, 每个变量的作用必须是唯一的, 不能说在第一个语句是读取父作用域的同名变量值, 第二个语句就因为赋值变成了该作用域的局部变量。

8.1 句法作用域

函数可以读取其外围环境中的变量的值。 这里“外围环境”的定义按照句法作用域(lexical scoping), 即一个自定义函数的作用域的外围环境是定义此函数的环境, 而不是运行时调用这个函数的环境。

例如:

module Amod
  x = 1
  foo() = x
end
import .Amod
x = -1
println(Amod.foo())
## 1

最后一个语句调用Amod.foo()时用到的是模块Amod的全局变量x, 这是定义foo()时包含函数定义的代码中的x变量。 赋值为-1的变量x是命令行的全局变量, 这是调用foo()时所处环境中的变量。

要注意的是, 函数使用所处环境中的变量的当前值, 这不一定是定义该函数时的变量值, 这称为“动态查找”(dynamic lookup)。如

module Amod2
  x = 1
  foo() = x
  function setx!(xnew) 
    global x = xnew
    return ()
  end
end
import .Amod2
Amod2.setx!(2)
x = -1
println(Amod2.foo())
## 2

以上程序中调用Amod2.foo()函数时, 其依赖的变量Amod.x的值已经被修改为2了, 所以Amod2.foo()返回值不是1而是2。

一个模块内的所有代码可以区分为不同的作用域。

  • 不在任何函数内的变量是全局变量。
  • 函数的自变量和函数内用local声明的变量是属于函数的局部变量。
  • for, while, let环境内用local声明的变量是属于环境本身局部作用域的局部变量。

8.2 全局作用域

在命令行环境中定义的变量属于命令行环境的全局作用域,实际是Main模块的全局作用域。

每一个模块有自己的全局作用域, 但是没有一个在所有模块中都起作用的统一的全局作用域。 模块内在所有函数定义外部赋值的变量为全局变量。 模块内的任何位置用global关键字声明的变量为该模块的全局变量。

为了访问其它模块的全局变量,可以用using或者import引入, 也可以用“模块名.变量名”格式。 事实上,每个模块是一个名字空间。 只有同一模块的代码可以修改本模块的全局变量, 在模块外可以读取模块内全局变量的值但是不允许直接对模块内的全局变量赋值修改, 可以通过调用同一模块的函数间接地修改模块内的全局变量。

使用全局变量容易造成程序漏洞, 尤其是修改全局变量的值会造成难以察觉的错误。 在为全局变量赋值时用const前缀声明其为常数全局变量, 这样的全局变量不能重新绑定, 但如果其中保存可变类型(mutable)值的话还是可以修改保存的值的。 这种做法可以避免使用全局变量的一些错误以及性能缺陷。如

const GC = 9.8

8.3 局部作用域

许多代码块结构都会产生一个局部作用域,如函数定义、for循环等。 局部作用域都可以读写其定义环境所处作用域(称为父作用域)的变量, 但有例外:

  • 如果局部作用域对变量的赋值会修改全局变量, 需要在局部作用域中用global声明该变量, 否则程序出错;
  • 如果在局部作用域中用local声明了变量, 则对该变量的修改不会影响父作用域的变量。

局部作用域不是名字空间, 所以内层可以访问外层变量, 但是外层无论如何不能访问内层作用域的局部变量。

按照对父作用域的变量如何继承来区分, 局部作用域分成硬局部作用域和软局部作用域。

下面举例说明这些作用域规则。 假设每个例子都是单独放在一个程序文件中在新建的命令行环境内用include执行的, 这样没有其它全局变量的干扰。

8.3.1 例1

下面的例子说明, 局部作用域独有的变量在父作用域内无法访问。 例子中for循环内构成了一个局部作用域, 其中独有的变量z无法在其父作用域即f1()函数局部作用域内访问。

function f1()
  for i=1:5
    z = i
  end
  println(z) # 错误:z无定义
end

如果调用f1(),会出错:

UndefVarError: z not defined

即在for循环外部变量z无定义。

8.3.2 例2

在下面的程序中, f2()函数内构成一个局部作用域。 f2()内定义的变量z不能在外部访问。

function f2()
  z = 1
  println("Inside f2(): z=$z")
end

如果执行f2(),结果为

Inside f2(): z=1

如果执行println("Outside f2(): z=$z"),结果为

UndefVarError: z not defined

8.3.3 例3

下面的例子修改了父作用域中的变量, 变量不是全局变量也没有用local声明。 函数f3()中的for循环构成一个局部作用域, 其父作用域是f3()的局部作用域, for()循环中可以直接读取并修改父作用域中非全局的变量z的值:

function f3()
  z = 0
  for i=1:5
    z += i
  end
  println(z) # 15
end
f3()
## 15

注意for作用域中z不是全局变量也没有用local声明。

8.3.4 例4

f3()定义中,如果在for结构内用local声明变量z, 则程序会出错。

下面的例子说明f4()中的局部变量z与其中的for结构中的z是两个不同的变量, 因为在for结构中用local声明了该结构中的z是局部版本:

function f4()
  z = 0
  for i=1:3
    local z
    z = i
    println("i=$i z=$z")
  end
  println("Outside for loop: z=$z") # 0
end
f4()
## i=1 z=1
## i=2 z=2
## i=3 z=3
## Outside for loop: z=0

8.3.5 例5

下面的例子说明,如果局部作用域内的赋值会修改外部存在的全局变量的值, 又在局部作用域内用global声明该变量, 需要区分程序的运行环境。 如果是在交互运行(如RELP, Jupyter)中, 该全局变量会被修改。 如果是在运行整个源程序, 会给出错误警告, 并建立一个局部变量版本而不会修改全局变量。 如:

z = 0
for i=1:5
  z = i
end
println(z)

在交互环境运行时, 程序结果为5,正确地修改了全局变量z的值。 如果将上述程序存入源文件并用include()运行, 会给出一个z的作用域有歧义的警告, 并给出结果0, 所以for循环中的z是局部的。

这个问题的正确做法是将程序像f3()那样写在一个函数中, 这就不会发生在局部作用域内修改全局变量的问题。

但是, 如果在for循环中定义未在f5()的局部作用域中定义的变量, 该变量将是for循环的局部变量, 不能在退出for循环后访问。如:

function f5a()
  z = 0
  for i=1:5
    z = i
    v = i
  end
  println(z)
  println(v)
end
## 5
## ERROR: LoadError: UndefVarError: v not defined
## ......

则会在println(v)时因为v没有在f5a()的局部作用域中定义而出错。

在局部作用域用global声明要修改的全局变量也可以:

z = 0
for i=1:5
  global z
  z = i
end
println(z) 
## 5

使用global关键字要慎重, 一旦在局部作用域中将某个变量用global声明为全局变量, 则此变量就不仅仅可以被其父作用域访问, 而是在模块内全局可访问。 如

function f5b()
  for i=1:5
    global z=i
  end
  println("Outside for loop: z=$z") 
end
f5b()
println("Outside function f5b(): z=$z") 
## Outside for structure: z=5
## Outside function f5b(): z=5

8.4 软局部作用域

软局部作用域内的变量默认是在其父作用域内的变量, 但是:

  • 在软局部作用域内新定义的变量,作用域外仍不能访问;
  • 软局部作用域内用local声明过的变量仅能在该作用域内访问而不会与其父作用域的同名变量冲突;
  • 软局部作用域内试图为全局变量赋值而未在作用域内用global声明, 除非是在交互运行环境, 否则该变量会因作用域歧义使得程序发出警告,并作为局部变量使用。

for循环,while循环,自省(comprehension)结构, try-catch-finally结构,let结构会引入软局部作用域。 软局部作用域,如for循环, 一般用来处理其父作用域(一般是函数内部)内的变量, 与其周围代码是不可分割的。 比如在用for循环做累加时, 累加结果变量一定是在for循环父作用域内而不能是局部的, 所以软作用域非以上三种特殊情况下可以读写其父作用域的变量。

for循环和自省结构中的循环变量都是结构内的局部变量, 即使在其父作用域中有同名变量也是如此, while循环没有语法上的循环变量所以不受此限制。 在结构中新定义的变量都是结构内的局部变量, 但如果父作用域是函数的局部作用域且父作用域内有同名变量, 则结构内的变量读写父作用域中的变量。

软局部作用域的这些规定与例外规定与其它程序语言存在较大差别, 初学者很容易出错。建议如下:

  • 对软局部作用域,尽量不要在其中新定义变量;
  • 如果新定义变量, 用local声明使其作用域变得明显可见就不会发生误读误判, 即使父作用域中没有同名变量也加上这个声明可以使得程序的意图更清楚;
  • 对于函数内的软局部作用域,其父作用域是函数的局部作用域, 为了能够访问函数的局部变量, 软局部作用域内不需要也不应该使用global声明该变量, 因为其变量除了新定义的,都是函数的自变量和局部变量, 使用global声明的副作用是该变量成为全局变量, 而不仅仅是父作用域中可访问的变量。

8.4.1 例6

软局部作用域新定义的变量, 是该作用域的局部变量, 在该作用域外不能访问; 这时,如果有同名的全局变量, 会错误引用全局变量的值。 如:

z = 0
function f6()
    for i=1:3
      z = i 
      println("In f6() and for loop: z = $z")
    end
    return z
end
@show f6()

结果:

In f6() and for loop: z = 1
In f6() and for loop: z = 2
In f6() and for loop: z = 3
f6() = 0

但是, 我们写这个程序的本意很可能是想返回for循环最后产生的z值, 即z=3

8.5 硬局部作用域

函数定义,struct结构,宏定义内部为硬局部作用域。 其中函数定义可以嵌套在另一个函数定义中, 而struct结构和宏定义则只能在全局作用域中定义。

在硬局部作用域中,几乎所有变量都是从其父作用域内继承来的, 例外情况包括:

  • 对父作用域内的变量赋值,会修改全局变量时, 这时赋值会产生一个局部副本而不修改全局变量值。
  • 用local声明的变量,仅在此局部作用域内起作用, 即使父作用域中有同名变量或者有同名的全局变量也是如此。

在硬局部作用域中, 可以不经声明直接读取父作用域中的变量以及全局变量; 父作用域中的变量如果不是全局变量, 可以直接修改变量值。

硬局部作用域中不经声明不能修改全局变量值。 需要在局部作用域内用global声明该变量才能修改全局变量值。 为了能明显地反映程序意图, 在局部作用域内不论读或者写访问全局变量时, 都最好在局部作用域内用global关键字声明该变量。

8.5.1 例7

在函数内部读取全局变量的值, 可以不用global声明:

z = -1
function f7a()
  println("函数内读取全局变量:z=$z")
end
f7a()
## 函数内读取全局变量:z=-1

f7a()可以读取是全局变量z的值, 并不要求定义f7a()z已有定义。 如:

function f7b()
  println("函数内读取外部变量:z=$z")
end
z = -1
f7b()

结果与上面相同。

在函数内如果要修改全局变量值, 需要用global声明该全局变量, 否则不能修改, 意图修改全局变量的代码实际是建立了一个局部变量:

z = -1
function f7c()
  z = 1
  println("函数内不用global声明修改全局变量,修改后:z=$z")
end
f7c()
println("退出函数后全局变量:z=$z")
## 函数内不用global声明修改全局变量,修改后:z=1
## 退出函数后全局变量:z=-1

这说明函数f7c()内并没有修改全局变量z的值, f7c()运行期间显示的z是一个局部变量。 函数内并没有能够修改外部的z变量值, 而且一旦函数内给z赋值,整个函数体内z都是局部变量, 这样在给z赋值之前z是无定义的, 而不是能访问外部的z值。 下面的程序在z=1赋值之前显示z的值, 这时的z已经是局部变量,所以程序会出错:

z = -1
function f7d()
  println("函数内不用global声明修改全局变量,修改前:z=$z")
  z = 1
  println("函数内不用global声明修改全局变量,修改后:z=$z")
end
f7d()
println("退出函数后全局变量:z=$z")
## UndefVarError: z not defined

所以,函数内赋值的变量, 最好用local声明以避免误解。 嵌套定义的函数是一个例外, 嵌套定义的函数的父作用域是另一个函数的局部作用域, 可以直接修改定义它的那个函数内部的局部变量。

在函数内用global声明变量, 就可以读写访问全局变量。 为了程序意图更清晰, 即使仅读取全局变量, 最好也用global声明。 如

z = -1
function f7e()
  global z
  z = 1
  println("函数内用global声明修改全局变量:z=$z")
end
f7e()
println("退出函数后全局变量:z=$z")
## 函数内用global声明修改全局变量:z=1
## 退出函数后全局变量:z=1

上述程序中所有的变量z都是全局变量z

8.6 嵌套定义函数的作用域

嵌套地定义在函数内的函数, 其作用域与直接定义在全局作用域的函数不同:

  • 直接定义在全局作用域的函数的父作用域是全局作用域, 父作用域的变量是全局变量, 按照规定, 函数内修改没有用global声明的变量只能生成一个同名局部变量;
  • 嵌套地定义的函数, 其父作用域是另一个函数的局部作用域, 所以在嵌套定义内可以不需要声明直接读写父作用域中的变量, 实际上也不能用global声明父作用域中的变量。
  • 对于struct结构和宏定义, 它们只能在全局作用域定义而不能嵌套定义, 所以不存在这个差别。

8.6.1 例8

x = "global.x" # 全局变量
function f8()
  local x = "f8.x" # 这是一个f8()作用域内的局部变量
  function f8a()
    println("在f8a()开始时: x=$x (读取了父作用域的局部变量值)") # 值为f8.x
    x = "f8a.x"   # 内嵌函数内,允许读写访问其所在函数的局部变量
    println("在f8a()内部修改后: x=$x (修改了父作用域的局部变量值)") # f78.x
  end
  f8a()
  println("在f8a()结束后: x=$x (修改了父作用域的局部变量值)") 
  # f8a.x,函数f8()的局部变量x被其内嵌函数f8a修改了
end
f8()
println("在f8()结束后: x=$x (内嵌函数没有修改全局变量)") # global.x 
## 在f8a()开始时: x=f8.x (读取了父作用域的局部变量值)
## 在f8a()内部修改后: x=f8a.x (修改了父作用域的局部变量值)
## 在f8a()结束后: x=f8a.x (修改了父作用域的局部变量值)
## 在f8()结束后: x=global.x (内嵌函数没有修改全局变量)

可以看到,f8()f8a()内的x都与全局的x="globa.x"没有关系。 嵌套定义的f8a()内能读取其父作用域f8()作用域的x的值, 也修改了其父作用域中的x的值, 这一点与非内嵌函数修改全局变量不同。

8.6.2 例9:运行次数计数

之所以规定嵌套函数可以直接读写访问其父作用域中的变量, 是实现所谓“闭包”(closure)的要求。 闭包是带有状态的函数, 因为Julia不支持如Java、C++、Python这些语言的“类”(class), 所以需要利用闭包来实现记忆状态的函数。 在这一点上Julia语言与R语言比较相近。 Julia语言的多重派发(multiple dispatch)也起到了与“类”功能相近的作用。

在下面的例子中,f9()内是一个局部作用域, 在f9()内嵌套地定义了无名函数并且将其作为函数f9()的返回值, 这时f9()的函数值是一个函数对象。 通过将f9()的函数值赋值给变量counter, 变量counter就变成了一个函数, 按照句法作用域规则, counter()函数可以完全读写访问其定义时的父作用域f9()中的变量state, state就变成了counter()函数的私有状态变量, 可以保存上次运行状态:

function f9()
  state = 0
  function ()
    state += 1
  end
end
counter = f9()
println(counter(), ", ", counter())
## 1, 2

8.7 let结构

let结构的写法例子如

let x=1, y=2, z
    z = x + y
end

这会产生一个硬局部作用域, 在let语句中声明的x, y, z都是此作用域的局部变量。 此结构有一个返回值, 等于结构体最后一个表达式的值。

8.8 硬作用域和软作用域的比较

软局部作用域,如for循环, 一般用来处理其父作用域(一般是函数局部作用域)内的变量, 与其周围代码是不可分割的。 比如在用for循环做累加时,累加结果变量一定是在for循环父作用域内而不能是局部的, 所以软作用域可以读写其父作用域的变量(非交互环境下不能修改全局变量)。

另一方面,硬作用域一般是独立地运行在不同的调用场合的, 与周围的代码的关系没有那么紧密,很可能会在别的模块中被调用。 所以硬作用域不允许修改全局变量值(除非使用global声明)。