Đến với metaprogramming trong Ruby qua bài toán nhỏ
Bài đăng này đã không được cập nhật trong 3 năm
I, Giới thiệu
Metaprogramming là một chủ đề lớn của Ruby. Có khá nhiều bài viết đã giới thiệu và đề cập về chủ đề này. Hôm nay mình xin chia sẻ một góc nhìn mới về Metaprogramming trong Ruby
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. 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. Chúng ta sẽ bắt đầu tìm hiểu thông qua bài toán sau.
II, Mô tả bài toán
Chắc các bạn cũng biết method attr_accessor() trong Ruby. Nó được dùng để sinh ra thuộc tính cho các đối tượng. Giả sử bạn có 1 project mà trong đó method attr_accessor() được dùng ở khắp nơi để sinh các thuộc tính cho các đối tượng. Nhằm thay thế việc đó, bài toán của chúng ta là viết một Class Marco, tượng tự như method attr_accessor, để sinh ra một validated attribute, Class Marco này có tên là attr_checked(). Method này phải thỏa mãn 2 điều:
_ Nó phải đưa ra tên của attribute, như là 1 block. Block này được dùng để validation. Nếu bạn truyền một giá trị cho một thuộc tính nào đó mà block không trả về true, thì bạn get một runtime exception.
_ Method attr_checked() không được avaiable trên mọi class mà chỉ được active trên một số class mong muốn. Vì vậy, một class sử dụng được attr_checked() khi chúng đã include module có tên CheckedAttributes
Ví dụ:
class Person
include CheckedAttributes
attr_checked :age do |v|
v >= 18
end
end
me = Person.new
me.age = 39 # OK
me.age = 12 # Exception!
Tóm lại là cần viết method attr_checked() và module CheckedAttributes
III, Giải pháp
Các bước để giải quyết bài toàn trên như sau:
_ Bước 1: Viết một Kernel Method có tên add_checked_attribuite(). Method này sử dụng eval() để thêm validated attributes đơn giản cho một class.
_ Bước 2: Refactor add_checked_attribute() method để loại bỏ eval().
_ Bước 3: Validate attribute thông qua một block
_ Bước 4: Thay đổi add_checked_attribute() sang một Class Marco có tên attr_checked(), method này có thể avaiable mọi class.
_ Bước 5: Viết một Module chứa attr_checked(), để chọn các class mong muốn thông qua một hook
IV, Thực hiện
a, Bước 1:
Ở bước đầu tiên, chúng ta cần sử dụng eval() để thêm những validated attribute một cách đơn giản nhất đến một lớp. Chúng ta cũng tạo method add_checked_attribute() để sinh ra các reader method và writer method, khá giống với attr_accessor().
Method add_checked_attribute() khác với attr_accessor() ở 3 điểm sau:
_ Trong khi attr_accessor() là một Class Marco thì add_checked_attribute() là một Kernel Method.
_ attr_accessor() được viết trong C thì add_checked_attribute() sử dụng Ruby.
_ add_checked_attribute() phải chứa những validation cơ bản.
Giả sử rằng bạn đã có những thông tin validation cơ bản của các thuộc tính, thuộc tính sẽ gây ra một runtime exception nếu bạn truyền cho thuộc tính một giá trị nil hoặc false. Những yêu cầu sẽ rõ ràng hơn trong đoạn test sau: Download ctwc/checked_attributes/eval.rb
require 'test/unit'
class Person; end
class TestCheckedAttribute < Test::Unit::TestCase
def setup
add_checked_attribute(Person, :age)
@bob = Person.new
end
def test_accepts_valid_values
@bob.age = 20
assert_equal 20, @bob.age
end
def test_refuses_nil_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = nil
end
end
def test_refuses_false_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = false
end
end
end
# Here is the method that you should implement.
# (We called the class argument "clazz", because
# "class" is a reserved keyword.)
def add_checked_attribute(clazz, attribute)
# ...
end
Liệu có thể thực thi add_checked_attribute() method và truyền vào test?
Đây là giải pháp:
def add_checked_attribute(clazz, attribute)
eval "
class #{clazz}
def #{attribute}=(value)
raise 'Invalid attribute' unless value
@#{attribute} = value
end
def #{attribute}()
@#{attribute}
end
end
"
end
b, Bước 2:
Ở bước này, chúng ta cần refactor lại method add_checked_attribute bằng việc thay eval() bằng một method ruby thông thường, nhằm đảm bảo sự an toàn cho dự án của bạn. Do đó, việc viết lại các method mà không sử dụng Strings of Code sẽ làm cho source clear và đẹp hơn.
Giải pháp:
Để định nghĩa những methods cho một class, bạn cần đi vào trong phạm vi của class đó. Ở bước 1 thì method add_checked_attribute đã làm điều đó bằng việc sử dụng Open Class trong Strings of Code. Nếu loại bỏ eval(), bạn sẽ không sử dụng từ khóa "class" được nữa vì class sẽ không chấp nhận đặt tên bằng một biến. Do vây, bạn sẽ đi vào bên trong class bằng class_eval(). Download ctwc/checked_attributes/no_eval.rb:
def add_checked_attribute(clazz, attribute)
clazz.class_eval do
# ...
end
end
Bây giờ, bạn đã ở trong class, bạn có thể định nghĩa các method reader và writer bằng việc sử dụng Dynamic Method:
def add_checked_attribute(clazz, attribute)
clazz.class_eval do
define_method "#{attribute}=" do |value|
# ...
end
define_method attribute do
# ...
end
end
end
Code hoàn chỉnh là:
def add_checked_attribute(clazz, attribute)
clazz.class_eval do
define_method "#{attribute}=" do |value|
raise 'Invalid attribute' unless value
instance_variable_set("@#{attribute}" , value)
end
define_method attribute do
instance_variable_get "@#{attribute}"
end
end
end
_ Bước 3:
Những attribute mà bạn đã tạo ra từ bước trước có thể sẽ gây ra các exception nếu giá trị mà bạn truyền vào là nil hoặc false. Tuy nhiên, giả sử bây giờ nó được suppost một validation linh hoạt thông qua một block. Download ctwc/checked_attributes/block.rb:
require 'test/unit'
class Person; end
class TestCheckedAttribute < Test::Unit::TestCase
def setup
add_checked_attribute(Person, :age) {|v| v >= 18 }
@bob = Person.new
end
def test_accepts_valid_values
@bob.age = 20
assert_equal 20, @bob.age
end
def test_refuses_invalid_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = 17
end
end
end
def add_checked_attribute(clazz, attribute, &validation)
# ... (The code here doesn't pass the test. Modify it.)
end
Giải pháp:
def add_checked_attribute(clazz, attribute, &validation)
clazz.class_eval do
define_method "#{attribute}=" do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}" , value)
end
define_method attribute do
instance_variable_get "@#{attribute}"
end
end
end
_ Bước 4:
Ở bước này chúng ta cần thay đổi Kernel Method sang Class Marco, cái mà avaiable đến mọi class. Loại bỏ add_checked_attribute() và thay bằng attr_checked(), cái mà có thể sử dụng trong định nghĩa lớp. Bởi vậy, tham số chỉ cần tên của attribute vì lớp ở đây chính là self. Test case được update như sau:
require 'test/unit'
class Person
attr_checked :age do |v|
v >= 18
end
end
class TestCheckedAttributes < Test::Unit::TestCase
def setup
@bob = Person.new
end
def test_accepts_valid_values
@bob.age = 20
assert_equal 20, @bob.age
end
def test_refuses_invalid_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = 17
end
end
end
Nhiệm vụ bây giờ là viết attr_checked() method.
Giải pháp:
Nếu bạn muốn tạo method attr_checked() có thể avaiable đến mọi class, đơn giản bạn có thể tạo nó như là một instance method trong lớp Class. Download ctwc/checked_attributes/macro.rb
class Class
def attr_checked(attribute, &validation)
define_method "#{attribute}=" do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}" , value)
end
define_method attribute do
instance_variable_get "@#{attribute}"
end
end
end
_ Bước 5:
ở bước này, test case sẽ thêm một dòng include module:
require 'test/unit'
class Person
include CheckedAttributes
attr_checked :age do |v|
v >= 18
end
end
class TestCheckedAttributes < Test::Unit::TestCase
def setup
@bob = Person.new
end
def test_accepts_valid_values
@bob.age = 20
assert_equal 20, @bob.age
end
def test_refuses_invalid_values
assert_raises RuntimeError, 'Invalid attribute' do
@bob.age = 17
end
end
end
Bạn sẽ loại bỏ attr_checked() khỏi lớp Class và viết module CheckedAttributes chứa method attr_checked() như sau:
module CheckedAttributes
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def attr_checked(attribute, &validation)
define_method "#{attribute}=" do |value|
raise 'Invalid attribute' unless validation.call(value)
instance_variable_set("@#{attribute}" , value)
end
define_method attribute do
instance_variable_get "@#{attribute}"
end
end
end
end
Giờ thì ta đã hoàn thành bài toán, rõ ràng nếu có nhiều lớp, mỗi lớp có nhiều thuộc tính, mỗi thuộc tính có validation khác nhau, cần những method reader và writer khác nhau thì sử dụng metaprogramming để sinh các thuộc tính cho các đối tượng giúp code của chúng ta ngắn gọn và tinh tế hơn rất nhiều.
V, Kết luận
Trên đây là những kiến thức ban đầu khi mình mới tìm hiểu về metaprogramming. Mình sẽ tìm hiểu thêm và có những bài viết tiếp theo về chủ đề này. Thanks for read!
Nguồn: Book "Metaprogramming Ruby" of Paolo Perrotta.
All rights reserved