理解Ruby中的作用域

理解Ruby中的作用域

作用域对于Ruby以及其它编程语言都是一个需要理解的至关重要的基础知识。在我刚开始学习ruby的时候遇到很多诸如变量未定义、变量没有正确赋值之类的问题,归根结底是因为自己对于ruby作用域的了解不够,但在你看看完我的这篇文章后,相信你不会再担心会遇到这些头疼的问题。

什么是作用域?

当谈论到作用域的时候,应该马上想到变量可见性这两个词,变量可见性是作用域的主要内容,没错,作用域就是关于在代码的什么地方什么变量是可见的,当你充分了解了作用域后,给你一段代码,你可以轻易知道此时什么变量是可见的,还有最重要的是知道什么变量在这段代码执行时是不可见的。

那就从一开始的地方就将所有变量定义好,让所有变量在程序的所有地方都是可见的,不就可以免除作用域的问题了?这样不是让生活更简单吗?嗯,但事实并不是这样......

你也许知道很多对立的程序员阵营,如:函数式编程阵营与面向对象编程阵营、不同的变量命名风格阵营、不同的代码格式阵营等等,但从没有人听说过支持去除作用域的阵营,特别是那些有着丰富编程经验的程序员更是保留作用域的忠实支持者,为什么? 因为如果你编程的经历越多,你会越来越发觉对所有变量在整个程序中保持可见是多么愚蠢、破坏性的行为,因为一开始就将所有变量定义并对整个程序都可见,那么在程序运行时你很难追踪什么时候、哪一段代码对哪个变量做了修改,而对于多人协作的工程,当面对成千上万行的代码时你很难知道某个变量是谁定义的?在什么地方被赋值?大量使用全局变量会使得你的程序变得难以检测追踪、运行结果难以预测,如果使用全局变量,你会遇到一个很棘手的问题就是如何给成千个全局变量进行唯一命名。

作用域提供开发者一个实现类似计算机安全系统中的最少权限原则的方式,试想一下你正在开发一个银行系统,而所有人都可以进行读写所有的数据,某个人对存款进行了更改但不能确定他是这笔存款的所有者,这将是多么可怕的一件事!

  Ruby变量作用域快速浏览

  你可能已经对ruby的变量作用域有所了解,但我发现大部分教程都是对变量类型仅仅做一个简单的介绍,而没有对其有一个精确的讲解,下面是对于ruby中各类型变量的一个详细介绍:

类变量(以@@为前缀):仅对定义该类变量的类及其子类可见。

实例变量(以@为前缀):对定义该 变量的类的实例及其实例方法可见,但不可以直接被类使用。

全局变量(以$为前缀):对整个ruby脚本程序可见。

局部变量:仅在局部代码块中可见,这也是在编程中最经常使用到和容易出现问题的变量类型,因为局部变量的作用范围依赖很多的上下文代码块。

下面用一张图片简洁明了地阐述4种变量作用域的关系。

理解Ruby中的作用域

接下来的篇章我会专注于介绍这局部变量。从我的经验以及与他人交谈中发现大部分作用域的问题都是由于对局部变量没有一个很好的理解。

局部变量什么时候被定义?

  在ruby语言中,对于在一个类中定义的实例变量(如@variable)不需要显式提前声明,在类的方法中尝试获取一个还未声明的实例变量会返回nil,而当你尝试获取一个未声明的局部变量的值时会抛出NameError错误 (undefined local variable or method)。

Ruby解释器在看到局部变量赋值语句时将该变量加入局部作用域,需要注意的是无论该局部变量的赋值是否会执行,只要ruby解释器看到程序存在该变量赋值语句就会将该变量加入局部作用域,所以像下面的代码是可以正常执行而不报错的。

if false # the code below will not run
a = 'hello' # ruby解释器看到该条语句将a变量加入局部作用域
end
p a # nil, 因为对a的赋值语句没有执行

你可以尝试下删除 a = ‘hello’ 这条语句,看看会有什么情况发生。

局部变量命名冲突

  假设你有以下代码

def something
'hello'
end p something
==> hello
something= 'Ruby'
p something
==> Ruby #'hello' is not printed

在ruby中方法的调用可以像变量一样不需显式添加一个括号和方法接收对象,所以你可能会遇到像上面代码的命名冲突问题。

当你的ruby代码中存在同名的变量名和方法名,同名的变量会以较高的优先级覆盖掉同名的方法,但这并不表示你不能再调用该方法,此时可以通过在方法调用时显式添加括号或者在调用方法前显式添加self作为方法接收对象。

def some_var; 'I am a method'; end
some_var = 'I am a variable'
p some_var # I am a variable
p some_var() # I am a method
p self.some_var # I am a method. 显式使用self对象调用some_var方法

一个很有效的判断变量是否在作用域之外的方法

首先在你的代码段中找到你要查看的变量 ,接着一直往上查找改变量,直到你找到该变量,这时会有两种情况:

  1. 到了作用域的起始地点(def/class/module/do-end 代码块的开头)
  2. 找到对该变量赋值的语句

