極めよRuby道

第4回 テキスト処理の実例

後藤謙太郎 <URL:http://www.notwork.org/~gotoken/mag/cmagazine/>

註: この文書は『C MAGAZINE』2000年10月号 に掲載された記事の元となるものに手を加えたものです.

記事中のプログラムを一つずつファイルにしたものもあります(→list)

中田さんのおかげでNetscapeでも読めるようになりましたが,まだちょっとカッコ悪いです。「CSSの書き方がなっとらん、こう書け」というお便りは歓迎します。

間違いを見つけたら,gotoken@notwork.org宛に御連絡くださると喜びます。

Copyright(c) 2000 by GOTO Kentaro. All rights reserved.


こんにちわ, RD ってなに? ( RD の規則, RDの強み ), とりあえず RD を抜き出す, 作戦を立てよう ( 大まかな流れ ), ブロック解析, ( インターフェイス ), 実装 ( メインループ, 各ブロックの処理, 雑関数, 構文木の詳細 ), まとめ, 謝辞, 参考資料


こんにちわ

ごとけんです。もうすぐ11月。秋といえば紅玉りんごの旬ですね。紅玉は宝石 rubyの訳語でもあるわけですが、先月、難産の末にようやく Ruby 1.6.0 がリリースされました。すでにお使いの方も多いと思います。てなわけで、今回からこの連載のターゲットは 1.6 系の最新版とします。まだ1.4系をお使いの方はこの機会に是非入れ換えることをお勧めします。1.6の詳細は近く出版されるリファレンスマニュアルなどでも知ることができますけど、せっかくなのでここで新機能を1つだけ紹介しましょう。

これまでRubyには「あるクラスのインスタンス間で共有される変数」、いわゆるクラス変数がありませんでした。そこで定数コンテナ*1で代用していたわけですが、ついにクラス変数が導入されました。Ruby の変数の接頭辞はスコープを表すものになっていますが、クラス変数の接頭辞には「@@」が割り当てられました。これはインスタンス変数との類似性や、使用頻度を考えての結果です。ありがちだけどシンプルな例をList 1に挙げておきます。

-- List1 objnum.rb: インスタンスの個数をクラス変数で

  class Foo
    @@cnt = 0

    def initialize
      @@cnt += 1
    end

    def n
      @@cnt
    end
  end

  if __FILE__ == $0
    p Foo.new.n #=> 1
    p Foo.new.n #=> 2
  end

それと重要な変更点の一つに、デフォルトの文字コードが none、つまり「無し」になったことが挙げられます。自分のRubyのデフォルトの文字コードを次のようにして調べておきましょう。文字コードの詳細はこの連載の第1回(Cマガ8月号)を御覧ください。

% ruby -e 'p $KCODE'
"NONE"

ところで 1.6 リリースを記念するわけではありませんが、11月の29,30日に Perl/Ruby Conference 2000 という集会が京都で開催されます[PRC]。ぼくも参加する予定でして、たくさんのRuby好きのみなさんにお会いできるのをいまから楽しみにしています。

さてさて、この連載ではこれまで、どちらかといえば断片的な事柄ばかりを紹介してきたんですが、風にのって聞こえてきた読者の声に従って今回はモノを作ることをゴールにしたいと思います。確かに実際のプログラムを見ないと分かりにくいってとこはありますよね。そして、利用頻度を考えてテキスト処理を題材にとった次第です。あ、何を作るかというと、RDの変換器です。

RD ってなに?

RD とは Ruby Document の略で、Rubyのプログラムに埋め込まれたコメントの書き方です。HTMLやRoffなどの他のマークアップ言語に変換することを狙っていて、このおかげで、プログラムのなかにマニュアルのソースを読みやすい形で含めることができます。Perlユーザには「PODのようなもの」といえば分かっていただけるかも知れません。

