+4

Ruby Metaprogramming

Nếu bạn đã làm việc với Ruby, rất có thể bạn đã nghe đến từ "metaprogramming" khá nhiều. Và bạn có thể đã sử dụng metaprogramming, nhưng chưa hiểu rõ hoàn toàn sức mạnh thực sự hoặc sự hữu ích về những gì nó có thể làm. Qua bài viết này bạn có thể biết được metaprogramming là gì, cũng như khả năng mà nó có thể làm, và làm thế nào bạn có thể khai thác một trong những “killer features” của Ruby trong các projects của bạn.

1. “metaprogramming” là gì?

Metaprogramming được giải thích tốt nhất là lập trình của lập trình. Hay hiểu đơn giản là chương trình có thể tự tạo ra chương trình hoặc một phần của chương trình khác.

Metaprogramming có thể được sử dụng để thêm, sửa hoặc thay đổi code chương trình của bạn trong khi nó đang chạy. Sử dụng nó bạn có thể tạo mới hoặc xoá các phương thức đã có trong các object, mở lại hoặc thay đổi các class đã 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.

2. Ruby gọi các phương thức như thế nào?

Trước khi bạn có thể hiểu được phạm vi đầy đủ của metaprogramming, bạn cần phải nắm bắt được cách ruby tìm kiếm một phương thức khi nó được gọi. Khi bạn gọi một phương thức trong ruby, nó phải xác định được vị trí phương thức đó (nếu nó tồn tại) trong tất cả code trong chuỗi kế thừa.

    class Person
      def say
        "hello"
      end
    end

    john_smith = Person.new
    john_smith.say # => "hello"

Khi method say() được gọi ở ví dụ trên, đầu tiên Ruby sẽ tìm kiếm cha của đối tượng john_smith, bởi vì nó là một instance của class Person và nó có thể gọi phương thức say()

Phức tạp hơn chút là khi đối tượng là một instance của một class mà nó được kế thừa từ một class khác:

    class Animal
      def eats
        "food"
      end

      def lives_in
        "the wild"
      end
    end

    class Pig < Animal
      def lives_in
        "farm"
      end
    end

    babe = Pig.new
    babe.lives_in # => "farm"
    babe.eats # => "food"
    babe.thisdoesnotexist # => NoMethodError: undefined method `thisdoesnotexist' for #<Pig:0x16a53c8>

Khi chúng ta thêm vào kế thừa, Ruby cần xem xét các phương thức được định nghĩa cao hơn trong chuỗi kế thừa. Khi chúng ta gọi babe.lives_in() Ruby sẽ kiểm tra class Pig đầu tiên xem có tồn tại phương thức lives_in() không nếu có thì nó sẽ được gọi.

Phức tạp hơn một chút khi chúng ta gọi phương thức babe.eats(). Ruby sẽ kiểm tra phương thức trong class Pig xem có tồn tại phương thức eats() không, nếu không nó sẽ tiếp tục tìm trong class cha của nó là Animal nếu có tương ứng thì phương thức đó sẽ được gọi ra.

Khi chúng ta gọi babe.thisdoesnotexist(), bởi vì phương thức không tồn tại trong cả class cha và con nên chúng ta sẽ nhận được một ngoại lệ NoMethodError. Bạn có thể nghĩ điều này như một dòng thác, bất cứ phương thức nào được xác định thấp nhất trong chuỗi thừa kế sẽ là phương thức cuối cùng được gọi và nếu phương thức đó không tồn tại thì ngoại lệ sẽ xảy ra.

Chúng ta có thể tóm tắt mô hình của Ruby giống như sau:

  • Hỏi class cha của object nếu nó có phương thức tương ứng, gọi nó nếu nó tìm thấy.
  • Hỏi lớp cha tiếp theo nếu nó có phương thức tương ứng thì gọi nó nếu tìm thấy. Tiếp tục các bước này lên các lớp trên theo chuỗi kế thừa, nếu vẫn chưa tìm thấy phương thức tương ứng.
  • Nếu không thấy trong chuỗi kế thừa phương thức tương ứng, hay phương thức đó không tồn tại khi đó một ngoại lệ cần được đưa ra.

