+5

Ruby Metaprogramming 1

Trong bài viết này, chúng ta sẽ nhìn vào một vài khía cạnh khác nhau của metaprograming trong Ruby. Để bắt đầu, metaprograming là gì?

Metaprogramming is the writing of computer programs with the ability to treat programs as their data. It means that a program could be designed to read, generate, analyze, or transform other programs and even modify itself while running.

Chúng ta sẽ tập trung vào việc làm thế nào ta có thể đọc và phân tích code trong Ruby, làm thế nào ta có thể gọi các phương thức(methods), hoặc gửi tin một cách tự động, và làm thế nào ta có thể tạo ra những methods mới trong khi chương trình đang được thực thi.

Asking Our Code Questions

Một trong những khía cạnh vượt trội của Ruby là có thể tự hỏi code của ta những câu hỏi về chính nó trong quá trình thực thi. Điều này cũng được biết đến như một sự tự xét (introspection). Cũng giống như chúng ta tự hỏi chính mình những câu hỏi như "Tại sao tôi ở đây?", code của ta cũng tương tự thế, mặc dù những câu hỏi đó có thể không tồn tại.

Am I able to respond to this method call?

Ta có thể hỏi bất cứ object nào liệu rằng nó có khả năng phản hồi một method call nào đó hay không bằng cách sử dụng respond_to? method.

"Roberto Alomar".respond_to? :downcase
# => true
"Roberto Alomar".respond_to? :floor
# => false

What does my object ancestry chain look like?

Nếu bạn kiểm tra một ActiveRecord model trong Rails 5, bạn sẽ thấy nó có đến tận 71 ông bà tổ tiên, bất ngờ chưa?

School.ancestors.size
# => 71
String.ancestors
# => [String, Comparable, Object, Kernel, BasicObject]

What instance variables and methods have been defined?

Chúng ta có thể sử dụng methods để trả về tất cả những methods khả dụng của một object cụ thể và instance_variables trả về một list những biến instance được định nghĩa cho object đó.

require 'date'

class Alpaca
  attr_accessor :name, :birthdate

  def initialize(name, birthdate)
    @name = name
    @birthdate = birthdate
  end

  def spit
    "Putsuuey"
  end
end

spitty = Alpaca.new('Spitty', Date.new(1990, 10, 10))

spitty.methods
# => [:name, :name=, :birthdate, :spit, :birthdate=, :instance_of?, :public_send, :instance_variable_get, :instance_variable_set, :instance_variable_defined?, :remove_instance_variable, :private_methods, :kind_of?, :instance_variables, :tap, :is_a?, :extend, :define_singleton_method, :to_enum, :enum_for, :<=>, :===, :=~, :!~, :eql?, :respond_to?, :freeze, :inspect, :display, :send, :object_id, :to_s, :method, :public_method, :singleton_method, :nil?, :hash, :class, :singleton_class, :clone, :dup, :itself, :taint, :tainted?, :untaint, :untrust, :trust, :untrusted?, :methods, :protected_methods, :frozen?, :public_methods, :singleton_methods, :!, :==, :!=, :__send__, :equal?, :instance_eval, :instance_exec, :__id__]

spitty.instance_variables
# => [:@name, :@birthdate]

Sending Messages

Ruby là một ngôn ngữ động. Nó bao gồm một loạt các object có thể truyền các gói tin qua lại với nhau, tương tự như khi chúng ta nói "call a method". Hãy xem thử downcase method của String objects.

"Roberto Alomar".downcase
# => "roberto alomar"

Khi ta gọi method này sử dụng dấu chấm ., điều ta đang thật sự nói là ta truyền 1 thông điệp đến String, và nó quyết định cách trả lời thông điệp đó. Trong trường hợp này, nó trả lời với một phiên bản viết thường của chính nó.

Hãy tìm hiểu sâu hơn nữa, hãy chia hành động này thành 3 phần: Đầu tiên, "Roberto Alomar" là object, đối tượng sẽ nhận thông điệp. Dấu chấm (.) nói cho đối tượng nhận thông điệp biết chúng ta đang gửi một lệnh hoặc một thông điệp nào đó. Và phần theo sau dấu chấm, downcase là thông điệp mà chúng ta muốn gởi. Trong tiếng Anh, chúng ta có thể nói rằng "we are sending the downcase message to "Roberto Alomar". It figures out what to do or send back once it receives that message."

Với Ruby thì có thể dùng cách khác để thực hiện, bằng send method:

"Roberto Alomar".send(:downcase)
# => "roberto alomar"

Thông thường chúng ta không sử dụng hình thức này, nhưng Ruby cho phép chúng ta gửi thông điệp (hoặc gọi một phương thức nào đó ) trong trường hợp này, cho phép lựa chọn việc gửi một thông điệp động (dynamic message) hoặc gọi phương thức một cách tự động.

method = :downcase
"Roberto Alomar".send(method)
# => "roberto alomar"

Trông có vẻ không nhiều nhặn gì, nhưng đây là một trong những cấu trúc mà Ruby cho phép chúng ta viết code động, code thậm chí không tồn tại khi bạn viết chúng. Trong phần tiếp theo, chúng ta sẽ xem xem làm cách nào để tạo ra code mới một cách tự động trong Ruby bằng cách sử dụng define_method.

Generating New Methods

