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

การสร้าง Ruby Interpreter: ภาค 2 – การสร้างภาษาโปรแกรมมิ่งแบบกำหนดเอง

ซอร์สโค้ดเต็มรูปแบบบน GitHub

การนำภาษาโปรแกรมมิ่ง Stoffle ไปใช้งานอย่างสมบูรณ์มีให้ดาวน์โหลดบน GitHub โปรดเปิด issue หากพบบั๊กหรือมีคำถาม

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

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

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

Gauss กลับมาอีกครั้ง

หากคุณมีความจำดี คุณอาจจำได้ว่าในภาคที่ 2 ของซีรีส์ เราได้พูดถึงการสร้าง Lexer ในบทความนั้น เราได้ดูโปรแกรมสำหรับหาผลรวมของเลขในช่วงเพื่อแสดงไวยากรณ์ของ 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) 

Abstract Syntax Tree (AST) สำหรับโปรแกรมหาผลรวมจำนวนเต็มของเราคือ:

การสร้าง Ruby Interpreter: ภาค 2 – การสร้างภาษาโปรแกรมมิ่งแบบกำหนดเอง

นักคณิตศาสตร์ที่เป็นแรงบันดาลใจให้โปรแกรมตัวอย่าง Stoffle

คาร์ล ฟรีดริช กอส (Carl Friedrich Gauss) 據說在年僅 7 歲時 自行發現了求級數和的公式。

สังเกตได้ว่าโปรแกรมของเราไม่ได้ใช้สูตรของ Gauss เนื่องจากปัจจุบันเรามีคอมพิวเตอร์ เราจึงมีสะดวกในการแก้ปัญหานี้แบบ "brute-force" ให้ซิลิคอนทำงานหนักแทนเรา

การกำหนดฟังก์ชัน

สิ่งแรกที่เราทำในโปรแกรมคือกำหนดฟังก์ชัน sum_integers การประกาศฟังก์ชันหมายถึงอะไร? ตามที่คุณคาดเดา นี่เป็นการกระทำที่คล้ายกับการกำหนดค่าให้ตัวแปร เมื่อเรากำหนดฟังก์ชัน เรากำลังเชื่อมโยงชื่อ (เช่น ชื่อฟังก์ชัน ซึ่งเป็น identifier) กับนิพจน์หนึ่งหรือหลายตัว (คือ body ของฟังก์ชัน) เรายังจดบันทึกว่าค่าที่ส่งเข้ามาตอนเรียกฟังก์ชันควรผูกกับชื่ออะไร identifier เหล่านี้จะกลายเป็นตัวแปร local ระหว่างการทำงานของฟังก์ชัน และเรียกว่า parameters ค่าที่ส่งเข้ามาตอนเรียกฟังก์ชัน (และผูกกับ parameters) เรียกว่า arguments

มาดู #interpret_function_definition:

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

ตรงไปตรงมาเลยใช่ไหม? ตามที่คุณจำได้จากบทความล่าสุด เมื่อ interpreter ถูกสร้างขึ้น เราสร้าง environment ขึ้นมา นี่คือที่เก็บสถานะของโปรแกรม ซึ่งในกรณีนี้คือแค่ Ruby hash ในบทความก่อนหน้า เราเห็นว่าตัวแปรและค่าที่ผูกไว้ถูกเก็บใน env การกำหนดฟังก์ชันก็จะถูกเก็บไว้ที่นี่เช่นกัน key คือชื่อฟังก์ชัน และ value คือ AST node ที่ใช้กำหนดฟังก์ชัน (Stoffle::AST::FunctionDefinition) นี่คือการทบทวน AST node นี้:

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 # instance variable @name เป็น AST::Identifier name.name end def ==(other) children == other&.children end def children [name, params, body] end end 

การมีชื่อฟังก์ชันเชื่อมโยงกับ Stoffle::AST::FunctionDefinition หมายความว่าเราสามารถเข้าถึงข้อมูลทั้งหมดที่จำเป็นสำหรับการรันฟังก์ชันได้ ตัวอย่างเช่น เรารู้จำนวน arguments ที่คาดหวัง และสามารถแสดง error ได้ง่ายหากการเรียกฟังก์ชันไม่ได้ให้ argument ตรงตามจำนวน รายละเอียดอื่นๆ เราจะเห็นเมื่อเราสำรวจโค้ดที่รับผิดชอบการตีความการเรียกฟังก์ชัน ต่อไป

