Metaprogramming trong Ruby

Metaprogramming là gì?

Một trong những khía cạnh ấn tượng nhất của Ruby là metaprgramming. Là 1 ngôn ngữ động, Ruby cho bạn tự do định nghĩa các method và thậm chí là các class trong suốt thời gian chạy. Lập trình meta với ruby, người ta có thể làm trong vài phút các việc mà ngôn ngữ khác có thể phải mất vài giờ để làm nó. Bởi việc lên kế hoạch cẩn thận và tổ chức khéo léo cho các dòng lệnh của bạn sử dụng các kỹ thuật được nhắc đến dưới đây, code của bạn sẽ DRY, nhẹ, trực quan và có tính mở rộng hơn .

Metaprogramming hiểu đơn giản là "Code sinh ra code" nghĩa là mình viết một chương trình và chương trình này sẽ sinh ra, điều khiển các chương trình khác hoặc làm 1 phần công việc ở thời điểm biên dịch. Chính vì vậy, metaprogramming giúp source của chúng ta trở nên ngắn gọn hơn, giảm thiểu vấn đề duplicate, như các method có chức năng tương tự nhau trong source code (nhất là trong test), dễ dàng thay đổi cũng như chỉnh sửa, giúp hệ thống trở nên gọn nhẹ và mượt mà hơn.

Metaprogramming là hành động của việc viết code mà hoạt động của các dòng code này thiên về trên code hơn là trên dữ liệu. Bao gồm việc kiểm tra và sửa đổi cấu trúc hoạt động của chương trình bằng ngôn ngữ.

Thêm method vào nội dung của 1 object

Trong Ruby thì mọi thứ đều là object. Lớp cơ bản (base class) trong Ruby được gọi là Object (hoặc BasicObject trong Ruby 1.9), tất cả các class khác kế thừa các thuộc tính từ nó. Mỗi object trong Ruby có các method riêng của nó, và các biến instance (@variable_name) có thể được thêm, chỉnh sửa hoặc xóa bỏ trong suốt thời gian chạy chương trình. Ví dụ :

# Ví dụ 1: tạo 1 instance mới cho class Object
my_object = Object.new

# định nghĩa method set_my_variable trong my_object để thiết lập biến instance @my_instance_variable
def my_object.set_my_variable=(var)
  @my_instance_variable = var
end

# định nghĩa method get_my_variable trong my_object để trả về giá trị của biến instance @my_instance_variable
def my_object.get_my_variable
  @my_instance_variable
end

# gán giá trị cho biến @my_instance_variable = "Hello" bằng cách gọi method set_my_variable
my_object.set_my_variable = "Hello"

# trả về giá trị của biến @my_instance_variable bằng cách gọi method get_my_variable, giá trị trả về là Hello
my_object.get_my_variable # => Hello

Trong ví dụ trên, chúng ta tạo 1 instance mới của class Object và định nghĩa 2 method trong instance này để ghi và đọc dữ liệu (setting và getting). 2 method này được định nghĩa riêng cho object my_object và chỉ khả dụng với object này. Điều này có thể chứng minh trong ví dụ sau đây :

# Ví dụ 2: tạo 1 instance class Object mới
my_object = Object.new

# tạo 1 instance class Object khác
my_other_object = Object.new

# định nghĩa 1 method trong my_object để thiết lập biến instance @my_instance_variable
def my_object.set_my_variable=(var)
  @my_instance_variable = var
end

# định nghĩa 1 method trong my_object để trả về giá trị của biến instance @my_instance_variable
def my_object.get_my_variable
  @my_instance_variable
end

my_object.set_my_variable = "Hello"
my_object.get_my_variable # => Hello

my_other_object.get_my_variable = "Hello" # => NoMethodError

Khi gọi get_my_variable() trong object my_other_object thì kết quả trả về là NoMethodError có nghĩa là my_other_object không nhận biết được method get_my_variable().

Viết Class Methods

Cú pháp chung cho viết method trong class Ruby :

# Ví dụ 3
class MyClass
  def self.capitalize_name
    name.upcase
  end
end
print MyClass.capitalize_name # => MYCLASS

