はじめに
皆さんはRuby、好きですか?僕は好きです。
皆さんはモナド、好きですか?僕は好きです。
好きなものと好きなもの、どっちも使いたくなるのが人間の性。
どうにかしてRubyでモナドを使いたい!
ということで、Rubyでモナドを使って、Rubyでよく書くありがちなコードをいい感じにしていきます。
モナドってなに?
モナドはHaskellなどの関数型言語で使われる概念です。
細かい定義は他の記事に任せますが、簡単にいってしまうと「書いたコード(文字通り)よりも外の世界から受ける影響に安全にアクセスする方法」です。こういうのをプログラミングでは「副作用」って言ったりします。
モナドについてはこことここの記事が個人的に勉強になりました。
モナドが力を発揮する場面としてよくあげられるのが「IO」です。IOはまさに「自分が書いたコードの外から受ける影響」ですよね。
入力ではコードを実行するまでどんな値が入ってくるかわかりません。冷静に考えるとこれは結構怖いことだったりします。書いたコードは自分でいくらでもバグらないように修正できますが、外部からの影響には「備える」ことしかできません。
そんな時にモナドを使うと、「外界の影響」を箱に詰めてブラックボックス化しながら、コードとしてはやりたいことを素直に記述するだけでうまいコードを書くことができるようになります。
ここまでの説明を読むだけでも、モナドはなんだかいいもののように感じませんか?(良いものです)
残念ながらRubyには組み込みの文法としてのモナドは存在しません。(今後も多分入らない)
しかし、型安全やバリデーションをシステムに提供してくれるライブラリ群、dry-rbにdry-monadsというgemがあります!
このgemを使ってモナドの力をRubyに加えてみます。
実践
dry-monadsは関数型言語のモナドをRubyでも扱えるようにしたgemですが、正確にいうと関数型言語におけるモナドとは異なります。
詳しいことは省きますが、結論から言うとモナド則を満たさない記述ができてしまうからです(動的型付け言語だから仕方がないのかもしれない)
しかし、それでもモナドが提供してくれる恩恵を得ることはできます。
言葉で書いてよくもわからないと思うので、早速手続き型プログラミングで書いた場合とモナドを使用して書いた場合のコードを載せます。
手続き型言語ではIO処理ではよくガード条件を使って外部からの影響に備えますね。
例えばRailsのActiveRecordとかを使っているとよくこんな感じのコードを書くんじゃないでしょうか?(ちょっと大げさに書いてます)
user = User.find_by(id: user_id)
address = Address.find_by(id: address_id)
if user.present? && address.present?
if user.update(address: address)
...
else
...
end
else
...
end
このように、手続き型で素朴に記述するとUser.find_by
が存在しているかどうか、取得したUser
と関連をもつArticle
が存在するかどうかを想定したコードになります。
これにdry-rb
を適用して書くとこうなります。
require 'dry/monads'
extend Dry::Monads[:maybe]
result = Maybe(User.find_by(id: user_id)).bind do |user|
Maybe(Address.find_by(id: address_id)).fmap do |address|
user.update(address: address)
end
end.to_result
if result.success?
...
else
...
end
このようにモナドを使うことで、user
がなんの値になろうがarticle
がなんの値になろうが途中の処理は全てモナドが隠してくれます。これによってコードを書く上では最終結果に対してsuccess?
がtrue
になるかどうかだけを考えれば良くなります。(to_result
メソッドを用いてResult
モナドに変換しています)思考がスッキリするのを感じますね。
また、ruby2.7で使うことができるようになるパターンマッチを用いると、↓のようにResultへのキャストをする必要もなく記述できるようになります
require 'dry/monads'
extend Dry::Monads[:maybe]
result = Maybe(User.find_by(id: user_id)).bind do |user|
Maybe(Address.find_by(id: address_id)).fmap do |address|
user.update(address: address)
end
end
case result
in Some(_)
...
in None()
...
end
この例ではオプショナル型に近いようなMaybe
モナドを使いましたが、そのほかにもモナドはたくさんあるのでそれぞれの使い方を紹介します
Maybe
例でも用いたMaybeは、nil
を受け取った際にはNone()
を返し、それ以外の時はSome(value)
を返します。
これを用いることで最終結果がSome
or None
になるため、最後にその検証だけをすればよくなります。
https://dry-rb.org/gems/dry-monads/1.0/maybe/
コード再掲
require 'dry/monads'
extend Dry::Monads[:maybe]
result = Maybe(User.find_by(id: user_id)).bind do |user|
Maybe(Address.find_by(id: address_id)).fmap do |address|
user.update(address: address)
end
end
case result
in Some(_)
...
in None()
...
end
Result
ResultはMaybeに似たようなモナドです。Maybeがnil
をNone
に変換してくれたのに対し、Resultは自分でSuccess
とFailure
の定義をする必要があります。
https://dry-rb.org/gems/dry-monads/1.0/result/
require 'dry/monads'
extend Dry::Monads[:result]
result = find_user(user_id).bind do |user|
find_address(address).fmap do |address|
user.update(address: address)
end
end
case result
in Success(_)
...
in Failure()
...
end
def find_user(user_id)
user = User.find_by(id: user_id)
user.present? ? Success(user) : Failure()
end
def find_address(address_id)
address = Address.find_by(id: address_id)
address.present? ? Success(address) : Failure()
end
Try
Tryはエラーハンドリングに使うことができるモナドです。
これを用いることで、エラー処理をcall
内に閉じ込めることができます。
https://dry-rb.org/gems/dry-monads/1.0/try/
require 'dry/monads'
class UpdateUser
include Dry::Monads[:try]
attr_reader :user_id, :address_id
def initialize(user_id, address_id)
@user_id = user_id
@address_id = address_id
end
def call
Try { User.find(user_id) }.bind do |user|
Try { Address.find(address_id) }.bind do |address|
Try { user.update!(address: address) }
end
end
end
end
result = UpdateUser.new(user_id, address_id).call
case result
in Try::Value(_)
...
in Try::Error(_)
...
end
List
Listは配列に対するイテレーションに使うことができるモナドです。
マップ処理などをモナドの世界に閉じ込めることができます。
https://dry-rb.org/gems/dry-monads/1.0/list/
require 'dry/monads/list'
M = Dry::Monads
M::List[1, 2].bind { |x| [x - 1] } # => List[0, 1]
M::List[1, 2].bind(-> (x) { [x, x * 2] }) # => List[1, 2, 2, 4]
M::List[1, nil].bind { |x| [x - 1] } # => raise Error
Task
Taskは非同期処理に対して使うことができるモナドです。
これを用いることで、JSのasync
、await
のような処理を行うことができるようになります。
https://dry-rb.org/gems/dry-monads/1.0/task/
require 'dry/monads'
class AsyncTask
include Dry::Monads[:task]
def call
task1 = Task { fetch_task1 }
task2 = Task { fetch_task2 }
task1.bind { |t1| task2.fmap { |t2| [t1, t2] } }
end
def fetch_task1
sleep 3
'task1'
end
def fetch_task2
sleep 2
'task2'
end
end
async_task = AsyncTask.new
task = async_task.call
task.fmap do |t1, t2|
puts "Task: #{t1}"
puts "Task: #{t2}"
end
sleep 10
puts 'done'
まとめ
状況に応じてこれらのモナドを使うことで、やりたいことを素直に記述したコードがかけるようになります。
少しでもご興味を持っていただけたら幸いです。