การเรียกใช้ฟังก์ชัน

ต่อไปมาดูการเรียกฟังก์ชัน หลังจากกำหนดฟังก์ชัน sum_integers แล้ว เราเรียกใช้มันโดยส่งตัวเลข 1 และ 100 เป็น arguments:

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 หรือไม่ หากเป็นฟังก์ชันที่ผู้ใช้กำหนดเอง (กรณีนี้) เราจะดำเนินต่อไปและดึง definition มาโดยใช้ #fetch_function_definition ตามโค้ดด้านล่าง ฟังก์ชันนี้ตรงไปตรงมา เราเพียงแค่ดึง AST node Stoffle::AST::FunctionDefinition ที่เราเก็บไว้ใน environment ก่อนหน้านี้ หรือ raise error หากฟังก์ชันไม่มีอยู่

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 เรื่องเริ่มน่าสนใจขึ้น เมื่อคิดถึงฟังก์ชันในภาษาเล็กๆ ของเรา เรามีสองเรื่องสำคัญที่ต้องจัดการ แรก เราต้องมีกลยุทธ์ในการติดตามตัวแปรที่เป็น local ของฟังก์ชัน สอง เราต้องจัดการกับนิพจน์ return เพื่อแก้ปัญหาเหล่านี้ เราจะสร้าง object ใหม่ซึ่งเราจะเรียกว่า frame ทุกครั้งที่ฟังก์ชันถูกเรียก แม้ฟังก์ชันเดียวกันจะถูกเรียกหลายครั้ง แต่แต่ละครั้งจะสร้าง frame ใหม่ object นี้จะเก็บตัวแปร local ทั้งหมดของฟังก์ชัน เนื่องจากฟังก์ชันหนึ่งสามารถเรียกฟังก์ชันอื่นได้ เราจึงต้องมีวิธีแทนและติดตามการไหลของการทำงานของโปรแกรม เราจะใช้โครงสร้างข้อมูล stack ซึ่งเราจะเรียกว่า call stack ใน Ruby array ธรรมดาที่มี method #push และ #pop จะทำหน้าที่ stack ได้ดีพอ

Call Stack และ Stack Frame

โปรดจำว่าเราใช้คำว่า call stack และ stack frame อย่างหลวมๆ โปรเซสเซอร์และภาษา low-level โดยทั่วไปก็จะมี call stacks และ stack frames แต่ไม่ได้เป็นเหมือนกับที่เรามีในภาษาเล็กของเรา

หากคุณสนใจแนวคิดเหล่านี้ ขอแนะนำให้ค้นคว้าเพิ่มเติม หากอยากเข้าใกล้ hardware จริงๆ ขอแนะนำให้ศึกษา processor call stacks โดยเฉพาะ

นี่คือโค้ดสำหรับ 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 ขั้นตอนต่อไปคือการกำหนดค่าที่ส่งในการเรียกฟังก์ชันให้กับ parameters ที่คาดหวัง ซึ่งจะเข้าถึงได้เป็นตัวแปร local ภายใน body ของฟังก์ชัน #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 ไปใช้งาน เราต้องพูดถึง variable scoping ก่อน นี่เป็นหัวข้อที่ซับซ้อนและละเอียดอ่อน สำหรับ Stoffle เราจะเป็น pragmatic และเลือกวิธีง่ายๆ ในภาษาเล็กของเรา โครงสร้างที่สร้าง scope ใหม่มีแค่ฟังก์ชันเท่านั้น นอกจากนี้ global variables จะมีความสำคัญก่อน ดังนั้นตัวแปรที่ประกาศ (การใช้งานครั้งแรก) นอกฟังก์ชันจะเป็น global และเก็บใน env ตัวแปรภายในฟังก์ชันจะเป็น local ของฟังก์ชันนั้น และเก็บใน env ของ stack frame ที่สร้างขึ้นระหว่างการตีความการเรียกฟังก์ชัน มีข้อยกเว้นหนึ่ง: ชื่อตัวแปรที่ชนกับ global variable ที่มีอยู่แล้ว หากชนกัน ตัวแปร local จะ ไม่ ถูกสร้าง และเราจะอ่านและกำหนดค่าให้ global variable ที่มีอยู่แทน

