Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyテンプレート:インタプリタを焼き上げる

コーヒーの上にストループワッフルを温めておいてください。今日は、あの粘着力のある「ストループ」(ストループワッフルの2枚の生地を貼り合わせるシロップのこと)を使って、すべてを組み上げていきます。本シリーズの前半2回では、Lexer(字句解析器)とParser(構文解析器)を焼き上げました。そして今回、ついにInterpreter(インタプリタ)を追加し、ストループを流し込んで全体をひとつにまとめます。

材料をそろえる

それでは、キッチンの準備をして材料をテーブルに並べましょう。インタプリタが仕事をするために必要な材料は2つです。1つはこれまでに生成した抽象構文木(AST)、もう1つはテンプレートに埋め込みたいデータです。このデータをenvironmentと呼ぶことにします。

ASTを走査するために、ここではビジター(visitor)パターンを使ってインタプリタを実装します。ビジター(つまり私たちのインタプリタ)は、ノードを引数として受け取る汎用的なvisitメソッドを実装します。このメソッドはノードを処理したうえで、現在のノードにとって意味があると判断されれば、その子ノードの一部(または全部)に対して再びvisitメソッドを呼び出します。

module Magicbars
  class Interpreter
    attr_reader :root, :environment
 
    def self.render(root, environment = {})
      new(root, environment).render
    end
 
    def initialize(root, environment = {})
      @root = root
      @environment = environment
    end
 
    def render
      visit(root)
    end
 
    def visit(node)
      # Process node
    end
  end
end

先へ進む前に、テンプレートとenvironmentを受け取ってレンダリング結果を出力する、小さなMagicbars.renderメソッドも作っておきましょう。

module Magicbars
  def self.render(template, environment = {})
    tokens = Lexer.tokenize(template)
    ast = Parser.parse(tokens)
    Interpreter.render(ast, environment)
  end
end

これで、ASTを手作業で組み立てなくてもインタプリタをテストできるようになります。

Magicbars.render('Welcome to {{name}}', name: 'Ruby Magic')
# => nil

予想どおり、現時点では何も返ってきません。そこでvisitメソッドの実装に取り掛かりましょう。おさらいとして、このテンプレートのASTがどのような形になるかを見てみます。

このテンプレートを処理するには、TemplateContentExpressionIdentifierという4種類のノードを扱う必要があります。もちろん、巨大なcase文をvisitメソッドの中に書くこともできますが、すぐに読みにくくなってしまうでしょう。代わりに、Rubyのメタプログラミング機能を活用して、コードを整理された読みやすい状態に保ちます。

module Magicbars
  class Interpreter
    # ...
 
    def visit(node)
      short_name = node.class.to_s.split('::').last
      send("visit_#{short_name}", node)
    end
  end
end

このメソッドはノードを受け取り、そのクラス名を取得してモジュール名を取り除きます(別のやり方に興味があれば、文字列クリーンアップに関する記事もぜひご覧ください)。その後、sendを使って、そのノードタイプ専用の処理を行うメソッドを呼び出します。各タイプのメソッド名は、モジュール名を除いたクラス名にvisit_という接頭辞を付けたものになります。メソッド名に大文字が入るのは少し風変わりですが、メソッドの意図がとても明確になります。

module Magicbars
  class Interpreter
    # ...
 
    def visit_Template(node)
      # Process template nodes
    end
 
    def visit_Content(node)
      # Process content nodes
    end
 
    def visit_Expression(node)
      # Process expression nodes
    end
 
    def visit_Identifier(node)
      # Process identifier nodes
    end
  end
end

各ノードタイプの実装

まずはvisit_Templateメソッドから実装しましょう。このメソッドは、ノードが持つすべてのstatementsを処理して、結果を連結するだけです。

def visit_Template(node)
  node.statements.map { |statement| visit(statement) }.join
end

次にvisit_Contentメソッドです。コンテンツノードは単なる文字列を包んでいるだけなので、実装は非常にシンプルです。

def visit_Content(node)
  node.content
end

続いてvisit_Expressionメソッドです。ここでプレースホルダーが実際の値に置き換えられます。

def visit_Expression(node)
  key = visit(node.identifier)
  environment.fetch(key, '')
