Ruby動態刪除方法中的列印語句

weixin_34337265發表於2017-08-24

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的方式來處理一下個問題,肯定不是最優雅的,不過這是我目前能想到的足夠折騰的處理方式。

192883-e465e6c8c13b2ceb.png
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. 再見

以上程式碼有什麼用? .........其實還真沒什麼卵用,純屬瞎折騰。

192883-8f0583647c6b8f86.png
play

Happy Coding and Writing !!!

相關文章