+3

Đến với metaprogramming trong Ruby qua bài toán nhỏ

images.jpg

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

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í