นกกระสา ตอนที่ 3: การใช้นิพจน์และตัวแปร

เผยแพร่แล้ว: 2022-03-11

ในตอนที่ 3 ของซีรีส์ของเรา ในที่สุดภาษาโปรแกรมแบบไลท์เวทของเราจะทำงาน มันจะไม่สมบูรณ์ทัวริง มันจะไม่มีประสิทธิภาพ แต่จะสามารถประเมินนิพจน์และแม้แต่เรียกใช้ฟังก์ชันภายนอกที่เขียนใน C ++

ฉันจะพยายามอธิบายกระบวนการอย่างละเอียดที่สุดเท่าที่จะทำได้ ส่วนใหญ่เป็นเพราะมันเป็นจุดประสงค์ของชุดบล็อกนี้ แต่สำหรับเอกสารของฉันเองด้วย เพราะในส่วนนี้ สิ่งต่าง ๆ มีความซับซ้อนเล็กน้อย

ฉันเริ่มเขียนโค้ดสำหรับส่วนนี้ก่อนเผยแพร่บทความที่สอง แต่ปรากฏว่า parser นิพจน์ควรเป็นองค์ประกอบแบบสแตนด์อโลนที่สมควรได้รับการโพสต์ในบล็อกของตัวเอง

ร่วมกับเทคนิคการเขียนโปรแกรมที่น่าอับอายบางอย่างทำให้ส่วนนี้ไม่ใหญ่โตอย่างมหึมา แต่ผู้อ่านบางคนมักจะชี้ไปที่เทคนิคการเขียนโปรแกรมดังกล่าวและสงสัยว่าทำไมฉันถึงต้องใช้พวกเขา

ทำไมเราใช้มาโคร?

เมื่อฉันได้รับประสบการณ์การเขียนโปรแกรมในการทำงานในโครงการต่างๆ และกับผู้คนที่แตกต่างกัน ฉันได้เรียนรู้ว่านักพัฒนามักจะเป็นคนดื้อรั้น อาจเป็นเพราะมันง่ายกว่า

มาโครใน C++

หลักประการแรกของการเขียนโปรแกรมคือคำสั่ง goto นั้นไม่ดี ชั่วร้าย และน่ากลัว ฉันสามารถเข้าใจได้ว่าความรู้สึกนั้นเกิดขึ้นที่ใด และฉันเห็นด้วยกับแนวคิดนั้นในกรณีส่วนใหญ่เมื่อมีคนใช้คำสั่ง goto โดยปกติสามารถหลีกเลี่ยงได้และสามารถเขียนโค้ดที่อ่านได้ง่ายขึ้นแทน

อย่างไรก็ตาม เราไม่สามารถปฏิเสธได้ว่าการหลุดจากวงในในภาษา C++ สามารถทำได้ง่ายด้วยคำสั่ง goto ทางเลือกอื่น—ซึ่งต้องใช้ตัวแปร bool ลหรือฟังก์ชันเฉพาะ—อาจอ่านได้น้อยกว่าโค้ดที่ตกลงไปในถังของเทคนิคการเขียนโปรแกรมต้องห้าม

หลักคำสอนประการที่สองซึ่งเกี่ยวข้องกับนักพัฒนา C และ C++ โดยเฉพาะคือมาโครนั้นไม่ดี ชั่วร้าย เลวร้าย และโดยพื้นฐานแล้ว ภัยพิบัติกำลังรอที่จะเกิดขึ้น นี้มักจะมาพร้อมกับตัวอย่างนี้:

 #define max(a, b) ((a) > (b) ? (a) : (b)) ... int x = 3; int z = 2; int y = max(x++, z);

แล้วก็มีคำถาม: ค่าของ x หลังโค้ดชิ้นนี้คืออะไร และคำตอบคือ 5 เพราะ x เพิ่มขึ้นสองครั้ง อันละด้านของ ? -โอเปอเรเตอร์

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

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

คุณสามารถค้นหาโค้ดและมาโครทั้งหมดสำหรับส่วนนี้ได้ที่นี่

ตัวแปร

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

เนื่องจากเราต้องการเก็บไว้ในคอนเทนเนอร์ตัวแปรส่วนกลางหรือบนสแต็ก วิธีหนึ่งที่สะดวกคือการกำหนดคลาสฐานและสืบทอดจากคลาสนั้น

 class variable; using variable_ptr = std::shared_ptr<variable>; class variable: public std::enable_shared_from_this<variable> { private: variable(const variable&) = delete; void operator=(const variable&) = delete; protected: variable() = default; public: virtual ~variable() = default; virtual variable_ptr clone() const = 0; template <typename T> T static_pointer_downcast() { return std::static_pointer_cast< variable_impl<typename T::element_type::value_type> >(shared_from_this()); } };

