Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

การสร้างภาษาการเขียนโปรแกรมใหม่ใน Ruby:The Interpreter

ที่มาแบบเต็มบน Github

มีการใช้งานภาษาการเขียนโปรแกรม Stoffle อย่างสมบูรณ์ที่ GitHub อย่าลังเลที่จะเปิดปัญหาหากคุณพบข้อบกพร่องหรือมีคำถาม

ในบล็อกโพสต์นี้ เราจะเริ่มใช้งานล่ามสำหรับ Stoffle ซึ่งเป็นภาษาการเขียนโปรแกรมของเล่นที่สร้างขึ้นใน Ruby ทั้งหมด คุณสามารถอ่านเพิ่มเติมเกี่ยวกับโครงการนี้ได้ในส่วนแรกของชุดนี้

ล่ามที่เราจะสร้างมักเรียกกันว่าล่ามเดินตามต้นไม้ ในโพสต์ก่อนหน้าของซีรีส์นี้ เราได้สร้าง parser เพื่อแปลงลำดับของโทเค็นแบบเรียบๆ ให้เป็นโครงสร้างข้อมูลแบบทรี (แผนผังไวยากรณ์นามธรรมหรือ AST แบบสั้น) อย่างที่คุณอาจจินตนาการได้ ล่ามของเรามีหน้าที่ในการดำเนินการผ่าน AST ที่สร้างโดย parser ของเรา และนำชีวิตเข้าสู่โปรแกรม Stoffle ฉันพบว่าขั้นตอนสุดท้ายนี้เป็นขั้นตอนที่น่าตื่นเต้นที่สุดในการนำภาษาไปใช้ เมื่อสร้างล่าม ในที่สุดทุกอย่างก็คลิกเข้ามา และเราจะได้เห็นโปรแกรม Stoffle ทำงานจริง!

ฉันจะแสดงและอธิบายการใช้งานล่ามในสองส่วน ในส่วนแรกนี้ เราจะให้การทำงานพื้นฐาน:ตัวแปร เงื่อนไข ตัวดำเนินการยูนารีและไบนารี ชนิดข้อมูล และการพิมพ์ไปยังคอนโซล เรากำลังจองสิ่งที่มีเนื้อมากขึ้น (การกำหนดฟังก์ชัน การเรียกใช้ฟังก์ชัน การวนซ้ำ ฯลฯ) สำหรับโพสต์ที่สองและสุดท้ายเกี่ยวกับการใช้งานล่ามของเรา

สรุปย่อของ Lexer และ Parser

ก่อนที่เราจะลงลึกและเริ่มใช้งานล่าม เรามาเตือนตัวเองอย่างรวดเร็วว่าเราทำอะไรในโพสต์ก่อนหน้าของซีรีส์นี้ ขั้นแรก เราสร้าง lexer ซึ่งแปลงซอร์สโค้ดดิบเป็นโทเค็น จากนั้น เราใช้ parser ซึ่งเป็นองค์ประกอบที่รับผิดชอบในการเปลี่ยนโทเค็นให้เป็นโครงสร้างแบบต้นไม้ (AST) โดยสรุป นี่คือการเปลี่ยนแปลงที่เราสังเกตเห็น:

สถานะ 0:แหล่งที่มา

my_var = 1

สถานะ 1:Lexer แปลงซอร์สโค้ดดิบเป็นโทเค็น

[:identifier, :'=', :number]

สถานะ 2:Parser แปลงโทเค็นเป็นแผนผังไวยากรณ์นามธรรม

การสร้างภาษาการเขียนโปรแกรมใหม่ใน Ruby:The Interpreter

ทุกอย่างเกี่ยวกับการเดิน

ตอนนี้เรามี AST แล้ว งานของเราคือเขียนโค้ดเพื่อใช้โครงสร้างนี้ เราต้องเขียนโค้ด Ruby ที่สามารถให้ชีวิตกับสิ่งที่แต่ละโหนดใน AST อธิบายได้ หากเรามีโหนดที่อธิบายการเชื่อมโยงตัวแปร เช่น งานของเราคือการเขียนโค้ด Ruby ที่สามารถเก็บผลลัพธ์ทางด้านขวามือของนิพจน์การโยงตัวแปรของเราและเพื่อให้มีพื้นที่เก็บข้อมูลนี้สัมพันธ์กับ (และ เข้าถึงได้ผ่าน) ชื่อที่กำหนดให้กับตัวแปร

