Ruby
プロを目指す人のためのRuby入門

はじめに

この記事は書籍「プロを目指す人のためのRuby入門」に掲載できなかったトピックを著者自らが紹介するアドベントカレンダーの10日目です。
本文に出てくる章番号や項番号は書籍の中で使われている番号です。

この記事では範囲式を使ったフリップフロップを紹介します。

必要な前提知識

「プロを目指す人のためのRuby入門」の第4章まで読み終わっていること。

範囲式を使ったフリップフロップ

範囲式(a..bのような式)はフリップフロップという少し変わったロジックを構築するために利用できます。

フリップフロップとは何かを文章で説明してもわかりづらいので、先にサンプルコードを紹介します。

たとえば次のようなテキストがあったとします。

おはようございます
いただきます
カレーを食べる
スープを飲む
デザートを食べる
ごちそうさま
ひるねをする
しごとをする
いただきます
ピザを食べる
ケーキを食べる
コーヒーを飲む
ごちそうさま
おやすみなさい

この中からご飯を食べている間だけ、つまり「いただきます」から「ごちそうさま」の間だけを抜き出して出力する方法を考えます。
すなわち、以下のような出力になればOKです。

いただきます
カレーを食べる
スープを飲む
デザートを食べる
ごちそうさま
いただきます
ピザを食べる
ケーキを食べる
コーヒーを飲む
ごちそうさま

フリップフロップを使わないロジック

単純に考えると次のようなロジックで実現できそうです。

  • テキストを1行ずつ抜き出す
  • 「いただきます」が渡されたら、フラグをONにする
  • フラグがONであればテキストを出力する
  • 「ごちそうさま」が渡されたら、フラグをOFFにする
  • 最後の行まで上の処理を繰り返す

Rubyのコードで表現すると次のようになります。

text = <<TEXT
おはようございます
いただきます
カレーを食べる
スープを飲む
デザートを食べる
ごちそうさま
ひるねをする
しごとをする
いただきます
ピザを食べる
ケーキを食べる
コーヒーを飲む
ごちそうさま
おやすみなさい
TEXT

flag = false
text.each_line(chomp: true) do |line|
  if line == 'いただきます'
    flag = true
  end
  if flag
    puts line
  end
  if line == 'ごちそうさま'
    flag = false
  end
end

each_line(chomp: true)は行末の改行文字を取り除きながら、テキストを1行ずつ抜き出す処理です。

ただし、Ruby 2.3以前ではchomp: trueの引数を渡すとエラーになるため、text.each_line.map(&:chomp).eachのように書いてください。

それ以外は特に難しい部分はないはずです。

フリップフロップを使うロジック

先ほどのコードをフリップフロップを使って書き換えて見ましょう。
なんと、こうなります。

text.each_line(chomp: true) do |line|
  if (line == 'いただきます')..(line == 'ごちそうさま')
    puts line
  end
end

ぱっと見ただけでは意味がわからないと思うので、解説します。

if文の条件式になっている(line == 'いただきます')..(line == 'ごちそうさま')がフリップフロップです。
フリップフロップは次のような範囲式になっています。

(式1)..(式2)

まず、Rubyは式1を評価します。
式1が偽を返せば、フリップフロップ全体の戻り値は偽になります。

式1が真を返すと、今度は式2が評価されるようになります。
また、フリップフロップ全体の戻り値は真になります。

式2が偽を返す間は、フリップフロップ全体の戻り値は真になります。
式2が真を返すと、初期状態に戻ります。つまり式1が評価されるようになります。
また、式2が真を返したタイミングのフリップフロップ全体の戻り値は真です。

先ほどのサンプルコードでいうと、次のようなフローになります。

  • lineに"おはようございます"が入る
  • line == 'いただきます'は偽なので、フリップフロップ全体は偽
  • lineに"いただきます"が入る
  • line == 'いただきます'は真なので、フリップフロップ全体は真
  • (ここで式2に切り替わる)
  • line == 'ごちそうさま'は偽なので、フリップフロップ全体は真のまま
  • lineに"カレーを食べる"が入る
  • line == 'ごちそうさま'は偽なので、フリップフロップ全体は真のまま
  • ("スープを飲む"、"デザートを食べる"も同様なので省略)
  • lineに"ごちそうさま"が入る
  • line == 'ごちそうさま'は真なので、フリップフロップ全体は真だが、フリップフロップは初期状態に戻る
  • lineに"ひるねをする"が入る
  • line == 'いただきます'は偽なので、フリップフロップ全体は偽
  • (以下同様なので、省略)

文章にするとやはりややこしい感じがしますが、結局のところ、フリップフロップは「フリップフロップを使わないロジック」と同じような処理を内部で実行していることになります。

範囲式を変数に入れるとエラー

範囲式はif文の条件に直接渡す必要があります。次のように変数に入れようとするとエラーになります。

text.each_line(chomp: true) do |line|
  range = (line == 'いただきます')..(line == 'ごちそうさま')
  if range
    puts line
  end
end
#=> ArgumentError: bad value for range

なぜなら、上のコードでは次のような範囲オブジェクトを作成しようとするからです。

range = true..false
#=> ArgumentError: bad value for range

if (line == 'いただきます')..(line == 'ごちそうさま')のようなコードは、あくまで「条件式としての範囲式」であり、範囲オブジェクトとは別物と考えるのが良さそうです。

参考:正規表現を使うロジック

今回はフリップフロップを説明するために、あえてフリップフロップを使いましたが、今回の要件であれば次のように正規表現を使う方法もあります。

puts text.scan(/いただきます.*?ごちそうさま/m)

.*?の意味が何なのかは、以下のQiita記事を参照してください。

初心者歓迎!手と目で覚える正規表現入門・その2「微妙な違いを許容しつつ置換しよう」 - Qiita

また、正規表現リテラルの最後に付いているmは「6.5.3 正規表現オブジェクト作成時のオプション」で説明しています。

次回予告

次回はいろんなハッシュの便利メソッドを紹介します。