如果你在遇到2之前先遇到1的情况,那么很有可能你的代码会抛出NameError错误,如果你在遇到1之前先遇到情况2,那么恭喜你,该局部变量就在这段代码的作用域当中。

实例变量 vs 局部变量

实例变量属于某个对象,在该对象的所有方法中都可用,当局部变量是属于某个特定的作用域,仅在该作用域下可用。实例变量在每个新实例中可用进行修改,而局部变量会在进入一个新作用域时被改变或者覆盖,那如何知道作用域什么时候会改变?答案是:作用域门。

作用域门:理解作用域至关重要的一个概念

  当使用下面这些语句时,你猜想会对作用域产生什么影响?

  1. 使用class关键字定义一个类;
  2. 使用module 定义一个模块;
  3. 使用def关键字定义一个方法。

当你使用这些关键字的时候你就开辟了一个新的作用域,相当于ruby打开了一扇门让你的代码进入一个全新的上下文环境。所有的class/def/module 定义被成为作用域门,因为它们开启了一个新的作用域,在这个作用域中所有的旧作用域都不再可用,旧的局部变量会被新的局部变量所替代。

如果你对上面的陈述感到疑惑,没关系,通过下面的例子可以让你更好地掌握这一概念。

v0 = 0
class SomeClass # 开启新作用域
v1 = 1
p local_variables # 打印出所有局部变量 def some_method # 开启新作用域
v2 = 2
p local_variables
end # 作用域关闭
end # 作用域关闭 some_class = SomeClass.new
some_class.some_method

当你运行上面的代码后,你会看到分别打印出[:v1]、[:v2],为什么v0不会在SomeClass中的局部变量中?v1不再some_method方法中?正是因为class和def关键字分别开辟了新的作用域,将旧的作用域替代了,旧作用域的局部变量在新的作用域中便不复存在了,为什么说作用域是被"替代”了?因为旧的作用域只是暂时被替换而已,在新作用域关闭时会再次回到旧的作用域,在some_class.som_method这条语句之后运行p local_variables你会看到v0重新出现在局部变量列表中。

打破作用域门

  正如你所见,通过class/def/module会限制局部变量的作用域,并且会屏蔽掉之前的作用域,使得原来定义的变量在新作用域中不可见,那假如我们想要处理作用定义的方法、类、模块之外的局部变量,应该如何打破作用域之间的隔离?

答案很简单,只需要用方法调用的方式替换作用域门的方式,就是说:

  • 用Class.new 代替 class
  • 用Module.new 代替 module
  • 用define_method代替def

下面是一个例子:

v0 = 0
SomeClass = Class.new do
v1 = 1
p local_variables define_method(:some_method) do
v2 = 2
p local_variables
end
end some_class = SomeClass.new
some_class.some_method

运行上面的代码后,会打印出[:v1, :v0, :some_class]和[:v2, :v1, :v0, :some_class]这两行局部变量名,可以看到我们成功地打破了作用域的限制,这归功于我们接下来需要学习的ruby中的blocks的功能。

Blocks也是作用域门的一种吗?

你也许会认为blocks也是作用域门的一种,毕竟它也创建了一个包含局部变量的作用域,并且在blocks中定义的局部变量在外部是不可访问的,就像下面的例子一样:

sample_list = [1,2,3]
hi = ''
sample_list.each do |item| # block代码块的开始
puts hi # 是打印出123还是抛出错误?
hello = 'hello' # 声明并赋值给变量hello
end p hello # 打印出‘hello’还是抛出undefined local variable 异常

如你所见,在blocks代码块中定义的变量‘hello’是只存在block作用域中的局部变量,外部不能访问也不可见。

如果block代码块是一个作用域门,那么在puts hi这条语句执行时应该会触发异常,但在block中却能成功打印出hi的值,而且在blocks中你不仅能访问hi的值,并且能够对其进行修改,尝试在do/end代码块中修改hi的值为‘456’,你会发现外部变量 hi 的值成功被修改。

那如果不想让block中的代码修改外部局部变量的值呢?这是我们可以使用block-local variable(类似方法中的形参),只需在block中将参数用 ; 分割后填写外部同名的局部变量名(这些变量在block中会成功block-local variables),在block里面对这些变量的修改不会影响其在外部原来的值,下面是一个例子:

hi = 'hi'
hello ='hello'
3.times do |i; hi, hello|
p i
hi = 'hi again'
hello = 'hello again'
end
p hi # "hi"
p hello # "hello"

如果你在block的参数列表中移除 ; hi, hello ,在代码块外面你会发现变量 hi 和 hello 的值变成‘hi again' 和 'hello again'了。

记住使用do和end创建block代码块的同时会创建一个新的作用域。

[1,2,3].select do |item| # do is here, new scope is being introduced
# some code
end