อย่างที่คุณเห็น มันค่อนข้างเรียบง่าย และฟังก์ชัน clone ซึ่งทำการคัดลอกแบบลึก เป็นฟังก์ชันสมาชิกเสมือนเพียงฟังก์ชันเดียวที่นอกเหนือจากตัวทำลาย

เนื่องจากเราจะใช้อ็อบเจ็กต์ของคลาสนี้ผ่าน shared_ptr เสมอ จึงควรที่จะสืบทอดจาก std::enable_shared_from_this เพื่อที่เราจะได้แชร์พอยน์เตอร์จากคลาสนี้ได้อย่างง่ายดาย ฟังก์ชัน static_pointer_downcast อยู่ที่นี่เพื่อความสะดวก เนื่องจากเรามักจะต้อง downcast จากคลาสนี้ไปสู่การใช้งาน

การนำคลาสนี้ไปใช้งานจริงคือ variable_impl พารามิเตอร์ด้วยประเภทที่มีอยู่ จะมีการยกตัวอย่างสำหรับสี่ประเภทที่เราจะใช้:

 using number = double; using string = std::shared_ptr<std::string>; using array = std::deque<variable_ptr>; using function = std::function<void(runtime_context&)>;

เราจะใช้ double เป็นประเภทตัวเลขของเรา สตริงจะถูกนับโดยการอ้างอิง เนื่องจากจะไม่เปลี่ยนรูปแบบ เพื่อเปิดใช้งานการเพิ่มประสิทธิภาพบางอย่างเมื่อส่งผ่านตามค่า Array จะเป็น std::deque เนื่องจากมีเสถียรภาพ และโปรดทราบว่า runtime_context เป็นคลาสที่เก็บข้อมูลที่เกี่ยวข้องทั้งหมดเกี่ยวกับหน่วยความจำของโปรแกรมระหว่างรันไทม์ เราจะไปที่นั่นในภายหลัง

คำจำกัดความต่อไปนี้มักใช้เช่นกัน:

 using lvalue = variable_ptr; using lnumber = std::shared_ptr<variable_impl<number>>; using lstring = std::shared_ptr<variable_impl<string>>; using larray = std::shared_ptr<variable_impl<array>>; using lfunction = std::shared_ptr<variable_impl<function>>;

“l” ที่ใช้ในที่นี้ย่อมาจาก “lvalue” เมื่อใดก็ตามที่เรามีค่า lvalue สำหรับบางประเภท เราจะใช้ตัวชี้ที่ใช้ร่วมกันไปที่ variable_impl

บริบทรันไทม์

ระหว่างรันไทม์ สถานะหน่วยความจำจะถูกเก็บไว้ในคลาส runtime_context

 class runtime_context{ private: std::vector<variable_ptr> _globals; std::deque<variable_ptr> _stack; std::stack<size_t> _retval_idx; public: runtime_context(size_t globals); variable_ptr& global(int idx); variable_ptr& retval(); variable_ptr& local(int idx); void push(variable_ptr v); void end_scope(size_t scope_vars); void call(); variable_ptr end_function(size_t params); };

เริ่มต้นด้วยการนับตัวแปรส่วนกลาง

  • _globals เก็บตัวแปรโกลบอลทั้งหมด สามารถเข้าถึงได้ด้วยฟังก์ชันสมาชิก global ด้วยดัชนีแบบสัมบูรณ์
  • _stack เก็บตัวแปรในเครื่องและอาร์กิวเมนต์ของฟังก์ชัน และจำนวนเต็มที่อยู่ด้านบนสุดของ _retval_idx จะเก็บดัชนีแบบสัมบูรณ์ใน _stack ของค่าส่งคืนปัจจุบัน
  • ค่าที่ส่งกลับ สามารถเข้าถึงได้ด้วยฟังก์ชัน retval ในขณะที่ตัวแปรท้องถิ่นและอาร์กิวเมนต์ของฟังก์ชันสามารถเข้าถึงได้ด้วยฟังก์ชัน local โดยการส่งผ่านดัชนีที่สัมพันธ์กับค่าที่ส่งคืนในปัจจุบัน อาร์กิวเมนต์ของฟังก์ชันมีดัชนีเชิงลบในกรณีนี้
  • ฟังก์ชัน push ชจะเพิ่มตัวแปรลงในสแต็ก ในขณะที่ end_scope จะลบจำนวนตัวแปรที่ส่งผ่านออกจากสแต็ก
  • ฟังก์ชันการ call จะปรับขนาดสแต็กทีละหนึ่งและผลักดัชนีขององค์ประกอบสุดท้ายใน _stack ไปที่ _retval_idx
  • end_function ลบค่าที่ส่งกลับและจำนวนอาร์กิวเมนต์ที่ส่งผ่านจากสแต็กและยังส่งคืนค่าที่ส่งคืนที่ลบออก

