+7

Metaprogramming trong Ruby

Bài viết gốc: https://www.toptal.com/ruby/ruby-metaprogramming-cooler-than-it-sounds

Metaprogramming

Metaprogramming là một kỹ thuật mà bạn có thể viết code tự động sinh ra code trong thời gian chạy. Điều này có nghĩa là bạn có thể định nghĩa các phương thức cho một class ngay trong khi đang chạy một chương trình nào đó. Điên rồ, phải không? Tóm lại thì bằng cách sử dụng metaprogramming, bạn có thể sửa đổi các class, kiểm tra các phương thức không tồn tại và tự tạo ra chúng ngay lập tức, giúp cho code DRY, tránh lặp lại, v.v.

Cơ bản

Trước khi đi sâu vào tìm hiểu thêm, chúng ta cần lắm rõ được mức cơ bản. Và cách tốt nhất để làm điều đó là bằng ví dụ. Hãy cùng bắt đầu tìm hiểu Ruby metaprogramming từng bước một nhé. Bạn có lẽ sẽ đoán được đoạn code này đang làm gì:

class Developer

  def self.backend
    "I am backend developer"
  end
  
  def frontend
    "I am frontend developer"
  end

end

Chúng ta đã định nghĩa một class với hai methods. Phương thức đầu tiên là một class method, và phương thức thứ hai là của instance. Đây là ví dụ cơ bản trong Ruby nhưng có nhiều thứ chúng ta cần hiểu rõ hơn. Cần chỉ ra rằng bản thân lớp Developer thực sự là một đối tượng. Trong Ruby, mọi thứ đều là một đối tượng, bao gồm cả các class. Có nghĩ là Developer cũng là một instance, nó là một instance của lớp Class. Đây là một mô hình thể hiện đối tượng Ruby trông như thế nào:

p Developer.class # Class
p Class.superclass # Module
p Module.superclass # Object
p Object.superclass # BasicObject

Điểm đặc biệt ở đây là chúng ta cần hiểu ý nghĩa của self trong đoạn code trên. Phương thức frontend là một phương thức cơ bản có sẵn trên instance của Developer, nhưng tại sao backend lại là một class method? Mỗi đoạn code được thực thi trong Ruby đều được thực thi trên một self cụ thể. Khi trình thông dịch Ruby thực hiện chạy code nó luôn theo dõi giá trị của self trên tất cả các dòng. self luôn tham chiếu đến một đối tượng và nó cũng có thể được thay đổi dựa trên các đoạn mã được thực thi. Ví dụ, bên trong một định nghĩa class, self sẽ tham chiếu đến chính class đó và là một instance của lớp Class.

class Developer
  p self 
end
# Developer

Bên trong một instance method, self tham chiếu đến instance của class hiện tại.

class Developer
  def frontend
    self
  end
end
 
p Developer.new.frontend
# #<Developer:0x2c8a148>

Bên trong một class method, self sẽ tham chiếu đến chính class hiện tại (điều này sẽ được nói đến thêm):

class Developer
  def self.backend
    self
  end
end

p Developer.backend
# Developer

Vậy cuối cùng thì class method là gì? Trước khi trả lời câu hỏi đó, chúng ta cần đề cập đến sự tồn tại của một khái niệm gọi là metaclass, thương được biết đến là singleton class và eigenclass. Class method frontend thực ra là một instance method của metaclass trên object Developer. Metaclass về cơ bản là một lớp mà Ruby tạo ra và chèn vào hệ thống phân cấp kế thừa để giữ các phương thức của một lớp, do đó không can thiệp vào các instance được tạo ra từ lớp đó.

Metaclasses

Mỗi đối tượng trong Ruby đều có metaclass riêng. Ví dụ, chúng ta hãy tạo một đối tượng String và thao tác với metaclass của nó:

example = "I'm a string object"

def example.something
  self.upcase
end

p example.something
# I'M A STRING OBJECT

Những gì chúng ta làm ở đây là chúng ta đã thêm một singleton method something vào một đối tượng. Sự khác biệt giữa các class method và singleton method là các class method có sẵn trên tất cả các trường hợp của một đối tượng lớp, trong khi các singleton method chỉ có sẵn cho một trường hợp đơn lẻ.

Ví dụ trước có thể được viết lại như thế này:

example = "I'm a string object"

class << example
  def something
    self.upcase
  end
end

Cú pháp khác nhau nhưng nó có kết quả giống nhau. Bây giờ chúng ta hãy quay lại ví dụ trước, xem lại một cách khác để định nghĩa một class method:

class Developer
  def self.backend
    "I am backend developer"
  end
end