Trong định nghĩa lớp, chúng ta đã xác định 1 phương thức (method) trên 1 đối tượng cụ thể (trong ví dụ 1), còn ở trong ví dụ 3 này object là self (chính là MyClass). Ở trong ví dụ 3, method capitalize_name() chỉ khả dụng với object MyClass, đó chỉ là cách đơn giản hướng dẫn chúng ta viết 1 method class thế nào, còn trong ví dụ 4 dưới đây chúng ta có 3 cách khác nhau để định nghĩa 1 method :

# Ví dụ 4
# cách 1 :
class MyClass
  # định nghĩa method bên trong định nghĩa lớp
  def MyClass.capitalize_name
    name.upcase
  end
end

# cách 2 :
class MyClass
  ...
end
# định nghĩa method bên ngoài định nghĩa lớp
def MyClass.capitalize_name
  name.upcase
end

# cách 3
# định nghĩa 1 class mới được gọi là MyClass
MyClass = Class.new
# thêm method capitalize_name vào lớp MyClass
def MyClass.capitalize_name
  name.upcase
end

Bạn có nhận ra sự tương đồng từ 3 cách đơn giản trên so với ở trong ví dụ 1? qua những ví dụ trên, tôi nghĩ rằng việc viết 1 class method trong Ruby cũng giống như việc tạo 1 instance của class và chỉ thêm method vào instance đó, trong ví dụ 4 trên thì nó là instance của class "Class".

Viết Code dùng để viết Code

