Christopher Strachey の GPM 第 2.1 版

懲りずに似たコードを含むエントリが続きます。さて、Ruby に書き直した第 2 版は、パラメータ置換のループとマクロ展開のループで 2 回同じ文字列を先頭から末尾まで調べる無駄が生じていました。無駄は嫌なので、ループを 1 回に減らします。そうすることで、和田先生の Scheme 版から記述がずれてしまっていたのが元に戻ってメソッドと手続きの対応関係が復活します。ひとまず Ruby 版は一段落したので、Gist に置いておきます。

Christopher Strachey's General Purpose Macrogenerator (GPM) Gist

第 1 版と同じにして、コンストラクタの第 3 引数に実引数配列を渡すようにします。ただし、第 1 版では実引数リストだったのに対して、配列または nil のバリアントにしています。トップレベルでは nil を指定して、マクロ展開時は配列を渡します。

def gpm(s)
  StracheyGPM.new(s, nil, nil).expand
end

class StracheyGPM
  def initialize(str, env, args)
    @str = str
    @env = env
    @args = args
    @pos = 0
  end

#@<str 文字列を入力ストリームのように扱います@>
#@<expand メソッドで平文を処理します@>
end

StracheyGPM クラスは入力文字列ストリームの簡略版のようなふるまいをします。eof? メソッドで、文字列終端に達しているかを調べることができます。peekch メソッドで、文字ポインタを変更せずに次に getch したときに得られるはずの文字を覗き見することができます。getch メソッドは、文字ポインタを更新します。getch メソッドは、Scheme 版の getch 手続きに対応しています。Scheme 版では ch 変数で先読みされた文字を保持していますが、ここでは peekch メソッドで先読みをおこなうようにしています。

#@<str 文字列を入力ストリームのように扱います@>=
  def eof?() @pos >= @str.size end

  def peekch
    return nil if eof?
    @str[@pos]
  end

  def getch
    return nil if eof?
    @str[(@pos += 1) - 1]
  end

オブジェクトのエントリ・ポイントである expand メソッドは平文を処理します。平文の処理そのものは下請けの expand_string メソッドが担当しています。expand_string から戻ってくると、現在の文字列はコンマかセミコロンのいずれかか、もしくは文字列末尾の 3 通りしかありません。コンマとセミコロンのときは出力文字列へそのまま追加してループを回します。expand メソッドは、Scheme 版の gloop 手続きに対応しています。

#@<expand メソッドで平文を処理します@>=
  def expand
    t = ""
    while not eof?
      t << expand_string
      break if eof?
      t << getch    # ',' or ';'
    end
    t
  end

#@<expand_string メソッドで文字列を処理します@>

下請けメソッド expand_string は、コンマかセミコロンで終端されているマクロ呼び出しとクォートを含む文字列を処理します。その際、終端は文字列末尾であってもかまいません。さらに加えて、マクロ呼び出しのマクロ定義本体を展開するときはパラメータ置換もおこないます。マクロ定義の展開中は args インスタンス変数が配列オブジェクトであり、トップ・レベルの平文では nil であるため、前者の場合にパラメータ置換をします。expand_string メソッドは、Scheme 版の readstring 手続きに対応しています。

#@<expand_string メソッドで文字列を処理します@>=
  def expand_string
    t = ""
    while not eof? and not [',', ';'].include?(peekch)
      ch = getch
      if not @args.nil? and ch == '~' and not eof? and ('0' .. '9').cover?(peekch)
        t << @args[getch.ord - '0'.ord]
      elsif ch == '<'
        t << expand_quote
      elsif ch == '$'
        t << expand_macro
      else
        t << ch
      end
    end
    t
  end

#@<expand_quote メソッドでクォートを一段外します@>
#@<expand_macro メソッドでマクロを展開します@>

expand_quote でクォートを一段外します。これが呼ばれたとき、左端のクォート開始記号を読み終えているため、入れ子を追跡して対応する右端のクォート終了記号が見つかるまで入力文字列から出力文字列へ転写します。対応する右端のクォート終了記号は読み飛ばします。expand_quote メソッドは、Scheme 版の readquote 手続きに対応しています。

#@<expand_quote メソッドでクォートを一段外します@>=
  def expand_quote
    t = ""
    level = 1
    while level > 0
      not eof? or raise SyntaxError, "unbalanced quote < .. >"
      ch = getch
      if ch == '>'
        level -= 1
      elsif ch == '<'
        level += 1
      end
      t << ch if level > 0
    end
    t
  end

マクロ展開では、下請けの expand_string を使ってマクロ名と実引数を左から右へ順に展開して引数配列を作り、引数配列を使ってマクロを呼び出すことで処理をおこないます。マクロ名と実引数はコンマで区切られているか、もしくはセミコロンで終端されています。実引数中にマクロの局所定義を並べることができ、局所定義はマクロ展開後にマクロ定義から消さないといけないため、実引数の並びの展開を開始する前にマクロ定義リスト @env の巻き戻し箇所を trail 変数に記録しておいて、マクロ呼び出し後に巻き戻しをします。expand_macro メソッドは、Scheme 版の readmacrocall 手続きに対応しています。

#@<expand_macro メソッドでマクロを展開します@>=
  def expand_macro
    args = []
    trail = @env
    t = ""
    while not eof?
      t << expand_string
      break if eof?
      ch = getch
      if ch == ','
        while not eof? and ["\n", " "].include?(peekch)
          getch
        end
        args.push t
        t = ""
      elsif ch == ';'
        args.push t
        r = macrocall(args)
        @env = trail if args[0] != "def"
        return r
      end
    end
    raise SyntaxError, "missing semicolon $macro,arg1,arg2,..;"
  end

#@<macrocall メソッドでマクロ呼び出しをおこないます@>

macrocall メソッドは def でマクロ定義をおこない、dnl で次の行頭まで入力文字列を読み飛ばし、それ以外でユーザ定義マクロ呼び出しをおこないます。マクロ定義は、一本の連想リストになっていて、インスタンス変数 @env がその先頭を参照しています。macrocall メソッドは、Scheme 版の macrocall 手続きに対応しています。

#@<macrocall メソッドでマクロ呼び出しをおこないます@>=
  def macrocall(args)
    if args[0] == "def"
      @env = [[args[1], args[2]], @env]
      ""
    elsif args[0] == "dnl"
      discard_to_next_line
      ""
    else
      str = lookup_macro(args[0], @env)
      StracheyGPM.new(str, @env, args).expand
    end
  end

  def discard_to_next_line
    while not eof? and peekch != "\n"
      getch
    end
    if not eof? and peekch == "\n"
      getch
    end
  end

  def lookup_macro(key, env)
    while not env.nil?
      return env.first.last if key == env.first.first
      env = env.last
    end
    raise RuntimeError, "not define `#{args[0]}'."
  end