Đây là một định nghĩa cơ bản mà hầu như mọi người đều sử dụng.

def Developer.backend
  "I am backend developer"
end

Đây là cách tương tự, để chúng ta đã định nghĩa một class method backend cho Developer. Chúng ta không sử dụng self nhưng vẫn có thể định nghĩa một class method:

class Developer
  class << self
    def backend
      "I am backend developer"
    end
  end
end

Một lần nữa, chúng ta đang định nghĩa một class method, nhưng sử dụng cú pháp tương tự như cú pháp mà chúng ta đã sử dụng để định nghĩa một singleton method cho một đối tượng String. Bạn có thể nhận thấy rằng chúng ta đã sử dụng self ở đây để tham chiếu chính class Developer. Đầu tiên, trong class Developer, chúng ta biết self tham chiếu đến chính class Developer, tiếp theo viết class << self làm cho self tương đương với metaclass của Developer. Sau đó định nghĩa phương thức backend trong metaclass của Developer.

class << Developer
  def backend
    "I am backend developer"
  end
end

Với cách viết như trên, chúng ta đang đặt self tham chiếu tới metaclass của Developer bên trong block trên. Và backend được định nghĩa là method của metaclass của Developer thay vì Developer.

Hãy xem metaclass này hoạt động như thế nào trong cây kế thừa:

Như bạn đã thấy trong các ví dụ trước, không có bằng chứng thực tế nào cho thấy metaclass thậm chí còn tồn tại. Nhưng chúng ta có thể sử dụng một thủ thuật nhỏ có thể cho chúng ta thấy sự tồn tại của lớp vô hình này:

class Object
  def metaclass_example
    class << self
      self
    end
  end
end

Nếu chúng ta định nghĩa một instance method trong lớp Object (vâng, chúng ta có thể mở lại bất kỳ lớp nào vào bất kỳ lúc nào, đó là một vẻ đẹp khác của metaprogramming) chúng ta có self tham chiếu đến Object, và sau đó dùng cú pháp class << self để tham chiếu đến metaclass của object hiện tại. Phương thức trên trả về self, hiện tại cũng chính là bản thân metaclass. Vì vậy, bằng cách gọi phương thức trên, chúng ta có thể nhận được metaclass của một đối tượng bất kỳ. Hãy viết lại lớp Developer của chúng ta và bắt đầu khám phá một chút:

class Developer

  def frontend
    p "inside instance method, self is: " + self.to_s
  end

  class << self
    def backend
      p "inside class method, self is: " + self.to_s
    end
  end
  
end

developer = Developer.new
developer.frontend
# "inside instance method, self is: #<Developer:0x2ced3b8>"

Developer.backend
# "inside class method, self is: Developer"

p "inside metaclass, self is: " + developer.metaclass_example.to_s
# "inside metaclass, self is: #<Class:#<Developer:0x2ced3b8>>"

Và hãy cùng kiểm tra, frontend là một instance method của Developer còn, backend là của metaclass của Developer.

p developer.class.instance_methods false
# [:frontend]

p developer.class.metaclass_example.instance_methods false
# [:backend]

Ngoài ra bạn có thể chỉ cần sử dụng phương thức singleton_class có sẵn trong Ruby để lấy metaclass mà không cần làm như trên.

p developer.class.singleton_class.instance_methods false
# [:backend]

Định nghĩa method sử dụng class_evalinstance_eval

Có một cách nữa để tạo một class method, và đó là sử dụng instance_eval:

class Developer
end

Developer.instance_eval do
  p "instance_eval - self is: " + self.to_s
  def backend
    p "inside a method self is: " + self.to_s
  end
end
# "instance_eval - self is: Developer"

Developer.backend
# "inside a method self is: Developer"

Phần code trên thì trình thông dịch của Ruby đánh giá ngữ cảnh là thực hiện trên một instance, ở đây là đối tượng Developer. Và khi bạn định nghĩa một phương thức trên một đối tượng, bạn đang tạo class method hoặc một singleton method. Trong trường hợp này thì là class method. Chính xác thì là class method cũng là các singleton method nhưng là singleton method của một class, trong khi các trường hợp khác là singleton method của một đối tượng.

Còn với class_eval, ngữ cảnh là thực hiện trên instance của một class, thay vì một instance. Nó tương đương với mở lại một class. Đây là cách class_eval có thể được sử dụng để tạo một phương thức instance:

Developer.class_eval do
  p "class_eval - self is: " + self.to_s
  def frontend
    p "inside a method self is: " + self.to_s
  end
end
# "class_eval - self is: Developer"

p developer = Developer.new
# #<Developer:0x2c5d640>

