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

Rubyでのプログラミング言語の構築:インタープリター、パート2

Githubのフルソース

Stoffleプログラミング言語の完全な実装は、GitHubで入手できます。バグを見つけたり質問がある場合は、遠慮なく問題を開いてください。

このブログ投稿では、Rubyで完全に構築されたおもちゃのプログラミング言語であるStoffleのインタープリターを引き続き実装します。以前の投稿で通訳を始めました。このプロジェクトの詳細については、このシリーズの最初の部分をご覧ください。

前回の投稿では、Stoffleのより単純な機能(変数、条件、単項および二項演算子、データ型、コンソールへの出力)を実装する方法について説明しました。今度は、袖をまくり上げて、関数定義、関数呼び出し、変数スコープ、ループなど、より難しい残りのビットに取り組みます。

以前と同じように、この投稿の最初から最後まで同じサンプルプログラムを使用します。 Stoffleサンプルプログラムでそれぞれの異なる構造を実現するためにインタプリタで必要な実装を調査しながら、1行ずつ説明します。最後に、インタプリタの動作を確認し、シリーズの前回の記事で作成したCLIを使用してプログラムを実行します。

ガウスが帰ってきた

良い記憶があれば、シリーズのパート2で、レクサーの作成方法について説明したことを覚えているでしょう。その投稿では、Stoffleの構文を説明するために、一連の数値を合計するプログラムを調べました。この記事の終わりに、私たちはついに前述のプログラムを実行できるようになります!それで、ここに再びプログラムがあります:

fn sum_integers: first_integer, last_integer
  i = first_integer
  sum = 0
  while i <= last_integer
    sum = sum + i

    i = i + 1
  end

  println(sum)
end

sum_integers(1, 100)

整数合計プログラムの抽象構文木(AST)は次のとおりです。

Rubyでのプログラミング言語の構築:インタープリター、パート2

私たちのStoffleサンプルプログラムに影響を与えた数学者

カールフリードリヒガウスは、たった7歳で、シリーズの数を合計する式を自分で考え出したと思われます。

お気づきかもしれませんが、私たちのプログラムはガウスによって考案された式を使用していません。今日、私たちはコンピューターを持っているので、この問題を「ブルートフォース」の方法で解決する贅沢があります。シリコンの友達に私たちのために大変な仕事をさせてください。

関数の定義

プログラムで最初に行うことは、sum_integersを定義することです。 関数。関数を宣言するとはどういう意味ですか?ご想像のとおり、これは変数に値を割り当てるのと同様のアクションです。関数を定義するときは、名前(つまり、関数名、識別子)を1つ以上の式(つまり、関数の本体)に関連付けます。また、関数呼び出し中に渡された値をどの名前にバインドするかを登録します。これらの識別子は、関数の実行中にローカル変数になり、パラメーターと呼ばれます。関数が呼び出されたとき(およびパラメーターにバインドされたとき)に渡される値は引数です。

#interpret_function_definitionを見てみましょう :

def interpret_function_definition(fn_def)
  env[fn_def.function_name_as_str] = fn_def
end

かなり簡単ですねこのシリーズの前回の投稿で覚えていると思いますが、インタプリタがインスタンス化されると、環境が作成されます。これは、プログラムの状態を保持するために使用される場所であり、この場合は、単なるRubyハッシュです。前回の投稿では、変数とそれにバインドされた値がenvにどのように格納されるかを見ました。 。関数定義もそこに保存されます。キーは関数名であり、値は関数を定義するために使用されるASTノードです(Stoffle::AST::FunctionDefinition )。このASTノードの復習は次のとおりです。

class Stoffle::AST::FunctionDefinition < Stoffle::AST::Expression
  attr_accessor :name, :params, :body

  def initialize(fn_name = nil, fn_params = [], fn_body = nil)
    @name = fn_name
    @params = fn_params
    @body = fn_body
  end

  def function_name_as_str
    # The instance variable @name is an AST::Identifier.
    name.name
  end

  def ==(other)
    children == other&.children
  end

  def children
    [name, params, body]
  end
end

