+1

Monad và cách sử dụng để tái cấu trúc code ruby

Giới thiệu

monads xuất phát từ lập trình hàm (Functional Programming - FP).Thông thường mọi người nhắc đến monads như là một thứ gì đó rất cao siêu và khó hiểu, tuy nhiên thực chất nó lại rất đơn giản. Ở bài viết này tôi sẽ giải thích thế nào là monads và sử dụng nó để tái cấu trúc lại một số code ruby.

Stacks

Trước khi đi vào tìm hiểu monads thì ta sẽ xem stack là gì.

Stack đơn giản mà nói là một kiểu dữ liệu mà có một số hành động sau:

  • push

  • pop

  • top

  • empty?

  • empty

Ở đây tôi nói về immutable stack, tức là hàm pushpop sẽ không thay đổi stack mà nó sẽ trả về một stack mới. Bạn sẽ cần hàm top để lấy phần tử ở đầu stack. Hàm empty giúp ta tạo một stack rỗng để bắt đầu sử dụng.

Để một giá trị nào đấy được coi là stack, nó cần đảm bảo các hành động cần tuân theo quy luật sau:

  • stack.push(x).top == x

  • stack.push(x).pop == stack

  • empty.empty? == true

  • stack.push(x).empty? == false

Các luật trên mô tả một stack hoạt động như thế nào. Khi bạn push một giá trị vào stack thì nó trở thành top. vừa push vừa pop thì tương đương với việc không xử lý gì. Một stack không có giá trị sẽ là empty còn có giá trị thì empty? sẽ trả ra false.

Ta có thể implement các hàm trên của stack bằng bất kể cách nào tùy ý, miễn là nó thỏa mãn luật trên.

Dưới đây là một ví dụ:

ArrayStack = Struct.new(:values) do
  def push(value)
    ArrayStack.new([value] + values)
  end

  def top
    values.first
  end

  def pop
    ArrayStack.new(values.drop(1))
  end

  def empty?
    values.empty?
  end

  def self.empty
    new([])
  end
end

ArrayStack lưu giá trị dưới dạng mảng. ArrayStack#push thêm giá trị mới vào trước mảng values còn ArrayStack#pop sẽ xóa phần tử đầu tiên của mảng.

Và sau đây lại là một ví dụ khác, implement stack bằng một cách khác:

LinkedListStack = Struct.new(:top, :pop) do
  def push(value)
    LinkedListStack.new(value, self)
  end

  # def top
  #   # we get this for free!
  # end

  # def pop
  #   # and this!
  # end

  def empty?
    pop.nil?
  end

  def self.empty
    new(nil, nil)
  end
end

Thay vì lưu lại toàn bộ mảng giá trị, LinkedListStack chỉ lưu giá trị top và một con trỏ tới phần còn lại của stack. Hàm top với pop chỉ đơn giản là 2 hàm getter, còn hàm push sẽ lưu giá trị của nó vào một object LinkedListStack mới.

Vậy, những rule của stack có mục đích là mô tả cách mà một stack hoạt động như thế nào. Vì thế mặc dù ta có thể implement stack theo nhiều cách khác nhau nhưng ta lại có thể sử dụng chúng mà không cần phải biết nó được implement thế nào.

Ví dụ:

# ArrayStack
>> stack = ArrayStack.empty
=> #<struct ArrayStack values=[]>

>> stack.push('hello').push('world').pop.top
=> "hello"

# LinkedListStack
>> stack = LinkedListStack.empty
=> #<struct LinkedListStack top=nil, rest=nil>

>> stack.push('hello').push('world').pop.top
=> "hello"

Do sự đảm bảo từ các rule mà ta có thể định nghĩa các hàm mới dựa trên các hàm mặc định:

module Stack
  def size
    if empty?
      0
    else
      pop.size + 1
    end
  end
end

ArrayStack.include(Stack)
LinkedListStack.include(Stack)

Ở đây, hàm size sẽ tính độ dài của một stack thông qua việc đệ qui và đếm số lần gọi pop cho đến khi empty

size sẽ hoạt động chừng nào mà empty?pop hoạt động đúng theo các rule được định nghĩa.

Monads