RD も Ruby の作者のまつもとさんがはじめた書き方で、POD とPlain2 を参考にして可読性と書きやすさを重視したものになっています。大雑把にいうと、見出しを付けること、箇条書き、プログラムの貼り付けだけができます。また Plain2に由来する書式を使って強調なども用意されています。機能が単純なので規則もシンプルなことから、普通の文書を書くのにもなかなか便利です。そんなわけで実はこの原稿も RD で書いています。

RD の規則

RD の規則は RDtool *2 の作者でもある Tosh さんが提案文書の形でまとめてくれています[JARS]。簡単に紹介しましょう。ここではブロックに話を絞って、RDで書いたRDの要約(List 2) を示します。たとえば「= RDとわ」と書いてあるのは、「=」ではじまるので見出しで、「=」が1個なので最も大きな見出し、HTMLでいえばH1要素に相当します。次の行はTextBlockといって普通の文(HTMLのP要素相当)です。また、「*」ではじまるのは見てのとおり箇条書です。

-- List2 rdsample.rb: RDで書いたRDの要約
 =begin
 = RDとわ

 Rubyのドキュメントの書き方です。
 従うといろいろ便利です。

 == 規則

 だいたい次のとおりです。

 * 「=begin」と「=end」の間に書く。
 * ブロックの並びからなる。
 * ブロックには次の7つがある:
   (1) Headline (見出し)
   (2) Include (差し込み)
   (3) TextBlock (段落)
   (4) Verbatim (そのまま)
   (5) ItemList (箇条書き)
   (6) EnumList (番号付箇条書き)
   (7) DescList (小見出付箇条書き)
 * リストはその名を関した項目の列
   (例:ItemListItemはListItemの列)。
 * MethodListもあるが、現在
   改良の余地が検討されている。
 * List系ブロックは入れ子にできる。
   範囲はインデントで表現。
 * 空行が段落の区切り。
 * 「=」で始まる行は見出し。
 * 「*」で始まる行は箇条書。
 * 「(1)」などで始まる行も箇条書。
 * インデントされてるのは貼り付け。
 * ブロックにはインラインが使える。
 * インラインは ((...)) のような
   形をしている。

 == ブロックの表し方

 先頭の文字で決まります。

  : Headline
    等号(=)ではじまる。
    先頭に空白は許されない。
    続けて見出し自体を書く。
    =の数で見出しの深さを表す。
  : Include
    三つの小なり(<<<)にファイル名
  : ItemListItem
    星(*)ではじめる。
  : DescListItem
    コロン(:)ではじめる。その
    後ろは見出し(Term)で、次の行
    からが説明文(Description)に
    なる。
  : EnumListItem
    「(1)」のように数字を括弧で
    くくる。
  : Verbatim
    余分にインデントされた行。
  : TextBlock
    余分なインデントがなくて上に
    該当しないものは地の文と
    みなされる。
 =end

List 2 をみて分かるようにフォーマットしなくてもとても読みやすいものです。詳しくみてみると、RDのかたまり(ブロック要素) は空行でない次の行の先頭部分を見る事で終りが来たかを判定できることも分かります。

ところで、このRDのような英文と和文の混在するテキストの入力にはSKK、ゆでたまご*3あるいはT-Codeのような方法が便利だと思います。

RDの強み

RDのメリットはいろいろあるのですが、たとえばHTMLとくらべるとタグに相当するものをを打つことがとても少ないうえに、テキストの見た目が構造を表現するので、文法的な間違いを犯すことが滅多にないことが上げられます。

RDはあらかじめ機能を限定しているので、RDだけでなんでもできるわけではないけれど、その分、入力も容易だし利用者が覚えることはとても少なくてすみます。他のフォーマットに対する一種のオーサリングツールと思ってもよいかも知れません。例えば配布用のXML文書をRDから生成するということも考えられます。

ただし、RDの文法要素には名前がついてはいるものの、具体的にどう変換するかはユーザに任せることになっていてます。しかしそれほど遠くない将来、 Rubyに標準のRD変換器が添付されると思われます。

とりあえず RD を抜き出す

