جو ستورك ، الجزء الثالث: تنفيذ التعبيرات والمتغيرات

نشرت: 2022-03-11

في الجزء 3 من سلسلتنا ، سيتم أخيرًا تشغيل لغة البرمجة خفيفة الوزن الخاصة بنا. لن يكون Turing كاملًا ، ولن يكون قويًا ، لكنه سيكون قادرًا على تقييم التعبيرات وحتى استدعاء الوظائف الخارجية المكتوبة في C ++.

سأحاول وصف العملية بأكبر قدر ممكن من التفاصيل ، ويرجع ذلك أساسًا إلى أنها الغرض من سلسلة المدونات هذه ، ولكن أيضًا للتوثيق الخاص بي لأنه ، في هذا الجزء ، أصبحت الأمور معقدة بعض الشيء.

لقد بدأت في الترميز لهذا الجزء قبل نشر المقال الثاني ، ولكن بعد ذلك اتضح أن محلل التعبير يجب أن يكون مكونًا مستقلاً يستحق منشور المدونة الخاص به.

هذا ، إلى جانب بعض تقنيات البرمجة الشائنة ، جعل من الممكن ألا يكون هذا الجزء كبيرًا بشكل رهيب ، ومع ذلك ، من المرجح أن يشير بعض القراء إلى تقنيات البرمجة المذكورة ويتساءلون لماذا اضطررت لاستخدامها.

لماذا نستخدم وحدات الماكرو؟

عندما اكتسبت خبرة في البرمجة من خلال العمل في مشاريع مختلفة ومع أشخاص مختلفين ، تعلمت أن المطورين يميلون إلى أن يكونوا دوغمائيين تمامًا - ربما لأنه أسهل على هذا النحو.

وحدات الماكرو في 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 ++؟

العقائد في البرمجة غالبًا ما تكون سيئة. أنا أستخدم كلمة "تقريبًا" هنا بحذر فقط لتجنب الوقوع المتكرر في فخ العقيدة الذي أعددته للتو.

يمكنك العثور على الكود وجميع وحدات الماكرو الخاصة بهذا الجزء هنا.

المتغيرات

في الجزء السابق ، ذكرت أنه لن يتم تجميع Stork في ثنائي أو أي شيء مشابه للغة التجميع ، لكنني قلت أيضًا إنها ستكون لغة مكتوبة بشكل ثابت. لذلك ، سيتم تجميعه ، ولكن في كائن 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 موجودة هنا للراحة لأننا سنضطر كثيرًا إلى الانتقال من هذه الفئة إلى تنفيذها.

التطبيق الحقيقي لهذه الفئة هو 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 كنوع الرقم لدينا. يتم احتساب السلاسل كمرجع ، لأنها ستكون غير قابلة للتغيير ، لتمكين بعض التحسينات عند تمريرها بالقيمة. ستكون المصفوفة 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 على تغيير حجم المكدس بمقدار واحد ودفع فهرس العنصر الأخير في _retval_idx _stack
  • 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 ) ، وإضافتهما ، ثم بناء المتغير_impl 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 الافتراضية ، أو سنواجه مشكلات خطيرة في الأداء بسبب التحويل غير الضروري من رقم إلى سلسلة.

لذلك ، من الواضح أن الأمور تصبح قبيحة ، وتصميمنا بالكاد ينجو منها لأننا لا نملك نوعين من التعبيرات التي يجب تحويلها إلى أخرى (كلا الاتجاهين). إذا كان الأمر كذلك ، أو إذا حاولنا إجراء أي نوع من التحويل الدائري ، فلن يتمكن التسلسل الهرمي لدينا من التعامل معه. بعد كل شيء ، يجب أن يعكس التسلسل الهرمي - علاقة ، وليس - علاقة قابلة للتحويل - إلى ، وهي أضعف.

كل هذه المحاولات الفاشلة قادتني إلى تصميم معقد - في رأيي - مناسب. أولاً ، وجود فئة أساسية واحدة ليس أمرًا بالغ الأهمية بالنسبة لنا. نحتاج إلى فئة التعبير التي يمكن تقييمها على أنها باطلة ، ولكن إذا تمكنا من التمييز بين التعبيرات الفارغة والتعبيرات من نوع آخر في وقت التحويل البرمجي ، فلا داعي للتحويل بينهما في وقت التشغيل. لذلك ، سنقوم بوضع علامة على الفئة الأساسية مع نوع الإرجاع الخاص بالتعبير.

هنا هو التنفيذ الكامل لتلك الفئة:

 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 variable_impl ).
  • Unbox إذا كان محاصر.
  • تحويل إلى سلسلة إذا كان نوع الهدف سلسلة.
  • لا تفعل شيئًا وتحقق مما إذا كان الهدف باطلاً.