Vậy, stack thực chất là một abstract data type, một dạng dữ liệu với một số các hành động (hàm) được định nghĩa bởi một số luật nhất định.

Và tại sao tôi lại giới thiệu và giải thích stack ở đây là vì monads thực tế cũng là một dạng abstract data type giống như stack vậy. monads cũng là một kiểu dữ liệu (hay như trong ruby là object) có một số hàm mà theo một số quy luật nhất định.

Vậy thì hàm và các luật của monads là gì. Trước tiên ta cần xem định nghĩa của monad từ bên FP:

Monads apply a function that returns a wrapped value to a wrapped value. Monads have a function >>= (pronounced "bind") to do this

Để hiểu được câu này thì trước hêt bạn cần hiểu wrapped value là như thế nào. Như đã nói ở trên, monads là một abstract data type. Tức là monads là một kiểu dữ liệu có các hàm đi kèm. Như vậy, trong monads sẽ chứa một loại dữ liệu nào đó (string, integer, array, object...). Như vậy ta có thể thấy wrapped value ở đây chính là monads. Hàm >>= hay còn gọi là bind chính là hàm định nghĩa của monads.

Ta có thể hiểu đoạn trên như sau trong ruby:

Một monad object sẽ dùng một hàm (bind) để gọi một block ở ngoài mà cũng trả về một monad. Hàm này cũng sẽ lại trả về chính monad đó.

Vậy ta có thể thấy luật của hàm bind của monad sẽ là nhận vào một block có trả về monad rồi hàm này lại trả ra đúng monad đấy.

Giải thích các khái niệm của lập trình hàm bằng lời nói tương đối khó khăn, vây nên tôi sẽ thử sử dụng các ký hiệu để giải thích lại một lần nữa cho các bạn:

  • Ta gọi một monad là m và một giá trị nào đó là v

  • Trong monad m tồn tại một hàm f

  • Một block b sẻ trả về một monad m(v1)

  • Khi ta gọi m(v).f &b thì kết quả trả về sẽ là m(v1)

Refactoring

Ta cần một số ví dụ để hiểu sâu hơn về monad. Đầu tiên là việc xử lý các giá trị nil

Xử lý nil

Đầu tiên ta cần một số code để xử lý nil:

Project = Struct.new(:creator)
Person  = Struct.new(:address)
Address = Struct.new(:country)
Country = Struct.new(:capital)
City    = Struct.new(:weather)

Giờ nếu ta muốn hiển thị thời tiết cho một project bất kỳ, vậy ta sẽ có hàm sau:

def weather_for(project)
  project.creator.address.
    country.capital.weather
end

Giả sử với trường hợp mà Address lại không có country thì chắc chắn đoạn code trên sẽ báo exception

>> bad_project = Project.new(Person.new(Address.new(nil)))
=> #<struct Project …>