Stoffle::AST::FunctionDefinitionに関連付けられた関数名を持つ これは、関数の実行に必要なすべての情報にアクセスできることを意味します。たとえば、予想される引数の数が手元にあり、関数呼び出しがそれを提供しない場合、エラーを簡単に発行する可能性があります。これと他の詳細は、次に、関数呼び出しの解釈を担当するコードを調べるときにわかります。

関数の呼び出し

この例のウォークスルーを続けて、関数呼び出しに焦点を当てましょう。 sum_integersを定義した後 関数の場合、引数として1と100の数字を渡して呼び出します。

fn sum_integers: first_integer, last_integer
  i = first_integer
  sum = 0
  while i <= last_integer
    sum = sum + i

    i = i + 1
  end

  println(sum)
end

sum_integers(1, 100)

関数呼び出しの解釈は、#interpret_function_callで行われます。 :

def interpret_function_call(fn_call)
  return if println(fn_call)

  fn_def = fetch_function_definition(fn_call.function_name_as_str)

  stack_frame = Stoffle::Runtime::StackFrame.new(fn_def, fn_call)

  assign_function_args_to_params(stack_frame)

  # Executing the function body.
  call_stack << stack_frame
  value = interpret_nodes(fn_def.body.expressions)
  call_stack.pop
  value
end

これは複雑な関数なので、ここで時間をかける必要があります。前回の記事で説明したように、最初の行は、呼び出される関数がprintlnであるかどうかを確認する役割を果たします。 。ここにあるように、ユーザー定義関数を扱っている場合は、次に進み、#fetch_function_definitionを使用してその定義をフェッチします。 。以下に示すように、この関数は単純な帆であり、基本的にStoffle::AST::FunctionDefinitionを取得します。 以前に環境に保存したASTノード、または関数が存在しない場合はエラーを発行します。

def fetch_function_definition(fn_name)
  fn_def = env[fn_name]
  raise Stoffle::Error::Runtime::UndefinedFunction.new(fn_name) if fn_def.nil?

  fn_def
end

#interpret_function_callに戻る 、物事はより面白くなり始めます。単純なおもちゃの言語で機能について考えるとき、2つの特別な懸念があります。まず、関数のローカル変数を追跡するための戦略が必要です。 returnも処理する必要があります 式。これらの課題に取り組むために、フレームと呼ばれる新しいオブジェクトをインスタンス化します。 、関数が呼び出されるたび。同じ関数が複数回呼び出された場合でも、新しい呼び出しごとに新しいフレームがインスタンス化されます。このオブジェクトは、関数にローカルなすべての変数を保持します。ある関数が別の関数を呼び出すことができるなどの理由で、プログラムの実行フローを表現および追跡する方法が必要です。そのために、スタックデータ構造を使用します。これを呼び出しスタックと名付けます。 。 Rubyでは、#pushを持つ標準配列 および#pop メソッドはスタック実装として機能します。

コールスタックとスタックフレーム

コールスタックとスタックフレームという用語を大まかに使用していることに注意してください。プロセッサと低レベルのプログラミング言語には、通常、コールスタックとスタックフレームもありますが、これらは、おもちゃの言語でここにあるものとは正確には異なります。

これらの概念が好奇心をそそる場合は、コールスタックとスタックフレームを調査することを強くお勧めします。本当に金属に近づきたいのであれば、プロセッサのコールスタックを具体的に調べることをお勧めします。

Stoffle::Runtime::StackFrameを実装するためのコードは次のとおりです。 :

module Stoffle
  module Runtime
    class StackFrame
      attr_reader :fn_def, :fn_call, :env

      def initialize(fn_def_ast, fn_call_ast)
        @fn_def = fn_def_ast
        @fn_call = fn_call_ast
        @env = {}
      end
    end
  end
end

ここで、#interpret_function_callに戻ります。 、次のステップは、関数呼び出しで渡された値をそれぞれの期待されるパラメーターに割り当てることです。これらのパラメーターは、関数本体内のローカル変数としてアクセスできます。 #assign_function_args_to_params このステップを担当します:

def assign_function_args_to_params(stack_frame)
  fn_def = stack_frame.fn_def
  fn_call = stack_frame.fn_call

  given = fn_call.args.length
  expected = fn_def.params.length
  if given != expected
    raise Stoffle::Error::Runtime::WrongNumArg.new(fn_def.function_name_as_str, given, expected)
  end

  # Applying the values passed in this particular function call to the respective defined parameters.
  if fn_def.params != nil
    fn_def.params.each_with_index do |param, i|
      if env.has_key?(param.name)
        # A global variable is already defined. We assign the passed in value to it.
        env[param.name] = interpret_node(fn_call.args[i])
      else
        # A global variable with the same name doesn't exist. We create a new local variable.
        stack_frame.env[param.name] = interpret_node(fn_call.args[i])
      end
    end
  end
end

#assign_function_args_to_paramsを調べる前に 実装では、最初に変数スコープについて簡単に説明する必要があります。これは複雑で微妙なテーマです。 Stoffleの場合、非常に実用的で、単純なソリューションを採用しましょう。私たちの小さな言語では、新しいスコープを作成する唯一の構成要素は関数です。さらに、グローバル変数が常に最初に来ます。結果として、関数の外部で宣言された(つまり、最初の使用法)すべての変数はグローバルであり、envに格納されます。 。関数内の変数はそれらに対してローカルであり、envに格納されます 関数呼び出しの解釈中に作成されたスタックフレームの。ただし、例外が1つあります。それは、既存のグローバル変数と衝突する変数名です。衝突が発生した場合、ローカル変数はありません 作成され、既存のグローバル変数を読み取って割り当てます。

さて、変数スコープ戦略が明確になったので、#assign_function_args_to_paramsに戻りましょう。 。メソッドの最初のセグメントでは、最初に渡されたスタックフレームオブジェクトから関数定義と関数呼び出しノードを取得します。これらが手元にあると、提供された引数の数がパラメーターの数と一致するかどうかを簡単に確認できます。呼び出される関数はを期待します。指定された引数と期待されるパラメータの間に不一致がある場合、エラーが発生します。 #assign_function_args_to_paramsの最後の部分 、関数呼び出し中に提供された引数(つまり、値)をそれぞれのパラメーター(つまり、関数内のローカル変数)に割り当てます。パラメータ名が既存のグローバル変数と衝突するかどうかをチェックすることに注意してください。前に説明したように、これらの場合、関数のスタックフレーム内にローカル変数を作成せず、代わりに、渡された値を既存のグローバル変数に適用します。

#interpret_function_callに戻る 、最後に、新しく作成したスタックフレームを呼び出しスタックにプッシュします。次に、旧友を#interpret_nodesと呼びます。 関数本体の解釈を開始するには:

def interpret_function_call(fn_call)
  return if println(fn_call)

  fn_def = fetch_function_definition(fn_call.function_name_as_str)

  stack_frame = Stoffle::Runtime::StackFrame.new(fn_def, fn_call)

  assign_function_args_to_params(stack_frame)

  # Executing the function body.
  call_stack << stack_frame
  value = interpret_nodes(fn_def.body.expressions)
  call_stack.pop
  value
end
関数本体の解釈

関数呼び出し自体を解釈したので、次は関数本体を解釈します。

fn sum_integers: first_integer, last_integer
  i = first_integer
  sum = 0
  while i <= last_integer
    sum = sum + i

    i = i + 1
  end

  println(sum)
end

sum_integers(1, 100)

sum_integersの最初の2行 関数は変数の割り当てです。このトピックについては、このシリーズの前回のブログ投稿で取り上げました。ただし、現在は可変スコープがあり、その結果、代入を処理するコードが少し変更されています。調べてみましょう:

def interpret_var_binding(var_binding)
  if call_stack.length > 0
    # We are inside a function. If the name points to a global var, we assign the value to it.
    # Otherwise, we create and / or assign to a local var.
    if env.has_key?(var_binding.var_name_as_str)
      env[var_binding.var_name_as_str] = interpret_node(var_binding.right)
    else
      call_stack.last.env[var_binding.var_name_as_str] = interpret_node(var_binding.right)
    end
  else
    # We are not inside a function. Therefore, we create and / or assign to a global var.
    env[var_binding.var_name_as_str] = interpret_node(var_binding.right)
  end
end

