Ruby Ruby 元编程(第二版)--- 代码块

qinsicheng · 2023年01月27日 · 626 次阅读

代码块

学习路线

  • 代码块的基础知识
  • 作用域的基础知识:用代码块携带变量穿越作用域
  • 通过传递块给instance_eval方法来控制作用域
  • 怎么把块转换为 Proc 和 Lambda 这样的可调用对象,供以后调用

代码块基础知识

def a_method(a,b) 
  a+ yield(a,b)
end
a_method(1,2) {|x,y| (x+y)*3 }

​ 代码块可以用大括号定义,也可以使用do...end关键字定义,通常如果一行的块使用大括号,而多行的块使用do...end

​ 块可以有自己的参数,比如上面的例子中的 x 和 y,可以像调用方法那样为块提供参数,

​ 可以通过Kernel#block_given?查看是否传达快,如果没有,调用 yield 则会报错

代码块是闭包

代码块可以把变量带出原来的作用域

代码块不能孤立的运行,它需要一个执行环境:局部变量,实例变量,self 等

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

当我们定义一个 block 时,它将获取到环境中的绑定,当 block 被传给一个方法时,他会带着这些绑定一起进入该方法

def my_method
  x = "Good"
  yield "cruel"
end

x = "Bad"
my_method {|y| p "#{x} , #{y} World"}  # => "Bad , cruel World"

这里我们调用 my_method 时,创建了一个 block,并获取顶层上下文中的 x 变量,而 my_method 中定义的 x 变量对于 block 是不可见的,也可以在 block 中定义额外的绑定,但这些绑定在 block 结束时就消失了。

def just_yield
  yield
end

top_leval_var = 1

just_yield do
  top_leval_var += 1
  local_to_block = 1
end

p top_leval_var     # => 2
p local_to_block    # => error!

​ 基于这些特性,人们喜欢把代码块称为闭包,换句话说:代码块可以获取局部绑定,并一直带着他们

如何使用闭包呢?

​ 这里我们需要了解绑定寄居的地方——作用域,需要判断程序在哪里切换了作用域,作用域的作用有哪些?

作用域

​ 不论是 Java,Python,Ruby 都会有作用域的概念,就好像是单独的一个作用空间,一个领地,在这里有专属的局部变量

切换作用域

v1 = 1  # 顶层作用域
class MyClass  # 类作用域
    v2 = 2
    p local_variables  # => [:v2]
    def my_method  # 方法作用域
        v3 = 3
        p local_variables  # => [:v3]
    end
    p local_variables  # => [:v2]
end
obj = MyClass.new 
obj.my_method
obj.my_method
p local_variables  # => [:v1, :obj]

最初在顶层作用域 定义 v1 = 1

定义 class MyClass,切换作用域,一旦切换作用域,绑定也会修改,v1 对于 MyClass 内部域是不可见的,在其内部定义了一个方法和变量,当执行到实例方法内部,作用域再次切换

当 MyClass 定义完毕,再次切换回顶级作用域。

全局变量与顶级实例变量

$var = 1
class MyClass
    def incre
        $var += 1
    end
end
p $var
obj = MyClass.new 
obj.incre
p $var

这里定义了全局变量 var,发现在所有作用域都可以访问并操作到,所以一旦出现问题,很难排查。

@var = 1
def my_method
    @var += 1
end
p @var
my_method
p @var

这里定义一个顶级上下文中的实例变量,当 main 对象扮演 self 的角色,就可以访问到顶级实例变量,但如果进入其他对象作为 self,则无法访问到

@var = 1
class MyClass
  def my_method
    @var = "this is not top level @var"
  end
end 
obj = MyClass.new
p obj.my_method

顶级实例变量 要比 全局变量 有限的安全

这里我们想弄清楚作用域是如何切换,绑定是如何切换的,需要了解作用域门

作用域门

程序一般会在三个地方,关闭之前的作用域,打开新的作用域,分别为:

  1. 方法 def
  2. 类定义 class
  3. 模块儿定义 module

每个关键字对应一个作用域门,各个作用域中的变量相互隔离

现在看到每个作用域有独立的空间,如果想要变量在作用域之间传递,该如何操作?

扁平化作用域

var = "Success"
class MyClass
    # 这里想访问到var
    def my_method
        # 这里想访问到var
    end
end