อย่างที่คุณเห็น เราจะไม่ใช้การจัดการหน่วยความจำระดับต่ำใดๆ และเราจะใช้ประโยชน์จากการจัดการหน่วยความจำแบบเนทีฟ (C++) ซึ่งเราสามารถมองข้ามได้ เราจะไม่นำการจัดสรรฮีปไปใช้ อย่างน้อยก็ในตอนนี้

ด้วย runtime_context ในที่สุด เราก็มีหน่วยการสร้างทั้งหมดที่จำเป็นสำหรับส่วนประกอบส่วนกลางและส่วนที่ยากที่สุดของส่วนนี้

นิพจน์

เพื่ออธิบายวิธีแก้ปัญหาที่ซับซ้อนอย่างครบถ้วนซึ่งฉันจะนำเสนอที่นี่ ฉันจะแนะนำคุณสั้น ๆ ถึงความพยายามที่ล้มเหลวสองสามครั้งที่ฉันทำก่อนที่จะตกลงกับแนวทางนี้

วิธีที่ง่ายที่สุดคือประเมินทุกนิพจน์เป็น variable_ptr และมีคลาสฐานเสมือนนี้:

 class expression { ... public: variable_ptr evaluate(runtime_context& context) const = 0; lnumber evaluate_lnumber(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lnumber>(); } lstring evaluate_lstring(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lstring>(); } number evaluate_number(runtime_context& context) const { return evaluate_lnumber(context)->value; } string evaluate_string(runtime_context& context) const { return evaluate_lstring(context)->value; } ... }; using expression_ptr = std::unique_ptr<expression>;

จากนั้นเราจะสืบทอดจากคลาสนี้สำหรับการดำเนินการแต่ละครั้ง เช่น การบวก การต่อ การเรียกใช้ฟังก์ชัน ฯลฯ ตัวอย่างเช่น นี่จะเป็นการนำนิพจน์เพิ่มเติมไปใช้:

 class add_expression: public expression { private: expression_ptr _expr1; expression_ptr _expr2; public: ... variable_ptr evaluate(runtime_context& context) const override{ return std::make_shared<variable_impl<number> >( _expr1->evaluate_number(context) + _expr2->evaluate_number(context) ); } ... };

ดังนั้นเราจำเป็นต้องประเมินทั้งสองด้าน ( _expr1 และ _expr2 ) เพิ่มพวกเขาแล้วสร้าง variable_impl<number>

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

จากนั้น ฉันก็ลองใช้นิพจน์เฉพาะประเภทที่สืบทอดมาจากฐานทั่วไป:

 class expression { ... public: virtual void evaluate(runtime_context& context) const = 0; ... }; class lvalue_expression: public virtual expression { ... public: virtual lvalue evaluate_lvalue(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_lvalue(context); } ... }; using lvalue_expression_ptr = std::unique_ptr<lvalue_expression>; class number_expression: public virtual expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr<number_expression>; class lnumber_expression: public lvalue_expression, public number_expression { ... public: virtual lnumber evaluate_lnumber(runtime_context& context) const = 0; lvalue evaluate_lvalue(runtime_context& context) const override { return evaluate_lnumber(context); } number evaluate_number(runtime_context& context) const override { return evaluate_lnumber(context)->value; } void evaluate(runtime_context& context) const override { return evaluate_lnumber(context); } ... }; using lnumber_expression_ptr = std::unique_ptr<lnumber_expression>;

นี่เป็นเพียงส่วนหนึ่งของลำดับชั้น (สำหรับตัวเลขเท่านั้น) และเราพบปัญหาเกี่ยวกับรูปทรงเพชรแล้ว (คลาสที่สืบทอดคลาสสองคลาสที่มีคลาสพื้นฐานเดียวกัน)