في رأيي ، هذا أكثر قابلية للقراءة من الصيغة القديمة القائمة على 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; );

يمكننا تحديد جميع التعبيرات تقريبًا باستخدام وحدات الماكرو هذه. الاستثناءات هي العوامل التي حددت ترتيب تقييم الوسيطات (المنطقية && و || ، عامل التشغيل الثلاثي ( ? ) والفاصلة ( , )) ، فهرس المصفوفة ، استدعاء الوظيفة ، و 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 ++
  • وقت التجميع: المرحلة التي يتم فيها تشغيل مترجم ستورك
  • وقت التشغيل: مرحلة تشغيل البرنامج النصي ستورك

هذا هو الكود الزائف لمنشئ التعبير:

 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 1 له هذا النوع أيضًا ، expression2 2 له number النوع.

ومع ذلك ، فإن التعبير (expression1) < (expression2) يحتوي دائمًا على number النوع ، ولكن يمكن أن تحتوي التعبيرات الفرعية الخاصة بها على number النوع أو string النوع. في حالة هذا التعبير ، سوف نتحقق مما إذا كانت كلتا العقدتين أرقام. إذا كان الأمر كذلك ، فسنبني expression1 expression2 expression<number> . خلاف ذلك ، سيكونون من النوع expression<string> .

هناك مشكلة أخرى علينا مراعاتها والتعامل معها.

تخيل لو احتجنا إلى بناء تعبير number النوع. بعد ذلك ، لا يمكننا إرجاع أي شيء صالح إذا واجهنا عامل تسلسل. نحن نعلم أنه لا يمكن أن يحدث ذلك ، حيث قمنا بالفعل بفحص الأنواع عندما قمنا ببناء شجرة التعبير (في الجزء السابق) ، ولكن هذا يعني أنه لا يمكننا كتابة وظيفة القالب ، مع تحديد معلمات مع نوع الإرجاع ، لأنه سيكون لها فروع غير صالحة اعتمادًا على على هذا النوع من الإرجاع.

قد يقسم أحد الأساليب الوظيفة حسب نوع الإرجاع ، باستخدام if constexpr ، لكنها غير فعالة لأنه إذا كانت العملية نفسها موجودة في عدة فروع ، فسيتعين علينا تكرار الكود الخاص بها. يمكننا كتابة دالات منفصلة في هذه الحالة.

الحل المنفذ يقسم الوظيفة بناءً على نوع العقدة. في كل فرع ، سوف نتحقق مما إذا كان نوع الفرع هذا قابل للتحويل إلى نوع إرجاع الوظيفة. إذا لم يكن الأمر كذلك ، فسوف نلقي بخطأ المترجم ، لأنه لا يجب أن يحدث أبدًا ، لكن الكود معقد للغاية لمثل هذا الادعاء القوي. ربما أكون قد ارتكبت خطأ.

نحن نستخدم بنية سمة النوع التالية التي لا تحتاج إلى شرح للتحقق من قابلية التحويل:

 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 ) ); };

بعد هذا الانقسام ، يكون الرمز واضحًا تقريبًا. يمكننا أن ننتقل لغويًا من نوع التعبير الأصلي إلى النوع الذي نريد بناءه ، ولا توجد أخطاء في وقت التحويل البرمجي التلوي.

هناك الكثير من التعليمات البرمجية المعيارية ، ومع ذلك ، فقد اعتمدت بشدة على وحدات الماكرو لتقليلها.

 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 على نوع العقدة. تطبق هذه الوظيفة المميزات التي تم تمريرها على variant ، وفصلها في العملية. يمكنك قراءة المزيد حول هذا الموضوع وعن المشغّل المحمّل فوق 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();\ }

يجب أن نعيد المؤشر الفارغ في فرع else ، لأن المترجم لا يمكنه معرفة نوع إرجاع الوظيفة في حالة التحويل المستحيل ؛ خلاف ذلك ، تتطلب 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 إذا كان هذا هو الحال.

بعد ذلك ، يتحقق مما إذا كانت العقدة تحتوي على معرف وإرجاع تعبير متغير عالمي أو محلي لنوع 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 الخاصة بي ، يمكنك الحصول على كود المصدر الكامل ، وتجميعه ، ثم كتابة التعبيرات والاطلاع على نتيجة المتغيرات التي تم تقييمها.

أتخيل أنه في جميع فروع الإبداع البشري ، هناك لحظة يدرك فيها المؤلف أن منتجه على قيد الحياة ، إلى حد ما. في بناء لغة البرمجة ، إنها اللحظة التي ترى فيها أن اللغة "تتنفس".

في الجزء التالي والأخير من هذه السلسلة ، سنقوم بتنفيذ بقية مجموعة ميزات اللغة الدنيا لرؤيتها تعمل مباشرة.