Monad và cách sử dụng để tái cấu trúc code ruby
Bài đăng này đã không được cập nhật trong 7 năm
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 push
và pop
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?
và 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àmf
-
Một block
b
sẻ trả về một monadm(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