โชคดีที่ C++ มีการสืบทอดเสมือน ซึ่งช่วยให้สามารถสืบทอดจากคลาสพื้นฐาน โดยเก็บตัวชี้ไว้ ในคลาสที่สืบทอด ดังนั้น หากคลาส B และ C สืบทอดมาจาก A อย่างแท้จริง และคลาส D สืบทอดมาจาก B และ C ก็จะมี A เพียงสำเนาเดียวใน D

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

ตอนนี้ การใช้นิพจน์การบวกจะดูเป็นธรรมชาติมากขึ้น:

 class add_expression: public number_expression { private: number_expression_ptr _expr1; number_expression_ptr _expr2; public: ... number evaluate_number(runtime_context& context) const override{ return _expr1->evaluate_number(context) + _expr2->evaluate_number(context); } ... };

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

มาเพิ่มสตริงลงในมิกซ์นี้และดูว่าได้อะไรจากเรา:

 class string_expression: public virtual expression { ... public: virtual string evaluate_string(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_string(context); } ... }; using string_expression_ptr = std::unique_ptr<string_expression>;

เนื่องจากเราต้องการให้ตัวเลขสามารถแปลงเป็นสตริงได้ เราจึงต้องรับช่วง number_expression จาก string_expression

 class number_expression: public string_expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; string evaluate_string(runtime_context& context) const override { return tostring(evaluate_number(context)); } void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr<number_expression>;

เรารอดจากสิ่งนั้น แต่เราต้องแทนที่การ evaluate วิธีเสมือนใหม่ มิฉะนั้นเราจะประสบปัญหาด้านประสิทธิภาพที่ร้ายแรงเนื่องจากการแปลงจากตัวเลขเป็นสตริงโดยไม่จำเป็น

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

ความพยายามที่ไม่ประสบความสำเร็จทั้งหมดเหล่านี้ทำให้ฉันมีความซับซ้อน แต่ในความคิดของฉัน - การออกแบบที่เหมาะสม ประการแรก การมีคลาสเบสตัวเดียวไม่สำคัญสำหรับเรา เราต้องการคลาสนิพจน์ที่จะประเมินเป็นโมฆะ แต่ถ้าเราสามารถแยกความแตกต่างระหว่างนิพจน์ void และนิพจน์ประเภทอื่นในเวลาคอมไพล์ ไม่จำเป็นต้องแปลงระหว่างพวกมันในรันไทม์ ดังนั้น เราจะกำหนดพารามิเตอร์คลาสฐานด้วยชนิดส่งคืนของนิพจน์

นี่คือการใช้งานคลาสนั้นอย่างเต็มรูปแบบ:

 template <typename R> class expression { expression(const expression&) = delete; void operator=(const expression&) = delete; protected: expression() = default; public: using ptr = std::unique_ptr<const expression>; virtual R evaluate(runtime_context& context) const = 0; virtual ~expression() = default; };

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

เพื่อให้บรรลุผลนั้น เราจะกำหนดพารามิเตอร์แต่ละนิพจน์ด้วยประเภทการส่งคืน และสืบทอดจากคลาสพื้นฐานที่เกี่ยวข้อง จากนั้น ในฟังก์ชัน evaluate เราจะแปลงผลการประเมินเป็นค่าส่งคืนของฟังก์ชันนั้น

ตัวอย่างเช่น นี่คือนิพจน์การบวกของเรา:

 template <typename R> class add_expression: public expression<R> { ... R evaluate(runtime_context& context) const override{ return convert<R>( _expr1->evaluate(context) + _expr2->evaluate(context) ); } ... };

ในการเขียนฟังก์ชัน "แปลง" เราจำเป็นต้องมีโครงสร้างพื้นฐานบางอย่าง:

 template<class V, typename T> struct is_boxed { static const bool value = false; }; template<typename T> struct is_boxed<std::shared_ptr<variable_impl<T> >, T> { static const bool value = true; }; string convert_to_string(number n) { std::string str if (n == int(n)) { str = std::to_string(int(n)); } else { str = std::to_string(n); } return std::make_shared<std::string>(std::move(str)); } string convert_to_string(const lnumber& v) { return convert_to_string(v->value); }

โครงสร้าง is_boxed เป็นลักษณะประเภทที่มีค่าคงที่ภายใน value ซึ่งประเมินว่าเป็นจริง ถ้า (และเฉพาะในกรณีที่) พารามิเตอร์แรกเป็นตัวชี้ที่ใช้ร่วมกันไปยัง variable_impl ซึ่งกำหนดพารามิเตอร์ด้วยประเภทที่สอง