Chú ý: Bởi vì mỗi đối tượng đều kế thừa từ Object (hoặc BasicObject trong ruby 1.9) ở cấp trên cùng, class đó sẽ luôn luôn được tìm kiếm cuối cùng, nhưng chỉ khi mà các lớp ở cấp dưới không tìm thấy một phương thức nào tương ứng.

3. Giới thiệu về Singleton class

Ruby cung cấp đầy đủ lập trình hướng đối tượng và cho phép bạn tạo các đối tượng kế thừa từ các class khác và gọi các phương thức của chúng. Nhưng nếu chỉ có một đối tượng duy nhất yêu cầu một sự bổ sung, thay đổi hoặc xóa thì sao? Các "singleton class" (đôi khi được gọi là "Eigenclass"), được thiết kế chính xác cho điều này và cho phép bạn thực hiện tất cả và nhiều hơn nữa. Ví dụ:

    greeting = "Hello World"

    def greeting.greet
      self
    end

    greeting.greet # => "Hello World"

Ta sẽ đi phân tích từng dòng một. Dòng đầu tiên, chúng ta tạo một biến mới được gọi là greeting biểu thị một giá trị String đơn giản. Dòng thứ 2, chúng ta tạo một phương thức mới được gọi là greeting.greet chứa một nội dung đơn giản. Ruby cho phép bạn chọn đối tượng để gắn với định nghĩa phương thức bằng cách sử dụng dạng: some_object.method_name, bạn có thể nhận thấy nó giống với cú pháp thêm các phương thức lớp cho các class (vd: def self.something). Trong trường hợp này chúng ta có greeting đầu tiên, phương thức đã kèm theo biến greeting là greeting.greet. Dòng cuối cùng là lời gọi phương thức greeting.greet vừa được định nghĩa.

Phương thức greeting.greet được truy cập đến đối tượng mà nó được đính kèm. Trong ruby, chúng ta thường nhắc đến đối tượng self. Trong trường hợp này self truy xuất đến giá trị String mà chúng ta gắn vào đối tượng greeting. Nếu ta cho gắn với một Array thì self sẽ phải trả về một đối tượng là Array.

Như bạn thấy việc thêm một phương thức some_object.method_name không phải là cách tốt nhất để làm nhiệm vụ đó. Đó là lý do tại sao Ruby chung cấp một hữu dụng hơn khi làm việc với các đối tượng và phương thức của chúng một cách năng động.

    greeting = "i like cheese"

    class << greeting
      def greet
        "hello! " + self
      end
    end

    greeting.greet # => "hello! i like cheese"

Đây là một phương thức singleton class cho phép bạn thêm nhiều phương thức cùng một lúc mà không cần phải thêm tiền tố cho tất cả tên các phương thức của bạn. Cú pháp này cũng cho phép bạn thêm bất kỳ điều gì mà bạn thường thêm trong việc khai báo một lớp. Bao gồm cả các phương thức attr_writer, attr_reader, và attr_accessor

Chuỗi kế thừa có thể như sau:

  some object instance > singleton class > parent class > ... > Object/BasicObject

4. Đưa metaprogramming làm việc với instance_evalclass_eval

instance_eval là phương thức được định nghĩa trong module Kernel chuẩn của Ruby và cho phép bạn thêm phương thức instance cho một object giống như cú pháp lớp singleton

    foo = "bar"
    foo.instance_eval do
      def hi
        "you smell"
      end
    end

    foo.hi # => "you smell"

Phương thức instance_eval có thể gồm một khối (trong đó có thiết lập self của đối tương mà bạn đang sử dụng), hoặc một chuỗi các mã. Bên trong khối, bạn có thể định nghĩa các phương thức mới khi bạn đang viết một class và chúng sẽ được thêm vào lớp singleton của đối tượng tương ứng.

