词法作用域 / 动态作用域

一言以蔽之:

  • 静态作用域作用于空间, 从空间上寻找最近的定义.
  • 动态作用域作用于时间, 从时间上寻找最近的定义.

While there could be any number of static or dynamic policies for scoping, there is an interesting relationship between the normal(block-structured) static scoping rule and the normal dynamic policy. In a sense, the dynamic rule is to time as the static rule is to space. While the static rule asks us to find the declaration whose unit(block) most closely surrounds the physical location of the use, the dynamic rule asks us to find the declaration whose unit(procedure invocation) most closely surrounds the time of the use.

词法作用域(lexical scoping), 也被称为静态作用域(static scoping), 是目前大多数现代编程语言使用的一种方式, 其值只跟定义的时候有关.

以 Perl 为例, 调用 foo 之前改变 value 的值不影响 foo 的输出:

#!/usr/bin/env perl
use 5.016;

my $value = 1;

sub foo {
    say $value;
}

sub bar {
    my $value = 2;
    foo();
}

bar()  # 1

其对立面是动态作用域(dynamic scoping), 其值跟运行时的状态有关, 也可认为永远是全局变量.

以 Shell 为例, 调用 foo 之前改变 value 的值改变了 foo 的输出:

#!/usr/bin/env sh

value=1

function foo() {
    echo $value
}

function bar() {
    value=2
    foo
}

bar  # 2

下文提到的词法变量实际上使用的就是词法作用域, 所以其值只跟定义时有关.

软件包变量(包括 ourlocal 声明的变量)实际上使用的是动态作用域, 在运行中改变了值会导致整个运行空间内的值发生改变. 其中重点关注 local, 其作用在于在某个作用域内临时的更改整个运行空间中的该值.

此外, 关于"完全别名"和"部分别名"的内容, 可以总结为:

  • 完全别名, 创建的是软件包变量, 使用动态作用域, 永远是全局变量
  • 部分别名, 创建的是词法变量, 使用词法作用域, 可以是局部变量

软件包变量和词法变量

软件包变量(package variable)一般也被称为全局变量, 使用 our 可以声明软件包变量(local 声明的也是软件包变量). 软件包变量会被存入符号表. 操作符号表和 typeglob 的时候操作的是软件包变量.

词法变量(lexical variable)使用关键字 my 声明, 作用域有限, 且不会被存入符号表.

符号表类似于一个哈希表, 只是类似, 并不完全相同. 我们可以使用 keys 关键字来查询其中保存的内容, 比如:

foreach (keys %main::) {
    print $_ . "\n";
}

如果有定义 package 的名字, 则可以这样查看:

package Foo;

foreach (keys %Foo::) {
    print $_ . "\n";
}

其中的 %main::%Foo:: 都是符号表.

基于此, 我们使用 keys 可以观察一下使用 ourmy 声明的变量的不同:

our:

package Foo;

our $bar;
# infact, it will also print "bar" if we declare $bar by using local
# local $bar;

# this will print "bar"
foreach (keys %Foo::) {
    print $_ . "\n";
}

my:

package Foo;

my $bar;

# this will print nothing
foreach (keys %Foo::) {
    print $_ . "\n";
}

可以很明显的发现, 使用 our 声明的变量会被加入符号表 %Foo::, 而使用 my 声明的变量则不会. 这是这两种声明方法比较大的区别: 一个声明的是软件包变量, 存在于符号表中, 另一个声明的是词法变量, 不会存在于符号表.

our / local / my

其实前面提到了 ourlocal 声明的变量属于一类, 都属于软件包变量, 而 my 声明的属于另一类, 叫做词法变量, 但是他们的关系还是有点复杂, 举些例子来分析和理解.

一. 重复使用 our 声明变量会覆盖前面的变量, 即使是在新的作用域中:

our $foo = 1;
{
    our $foo = 2;
}
print $foo;  # now $foo is 2