การใช้ฟังก์ชันการ convert จะเป็นไปได้แม้ในเวอร์ชันเก่าของ C++ แต่มีคำสั่งที่มีประโยชน์มากใน C++17 ที่เรียกว่า if constexpr ซึ่งประเมินเงื่อนไขในเวลาคอมไพล์ หากประเมิน false มันจะลบบล็อกทั้งหมด แม้ว่าจะทำให้เกิดข้อผิดพลาดในการคอมไพล์ก็ตาม มิฉะนั้น มันจะวางบล็อก else

 template<typename To, typename From> auto convert(From&& from) { if constexpr(std::is_convertible<From, To>::value) { return std::forward<From>(from); } else if constexpr(is_boxed<From, To>::value) { return unbox(std::forward<From>(from)); } else if constexpr(std::is_same<To, string>::value) { return convert_to_string(from); } else { static_assert(std::is_void<To>::value); } }

ลองอ่านฟังก์ชั่นนี้:

  • แปลงหากสามารถแปลงได้ใน C ++ (สำหรับตัวชี้ variable_impl _impl)
  • แกะกล่องหากมีกล่อง
  • แปลงเป็นสตริงหากประเภทเป้าหมายเป็นสตริง
  • ไม่ทำอะไรและตรวจสอบว่าเป้าหมายเป็นโมฆะหรือไม่

ในความคิดของฉัน สิ่งนี้อ่านง่ายกว่าไวยากรณ์ที่เก่ากว่าที่ใช้ SFINAE

ฉันจะให้ภาพรวมโดยสังเขปของประเภทนิพจน์และละเว้นรายละเอียดทางเทคนิคบางอย่างเพื่อให้สั้นพอสมควร

นิพจน์ลีฟมีสามประเภทในแผนผังนิพจน์:

  • นิพจน์ตัวแปรส่วนกลาง
  • นิพจน์ตัวแปรท้องถิ่น
  • นิพจน์คงที่
 template<typename R, typename T> class global_variable_expression: public expression<R> { private: int _idx; public: global_variable_expression(int idx) : _idx(idx) { } R evaluate(runtime_context& context) const override { return convert<R>( context.global(_idx) ->template static_pointer_downcast<T>() ); } };

นอกเหนือจากประเภทการส่งคืน มันยังถูกทำให้เป็นพารามิเตอร์ด้วยประเภทตัวแปรด้วย ตัวแปรโลคัลได้รับการปฏิบัติเช่นเดียวกัน และนี่คือคลาสสำหรับค่าคงที่:

 template<typename R, typename T> class constant_expression: public expression<R> { private: T _c; public: constant_expression(T c) : _c(std::move(c)) { } R evaluate(runtime_context& context) const override { return convert<R>(_c); } };

ในกรณีนี้ เราแปลงค่าคงที่ทันทีในตัวสร้าง

ใช้เป็นคลาสพื้นฐานสำหรับนิพจน์ส่วนใหญ่ของเรา:

 template<class O, typename R, typename... Ts> class generic_expression: public expression<R> { private: std::tuple<typename expression<Ts>::ptr...> _exprs; template<typename... Exprs> R evaluate_tuple( runtime_context& context, const Exprs&... exprs ) const { return convert<R>(O()( std::move(exprs->evaluate(context))...) ); } public: generic_expression(typename expression<Ts>::ptr... exprs) : _exprs(std::move(exprs)...) { } R evaluate(runtime_context& context) const override { return std::apply( [&](const auto&... exprs){ return this->evaluate_tuple(context, exprs...); }, _exprs ); } };

อาร์กิวเมนต์แรกคือประเภท functor ที่จะสร้างอินสแตนซ์และเรียกใช้การประเมิน ประเภทที่เหลือคือชนิดส่งคืนของนิพจน์ย่อย

เพื่อลดโค้ดสำเร็จรูป เรากำหนดมาโครสามตัว:

 #define UNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1> \ auto operator()(T1 t1) {\ code;\ }\ };\ template<typename R, typename T1>\ using name##_expression = generic_expression<name##_op, R, T1>; #define BINARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2>\ auto operator()(T1 t1, T2 t2) {\ code;\ }\ };\ template<typename R, typename T1, typename T2>\ using name##_expression = generic_expression<name##_op, R, T1, T2>; #define TERNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2, typename T3>\ auto operator()(T1 t1, T2 t2, T3 t3) {\ code;\ }\ };\ template<typename R, typename T1, typename T2, typename T3>\ using name##_expression = generic_expression<name##_op, R, T1, T2, T3>;

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