>> weather_for(bad_project)
NoMethodError: undefined method `capital' for nil:NilClass

Để xử lý vấn đề này, ta có thể thêm điều kiện kiểm tra nil ở mỗi bước như sau:

def weather_for(project)
  unless project.nil?
    creator = project.creator
  end

  unless creator.nil?
    address = creator.address
  end

  unless address.nil?
    country = address.country
  end

  unless country.nil?
    capital = country.capital
  end

  unless capital.nil?
    weather = capital.weather
  end
end

Hoặc nếu bạn dùng rails thì ta có hàm try:

class Object
  def try(*a, &b)
    if a.empty? && block_given?
      yield self
    else
      public_send(*a, &b) if respond_to?(a.first)
    end
  end
end

class NilClass
  def try(*args)
    nil
  end
end
def weather_for(project)
  project.
    try(:creator).
    try(:address).
    try(:country).
    try(:capital).
    try(:weather)
end

try được rails thêm vào ruby nhờ phương pháp monkey patching. Đó không phải là một việc tốt do nó sẽ làm tăng độ phụ thuộc cho code của chúng ta. Vậy làm thể nào để ta có thể cung cấp một method tương tự như try vào đây. Trong OOP thì cách tốt nhất là sử dụng decoration.

Ta có thể tạo một class decorator gọi là Optional và có duy nhất một thuộc tính là value:

Maybe = Struct.new :value

Một instance của class này sẽ bọc một giá trị khác (nil, string, number...). Và thay vì đưa try vào Object, ta sẽ đưa nó vào Optional

class Optional
  def try(*args, &block)
    if value.nil?
      nil
    else
      value.public_send(*args, &block)
    end
  end
end

Nếu value là nil thì try sẽ đơn giản trả về nil, còn không thì nó sẽ gọi hàm public của value dựa vào args.

Hàm try ở đây vẫn còn một vấn đề nữa là nó có nhiều nhiệm vụ ở đây. Thứ nhất là việc check nil, try sẽ kiểm tra xem value có nil hay không. Tuy nhiên, try cũng sẽ gọi method của value nữa. Nếu ta muốn sử dụng value theo một cách khác khi mà nó không nil thì điều này sẽ là không thể đối với method try hiện tại.

Để làm được như vậy, ta cần thay code ở trong else thay vì public_send trên value, ta sẽ truyền vào một block và gọi nó:

class Optional
  def try(&block)
    if value.nil?
      nil
    else
      block.call(value)
    end
  end
end

Giờ ta có thể truyền một block cho try và làm bất cứ thứ gì ta muốn với value ở bên trong, gọi một hàm của nó hoặc sử dụng như một tham số của một hàm khác... Bây giờ, thay vì gọi try với tên method, ta có thể gọi try với một block và trong đấy ta có thể làm bất cứ thứ gì đối với value:

def weather_for(project)
  optional_project = Optional.new(project)
  optional_creator = optional_project.try { |project| Optional.new(project.creator) }
  optional_address = optional_creator.try { |creator| Optional.new(creator.address) }
  optional_country = optional_address.try { |address| Optional.new(address.country) }
  optional_capital = optional_country.try { |country| Optional.new(country.capital) }
  optional_weather = optional_capital.try { |capital| Optional.new(capital.weather) }
  weather          = optional_weather.value
end

Mặc dù vậy, đoạn code trên vẫn có vấn đề với nil vì try sẽ return nil ở một lúc nào đó. Ta có thể sửa nó dễ dàng như sau:

class Optional
  def try(&block)
    if value.nil?
      Optional.new(nil)
    else
      block.call(value)
    end
  end
end

Như vây ta có thể chain các method như sau:

def weather_for(project)
  Optional.new(project).
    try { |project| Optional.new(project.creator) }.
    try { |creator| Optional.new(creator.address) }.
    try { |address| Optional.new(address.country) }.
    try { |country| Optional.new(country.capital) }.
    try { |capital| Optional.new(capital.weather) }.
    value
end

Tên method try không còn hợp lý ở đây nữa vì ở đây method này cụ thể hơn rất nhiều. Nó gần giống với tên class Optional của nó vậy. Ta sẽ đổi tên try thành bind và bạn có thể thấy là Optional chính là một monad vì:

  • Optional tồn tại một hàm bind

  • Hàm bind nhận vào một block mà trả về một instance của Optional

  • Hàm bind cũng trả về chính instance Optional được trả về từ block.

Như vậy, ta có thể thấy điểm mạnh của monad là khả năng chain method và khả năng thêm logic mới cho object thông qua việc viết thêm code mới thay vì sửa đổi code có sẵn, tuân thủ theo nguyên tắc Open-close principle trong SOLID.

Mặc dù vậy, nếu so sánh với hàm try của rails thì cách refactor của chúng ta dài dòng hơn và cũng xấu hơn nhiều. Ta có thể sử dụng metaprogramming của Ruby để fix lại nó:

class Optional
  def and_then(&block)
    if value.nil?
      Optional.new(nil)
    else
      block.call(value)
    end
  end

  def method_missing(*args, &block)
    bind do |value|
      Optional.new(value.public_send(*args, &block))
    end
  end
end

Bây giờ ta có thể viết lại code như sau:

def weather_for(project)
  Optional.new(project).
    creator.address.country.capital.weather.
    value
end

Kết luận

Monad là một kỹ thuật tốt để chúng ta có thể áp dụng trong việc viết code. Nó giúp gỡ bỏ các callback lồng nhau, tạo các object có khả năng tái sử dụng cao.

References

refactoring-ruby-with-monads functors,_applicatives,_and_monads_in_pictures


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.