Một triết lý quan trọng trong lập trình là DRY (Don't Repeat Yourself - không lặp lại những gì đã viết). Việc trùng lặp code nhiều lần không chỉ lãng phí thời gian mà còn là 1 cơn ác mộng nếu bạn cần thay đổi, chỉnh sửa sau này. Mỗi khi viết code bạn luôn luôn cần phải suy nghĩ cách tối ưu nhất để DRY được chương trình của mình để đảm bảo được tính dễ đọc và đầy đủ.

Ví dụ sau đây là đoạn code chưa được DRY :

# Ví dụ 5
class CarModel
  def engine_info=(info)
    @engine_info = info
  end

  def engine_info
    @engine_info
  end

  def engine_price=(price)
    @engine_price = price
  end

  def engine_price
    @engine_price
  end

  def wheel_info=(info)
    @wheel_info = info
  end

  def wheel_info
    @wheel_info
  end

  def wheel_price=(price)
    @wheel_price = price
  end

  def wheel_price
    @wheel_price
  end

  def airbag_info=(info)
    @airbag_info = info
  end

  def airbag_info
    @airbag_info
  end

  def airbag_price=(price)
    @airbag_price = price
  end

  def airbag_price
    @airbag_price
  end

  def alarm_info=(info)
    @alarm_info = info
  end

  def alarm_info
    @alarm_info
  end

  def alarm_price=(price)
    @alarm_price = price
  end

  def alarm_price
    @alarm_price
  end

  def stereo_info=(info)
    @stereo_info = info
  end

  def stereo_info
    @stereo_info
  end

  def stereo_price=(price)
    @stereo_price = price
  end

  def stereo_price
    @stereo_price
  end
end

Trên đây là đoạn code của 1 chương trình ứng dụng quản lý cho một hãng sản xuất xe ô tô để lưu trữ và truy cập dữ liệu trên mỗi mô hình của nó. Trong ứng dụng trên có 1 class là CarModel. Mỗi mô hình xe hơi đi kèm với các tính năng : "engine", "wheel", "airbag", "alarm", "stereo"; tương ứng với mỗi tính năng có method để thiết lập và lấy giá trị của thông số feature_info và giá tiền feature_price ứng với mỗi tính năng đó. mỗi 1 loại method giữa các tính năng là tương tự nhau, thay vì viết riêng method cho từng feature thì chúng ta có thể DRY lại code cho gọn như sau :

# Ví dụ 6
class CarModel
  # định nghĩa 1 mảng chứa tất cả các feature
  FEATURES = ["engine", "wheel", "airbag", "alarm", "stereo"]

  # lặp để định nghĩa methods cho từng tính năng
  FEATURES.each do |feature|
    # cụm #{feature} có giá trị bằng với feature của vòng lặp. ví dụ ở đây feature = "wheel" thì #{feature}_info = wheel_info
    define_method("#{feature}_info=") do |info|
      instance_variable_set("@#{feature}_info", info)
    end

    define_method("#{feature}_info") do
      instance_variable_get("@#{feature}_info")
    end

    define_method "#{feature}_price=" do |price|
      instance_variable_set("@#{feature}_price", price)
    end

    define_method("#{feature}_price") do
      instance_variable_get("@#{feature}_price")
    end
  end
end

Trong ví dụ trên, chúng ta định nghĩa 1 mảng tên là FEATURES bao gồm tất cả các tính năng (feature) mà chúng ta muốn quản lý (thêm method cho nó). Sau đó với mỗi feature sử dụng Module#define_method của Ruby để định nghĩa ra các method cần thiết. Như trong ví dụ 5 và 6, thì mỗi 1 feature có 4 method tương ứng để get và set giá trị của info và price. Nếu không sử dụng 1 mảng để lặp các giá trị của feature thì chúng ta phải viết đủ 20 method cho 5 feature này. Điều này làm cho code của chúng ta không DRY nhìn dài, rối mắt và khó theo dõi. Nếu số lượng feature còn tăng lên nữa thì không biết số lượng method chúng ta cần phải viết sẽ tăng thêm là bao nhiêu. Nhưng nếu sử dụng phương pháp hướng dẫn giống trong ví dụ 6 thì chúng ta chỉ phải viết 4 method cơ bản chung nhất cho các feature, việc còn lại chỉ cần lặp mảng các feature để định nghĩa method cho từng feature tương ứng. Số lượng method sinh ra vẫn không thiếu mà code của bạn nhìn rất xúc tích, gọn mắt và dễ theo dõi. Khi số lượng feature tăng thêm bạn không cần phải lo viết thêm các method cho từng feature đó mà việc bạn cần làm chỉ là thêm feature đó và trong mảng feature đã có. Trên đây còn sử dụng method Object#instance_variable_set() để xét giá trị cho các biến instance và sử dụng Object#instance_variable_get() để lấy về giá trị của các biến instance tương ứng với mỗi feature. Ví dụ feature là "wheel" thì biến instance sẽ là @wheel_info@wheel_price

Sự cần thiết phải định nghĩa các method getter và setter như thế này là khá phổ biến trong Ruby, vì vậy Ruby đã tạo ra Module#attr_accessor để làm việc đó, Module#attr_accessor có thể được sử dụng để làm các việc giống như trong ví dụ 6 chỉ với dòng code đơn giản sau:

# Example 7
class CarModel
  attr_accessor :engine_info, :engine_price, :wheel_info, :wheel_price,
    :airbag_info, :airbag_price, :alarm_info, :alarm_price, :stereo_info, :stereo_price
end

Tuyệt vời. Việc thiết lập các biến attr_accessor cho từng feature trên đã có thể cho phép chúng ta get - set giá trị trong nó. Nhưng đó chưa phải là ý tưởng tối ưu nhất. Để tốt hơn chúng ta nên định nghĩa 1 lớp macro (lớp phụ thuộc) gọi là features để thiết lập các feature:

# Example 8
class CarModel
  # định nghĩa 1 lớp macro để thiết lập các feature
  def self.features(*args)
    args.each do |feature|
      # định nghĩa các biến attr_accessor cho từng feature với 2 loại là _info và price
      attr_accessor "#{feature}_price", "#{feature}_info"
    end
  end

  # thiết lập giá trị cho lớp macro features
  features :engine, :wheel, :airbag, :alarm, :stereo
end

Trong ví dụ này chúng ta mang đến các đối số cho CarModel#features và chuyển chúng thành các biến attr_accessor với các phần mở rộng _price_info.

Giải thích tóm tắt về Ruby Object Model

Trước khi đi sâu hơn để tìm hiểu về metaprogramming, điều quan trọng là phải hiểu cơ bản về Object Model của Ruby và cách Ruby giao dịch với các method gọi đến như thế nào. Khi bạn gọi 1 method trên một object, đầu tiên các bộ thông dịch viên sẽ tìm kiếm trong các method instance của object để xem liệu nó có thể tìm được method được gọi hay không. Nếu có thể tìm được method thì nó sẽ thực hiện method được gọi, còn nếu không tìm được nó sẽ gửi yêu cầu đến chuỗi class của object đó. Nếu vẫn không thể tìm thấy method cần nó sẽ tiếp tục tìm kiếm trong lớp cha của lớp đó sau đó là lớp cha của lớp cha.... lên đến lớp Object của chính nó. Khoogn dừng lại ở đó, nếu không thể tìm thấy method ở bất cứ đâu trong số các lớp kế thừa của object đó nó sẽ qua trở lại object và gọi 1 method khác gọi là method_missing() và trả về lỗi NoMethodError.

Bằng việc xác định method_missing() bên trong 1 class, chúng ta có thể thay đổi hành vi mặc định của method này bằng 1 số hiệu ứng khác có ích. Trong ví dụ sau đây method method_missing() được truyền vào 2 đối số : tên của missing method (giống như 1 ký tự) và mảng các đối số của nó :

# Example 9
class MyGhostClass
  def method_missing(name, *args)
    puts "#{name} was called with arguments: #{args.join(',')}"
  end
end

m = MyGhostClass.new
m.awesome_method("one", "two") # => awesome_method was called with arguments: one,two
m.another_method("three", "four") # => another_method was called with arguments: three,four

Trong ví dụ trên không có method awesome_method() hoặc another_method() trong class của chúng ta khi gọi chúng, thay vì nhìn thấy kết quả trả về "NoMethodError" như bình thường thì chúng ta sẽ nhìn thấy tên của method và đối số của chúng mà chúng ta đã định nghĩa trong method method_missing() trên.

Chúng ta có thể mở rộng ý tưởng này bằng việc thêm điều kiện vào trong method này, ví dụ như chỉ khi không tìm được 1 số method nào đó thì mới được trả về kết quả trong method method_missing() còn lại thì trả về "NoMethodError" như mặc định. Giống như trong ví dụ sau :

# Example 10
class MyGhostClass
  def method_missing(name, *args)
    if name.to_s =~ /awesome/
      # nếu tên method được gọi có chứa từ "awesome" thì mới thực hiện lệnh dưới
      puts "#{name} was called with arguments: #{args.join(',')}"
    else
      # nếu tên không chứa "awesome" thì trả về kết quả "NoMethodError" như mặc định
      super
    end
  end
end

m = MyGhostClass.new
m.awesome_method("one", "two") # =>  awesome_method was called with arguments: one,two

# another_method không chứa "awesome" nên trả về "NoMethodError" như mặc định
m.another_method("three", "four") # =>  NoMethodError

Ghost Methods - Method ma

Nói đúng ra, "MyGhostClass#awesome_method" không thực sự là 1 method. Nếu chúng ta tạo một instance của MyGhostClass và quét các method của nó để tìm ra method chứa "awesome" thì chúng ta sẽ không tìm được bất kì kết quả nào.

# Example 11
@m = MyGhostClass.new
@m.methods.grep(/awesome/) # => nil

Thay vì gọi nó là method thì chúng ta gọi nó là method ma. Nó đi kèm ưu điểm và khuyết điểm. Ưu điểm. Ưu điểm của nó là khả năng viết mã đáp ứng các method khi bạn không có cách nào để nhận biết được các method được gọi đến. Nhược điểm là việc thay đổi hành vi mặc định của Ruby như thế này có thể gây ra lỗi bất ngờ nếu bạn không cẩn thận với tên method của bạn. Có thể dùng method ma này trong ví dụ quản lý xe hơi phía trên :

# Example 12
class CarModel
  def method_missing(name, *args)
    # chuyển đối số name về kiểu string
    name = name.to_s

    super unless name =~ /(_info|_price)=?$/
    if name =~ (/=$/)
      instance_variable_set("@#{name.chop}", args.first)
    else
      instance_variable_get("@#{name}")
    end
  end
end

Lời kết

Bài viết trên đây còn khá sơ sài, nếu bạn quan tâm có thể xem thêm ở các link sau :

Metaprogramming tuy là khía cạnh rất mạnh của Ruby nhưng không phải là nó không có những hạn chế, khuyết điểm. Để tìm hiểu về những hạn chế này bạn có thể xem thêm ở bài viết sau :