一旦切换作用域局部变量就会失效,如何能让 var 穿越两个作用域被访问到?

Ruby 是非常灵活的,它为一种实现提供了多种方法,上面知道了class module def三个关键字为作用域门,那我们使用其他方式来实现相同的结果,这样就可以避免切换作用域了

var = "Success"
# 使用Class.new 切换 class关键字,避免切换作用域
MyClass = Class.new do 
    p var
  # 使用动态定义方法,替换def关键字,避免切换作用域
    define_method :my_method do
        p var
    end
end
obg = MyClass.new 
obg.my_method

​ 如果两个作用域挤压在一起,我们通常简称为 扁平作用域

共享作用域

​ 如果想在一组方法之间共享一个变量,但又不想别的方法访问到这个变量,就可以把这些方法定义在该变量所在的扁平作用域

def my_method
  # 这里是一个局部变量,希望仅被几个方法访问到
    share = 0
  # 使用内核方法,调用 define_method 来定义函数,而又不用切换域,这样这几个方法就可以访问到变量
  # 也可以只用 define_method :counter do
    Kernel.send :define_method,:counter do
        share
    end
    Kernel.send :define_method,:inc do |x|
        share += x
    end
end
def other_method
  # 这里则无法再访问到share
  share
end
my_method
p counter   # => 0
inc 4
p counter   # => 4

上下文探针(instance_eval)

这里我们学习一个新的方法:BasicObject#instance_eval,它在一个对象的上下文中执行 block,运行时,代码块的接收者将会成为 self

class MyClass
    def initialize
        @v = 1
    end
end
obj = MyClass.new
# 这里self切换为obj
obj.instance_eval do
    p self
  # 输出的实例变量也是属于self的
    p @v
end
# 上下文探针结束后,self又变回了 main 
# 这里 下面三行代码都处在扁平作用域,所以Block可以使用局部变量v,并访问到obj中的实例变量
v = 2
obj.instance_eval {@v = v}
obj.instance_eval {p @v}

这里需要注意一个点:instance_eval 会将接收者变为当前对象 self。而调用者的实例变量就落在作用域范围外,如果不了解,就会出现 Bug,例如:

class C
    def initialize
        @x = 1
    end
end
class D
    def twisted_method
    # 如果这里 @y 改为 y ,即可被访问到
        @y = 2
    # 在执行下面代码之前,self为D.new,一旦执行下面的代码,C.new为self,@x在C.new中定义了,@y则没有
        C.new.instance_eval {"@x : #{@x} , @y : #{@y}"}
    end
end
p D.new.twisted_method  # => "@x : 1 , @y : "

​ 这里输出发现,@y并未访问到,可是上面的调用代码在同一个扁平作用域,原来是因为 instance_eval 将 C.new 对象变为当前对象 self,调用者的实例变量就落在了作用域外了,所以访问不到为:nil。

这里使用 instance_exec 来解决传递参数的问题

instance_exec

class D
    def twisted_method
        @y = 2
    # 这里主动将 @y传递到块儿中
        C.new.instance_exec(@y) {|y|"@x : #{@x} , @y : #{y}"}
    end
end

洁净室

class CleanRoom
  def current_temp
    18
  end
end
clean_room = CleanRoom.new
clean_room.instance_eval do
  if current_temp < 20
    p "wear a jacket"
  end
end

​ 洁净室只是一个用来执行块的环境,它提供若干有用的方法供代码块调用,比如本例中的current_temperature方法,然而一个理想的洁净室应该是没有实例变量和方法的,因为这可能与 block 从环境中带来的名字冲突,因此 BasicObject 的实例往往用来充当洁净室,因为它是白板类,几乎没什么方法。

可调用对象

​ 目前我们使用的 Block 是直接执行的,我们需要让 Block 变为对象,可以进行打包传递,调用,我们看一下有哪些打包代码的方式。

  1. proc , 将 Block 转为 Proc 对象
  2. lambda,属于 proc 的变种
  3. 使用方法

Proc 对象

# 将Block打包为Proc
inc = Proc.new {|x| x + 1}
# 主动调用Block
p inc.call 2

inc_ = proc {|x| x + 1}
p inc.call 2

obj = lambda {|x| x + 1}
p obj.call 2

obj_ = ->(x) {x + 1}
p obj_.call 2

&操作符

