🙄

Ruby/Railsの正規表現パターンマッチング実践

に公開

はじめに

個人開発でワイン関連のアプリをつくっています。
ワインのレビューをユーザーが入力するとき、ユーザーが入力する文字列は様々です。「Bordeaux」「ボルドー」「bordeaux」など、表記揺れに対応しつつ、適切な処理を行う必要があります。

この記事では、実際のRails APIコントローラーのコードを例に、正規表現を使った実践的なパターンマッチング手法を紹介します。

基本的なパターンマッチング

シンプルなケース

まずは基本的な例から見ていきましょう。

def generate_description_from_name(wine_name)
  name_lower = wine_name.downcase
  
  case name_lower
  when /bordeaux/
    "威厳"
  when /burgundy/
    "繊細"
  when /champagne/
    "華麗"
  else
    "個性的"
  end
end

このコードのポイント:

  • downcaseで小文字に統一し、大文字小文字を気にしない比較を実現
  • case文と正規表現の組み合わせで読みやすいコードに
  • パターンマッチには===演算子が使われる

複数の表記に対応する

実際のアプリケーションでは、英語表記と日本語表記の両方に対応する必要があります。

case name_lower
when /bordeaux|ボルドー/
  "威厳"
when /burgundy|bourgogne|ブルゴーニュ/
  "繊細"
when /champagne|シャンパーニュ/
  "華麗"
end

ポイント:

  • |(パイプ)で複数のパターンを指定
  • 英語名、フランス語名、日本語名をまとめて対応

より複雑なパターンマッチング

地名と品種の組み合わせ

ワイン名には地名や品種名が含まれることが多いです。より詳細なマッチングを見てみましょう。

case name_lower
when /bordeaux|margaux|medoc|ボルドー|マルゴー/
  ["威厳", "格調", "堂々", "風格"].sample
when /pinot.?noir|ピノ[・]?ノワール/
  ["エレガント", "繊細", "上品", "洗練"].sample
when /cabernet|カベルネ[・]?ソーヴィニヨン|カベルネ/
  ["重厚", "濃厚", "パワフル", "骨格"].sample
end

注目ポイント:

  1. .? - オプショナルな文字

    /pinot.?noir/  # "pinotnoir", "pinot noir", "pinot-noir"にマッチ
    
  2. [・]? - 中黒の有無に対応

    /ピノ[・]?ノワール/  # "ピノノワール"と"ピノ・ノワール"の両方にマッチ
    
  3. 複数の候補からランダム選択

    ["威厳", "格調", "堂々", "風格"].sample
    

    同じワイン産地でも毎回違う表現を返すことで、バリエーションを持たせる

国際化対応のテクニック

日本語の表記揺れに対応

日本語のワイン名には様々な表記揺れがあります。

when /dom.?perignon|ドン[・]?ペリ/
  "華麗"
when /gewurztraminer|ゲヴュルツ[・]?トラミネール/
  "香り高い"
when /savigny|savigny.les.beaune|サヴィニー[・]?レ[・]?ボーヌ/
  "エレガント"

対応している揺れ:

  • ドットの有無: dom.perignon vs domperignon
  • 中黒の有無: ドン・ペリ vs ドンペリ
  • 省略形: ドン・ペリ (フルネームは ドン・ペリニヨン)

フランス語のアクセント記号対応

フランス語のワイン名にはアクセント記号が含まれますが、入力時に省略されることがあります。

when /rose|rosé|ロゼ/
  "可憐"
when /cremant|crémant|クレマン/
  "軽やか"
when /millesime|millésime|ミレジム/
  "ヴィンテージ"

階層的なパターンマッチング

より一般的なパターンは後ろに配置することで、階層的なマッチングを実現できます。

case name_lower
# 具体的な銘柄
when /margaux|マルゴー/
  "格調"

# 地域
when /bordeaux|ボルドー/
  "威厳"

# ぶどう品種
when /cabernet|カベルネ/
  "重厚"

# 色
when /red|赤|rouge|ルージュ/
  "深い"

# 国
when /france|フランス/
  "エレガント"

# 最も一般的なフォールバック
else
  "個性的"
end

マッチングの優先順位:

  1. 最も具体的な銘柄名(例: シャトー・マルゴー)
  2. 地域名(例: ボルドー)
  3. ぶどう品種(例: カベルネ)
  4. ワインの色(例: 赤ワイン)
  5. 国名(例: フランス)
  6. デフォルト

この順序により、「Margaux Rouge」という名前なら、最初のmargauxパターンにマッチします。

実践的な実装例

実際のコントローラーで使用する完全な例です。

def generate_description_from_name(wine_name)
  name_lower = wine_name.downcase
  is_generic = false

  description = case name_lower
  # フランス高級ワイン産地
  when /bordeaux|margaux|medoc|ボルドー|マルゴー/
    ["威厳", "格調", "堂々", "風格"].sample
  when /burgundy|bourgogne|ブルゴーニュ/
    ["繊細", "優雅", "魅惑", "官能"].sample
  when /champagne|シャンパーニュ|dom.?perignon|ドン[・]?ペリ/
    ["華麗", "祝祭", "きらめき", "泡立ち"].sample
  
  # ぶどう品種
  when /pinot.?noir|ピノ[・]?ノワール/
    ["エレガント", "繊細", "上品", "洗練"].sample
  when /cabernet|カベルネ[・]?ソーヴィニヨン|カベルネ/
    ["重厚", "濃厚", "パワフル", "骨格"].sample
  when /chardonnay|シャルドネ/
    ["まろやか", "クリーミー", "豊潤", "バランス"].sample
  
  # ワインの種類
  when /sparkling|スパークリング/
    ["弾ける", "喜び", "躍る", "爽快"].sample
  when /red|赤|rouge|ルージュ/
    ["深い", "芳醇", "コクのある", "温かい"].sample
  when /white|白|blanc|ブラン/
    ["爽やか", "すっきり", "清らか", "軽やか"].sample
  
  # 国
  when /france|フランス/
    ["エレガント", "洗練", "気品", "伝統"].sample
  when /italy|italia|イタリア/
    ["陽気", "情熱", "温かい", "親しみやすい"].sample
  
  # どれにもマッチしない場合
  else
    is_generic = true
    ["個性的", "ユニーク", "印象的", "魅力的"].sample
  end

  [description, is_generic]
