Rubyで関数型プログラミング#1: ステート(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rubyで関数型プログラミング#1: ステート(翻訳)

Rubyは本質的にオブジェクト指向言語ですが、LISPのような関数型言語からも実に多くのヒントを得ています。

関数型プログラミングは、一般的によく言われるようなプログラミングスペクトラムの反対側の極北に位置するものではありません。むしろ同じ問題に対するもうひとつの思考法であり、Rubyプログラマーにとっても極めて有益なものになりえます。

実を言うと、皆さんもおそらく既に「関数型」のコンセプトをさんざん使っているはずです。何もHaskellScalaといった言語を極めなければその恩恵を受けられない、なんてことはありません。

本シリーズの目的は、Rubyプログラマーにも当てはまる「実用」という光を関数型プログラミングに当てることです。言い換えれば、厳密な証明だの純粋この上ない関数型の「象牙の塔」だのを攻略しなくてもよいコンセプトもいくつかあり、それで十分なのです。

今日にでも私たちのプログラムの改善に使えるコード例を中心に据えて、本シリーズを進めたいと思います。

そういうわけで、第一回のお題は「ステート」です。

関数型プログラミングと「ステート」

関数型プログラミングの主要なコンセプトのひとつが「ステートがイミュータブル(変更不可)」です。Rubyでステートを撤廃したところで全面的に実用的なものになるとは限らないかもしれませんが、コンセプトそのものには計り知れない値打ちがあります。

ステートの利用を控えることでアプリが理解しやすくなり、アプリのテストも楽になります。この恩恵を受けるためにステートの利用を全面的に我慢する必要はないという点が秘訣です。

ステートを定義する

そもそもステートとは一体何だかご存知ですか?ステートとは、プログラム全体に流れるデータのことであり、ステートが「イミュータブル」とは一度ステートが設定されたらその後変更されないということです。

x = 5
x += 2 # ステートが改変されたぞ!

これが特に当てはまるのはメソッドです。

def remove(array, item)
  array.reject! { |v| v == item }
end

array = [1,2,3]
remove(array, 1)
# => [2, 3]

array
# => [2, 3]

渡したarrayは上の操作によって改変されてしまいました。arrayを改変する関数が他にも2つ3つあると、少々困ったことになります。

純粋な意味での「関数」は、入力を一切改変しません。

def remove(array,item)
  array.reject { |v| v == item }
end

array = [1,2,3]
remove(array, 1)
# => [2, 3]

array
# => [1, 2, 3]

その分遅くはなりますが、この関数が新しいarrayを返すということが容易に想像できます。入力Aを与えるたびに結果Bが返されるというわけです。

「それは本当に起こったのか?」

問題は、純粋関数のメリットを朝から晩まで口を酸っぱくして説くまではいいのですが、言われた方は(訳注: オブジェクトの改変による)罠を踏むまではそのメリットを十分わかってもらえないことです。

一度、とあるゲーム画面の出力をJavaScriptのreverseでテストしたことがありました。そのときは問題はなさそうでしたが、reverseをもうひとつ追加した途端テストが全部コケてしまったのです!

何がまずかったのでしょうか?

そのときわかったのですが、このreverse関数はゲーム画面を改変していたのです。

状況を理解するのにかかった時間よりも、私がそれを受け入れる方に時間がかかりました。しかし改変はプログラムのカスケード時に微妙な効果をもたらし、それに気づくのは制御不能になってからです。

ここが秘訣なのですが、改変を何が何でも避ける必要はありません。改変が行われたタイミングと場所がはっきりわかる形で掌握しておけばよいのです。

Rubyの場合、ステートの改変はメソッド名末尾に!を付けて表現することがよくありますが、常にそうなっているとは限りません。concatメソッドなどはこのルールから外れているので、十分目を光らせておきましょう。

ステートを分離する

ステートを扱うメソッドは、ステートの扱いを閉じ込めるために使います。以下のような純粋関数を考えてみましょう。

def add(a, b)
  a + b
end

この関数に与える入力が同じであれば、出力はいついかなる場合でも同じになります。これはこれで便利ですが、詳細をうまく隠蔽する方法は他にもいろいろ考えられます。

def count_by(array, &fn)
  array.each_with_object(Hash.new(0)) { |v, h|
    h[fn.call(v)] += 1
  }
end

count_by([1,2,3], &:even?)
# => {false=>2, true=>1}

