Perusing delegate.rb from Ruby’s Standard Library

Tổng quan

Đầu tiên khi đi vào bài này mình muốn đưa ra khái niệm delegate là gì, để mọi người có thể nắm được tổng quan về delegate trong ruby có gì giống và khác với delegate của các ngôn ngữ khác.
Trong lập trình ruby, có rất nhiều cách giúp bạn chuyển tiếp đối tượng (đại khái là việc bạn gọi đối tượng từ một class khác). Delegate là một trong những cách đó. Delegate giúp gọi các public methods của object khác giống như là của chính mình. Delegate rất hữu ích với Active Record associations. Bài này chúng ta cùng xét một ví dụ về class Movie (Vì chủ đề này mình thấy khá liên quan, nhiều khi diễn viên 1 sẽ phải truyền thông điệp qua diễn viên 3 thông qua diễn viên 2 và người này cần được yêu cầu để ủy thác - delegate thông điệp cho người còn lại). Định nghĩa này tương tự như sự tương tự trên sân khấu - nó định nghĩa sự ủy nhiệm là quá trình chuyển một thông điệp tới một đối tượng, điều này chỉ để có một đối tượng chuyển tiếp thông điệp tới một đối tượng khác. Nhưng tại sao chúng ta lại muốn làm điều đó?
Bài viết sẽ chỉ ra rằng việc sử dụng delegate.rb có thể giúp ta trong việc thiết kế các giao diện mạnh mẽ nhưng vẫn linh hoạt. Sau đó, tự nhìn vào mã nguồn, mình sẽ cố gắng giải thích một chút về mọi việc đang diễn ra bên dưới của quá trình.

Cùng xét ví dụ về một chương trình gợi ý phim hay

Giả sử chúng ta đang làm việc trên phần back-end của công cụ đề xuất phim. Trong cuộc sống đơn giản của chúng ta hiện nay, những gì chúng ta cần biết về Movie là số (điểm) đánh giá của nó từ iMDb và Rotten Tomatoes (2 trang web đánh giá phim nổi tiếng, lâu đời và uy tín nhất hiện nay). Giả sử cả hai điểm được chuẩn hóa trên cùng quy mô, chúng ta cũng muốn nhận một số (đơn giản) - average_score- dựa trên mức trung bình của hai điểm bên ngoài.Nó sẽ có dạng thế này thế này:

class Movie
  attr_reader :imdb_score, :rotten_tomatoes_score

  def initialize(name, imdb_score, rotten_tomatoes_score)
    @name = name
    @imdb_score = imdb_score
    @rotten_tomatoes_score = rotten_tomatoes_score
  end

  def average_score
    (@imdb_score + @rotten_tomatoes_score) / 2
  end
end

Những gì chúng ta cần tiếp theo là một class chứa một mảng Movies (hay gọi là có quan hệ với movies và có thể gọi đến đối tượng của nó), được gọi là RecommendedMovies, khi đó chúng ta có thể thực hiện các truy vấn liên quan:

class RecommendedMovies
  def initialize(movies)
    @movies = movies
  end

  def best_by_imdb
    @movies.max_by(&:imdb_score)
  end

  def best_by_rotten_tomatoes
    @movies.max_by(&:rotten_tomatoes_score)
  end

  def best
    @movies.max_by(&:average_score)
  end
end

Nhìn đống code trên khá đơn giản đúng không. Chúng ta có thể tạo ra một đối tượng chuyên dụng (nghĩa là không gắn bó với mảng nguyên thủy) có giao diện rõ ràng và hữu ích. Hãy lấy một số dữ liệu vào và đưa nó ra cho một ổ thử nghiệm:

north_by_northwest = Movie.new('North by Northwest', 85, 100)
inception = Movie.new('Inception', 88, 86)
the_dark_knight = Movie.new('The Dark Knight', 90, 94)

recommended_movies = RecommendedMovies.new([north_by_northwest, inception, the_dark_knight])

Chúng ta có thể truy vấn recommend_movies một cách đơn giản

recommended_movies.best
 => #<Movie:0x007fbcf7048948 @name="North by Northwest", @imdb_score=85, @rotten_tomatoes_score=100>