Một khía cạnh khác của metaprograming là Ruby cho chúng ta khả nặng tạo ra code mới trong lúc thực thi. Ta sẽ làm điều này bằng cách sử dụng một phương thức từ Module class được gọi là define_method. Cách nó hoạt động là, nó truyền một biểu tượng mà sẽ trở thành tên của phương thức mới, và tất cả được bọc trong một khối block như này:

class Person
  define_method :greeting, -> { puts 'Hello!' }
end

Person.new.greeting
# => Hello!

Có thể bạn đã dùng delegate method trước đó rồi, đi kèm ActiveSupport với Rails và extends Module. Điều này cho phép ta nói rằng, khi ta gọi một phương thức cụ thể, ta gọi method đó trên một object khác object hiện tại (self). Bạn có thể tham khảo thêm source code ở đây. Hãy cùng tham khảo ví dụ bên dưới. Đầu tiên ta thêm 1 method mới vào Module class, gọi là delegar.

class Module
  def delegar(method, to:)
    define_method(method) do |*args, &block|
      send(to).send(method, *args, &block)
    end
  end
end

Khi method này được gọi, nó sẽ định nghĩa 1 method mới với trách nhiệm làm việc với object khác, giống như một proxy.

class Receptionist
  def phone(name)
    puts "Hello #{name}, I've answered your call."
  end
end

class Company
  attr_reader :receptionist
  delegar :phone, to: :receptionist

  def initialize
    @receptionist = Receptionist.new
  end
end

company = Company.new
company.phone 'Leigh'
# => "Hello Leigh, I've answered your call."

Bạn có thể thấy ta đang gọi phone method trên Company, nhưng thật sự thì Receptionist mới là kẻ trả lời.

Dollars and Cents

Có thể bạn từng nghe rằng ko nên lưu trữ và sử dụng tiền dưới dạng Float bởi vấn đề này. Một trong những cách giải quyết là lưu dưới dạng cents. $10.25 sẽ được lưu trong db là 1025 cents. Người dùng rõ ràng không muốn nhập vào đơn vị cents, nên chúng ta sẽ phải thêm ít code để chuyển đổi qua lại giữa 2 đơn vị dollars và cents. Ta sẽ dùng một chút metaprograming để giúp mọi thứ trông đơn giản hơn.

Hãy nhìn vào class Purchase có một trường trong db là price_cents. Trông như thế này:

class Purchase
  attr_accessor :price_cents
  extend MoneyFields
  money_fields :price
end

Nếu là một ActiveRecord object trong Rails, chúng ta sẽ không cần thêm dòng attr_accessor :price_cents vì nó sẽ tự làm việc đó cho chúng ta, nhưng đây là ví dụ cho một Ruby phiên bản cũ hơn. Code này sẽ cho phép ta tương tác với trường đó, như sau:

purchase = Purchase.new
purchase.price = 10.25
purchase.price_cents
# => 1025
purchase.price_cents = 555
purchase.price
# => #<BigDecimal:7fbc7497ac88,'0.555E1',18(36)>

Nhưng method priceprice= ở đâu ra vậy? Method money_fields tạo ra 2 methods mới tương tác với price_centsprice_cents=.

module MoneyFields
  require 'bigdecimal'

  def money_fields(*fields)
    fields.each do |field|
      define_method field do
        value_cents = send("#{field}_cents")
        value_cents.nil? ? nil : BigDecimal.new(value_cents / BigDecimal.new("100"))
      end

      define_method "#{field}=" do |value|
        value_cents = value.nil? ? nil : Integer(BigDecimal.new(String(value)) * 100)
        send("#{field}_cents=", value_cents)
      end
    end
  end
end

Để xem thử nó hoạt động có như mong đợi ko, đây là đoạn test kiểm tra các chuyển đổi qua lại khác nhau:

require 'minitest/autorun'

class PurchaseTest < MiniTest::Test
  attr_reader :purchase

  def setup
    @purchase = Purchase.new
  end

  def test_reading_writing_dollars
    purchase.price = 5.00
    assert_equal purchase.price, 5.00
  end

  def test_converting_to_dollars
    purchase.price_cents = 500
    assert_equal purchase.price, 5.00
  end

  def test_converting_to_cents
    purchase.price = 5.00
    assert_equal purchase.price_cents, 500
  end

  def test_writing_dollars_from_string
    purchase.price = "5.00"
    assert_equal purchase.price_cents, 500
  end

  def test_nils
    purchase.price = nil
    assert_equal purchase.price, nil
  end

  def test_creating_methods
    assert_equal Purchase.instance_methods(false).sort, [:price_cents, :price_cents=, :price, :price=].sort
  end

  def test_respond_to_dollars
    assert_equal purchase.respond_to?(:price), true
    assert_equal purchase.respond_to?(:price=), true
  end
end

Conclusion

Metaprograming thật tuyệt vời nhưng chỉ khi nó được sử dụng ít. Nó có thể giúp bạn viết các đoạn code lặp lại dễ dàng hơn nhiều( như field money bên trên chẳng hạn), nó có thể giúp bạn debug và phân tích code dễ dàng, nhưng đôi khi nó cũng làm code của bạn khó đọc và khó hiểu hơn. Chỉ nên sử dụng metaprograming chỉ khi nó mang lại một lợi thế rõ ràng.

Nguồn: blog.codeship.com


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí