Cái giá phải trả cho Metaprogramming

I. Lời nói đầu:

Xin chào các bác.

Đến hẹn lại lên, bài viết hôm nay sẽ chia sẻ về Metaprogramming. (dance2)

Chắc hẳn các bạn đã từng nghe hoặc sử dụng nó rồi. Metaprogramming là phương pháp viết code động, những đoạn code được sinh ra không phải do lập trình viên viết từ đầu đến cuối, mà nó được dựa trên những đoạn string, variable, data ...

Nghe thì có vẻ rất hay và pro, nhưng nó có thực sự tốt không?

Metaprogramming có thể rất hữu dụng cho một số trường hợp.

Nhưng nhiều người lại không nhận ra rằng, việc sử dụng Metaprogramming cũng có cái giá của nó, khiến chúng ta phải đánh đổi. (Em cũng thế :v)

Trong Rails, nếu dùng Metaprogramming, ta sẽ sử dụng một số hàm kiểu như:

  • Thay đổi cấu trúc code - define_method
  • Chạy một string như thể nó là đoạn Ruby code - instance_eval
  • Phản ứng đối với event - method_missing

Vậy chúng ta phải đánh đổi những gì cho Metaprogramming? Tôi gạch ra 3 cái đầu dòng như sau:

  • Tốc độ (speed).
  • Tính dễ đọc (readability).
  • Khả năng tìm kiếm (searchability).

Ngoài ra, nếu tính cả method eval bạn sẽ có gạch đầu dòng thứ 4 - tính bảo mật (security). Vì bản thân method đó không có một chút gì để check security những thứ chạy bên trong, mà bạn phải làm điều đó bằng tay.

Ok, giờ ta sẽ đi chi tiết vào từng mục một. (honho)

II. Costs

1. Tốc độ (speed):

Cái giá phải trả đầu tiên của Metaprogramming chính là tốc độ, các methods của Metaprogramming sẽ chạy chậm hơn những method khai báo theo phương pháp thông thường.

Dưới đây là Benchmark so sánh:

require 'benchmark/ips'
 
class Thing
  def method_missing(name, *args)
  end
 
  def normal_method
  end
 
  define_method(:speak) {}
end
 
t = Thing.new
 
Benchmark.ips do |x|
  x.report("normal method")  { t.normal_method }
  x.report("missing method") { t.abc }
  x.report("defined method") { t.speak }
 
  x.compare!
end

Và kết quả từ Ruby 2.2.4

normal method:   7344529.4 i/s
defined method:  5766584.9 i/s - 1.34x  slower
missing method:  4777911.7 i/s - 1.54x  slower

Bạn có thể thấy cả 2 method của metaprograming (ở đây là define_methodmethod_missing) đều chạy chậm hơn normal_method một chút.

Còn một điều nữa tôi phát hiện ra.

Kết quả bên trên là chạy với Ruby 2.2.4, nhưng nếu bạn thử benchmark trên Ruby 2.3 hoặc 2.4 thì những methods đó còn chạy chậm hơn nữa.

Ruby 2.4 benchmark:

normal method:   8252851.6 i/s
defined method:  6153202.9 i/s - 1.39x  slower
missing method:  4557376.3 i/s - 1.87x  slower

Tôi đã chạy thử benchmark nhiều lần để chắc rằng không phải do máy bị choke (yaoming).

Nhưng nếu bạn chú ý và nhìn vào iterations per second (i/s) thì có vẻ như method bình thường chạy nhanh hơn kể từ phiên bản Ruby 2.3.

Đó là nguyên nhân mà method_missing trông có vẻ chạy chậm hơn.

2. Tính dễ đọc (Readability):

Error messages có thể khá phế nếu ta sử dụng method instance_eval hoặc class_eval

Hãy thử nhìn vào đoạn code sau đây:

class Thing
  class_eval("def self.foo; raise 'something went wrong'; end")
end

Thing.foo

Kết quả sẽ dẫn đến lỗi sau:

(eval):1:in 'foo': 'something went wrong...' (RuntimeError)

Nhìn vào cái errors này bắn ra, cảm giác có cái gì đó thiếu thiếu :v.

Chúng ta đã thiếu file name và số dòng code chỉ ra errors.

Tuy nhiên có thể khắc phục điều đó bằng cách thêm 2 parameters vào method eval:

  • File name
  • Line number

Sử dụng constant __FILE____LINE__ như parameters cho class_eval, bạn sẽ có được thông tin chính xác của error message.

class Thing
  class_eval(
    "def foo; raise 'something went right'; end",
    __FILE__,
    __LINE__
  )
end

Sao mấy cái parameters này không phải là default mà mình lại phải xử lý bằng tay?

... Chịu, nhưng đó là điều bạn cần lưu ý nếu sử dụng method kiểu như vậy.

3. Khả năng tìm kiếm (Searchability):

Metaprogramming method làm code của bạn khó tìm hơn, khó truy cập tới và khó để debug.

Nếu bạn đang cần tìm method vừa gọi, bạn sẽ không thể dùng CTRL + F để tìm tới method được định nghĩa thông qua metaprogramming, đặc biệt khó nếu tên method được build run-time.

Ví dụ ta viết 3 methods sử dụng metaprogramming:

class RubyBlog
  def create_post_tags
    types = ['computer_science', 'tools', 'advanced_ruby']
 
    types.each do |type|
      define_singleton_method(type + "_tag") { puts "This post is about #{type}" }
    end
  end
end
 
rb = RubyBlog.new
 
rb.create_post_tags
rb.computer_science_tag

Những tool để generate document như kiểu Yard hay RDoc sẽ không thể tìm và list ra những method đó.

Các tool đó sử dụng kỹ thuật gọi là Static Analysis để tìm class và method.

Kỹ thuật này chỉ có thể tìm method được viết trực tiếp theo cách thông thường.

Hãy thử chạy yard doc với cái ví dụ trên, bạn sẽ thấy nó chỉ tìm ra method là create_post_tags

Cũng có 1 cách để yard có thể in ra những methods kia, bằng cách sử dụng @method tag, nhưng không phải lúc nào cũng áp dụng được.

class Thing
  # @method build_report
  define_method(:build_report)
end

Ngoài ra nếu bạn có sử dụng những tool như grep, ack, hoặc editor để search ra nơi method được định nghĩa, việc tìm metaprogramming methods cũng khó hơn methods thông thường là cái chắc.

Như creator của Sidekiq - Mike Perham đã nói:

I don’t think Sidekiq uses any metaprogramming at all because I find it obscures the code more than it helps 95% of the time.

Túm cái váy lại

Metaprogramming không phải lúc nào cũng tệ. Nó sẽ cực kỳ hữu dụng nếu bạn sử dụng chúng đúng trường hợp, giúp code của bạn gọn và linh hoạt hơn nhiều.

Chỉ cần nhận ra những nhược điểm của nó, bạn sẽ đưa ra được lựa chọn tốt nhất.

Nguồn:

All Rights Reserved