Ruby Ruby 动态删除方法中的打印语句

lanzhiheng · August 24, 2017 · Last by lanzhiheng replied at August 27, 2017 · 3660 hits

0. 前言及需求

运行时动态编程也算是 Ruby 的卖点之一,心血来潮想要尝试一下我是否能够在运行时动态对 Ruby 原有的方法进行调整?可能生产线上用不上这些黑科技,不过对于个人折腾来说这还是蛮好的,可以打发一下单身的时光。

我们设定一个简单的方法,做的事情很简单,只是输出了对应的方法名,并且前后有形如p xxx的打印语句

def method_with_print
  p "begin method"
  puts "This is '#{self.method(:method_with_print).name}' method"
  p "end method"
end


if __FILE__ == $0
  method_with_print
end

运行结果如下

>> ruby xx.rb
"begin method"
This is 'method_with_print' method
"end method"

我们有什么办法把上述方法中的p语句去掉,我能想到的方式是

  1. 获取方法的字符串版本。
  2. 使用正则表达式匹配并替换p语句,得到一个新的定义方法的字符串。
  3. 在恰当的上下文运行新的字符串,重新定义方法。
  4. 以 Ruby 的方式包装一个方法,去改造并调用原来的方法。

看起来好像也不难,一步步来试试看。

1. 获取方法定义的字符串版本

如果我们是用的 JavaScript,想要获取对应方法的字符串版本只需要调用方法对象的toString便可

> function a() {
... return "lan"
... }
undefined
> a.toString()
'function a() {\nreturn "lan"\n}'

但 Ruby 似乎并没有这么直接的方式获取定义方法的字符串,估计是社区觉得这种需求其实用处不大吧,从网上搜索了一下有一个叫做method_source的包可以做到这一点,他是以补丁的方式来增强 Ruby 原有的模块方法,进而为每一个非绑定方法添加一个source的属性,我们通过这个属性就可以获取到方法的源代码了。不过这里有一个问题,这个方法的实现机制是通过调用目标方法原有的source_location方法获取到定义该方法的具体位置,然后访问对应的文件,截取出指定方法对应的定义字符串。换句话说如果我们的方法是在REPL 里面定义的话就不能获取到对应方法的字符串了。目前我们只能在获取在脚本里(xx.rb)定义对应方法的字符串了。

上面所说的这个库,其实已经包含在我们平时用得比较多的pry工具里面了,这是一个比较常用的 REPL 工具,现在我只需要在原有代码的可执行部分里面添加

if __FILE__ == $0
  # method_with_print
  require 'pry'
  puts Object.instance_method(:method_with_print).source
end

运行对应脚本 xx.rb就能得到目标方法的定义字符串了

>> ruby xx.rb

def method_with_print
  p "begin method"
  puts "This is '#{self.method(:method_with_print).name}' method"
  p "end method"
end

可以看到以上的做法相比 JavaScript 来说有点绕,因为 Ruby 的方法是不需要添加括号就可以调用的,直接写方法名的话就是调用方法。这里想要操作对应方法名的方法对象,并且是非绑定版本的。可以通过Object.instance_method来获取。

2. 正则匹配且替换

我们已经获取了对应的字符串版本了,那么接下来要做的就是匹配并且替换掉原来的p xxxx语句了。

我就写一个比较简单的正则匹配就好了,毕竟如果要匹配 ruby 所有的打印语句的话,会占用比较多的时间以及篇幅。

根据上面的思路我得到了这样一个程序版本

if __FILE__ == $0
  # method_with_print
  require 'pry'
  REG_CONSOLE = /\s+p\s+.+/
  method_string = Object.instance_method(:method_w\
ith_print).source
  method_string.gsub!(REG_CONSOLE, '')

  method_string
end

运行看看

>> ruby xx.rb

def method_with_print
  puts "This is '#{self.method(:method_with_print).name}' method"
end

可见,对应的p xxx语句已经从字符串中删除了,我们已经得到了改版之后的方法定义字符串了。

3. 重新定义方法

如何重新定义方法?我们应该都听过 JavaScript 有名为eval的方法,可以动态执行字符串。类似的的 Ruby 也有Kernel#eval。而且它在类层面还提供了Class#class_eval,在对象层面提供了Object#instance_eval方法,让你可以操作不同的上下文。这里讲一下比较直观的Kernel#eval, 我们要直接执行 Ruby 代码可以像这样执行

>> eval("p 'I love ruby'")

"I love ruby"
 => "I love ruby"

那之前定义的方法是不是也能以字符串的形式,通过Kernel#eval方法来重新定义?我把代码写成这样