ตอนนี้ เราสามารถกำหนดนิพจน์ส่วนใหญ่ได้ ตัวอย่างเช่น นี่คือคำจำกัดความของ /= :

 BINARY_EXPRESSION(div_assign, t1->value /= t2; return t1; );

เราสามารถกำหนดนิพจน์เกือบทั้งหมดได้โดยใช้มาโครเหล่านี้ ข้อยกเว้นคือโอเปอเรเตอร์ที่กำหนดลำดับการประเมินอาร์กิวเมนต์ (ตรรกะ && และ || ตัวดำเนินการ ternary ( ? ) และเครื่องหมายจุลภาค ( , ) ดัชนีอาร์เรย์ การเรียกใช้ฟังก์ชัน และ param_expression ซึ่งโคลนพารามิเตอร์เพื่อส่งต่อไปยังฟังก์ชัน ตามมูลค่า

ไม่มีอะไรซับซ้อนในการดำเนินการเหล่านี้ การใช้งานการเรียกฟังก์ชันนั้นซับซ้อนที่สุด ดังนั้นฉันจะอธิบายไว้ที่นี่:

 template<typename R, typename T> class call_expression: public expression<R>{ private: expression<function>::ptr _fexpr; std::vector<expression<lvalue>::ptr> _exprs; public: call_expression( expression<function>::ptr fexpr, std::vector<expression<lvalue>::ptr> exprs ): _fexpr(std::move(fexpr)), _exprs(std::move(exprs)) { } R evaluate(runtime_context& context) const override { std::vector<variable_ptr> params; params.reserve(_exprs.size()); for (size_t i = 0; i < _exprs.size(); ++i) { params.push_back(_exprs[i]->evaluate(context)); } function f = _fexpr->evaluate(context); for (size_t i = params.size(); i > 0; --i) { context.push(std::move(params[i-1])); } context.call(); f(context); if constexpr (std::is_same<R, void>::value) { context.end_function(_exprs.size()); } else { return convert<R>( context.end_function( _exprs.size() )->template static_pointer_downcast<T>() ); } } };

มันเตรียม runtime_context โดยการกดอาร์กิวเมนต์ที่ประเมินแล้วทั้งหมดบนสแต็กและเรียกใช้ฟังก์ชันการ call จากนั้นจะเรียกอาร์กิวเมนต์แรกที่ประเมิน (ซึ่งเป็นฟังก์ชันเอง) และส่งคืนค่าที่ส่งคืนของเมธอด end_function เราสามารถดูการใช้ไวยากรณ์ if constexpr ที่นี่เช่นกัน ช่วยเราจากการเขียนความเชี่ยวชาญสำหรับทั้งชั้นเรียนสำหรับฟังก์ชันที่ส่งคืน void

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

ตัวสร้างนิพจน์

เพื่อหลีกเลี่ยงความสับสน ให้ตั้งชื่อขั้นตอนต่างๆ ของวงจรการพัฒนาภาษาของเรา:

ขั้นตอนต่างๆ ของวัฏจักรการพัฒนาภาษาโปรแกรม
  • Meta-compile-time: ระยะที่คอมไพเลอร์ C++ ทำงาน
  • เวลาคอมไพล์: เฟสที่คอมไพเลอร์ Stork ทำงาน
  • รันไทม์: เฟสเมื่อสคริปต์ Stork ทำงาน

นี่คือรหัสเทียมสำหรับตัวสร้างนิพจน์:

 function build_expression(nodeptr n, compiler_context context) { if (n is constant) { return constant_expression(n.value); } else if (n is identifier) { id_info info = context.find(n.value); if (context.is_global(info)) { return global_variable_expression(info.index); } else { return local_variable_expression(info.index); } } else { //operation switch (n->value) { case preinc: return preinc_expression( build_expression(n->child[0]) ); ... case add: return add_expression( build_expression(n->child[0]), build_expression(n->child[1]) ); ... case call: return call_expression( n->child[0], //function n->child[1], //arg0 ... n->child[k+1], //argk ); } } }

นอกจากจะต้องจัดการการทำงานทั้งหมดแล้ว ดูเหมือนว่าอัลกอริธึมที่ตรงไปตรงมา

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

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

โชคดีที่เราทราบประเภทของนิพจน์ระดับบนสุด - ขึ้นอยู่กับบริบทของการคอมไพล์ แต่เราทราบประเภทของนิพจน์โดยไม่ต้องแยกวิเคราะห์แผนผังนิพจน์ ตัวอย่างเช่น ถ้าเรามี for-loop:

 for (expression1; expression2; expression3) ...

นิพจน์แรกและนิพจน์ที่สามมีประเภทการส่งคืน void เนื่องจากเราไม่ดำเนินการใดๆ กับผลการประเมิน อย่างไรก็ตาม นิพจน์ที่สองมี number ประเภทเนื่องจากเรากำลังเปรียบเทียบกับศูนย์ เพื่อที่จะตัดสินใจว่าจะหยุดการวนซ้ำหรือไม่

หากเราทราบประเภทของนิพจน์ที่เกี่ยวข้องกับการทำงานของโหนด ก็มักจะกำหนดประเภทของนิพจน์ย่อย

ตัวอย่างเช่น หากนิพจน์ (expression1) += (expression2) มีประเภท lnumber นั่นหมายความว่า expression1 มีประเภทนั้นด้วย และ expression2 มี number ประเภท

อย่างไรก็ตาม นิพจน์ (expression1) < (expression2) จะมี type number เสมอ แต่นิพจน์ย่อยสามารถมี type number หรือ type string ได้ ในกรณีของนิพจน์นี้ เราจะตรวจสอบว่าโหนดทั้งสองเป็นตัวเลขหรือไม่ ถ้าเป็นเช่นนั้น เราจะสร้าง expression1 และ expression2 เป็น expression<number> มิฉะนั้นจะเป็นประเภท expression<string>

มีปัญหาอื่นที่เราต้องพิจารณาและจัดการ

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

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

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

เรากำลังใช้โครงสร้างลักษณะเฉพาะที่อธิบายตนเองได้ดังต่อไปนี้เพื่อตรวจสอบการเปลี่ยนแปลง:

 template<typename From, typename To> struct is_convertible { static const bool value = std::is_convertible<From, To>::value || is_boxed<From, To>::value || ( std::is_same<To, string>::value && ( std::is_same<From, number>::value || std::is_same<From, lnumber>::value ) ); };

หลังจากแยกนั้น รหัสก็เกือบจะตรงไปตรงมา เราสามารถถ่ายทอดความหมายจากประเภทนิพจน์ดั้งเดิมไปยังประเภทที่เราต้องการสร้าง และไม่มีข้อผิดพลาดใน meta-compile-time

มีโค้ดสำเร็จรูปจำนวนมาก ดังนั้นฉันจึงอาศัยมาโครอย่างมากเพื่อลดขนาด

 template<typename R> class expression_builder{ private: using expression_ptr = typename expression<R>::ptr; static expression_ptr build_void_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_number_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lnumber_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lstring_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_array_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_larray_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_function_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lfunction_expression( const node_ptr& np, compiler_context& context ); public: static expression_ptr build_expression( const node_ptr& np, compiler_context& context ) { return std::visit(overloaded{ [&](simple_type st){ switch (st) { case simple_type::number: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lnumber); } else { RETURN_EXPRESSION_OF_TYPE(number); } case simple_type::string: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lstring); } else { RETURN_EXPRESSION_OF_TYPE(string); } case simple_type::nothing: RETURN_EXPRESSION_OF_TYPE(void); } }, [&](const function_type& ft) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lfunction); } else { RETURN_EXPRESSION_OF_TYPE(function); } }, [&](const array_type& at) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(larray); } else { RETURN_EXPRESSION_OF_TYPE(array); } } }, *np->get_type_id()); } };