ดังที่เราทำในส่วนก่อนหน้าของชุดนี้ เราจะสำรวจการใช้งานโดยดำเนินการตามบรรทัดสำคัญของโค้ดที่เกี่ยวข้องในการจัดการโปรแกรมตัวอย่าง ส่วนของรหัส Stoffle ที่เราต้องตีความมีดังต่อไปนี้:

num = -2
if num > 0
  println("The number is greater than zero.")
else
  println("The number is less than or equal to zero.")
end

นี่คือ AST ที่สร้างขึ้นสำหรับโปรแกรมเดียวกัน:

การสร้างภาษาการเขียนโปรแกรมใหม่ใน Ruby:The Interpreter

ก้าวแรกในการเดินของเรา

อย่างที่คุณอาจจำได้จากโพสต์ล่าสุดในซีรีส์นี้ Stoffle AST จะมี AST::Program เป็นรูทเสมอ โหนด รูทนี้โดยทั่วไปมีลูกหลายคน บางส่วนจะตื้น (ลองนึกถึง AST ที่สร้างขึ้นสำหรับการกำหนดตัวแปรอย่างง่าย) เด็กคนอื่นๆ อาจเป็นรากของต้นไม้ย่อยที่ค่อนข้างลึก (ลองนึกถึงลูปที่มีเส้นหลายเส้นอยู่ภายในร่างกาย) นี่คือรหัส Ruby ที่เราต้องเริ่มดำเนินการผ่าน AST ที่ส่งผ่านไปยังล่ามของเรา:

module Stoffle
  class Interpreter
    attr_reader :program, :output, :env

    def initialize
      @output = []
      @env = {}
    end

    def interpret(ast)
      @program = ast

      interpret_nodes(program.expressions)
    end

    private

    def interpret_nodes(nodes)
      last_value = nil

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

      last_value
    end

    def interpret_node(node)
      interpreter_method = "interpret_#{node.type}"
      send(interpreter_method, node)
    end

    #...

  end
end

เมื่อ Interpreterใหม่ ถูกสร้างขึ้นทันทีตั้งแต่เริ่มต้น เราสร้างตัวแปรอินสแตนซ์สองตัว:@output และ @env . ความรับผิดชอบของอดีตคือการจัดเก็บทุกอย่างที่โปรแกรมของเราพิมพ์ออกมาตามลำดับเวลา การมีข้อมูลนี้อยู่ในมือมีประโยชน์มากเมื่อเขียนการทดสอบอัตโนมัติหรือการดีบัก ความรับผิดชอบของ @env แตกต่างกันเล็กน้อย เราตั้งชื่อมันว่าเป็นการอ้างอิงถึง "สิ่งแวดล้อม" ตามชื่อที่แนะนำ หน้าที่ของมันคือการรักษาสถานะของโปรแกรมที่ทำงานอยู่ของเรา หนึ่งในหน้าที่ของมันคือการนำการเชื่อมโยงระหว่างตัวระบุ (เช่น ชื่อตัวแปร) กับค่าปัจจุบัน

#interpret_nodes วิธีการวนซ้ำผ่านชายน์ทั้งหมดของโหนดรูท (AST::Program ). จากนั้นจะเรียก #interpret_node สำหรับแต่ละโหนด

#interpret_node เรียบง่ายแต่น่าสนใจ ที่นี่เราใช้ Ruby metaprogramming เล็กน้อยเพื่อเรียกวิธีการที่เหมาะสมในการจัดการประเภทโหนดที่อยู่ในมือ ตัวอย่างเช่น สำหรับ AST::VarBinding โหนด #interpret_var_binding วิธีการคือสิ่งที่ถูกเรียก

เราต้องพูดถึงตัวแปรอย่างสม่ำเสมอ