関数呼び出し用に作成されたスタックフレームをcall_stackにプッシュしたときのことを覚えていますか ? call_stackを確認することで関数内にいるかどうかを確認できるため、これは便利です。 長さがゼロより大きい(つまり、少なくとも 1スタックフレーム)。現在解釈しているコードの場合のように、関数内にいる場合は、値をバインドしようとしている変数と同じ名前のグローバル変数がすでにあるかどうかを確認します。ご存知のように、衝突が発生した場合、既存のグローバル変数に値を割り当てるだけで、ローカル変数は作成されません。名前が使用されていない場合は、新しいローカル変数を作成し、目的の値を割り当てます。 call_stack以降 はスタック(つまり、後入れ先出しデータ構造)であるため、このローカル変数はenvに格納する必要があることがわかっています。 最後の スタックフレーム(つまり、現在処理中の関数用に作成されたフレーム)。最後に、#interpret_var_bindingの最後の部分 関数の外部で発生する割り当てを処理します。 Stoffleで新しいスコープを作成するのは関数のみであるため、関数の外部で作成された変数は常にグローバルであり、インスタンス変数envに格納されるため、ここでは何も変更されません。 。

プログラムに戻って、次のステップは整数の合計を担当するループを解釈することです。記憶をリフレッシュして、StoffleプログラムのASTをもう一度見てみましょう:

Rubyでのプログラミング言語の構築:インタープリター、パート2

ループを表すノードはStoffle::AST::Repetitionです。 :

class Stoffle::AST::Repetition < Stoffle::AST::Expression
  attr_accessor :condition, :block

  def initialize(cond_expr = nil, repetition_block = nil)
    @condition = cond_expr
    @block = repetition_block
  end

  def ==(other)
    children == other&.children
  end

  def children
    [condition, block]
  end
end

このASTノードは、基本的に、以前の記事で検討した概念をまとめたものであることに注意してください。その条件については、通常、ルートにStoffle::AST::BinaryOperatorを持つ式があります(式のASTルートノードについて考えてみてください)。 (例:'>'、'または'など)。ループの本体には、Stoffle::AST::Blockがあります。 。これは理にかなっていますよね?ループの最も基本的な形式は、1つ以上の式(ブロック)です。 )式が真実である間(つまり、条件付きの間)繰り返される 真の値に評価されます。

インタプリタでの対応するメソッドは#interpret_repetitionです。 :

def interpret_repetition(repetition)
  while interpret_node(repetition.condition)
    interpret_nodes(repetition.block.expressions)
  end
end