さて、まずは「=begin」と「=end」に挟まれた部分を抜き出す所からはじめましょう。やり方はいろいろあると思いますが、ここでは1行ずつ読むことにし、いまRD を読んでいるのか、プログラムの部分を読んでいるのかを表す変数を使って切り分けてみます。

List 3 は、Rubyプログラムを読んで、RD の部分だけを出力するモノです。 where がいま読んでいるのがRDなのかどうかを表しています。先月号で紹介したようにRubyの case=== を使って判定するのでいろいろな場合わけに使えます。

-- List3 getrd.rb: 入力ファイルの=beginと=endのあいだを抜き出す
  #!/usr/bin/env ruby
  state = :Ruby

  while line = gets
    case where
    when :Ruby
      next if /^=begin/ !~ line
      where = :RD
    when :RD
      case line
      when /^=end/
        where = :Ruby
      else
        puts line
      end
    end
  end

ここで :RD:Ruby というのが登場しますが、これは Symbol オブジェクトのリテラルです。Ruby 1.4 ではこのリテラルはインタプリタ内部で対応する整数を表していましたが、1.6 では Symbol という別のクラスになりました。List 3 では String を使ってもいいんだけど、文字列である必要もないので Symbol を使ってます。

作戦を立てよう

List 2 をどうやって発展させればよいんでしょう。case line のところをどんどん拡大する? うーん、なんか大変そうです。やらないといけないことをすこし落ち着いて考えてみましょう。

まずブロックの解析はインデントを見ていけば良いでしょう。一行ごとに見ていくなら、いまどのブロックを見ていたかをスタックに積んでいくのが良さそうです。

一方インラインはどうすれば良いでしょうか。例えばList 4はインラインを含む文字列を分解するものです。ここでの分解とは [種類, 文字列] の形をした配列を要素とする配列を返すこととしています。ここでやっているようにクラスデザインをする場合にとりあえず配列で組むことが出来ることもありますね。粒度*4 によっては配列のままでもいいかも知れません。

untilの中身を少し説明しておきましょう。パターンにマッチした場合(if pat =~ buf)は、まずマッチした箇所より前の部分 $' がカラでなければその部分の種類を :String として結果の配列 res 格納します。それからどのパターンにマッチしたかによって場合わけし、そのインライン要素の種類を表すシンボルと、マッチした文字列からなる配列を res に突っ込み、残り($')を buf に再設定します。この buf がカラになるまで (until buf.empty?) は繰り返します。マッチしなかった場合(else)は、そのなかにはもうインラインはないわけですから、その時点の buf すべてを :String) としています。

もっともここまで厳格にやる必要はないかも知れません。もしも構造を返す必要がなく、さらにインラインが入れ子にならないという制限がついていれば、 String#gsub! で十分です。逆にこれでは足りないのかも知れません。たとえば相互参照*5を実現するには文書内部を参照する場合には2-passは必要でしょうし、外部を参照する場合はとくにマニュアルなどはURLよりも簡便な方法が欲しいものです。