Phương thức được định nghĩa bởi instance_eval sẽ là các phương thức instance. Nó là chú ý quan trọng rằng phạm vi là các phương thức instance, bởi vì nó có nghĩa là bạn không thể làm mọi thứ giống attr_accessor như một kết quả. Nếu bạn muốn điều này, bạn sẽ muốn thay đổi hoạt động trên Class của đối tượng thay thế bằng class_eval:

    bar = "foo"
    bar.class.class_eval do
      def hello
        "i can smell you from here"
      end
    end

    bar.hello # => "i can smell you from here"

Như bạn thấy instance_evalclass_eval khá là giống nhau. Bạn cần nhớ tình huống nào thì sử dụng cái nào: instance_eval tạo các phương thức instance và class_eval để tạo các phương thức class.

5. Mocking objects for testing

Chúng ta sẽ đi tìm hiểu một trong những ứng dụng quan trọng nhất của metaprogramming trong Ruby test frameworks: metaprogramming cho mocking và stubbing. Sau đây là một ví dụ đơn giản:

    class Book
    end

    class Borrower
    end

Giả sử chúng ta quản lý thư viện trong một ngày và chúng ta cần ghi lại những quyển sách được mượn và người mượn sách. Chúng ta cũng cần biết số sách người mượn đã đạt mức tối đa cho phép chưa. Vì vậy, ta có thể có 2 class là BookBorrower.

    class Borrower
      attr_accessor :books

      def initialize
        @books = []
      end
    end

Một Borrower được phép mượn nhiều books, vì vậy ở trên chúng ta khai báo một biến class @books cho mỗi cá thể của Borrower để quản lý số sách hiện thời họ đang mượn. Chúng ta thêm attr_accessor để ta có thể truy cập bằng borrower.books và gán giá trị cho nó bằng borrower.books=. Để biết được số sách borrower hiện mượn ta cần thêm vào phương thức:

    class Borrower
      # ...

      def books_on_loan
        @books.length
      end

      # ...
    end

Cuối cùng chúng ta cần giới hạn số sách người dùng được mượn tùy ý (ở đây ta giới hạn là 5 books tại một thời điểm). Do đó ta cần thêm vào phương thức sau:

    class Borrower
      # ...

      def can_loan?
        @books.length < 5
      end

      # ...
    end

Tiếp theo ta sẽ đi viết một vài test, một trong những điều đầu tiên bạn có thể làm là viết test cho phương thức can_loan?, bạn cần phải có một bộ sách giao cho borrower. Bạn có thể đạt được điều này bằng cách thêm một số sách để vay của bạn trước khi bạn test các phương thức thực tế, nhưng cách test này khiến bạn vất vả và sẽ thất bại nếu có bất kỳ vấn đề với các lớp Book khi tạo những trường hợp này. Đây là lúc metaprogramming đến để giải thoát:

    describe Borrower do
      before :each do
        @borrower = Borrower.new
      end

      describe "can_loan? performs correctly" do
        it "returns false if equal to or over the limit" do
          @borrower.books.instance_eval do
            def length
              5
            end
          end
          @borrower.can_loan?.should == false
        end

        it "returns true if under the limit" do
          @borrower.books.instance_eval do
            def length
              1
            end
          end
          @borrower.can_loan?.should == true
        end
      end
    end

Nhờ vào sức mạnh của metaprogramming, bạn không còn phải tạo một bộ dữ liệu Books và thiết lập test phụ thuộc từ bên ngoài. Thay vào đó bạn có thể ghi đè lên các phương thức class để chúng trả về giá trị mà bạn đang kiểm tra cho nó.

6. Dynamic methods

Các thư viện như ActiveRecord sử dụng metaprogramming, cho phép chúng tạo các phương thức mà được sinh ra một cách nhanh chóng dựa trên dữ liệu người dùng.