​ 在调用方法时,我们为其传递一个 Block,可通过 yield 进行执行,但是如果我们想将这个 Block 封装起来,延迟调用,该如何操作

# 这里&block将Block封装为Proc
def my_method(name,&block)
    p name
  # 这里对其调用
    block.call
end
# 传递Block时,不需要再参数后面加 , 
my_method "qsc" do p "Hello World" end

如果想把 Proc 再转为 Block 该怎么操作

def my_method(greeting)
    p "#{greeting} , #{yield}}"
end
my_proc = proc {"Bill"}
my_method("Hello",&my_proc)

​ 现在就可以将 Block 与 Proc 相互转化了

Lambda 和 Proc 的区别

  1. 参数校验不同
  2. return 定义不同

​ 参数校验是指:Lambda 中定义两个入参,如果你没传递,或者传递多了,则会报错,如果是 Proc 定义两个入参,如果没传递,则变量为 nil,如果传递多了,多余部分也不会使用。Lambda 更严格一些

​ return 定义不同:Lambda 中使用 return,表示从 Lambda 表达式中返回,而 Proc 表示从定义 Proc 的作用域中返回

def my_method
  # 这里block在my_method中定义,一旦执行return就会从my_method中退出
    p = Proc.new {return 10}
    result = p.call  # 这里调用完,就退出定义p的作用域,所以下面执行不到
    return result * 2  # 这里实际上是不可到达的
end

p my_method # => 10 
def my_method
    p = lambda {return 10}
    result = p.call
    return result * 2  
end

p my_method # => 20 

Lambda 和 Proc 对比

​ 整体而言,Lambda 更直观,它更像一个方法,对参数数量校验严格,在调用 return 时,只是从代码中返回。

Method 对象

class MyClass
    def initialize
        @v = 100
    end
    def my_method
        @v
    end
end
obj = MyClass.new
# 通过Kernel#method方法,将obj中的方法转为一个Method对象 !!! 
mobj = obj.method :my_method

p mobj.class  # => Method
# 主动调用
p mobj.call
mobj.to_proc  # 将Method转为Proc

Method 和 Proc 有什么区别?

lambda 在定义它的作用域执行,block 是一个闭包

Method 对象会在自身所在的对象的作用域执行,因为 Method 是所属对象的

自由方法

​ 听名字感觉是一个脱离类,模块儿的一个方法,可以使用Module#unbind将一个方法转为自由方法,也可以使用Module#instance_method获取一个自由方法

module MyModule
    def my_method
        42
    end
end
unbound = MyModule.instance_method(:my_method)
p unbound.class  # => UnboundMethod

​ 自由方法并不能脱离对象执行,所以我们可以把他再绑定到一个对象中,使之再次成为 Method 对象,可以使用UnboundMethod#bind进行绑定,从某个类中分离出来的 UnboundMethod,只能绑定在该类或者子类的对象上,模块儿中分离的自由方法则可以自由处置。

module MyModule
    def my_method
        42
    end
end
unbound = MyModule.instance_method(:my_method)
p unbound.class  # => UnboundMethod
# 这里在String中定义新的方法,以前我们会传递一个Block,这里我们直接像参数一眼进行传递
String.send :define_method,:another_method,unbound
p "abc".another_method

编写领域专属语言(DSL)

编写一个监视工具,如果发生不正常状况,进行消息通知,比如:

# 定义一个事件,传达一个事件描述,如果传递的代码块为true,则进行事件描述通知,如果为false,则不通知
event "we're earning wade of money" do
    # 这里从数据库中获取数据
    recent_orders = 10000
    recent_orders > 5000
end

第一个领域专用语言

我们进行第一版的设计,只要让这个程序每隔几分钟运行一次

# 这里需要将加载路径添加,否则load会报错
$LOAD_PATH.unshift(File.dirname(__FILE__)) 

def event(description)
    p description if yield
end
# 加载后,所有事件将会执行
load 'events.rb'
# 这个文件就定义所有的事件
event "i am test can i get the right result: false" do
    false
end
event "i am test can i get the right result: true" do
    true
end

共享事件