end

この実装のメリット:

  • 柔軟性: 様々な表記に対応
  • 拡張性: 新しいパターンの追加が容易
  • 可読性: case文で構造化されており理解しやすい
  • 国際化: 複数言語に対応

パフォーマンスの考慮

正規表現のコンパイル

頻繁に使用する正規表現は定数として定義することで、パフォーマンスを改善できます。

class WineDescriptionGenerator
  # 正規表現を定数化
  BORDEAUX_PATTERN = /bordeaux|margaux|medoc|ボルドー|マルゴー/i
  BURGUNDY_PATTERN = /burgundy|bourgogne|ブルゴーニュ/i
  CHAMPAGNE_PATTERN = /champagne|シャンパーニュ|dom.?perignon|ドン[・]?ペリ/i
  
  def self.generate(wine_name)
    name = wine_name.downcase
    
    case name
    when BORDEAUX_PATTERN
      ["威厳", "格調", "堂々", "風格"].sample
    when BURGUNDY_PATTERN
      ["繊細", "優雅", "魅惑", "官能"].sample
    when CHAMPAGNE_PATTERN
      ["華麗", "祝祭", "きらめき", "泡立ち"].sample
    else
      "個性的"
    end
  end
end

ポイント:

  • 正規表現リテラルは一度だけコンパイルされる
  • iフラグで大文字小文字を無視できる(downcaseが不要に)

マッチング順序の最適化

頻繁にマッチするパターンを上に配置することで、平均的なマッチング時間を短縮できます。

# よくマッチするパターンを上に
case name_lower
when /red|赤/        # 最も一般的
  "深い"
when /white|白/      # 2番目に一般的
  "爽やか"
when /margaux/       # 特定の銘柄(頻度低)
  "格調"
end

テストの書き方

正規表現を使ったコードは、エッジケースが多いためテストが重要です。

require 'rails_helper'

RSpec.describe 'WineDescriptionGenerator' do
  describe '#generate_description_from_name' do
    context 'ボルドーワインの場合' do
      it '英語名でマッチする' do
        result = generate_description_from_name('Bordeaux Rouge')
        expect(result[0]).to be_in(['威厳', '格調', '堂々', '風格'])
      end

      it '日本語名でマッチする' do
        result = generate_description_from_name('ボルドー赤')
        expect(result[0]).to be_in(['威厳', '格調', '堂々', '風格'])
      end

      it '大文字小文字を区別しない' do
        result = generate_description_from_name('BORDEAUX')
        expect(result[0]).to be_in(['威厳', '格調', '堂々', '風格'])
      end
    end

    context '表記揺れに対応する' do
      it '中黒の有無に対応する' do
        result1 = generate_description_from_name('ピノノワール')
        result2 = generate_description_from_name('ピノ・ノワール')
        
        expect(result1[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
        expect(result2[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
      end

      it 'スペースの有無に対応する' do
        result1 = generate_description_from_name('pinotnoir')
        result2 = generate_description_from_name('pinot noir')
        
        expect(result1[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
        expect(result2[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
      end
    end

    context 'マッチしない場合' do
      it 'デフォルト値を返す' do
        result = generate_description_from_name('Unknown Wine')
        expect(result[1]).to be true  # is_generic
        expect(result[0]).to be_in(['個性的', 'ユニーク', '印象的', '魅力的'])
      end
    end
  end
end

よくあるミスと対策

1. 正規表現の順序ミス

# ❌ 悪い例:一般的なパターンが先
case name
when /red/
  "赤ワイン"
when /bordeaux red/  # このパターンには到達しない!
  "ボルドー赤"
end

# ✅ 良い例:具体的なパターンが先
case name
when /bordeaux red/
  "ボルドー赤"
when /red/
  "赤ワイン"
end

2. エスケープ漏れ

# ❌ 悪い例:ドットがメタ文字として解釈される
when /dom.perignon/  # "domXperignon"にもマッチしてしまう

# ✅ 良い例:ドットをオプショナルに
when /dom.?perignon/  # "domperignon"と"dom perignon"にマッチ

3. 文字コードの問題

# ✅ ファイルの先頭でエンコーディングを指定
# frozen_string_literal: true
# encoding: utf-8

when /ブルゴーニュ/
  "繊細"

まとめ

正規表現を使ったパターンマッチングは、以下のような場合に非常に有効です:

  • 表記揺れへの対応: 英語/日本語、大文字/小文字、記号の有無など
  • 柔軟な検索: 部分一致や複数パターンの同時マッチング
  • 国際化対応: 複数言語の入力に対応

実装時のポイント:

  1. 具体的なパターンから一般的なパターンへ順序付ける
  2. .?[・]?で表記揺れに対応
  3. 定数化でパフォーマンスを改善
  4. テストでエッジケースをカバー

この手法は、ワインに限らず、商品名、地名、人名など、表記が統一されていないデータを扱う際に応用できます。

参考リンク

Discussion