この初版はLua 5.0向けに書かれています。後のバージョンにも概ね該当しますが、いくつかの違いがあります。
第4版はLua 5.3を対象としており、Amazonや他の書店で入手できます。
本書をご購入いただくことで、Luaプロジェクトへの支援にもなります。


20.4 – 実用的なテクニック

パターンマッチングは、文字列を操作するための強力なツールです。 string.gsubfind を数回呼び出すだけで、多くの複雑な操作を実行できます。しかし、どんな力にも言えることですが、注意して使用する必要があります。

パターンマッチングは、適切なパーサーの代わりになるものではありません。手軽なプログラムであれば、ソースコードで有用な操作を行うことができますが、品質の高い製品を構築することは困難です。良い例として、Cプログラムのコメントを一致させるために使用したパターン '/%*.-%*/' を考えてみましょう。プログラムに "/*" を含む文字列があると、誤った結果が得られます。

    test = [[char s[] = "a /* here";  /* a tricky string */]]
    print(string.gsub(test, "/%*.-%*/", "<COMMENT>"))
      --> char s[] = "a <COMMENT>
このような内容の文字列はまれであり、個人使用であれば、そのパターンはおそらく機能するでしょう。しかし、そのような欠陥のあるプログラムを販売することはできません。

通常、パターンマッチングはLuaプログラムにとって十分に効率的です。Pentium 333MHz(今日の基準では高速なマシンではありません)では、20万文字(3万語)のテキスト内のすべての単語を一致させるのに10分の1秒もかかりません。しかし、予防措置を講じることはできます。常にパターンをできるだけ具体的にする必要があります。あいまいなパターンは、具体的なパターンよりも遅くなります。極端な例は、最初のドル記号までの文字列内のすべてのテキストを取得するための '(.-)%$' です。対象の文字列にドル記号があれば、すべてうまくいきます。しかし、文字列にドル記号が含まれていないとします。アルゴリズムは、まず文字列の最初の位置からパターンを一致させようとします。文字列全体を調べて、ドルを探します。文字列が終了すると、文字列の*最初の位置*でパターンが失敗します。次に、アルゴリズムは文字列の2番目の位置から再び検索全体を実行し、パターンがそこでも一致しないことを発見します。以下同様です。これは2次関数的な時間を要し、Pentium 333MHzでは20万文字の文字列に対して3時間以上かかります。この問題は、'^(.-)%$' を使用して、パターンの先頭を文字列の最初の位置に固定することで簡単に修正できます。アンカーは、最初の位置で一致が見つからない場合、検索を停止するようにアルゴリズムに指示します。アンカーを使用すると、パターンは10分の1秒未満で実行されます。

また、*空の*パターン、つまり空の文字列と一致するパターンにも注意してください。たとえば、'%a*' のようなパターンで名前を一致させようとすると、どこにでも名前が見つかります。

    i, j = string.find(";$%  **#$hello13", "%a*")
    print(i,j)   --> 1  0
この例では、string.find の呼び出しは、文字列の先頭にある空の文字シーケンスを正しく見つけています。

