Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 3.0でアドベント問題集を解く(2日目-1)パスワードの理念(翻訳)

概要

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

今回の出題: Day 2: Password Philosophy - Advent of Code 2020

Ruby 3.0でアドベント問題集を解く(2日目-1)パスワードの理念(翻訳)

この問題では、いくつかのパスワードを指定の基準で検証する必要があります。

1-3 a: abcde

2つの数字(訳注: 13)は、右端のパスワードに含まれるべき文字の数を示しています。上記の場合、abcde の中には a が1〜3回出現しているはずであり、それに該当すれば有効なパスワードです。

しかし以下のパスワードは、bが出現していないので無効です。

1-3 b: cdefg

以上を踏まえたうえで、以下の私の完全な解答例を追ってみましょう。

正規表現の短期養成講座

欲しいデータを得るための最も理想的な方法は、おそらく正規表現を使ってデータを解析することでしょう。その前に、いくつかの概念について簡単に説明し、 Rubularというサイトで実際に動かして確かめてみましょう。

完全なチュートリアルではなくクイックリファレンスですが、Rubyの正規表現についてひととおりのチュートリアルが欲しい場合は以下をご覧ください。

参考: Ruby regular expressions - working with regular expressions in Ruby

複数箇所にマッチさせる

言うまでもなく、正規表現の中にはリテラルシンボル(訳注: 通常の文字)も書けますが、「任意の文字(.)」「任意の数値(\d)」「小文字アルファベット([a-z])」「リテラルスペース文字(\s)」などを指定したい場合もあります。これらは入力形式の記述に使えるので、入力から必要なものを抽出できます。

また、直前の文字がゼロ回以上繰り返されることを表す*や、直前の文字が1回以上繰り返されることを表す+といったモディファイア(modifier: 修飾語)も使えます。

'abc'.match(/[a-z]+/)
# => #<MatchData "abc">

'012'.match(/[a-z]+/)
# => nil

グループをキャプチャする

私たちが使いたい正規表現の主な機能のひとつは、「入力の特定の部分をキャプチャして名前を付ける」という概念です。たとえば、以下のようにIPアドレスを(素朴に)解析したいとしましょう。

IP_REGEX = /(?<first>\d+)\.(?<second>\d+)\.(?<third>\d+)\.(?<fourth>\d+)/

'192.168.1.1'.match(IP_REGEX)
# => #<MatchData "192.168.1.1"
#   first:"192"
#   second:"168"
#   third:"1"
#   fourth:"1"
# >

実際にRubularで動かすと4つのグループが表示されますが、この(?<capture_name>captured_regex_value)では「抽出したい部分に名前を付けられる」という概念を用いています。以下のようにnamed_captures を使えばマッチデータからこれらの値を取得することも可能ですが、マッチしなかったときに nil が返る場合は、&.(訳注: Rubyの「ぼっち演算子」)を付けておくことをおすすめします。

'192.168.1.1'.match(IP_REGEX).named_captures
# => {"first"=>"192", "second"=>"168", "third"=>"1", "fourth"=>"1"}

ホワイトスペースの有無を区別しないようにする

正規表現の末尾にオプションを追加すると、動作を変更できます。たとえば、以下のようにx を付けるとホワイトスペース(訳注: 半角スペースとタブと改行の総称)を区別しなくなるので複数行を扱えますし、コメントを追加して正規表現の意図をわかりやすくすることもできます。

IP_REGEX = /
  (?<first>\d+)\.
  (?<second>\d+)\.
  (?<third>\d+)\.
  (?<fourth>\d+)
/x

考え方は同じですが、このように書くことで何が行われているかを読み取るのがずっと簡単になります。

正規表現を適用する

以上を踏まえると、以下のような正規表現になります。

PASSWORD_INFO = /
  # 行の冒頭
  ^

  # 入力全体を「input」でキャプチャする
  (?<input>

    # 文字のローカウントを取得して「low_count」でキャプチャする
    (?<low_count>\d+)

    # ダッシュ記号を無視する
    -

    # ハイカウントをキャプチャする
    (?<high_count>\d+)

    #リテラルスペース
    \s

    # 対象となる文字を検索する
    (?<target_letter>[a-z]):

    \s

    # 行の残りの部分がパスワードになる
    (?<password>[a-z]+)

  # 入力終了
  )

  # 行終了
  $
/x

ここでやりたいのは、「行全体」と「カウント、ターゲット文字、パスワード本体などの部分」を両方取りたいということです。上の正規表現を以下の2つの例にそれぞれ適用すると、以下の結果が得られます。

'1-3 a: abcde'.match(PASSWORD_INFO)
# => #<MatchData "1-3 a: abcde"
#   input:"1-3 a: abcde"
#   low_count:"1"
#   high_count:"3"
#   target_letter:"a"
#   password:"abcde"
# >

'1-3 b: cdefg'.match(PASSWORD_INFO)
# => #<MatchData "1-3 b: cdefg"
#   input:"1-3 b: cdefg"
#   low_count:"1"
#   high_count:"3"
#   target_letter:"b"
#   password:"cdefg"
# >

どうやらこれで問題を解けそうです。ただし取得したカウントは数値ではなく文字列であることに注意しましょう。

パスワードを抽出する

以下のようなパスワード抽出用ワンライナーを作ってみましょう。

def extract_password(line) =
    line.match(PASSWORD_INFO)&.named_captures&.transform_keys(&:to_sym)

これは技術的にはワンライナーではなく、どことなくPython風に見えます。手短に説明すると、Rubyのendレスメソッドには式または値を1つしか含めるべきではありません(2行目のチェインが式です)。

ホワイトスペースも自由に含められますので、今後コードをもう一度読み返したときに、ホワイトスペースで読みやすくしておいてよかったと思えることでしょう。

