Ruby元编程笔记——代码块

本章内容理解:讲了打破封装,随时随地增加或修改变量和方法的技术。

代码块作为“程序中的基本操作元素之一”,是实现下面这些操作的基础:

代码块是闭包,闭包的作用在于,让变量在指定的作用域发挥作用,不会受到作用域外变量的干扰。也在于将变量带到别的作用域。

接下来依次记录块,作用域以及打破封装共享变量的技术操作。

1
2
3
4
5
def a_method(a, b)
a + yield(a, b)
end
a_method(1, 2) {|x, y| (x + y) * 3 } #=> 10

通常情况下,如果只有一行,代码块可以通过大括号定义,比如上面的{|x, y| (x + y) * 3 },若有多行,可以通过def...end来定义。

在调用一个方法时才可以定义一个块。块会被直接传递给这个方法,该方法可以用yield关键字调用这个块

通过Kernel#block_given?()方法来询问当前的方法调用是否包含块。

1
2
3
4
5
6
7
def a_method
return yield if block_given?
'no block'
end
a_method # "no block"
a_method { "here's a block!" } # "here's a block!"

可以运行的代码由两部分组成:代码本身和一组绑定。

从上面两个例子可以看出,代码块是闭包,它可以把变量带出原来的作用域,带入a_method中执行。代码块不能孤立地运行,它需要一个执行环境:局部变量,实例变量,self等。可运行的代码包括两部分:代码本身和一组绑定。那么代码块是如何获得一组绑定的呢?

1
2
3
4
5
6
7
def my_method
x = "Goodbye"
yield("cruel")
end
x = "Hello"
my_method {|y| "#{x}, #{y} world" } # "Hello, cruel world"

创建代码块时,代码块会获得局部绑定,然后将这两者一起传给一个方法。

还可以在代码块内定义额外的绑定,但这些绑定在代码块结束时就消失了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def just_yield
return yield if block_given?
end
top_level_variable = 1
just_yield do
top_level_variable += 1
local_to_block = 1
puts local_to_block # => 1
end
puts top_level_variable # => 2
puts local_to_block
# => block_local_vars_failure.rb:24:in `<main>':
# undefined local variable or method `local_to_block' for main:Object (NameError)

基于代码块可以获取局部绑定并一直携带它们的特性,那应该如何使用闭包呢?接下来就要理解作用域了。局部变量之所以被称为局部变量,也是因为它只在自己的作用域内有效。

作用域

作用域(scope),程序设计概念,通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。

作用域通过作用域门来划分。程序会在作用域门关闭前一个作用域,同时打开一个新的作用域,作用域门有三处地方:

1.类定义。

2.模块定义。

3.方法。

切换作用域下面的例子演示了在程序运行时作用域是怎样切换的,它会用Kernel#local_variables()方法来跟踪绑定的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
blocks/scopes.rb
module MyModule # 作用域门:进入module
v1 = 1
class MyClass # 作用域门:进入class
v2 = 2
local_variables # [:v2]
def my_method # 作用域门:进入def
v3 = 3
local_variables
end # 作用域门:离开def
local_variables # [:v2]
end # 作用域门:离开class
end # 作用域门:离开module
obj = MyClass.new
obj.my_method # [:v3]
local_variables # [:v1, :obj]

在一些语言中,比如Java和C#,有“内部作用域(innerscope)”的概念。在内部作用域中可以看到“外部作用域(outerscope)”中的变量。但Ruby中没有这种嵌套式的作用域,它的作用域之间是截然分开的:一旦进入一个新的作用域,原先的绑定就会被替换为一组新的绑定。这意味着在程序进入MyClass后,v1便“超出作用域范围”,从而就不可见了。

几种变量及其作用域:

全局变量

顶级实例变量

局部变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
> class A
> $var1 = 2 # => 全局变量
> @var = "the top level variable" # =>顶级实例变量
>
> def my_method
> puts @var
> var2 = 3
> end
>
> def my_method1
> puts var2
> # => NameError: undefined local variable or method `var2' for
> # # <A:0x007ffc8f8bfe00> 在my_method中定义的局部变量不能在my_method1中使用
> end
> end
>
> class B
> def my_method
> puts $var1
> end
>
> def my_method1
> puts @var2
> end
> end
>
> a = A.new
> #=> #<A:0x007ffd08900328>
> a.my_method
> # 没有输出,要由main扮演self的角色,才能有用。而这里的self是对象a
> # 所以去掉classA,直接在irb中运行这段代码才可以输出@var
>
> b = B.new
> #=> #<B:0x007fb62584bb78>
> b.my_method
> #=> 2 # 全局变量可以在任何作用域中访问,所以即使$var1定义在A中,B的my_method方法也可以输出它
> b.my_method1
> # 没有输出
>

>

全局变量的问题在于系统的任何部分都可以修改它们。因此,你会立即发现几乎没法追踪谁把它们改成了什么。正因为如此,基本的原则是:如非必要,尽可能少使用全局变量。

你有时可以用顶级实例变量来代替全局变量,只要main对象在扮演self的角色,就可以访问一个顶级实例变量。但当其他对象成为self时,顶级实例变量就退出作用域了。

如果希望让一个变量穿越作用域,那么该怎么做呢?要解答这个问题,还是得回到块的主题上。

如何改变作用域的范围?

1.扁平化作用域:
1
2
3
4
5
6
7
my_var = "Success"
class MyClass
# 你希望在这里打印my_var...
def my_method
# ..还有这里
end
end

class这个作用域门。虽然不能让my_var穿越它,但是可以把class关键字替换为某个非作用域门的东西:方法。如果能用方法替换class,就能在一个闭包中获得my_var的值,并把这个闭包传递给该方法。