修飾子 `-´ で開始または終了するパターンを記述することは、空の文字列にしか一致しないため、意味がありません。この修飾子は、展開を固定するために、常に周囲に何かが必要です。同様に、'.*' を含むパターンは、この構造が意図したよりもはるかに大きく展開する可能性があるため、注意が必要です。

場合によっては、Lua自体を使用してパターンを構築すると便利です。例として、テキスト内の長い行、たとえば70文字を超える行をどのように見つけることができるかを見てみましょう。長い行とは、改行とは異なる70文字以上のシーケンスです。文字クラス '[^\n]' を使用して、改行とは異なる単一の文字を一致させることができます。したがって、1文字のパターンを70回繰り返した後に、それらの文字が0個以上続くパターンと一致させることで、長い行を一致させることができます。このパターンを手書きする代わりに、string.rep を使用して作成できます。

    pattern = string.rep("[^\n]", 70) .. "[^\n]*"

別の例として、大文字と小文字を区別しない検索を行いたいとします。そのための1つの方法は、パターン内の任意の文字 *x* をクラス '[xX]'、つまり元の文字の大文字と小文字の両方を含むクラスに変更することです。関数を使用して、その変換を自動化できます。

    function nocase (s)
      s = string.gsub(s, "%a", function (c)
            return string.format("[%s%s]", string.lower(c),
                                           string.upper(c))
          end)
      return s
    end
    
    print(nocase("Hi there!"))
      -->  [hH][iI] [tT][hH][eE][rR][eE]!

場合によっては、どの文字も特殊文字と見なすことなく、s1 のすべてのプレーンな出現を s2 に変更したい場合があります。文字列 s1s2 がリテラルである場合、文字列を記述するときに特殊文字に適切なエスケープを追加できます。ただし、これらの文字列が変数値である場合は、別の gsub を使用してエスケープを挿入できます。

    s1 = string.gsub(s1, "(%W)", "%%%1")
    s2 = string.gsub(s2, "%%", "%%%%")
検索文字列では、すべての英数字以外の文字をエスケープします。置換文字列では、`%´ のみをエスケープします。

パターンマッチングのもう1つの有用なテクニックは、実際の作業の前に対象の文字列を前処理することです。前処理の使用の簡単な例は、テキスト内のすべての引用符で囲まれた文字列を大文字に変更することです。ここで、引用符で囲まれた文字列は二重引用符(`"´)で開始および終了しますが、エスケープされた引用符("\"")が含まれている場合があります。

    follows a typical string: "This is \"great\"!".
このようなケースを処理するための私たちのアプローチは、問題のあるシーケンスを他のものに変換するようにテキストを前処理することです。たとえば、"\"""\1" としてコーディングできます。ただし、元のテキストにすでに "\1" が含まれている場合は、問題が発生します。エンコーディングを行い、この問題を回避する簡単な方法は、すべてのシーケンス "\x""\ddd" としてコーディングすることです。ここで、*ddd* は文字 *x* の10進数表現です。
    function code (s)
      return (string.gsub(s, "\\(.)", function (x)
                return string.format("\\%03d", string.byte(x))
              end))
    end
これで、エンコードされた文字列内のシーケンス "\ddd" はすべてコーディングに由来する必要があります。元の文字列内の "\ddd" もすべてコーディングされているためです。そのため、デコードは簡単な作業です。
    function decode (s)
      return (string.gsub(s, "\\(%d%d%d)", function (d)
                return "\\" .. string.char(d)
              end))
    end

これで、タスクを完了できます。エンコードされた文字列にはエスケープされた引用符("\"")が含まれていないため、'".-"' を使用して引用符で囲まれた文字列を簡単に検索できます。

    s = [[follows a typical string: "This is \"great\"!".]]
    s = code(s)
    s = string.gsub(s, '(".-")', string.upper)
    s = decode(s)
    print(s)
      --> follows a typical string: "THIS IS \"GREAT\"!".
または、よりコンパクトな表記法では、
    print(decode(string.gsub(code(s), '(".-")', string.upper)))

より複雑なタスクとして、\command{string} として記述されたフォーマットコマンドをXMLスタイルに変更する、プリミティブなフォーマットコンバーターの例に戻りましょう。

    <command>string</command>
しかし、今回、元のフォーマットはより強力になり、バックスラッシュ文字を一般的なエスケープとして使用するため、"\\""\{"、および "\}" と記述することで、文字 `\´、`{´、および `}´ を表現できます。パターンマッチングでコマンドとエスケープ文字が混同されないように、元の文字列でこれらのシーケンスを再コーディングする必要があります。ただし、今回はすべてのシーケンス \x をコーディングすることはできません。それは、コマンド(\command として記述)もコーディングしてしまうためです。代わりに、*x* が文字でない場合にのみ \x をコーディングします。
    function code (s)
      return (string.gsub(s, '\\(%A)', function (x)
               return string.format("\\%03d", string.byte(x))
             end))
    end