うるさいことを言えば、上の配列内の値ごとにハッシュが改変されていますが、もう少し気楽に言えば、入力が同じであれば出力も正確に同じになります。

これで関数が純粋になってくれるんでしょうか?いいえ、そうではありません。上はステートが関数内にだけ存在するように「ステートの分離」を作り出したのです。この関数内でハッシュに対して行った操作は、外部からはまったくうかがい知れませんし、Rubyでは十分受け入れられる妥協策です。

しかし今度は、ステートを分離しても1つの関数がたったひとつの操作しか行えないという点を問題にします。

単一責任とI/Oステート

関数は1つの操作だけを行い、それ以外は行うべきではありません。

私はこれまでに、新人プログラマーたちのコードで以下のようなパターンを嫌になるほど目にしました。

class RubyClub
  attr_reader :members

  def initialize
    @members = []
  end

  def add_member
    print "メンバー名: "
    member = gets.chomp
    @members << member
    puts "メンバーを追加しました!"
  end
end

この書き方の問題は、「メンバーを追加する」と「ユーザーにメッセージを表示する」という異なるアイデアを一箇所に押し込めてしまっていることです。このクラスにしてみれば、メンバーの追加方法さえわかればよいのであって、メッセージの表示方法など知ったことではありません。

入力を受け取って最後にはちゃんと出力されているのだから、ぱっと見には害はなさそうと思うかもしれません。しかし今度は、getsの部分でテストが止まってしまうという問題が待ち構えています。getsは入力待ちの状態になり、その後putsnilを返すことになります。

こんな代物をどうやってテストすればよいというのでしょうか?

describe '#add_member' do
  before do
    $stdin = StringIO.new("Havenwood\n")
  end

  after do
    $stdin = STDIN
  end

  it 'adds a member' do
    ruby_club = RubyClub.new
    ruby_club.add_member
    expect(ruby_club.members).to eq(['Havenwood'])
  end
end

長いコードですね。テストを動かすためにSTDIN(標準入力)をインターセプトしなければならず、おかげでコードを読むのも大変です。

もう少し実装に集中しましょう。このクラスは、新しいメンバーを入力で受け取り、すべてのメンバーを出力として返すことだけに関心を持っています。

class RubyClub
  attr_reader :members

  def initialize
    @members = []
  end

  def add_member(member)
    @members << member
  end
end

これなら以下をテストするだけで済みます。

describe '#add_member' do
  it 'adds a member' do
    ruby_club = RubyClub.new
    expect(ruby_club.add_member('Havenwood')).to eq(['Havenwood'])
  end
end

上は、I/O(putsgets)を扱うという関心から、別の形のステートを抽出したものです。

それではこのRuby ClubをCLIでも動かさなければならないとか、結果をファイルから読み込む可能性があるとしましょう。どうリファクタリングすれば動くでしょうか?現在のクラスは、入力を受け取って出力を扱うという考えに囚われています。

このままでは追加されるテストやコードが非常に脆くなり、時間とともに問題が顕在化するでしょう。

静的なステート

他によくあるパターンは、抽象データを定数に保存するというものです。この考え方自体は別に悪くないのですが、クラスやメソッドを何とか動かそうとするあまり着々とハードコードされてしまう可能性があります。

以下のコード例で考えてみましょう。

class SampleLoader
  SAMPLES_DIR = '/samples/ruby_samples'

  def initialize
    @loaded_samples = {}
  end

  def load_sample(name)
    @loaded_samples[name] ||= File.read("#{SAMPLES_DIR}/#{name}")
  end
end

上のコードは、(クラスの)関心が特定のディレクトリだけに向けられている間はうまくいくのですが、elixir_samplesrust_samplesなどで用いるサンプルローダーを書く必要が生じたとしたらどうでしょうか。SAMPLES_DIR定数は変更の効かない静的なステートのかけらになってしまっています。

この場合の解決法は、「注入(injection)」というアイデアを活用することです。値をクラスにハードコードするのではなく、必要な知識をクラスの外から注入するのです。

class SampleLoader
  def initialize(base_path)   # 注入
    @base_path = base_path
    @loaded_samples = {}
  end

  def load_sample(name)
    @loaded_samples[name] ||= File.read("#{@base_path}/#{name}")
  end
end