-- List4 inlineperser0.rb: インライン解析の雛型

   def inlineperser0(str)
     o = [
       '\{\s*(.+?)\s*\}',
       '%\s*(.+?)\s*%',
       '-\s*(.+?)\s*-',
       '\*\s*(.+?)\s*\*'
     ].join('|')
     pat = /\(\((?:#{o})\)\)/m

     res = []
     buf = str.dup
     until buf.empty?
       if pat =~ buf
         unless $`.empty?
           res.push [:String, $`]
         end

         if $1
           res.push [:Code, $1]
         elsif $2
           res.push [:Kbd, $2]
         elsif $3
           res.push [:Note, $3]
         elsif $4
           res.push [:Em, $4]
         end

         buf = $'
       else
         res.push [:String, buf]
         break
       end
     end
     res
   end

図5に実行例を示しておきます。

-- 図5 List 4の実行例(zshの場合)

  % ruby -Ke -r inlineperser0.rb -e '
  elements = inlineperser0 <<EOF
  「(({cat}))」と書きます。また、コマンドラインに入力する cat は
  「((%cat%))」と書けば区別されます。また強調は「((*強調*))」
  のように書き、注を要れるときは「RD((-Ruby Documentの略-))」のよ
  EOF
  elements.each{|i| p i}
  '
  [:String, "「"]
  [:Code, "cat"]
  [:String, "」と書きます。また、コマンドラインに入力する cat は\n「"]
  [:Kbd, "cat"]
  [:String, "」と書けば区別されます。また強調は「"]
  [:Em, "強調"]
  [:String, "」\nのように書き、注を要れるときは「RD"]
  [:Note, "Ruby Documentの略"]
  [:String, "」のよ\n"]
  %

大まかな流れ

以上の考察から今回はRubyだけで出来ることにします。戦略としては、RDtool にならってRDからまず中間表現に変換し、その中間表現から各種のフォーマットに変換するという手法をとりましょう。こうすると複数のフォーマットをターゲットにするとき、再利用が楽になります。将来インラインの解析をRaccに乗り変えて拡張できるような作りにしておきたい気もしますので、中間表現を得る際には入力テキストをブロック単位に分解してから、インラインを処理することにします(図6)。

-- 図5 処理の流れ

             +----+
             | RD |
             +----+
               ↓
        ( ブロック解析 )
               ↓
       ( インライン解析 )
               ↓
   +------------------------+
   | 中間表現としての構文木 |
   +------------------------+
      ↓         ↓     
   +------+  +-------+  ‥‥
   | HTML |  | LaTeX |  
   +------+  +-------+

それから名前も考えておきましょう。rd2という名前は紛らわしいので、rd2を日本語に訳して rdo とします。

ブロック解析

List 2 で見たような文字列にブロックの開始は、行頭の文字と、インデントで分かります。一方、ブロックの終りは現在のブロックが何であるかによります。例えば、次の例は2つの段落です。

-----------------------------------
これは文です。

これも文です。
-----------------------------------

次の例は1つの段落と1つのVerbatimです。

-----------------------------------
短いスクリプトを見ていましょう。

    #! /usr/bin/env ruby

    puts "おしゃべり!おしゃべり!"
-----------------------------------

このようにVerbatimの場合は空行で終りにしたくありません。Verbatimはインラインも使わないですし特別扱いしたほうが良さそうです。

最後にリストをみてみましょう。

-----------------------------------
* これはItemListItemです。

  ここはItemListItemの続きで
  次の段落でしょう。

    ここはItemListItemの中の
    Verbatim ですね。

* これは次のItemListItemです。
-----------------------------------

リストの場合は、リストを表す目印「*」、「(1)」、「:」の次に来る文字の位置を基準にインデントの勘定が補正されます。この「目印の次に来る文字の位置」はBaselineと呼ばれています。終る条件はBaselineよりも浅いインデントが行なわれたときとなります。また一般化するために各リストの項目は、目印にブロック要素がひっついたものとみなし、TextBlock にも Baseline があると考えます。

また、やや面倒な問題としては、空白文字 (/\s/) に関連することです。まず、/^$//^\s+$/ にそれぞれマッチする行たちを区別するか否かという問題があります。これらの行はテキストエディタの上では区別がつかないことが多いですから、とりあえず区別しないことにしましょう。それとタブ文字と、スペース文字の区別も厄介です。とりあえず行頭の空白文字に含まれるタブ文字は適宜、指定された長さのスペース文字に置き換えることにします。

インターフェイス

複数のフォーマッタに出力できるようにする場合、出来るだけ簡単にフォーマッタを書きたいですね。ただ、相互参照を行なうようにする場合は実際はフォーマッタといってもラベルをブロック解析の際に仕込んだりする必要があります。このような機能もここでは割愛します。

そうすると、フォーマッタはList 6程度で書けるようになるわけです。この例では、一番最後の行が実行文でARGFをBlockParserに読ませ、parseし、その結果を RD2HTML クラスのオブジェクトでformatさせようとしています。ここで、formatの引数は3つのメソッドopenclosewriteを持つと仮定してます。前回みたようにメソッド名をフックにするというのはオブジェクト指向の典型的なやりかたでしょう。

なお、write の引数 arg に渡されるのは葉の各行を要素とする配列です。インライン処理はここで行なえます。1行で完結して入れ子になっていないように限定されたインライン処理は単に正規表現を考えて、 gsub! するだけですから宿題としましょう。

-- List6 rdohtml.rb: RDをHTMLのBASE要素の中身に変換

  #!/usr/bin/env ruby
  require "rdo/bparse"

  class RD2HTML
    include RDElements

    MAP = {
      Headline1 => "H1",
      Headline2 => "H2",
      Headline3 => "H3",
      TextBlock => "P",
      Verbatim => "PRE",
      ItemList => "UL",
      EnumList => "OL",
      DescList => "DL",
      ItemListItem => "LI",
      EnumListItem => "LI",
      DescListItem => nil,
      Term => "DT",
      Desc => "DD"}

    def open(elm)
      if MAP[elm]
        print "<#{MAP[elm]}>"
      end
    end

    def close(elm)
      if MAP[elm]
        print "</#{MAP[elm]}>\n"
      end
    end

    def write(arg)
      print arg.join
    end
  end

  RDBlockParser.new(ARGF).parse.
    format(RD2HTML.new)

実装

さてすでに説明したブロック規則を実装したのが List 7〜12 です。少々長いので、いくつかのファイルに分割しています。

メインループ

まず List 7 はコアとなる部分で、主要なメソッドparseを持った RDBlockParserを定義しています。initializeは、引数に IO とタブ文字の幅を指定しています。その他に解析に必要な3つのスタックと、 List 3と同じ役割の @where、 そして結果を書き込む@treeを用意しています。

parseは慣用句「while gets」を使ったループで、各行毎にインデントが浅くなっていないかを調べてから、正規表現でブロックの種類を決定し、その処理をブロック要素名のメソッドで呼び出しています。もう少し詳しく見ると、インデントの深さはこれまでのBaselineをためてあるスタック @baseのうちから、どこまでブロックを閉じる必要があるかを find で探しています。つまり、インデントがこれまでのBaselineよりも浅いところpos) を探しているわけです。posが見つかればその深さまでのブロックをすべて閉じます。findのような良く使う処理が標準装備なのはうれしいことです。

parseの中でelseに続くVerbatimの例外的に処理メソッド verbatim のなかでブロックを閉じる処理を行なっているため redocaseを始めから再度評価しなおしています。

スタックで管理するのはインデント、Baseline、それから開いている余分なインデントの位置です。余分なインデントは、List系ブロックに許されています。これらのスタック操作は散在すると分かりにくくなりますから、ブロック要素を開閉するプライベートメソッドopencloseだけで操作するようにしています。openを呼び出している箇所はあとで掲げる各ブロック処理です。

ところで parse では textblock に対応するパターンの中で base という関数を使っていますが、open では同名の引数を使っています。このような場合、まず変数が検索され、そのあとメソッドが検索されます。もしカブっている場合は、メソッドの方は ((self.base)) と書けば呼び出せます。ただし、プライベートメソッドでは「self.」などと陽にレシーバを指定できませんから、そういう場合は名前を工夫するのがよいのですが、場合によってはプロテクトメソッド*6にするのが適切かも知れません。

-- List7 rdo/bparse.rb: ブロック解析器のメインループとスタック操作

  require "rdo/rdolib"
  require "rdo/block"

  class RDBlockParser
    def initialize(f, tab = 8)
      @file, @tab = f, tab

      @tree = RDTree.new
      @base = Stack.new
      @block = Stack.new
      @indent = Stack.new
      @where = Ruby
    end

    def parse
      while line = gets
        unless /^\s*$/
          i = indent(line)
            pos = @base.find{|b| i<b}
          if pos
            close_upto(pos)
          end
        end

        case line
        when /^={1,3}\s\S/
          headline(line)
        when /^\s*\*\s*\S/
          itemlistitem(line)
        when /^\s*\(\d+\)\s*\S/
          enumlistitem(line)
        when /^\s*:\s*\S/
          desclistitem(line)
        when /^\s{#{base}}\S/
          textblock(line)
        when /^\s*$/
            whiteline
        else # i.e., Verbatim
          verbatim(line)
          redo # to ungets
        end
      end
      close_all
      @tree
    end

    def open(elm, base, i = nil)
      i ||= base
      @indent.push i
      @block.push base
      if base
        @base.push base
      end
      @tree.open(elm)
      if block_given?
        yield
        close
      end
    end

    def close
      @indent.pop
      @base.pop if @block.pop
      @tree.close
    end

    def close_all
      until @tree.focus_root?
        close
      end
    end

    def close_upto(pos)
      while @base.top and
          @base.top >= pos
        close
      end
    end
  end

各ブロックの処理

各ブロック要素に対応するメソッドは List 8 の rdo/block.rb にまとめました。TextBlockだけは空行を含めることが出来ないという規則があるので、その辺だけに気を使ってあります。ブロック要素のうちで、実際に印字されるデータを直接持つのは各Headline と TextBlock、Verbatim、それから DescListItem 中の Term だけで、これらの要素を葉と呼ぶことにします。一方、ListListItem などはコンテナだと考えます。葉を open しているときだけは @tree.write で書き込むことが出来ます。

先にも述べた特別な存在である Verbatim ではメインループでの redo に備えるため、呼び出し側である parse で最後に読んだ行オブジェクトを変数 swap という名前で保存しておき、Verbatim が閉じる時にはこの swap を Verbatim で最後に読んだ行 l で置き換えてから break で脱出します。また Verbatim が終るのが分かった時点ではお尻に続く余分な空行を読んでいる可能性があるので、とりあえず読んだ行はスタックにためておき、余分な空行をすべて pop してから @tree に書き込んでいます。

-- List8 rdo/block.rb: 各ブロック要素の処理

  require "rdo/rdolib"
  require "rdo/const"
  require "rdo/tree"

  class RDBlockParser
    private

    def headline(l)
      close_all
      case l
      when /^= (\S.*)/
        open(Headline1, 0){
          @tree.write $1
        }
      when /^== (\S.*)/
        open(Headline2, 0){
          @tree.write $1
        }
      when /^=== (\S.*)/
        open(Headline3, 0){
          @tree.write $1
        }
      end
    end

    def listopen(t, l)
      if @tree.focus? TextBlock
        close
      end
      if indent(l) == @indent.top
        if @tree.focus? *LIST
          unless @tree.focus? t
            close
            open(t, nil, indent(l))
          end
        else
          open(t, nil, indent(l))
        end
      else
        open(t, nil, indent(l))
      end
    end

    def itemlistitem(l)
      listopen(ItemList, l)
      /^(\s*\*\s*)(\S.*)/ =~ l
      i, text = $1.size, $2
      open ItemListItem, i
      open TextBlock, i
      @tree.write text
    end

    def enumlistitem(l)
      listopen(EnumList, l)
      /^(\s*\(\d+\)\s*)(\S.*)/ =~ l
      i, text = $1.size, $2
      open EnumListItem, i
      open TextBlock, i
      @tree.write text
    end

    def desclistitem(l)
      listopen(DescList, l)
      /^(\s*:\s*)(\S.*)/ =~ l
      i, text = $1.size, $2
      open DescListItem, i
      open(Term, i){
        @tree.write text
      }
      open Desc, i
    end

    def textblock(l)
      unless @tree.focus? TextBlock
        open TextBlock, indent(l)
      end
      @tree.write l.sub(/^\s*/, "")
    end

    def whiteline
      if @tree.focus? TextBlock 
        close 
      end
    end

    def verbatim(l)
      if @tree.focus? TextBlock
        close
      end
      swap = l
      buf = Stack.new
      i = indent(l)
      open Verbatim, i
      buf.push l.sub(" "*i, "")

      while l = gets
        unless /^\s*$/ =~ l
          j = indent(l)
          if @base.find{|b| j<b}
            swap.replace(l)
            break
          end
        end
        buf.push l.sub(/^\s{#{i}}/, "")
      end
      while buf.top == /^\s*$/
        buf.pop
      end
      buf.each{|i| @tree.write i }
      close
    end
  end

雑関数

そういえば List 3 はどこにいったのか思う人がいるかも知れません。それは List 9 にあります。実は parse で使っていた gets は List 3 に手を加えたプライベートメソッドです。ちなみにふだん関数として使っている gets などのメソッドは組み込みのKernelモジュールで定義されたものですから同名のメソッドを再定義した場合は Kernel::gets と書けば呼び出すことが出来ます。タブ文字を適切な数のスペースに置換する方法は [FAQ] に載っているものを使っています。

-- List9 rdo/rdolib.rb

 require "rdo/util"
 require "rdo/const"

 class RDBlockParser
   include RDElements
   RD, Ruby = :RD, :Ruby

   private
   def gets
     while line = @file.gets
       case @where
       when Ruby
         case line
         when /^=begin/
           @where = RD
         else
           next
         end
       when RD
         case line
         when /^=end/
           @where = Ruby
         else
           return tab2sp(line)
         end
       end
     end
     return nil
   end

   def tab2sp(line)
     t = @tab
     if t
       1 while line.sub!(/\t/){
         " " * (t - $~.begin(0)%t)
       }
     end
     line
   end

   def indent(line)
     /^(\s*)/.match(line)[1].size
   end

   def base
     @base.top or 0
   end
 end

そういえば Stack などを紹介していませんでした。List 10 は、スタックと Module#mktoken を定義しています。みて分かるように Stacktop というメソッドだけを追加した配列です。Rubyの配列は使いでがありますね。ついでにいえば、Array#shift と Array#push の組合せで配列をキューとしても使うことが出来ます。また、ここでは読み込む文書はEUCに限定しています。Rubyには標準でNKFライブラリがあるので、これでとくに困ることはありません。

-- List10 rdo/util.rb: スタックと定数定義関数

  $KCODE = "EUC"

  class Stack < Array
    def top; self[-1]; end
  end

  class Module
    def mktoken(*arg)
      arg.each{|a|
        const_set(a.to_s, a)
      }
    end
  end

えっと、 mktoken はなにかというと、これはList 11で定義してある定数の定義のためです。List 3ではシンボルを直接書いていましたが、これでは分かりにくいバグの原因にもなるので定数としてまとめたわけです。こうして定数をモジュールにまとめておけば List 6 のようにクラスの取り込むことができます。定数定義自体は別に手で打ってもいいんですけど、識別子としてしか使わない定数の値を考えるのはなんだか面倒なので動的に処理しました。こういったコーディングの省略はマクロでやったり、いきなりevalを持ち出すことが多いのですが、Rubyでは動的なプログラミングのための道具が揃っているので、単一のモデルで考えることが出来て気持ち良いですね。

LISTはrdo/block.rbで使っています。一方、葉の集合を表すLEAFは次にみる rdo/tree.rb で使います。

-- List11 rdo/const.rb: 定数たち

  require "rdo/util"

  module RDElements
    mktoken(:Headline1,
            :Headline2,
            :Headline3,
            :TextBlock,
            :Verbatim,
            :ItemList,
            :EnumList,
            :DescList,
            :ItemListItem,
            :EnumListItem,
            :DescListItem,
            :Term,
            :Desc)

    LIST = [
      ItemList, EnumList, DescList]

    LEAF = [
      Headline1, Headline2, Headline3,
      TextBlock, Verbatim, Term]
  end

構文木の詳細

List 12には parse で出てきた @tree.open@tree.close@tree.write が実装してあります。また、木を育てている時に、いまどこを見ているかというのは@focus という変数に持っており、 @fstack@focus のスタックです。この辺はまぁ見れば分かると思うんですが、説明が必要なのはおそらく format の周辺でしょう。

formattraverse を使って木をなぞり引数で与えられたフォーマッタ fmt のどのメソッドを呼び出すかを決定するための情報 act を Node#traverse からもらっています。Node#traverse は名前 (@name)が葉に属すのかどうかを調べ、属す場合はシンボル :data と名前、文字列の配列をイテレータ引数に渡すことで format のブロックが実行されます。葉でない場合はすなわち入れ子なので、@data の各要素に対してそのままブロックを渡して traverse を実行しています。

当連載第2回でみたイテレータはこのように再帰構造に対しては絶大な威力を発揮します。

-- List12 rdo/tree.rb: 解析木
  require "rdo/const"

  class RDTree
    include RDElements
    Root = :Root

    def initialize
      @root = Node.new(Root)
      @focus = @root
      @fstack = Stack.new
    end

    def focus
      @focus.name
    end

    def focus?(*arg)
      arg.include? @focus.name
    end

    def focus_root?
      focus == Root
    end

    def open(elm)
      @fstack.push @focus
      node = Node.new(elm)
      @focus.data.push node
      @focus = node
    end

    def close
      @focus = @fstack.pop
    end

    def write(*args)
      unless LEAF.include? focus
        raise "non writable node `#{focus}'"
      end
      puts "write(", args, ")\n" if $DEBUG
      @focus.push *args
    end

    def format(fmt)
      traverse do |act, name, data|
        case act
        when :open
          fmt.open(name)
        when :close
          fmt.close(name)
        when :data
          fmt.write(data)
        end
      end
    end

    private

    def traverse(&blk)
      @root.data.each do |node|
        node.traverse(&blk)
      end
    end

    class Node
      include RDElements

      def initialize(name, data = [])
        @name, @data = name, data
      end

      attr_reader :name, :data

      def push(*args)
        @data.push(*args)
      end

      def traverse(&blk)
        yield :open, @name, nil
        if LEAF.include? @name
          yield :data, @name, @data
        else
          @data.each do |node|
            node.traverse(&blk)
          end
        end
        yield :close, @name, nil
      end
    end
  end

まとめ

今回はRDの紹介しRDを理解するため比較的長めのプログラムをみてました。 Rubyには実際のプログラムを書いて初めてわかる便利さもいろいろあります。紙面の都合もあってあまり凝ったことはできませんでしたが、配列のいろいろな使い方や再帰的な構造でのイテレータ活用法などを挙げることができました。また、説明は省略しましたがさまざまなパターンマッチングを使っています。マニュアルやFAQを参照してみて下さい。

謝辞

今回はToshさんのrdtoolやJARSを参考にしまくりました。心より感謝します。

参考資料

[FAQ]

``Ruby FAQ'', <URL:http://www.ruby-lang.org/ja/FAQ/rubyfaq-jp.html>

[JARS]

Tosh, ``Just Another RD Site'', <URL:http://www2.pos.to/~tosh/ruby/rdtool/ja/index.html>

[PRC]

Perl/Ruby Conference, <URL:http://perlruby-con.opensource.gr.jp/>


[Ruby] Rubyどうでしょう
著者: 後藤謙太郎
御意見,御感想,御批判の宛先: gotoken@notwork.org


*1 コンテナは容器のこと。具体的には配列やハッシュなど。
*2 人気のあるRD変換器。僕も愛用中
*3 Wnnのフロントエンドの一つ
*4 クラスの使用上の規模
*5 HTMLのA要素やLaTeXの\refのように別の部分を指し示す機能
*6 自分とサブクラスのインスタンスのみから呼び出せるメソッド