decode は前の例と似ていますが、最終的な文字列にバックスラッシュが含まれていません。したがって、string.char を直接呼び出すことができます。
    function decode (s)
      return (string.gsub(s, '\\(%d%d%d)', string.char))
    end
    
    s = [[a \emph{command} is written as \\command\{text\}.]]
    s = code(s)
    s = string.gsub(s, "\\(%a+){(.-)}", "<%1>%2</%1>")
    print(decode(s))
      -->  a <emph>command</emph> is written as \command{text}.

ここで紹介する最後の例は、*カンマ区切り値*(CSV)です。これは、Microsoft Excelなどの多くのプログラムでサポートされている、表形式データを表すテキスト形式です。CSVファイルはレコードのリストを表し、各レコードは1行に記述された文字列値のリストであり、値の間にカンマがあります。カンマを含む値は二重引用符で囲む必要があります。そのような値にも引用符が含まれている場合、引用符は2つの引用符として記述されます。例として、配列

    {'a b', 'a,b', ' a,"b"c', 'hello "world"!', ''}
は次のように表すことができます。
    a b,"a,b"," a,""b""c", hello "world"!,
文字列の配列をCSVに変換するのは簡単です。文字列を連結し、間にカンマを入れるだけです。
    function toCSV (t)
      local s = ""
      for _,p in pairs(t) do
        s = s .. "," .. escapeCSV(p)
      end
      return string.sub(s, 2)      -- remove first comma
    end
文字列にカンマまたは引用符が含まれている場合は、引用符で囲み、元の引用符をエスケープします。
    function escapeCSV (s)
      if string.find(s, '[,"]') then
        s = '"' .. string.gsub(s, '"', '""') .. '"'
      end
      return s
    end

CSVを配列に分割するのはより困難です。引用符で囲まれたカンマとフィールドを区切るカンマを混同しないようにする必要があるためです。引用符で囲まれたカンマをエスケープしようとすることができます。ただし、すべての引用符文字が引用符として機能するわけではありません。カンマの後の引用符文字のみが開始引用符として機能します。ただし、カンマ自体がカンマとして機能している場合に限ります(つまり、引用符で囲まれていない場合)。微妙な点が多数あります。たとえば、2つの引用符は、単一の引用符、2つの引用符、または何も表さない場合があります。

    "hello""hello", "",""
この例の最初のフィールドは文字列 "hello"hello"、2番目のフィールドは文字列 " """(つまり、スペースの後に2つの引用符が続く)、最後のフィールドは空の文字列です。

複数の gsub 呼び出しを使用してこれらのすべてのケースを処理しようとすることができますが、フィールドを明示的にループする、より従来型のアプローチを使用する方がこのタスクをプログラムする方が簡単です。ループ本体の主なタスクは、次のカンマを見つけることです。また、フィールドの内容を表に格納します。各フィールドについて、フィールドが引用符で始まるかどうかを明示的にテストします。そうであれば、終了引用符を探すループを実行します。このループでは、パターン '"("?)' を使用してフィールドの終了引用符を見つけます。引用符の後に別の引用符が続く場合、2番目の引用符はキャプチャされ、c 変数に割り当てられます。これは、まだ終了引用符ではないことを意味します。

    function fromCSV (s)
      s = s .. ','        -- ending comma
      local t = {}        -- table to collect fields
      local fieldstart = 1
      repeat
        -- next field is quoted? (start with `"'?)
        if string.find(s, '^"', fieldstart) then
          local a, c
          local i  = fieldstart
          repeat
            -- find closing quote
            a, i, c = string.find(s, '"("?)', i+1)
          until c ~= '"'    -- quote not followed by quote?
          if not i then error('unmatched "') end
          local f = string.sub(s, fieldstart+1, i-1)
          table.insert(t, (string.gsub(f, '""', '"')))
          fieldstart = string.find(s, ',', i) + 1
        else                -- unquoted; find next comma
          local nexti = string.find(s, ',', fieldstart)
          table.insert(t, string.sub(s, fieldstart, nexti-1))
          fieldstart = nexti + 1
        end
      until fieldstart > string.len(s)
      return t
    end
    
    t = fromCSV('"hello "" hello", "",""')
    for i, s in ipairs(t) do print(i, s) end
      --> 1       hello " hello
      --> 2        ""
      --> 3