Các hạn chế với cách này

class RecommendedMovies dường như hoạt động đúng như mong đợi, nhưng với một trở ngại đáng kể: chúng ta đã khởi tạo nó bằng một mảng, nhưng chúng tôi đã mất tất cả các hành vi của mảng ban đầu: nếu chúng ta chạy recommended_movies.count, chúng ta sẽ nhận được một lỗi đó là NoMethodError. Điều này là hơi hạn chế, vì điều này rất có thể xảy ra nếu chúng ta muốn thao tác với các method trên RecommendedMovies! Chúng ta có thể thực hiện method_missing trong class của chúng ta, nhưng có lẽ chúng ta có thể chuyển sang một giải pháp "thanh lịch" hơn, được tìm thấy trong thư viện chuẩn của delegate.rb của Ruby. Thư viện này đưa ra hai giải pháp cụ thể cho vấn đề của chúng ta - cả hai đều được thực hiện thông qua kế thừa. Mặc dù DelegateClass có giá trị bổ sung vào nó, nhưng đơn giản hơn là SimpleDelegator và nó phù hợp với các trường hợp trong câu hỏi được đưa ra một cách rất tốt. Chúng ta có thể sử dụng nó như sau:

require 'delegate'

class RecommendedMovies < SimpleDelegator
 def best_by_imdb
   max_by(&:imdb_score)
 end

 def best_by_rotten_tomatoes
   max_by(&:rotten_tomatoes_score)
 end

 def best
   max_by(&:average_score)
 end
end

Và ... nó đã trở nên đẹp hơn khá nhiều. Mọi thứ hoạt động giống như trước, và bây giờ chúng ta có tất cả các method dành cho mảng ban đầu trong tầm tay. Về cơ bản, chúng ta đã áp dụng Decorator Pattern trên một mảng. Đề cập đến cùng một cơ sở dữ liệu mà chúng ta đã chuẩn bị trước đó, chạy recommend_movies.count bây giờ chỉ đơn giản là trả về 3. Lưu ý rằng mình đã bỏ qua method initializer kể từ khi bắt đầu và đề cập đến các biến instance movies là không còn cần thiết! Nó giống như self trong trường hợp RecommendedMovies của chúng ta là mảng mà chúng tôi đã khởi tạo đối tượng RecommendedMovies ...

Các tiến trình diễn ra đằng sau nó.

Mã nguồn của delegate.rbtại đây mình sẽ dựa vào đây để nói và có thể các bạn cũng sẽ muốn tìm hiểu sâu hơn bằng cách đọc các comment trong bài đó. Và đầu tiên mình cùng bắt đầu phần này với SimpleDelegator nhé.
Kết quả của việc kế thừa từ SimpleDelegator, chuỗi ancestors cho RecommendedMovies sẽ giống như thế này:

recommended_movies.class.ancestors
 => [RecommendedMovies, SimpleDelegator, Delegator,
#<Module:0x007fed5005fc90>, BasicObject]

Đó là một sự thay đổi từ chuỗi ancestors của phiên bản ban đầu - [RecommendedMovies, Object, Kernel, BasicObject]. Lý do cho sự khác biệt là SimpleDelegator kế thừa từ một lớp khác - Delegator (dòng 316) - do đó thừa hưởng từ BasicObject (dòng 39). Đây là lý do tại sao Object và Kernel nằm ngoài dây chuyền. Không quen thuộc # <Module: 0x007fed5005fc90> (có thể hơi khác một chút trên máy của bạn, nếu bạn chạy mã này) là một mô đun vô danh, được xác định và bao gồm bởi class Delegate (dòng 53); nó phục vụ như là một phiên bản cắt nhỏ của mô-đun Kernel: Kernel được duplicated và đặt trong biến tạm thời (dòng 40); sau đó, các hành động được thực hiện ở variable's class (dòng 41) mà bỏ xác định một số method từ nó (dòng 44, dòng 50). Sau khi sửa đổi, Kernel cập nhật lần cuối cùng và được bao gồm trong Delegate. Điều này giải thích chuỗi ancestors chúng ta đang thấy.

Phân tích tính trong suốt phần khởi tạo (Transparent Initialization)

Như đã lưu ý ở trên, mình đã bỏ qua method initalize từ class RecommendedMovies mới được cập nhật của mình. Ruby sẽ tự động gọi initialize cho một đối tượng mới khởi tạo (nghĩa là sau khi chúng tôi gọi new trên class), nhưng vì không thực hiện một initialization method, nó đã đi lên chuỗi các ancestors để tìm kiếm nó, như mong đợi. SimpleDelegator không thực hiện nó là điều tốt, nhưng Delegator đã làm điều đó (dòng 71). Nó mong đợi một đối số duy nhất, obj, là đối số mà trường hợp RecommendedMovies đã được tạo ra - trong trường hợp của chúng ta, một đối tượng Array of Movie - và đó là đối tượng chúng ta sẽ ủy thác cho các message.
Bên trong, Delegator#initialize chỉ đơn giản gọi phương thức setobj, đi qua cùng một obj như một đối số một lần nữa. Nhưng Delegator không thực hiện setobj: nếu nó đã nhận được cuộc gọi như vậy, nó sẽ gây ra một lỗi (dòng 176). Điều này là bởi vì Delegate phục vụ như là một lớp trừu tượng. Các lớp con của nó nên thực hiện setobj mình, và thực sự, SimpleDelegator làm điều đó (dòng 340). SimpleDelegator#__ setobj__ chỉ đơn giản là lưu trữ obj trong một biến ví dụ tên delegate_sd_obj (sd là viết tắt của SimpleDelegator). Hãy nhớ rằng, trong ví dụ của chúng ta, self vẫn chính là recommended_movies!

Delegation!

Như đã trình bày trước đó, một khi đối tượng recommended_movies của chúng ta được sinh ra, chúng ta có thể sử dụng nó như một mảng decorated. Khi chúng ta gọi method tốt nhất theo nó, Ruby định vị nó trong lớp đối tượng, RecommendedMovies, và thực hiện nó cho chúng ta. Nhưng khi chúng ta gọi count, Ruby sẽ không tìm thấy nó trong class của chúng ta. Thông dịch sau đó đi qua chuỗi tổ tiên tìm kiếm nethod, nhưng than ôi, đếm không được định nghĩa trong bất kỳ ancestors nào trong class của chúng ta!
Đây là điểm nơi method_missing đi vào chơi. Nếu Ruby hoàn thành tra cứu method thông thường mà không phát hiện, nó sẽ không ném NoMethodError ngay lập tức; thay vào đó, nó sẽ bắt đầu tra cứu một lần nữa, lần này tìm method_missing. Nếu bất kỳ lớp nào trong chuỗi ancestors định nghĩa phương thức này, nó sẽ được gọi. Nếu không, chúng ta sẽ nhận được NoMethodError sau khi tìm kiếm kết thúc ở đầu chuỗi.
Trong ngữ cảnh của chúng ta, class Delegator định nghĩa method_missing (dòng 78). Đầu tiên, nó tìm nạp đối tượng đích chúng ta sẽ cố gắng ủy thác bằng cách gọi getobj (dòng 80), được thực hiện bởi SimpleDelegator (dòng 318). Về cơ bản, method này đưa chúng ta trở lại đối tượng đích được lưu trữ trong @delegate_sd_obj. Sau đó, nó sẽ cố gắng gọi method được đề cập tới đối tượng đích (dòng 83). Nếu đối tượng đích không đáp ứng với method, Delegate#method_missing sẽ kiểm tra xem Kernel có không và sẽ gọi nó cho phù hợp (dòng 85). Nếu không, nó sẽ gọi super (dòng 87), mà ở phần này của narrative, kết quả sẽ là NoMethodError. Khá dài và khó để đọc phần này.

Tổng kết

Trên đây là một bài viết, với mình là khá mơ hồ vì còn khá nhiều thứ mình chưa nắm được hết. Tuy nhiên, mình vẫn mạnh dạn viết ra và mong nhận được sự góp ý từ mọi người. Bài viết được tham khảo ở một số trang và có thể nó chưa hoàn chỉnh. Mong mọi người cùng xây dựng để bài viết tốt hơn nữa.
Mình xin cảm ơn!