これでサンプルローダーは、ディスクに何らかのファイルが存在する限りはサンプルの取得元をまったく気にする必要がなくなりました。このコードにはキャッシュ周りのリスクがあることも確かですが、これについては読者の練習課題といたします。

デフォルト値に定数を使うというチョロい方法で解決する手もありますが、人によっては少々暗黙的に感じるかもしれません。次のように賢く使いましょう。

class SampleLoader
  SAMPLES_DIR = '/samples/ruby_samples'

  def initialize(base_path = SAMPLES_DIR)
    @base_path = base_path
    @loaded_samples = {}
  end

  def load_sample(name)
    @loaded_samples[name] ||= File.read("#{@base_path}/#{name}")
  end
end

ファイルを読むときのI/Oステート

さて、Ruby Clubがメンバーを読み込むことになったとしましょう。今度はパスをハードコードしないように注意します。

class RubyClub
  def initialize
    @members = []
  end

  def add_member(member)
    @members << member
  end

  def load_members(path)
    JSON.parse(File.read(path)).each do |m|
      @members << m
    end
  end
end

今回の問題点は、メンバーファイルが単なるファイルではなく、JSONフォーマットでもあるという事実にまで頼っていることです。これではローダーの柔軟性がガタ落ちになってしまいます。

ここではまた別の種類のI/Oステートが絡んでいます。データをRuby Clubに読み込む方法までクラスの関心に含めるのはやりすぎです。

ここでSQLiteなどのデータベースに切り替えたいとしたら、あるいは単にYAMLに切り替えたいとしたらどうでしょう。この手のコードを元に進めるのは非常につらいタスクです。

入力の種類に応じたローダーを複数こしらえることでこの問題を解決するという方法を、駆け出し開発者だった頃から何度か見かけました。そもそもRuby Clubはどんな方法で読み込むかについて何の関心も持たないとしたらどうでしょうか?

「メンバーの読み込み」という概念を丸ごと切り出せば、次のようなコードになるでしょう。

class RubyClub
  attr_reader :members

  def initialize(members = []) # メンバーリストを外から渡す
    @members = members
  end

  def add_members(*members)
    @members.concat(members)
  end
end

new_members = YAML.load(File.read('data.yml'))
RubyClub.new(new_members)

それって単なる「関心の分離」なんじゃ?

オブジェクト指向や関数型プログラミングで楽しいのは、どちらにも同じように適用できる概念がいろいろあることです(単に概念の名前が異なる傾向があるだけです)。両者がぴたりと重なるとは限らないかもしれませんが、関数型言語で学べることの多くが、命令形言語の要素がより強い言語のさまざまなベストプラクティスと非常に似通った印象を与えることがあります。

方法はさまざまですが、ステートを制御する作業とはすなわち関心を分離する作業です。これを実践した純粋関数を用いることで、並外れて柔軟かつ頑丈なコードを書けるようになり、テストも理解も拡張も楽に行なえます。

まとめ

Rubyにおけるステートは完全に純粋とはいかないかもしれませんが、ステートを制御することでプログラムは本質的に後々までうまく動くようになります。プログラミングにおいてはそれこそがすべてです。

コードは書く量よりも読んだり改修したりする量の方が明らかに多いのですから、最初に書くコードが柔軟であればあるほど、後々読んだり作業したりするときに楽になれます。

最初に書きましたように、本コースでは関数型プログラミングの実用的な使い方に重点を置きます。そうした使い方はRubyと関連しているからです。関数型から派生するラムダ計算のスキームに重点を置いて真に純粋なプログラムを書くことにしてもよいのですが、時間もかかりますし、それはそれはうんざりする作業になるでしょう。

とはいうものの、折を見てそうした関数型言語で遊びながら動作を理解するだけでも楽しいものです。関心をお持ちの方向けにこの分野の良書をご紹介します。

ウサギの穴のさらに奥深くを探検してみたい方には、Raganwald氏の素晴らしい著作をどうぞ。

Kestrels, Quirky Birds, and Hopeless Egocentricity by Reg “raganwald” Braithwaite [PDF/iPad/Kindle]

訳注: 本書は無料で公開されています。Rubyのメタプログラミングとコンビネーター論理を合わせて論じる意欲作です。

それではいつものようにお楽しみください!

次回は「クロージャ」です。

ツイートより

関連記事

Rails: 提案「コントローラから`@`ハックを消し去ろう」(翻訳)

Rails: jQueryをVue.jsに置き換える方法(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