ฟังก์ชัน build_expression เป็นฟังก์ชันสาธารณะเพียงฟังก์ชันเดียวที่นี่ มันเรียกใช้ฟังก์ชัน std::visit บนประเภทโหนด ฟังก์ชันนั้นใช้ functor ที่ส่งผ่านกับ variant โดยแยกระหว่างกระบวนการ คุณสามารถอ่านเพิ่มเติมเกี่ยวกับมันและเกี่ยวกับ functor ที่ overloaded ได้ที่นี่

แมโคร RETURN_EXPRESSION_OF_TYPE เรียกใช้ฟังก์ชันส่วนตัวสำหรับการสร้างนิพจน์ และแสดงข้อยกเว้นหากไม่สามารถแปลงได้:

 #define RETURN_EXPRESSION_OF_TYPE(T)\ if constexpr(is_convertible<T, R>::value) {\ return build_##T##_expression(np, context);\ } else {\ throw expression_builder_error();\ return expression_ptr();\ }

เราต้องส่งคืนพอยน์เตอร์ว่างในสาขาอื่น เนื่องจากคอมไพเลอร์ไม่ทราบประเภทการส่งคืนฟังก์ชันในกรณีที่ไม่สามารถแปลงได้ มิฉะนั้น std::visit กำหนดให้ฟังก์ชันโอเวอร์โหลดทั้งหมดมีประเภทการส่งคืนเหมือนกัน