二. 在新的作用域中使用 localmy 不会影响原作用域中的变量:

our $foo = 1;
{
    local $foo = 2;
}
print $foo;  # $foo is still 1

########################

our $foo = 1;
{
    my $foo = 2;
}
print $foo;  # $foo is still 1

在这里看起来 localmy 的表现形式又很类似了, 让人比较迷惑, 其实他们还是有很大区别的, 继续举例子说明.

三. local 会在新作用域中暂时改变符号表中的变量值, 直到作用域退出. 而 my 创建的变量由于不存在于符号表中, 所以不会影响符号表中的同名变量:

our $foo = 1;
sub print_val {
    print $foo;
}
{
    local $foo = 2;
    print_val();  # this will print 2
}

########################

our $foo = 1;
sub print_val {
    print $foo;
}
{
    my $foo = 2;
    print_val();  # this will print 1
}

分析下上面的例子, 在我们调用了一个函数来打印 $foo 的值的时候, 情况又发生了一些变化.

首先, 我们在函数 print_val 中打印的 $foo 肯定是 our $foo 声明出来的. 注意一点, ourlocal 声明的变量都存在于符号表中, 可以认为他们就是同一个变量. local 的效果就是在新的作用域中, 暂时把外部的变量的值保存起来, 然后把符号表中该变量的值设置为新的值, 等到作用域退出后, 将保存的值恢复到符号表中. 所以在我们使用 local $foo = 2$foo 重新赋值为 2 以后, print_val 函数打印出来的 $foo 也变成了 2. 因为他们都是软件包变量, 而且在新的作用域中, 它已经被暂时替换为了 1.

而第二种写法使用 my 声明变量的时候, 注意它声明出来的是词法变量, 并不存在于符号表中, 所以可以认为此时的 $foo 跟外部使用 our $foo 声明的变量 $foo 毫无关系, 根本就是两个变量. 所以我们当把这个词法变量 $oo 声明出来且赋值为 2 的时候, 对软件包变量$foo 没有任何影响, 自然使用 print_val 函数打印软件包变量 $oo 的时候依然会打印出最初的值 1 了.

研究的时候需要重点关注一下此处的区别.

typeglob

$fooscalar, @fooarray, %foohash 一样, *footypeglob. typeglob 也和符号表一样, 类似于一个哈希表但是却不是一个哈希表, 只是行为有些类似.

我们可以从中取值但是不能对其赋值:

$foo = *foo{SCALAR};
@foo = *foo{ARRAY};
%foo = *foo{HASH};

*foo{SCALAR}   = 5;  # error
*foo{WHATEVER} = 5;  # error

而且我们不能通过 keys 来查看 typeglob 中到底有哪些键名:

print keys *foo;  # Experimental keys on scalar is now forbidden

实际上, typeglob 中的键名只有如下几种, 是固定不可变的:

键值 变量写法 说明
SCALAR $foo 标量
ARRAY @foo 数组
HASH %foo 哈希
CODE &foo 函数
IO - 文件句柄
GLOB *foo typeglob
FORMAT - 报表的格式
NAME - 变量的名字
PACKAGES - 包的名字

别名 (alias)

我们可以通过把变量的 typeglob 赋值给另一个变量的 typeglob 来创建变量的别名.

创建别名的时候也有两种方式, 一种是创建了完全的别名, 即上一节提到的全部键值对应的部分都成为了别名. 另一种是创建部分别名, 将某个变量的引用赋给另一个变量的 typeglob, 那么只有这个类型的变量创建了别名:

*foo = *bar;  # full alias
*foo = \$bar;  # partial alias

而且除了"完全别名"和"部分别名"这个不同以外, 他们还有一些微妙的区别, 此处需要结合最初讲到的 local 等内容来举例说明:

our $bar = 1;
*foo = *bar;
{
    local $bar = 2;
    print $foo;  # this will print 2
}