developer.frontend
# "inside a method self is: #<Developer:0x2c5d640>"

Tóm lại, khi bạn gọi phương thức class_eval, bạn thay đổi self để tham chiếu đến class ban đầu còn khi bạn gọi instance_eval, self thay đổi để tham chiếu đến metaclass của class ban đầu.

Định nghĩa một missing method

Một phần khác của metaprogramming là method_missing. Khi bạn gọi phương thức này trên một đối tượng, đầu tiên Ruby kiểm tra class và duyệt qua các instance methods của nó. Nếu không tìm thấy phương thức nào nó sẽ tiếp tục với ancestors chain. Nếu Ruby vẫn không tìm thấy phương thức, nó sẽ gọi một phương thức khác có tên method_missing, đây là một instance method của class Kernel mà mọi đối tượng đều kế thừa.Vì chúng ta chắc chắn rằng Ruby sẽ gọi phương thức này cuối cùng cho các phương thức bị thiếu, chúng ta có thể sử dụng nó để thực hiện một số thủ thuật.

define_method là một phương thức được định nghĩa trong lớp Module mà bạn có thể sử dụng để tạo các method một cách linh động. Để sử dụng define_method, bạn gọi nó với tên của phương thức mới và một block trong đó các tham số của block trở thành các tham số của phương thức mới. Sự khác biệt giữa việc sử dụng def để tạo một phương thức và define_method là gì? Không có nhiều sự khác biệt ngoại trừ bạn có thể sử dụng define_method kết hợp với method_missing để viết mã DRY. Nói một cách chính xác, bạn có thể sử dụng define_method thay vì def để định nghĩa phương thức trong một lớp, nhưng đó là một câu chuyện hoàn toàn khác. Hãy xem một ví dụ đơn giản:

class Developer
  define_method :frontend do |*my_arg|
    my_arg.inject(1, :*)
  end

  class << self
    def create_backend
      singleton_class.send(:define_method, "backend") do
        "Born from the ashes!"
      end
    end
  end
end

developer = Developer.new
p developer.frontend(2, 5, 10)
# => 100

p Developer.backend
# undefined method 'backend' for Developer:Class (NoMethodError)

Developer.create_backend
p Developer.backend
# "Born from the ashes!"

Điều này cho thấy cách thức dùng define_method để tạo một phương thức instance mà không sử dụng def. Tuy nhiên, chúng ta có thể làm được nhiều việc hơn với chúng. Hãy xem đoạn mã này:

class Developer

  def coding_frontend
    p "writing frontend"
  end

  def coding_backend
    p "writing backend"
  end

end

developer = Developer.new

developer.coding_frontend
# "writing frontend"

developer.coding_backend
# "writing backend"

Đoạn code trên không hề DRY, và chúng ta có thể dùng define_method để làm nó DRY:

class Developer

  ["frontend", "backend"].each do |method|
    define_method "coding_#{method}" do
      p "writing " + method.to_s
    end
  end

end

developer = Developer.new

developer.coding_frontend
# "writing frontend"

developer.coding_backend
# "writing backend"

Đoạn code trên tốt hơn nhiều, nhưng vẫn chưa hoàn hảo. Tại sao? Nếu chúng ta thêm một method coding_debug vào ví dụ trên, chúng ta cần thêm debug vào mảng. Nhưng bằng cách sử dụng method_missing, chúng tôi có thể khắc phục điều này:

class Developer

  def method_missing method, *args, &block
    return super method, *args, &block unless method.to_s =~ /^coding_\w+/
    self.class.send(:define_method, method) do
      p "writing " + method.to_s.gsub(/^coding_/, '').to_s
    end
    self.send method, *args, &block
  end

end

developer = Developer.new

developer.coding_frontend
developer.coding_backend
developer.coding_debug

Đoạn mã này hơi phức tạp nên chúng ta hãy chia nhỏ nó ra. Việc gọi một phương thức không tồn tại sẽ kích hoạt method_missing. Ở đây, chúng ta muốn tạo một phương thức mới chỉ khi tên phương thức bắt đầu bằng "coding_". Nếu không thì chỉ cần chạy super để thự thi mặc định. Còn lại thì sử dụng define_method để định nghĩa phương thức mới hiện tại đang thiếu. Và như vậy chúng ta có thể làm code DRY. Ngoài ra thì define_method là một private method Module nên chúng ta cần dùng send để gọi nó.

Kết thúc

Hy vọng rằng bài viết này có thể giúp bạn tiến gần hơn đến việc hiểu về metaprograming và thậm chí có thể xây dựng DSL của riêng bạn, và bạn có thể sử dụng để viết mã hiệu quả hơn.


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í