共享事件:是否能让两个独立的事件访问同一个变量? (使用扁平作用域

def monthly_sales
    100  # 从数据库获取的
end

target_sales = 101 

event "monthly sales are suspiciously" do
    monthly_sales > target_sales
end

event "monthly sales are abysmally low" do
    monthly_sales < target_sales
end

改良的 DSL

这里我们将方法和临时变量直接定义在顶级作用域中,这是不合适的,应该进行封装与优化,希望是下面的效果

setup do
    puts "Setting up sky"
    @sky_height = 100
end

setup do
    puts "Setting up mountains"
    @mountains_height = 200
end

event "the sky is falling" do
    @sky_height < 200
end

event "its getting closer" do
    @sky_height < @mountains_height
end

event "whoops ... too late" do
    @sky_height < 0
end

# 我们可以自由的混合事件和setup代码块,DSL还是会检测事件,在每次执行事件前都会运行所有的setup,我们希望运行后的结果为:
"Setting up sky"
"Setting up mountains"

"the sky is falling"

"Setting up sky"
"Setting up mountains"

"its getting closer"

"Setting up sky"
"Setting up mountains"

setup 应该给@开头的变量赋值,事件可以读取这些变量,这样后面写代码就会干净的多

p "main对象的实例变量:#{self.instance_variables}"

$LOAD_PATH.unshift(File.dirname(__FILE__))
def event(description)
    # 每次执行block前,先load数据获取文件,我们在其中定义一些实例变量,当加载后,该实例变量归属于main对象,这里是扁平作用域,所以可以被事件感知到
    load 'setup.rb'
    p description if yield
end

def setup()
    yield
end

load 'events.rb'
# 在 load 后,加载文件中
p "main对象的实例变量:#{self.instance_variables}"

专门在一个文件中定义实例数据

setup do
    puts "Setting up sky"
    @sky_height = 100
end

setup do
    puts "Setting up mountains"
    @mountains_height = 200
end

新需求:要求按照特定的顺序执行块和事件

$LOAD_PATH.unshift(File.dirname(__FILE__))

def setup(&block)
    @setups << block
end

def event(description,&block)
    @events << {:description=>description,:condition=>block}
end

@setups = []
@events = []

# 这里所有的event已经被保存,所有的setup也保存好了
load 'events.rb'

@events.each do |event|
    @setups.each do |setup|
        setup.call
    end
    puts "ALERT: #{event[:description]}" if event[:condition].call
end

消除全局变量

新需求:@events@setups 为顶级实例变量,但其实也是全局变量的变形,安全性有限,我们能不能消除他们,这里我们使用共享作用域

lambda {
    setups = []
    events = []
    Kernel.send :define_method,:setup do |&block|
        setups << block
    end

    Kernel.send :define_method,:event do |description,&block|
        events << {:description=>description,:condition=>block}
    end

    Kernel.send :define_method,:each_setup do |&block|
        setups.each do |setup|
            block.call setup
        end
    end

    Kernel.send :define_method,:each_event do |&block|
        events.each do |event|
            block.call event
        end
    end
}.call

load "events.rb"

each_event do |event|
    each_setup do |setup|
        setup.call
    end
    puts "ALERT: #{event[:description]}" if event[:condition].call
end

​ 这里我们使用 lambda 来定义一个 block,在内部定义 setups 和 events,在 Kernel 中定义方法,这些方法能访问到 block 中的代码块,而其他方法则无法访问到,这样就可以保证,消除全局变量,但代码看起来更复杂了似乎

添加一个洁净室

在目前的版本中,事件可以修改其他事件共享的顶层实例变量,我们需要在 setup 中定义共享变量,而不是在事件中

event "define a shared variable" do
    @x = 1
end

event "define a shared variable" do
    @x = @x + 1
end

改进方案,使用 Object 作为白板类,使用上线文探针执行 block,这样每个 event 都是独立的,这里我们没有使用 BasicObject 充当白板类,因为里面缺少一些基本的方法:puts

each_event do |event|
    env = Object.new
    each_setup do |setup|
        env.instance_eval &setup
    end
    puts "ALERT: #{event[:description]}" if env.instance_eval &(event[:condition])
end

小结

  • 作用域门 和 Ruby 管理作用域的方式
  • 利用扁平作用域 和共享作用域 让绑定穿越作用域
  • 在对象的作用域中执行代码(通过 instance_eval 或者 instance_exec) ,在洁净室中执行代码
  • 在代码块和对象之间相互转化
  • 在方法和对象之间相互转换
  • 可调用对象(代码块,Proc,Lambda 及普通方法)的区别
  • 编写自己的领域专属语言
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号