โหนดแรกที่เราต้องแปลใน AST ของโปรแกรมตัวอย่างที่เรากำลังดำเนินการคือ AST::VarBinding . มัน @left เป็น AST::Identifier และ @right . ของมัน เป็น AST::UnaryOperator . มาดูวิธีการที่รับผิดชอบในการตีความการโยงตัวแปรกัน:

def interpret_var_binding(var_binding)
  env[var_binding.var_name_as_str] = interpret_node(var_binding.right)
end

อย่างที่คุณเห็น มันค่อนข้างตรงไปตรงมา เราเพิ่ม (หรือเขียนทับ) คู่คีย์-ค่าใน @env แฮช.

กุญแจสำคัญคือชื่อของตัวแปร (#var_name_as_str เป็นเพียงวิธีการช่วยเหลือที่เทียบเท่ากับ var_binding.left.name ). ในขณะนี้ ตัวแปรทั้งหมดเป็นสากล เราจะจัดการกับขอบเขตในโพสต์ถัดไป

ค่านี้เป็นผลจากการตีความนิพจน์ทางด้านขวามือของงานที่มอบหมาย ในการทำเช่นนั้น เราใช้ #interpret_node อีกครั้ง. เนื่องจากเรามี AST::UnaryOperator ทางด้านขวามือ วิธีถัดไปที่ถูกเรียกคือ #interpret_unary_operator :

def interpret_unary_operator(unary_op)
  case unary_op.operator
  when :'-'
    -(interpret_node(unary_op.operand))
  else # :'!'
    !(interpret_node(unary_op.operand))
  end
end

ความหมายของตัวดำเนินการเอกพจน์ที่รองรับของ Stoffle (- และ ! ) เหมือนกับใน Ruby ด้วยเหตุนี้ การใช้งานจึงไม่ง่ายไปกว่านี้:เราใช้ - ของ Ruby โอเปอเรเตอร์ต่อผลลัพธ์ของการตีความตัวถูกดำเนินการ ผู้ต้องสงสัยตามปกติ #interpret_node ปรากฏขึ้นอีกครั้งที่นี่ ตามที่คุณอาจจำได้จาก AST ของโปรแกรมของเรา ตัวถูกดำเนินการสำหรับ - เป็น AST::Number (เลข 2 ). ซึ่งหมายความว่าจุดต่อไปของเราคือ #interpret_number :

def interpret_number(number)
  number.value
end

การใช้งาน #interpret_number เป็นเค้กชิ้นหนึ่ง การตัดสินใจของเราที่จะใช้ Ruby float เป็นการแสดงตัวเลข (สิ่งนี้เกิดขึ้นใน lexer!) จ่ายออกไปที่นี่ @value ของ AST::Number โหนดมีการแสดงตัวเลขภายในที่ต้องการอยู่แล้ว ดังนั้นเราจึงดึงมันมา

ด้วยเหตุนี้ เราจึงตีความลูกโดยตรงคนแรกของ AST::Program . ให้เสร็จ . เพื่อสรุปการแปลโปรแกรมของเรา เราต้องจัดการกับลูกอื่นที่มีขนดกมากขึ้น:โหนดประเภท AST::Conditional .

ข้อกำหนดและเงื่อนไขอาจมีผลบังคับใช้

ย้อนกลับไปใน #interpret_nodes , เพื่อนที่ดีที่สุดของเรา #interpret_node ถูกเรียกอีกครั้งเพื่อตีความลูกโดยตรงคนต่อไปของ AST::Program .

def interpret_nodes(nodes)
  last_value = nil

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

  last_value
end

วิธีการที่รับผิดชอบในการตีความ AST::Conditional คือ #interpret_conditional . ก่อนที่เราจะดูมัน เรามารีเฟรชความทรงจำของเราโดยทบทวนการใช้งาน AST::Conditional ตัวเอง:

class Stoffle::AST::Conditional < Stoffle::AST::Expression
  attr_accessor :condition, :when_true, :when_false

  def initialize(cond_expr = nil, true_block = nil, false_block = nil)
    @condition = cond_expr
    @when_true = true_block
    @when_false = false_block
  end

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

  def children
    [condition, when_true, when_false]
  end
end

โอเค งั้น @condition มีการแสดงออกที่จะเป็นจริงหรือเท็จ @when_true ถือบล็อกที่มีหนึ่งนิพจน์ขึ้นไปที่จะดำเนินการในกรณีที่ @condition เป็นความจริงและ @when_false (ELSE ข้อ) ถือบล็อกที่จะเรียกใช้ในกรณีที่ @condition เกิดขึ้นเป็นเท็จ

ทีนี้มาดูที่ #interpret_condition :

def interpret_conditional(conditional)
  evaluated_cond = interpret_node(conditional.condition)

  # We could implement the line below in a shorter way, but better to be explicit about truthiness in Stoffle.
  if evaluated_cond == nil || evaluated_cond == false
    return nil if conditional.when_false.nil?

    interpret_nodes(conditional.when_false.expressions)
  else
    interpret_nodes(conditional.when_true.expressions)
  end
end

ความจริงใน Stoffle เหมือนกับใน Ruby กล่าวอีกนัยหนึ่ง ใน Stoffle มีเพียง nil และ false เป็นเท็จ อินพุตอื่นใดของเงื่อนไขเป็นความจริง

ขั้นแรก เราประเมินเงื่อนไขโดยการตีความนิพจน์ที่ถือโดย conditional.condition . มาดู AST ของโปรแกรมของเราอีกครั้งเพื่อดูว่าเรากำลังจัดการกับโหนดใด:

การสร้างภาษาการเขียนโปรแกรมใหม่ใน Ruby:The Interpreter

ปรากฎว่าเรามี AST::BinaryOperator (> ใช้ใน num > 0 ). โอเค มันเป็นเส้นทางเดิมอีกครั้ง:first #interpret_node ซึ่งเรียก #interpret_binary_operator ครั้งนี้:

def interpret_binary_operator(binary_op)
  case binary_op.operator
  when :and
    interpret_node(binary_op.left) && interpret_node(binary_op.right)
  when :or
    interpret_node(binary_op.left) || interpret_node(binary_op.right)
  else
    interpret_node(binary_op.left).send(binary_op.operator, interpret_node(binary_op.right))
  end
end

ตัวดำเนินการเชิงตรรกะของเรา (and และ or ) ถือได้ว่าเป็นตัวดำเนินการไบนารี ดังนั้นเราจึงจัดการพวกมันที่นี่เช่นกัน เนื่องจากความหมายของมันเทียบเท่ากับ && . ของ Ruby และ || , การใช้งานเป็นแบบแล่นเรือธรรมดาอย่างที่คุณเห็นด้านบน

ต่อไปเป็นส่วนของวิธีการที่เราสนใจมากที่สุด ส่วนนี้จัดการตัวดำเนินการไบนารีอื่นๆ ทั้งหมด (รวมถึง > ). ที่นี่ เราสามารถใช้ประโยชน์จากไดนามิกของ Ruby เพื่อประโยชน์ของเราและคิดวิธีแก้ปัญหาที่รัดกุม ใน Ruby มีตัวดำเนินการไบนารีเป็นเมธอดในออบเจ็กต์ที่เข้าร่วมในการดำเนินการ:

-2 > 0           # is equivalent to
-2.send(:'>', 0) # this
# and the following line would be a general solution,
# very similar to what we have in the interpreter
operand_1.send(binary_operator, operand_2)

การใช้งานแบบละเอียดของโอเปอเรเตอร์ไบนารี

อย่างที่คุณเห็น การใช้งานตัวดำเนินการไบนารีของเรานั้นกระชับมาก หาก Ruby ไม่ใช่ภาษาแบบไดนามิก หรือความหมายของโอเปอเรเตอร์ระหว่าง Ruby และ Stoffle ต่างกัน เราก็ไม่สามารถเขียนโค้ดโซลูชันในลักษณะนี้ได้

หากคุณเคยพบว่าตัวเองอยู่ในตำแหน่งเช่นนักออกแบบ/ผู้ปรับใช้ภาษา คุณสามารถถอยกลับไปใช้โซลูชันที่เรียบง่าย (แต่ไม่หรูหรานั้น) ได้เสมอ:โดยใช้โครงสร้างสวิตช์ ในกรณีของเรา การใช้งานจะมีลักษณะดังนี้:

# ... inside #interpret_binary_operator ...

case binary_op.operator
when :'+'
  interpret_node(binary_op.left) + interpret_node(binary_op.right)
# ... other operators
end

ก่อนจะกลับไปที่ #interpret_conditional , ลองอ้อมไปอย่างรวดเร็วเพื่อให้แน่ใจว่าไม่มีอะไรถูกมองข้าม หากคุณจำโปรแกรมที่เรากำลังตีความได้ num ตัวแปรถูกใช้ในการเปรียบเทียบ (โดยใช้ตัวดำเนินการไบนารี > ) เราเพิ่งสำรวจด้วยกัน เราดึงตัวถูกดำเนินการทางซ้ายได้อย่างไร (เช่น ค่าที่เก็บไว้ใน num ตัวแปร) ของการเปรียบเทียบนั้น? เมธอดที่รับผิดชอบคือ #interpret_identifier และการใช้งานนั้นง่าย:

def interpret_identifier(identifier)
  if env.has_key?(identifier.name)
    env[identifier.name]
  else
    # Undefined variable.
    raise Stoffle::Error::Runtime::UndefinedVariable.new(identifier.name)
  end
end

กลับไปที่ #interpret_conditional . ในกรณีของโปรแกรมเล็กๆ ของเรา เงื่อนไขจะถูกประเมินเป็น Ruby false ค่า. เราใช้ค่านี้เพื่อกำหนดว่าเราต้องดำเนินการ IF หรือสาขา ELSE ของโครงสร้างตามเงื่อนไข เราดำเนินการตีความสาขา ELSE ซึ่งบล็อกรหัสที่เกี่ยวข้องถูกเก็บไว้ใน conditional.when_false . เรามี AST::Block ซึ่งคล้ายกับรูทโหนดของ AST ของเรามาก (AST::Program ). บล็อกเช่นเดียวกันอาจมีนิพจน์จำนวนมากที่ต้องตีความ เพื่อจุดประสงค์นี้ เรายังใช้ #interpret_nodes .

def interpret_conditional(conditional)
  evaluated_cond = interpret_node(conditional.condition)

  # We could implement the line below in a shorter way, but better to be explicit about truthiness in Stoffle.
  if evaluated_cond == nil || evaluated_cond == false
    return nil if conditional.when_false.nil?

    interpret_nodes(conditional.when_false.expressions)
  else
    interpret_nodes(conditional.when_true.expressions)
  end
end

โหนด AST ถัดไปที่เราต้องจัดการคือ AST::FunctionCall . เมธอดที่ทำหน้าที่ตีความการเรียกใช้ฟังก์ชันคือ #interpret_function_call :

def interpret_function_call(fn_call)
  return if println(fn_call)
end

ดังที่เราได้พูดคุยกันในตอนต้นของบทความ คำจำกัดความของฟังก์ชันและการเรียกใช้ฟังก์ชันจะกล่าวถึงในโพสต์ถัดไปในชุดนี้ ดังนั้นเราจึงใช้เฉพาะกรณีพิเศษของการเรียกใช้ฟังก์ชันเท่านั้น ในภาษาของเล่นเล็ก ๆ ของเรา เรามี println เป็นส่วนหนึ่งของรันไทม์และนำไปใช้โดยตรงในล่ามที่นี่ เป็นทางออกที่ดีเมื่อพิจารณาถึงวัตถุประสงค์และขอบเขตของโครงการ

def println(fn_call)
  return false if fn_call.function_name_as_str != 'println'

  result = interpret_node(fn_call.args.first).to_s
  output << result
  puts result
  true
end

อาร์กิวเมนต์แรกและตัวเดียวของ AST::FunctionCall . ของเรา เป็น AST::String ซึ่งจัดการโดย #interpret_string :

def interpret_string(string)
  string.value
end

ใน #interpret_string เรามีกรณีเดียวกันกับ #interpret_number . AST::String มีค่าสตริง Ruby ที่พร้อมใช้งานอยู่แล้ว ดังนั้นเราจึงต้องดึงมันกลับมา

กลับไปที่ #println :

def println(fn_call)
  return false if fn_call.function_name_as_str != 'println'

  result = interpret_node(fn_call.args.first).to_s
  output << result
  puts result
  true
end

หลังจากจัดเก็บอาร์กิวเมนต์ของฟังก์ชัน (แปลงเป็นสตริง Ruby) ใน result เรามีอีกสองขั้นตอนที่ต้องทำให้เสร็จ ขั้นแรก เราเก็บสิ่งที่เรากำลังจะพิมพ์ไปยังคอนโซลใน @output . ตามที่ได้อธิบายไว้ก่อนหน้านี้ แนวคิดในที่นี้คือการตรวจสอบสิ่งที่พิมพ์ออกมาได้อย่างง่ายดาย (และในลำดับใด) การมีสิ่งนี้อยู่ในมือทำให้ชีวิตของเราง่ายขึ้นเมื่อทำการดีบั๊กหรือทดสอบล่าม สุดท้าย ในการดำเนินการพิมพ์บางอย่างไปยังคอนโซล เราใช้ puts . ของ Ruby .

เรื่องการดำเนินการ

ตอนนี้เราได้สำรวจทุกสิ่งที่จำเป็นในการปรับใช้ส่วนเปล่าของ Stoffle แล้ว มาสร้างไฟล์สั่งการขั้นพื้นฐานเพื่อดูการทำงานของล่าม

#!/usr/bin/env ruby

require_relative '../lib/stoffle'

path = ARGV[0]
source = File.read(path)
lexer = Stoffle::Lexer.new(source)
parser = Stoffle::Parser.new(lexer.start_tokenization)
interpreter = Stoffle::Interpreter.new

interpreter.interpret(parser.parse)

exit(0)

เคล็ดลับ: หากต้องการใช้ล่ามของ Stoffle จากทุกที่ อย่าลืมเพิ่มไฟล์ปฏิบัติการลงใน PATH ของคุณ

ในที่สุดก็ถึงเวลาเรียกใช้โปรแกรมของเรา หากทุกอย่างทำงานได้ดี เราควรเห็นสตริง "ตัวเลขน้อยกว่าหรือเท่ากับศูนย์" พิมพ์ลงในคอนโซล นี่คือสิ่งที่เกิดขึ้นเมื่อเราเรียกใช้ล่าม:

การสร้างภาษาการเขียนโปรแกรมใหม่ใน Ruby:The Interpreter

เคล็ดลับ: หากคุณได้ติดตั้งล่ามไว้ ให้ลองเปลี่ยน num ตัวแปรในโปรแกรมตัวอย่างของเราเพื่อให้มีค่ามากกว่าศูนย์ ตามที่คาดไว้ ตอนนี้สาขา IF จะถูกดำเนินการ และสตริง "The number is more than zero" จะถูกพิมพ์ออกมา

สรุปผล

ในโพสต์นี้ เราเห็นจุดเริ่มต้นของล่ามของ Stoffle เราใช้ล่ามเพียงพอสำหรับจัดการกับพื้นฐานภาษาบางอย่าง:ตัวแปร เงื่อนไข ตัวดำเนินการ unary และไบนารี ชนิดข้อมูล และการพิมพ์ไปยังคอนโซล ในส่วนถัดไปและสุดท้ายในล่าม เราจะจัดการกับบิตที่เหลือที่จำเป็นสำหรับเราเพื่อให้ภาษาของเล่นเล็กๆ ของเราทำงานตามที่ออกแบบไว้:การกำหนดขอบเขตตัวแปร การกำหนดฟังก์ชัน การเรียกใช้ฟังก์ชัน และการวนซ้ำ ฉันหวังว่าคุณจะสนุกกับการอ่านบทความ (ฉันสนุกกับการเขียนมันอย่างแน่นอน!) แล้วพบกันใหม่ในโพสต์ถัดไปในซีรีส์นี้!