11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

dry-monadsを使ってRubyでモナドの夢を見る

Last updated at Posted at 2019-12-14

はじめに

皆さんはRuby、好きですか?僕は好きです。
皆さんはモナド、好きですか?僕は好きです。

好きなものと好きなもの、どっちも使いたくなるのが人間の性。
どうにかしてRubyでモナドを使いたい!

ということで、Rubyでモナドを使って、Rubyでよく書くありがちなコードをいい感じにしていきます。

モナドってなに?

モナドはHaskellなどの関数型言語で使われる概念です。
細かい定義は他の記事に任せますが、簡単にいってしまうと「書いたコード(文字通り)よりも外の世界から受ける影響に安全にアクセスする方法」です。こういうのをプログラミングでは「副作用」って言ったりします。
モナドについてはここここの記事が個人的に勉強になりました。

モナドが力を発揮する場面としてよくあげられるのが「IO」です。IOはまさに「自分が書いたコードの外から受ける影響」ですよね。
入力ではコードを実行するまでどんな値が入ってくるかわかりません。冷静に考えるとこれは結構怖いことだったりします。書いたコードは自分でいくらでもバグらないように修正できますが、外部からの影響には「備える」ことしかできません。
そんな時にモナドを使うと、「外界の影響」を箱に詰めてブラックボックス化しながら、コードとしてはやりたいことを素直に記述するだけでうまいコードを書くことができるようになります。

ここまでの説明を読むだけでも、モナドはなんだかいいもののように感じませんか?(良いものです)
残念ながらRubyには組み込みの文法としてのモナドは存在しません。(今後も多分入らない)
しかし、型安全やバリデーションをシステムに提供してくれるライブラリ群、dry-rbdry-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がnilNoneに変換してくれたのに対し、Resultは自分でSuccessFailureの定義をする必要があります。
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のasyncawaitのような処理を行うことができるようになります。
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'

まとめ

状況に応じてこれらのモナドを使うことで、やりたいことを素直に記述したコードがかけるようになります。
少しでもご興味を持っていただけたら幸いです。

参考文献

11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?