ตอนนี้กลับไปที่ #assign_function_args_to_params ในส่วนแรกของเมธอด เราดึง function definition และ function call nodes จาก stack frame object ที่ส่งมา ด้วยข้อมูลเหล่านี้ ตรวจสอบได้ง่ายว่าจำนวน arguments ที่ให้มาตรงกับจำนวน parameters ที่ฟังก์ชันคาดหวังหรือไม่ เรา raise error เมื่อมีความไม่ตรงกันระหว่าง arguments ที่ให้มาและ parameters ที่คาดหวัง ในส่วนท้ายของ #assign_function_args_to_params เรากำหนด arguments (ค่า) ที่ส่งในการเรียกฟังก์ชันให้กับ parameters (ตัวแปร local ภายในฟังก์ชัน) ตามลำดับ สังเกตว่าเราตรวจสอบว่าชื่อ parameter ชนกับ global variable ที่มีอยู่หรือไม่ ตามที่อธิบายไปแล้ว ในกรณีเหล่านี้ เราจะไม่สร้างตัวแปร local ภายใน stack frame ของฟังก์ชัน แต่จะกำหนดค่าที่ส่งมาให้ global variable ที่มีอยู่แทน

กลับไปที่ #interpret_function_call เราสุดท้ายก็ push stack frame ที่สร้างใหม่เข้า call stack จากนั้นเราเรียก #interpret_nodes เพื่อเริ่มตีความ body ของฟังก์ชัน:

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 

การตีความ Body ของฟังก์ชัน

ตอนนี้เราตีความการเรียกฟังก์ชันเสร็จแล้ว ถึงเวลาตีความ body ของฟังก์ชัน:

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 เป็นการกำหนดตัวแปร เราครอบคลุมหัวข้อนี้ในบทความก่อนหน้าแล้ว แต่ตอนนี้เรามี variable scoping ดังนั้นโค้ดที่จัดการการกำหนดค่าจึงเปลี่ยนไปเล็กน้อย มาสำรวจกัน:

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 

คุณจำได้ไหมว่าเรา push stack frame ที่สร้างสำหรับการเรียกฟังก์ชันเข้า call_stack ไป? สิ่งนี้มีประโยชน์ตอนนี้ เพราะเราสามารถตรวจสอบได้ว่าอยู่ภายในฟังก์ชันหรือไม่ โดยดูว่า call_stack มีความยาวมากกว่าศูนย์ (คือมี อย่างน้อย หนึ่ง stack frame) หากเราอยู่ในฟังก์ชัน (กรณีนี้) เราตรวจสอบว่ามี global variable ชื่อเดียวกับตัวแปรที่เราพยายามผูกค่าใหม่อยู่แล้วหรือไม่ ตามที่คุณรู้ หากชนกัน เราจะแค่กำหนดค่าให้ global variable ที่มีอยู่ และจะไม่สร้างตัวแปร local เมื่อชื่อไม่ได้ถูกใช้ เราจะสร้างตัวแปร local ใหม่และกำหนดค่าที่ต้องการให้ เนื่องจาก call_stack เป็น stack (last in first out) เรารู้ว่าตัวแปร local นี้ควรเก็บใน env ของ frame ที่ถูก push ล่าสุด (คือ frame ที่สร้างสำหรับฟังก์ชันที่กำลังประมวลผล) ส่วนสุดท้ายของ #interpret_var_binding จัดการการกำหนดค่าที่เกิดขึ้นนอกฟังก์ชัน เนื่องจากใน Stoffle มีแค่ฟังก์ชันที่สร้าง scope ใหม่ ตัวแปรที่สร้างนอกฟังก์ชันจะเป็น global เสมอ และเก็บที่ instance variable env

กลับไปที่โปรแกรมของเรา ขั้นตอนต่อไปคือตีความลูปที่รับผิดชอบหาผลรวมจำนวนเต็ม มาทบทวน AST ของโปรแกรม Stoffle อีกครั้ง:

การสร้าง Ruby Interpreter: ภาค 2 – การสร้างภาษาโปรแกรมมิ่งแบบกำหนดเอง