所以上面的代码可以这样修改:

1
2
3
4
5
6
7
8
9
10
my_var = "Success"
MyClass = Class.new do
puts "# 你希望在这里打印 my_var = #{my_var}"
define_method :my_method do
puts "# ..还有这里 my_var = #{my_var}"
end
end
mc = MyClass.new
puts mc.my_method

这里用了Class.new以及define_method实现扁平作用域,从而实现了变量共享。

扁平化作用域,顾名思义就是把作用域挤压在一起,共享变量。

2.共享作用域

假定你想在一组方法之间共享一个变量,但是又不希望其他方法也能访问这个变量,就可以把这些方法定义在那个变量所在的扁平作用域中,这时这个扁平作用域中也叫做共享作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def define_methods
shared = 0
Kernel.send :define_method, :counter do
shared
end
Kernel.send :define_method, :inc do |x|
shared += x
end
end
define_methods
counter # => 0
inc(4)
counter # => 4

用自己的话整理了改变作用域操作代码块的操作:

1.更改一个类中的实例变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass
def initialize
@v = 1
end
end
obj = MyClass.new # => #<MyClass:0x007f93d5852260 @v=1>
obj.instance_eval do
self #=> #<MyClass:0x007f93d5852260 @v=1>
@v #=> 1
end
v = 2 obj.instance_eval { @v = v }
obj.instance_eval { @v } # 2
2.传递一个块到一个方法中
1
2
3
4
5
def a_method(a, b)
a + yield(a, b)
end
a_method(1, 2) {|x, y| (x + y) * 3 } #=> 10

这里就把{|x, y| (x + y) * 3 }传递到a_method方法中,然后用yield求出程序的最终结果。

3.传递一个块的结果到一个到另一个块中
1
2
3
4
5
6
7
8
9
10
11
12
class CleanRoom
def current_temperature
19
end
end
cr = CleanRoom.new
cr.instance_eval do
if current_temperature < 20
puts "wear jacket"
end
end

但是instance_eval无法传递参数,要改用instance_exec,下面的例子比较了它们之间的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class C
def initialize
@x = 1
end
def my_method
@x
end
end
class D
def twisted_method
@y = 2
C.new.instance_eval { "@x: #{@x}, @y: #{@y}" }
end
end
puts D.new.twisted_method
# => @x: 1, @y:
# 把instance_eval换成instance_exec
class D
def twisted_method
@y = 2
C.new.instance_exec(@y) { |y| "@x: #{@x}, @y: #{y}" }
end
end
puts D.new.twisted_method
# => @x: 1, @y: 2

剖析代码块的底层

从底层看,使用代码块分为两步,一是将代码块打包备用,二是是执行被打包的代码。

这些打包备用的东西就是可调用对象:

1.块

2.使用proc。proc基本上就是一个由块转换成的对象。

3.使用lambda。它是proc的近亲。

4.使用方法。

Proc对象

尽管Ruby中绝大多数东西都是对象,但是块不是。为什么要关心这个呢?设想希望存储[一个块供以后执行],这时,你需要一个对象才能做到。Bill说道,“为了解决这个问题,Ruby在标准库中提供了名为Proc的类。”一个Proc就是一个转换成对象的块。

1
2
3
inc = Proc.new {|x| x + 1 }
# 更多...
inc.call(2) #=>3

这种先打包代码,以后调用的技术称为延迟执行(Deferred Evaluation)。

1
2
3
dec = lambda {|x| x - 1 }
dec.class #=>Proc
dec.call(2) #=>1
&操作符

&操作符能把代码块传递给另一个方法或者代码块。

1
2
3
4
5
6
7
8
9
10
11
# 这里{ |x, y| x * y }就被当成&operation参数传递给do_math方法,随后再被传递给math方法
def math(a, b)
yield(a, b)
end
def do_math(a, b, &operation)
math(a, b, &operation)
end
# puts do_math(2, 3){ |x, y| x * y }
puts do_math(2, 3) #=> wrong

&操作符会把proc对象my_proc转换为块,再把这个块传给这个方法

1
2
3
4
5
6
def my_method(greeting)
puts "#{greeting}, #{yield}!"
end
my_proc = proc { "Bill" }
my_method("Hello", &my_proc)
Proc和lambda的区别:
1.对return的处理不同。
1
2
3
4
5
6
7
8
9
10
def double(callable_object)
callable_object.call * 2
end
l = lambda { return 10 }
double(l) # 20
p = Proc.new { return 10 }
# 这会失败,并产生一个LocalJumpError错误:
# double(p)
2.对参数检查的容忍度不同。
1
2
3
4
p = Proc.new{ |a, b| [a, b] }
p.arity #=> 2
p.call(1,2,3) #=> [1, 2]
p.call(1) #=> [1, nil]

proc根据实际参数的数量自动调整输出结果。再来看lambda

1
2
3
4
ld = lambda{ |a, b| [a, b] }
ld.arity #=> 2
ld.call(1,2,3) #=> ArgumentError: wrong number of arguments (given 3, expected 2)
ld.call(1) #=> ArgumentError: wrong number of arguments (given 1, expected 2)

如果参数个数不恰好是两个,就会报错。

方法也是一个可调用对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
blocks/methods.rb
class MyClass
def initialize(value)
@x = value
end
def my_method
@x
end
end
object = MyClass.new(1)
m = object.method :my_method
m.call => # 1

这章知识的运用:

开发一个DSL语言,进行几次重构,使代码质量不断进阶:

1.作用域共享

2.变量不要胡乱地散落在顶级作用域里

3.把事件触发条件从代码块转换成proc

4.消灭全局变量,使用共享作用域

5.增加洁净室