มีตัวอย่างเช่น ฟังก์ชันที่สร้างนิพจน์ด้วย string เป็นประเภทส่งคืน:

 static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ) { if (std::holds_alternative<std::string>(np->get_value())) { return std::make_unique<constant_expression<R, string>>( std::make_shared<std::string>( std::get<std::string>(np->get_value()) ) ); } CHECK_IDENTIFIER(lstring); switch (std::get<node_operation>(np->get_value())) { CHECK_BINARY_OPERATION(concat, string, string); CHECK_BINARY_OPERATION(comma, void, string); CHECK_TERNARY_OPERATION(ternary, number, string, string); CHECK_INDEX_OPERATION(lstring); CHECK_CALL_OPERATION(lstring); default: throw expression_builder_error(); } }

จะตรวจสอบว่าโหนดมีค่าคงที่ของสตริงหรือไม่และสร้าง constant_expression _expression หากเป็นกรณีนี้

จากนั้นจะตรวจสอบว่าโหนดมีตัวระบุและส่งคืนนิพจน์ตัวแปร global หรือ local ของประเภท lstring ในกรณีนั้น มันสามารถเก็บตัวระบุได้ถ้าเราใช้ตัวแปรคงที่ มิฉะนั้น จะถือว่าโหนดถือการทำงานของโหนดและพยายามดำเนินการทั้งหมดที่สามารถส่งคืน string ได้

ต่อไปนี้คือการใช้งานของมาโคร CHECK_IDENTIFIER และ CHECK_BINARY_OPERATION :

 #define CHECK_IDENTIFIER(T1)\ if (std::holds_alternative<identifier>(np->get_value())) {\ const identifier& id = std::get<identifier>(np->get_value());\ const identifier_info* info = context.find(id.name);\ if (info->is_global()) {\ return std::make_unique<\ global_variable_expression<R, T1>\ >(info->index());\ } else {\ return std::make_unique<\ local_variable_expression<R, T1>\ >(info->index());\ }\ }
 #define CHECK_BINARY_OPERATION(name, T1, T2)\ case node_operation::name:\ return expression_ptr(\ std::make_unique<name##_expression<R, T1, T2> > (\ expression_builder<T1>::build_expression(\ np->get_children()[0], context\ ),\ expression_builder<T2>::build_expression(\ np->get_children()[1], context\ )\ )\ );

แมโคร CHECK_IDENTIFIER ต้องปรึกษา compiler_context เพื่อสร้างนิพจน์ตัวแปรส่วนกลางหรือภายในเครื่องด้วยดัชนีที่เหมาะสม นั่นเป็นเพียงการใช้งาน compiler_context ในโครงสร้างนี้

คุณจะเห็นว่า CHECK_BINARY_OPERATION เรียก build_expression ซ้ำๆ สำหรับโหนดย่อย

ห่อ

ที่หน้า GitHub ของฉัน คุณสามารถรับซอร์สโค้ดแบบเต็ม คอมไพล์ จากนั้นพิมพ์นิพจน์ และดูผลลัพธ์ของตัวแปรที่ประเมิน

ฉันคิดว่า ในทุกสาขาของความคิดสร้างสรรค์ของมนุษย์ มีช่วงเวลาที่ผู้เขียนตระหนักว่าผลิตภัณฑ์ของตนยังมีชีวิตอยู่ ในบางแง่มุม ในการสร้างภาษาโปรแกรม มันเป็นช่วงเวลาที่คุณจะเห็นได้ว่าภาษานั้น “หายใจเข้า”

ในตอนต่อไปและตอนสุดท้ายของซีรีส์นี้ เราจะใช้ชุดฟีเจอร์ภาษาขั้นต่ำที่เหลือเพื่อให้ใช้งานได้จริง