ところで、ここで唯一新しい部分はtransform_keysです。transform_keysは、すべてのStringキーをSymbolに変換します。その理由は、次のセクションでパターンマッチの可能性を示すためです。

文字を種類ごとに数える

ワンライナーは手っ取り早く機能を使うのに便利ですが、最終的にはパスワードの中にある1文字1文字の数を数えてくれるものも必要そうです。

原注: もっと効率的な方法はもちろんありますが、読者の演習課題としておきます。

ありがたいことに、Rubyのtallyメソッドはこんなときにうってつけです。

def letter_counts(word) = word.chars.tally

tallyは、コレクション内にある項目ごとのカウントを返します。

letter_counts 'aabbccc'
# => {"a"=>2, "b"=>2, "c"=>3}

tallyを使わないとしたら以下のようになっていたでしょう。

def letter_counts(word) =
  word.chars.each_with_object(Hash.new(0)) { |c, counts| counts[c] += 1 }

私はtallyが好きで割とよく使っています。以下の記事にも書いたように、DavidとShannonとStephがこのメソッドに命名するときに私もお手伝いいたしました。

参考: Ruby 2.7 — Enumerable#tally. Enumerable#tally is a new function… | by Brandon Weaver | Medium

有効なパスワード

これで、パスワードが有効かどうかの確認に必要なツールが揃ったので、関数自体を見てみましょう。

def valid_passwords(input)
  input.filter_map do
    extracted_password = extract_password(_1) or next
    extracted_password => {
      input:, low_count:, high_count:, target_letter:, password:
    }

    low_count, high_count = low_count.to_i, high_count.to_i
    count = letter_counts(password)[target_letter]

    input if (low_count..high_count).include?(count)
  end
end

何やらいろいろやっていますが、順に見ていくことにします。

filter_map

filter_mapはその名のとおり、filtermapを一度に行える楽しいメソッドです。コードでは、正しい要素だけを結果に残すのに使っています。

[1, 2, 3].filter_map { _1 * 2 if _1.even? }
# => 4

ここでは必ずしもfilter_mapを使わなくても、selectでできます。しかし「パスワードそのものの抽出」「マッチしたデータの保持」「フィルタによる無効な入力の除外」をまとめて行いたい場合は本当に便利です。

orってもしかしてPerlの?

はい、私は必要であれば英語のor演算子を使います。今回はまさにそうしたケースです。抽出したパスワードがnilであれば早期に脱出してよいことがわかります。以下のorはPerl由来の「早期復帰」や「例外処理」テクニックで、Rubyにも取り入れられています。

line = gets or raise 'error!'

以前も述べましたが、私は「左から右に読み下せる」ことを特に重視しているので、業務用コードではorandの利用を避けています。

右代入ロケット演算子=>によるワンライナーパターンマッチング

ここがお楽しみの部分です。

// JavaScript
{ a, b } = { a: 1, b: 2 }
// => a = 1, b = 2

以下は、上のJavaScriptの分割代入に似ていますが、代入の向きが逆です。これは右代入(RHA: Right Hand Assignment)と呼ばれています。

# Ruby
extracted_password => {
 input:, low_count:, high_count:, target_letter:, password:
}

キーをシンボルに割り当てていた理由は、この機能をお見せするためだったのです。これが デフォルトでObject#sendに実装されていればよかったのにとしみじみ思います。もしそうなっていたら、以下のように書けたことでしょう。

person_object => { name:, age: }

残念ながらそうはいきませんでしたが。しかし、このパターンには「===に応答するバリデーションも含まれる」のがうれしい点です。===の面白さについては以下の記事をご覧ください。

参考: Triple Equals Black Magic. For the most part, === is either… | by Brandon Weaver | Ruby Inside | Medium

それでは=>の利用例を見ていきましょう。

{ a: 1, b: 2, c: 'foo' } => { a: Integer, b: 2, c: }

注意!これは実験的機能につき、微妙なバグや妙な挙動が少々あります。たとえば、上はローカル変数cしか定義されません。ローカル変数を3つとも定義するには以下のように書く必要があります。

{ a: 1, b: 2, c: 'foo' } => {
  a: Integer => a, b: 2 => b, c:
}

これはバグだと思いますので、そのうちレポートするつもりです。

残りを駆け足で解説

訳注

原文見出し「The Rest of the Owl」は、直接的には「フクロウの絵の残り(を最後まで描いてください)」という意味ですが、チュートリアルなどで詳しく作業を説明せずに実践するときの定番の言い回しです。

参考: Draw the rest of the owl now : funny

この関数の他の部分では、さほど珍しいことはやっていません。

low_count, high_count = low_count.to_i, high_count.to_i
count = letter_counts(password)[target_letter]

input if (low_count..high_count).include?(count)

ここではカウントの指定と実際のカウントを比較したいと思います。単語単語内にあるそれぞれの文字カウントを取得して、それが期待の範囲に収まるかどうかをチェックします。

文字カウントが期待の範囲内に収まる場合はinputを返し、そうでない場合は戻り値から除外されます。

有効なパスワードカウント

他のアドベント問題と同じように、上の関数を別の関数でラップすることで有効なパスワードのカウントを取得できるようになります。

def valid_password_count(...) = valid_passwords(...).size

繰り返しますが、私はこのように2つのアイデアを切り離しておくことでデバッグしやすくする方法がとても気に入っています。

入力を読み取る

これでAdvent of Codeで出題された入力を解析できるようになります。

File.readlines(ARGV[0]).then { puts valid_password_count(_1) }

この行は、今後Advent of Codeの問題を解くときに大きく変わることはないでしょう。

(その2に続く)

関連記事

はじめての正規表現とベストプラクティス1: 基本となる8つの正規表現


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。