使用each、map、detect或者其它方法,当你使用do/end创建代码块当做参数传给这些方法,只不过是创建了一个新的作用域。

Blocks和作用域的一些小怪癖

试想下面的代码会输出什么:

y to guess what will this Ruby code print:

2.times do
i ||= 1
print "#{i} "
i += 1
print "#{i} "
end

你是不是以为会输出 1 2 2 2 ?但答案是1 2 1 2 ,因为每一次的迭代会创建一个新的作用域并重置局部变量。因此,在这段代码中我们分别创建了两个迭代,每次迭代开始都将变量i 重置为1。

那么你认为下面的代码会输出什么?

def foo
x = 1
lambda { x }
end x = 2 p foo.call

答案是1,因为blocks和blocks对象看到的是定义在其内的作用域而不是调用该block时的作用域。这与它们在ruby中是被看作闭包有关,闭包是一种对代码的包含,从而使该段代码带有以下特征:

  • 该部分代码可以像对象一样被调用(可以在定义之后通过call调用)
  • 当闭包被定义时,记录该作用域下的变量

这些特性给我们在编写无限数字生成器等情况时提供方便:

def increase_by(i)
start = 0
lambda { start += i }
end increase = increase_by(3)
start = 453534534 # won't affect anything
p increase.call #
p increase.call #

你可以利用lambda表达式方便地对变量进行延迟赋值:

i = 0
a_lambda = lambda do
i = 3
end p i #
a_lambda.call
p i #

你认为下面代码段中的最后一行代码会输出什么?

a = 1
ld = lambda { a }
a = 2
p ld.call

如果你的回答是1,那么你就错了,最后一行语句会输出2。咦,等一下,lambda表达式没有看到它们的作用域吗?如果你再仔细想一想,你会发现这其实是正确的,a = 2也在lambda表达式的作用域当中,真如你所见到的,lambda表达式在其被调用时才开始计算变量的值。如果你没有考虑到这点,将会容易触发难以追踪的bug。

如何在两个方法*享变量

一旦我们知道如何打破作用域门,我们就可以利用这些来实现很惊人的效果,我从ruby元编程这本书中学习到这些知识,这本书帮助我理解ruby背后的作用域的工作原理。下面是一个例子:

def let_us_define_methods
shared_variable = 0 Kernel.send(:define_method, :increase_var) do
shared_variable += 1
end Kernel.send(:define_method, :decrease_var) do
shared_variable -= 1
end
end let_us_define_methods # 运行这条语句后在初始化increase_var、decrease_var方法的定义
p increase_var #
p increase_var #
p decrease_var #

是不是非常简洁?

顶层作用域

什么事顶层作用域?如何判断当前代码是否在顶层作用域中?处在顶层作用域意味着你还没有调用任何方法或者你所有的方法都已经调用结束并返回。

在ruby中,万事皆对象。即使你处在顶层作用域中,你同样也是处在一个对象中(该对象继承自Object类,称之为main对象),自己尝试运行下面的代码进行检验。

p self # main
p self.class # Object

我在哪里?

在调试中,如果你知道当前self的值会解决你很多令人头疼的问题,当前的self值影响到实例变量和没有显式指明接收对象的方法调用,如果你已经确定你的变量或者方法已经定义(你在程序中看到该变量/方法的定义)但还是触发undefined method/instance variable错误,那么很有可能的self值是有问题的。

小测试: 哪些变量是可用的?

通过下面的小测试来确认你是否已经掌握了本文提到的知识,设想你是一个小型ruby调试器来运行下面的代码:

class SomeClass
b = 'hello'
@@m = 'hi'
def initialize
@some_var = 1
c = 'hi'
end def some_method
sleep 1000
a = 'hello'
end
end some_object = SomeClass.new
some_object.some_method

以sleep 1000这条语句作为断点,你可以看见什么?哪些变量在现在这个断点是可用的?在你运行代码前先想一下并给出自己的答案,你的答案不应该仅仅包含可用的 变量,还应该说明为什么该变量在此时是可用的。

正如我之前提到的,局部变量是绑定在作用域中的,some_method函数定义时会闯将一个新的作用域并将其之前的作用域替换成新的作用域,在新的作用域当中你,a变量是唯一一个可用的变量。

实例变量是与self绑定的,在上面的代码中,some_object是当前的对象,@some_var是对于整个some_object可用的实力变量。类变量也类似,@mm实例变量在当前对象中也是可用的。局部变量b和c因为作用域门的原因在方法中变得不可见,如果你想让它们变得可见,请见打破作用域门的小节。

希望我的文章对你有帮助,如果有疑惑,请在评论进行评论。

----------------------------------------分割线----------------------------------------------------

本文翻译自Darko GjorgjievskiUnderstanding Scope in Ruby,觉得这篇文章对于作用域的讲解比较好,对于ruby作用域的理解很有帮助,所以就翻译下来。

上一篇:ruby中的可调用对象--方法


下一篇:[翻译]理解Ruby中的blocks,Procs和lambda