end

最後に、visit_Expressionメソッドがenvironmentからどのキーを取得すべきかを知るために、visit_Identifierメソッドを実装します。

def visit_Identifier(node)
  node.value
end

これら4つのメソッドが揃えば、テンプレートを再度レンダリングしたときに期待どおりの結果が得られます。

Magicbars.render('Welcome to {{name}}', name: 'Ruby Magic')
# => Welcome to Ruby Magic

ブロック式の解釈

ここまで書いたコードは、単純なgsubでも実現できる内容でした。そこで、もう少し複雑な例に進みましょう。

Welcome to {{name}}!
 
{{#if subscribed}}
  Thank you for subscribing to our mailing list.
{{else}}
  Please sign up for our mailing list to be notified about new articles!
{{/if}}
 
Your friends at {{company_name}}

おさらいとして、対応するASTの形を見てみます。

まだ扱っていないノードタイプは1つだけです。それはvisit_BlockExpressionノードです。ある意味ではvisit_Expressionノードと似ていますが、値によってBlockExpressionノードのstatementsを処理するか、inverse_statementsを処理するかが分岐します。

def visit_BlockExpression(node)
  key = visit(node.identifier)
 
  if environment[key]
    node.statements.map { |statement| visit(statement) }.join
  else
    node.inverse_statements.map { |statement| visit(statement) }.join
  end
end

このメソッドを見ると、2つの分岐が非常によく似ていることに気づきます。さらに、visit_Templateメソッドとも似ています。いずれも配列内のすべてのノードを訪問する処理なので、共通部分をvisit_Arrayメソッドとして切り出して、コードをすっきりさせましょう。

def visit_Array(nodes)
  nodes.map { |node| visit(node) }
end

新しいメソッドができたので、visit_Templatevisit_BlockExpressionから重複コードを取り除けます。

def visit_Template(node)
  visit(node.statements).join
end
 
def visit_BlockExpression(node)
  key = visit(node.identifier)
 
  if environment[key]
    visit(node.statements).join
  else
    visit(node.inverse_statements).join
  end
end

これでインタプリタがすべてのノードタイプを扱えるようになったので、複雑なテンプレートをレンダリングしてみましょう。

Magicbars.render(template, { name: 'Ruby Magic', subscribed: true, company_name: 'AppSignal' })
# => Welcome to Ruby Magic!
#
#
#  Please sign up for our mailing list to be notified about new articles!
#
#
# Your friends at AppSignal

ほぼ正しく見えますね。しかしよく見ると、environmentにsubscribed: trueを渡しているのに、メーリングリストへの登録を促すメッセージが表示されています。これはおかしいですね……。

ヘルパーメソッドのサポートを追加する

テンプレートを振り返ると、ブロック式の中にifがあることがわかります。visit_BlockExpressionはenvironmentからsubscribedの値を参照する代わりに、ifの値を参照していたのです。ifはenvironmentに存在しないため、参照結果はnil、つまり偽になります。

ここで「HandlebarsではなくMustacheの模倣を目指しているのだ」と宣言して、テンプレートからifを取り除けば、期待どおりの結果が得られます。

Welcome to {{name}}!
 
{{#subscribed}}
  Thank you for subscribing to our mailing list.
{{else}}
  Please sign up for our mailing list to be notified about new articles!
{{/subscribed}}
 
Your friends at {{company_name}}

しかし、楽しいときにやめる必要はありません。もう一歩進んでヘルパーメソッドを実装してみましょう。これは他の用途にも役立つはずです。

まずは単純な式へのヘルパーメソッド対応から始めます。渡された文字列を反転させるreverseヘルパーと、与えられた値のクラス名を教えてくれるdebugメソッドを追加します。

def helpers
  @helpers ||= {
    reverse: ->(value) { value.to_s.reverse },
    debug: ->(value) { value.class }
  }
end

これらのヘルパーはシンプルなラムダで実装し、ハッシュに格納することで名前による検索を可能にしています。

次に、environmentでの値参照を試みる前にヘルパーの検索を行うよう、visit_Expressionを修正します。

def visit_Expression(node)
  key = visit(node.identifier)
 
  if helper = helpers[key]
    arguments = visit(node.arguments).map { |k| environment[k] }
 
    return helper.call(*arguments)
  end
 
  environment[key]
end

指定された識別子に一致するヘルパーが存在すれば、メソッドはすべての引数を訪問してそれぞれの値を検索し、その後、値を引数としてヘルパーを呼び出します。

Magicbars.render('Welcome to {{reverse name}}', name: 'Ruby Magic')
# => Welcome to cigaM ybuR
 
Magicbars.render('Welcome to {{debug name}}', name: 'Ruby Magic')
# => Welcome to String

ここまでできたら、いよいよifunlessヘルパーを実装しましょう。引数に加えて2つのラムダを渡すことで、ノードのstatementsを処理すべきか、inverse_statementsを処理すべきかをヘルパー側で判断できるようにします。

def helpers
  @helpers ||= {
    if: ->(value, block:, inverse_block:) { value ? block.call : inverse_block.call },
    unless: ->(value, block:, inverse_block:) { value ? inverse_block.call : block.call },
    # ...
  }
end

visit_BlockExpressionへの変更はvisit_Expressionで行ったこととほぼ同じですが、今回は2つのラムダも一緒に渡します。

def visit_BlockExpression(node)
  key = visit(node.identifier)
 
  if helper = helpers[key]
    arguments = visit(node.arguments).map { |k| environment[k] }
 
    return helper.call(
      *arguments,
      block: -> { visit(node.statements).join },
      inverse_block: -> { visit(node.inverse_statements).join }
    )
  end
 
  if environment[key]
    visit(node.statements).join
  else
    visit(node.inverse_statements).join
  end
end

これでベーキングは完了です! この旅の始まりとなった、あの複雑なテンプレートをレンダリングできるようになりました。

Magicbars.render(template, { name: 'Ruby Magic', subscribed: true, company_name: 'AppSignal' })
# => Welcome to Ruby Magic!
#
#
#  Thank you for subscribing to our mailing list.
#
#
# Your friends at AppSignal

まだ表面をなぞったにすぎない

この全3回のシリーズでは、テンプレート言語を作成するための基礎を学びました。これらの概念は、Rubyのようなインタプリタ型プログラミング言語を作る際にも応用できます。とはいえ、適切なエラー処理(🙀)など、いくつかの点は省略しており、今日のプログラミング言語の基盤についても表面をなぞった程度です。

このシリーズを楽しんでいただけたなら幸いです。さらに続きを読みたい方は、ぜひRuby Magicのメーリングリストに登録してください。そして、ストループワッフルが食べたくなった方は、ぜひご一報を。こちらからお届けできるかもしれません!

  1. Rubyのメソッドルックアップパスを徹底解説!メソッド探索の仕組みを完全理解しよう

    メソッドを呼び出したとき、Ruby内部では何が起きているのでしょうか?同名のメソッドが複数存在する場合、Rubyはどちらを呼び出すべきかをどう判断するのでしょう?そもそも、そのメソッドはどこに定義されているのでしょうか? Rubyには、呼び出すべき正しいメソッドを決定し、該当するメソッドが見つからない場合に「NoMethodError」を返すタイミングを判断するための、明確な「仕組み」が備わっています。この仕組みこそが「Rubyメソッドルックアップパス(Method Lookup Path)」と呼ばれるものです。 本記事では、Rubyのメソッドルックアップについて詳しく掘り下げます。読み終える

  2. Rubyで学ぶリンクリストの基礎と実装:配列との違いからコード例まで徹底解説

    本記事は「Practical Computer Science in Ruby」シリーズの第3回です。今回はリンクリスト(連結リスト)について詳しく解説します。 リンクリストとは何か? 名前の通り、リンクリストとはデータをリスト形式で格納するためのデータ構造です。 「リンク(連結)」という言葉が示すように、データはノードと呼ばれる単位に保存され、これらのノードが順番に互いに連結される仕組みになっています。 リンクリストと配列の違い リンクリストは配列とは異なるパフォーマンス特性を持っています。それが、用途に応じてどちらかを選ぶ理由の一つです。つまり、特定のタスクにおいては、リンクリストの方が