Bằng cách định nghĩa method_missing trong một class, lời gọi bất kỳ đến phương thức trong lớp đó hoặc nó là chuỗi kế thừa sẽ tự động gọi phương thức method_missing bạn đã định nghĩa. Kết hợp với sức mạnh của instance_evalclass_eval, bạn có công thức cho DSLs đáng kinh ngạc và APIs linh động. Sự kết hợp này có thể khoogn chỉ được sử dụng để cho phép các phương pháp năng động hơn, Cùng có thể ngăn chương trình lặp đi lặp lại các phương thức.

Ví dụ: Chúng ta sẽ thử tạo môt lớp Country tương ứng với phương thức is_<countryname>? trong đó <countryname> là tên của country bạn đang test. Phức tạp hơn chút, chúng ta sẽ tạo tương ứng is_<countryname>(_or_<countryname>...)? trong đó _or... có thể được lặp lại vô hạn để phù hợp với một danh sách các country có thể .

Chúng ta sẽ đi tạo class Country:

    class Country
      attr_accessor :name

      def initialize(name)
        @name = name
      end
    end

Country có một thuộc tính là name mà phải được thiết lập khi tạo một instance của lớp. Chúng ta cũng có một phương thức khởi tạo đơn giản để gán giá trị ban đầu cho nó. Bây giờ ta sẽ đi thiết lập phương thức method_missing:

    class Country
      ...

      COUNTRY_QUERY_REGEX = /^is_((?:_or_)?[a-z]+?)+?$/i

      def method_missing(meth, *args, &block)
        if COUNTRY_QUERY_REGEX.match meth.to_s
          self.class.class_eval <<-end_eval
            def #{meth}
              self.__send__ :check_country, "#{meth}"
            end
          end_eval
          self.__send__(meth, *args, &block)
        else
          super
        end
      end
    end

Dòng thứ 5 định nghĩa hàm method_missing bằng cách kiểm tra nếu phương thức được gọi phù hợp với một biểu thức chính quy. Đoạn Regexp để kiểm tra format có phù hợp với dạng: is_something<_or_somethingelse...>? hay không. (ví dụ: is_italy?is_italy_or_ukraine?)

Ta sử dụng class_eval để thêm một phương thức cho class có tên là meth. Phương thức check_country được gọi với tham số truyền vào là một string "#{meth}". Sau đây chúng ta sẽ đi tạo phương thức check_country:

    class Country
      ...

    private
      def check_country(query)
        countries = query[3..-2].split("_or_")
        countries.any? { |s| s == @name }
      end
    end

Trong phương thức này chúng ta thực hiện truy vấn một mảng countries. Tách dữ liệu bởi or nằm giữa. Sử dụng phương thức any? để kiểm tra tất cả các phần tử của mảng xem có phù hợp với name của country không. Lời gọi cuối cùng của phương thức cũng chính là giá trị trả về của hàm.

    italy = Country.new("italy")
    italy.is_ukraine? # => false
    italy.is_italy? # => true
    italy.is_ukraine_or_italy? # => true
    italy.is_ukraine_or_australia_or_portugal_or_italy? # => true

Bây giờ bạn có thể nhận ra sức mạnh từ những gì ở trên. Để thực hiện các phương thức này bằng tay sẽ mất nhiều thời gian hoặc là khó để duy trì nó. Bằng cách kết hợp các kỹ thuật metaprogramming với method_missing, chúng ta đã tạo được một API đẹp và DRY và dễ dàng duy trì.

Kết luận:

Thông qua bài viết này mong các bạn sẽ hiểu rõ được phần nào về sự hữu ích của metaprogramming. Và có thể áp dụng nó vào ứng dụng của bạn, giúp bạn viết code nhanh hơn và để code của bạn có thể ngắn gọn và mượt mà hơn. Cảm ơn đã theo dõi bài viết, do kiến thức còn hạn hẹp nên bài viết còn khá sơ sài, rất mong nhận được sự góp ý của mọi người.

tài liệu tham khảo:

http://www.sitepoint.com/ruby-metaprogramming-part-i/

http://www.sitepoint.com/ruby-metaprogramming-part-ii/

http://rubylearning.com/blog/2010/11/23/dont-know-metaprogramming-in-ruby/


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í