ここで、この方法の単純さ(そして、あえて言うなら、美しさ)に驚かれるかもしれません。過去の記事ですでに検討したメソッドを組み合わせることで、ループの解釈を実装できます。 Rubyのwhileを使用する ループでは、Stoffleループを構成するノードの解釈を継続することができます(#interpret_nodesを繰り返し呼び出すことにより) )条件の評価が真である間。条件を評価する作業は、通常の容疑者である#interpret_nodeを呼び出すのと同じくらい簡単です。 メソッド。

関数から戻る

もうすぐフィニッシュラインになります!ループの後、続行して、合計の結果をコンソールに出力します。シリーズの最後の部分ですでに取り上げたので、これ以上は説明しません。簡単にまとめると、println 関数はStoffle自体によって提供され、インタープリターの内部では、Ruby独自のputsを使用しているだけです。 メソッド。

この投稿を終了するには、#interpret_nodesに再度アクセスする必要があります 。その最終バージョンは、過去に見たものとは少し異なります。現在、関数からの戻りと呼び出しスタックの巻き戻しを処理するコードが含まれています。これが#interpret_nodesの完成版です 栄光の中で:

def interpret_nodes(nodes)
  last_value = nil

  nodes.each do |node|
    last_value = interpret_node(node)

    if return_detected?(node)
      raise Stoffle::Error::Runtime::UnexpectedReturn unless call_stack.length > 0

      self.unwind_call_stack = call_stack.length # We store the current stack level to know when to stop returning.
      return last_value
    end

    if unwind_call_stack == call_stack.length
      # We are still inside a function that returned, so we keep on bubbling up from its structures (e.g., conditionals, loops etc).
      return last_value
    elsif unwind_call_stack > call_stack.length
      # We returned from the function, so we reset the "unwind indicator".
      self.unwind_call_stack = -1
    end
  end

  last_value
end

ご存知のとおり、#interpret_nodes 一連の式を解釈する必要があるたびに使用されます。これは、プログラムの解釈を開始するために使用され、ブロックが関連付けられているノード(Stoffle::AST::FunctionDefinitionなど)に遭遇するたびに使用されます。 )。具体的には、関数を処理する場合、関数の解釈とreturnのヒットの2つのシナリオがあります。 関数を最後まで表現または解釈し、returnをヒットしない 式。 2番目のケースでは、関数に明示的なreturnがないことを意味します。 通過した式またはコードパスにreturnがありませんでした 。

続行する前に、思い出をリフレッシュしましょう。上記のいくつかの段落から覚えていると思いますが、#interpret_nodes sum_integersの解釈を開始したときに呼び出されました 関数(つまり、プログラムで呼び出されたとき)。繰り返しになりますが、これが私たちが行っているプログラムのソースコードです:

fn sum_integers: first_integer, last_integer
  i = first_integer
  sum = 0
  while i <= last_integer
    sum = sum + i

    i = i + 1
  end

  println(sum)
end

sum_integers(1, 100)

関数の解釈はこれで終わりです。ご想像のとおり、この関数には明示的なreturnがありません。 。これは#interpret_nodesの最も簡単なパスです 。基本的に、すべての関数ノードを反復処理し、最後に最後に解釈された式の値を返します(クイックリマインダー:Stoffleには暗黙の戻り値があります)。これでフィニッシュラインに到達し、プログラムの解釈が完了します。

プログラムは完全に解釈されましたが、この記事の主な目的はインタープリターの実装を説明することです。ここでもう少し時間をかけて、return 関数内。

まず、return 式は、操作の開始時に評価されます。その値は、返されるものの評価になります。 Stoffle::AST::Returnのコードは次のとおりです。 :

class Stoffle::AST::Return < Stoffle::AST::Expression
  attr_accessor :expression

  def initialize(expr)
    @expression = expr
  end

  def ==(other)
    children == other&.children
  end

  def children
    [expression]
  end
end

次に、returnを検出する単純な条件があります。 ASTノード。これを行った後、最初に健全性チェックを実行して、関数内にいることを確認します。これを行うには、コールスタックの長さを簡単に確認できます。ゼロより大きい長さは、実際に関数内にいることを意味します。 Stoffleでは、returnの使用は許可されていません 関数の外部の式なので、このチェックが失敗するとエラーが発生します。プログラマーが意図した値を返す前に、まず呼び出しスタックの現在の長さを記録し、インスタンス変数unwind_call_stackに格納します。 。これが重要である理由を理解するために、#interpret_function_callを確認しましょう。 :

def interpret_function_call(fn_call)
  return if println(fn_call)

  fn_def = fetch_function_definition(fn_call.function_name_as_str)

  stack_frame = Stoffle::Runtime::StackFrame.new(fn_def, fn_call)

  assign_function_args_to_params(stack_frame)

  # Executing the function body.
  call_stack << stack_frame
  value = interpret_nodes(fn_def.body.expressions)
  call_stack.pop
  value
end

ここで、#interpret_function_callの最後に 、関数を解釈した後、呼び出しスタックからスタックフレームをポップすることに注意してください。結果として、コールスタックの長さは1つ短くなります。リターンを検出した時点でスタックの長さを保持しているため、#interpret_nodesで新しいノードを解釈するたびに、この初期の長さを比較できます。 。 #interpret_nodesのノードイテレータ内でこれを行うセグメントを見てみましょう。 :

def interpret_nodes(nodes)
  # ...

  nodes.each do |node|
    # ...

    if unwind_call_stack == call_stack.length
      # We are still inside a function that returned, so we keep on bubbling up from its structures (e.g., conditionals, loops etc).
      return last_value
    elsif unwind_call_stack > call_stack.length
      # We returned from the function, so we reset the "unwind indicator".
      self.unwind_call_stack = -1
    end

    # ...
  end

  # ...
end

これは、最初は理解するのが少し難しいかもしれません。インタープリターのこの最後の部分を理解するのに役立つと思われる場合は、GitHubで完全な実装を確認し、試してみることをお勧めします。ここで覚えておくべき重要な点は、典型的なプログラムには多くの深くネストされた構造があるということです。エルゴ、#interpret_nodesを実行 通常、#interpret_nodesへの新しい呼び出しが発生します 、これにより、#interpret_nodesへの呼び出しが増える可能性があります 等々! returnを押したとき 関数の内部では、深くネストされた構造の内部にいる可能性があります。たとえば、return ループの一部である条件内にあります。関数から戻るには、すべての#interpret_nodesから戻る必要があります #interpret_function_callによって開始されたものから戻るまで呼び出します (つまり、#interpret_nodesへの呼び出し 関数本体の解釈を開始しました。

上記のコードのセグメントでは、これをどのように行うかを正確に強調しています。 @unwind_call_stackに正の値を設定する および 呼び出しスタックの現在の長さに等しいものであるため、関数内にあり、まだreturnを実行していないことが確実にわかります。 #interpret_function_callによって開始された元の呼び出しから 。これが最終的に発生したとき、@unwind_call_stack コールスタックの現在の長さよりも大きくなります。したがって、戻ってきた関数を終了したことがわかり、バブリングのプロセスを続行する必要がなくなります。次に、@unwind_call_stackをリセットします 。 @unwind_call_stackを利用するためだけに 非常に明確です。可能な値は次のとおりです。

  • -1 、その初期値とニュートラル値。これは、返された関数の内部にいないことを示します。
  • コールスタックの長さに等しい正の値 、返された関数内にまだいることを示します。
  • コールスタックの長さより大きい正の値 、返された関数の内部にいないことを示します。

StoffleCLIを使用したプログラムの実行

シリーズの前回の記事では、Stoffleプログラムの解釈を容易にするための単純なCLIを作成しました。インタプリタの実装について説明したので、プログラムを実行して、実際の動作を見てみましょう。上記のさまざまなセクションで示したように、このコードはsum_integersを定義して呼び出します。 引数を渡す関数1 および100 。インタプリタが正常に機能している場合は、5050.0が表示されます。 (1から始まり100で終わる整数のセットの合計)コンソールに出力されます:

Rubyでのプログラミング言語の構築:インタープリター、パート2

最後の考え

この投稿では、インタプリタを完成させるために必要な最後の部分を実装しました。関数の定義、関数の呼び出し、変数のスコープ、ループに取り組みました。これで、シンプルでありながら機能するプログラミング言語ができました!

このシリーズの次の最後のパートでは、プログラミング言語の実装の研究を続けたい人のための素晴らしいオプションと私が考えるいくつかのリソースを共有します。また、Stoffleのバージョンを改善しながら、学習を継続するために取り組むことができるいくつかの課題を提案します。また後で;チャオ!


  1. Rubyネットワークプログラミング

    Rubyでカスタムネットワーククライアントとサーバーを作成しますか?または、それがどのように機能するかを理解しますか? 次に、ソケットを処理する必要があります。 このルビーネットワークプログラミングのツアーに参加してください 基本を学び、Rubyを使用して他のサーバーやクライアントと会話を始めましょう! では、ソケットとは何ですか ? ソケットは通信チャネルのエンドポイントであり、クライアントとサーバーの両方がソケットを使用して通信します。 動作方法は非常にシンプルです : 接続が確立されると、データをソケットに入れることができます。データはもう一方の端に送られ、そこで受信者はソケ

  2. プログラミング言語の影響グラフを視覚化する

    Gephi と Sigma.js を使用したネットワーク可視化チュートリアル これが今日作成するもののプレビューです:プログラミング言語はグラフに影響を与えます。リンクをチェックして、過去と現在の 250 を超えるプログラミング言語間の「デザインの影響」の関係を調べてください! あなたの番です! 今日の高度に接続された世界では、ネットワークは現代生活のいたるところにある側面です。 これまでの一日の始まり — ロンドンの交通網を利用しました 町に旅行する。それから支店に入りました お気に入りのコーヒー ショップの Wi-Fi ネットワークに接続するために Chromebook を使用しまし