if __FILE__ == $0
  # method_with_print
  require 'pry'
  REG_CONSOLE = /\s+p\s+.+/
  method_string = Object.instance_method(:method_with_print).source
  method_string.gsub!(REG_CONSOLE, '')

  eval(method_string)

  method_with_print
end

运行看看结果是否符合预期

>> ruby xx.rb

This is 'method_with_print' method

Awesome,已经满足了我们这次的需求了,我们可以在运行时删除方法的p xxx语句,并且重新定义了原有的方法。最后我试试用 Ruby 的方式来处理一下个问题,肯定不是最优雅的,不过这是我目前能想到的足够折腾的处理方式。

help

4. Ruby 的处理方式

Ruby 是“真”面向对象的编程语言,因为他真的能够做到一切都是对象,比如

[1] pry(main)> 1.to_s
=> "1"
[2] pry(main)> '2'.to_i
=> 2

平时我们定义的函数,其实也是方法

[3] pry(main)> def m
[3] pry(main)*   'lan'
[3] pry(main)* end
=> :m
[4] pry(main)> self.m
=> "lan"
[5] pry(main)> self
=> main

m其实是挂在main这个对象上的方法。用面向对象的方式来解决上面的问题,我们是否可以给方法添加一个属性,通过这个属性来调用原有方法的删除了p xxxx语句之后的版本呢?我们首先来看看方法对象的继承链条

[6] pry(main)> m_method = Object.instance_method(:m)
[10] pry(main)> m_method.class.ancestors
=> [UnboundMethod,
 MethodSource::MethodExtensions,
 MethodSource::SourceLocation::UnboundMethodExtensions,
 Object,
 PP::ObjectMixin,
 Kernel,
 BasicObject]

可见方法对象所属类的祖先链如下

[UnboundMethod, MethodSource::MethodExtensions, MethodSource::SourceLocation::UnboundMethodExtensions, Object, PP::ObjectMixin, Kernel, BasicObject]

祖先链有这一大堆的东西,那要不我们就斗胆一点扩展一下MethodSource::MethodExtensions这个模块吧。你怎么知道他是一个模块而不是类?

[13] pry(main)> MethodSource::MethodExtensions.class
=> Module

我尝试在模块里面添加MethodSource::MethodExtensions#remove_p_statement方法

require 'pry'

module MethodSource::MethodExtensions
  REG_CONSOLE = /\s+p\s+.+/

  def remove_p_statement(*params)
    method_string = self.source.gsub(REG_CONSOLE, '')
    method_owner = self.owner
    new_method = method_owner.instance_eval(method_string)
    method_owner.send(new_method, *params)
  end
end

它是方法的方法,只需要在方法的后面调用它。它会在对象的上下文Object#instance_eval重新定义这个方法,然后在内部自动发派这个方法,并附带上一个可变参数。最后我把执行脚本的主体内容改为

if __FILE__ == $0
  puts "=========="
  puts "new method result:\n"
  Object.instance_method(:method_with_print).remove_p_statement()
  puts "=========="

  puts "=========="
  puts "old method result:\n"
  method_with_print()
  puts "=========="
end

PS: 由于method_with_print方法定义的时候没有参数,我们这里括号里面的内容都是空。

最后的结果如下

==========
new method result:
This is 'method_with_print' method
==========

==========
old method result:
"begin method"
This is 'method_with_print' method
"end method"
==========

可见,我们的方法调用了 MethodSource::MethodExtensions#remove_p_statement 这个方法之后得到了一个新的方法并执行,但是却不会影响到执行脚本上下文中最初定义的原始方法的行为。

5. 再见

以上代码有什么用?.........其实还真没什么卵用,纯属瞎折腾。

play

Happy Coding and Writing !!!

在我看来,用 refine 重写 p 方法使其 p 任何内容时都返回 nil 即达到目的。

Reply to dfzy5566

thx, 感觉应该是可以的,细化这个功能还没怎么用过,我稍后试试。

可以打发一下单身的时光

感觉很凄凉

可以获取字节码然后修改之,不过 InstructionSequence 还没提供 from array 的构造函数

Reply to luikore

这个我还真没了解过,字节码这个有点过于底层了。

Reply to mizuhashi

我稍后会去看看,thx。

Reply to lanzhiheng
require 'pry'

def method_with_print
  p "begin method"
  puts "This is '#{self.method(:method_with_print).name}' method"
  p "end method"
end

module A
  refine Object do
    def p(*args)
      nil
    end
  end
end

if __FILE__ == $0
  method_with_print
  using A

  # 获取目标方法的定义字符串
  method_source = Object.method(:method_with_print).source

  # 在细化作用域重新定义方法
  eval(method_source)
  method_with_print
end

使用细化后的版本。另写一文章总结细化 https://ruby-china.org/topics/33950

You need to Sign in before reply, if you don't have an account, please Sign up first.