########################

our $bar = 1;
*foo = \$bar;
{
    local $bar = 2;
    print $foo;  # this will print 1
}

发现了吗, 在新的作用域中, "完全别名"的写法会受到 local 的影响, 而"部分别名"的写法不会受到 local 的影响.

我们可以通过打印各个变量的内存地址的方式来看看 local 的时候发生了什么, 而使用"完全别名"和"部分别名"两种写法的时候又各自发生了什么.

正常使用 local 时的效果

our $foo;
sub print_val {
    print \$foo . "\n";
}
print \$foo . "\n";         # SCALAR(0x3278c0)
{
    local $foo;
    print \$foo . "\n";  # SCALAR(0x72d430)
    print_val();           # SCALAR(0x72d430)
}

结合我们之前提到的知识, 可以发现, 使用 local 的时候, 会在新的作用域中, 创建另一个软件包变量, 而且能看见使用函数 print_val 打印出来的变量地址就是我们 local 产生的那个变量的地址, 证明了我们将外部作用域中同名软件包变量保存了起来, 然后使用新作用域中 local 产生的变量替换了这个软件包变量, 直到作用域退出后恢复以前的那个变量. 这里注意下实现方式是, 在新的作用域中, $foo 指向了 local 新产生的变量的地址, 原作用域中的变量依然存在, 在退出新作用域回到老作用域的时候, 将 $foo 重新指向老的变量地址, 实现了恢复的效果.

"完全别名"下的效果:

our $bar;
print \$bar . "\n";  # SCALAR(0x48dfb8)

*foo = *bar;
print \$foo . "\n";  # SCALAR(0x48dfb8)

sub print_val {
    print \$foo . "\n";  # SCALAR(0x4fb590)
    print \$bar . "\n";  # SCALAR(0x4fb590)
}

{
    local $bar;
    print_val();
    print \$bar . "\n";  # SCALAR(0x4fb590)
    print \$foo . "\n";  # SCALAR(0x4fb590)
}

在这种"完全别名"的写法下, 外部作用域中两个变量 $foo$bar 地址一致, 新作用域中两个变量 $foo$bar 的地址也一致, 使用函数打印的时候, $foo$bar 也一致, 而且和 local 产生的变量一致, 说明 foo 真的就完全是 bar 的别名, 你就是我, 我就是你, 双宿双飞.

当进入新作用域, 使用 local 创建了新的变量 $bar 的时候, 外部的 $bar 也会指向这个新的变量, 而 $foo 又是永远指向 $bar 的, 所以 $foo 也会指向这个新的变量地址.

"部分别名"下的效果:

our $bar;
print \$bar . "\n";  # SCALAR(0x327440)

*foo = \$bar;
print \$foo . "\n";  # SCALAR(0x327440)

sub print_val {
    print \$foo . "\n";  # SCALAR(0x327440)
    print \$bar . "\n";  # SCALAR(0x8ad430)
}

{
    local $bar;
    print_val();
    print \$bar . "\n";  # SCALAR(0x8ad430)
    print \$foo . "\n";  # SCALAR(0x327440)
}

使用"部分别名"的写法的时候, foo 其实只是外部变量 bar 的别名. 因为使用部分别名这种写法, 是让 $oo 指向外部这个 $bar 的变量地址, 并不是无条件指向 $bar 的最新地址.

所以当我们在新作用域中使用 local 创建了新的变量 $bar 的时候, $foo$bar 就分道扬镳了, 各自走上了不同的道路.

在新的作用域中, 创建了新的 $bar 的变量地址, 这会让所有名字为 $bar 的变量都指向这个地址. 然而原来的地址并没有消失, 而我们的 $foo 又是被定义为指向老的那个地址的, 它并没有必要跟着指向新的地址. 所以自然在这种状况下分别打印 $foo$bar 就看到的是不同的地址了.

Comments
Write a Comment