Node ที่แทนลูปคือ 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 node นี้รวมแนวคิดที่เราเคยสำรวจในบทความก่อนหน้า สำหรับ conditional จะมีนิพจน์ที่โดยทั่วไปจะมี root ของ AST เป็น Stoffle::AST::BinaryOperator (เช่น '>', 'or' ฯลฯ) สำหรับ body ของลูป จะมี Stoffle::AST::Block นี่สมเหตุสมผล เพราะรูปแบบพื้นฐานของลูปคือ นิพจน์หนึ่งหรือหลายตัว (หนึ่ง block) ที่จะทำซ้ำไปเรื่อยๆ ตราบใดที่นิพจน์เงื่อนไขเป็น truthy (คือ conditional ให้ค่า truthy)

เมธอดที่ตรงกันใน interpreter คือ #interpret_repetition:

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

ที่นี่คุณอาจตกใจกับความเรียบง่าย (และกล้าพูดว่า ความสวยงาม) ของเมธอดนี้ เราสามารถนำการตีความลูปไปใช้งานได้โดยการรวมเมธอดที่เราสำรวจมาในบทความก่อนหน้า ด้วยการใช้ while loop ของ Ruby เราสามารถรับรองได้ว่าเราตีความ nodes ที่ประกอบเป็นลูป Stoffle ต่อไป (โดยการเรียก #interpret_nodes ซ้ำๆ) ตราบใดที่การประเมิน conditional เป็น true งานประเมิน conditional ก็ง่ายดายเพียงแค่เรียก #interpret_node ตามปกติ

การ return จากฟังก์ชัน

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

เพื่อจบบทความนี้ เราต้องกลับมาดู #interpret_nodes อีกครั้ง เวอร์ชันสุดท้ายต่างจากที่เคยเห็นไปนิดหน่อย ตอนนี้รวมโค้ดสำหรับจัดการการ return จากฟังก์ชันและการ unwind call stack นี่คือ #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 ถูกใช้ทุกครั้งที่เราต้องตีความนิพจน์หลายตัว ใช้ตอนเริ่มตีความโปรแกรม และทุกครั้งที่เจอ nodes ที่มี block ผูกอยู่ (เช่น Stoffle::AST::FunctionDefinition) โดยเฉพาะตอนจัดการฟังก์ชัน มีสองสถานการณ์: ตีความฟังก์ชันและเจอ return หรือ ตีความฟังก์ชันจนจบโดยไม่เจอ return กรณีหลังหมายถึงฟังก์ชันไม่มี return ใดๆ หรือ code path ที่เราเดินผ่านไม่มี 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 ใดๆ นี่คือ path ที่ง่ายที่สุดของ #interpret_nodes เราแค่ iterate ผ่าน nodes ทั้งหมดของฟังก์ชัน แล้ว return ค่าของนิพจน์สุดท้ายที่ตีความ (เตือนใจ: Stoffle มี implicit returns) นี่นำพาเราสู่เส้นชัย สรุปการตีความโปรแกรมของเรา

แม้โปรแกรมของเราจะตีความเสร็จแล้ว แต่จุดประสงค์หลักของบทความนี้คืออธิบายการนำ interpreter ไปใช้งาน เราจะใช้เวลาอีกนิดหน่อยดูว่า interpreter จัดการกรณีที่เราเจอ return ภายในฟังก์ชันอย่างไร

ก่อนอื่น นิพจน์ 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 

จากนั้นเรามี conditional ง่ายๆ ที่ตรวจจับ AST nodes return หลังจากนั้น เราทำ sanity check ว่าอยู่ในฟังก์ชันหรือไม่ โดยดูความยาวของ call stack ความยาวมากกว่าศูนย์หมายถึงอยู่ในฟังก์ชันจริง ใน Stoffle เราไม่อนุญาตให้ใช้ return นอกฟังก์ชัน ดังนั้น raise error หากเช็คไม่ผ่าน ก่อน return ค่าที่โปรแกรมเมอร์ต้องการ เราจะบันทึกความยาวปัจจุบันของ call stack ไว้ที่ instance variable 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 สังเกตว่าเรา pop stack frame ออกจาก call stack หลังตีความฟังก์ชัน ดังนั้นความยาวของ call stack จะลดลงหนึ่ง เนื่องจากเราเก็บความยาวของ stack ณ моментที่ตรวจพบ return เราจึงสามารถเปรียบเทียบความยาวเริ่มต้นนี้ทุกครั้งที่ตีความ node ใหม่ใน #interpret_nodes มาดูส่วนที่ทำสิ่งนี้ภายใน node iterator ของ #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 

นี่อาจเข้าใจยากครั้งแรก ขอแนะนำให้ดู full implementation บน GitHub และลองเล่นกับมัน หากคิดว่าจะช่วยให้เข้าใจส่วนสุดท้ายนี้ของ interpreter แนวคิดสำคัญคือ โปรแกรมทั่วไปมีโครงสร้างซ้อนกันหลายชั้น ดังนั้นการรัน #interpret_nodes มักจะทำให้เกิดการเรียก #interpret_nodes ใหม่ ซึ่งอาจนำไปสู่การเรียก #interpret_nodes อีกมากขึ้น! เมื่อเราเจอ return ภายในฟังก์ชัน เราอาจอยู่ในโครงสร้างซ้อนลึกๆ จงจินตนาการว่า return อยู่ใน conditional ที่เป็นส่วนหนึ่งของลูป เพื่อ return จากฟังก์ชัน เราต้อง return จาก #interpret_nodes ทั้งหมดจนกว่าจะ return จากตัวที่ถูกเริ่มต้นโดย #interpret_function_call (คือการเรียก #interpret_nodes ที่เริ่มการตีความ body ของฟังก์ชัน)

ในส่วนของโค้ดด้านบน เราเน้นย้ำวิธีการทำอย่างชัดเจน โดยมีค่าบวกที่ @unwind_call_stack และ เท่ากับความยาวปัจจุบันของ call stack เราจึงทราบแน่ชัดว่ายังอยู่ในฟังก์ชันที่ return และยังไม่ได้ return จากการเรียกต้นทางที่เริ่มโดย #interpret_function_call เมื่อสิ่งนั้นเกิดขึ้นจริง @unwind_call_stack จะมากกว่าความยาวปัจจุบันของ call stack ดังนั้นเรารู้ว่าเราออกจากฟังก์ชันที่ return แล้ว และไม่ต้องทำ bubbling up ต่อ แล้วเรา reset @unwind_call_stack เพื่อให้เข้าใจการใช้ @unwind_call_stack ชัดเจน นี่คือค่าที่เป็นไปได้:

  • -1 ค่าเริ่มต้นและ netral บ่งบอกว่าไม่อยู่ในฟังก์ชันที่ return ใดๆ
  • ค่าบวกเท่ากับความยาว call stack บ่งบอกว่ายังอยู่ในฟังก์ชันที่ return
  • ค่าบวกมากกว่าความยาว call stack บ่งบอกว่าไม่อยู่ในฟังก์ชันที่ return แล้ว

การรันโปรแกรมของเราโดยใช้ Stoffle CLI

ในบทความก่อนหน้าของซีรีส์ เราสร้าง CLI 簡單 เพื่อให้การตีความโปรแกรม Stoffle ง่ายขึ้น ตอนนี้เราได้สำรวจการนำ interpreter ไปใช้งานแล้ว มาดูมันทำงานจริงโดยรันโปรแกรมของเรา ตามที่แสดงในหลายส่วน โค้ดของเรากำหนดแล้วเรียกฟังก์ชัน sum_integers ส่ง arguments 1 และ 100 หาก interpreter ทำงานถูกต้อง เราควรเห็น 5050.0 (ผลรวมของจำนวนเต็มตั้งแต่ 1 ถึง 100) พิมพ์ออกคอนโซล:

การสร้าง Ruby Interpreter: ภาค 2 – การสร้างภาษาโปรแกรมมิ่งแบบกำหนดเอง

ข้อคิดสรุป

ในบทความนี้ เรานำส่วนที่เหลือซึ่งจำเป็นต่อการ完成 interpreter ไปใช้งาน เราจัดการกับการกำหนดฟังก์ชัน การเรียกฟังก์ชัน variable scoping และลูป ตอนนี้เรามีภาษาโปรแกรมมิ่งเล็กๆ ที่ทำงานได้แล้ว!

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