يهدف هذا المقال إلى مساعدة المبرمج في توسيع معرفته البرمجية من خلال الاطلاع على شيفرة برمجية مفتوحة المصدر توضح له آلية العمل وتزيد من خبرته البرمجية، لذا سنوضح الشيفرة البرمجية لمفسر بايثون بسيط هو المفسر Byterun المكتوب بلغة بايثون نفسها والمحدود بحوالي 500 سطر، مما يبسط كيفية عمل المفسر ودراسة شيفرته البرمجية مفتوحة المصدر، وبالتالي سيساعد في تحسين المهارات البرمجية وفهم عمل المفسر ويعلمنا كيفية بناء مفسر لغة برمجية خاص بنا.
كتب Ned Batchelder و Allison Kaptur مفسر بايثون Byterun بناءً على عمل Paul Swartz، وبنيته مماثلة لمفسر بايثون الأساسي CPython، لذا سيساعدنا فهم مفسر Byterun على فهم المفسرات وخاصةً مفسر CPython شائع الاستخدام. يستطيع مفسر Byterun تشغيل معظم برامج بايثون البسيطة بالرغم من عدد سطور شيفرته القصير.
مفسر بايثون
لنحدد أولًا ما يعنيه مفسر بايثون، إذ يمكن استخدام مصطلح مفسر Interpreter بطرق مختلفة في سياق الحديث عن بايثون، حيث يشير أحيانًا المفسر إلى Python REPL، وهو الموجِّه التفاعلي الذي نحصل عليه من خلال كتابة python في سطر الأوامر، وقد يُستخدَم مفسر بايثون للدلالة على التنفيذ الكامل لشيفرة بايثون البرمجية، ولكن سيدل مصطلح المفسر في هذا المقال على مجال أضيق، وهو الخطوة الأخيرة من عملية تنفيذ برنامج بايثون.
يسبق تشغيل المفسر ثلاث خطوات في بايثون وهي: تحليل المفردات Lexing والتحليل Parsing والتصريف Compiling، حيث تعمل هذه الخطوات مع بعضها البعض لتحويل شيفرة المبرمج المصدرية من أسطر نصية إلى كائنات شيفرة Code Objects منظمة تحتوي على تعليمات Instructions يمكن للمفسر فهمها، وتتمثل مهمة المفسر في أخذ هذه الكائنات واتباع التعليمات.
يُعَد التصريف خطوة من خطوات تنفيذ شيفرة بايثون بالرغم من اعتبارها لغة مُفسَّرة مثل لغتي Ruby أو Perl، بينما تُعَد C أو Rust لغات مُصرَّفة. تتضمن معظم اللغات المفسَّرة بما في ذلك لغة بايثون خطوة تصريف، وسبب وصف بايثون بأنها لغة مفسَّرة هو أن خطوة التصريف تنجر عملًا أقل نسبيًا من العمل الذي تنجزه في لغة مُصرَّفة. ينجز المفسر في لغة بايثون العمل الأكبر نسبيًا، إذ يحتوي مُصرِّف بايثون على معلومات أقل بكثير حول سلوك البرنامج مقارنةً بمصرِّف لغة سي C.
تطوير مفسر بايثون باستخدام بايثون
Byterun هو مفسر بايثون مكتوب بلغة بايثون نفسها كما هو الحال مع مصرِّف C المستخدَم على نطاق واسع gcc المكتوب بلغة C، ولكن يمكننا كتابة مفسر بايثون بأي لغة تقريبًا.
توجد مزايا وعيوب لكتابة مفسر بايثون باستخدام بايثون، فالعيب الأكبر هو السرعة، إذ يُعَد تنفيذ الشيفرة البرمجية في Byterun أبطأ بكثير من تنفيذها في مفسر CPython المكتوب بلغة C، ولكن صُمِّم مفسر Byterun في الأصل كتطبيق تعليمي، لذا لم تكن السرعة ذات أهمية كبيرة.
الميزة الأهم لاستخدام بايثون هي أنه يمكننا تنفيذ المفسّر فقط بسهولة دون تنفيذ باقي وقت تشغيل بايثون وخاصة نظام الكائنات، إذ يمكن لمفسر Byterun مثلًا الرجوع إلى شيفرة بايثون الحقيقية عندما يحتاج إلى إنشاء صنف. يتميز مفسر Byterun بأنه سهل الفهم، ويعود ذلك إلى كتابته بلغة عالية المستوى هي لغة بايثون التي يجدها الكثيرون سهلة القراءة، ولا حاجة للتحسينات في مفسر Byterun بسبب تفضيل الوضوح والبساطة على السرعة فيه.
بناء مفسر
لنتعرف أولًا على كيفية عمل مفسر بايثون قبل البدء بشيفرة Byterun البرمجية، حيث يُعَد مفسر بايثون آلة افتراضية Virtual Machine، أي أنه برنامج يحاكي حاسوبًا حقيقيًا، وتكون هذه الآلة الافتراضية عبارة عن آلة مكدس Stack Machine، أي أنها تعالج عدة مكدسات لأداء عملياتها على عكس آلة التسجيل Register Machine التي تكتب وتقرأ من مواقع ذاكرة معينة.
مفسّر بايثون هو مفسر بايت كود Bytecode، فمدخلاته هي مجموعات من التعليمات تسمى بايت كود، حيث يولّد محلل المفردات والمحلل والمصرِّف كائنات شيفرة ليعمل عليها المفسّر عندما نكتب شيفرة بايثون الخاصة بنا، ويحتوي كل كائن من هذه الكائنات على مجموعة من التعليمات التي يجب تنفيذها والتي هي البايت كود بالإضافة إلى معلومات أخرى سيحتاجها المفسر. يُعَد البايت كود تمثيلًا وسيطًا لشيفرة بايثون البرمجية، لأنها تعبّر عن الشيفرة المصدرية المكتوبة بطريقة يمكن للمفسّر فهمها، وهي تشبه الطريقة التي تعمل بها لغة التجميع Assembly كتمثيل وسيط بين شيفرة C والعتاد.
بناء مفسر بسيط
لنبدأ ببناء مفسر بسيط يستطيع جمع أعداد فقط ولا يفهم إلّا ثلاث تعليمات، حيث تتكون الشيفرة البرمجية التي يمكنه تنفيذها من التعليمات الثلاث التالية ضمن مجموعات مختلفة:
-
LOAD_VALUE -
ADD_TWO_VALUES -
PRINT_ANSWER
لن نهتم في هذا المقال بمحلل المفردات والمحلل والمصرِّف، وبالتالي لن نهتم بكيفية توليد مجموعات التعليمات. لنفترض كتابة العملية 7 + 5 التالية، حيث يصدر المصرِّف مجموعة من التعليمات الثلاث السابقة، أو إذا كان لدينا المصرِّف الصحيح، فيمكننا الكتابة بصيغة لغة Lisp المُحوَّلة إلى مجموعة التعليمات نفسها. لا يهتم المفسر بذلك، فما يهمه هو أن يحصل على تنسيق مناسب من هذه التعليمات.
7 + 5
وستنتج العملية السابقة مجموعة التعليمات التالية:
what_to_execute = { "instructions": [("LOAD_VALUE", 0), # العدد الأول ("LOAD_VALUE", 1), # العدد الثاني ("ADD_TWO_VALUES", None), ("PRINT_ANSWER", None)], "numbers": [7, 5] }
مُفسِّر بايثون هو آلة مكدس، لذا يجب أن يعالج المكدسات لجمع عددين كما هو موضح في الشكل الآتي، حيث سيبدأ المفسر بتنفيذ التعليمة الأولى LOAD_VALUE ودفع العدد الأول إلى المكدس، ثم يدفع العدد الثاني إلى المكدس، ثم تستخرج التعليمة الثالثة ADD_TWO_VALUES العددين وتجمعهما وتدفع النتيجة إلى المكدس، وأخيرًا يستخرج المفسر الإجابة مرة أخرى من المكدس ويطبعها.
تخبر التعليمة LOAD_VALUE المفسر بدفع عدد إلى المكدس، ولكن لا تحدد التعليمات وحدها هذا العدد، إذ تحتاج التعليمة إلى معلومات إضافية تخبر المفسر بمكان العثور على العدد الذي يجب تحميله، لذا تحتوي مجموعة التعليمات في مثالنا على معلومتين هما: التعليمات نفسها وقائمة الثوابت التي ستحتاجها التعليمات. التعليمات هي البايت كود في بايثون، والكائن الذي يجب تنفيذه what_to_execute هو كائن الشيفرة.
ولكن لماذا لا نضع الأعداد في التعليمات مباشرةً؟ حسنًا، لنفترض أننا نريد جمع سلاسل نصية بدلًا من جمع أعداد، إذ لن نرغب في جعل التعليمات كبيرة الحجم عندما نضع هذه السلاسل النصية مع التعليمات، وتسمح هذه الطريقة بالحصول على نسخة واحدة فقط من كل كائن نحتاجه، وبالتالي يمكن أن تكون الأعداد "numbers" هي [7] فقط لإجراء العملية 7 + 7 مثلًا.
قد لا يكون سبب وجود التعليمات الأخرى المغايرة عن التعليمة ADD_TWO_VALUES واضحًا في مثالنا البسيط، ولكن تشكّل هذه التعليمات الأساس لبناء برامج أكثر تعقيدًا، إذ يمكننا مثلًا جمع ثلاث قيم أو أيّ عدد من القيم مع المجموعة الصحيحة من هذه التعليمات المذكورة في مثالنا. يوفر هذا المكدس طريقة واضحة لتتبع حالة المفسر، ويدعم مزيدًا من التعقيد لاحقًا.
لنبدأ الآن في كتابة المفسر، حيث يحتوي كائن المفسر على مكدس نمثله بقائمة، ويحتوي على تابع يحدد كيفية تنفيذ التعليمات، إذ سيدفع المفسر القيمة إلى المكدس مثلًا بالنسبة للتعليمة LOAD_VALUE.
class Interpreter: def __init__(self): # مكدّس لتخزين القيم أثناء التنفيذ self.stack = [] def LOAD_VALUE(self, number): # تحميل/إدخال قيمة إلى المكدّس self.stack.append(number) def PRINT_ANSWER(self): # إخراج آخر قيمة من المكدّس وطباعتها answer = self.stack.pop() print(answer) def ADD_TWO_VALUES(self): # سحب آخر قيمتين من المكدّس ثم جمعهما وإرجاع الناتج إلى المكدّس first_num = self.stack.pop() second_num = self.stack.pop() total = first_num + second_num self.stack.append(total)
تنفّذ الدوال السابقة التعليمات الثلاث التي يفهمها المفسر الذي يحتاج إلى طريقة لربطها معًا وتنفيذها، إذ تتمثل هذه الطريقة في التابع run_code الذي يأخذ وسيطًا هو القاموس what_to_execute، ويمر ضمن حلقة على كل تعليمة لمعالجة الوسطاء الخاصة بها إن كانت موجودة، ثم يستدعي التابع المقابل مع كائن المفسر.
def run_code(self, what_to_execute): # استخراج قائمة التعليمات وقائمة القيم العددية من المدخل instructions = what_to_execute["instructions"] numbers = what_to_execute["numbers"] # تنفيذ التعليمات بالترتيب (كمفسّر بسيط) for each_step in instructions: # كل خطوة تحتوي اسم التعليمة ووسيطها إن وجد instruction, argument = each_step if instruction == "LOAD_VALUE": # جلب قيمة عددية حسب الفهرس ثم وضعها في المكدّس number = numbers[argument] self.LOAD_VALUE(number) elif instruction == "ADD_TWO_VALUES": # جمع آخر قيمتين في المكدّس ثم وضع الناتج في المكدّس self.ADD_TWO_VALUES() elif instruction == "PRINT_ANSWER": # طباعة آخر قيمة في المكدّس (غالبًا النتيجة) self.PRINT_ANSWER()
يمكننا اختبار ذلك من خلال إنشاء نسخة من الكائن ثم استدعاء التابع run_code مع مجموعة التعليمات الخاصة بجمع العددين 7 + 5، وستظهر الإجابة 12 بالتأكيد.
interpreter = Interpreter() interpreter.run_code(what_to_execute)
يُعَد هذا المفسر محدودًا جدًا، ولكنه يمثل بالضبط الطريقة التي يجمع بها مفسر بايثون الحقيقي الأعداد، إذ تحتاج بعض التعليمات إلى وسطاء، حيث تحتوي كثير من التعليمات على وسطاء في البايت كود الحقيقي لبايثون، وتُضمَّن الوسطاء مع التعليمات كما في مثالنا، ولكن تختلف وسطاء التعليمات عن وسطاء التوابع المستدعاة. لا تتطلب التعليمة ADD_TWO_VALUES وسطاء، إذ أُخرِجت القيم المراد جمعها من مكدس المفسر، ويُعَد ذلك من السمات المميزة للمفسر القائم على المكدس.
يمكننا جمع أكثر من عددين في وقت واحد باستخدام مجموعات تعليمات صالحة دون أي تعديلات على المفسر الخاص بنا، إذًا ليكن لدينا مجموعة التعليمات التالية، ولنحاول توقع ما يحدث وما هي الشيفرة البرمجية المناسبة لتوليد هذه المجموعة من التعليمات عند وجود مصرِّف مناسب:
what_to_execute = { "instructions": [("LOAD_VALUE", 0), ("LOAD_VALUE", 1), ("ADD_TWO_VALUES", None), ("LOAD_VALUE", 2), ("ADD_TWO_VALUES", None), ("PRINT_ANSWER", None)], "numbers": [7, 5, 8] }
يمكننا تصور كيفية توسيع هذه البنية من خلال إضافة توابع إلى كائن المفسر لإجراء العديد من العمليات الأخرى مع وجود مصرِّف يزودنا بمجموعات تعليمات ذات تنسيق مناسب.
المتغيرات
نحتاج الآن إلى إضافة متغيرات إلى المفسر، حيث تتطلب المتغيرات تعليمات لتخزين قيمة متغير STORE_NAME وتعليمات لاسترجاعها LOAD_NAME مع ربط أسماء المتغيرات بقيمها. سنتجاهل حاليًا مساحات الأسماء والمجالات لنتمكن من تخزين ربط المتغيرات مع كائن المفسر. يجب التأكد من أن المتغير what_to_execute لديه قائمة بأسماء المتغيرات بالإضافة إلى قائمة الثوابت الخاصة به.
>>> def s(): ... a = 1 ... b = 2 ... print(a + b) # هذا قاموس يصف بايت كود مبسّط للدالة s: ماذا ننفّذ وبأي ترتيب what_to_execute = { # قائمة التعليمات بالترتيب # كل عنصر عبارة عن: (اسم التعليمة، وسيطها) والوسيط قد يكون فهرسًا أو لا يوجد "instructions": [ ("LOAD_VALUE", 0), # وضع القيمة الأولى في المكدّس ("STORE_NAME", 0), # تخزين أعلى قيمة في المكدّس داخل المتغير الأول ("LOAD_VALUE", 1), # وضع القيمة الثانية في المكدّس ("STORE_NAME", 1), # تخزين أعلى قيمة في المكدّس داخل المتغير الثاني ("LOAD_NAME", 0), # تحميل قيمة المتغير الأول إلى المكدّس ("LOAD_NAME", 1), # تحميل قيمة المتغير الثاني إلى المكدّس ("ADD_TWO_VALUES", None), # جمع آخر قيمتين في المكدّس ووضع الناتج في المكدّس ("PRINT_ANSWER", None) # طباعة آخر قيمة في المكدّس ], # قائمة القيم العددية التي تشير إليها التعليمات عبر فهارس "numbers": [1, 2], # قائمة أسماء المتغيرات التي تشير إليها التعليمات عبر فهارس "names": ["a", "b"] }
يمكننا تتبع الأسماء المرتبطة بالقيم من خلال إضافة القاموس environment إلى التابع __init__ مع إضافة STORE_NAME و LOAD_NAME، حيث تبحث هذه التوابع أولًا عن اسم المتغير ثم تستخدم القاموس لتخزين قيمة المتغير أو استرجاعها.
يمكن أن تكون الوسطاء الخاصة بالتعليمة إما فهرسًا في قائمة الأعداد "numbers"، أو يمكن أن تكون فهرسًا في قائمة الأسماء "names"، إذ يعرف المفسر ما يجب أن تكون عليه من خلال التحقق من التعليمة التي ينفذها. لنضع هذه الشيفرة البرمجية مع ربط التعليمات مع ما تمثله وسطاؤها في تابع منفصل كما يلي:
class Interpreter: def __init__(self): # مكدّس لتخزين القيم مؤقتًا أثناء التنفيذ self.stack = [] # بيئة لحفظ قيم المتغيرات (اسم المتغير ← قيمته) self.environment = {} def STORE_NAME(self, name): # أخذ آخر قيمة من المكدّس وتخزينها باسم متغير val = self.stack.pop() self.environment[name] = val def LOAD_NAME(self, name): # جلب قيمة متغير ووضعها في المكدّس val = self.environment[name] self.stack.append(val) def parse_argument(self, instruction, argument, what_to_execute): """تفسير معنى الوسيط لكل تعليمة""" # تعليمات تستخدم قائمة الأرقام numbers = ["LOAD_VALUE"] # تعليمات تستخدم قائمة أسماء المتغيرات names = ["LOAD_NAME", "STORE_NAME"] if instruction in numbers: # تحويل الفهرس إلى رقم فعلي argument = what_to_execute["numbers"][argument] elif instruction in names: # تحويل الفهرس إلى اسم متغير فعلي argument = what_to_execute["names"][argument] return argument def run_code(self, what_to_execute): # أخذ التعليمات وتنفيذها بالترتيب instructions = what_to_execute["instructions"] for each_step in instructions: # كل خطوة: (اسم التعليمة، وسيطها) instruction, argument = each_step # تحويل الوسيط من فهرس إلى قيمة مفهومة argument = self.parse_argument(instruction, argument, what_to_execute) if instruction == "LOAD_VALUE": # إدخال رقم إلى المكدّس self.LOAD_VALUE(argument) elif instruction == "ADD_TWO_VALUES": # جمع آخر قيمتين في المكدّس self.ADD_TWO_VALUES() elif instruction == "PRINT_ANSWER": # طباعة آخر قيمة في المكدّس self.PRINT_ANSWER() elif instruction == "STORE_NAME": # تخزين قيمة في متغير self.STORE_NAME(argument) elif instruction == "LOAD_NAME": # تحميل قيمة متغير إلى المكدّس self.LOAD_NAME(argument)
قد تصبح بنية التابع run_code معقدة بعض الشيء بالرغم من وجود خمس تعليمات فقط، إذ نحتاج فرعًا واحدًا من تعليمة if لكل تعليمة، لذا يمكن الاستفادة من البحث الديناميكي عن تابع في بايثون بحيث نعرّف تابعًا اسمه FOO ينفّذ التعليمة التي اسمها FOO، وبالتالي نستخدم الدالة getattr في بايثون للبحث عن التابع بسرعة بدلًا من استخدام تعليمة if السابقة المعقدة. سيبدو الآن التابع run_code على النحو التالي
def execute(self, what_to_execute): # جلب قائمة التعليمات instructions = what_to_execute["instructions"] # تنفيذ التعليمات خطوة بخطوة for each_step in instructions: # استخراج اسم التعليمة ووسيطها instruction, argument = each_step # تحويل الوسيط من فهرس إلى قيمة فعلية (رقم أو اسم متغير) argument = self.parse_argument(instruction, argument, what_to_execute) # الحصول على الدالة المطابقة لاسم التعليمة داخل هذا الصنف bytecode_method = getattr(self, instruction) # تنفيذ التعليمة: إن لم يكن لها وسيط ننفذها مباشرة، وإلا نمرر الوسيط if argument is None: bytecode_method() else: bytecode_method(argument)
بناء بايت كود حقيقي في بايثون
سننتقل الآن إلى البايت كود الحقيقي في بايثون الذي يشبه مجموعات التعليمات في المفسر السابق، ولكنه يستخدم بايتًا واحدًا بدلًا من اسم طويل لتحديد كل تعليمة. إذًا ليكن لدينا المثال التالي:
>>> def cond(): ... x = 3 ... if x < 5: ... return 'yes' ... else: ... return 'no'
يكشف بايثون عن مجموعة كبيرة من مكوناته الداخلية في وقت التشغيل بحيث يمكن الوصول إليها مباشرةً من بيئة REPL. يكون cond.__code__ هو كائن الشيفرة المرتبط بكائن الدالة cond، ويكون cond.__code__.co_code هو البايت كود، حيث لا يوجد سبب وجيه لاستخدام هذه السمات مباشرةً عند كتابة شيفرة بايثون، ولكنها تسمح لنا بالتعامل مع جميع أنواع المشاكل والاطلاع على المكونات الداخلية لفهمها.
>>> cond.__code__.co_code # الوصول إلى البايت كود الخام للدالة على شكل بايتات b'd\x01\x00}\x00\x00|\x00\x00d\x02\x00k\x00\x00r\x16\x00d\x03\x00Sd\x04\x00Sd\x00 \x00S' # هذه هي البايتات نفسها التي ينفّذها مفسّر بايثون >>> list(cond.__code__.co_code) # تحويل نفس البايتات إلى قائمة أعداد لقراءتها بسهولة [100, 1, 0, 125, 0, 0, 124, 0, 0, 100, 2, 0, 107, 0, 0, 114, 22, 0, 100, 3, 0, 83, 100, 4, 0, 83, 100, 0, 0, 83] # كل رقم يمثّل جزءًا من تعليمة أو وسيطها داخل البايت كود
يُعَد البايت كود غير مفهوم، إذ لا يمكننا تمييز إلّا سلسلة من البايتات، ولكن توجد أداة يمكننا استخدامها لفهمها، والتي هي وحدة dis في مكتبة بايثون المعيارية، حيث تمثل هذه الوحدة مفككًا للبايت كود الذي يأخذ شيفرة منخفضة المستوى ومكتوبة للأجهزة مثل شيفرة التجميع أو البايت كود، ويطبعها بطريقة يمكن أن يقرأها الإنسان، فمثلًا يؤدي تشغيل dis.dis إلى عرض شرحٍ للبايت كود الممرَّر إليه.
>>> dis.dis(cond) # عرض البايت كود بصيغة مقروءة (تعليمات + عناوين) 2 0 LOAD_CONST 1 (3) # تحميل الثابت رقم 1 (القيمة 3) 3 STORE_FAST 0 (x) # تخزين القيمة في المتغير المحلي رقم 0 (x) 3 6 LOAD_FAST 0 (x) # x تحميل قيمة المتغير المحلي 9 LOAD_CONST 2 (5) # تحميل الثابت رقم 2 (القيمة 5) 12 COMPARE_OP 0 (<) # (<) مقارنة القيمتين باستخدام العامل 15 POP_JUMP_IF_FALSE 22 # إذا كانت نتيجة الشرط غير صحيحة اقفز إلى العنوان 22 4 18 LOAD_CONST 3 ('yes') # 'yes' تحميل الثابت 21 RETURN_VALUE # إرجاع القيمة 6 >> 22 LOAD_CONST 4 ('no') # 'no' تحميل الثابت 25 RETURN_VALUE # إرجاع القيمة 26 LOAD_CONST 0 (None) # قيمة افتراضية عند عدم الإرجاع صراحة 29 RETURN_VALUE
لنوضح التعليمة الأولى LOAD_CONST كمثال، حيث يمثل العدد في العمود الأول (2) رقم السطر في شيفرة بايثون المصدرية الخاصة بنا، ويمثل العمود الثاني فهرسًا إلى البايت كود يخبرنا أن التعليمة LOAD_CONST تظهر في الموضع صفر، ويمثل العمود الثالث التعليمة مع ربطها باسمها القابل للقراءة. يمثل العمود الرابع -إن وجد- وسيط هذه التعليمة، والعمود الخامس -إن وجد- هو تلميح حول ما يعنيه الوسيط.
تمثل البايتات الستة الأولى من البايت كود [100, 1, 0, 125, 0, 0] تعليمتين مع وسطائهما، ويمكننا استخدام dis.opname التي تربط البايتات مع سلاسل نصية مفهومة لمعرفة ما تعنيه التعليمتان 100 و 125 كما يلي:
>>> dis.opname[100] # إرجاع اسم التعليمة المقابل لرقم العملية 100 'LOAD_CONST' # الرقم 100 يعني: تحميل ثابت >>> dis.opname[125] # إرجاع اسم التعليمة المقابل لرقم العملية 125 'STORE_FAST' # الرقم 125 يعني: تخزين قيمة في متغير محلي
يمثل البايتان الثاني والثالث 1 و 0 وسطاء التعليمة LOAD_CONST، ويمثل البايتان الخامس والسادس 0 و 0 وسطاء التعليمة STORE_FAST، إذ تحتاج التعليمة LOAD_CONST إلى معرفة مكان العثور على الثابت لتحميله، وتحتاج التعليمة STORE_FAST إلى العثور على الاسم لتخزينه. تمثل هذه البايتات الستة السطر الأول من الشيفرة البرمجية x = 3، حيث تقابل التعليمة LOAD_CONST في بايثون التعليمة LOAD_VALUE في المفسر السابق، وتقابل التعليمة LOAD_FAST التعليمة LOAD_NAME.
يُستخدَم بايتان لكل وسيط، لأنه إذا استخدم بايثون بايتًا واحدًا فقط لتحديد مكان الثوابت والأسماء بدلًا من استخدام بايتين، فلن نتمكن إلا من الحصول على 256 اسمٍ أو ثابت مرتبط بكائن شيفرة واحد، بينما يمكن الحصول على ما يصل إلى مربع العدد 256 أو 65536 عند استخدام بايتين.
التعليمات الشرطية والحلقات
نفّذَ المفسر حتى الآن الشيفرة البرمجية ببساطة من خلال تنفيذ التعليمات واحدة تلو الأخرى، ولكن قد يشكّل ذلك مشكلة، لأننا في أغلب الاحيان نريد تكرار تنفيذ تعليمات معينة، أو قد نرغب في تخطي بعض التعليمات في شروط محددة، لذا يجب أن يكون المفسر قادرًا على التنقل عبر مجموعة التعليمات للسماح بكتابة الحلقات والتعليمات الشرطية if في الشيفرة البرمجية، حيث يتعامل بايثون مع الحلقات والتعليمات الشرطية باستخدام تعليمات GOTO في البايت كود. لنطلع الآن على تفكيك الدالة cond مرة أخرى:
>>> dis.dis(cond) # عرض تعليمات الدالة بصيغة مقروءة (العنوان + اسم التعليمة + الوسيط) 2 0 LOAD_CONST 1 (3) # تحميل الثابت 3 إلى المكدّس 3 STORE_FAST 0 (x) # x تخزين القيمة في المتغير المحلي 3 6 LOAD_FAST 0 (x) # تحميل قيمة x إلى المكدّس 9 LOAD_CONST 2 (5) # تحميل الثابت 5 إلى المكدّس 12 COMPARE_OP 0 (<) # مقارنة: هل x أصغر من 5؟ 15 POP_JUMP_IF_FALSE 22 # إذا كانت المقارنة خطأ انتقل إلى العنوان 22 4 18 LOAD_CONST 3 ('yes') # تحميل 'yes' (فرع if) 21 RETURN_VALUE # إرجاع القيمة وإنهاء الدالة 6 >> 22 LOAD_CONST 4 ('no') # تحميل 'no' (فرع else) 25 RETURN_VALUE # إرجاع القيمة وإنهاء الدالة 26 LOAD_CONST 0 (None) # تحميل None كقيمة افتراضية 29 RETURN_VALUE # إرجاع None (مسار افتراضي/احتياطي)
تُصرَّف التعليمة الشرطية if x < 5 في السطر 3 من الشيفرة البرمجية إلى أربع تعليمات هي: LOAD_FAST و LOAD_CONST و COMPARE_OP و POP_JUMP_IF_FALSE، حيث يولد الشرط x < 5 الشيفرة البرمجية لتحميل x وتحميل القيمة 5 ومقارنة القيمتين. تكون التعليمة POP_JUMP_IF_FALSE مسؤولة عن تنفيذ تعليمة if، إذ تؤدي هذه التعليمة إلى سحب القيمة العليا من مكدس المفسر، حيث إذا كانت القيمة صحيحة، فلن يحدث شيء، ويمكن أن تكون القيمة صحيحة دون أن تكون كائن True حرفيًا، بينما إذا كانت القيمة خاطئة، فسينتقل المفسر إلى تعليمة أخرى.
تسمى التعليمة التي يجب الوصول إليها بهدف عملية القفز Jump Target، وتكون وسيطًا للتعليمة POP_JUMP، فهدف القفز هو 22 في مثالنا، والتعليمة ذات الفهرس 22 هي LOAD_CONST عند السطر 6، حيث تضع وحدة dis الرمز << على أهداف القفز. إذا كانت نتيجة الشرط x < 5 خاطئة، فسيقفز المفسر إلى السطر 6 مباشرةً return "no" مع تجاوز السطر 4 الذي هو return "yes"، وبالتالي يستخدم المفسر تعليمات القفز للتخطي الانتقائي لأجزاء من مجموعة التعليمات.
تعتمد حلقات Loops بايثون على القفز، فمثلًا يولّد السطر while x < 5 في البايت كود التالي بايت كود متطابقًا تقريبًا مع بايت كود التعليمة if x < 10، حيث تُطبَّق المقارنة في كلتا الحالتين ثم تتحكم التعليمة POP_JUMP_IF_FALSE بالتعليمة التالية التي ستُنفَّذ. ترسل التعليمة JUMP_ABSOLUTE في نهاية السطر 4 الذي يمثل نهاية جسم الحلقة دائمًا المفسر مرة أخرى إلى التعليمة 9 في أعلى الحلقة، وتؤدي التعليمة POP_JUMP_IF_FALSE إلى قفز المفسر إلى ما بعد نهاية الحلقة إلى التعليمة 34 عندما يصبح الشرط x < 5 خاطئًا.
>>> def loop(): ... x = 1 ... while x < 5: ... x = x + 1 ... return x ... >>> dis.dis(loop) # عرض تعليمات الدالة بصيغة مقروءة 2 0 LOAD_CONST 1 (1) # تحميل الثابت 1 إلى المكدّس 3 STORE_FAST 0 (x) # x تخزينه في المتغير المحلي 3 6 SETUP_LOOP 26 (to 35) # تجهيز إطار الحلقة (بداية/نهاية ومسار الخروج) >> 9 LOAD_FAST 0 (x) # تحميل x لفحص شرط الحلقة 12 LOAD_CONST 2 (5) # تحميل الثابت 5 15 COMPARE_OP 0 (<) # مقارنة: هل x أصغر من 5؟ 18 POP_JUMP_IF_FALSE 34 # إذا كان الشرط غير صحيح اقفز إلى نهاية الحلقة (العنوان 34) 4 21 LOAD_FAST 0 (x) # تحميل x لتنفيذ جسم الحلقة 24 LOAD_CONST 1 (1) # تحميل الثابت 1 27 BINARY_ADD # (x + 1) جمع القيمتين 28 STORE_FAST 0 (x) # x تخزين الناتج في 31 JUMP_ABSOLUTE 9 # الرجوع لبداية فحص الشرط (العنوان 9) >> 34 POP_BLOCK # إنهاء إطار الحلقة بعد الخروج منها 5 >> 35 LOAD_FAST 0 (x) # تحميل x بعد انتهاء الحلقة 38 RETURN_VALUE # x إرجاع
استكشاف البايت كود
يمكننا تجربة تشغيل dis.dis مع الدوال التي نكتبها للإجابة عن الأسئلة التالية:
- ما الفرق بين حلقة for وحلقة while بالنسبة لمفسر بايثون؟
- كيف يمكننا كتابة دوال مختلفة تولد بايت كود متطابق؟
-
كيف تعمل تعليمة
elif، وما هو استيعاب القوائم List Comprehensions؟
الإطارات Frames
تعلمنا حتى الآن أن آلة بايثون الافتراضية هي آلة مكدس، لأنها تتنقل وتقفز عبر التعليمات مع دفع وسحب القيم من المكدس وإليه، ولكن لا تزال هناك بعض الأمور الغامضة مثل التعليمة الأخيرة RETURN_VALUE التي تقابل تعليمة return في الشيفرة البرمجية، إذ لا نعرف المكان الذي تعود إليه هذه التعليمة.
نحتاج إلى إضافة طبقةٍ من التعقيد تتمثل في الإطار Frame لمعرفة المكان الذي تعود إليه التعليمة RETURN_VALUE، فالإطار هو مجموعة من المعلومات والسياق لجزء من الشيفرة البرمجية، وينشأ ويُدمَّر أثناء تنفيذ شيفرة بايثون الخاصة بنا. يوجد إطار واحد يقابل كل استدعاء لدالة، وبالتالي يمكن لكائن الشيفرة أن يكون له العديد من الإطارات بينما يكون لكل إطار كائن شيفرة واحد. إذا كان لدينا دالة تستدعي نفسها تعاوديًا عشر مرات، فسيكون لدينا أحد عشر إطارًا، إطار لكل مستوى من التعاود وإطار للوحدة التي بدأنا منها. يوجد إطار لكل مجال في برنامج بايثون، إذ يوجد مثلًا إطار لكل وحدة وإطار لكل استدعاء دالة وإطار لكل تعريف صنف.
توجد الإطارات في مكدس الاستدعاءات Call Stack الذي هو مكدس مختلف تمامًا عن المكدس الذي تحدثنا عنه سابقًا، فمكدس الاستدعاءات هو مكدسٌ رأيناه مسبقًا في عمليات التتبع العكسي Tracebacks للاستثناءات، إذ يقابل كل سطر في عملية التتبع العكسي يبدأ بعبارة "File 'program.py', line 10" إطارًا واحدًا في مكدس الاستدعاءات. نسمي المكدس الذي يتعامل معه المفسر أثناء تنفيذ البايت كود بمكدس البيانات Data Stack. يوجد مكدس ثالث اسمه مكدس الكتل Block Stack، حيث تُستخدَم الكتل لأنواع معينة من تدفق التحكم مثل التكرار ضمن حلقة ومعالجة الاستثناءات، ويكون لكل إطارٍ في مكدس الاستدعاءات مكدس بيانات ومكدس كتل خاص به.
لنفترض مثلًا أن مفسر بايثون ينفّذ حاليًا السطر المحدد بالرقم 3، حيث يكون المفسر عند استدعاء الدالة foo التي تستدعي الدالة bar بدورها. يوضح الرسم البياني الآتي مخطط مكدس الاستدعاءات للإطارات ومكدسات الكتل ومكدسات البيانات، وتعَد هذه الشيفرة البرمجية مكتوبة بطريقة مشابهة لجلسة REPL، لذلك جرى تعريف الدوال المطلوبة أولًا. ينفذ المفسر حاليًا الدالة foo() التي تصل بعد ذلك إلى جسم الدالة foo ثم إلى bar كما يلي:
>>> def bar(y): ... z = y + 3 # (3) داخل الدالة bar: حساب z بجمع y مع 3 ... return z ... >>> def foo(): ... a = 1 ... b = 2 ... return a + bar(b) # (2) ... >>> foo() # (1) استدعاء الدالة foo لتنفيذ ما بداخلها 3 # foo الناتج النهائي بعد تنفيذ
يكون المفسر حاليًا عند استدعاء الدالة bar، وتوجد ثلاثة إطارات في مكدس الاستدعاءات هي: إطار على مستوى الوحدة، وإطار للدالة foo، وإطار للدالة bar كما هو موضح في الشكل السابق، حيث تعود الدالة bar ثم يُخرَج ويُهمَل الإطار المرتبط بها من مكدس الاستدعاءات. تخبر تعليمة البايت كود RETURN_VALUE المفسرَ بتمرير قيمةٍ بين الإطارات، حيث يخرِج أولًا القيمة العليا من مكدس البيانات الخاص بالإطار العلوي من مكدس الاستدعاءات، ثم يخرِج الإطار بالكامل من مكدس الاستدعاءات ويتخلص منه، وتُدفَع أخيرًا القيمة إلى مكدس البيانات في الإطار التالي.
واجه مطورو مفسر Byterun لفترة طويلة خطأً كبيرًا في التنفيذ، إذ كان يوجد مكدس بيانات واحد فقط على الآلة الافتراضية بالكامل بدلًا من وجود مكدس بيانات لكل إطار، حيث أُجريت كثير من الاختبارات المكوَّنة من مقاطع شيفرة بايثون والمنفَّذة باستخدام Byterun ومفسر بايثون الحقيقي للتأكد من حدوث الشيء نفسه عليهما. نجحت جميع هذه الاختبارات تقريبًا باستثناء دوال التوليد Generators، حيث اُكتشِف الخطأ أخيرًا عند قراءة شيفرة CPython بعناية، ولكن أدى استخدام مكدس بيانات لكل إطار إلى حل المشكلة.
لا تعتمد بايثون على أن يكون لكل إطار مكدس بيانات، إذ تنظّف جميعُ العمليات تقريبًا في مفسّر بايثون مكدسَ البيانات بعناية، لذا لم يكن مهمًا أن تشترك الإطارات في المكدس نفسه، حيث سيكون مكدس البيانات الخاص بالدالة bar فارغًا في المثال السابق عند انتهاء تنفيذها. ستكون القيم أقل حتى إذا تشاركت foo في المكدس نفسه، ولكن تكمن الميزة الرئيسية لدوال التوليد في القدرة على إيقاف إطار مؤقتًا والعودة إلى إطار آخر ثم العودة إلى إطار الدالة المولدة لاحقًا بحيث يكون في الحالة نفسها تمامًا التي تركناه عليها.
مفسر Byterun
تعرّفنا على بعض المفاهيم المتعلقة بمفسر بايثون ولنبدأ الآن بمفسر Byterun، حيث توجد أربعة أنواع من الكائنات في Byterun وهي:
-
الصنف
VirtualMachine: يدير البنية ذات المستوى الأعلى وخاصة مكدس استدعاءات الإطارات، ويحتوي على ربط بين التعليمات والعمليات، حيث يُعَد نسخة أكثر تعقيدًا من الكائنIntepreter. -
الصنف
Frame: تحتوي كل نسخة من هذا الصنف على كائن شيفرة واحد، وتدير عددًا من بتات الحالة الضرورية الأخرى وخاصة مساحات الأسماء المحلية والعامة ومرجعًا للإطار المستدعِي وتعليمة البايت كود الأخيرة المُنفَّذة. -
الصنف
Function: يُستخدَم بدلًا من دوال بايثون الحقيقية، حيث ينشئ استدعاء دالة إطارًا جديدًا في المفسر، ونُفِّذ هذا الصنف للتحكم في إنشاء إطارات جديدة. -
الصنف
Block: يغلِّف سمات Attributes الكتل، ولا تُعَد تفاصيل الكتل أساسية بالنسبة لمفسر بايثون، لذا لن نخوض بتفاصيلها، ولكنها مُدرجَة في هذا المقال حتى يتمكّن مفسر Byterun من تشغيل شيفرة بايثون الحقيقية.
الصنف VirtualMachine
تنشأ نسخة واحدة فقط من الصنف VirtualMachine عند كل تشغيل للبرنامج بسبب وجود مفسر بايثون واحد فقط، ويخزّن هذا الصنف مكدس الاستدعاءات وحالة الاستثناءات والقيم المُعادة أثناء تمريرها بين الإطارات. يبدأ تنفيذ الشيفرة البرمجية من التابع run_code الذي يأخذ كائن شيفرة مُصرَّف كوسيط، ويبدأ من خلال إعداد إطار وتشغيله، وقد ينشئ هذا الإطار إطارات أخرى، إذ سيكبر مكدس الاستدعاءات ويصغر عند تنفيذ البرنامج، وينتهي التنفيذ عند إعادة الإطار الأول.
class VirtualMachine(object): def __init__(self): self.frames = [] # مكدس استدعاءات الإطارات self.frame = None # الإطار الحالي self.return_value = None self.last_exception = None def run_code(self, code, global_names=None, local_names=None): """ An entry point to execute code using the virtual machine.""" frame = self.make_frame(code, global_names=global_names, local_names=local_names) self.run_frame(frame)
الصنف Frame
الإطار هو مجموعة من السمات بدون توابع، وتتضمن هذه السمات كائن الشيفرة الذي أنشأه المصرف ومساحات الأسماء المحلية والعامة والمضمَّنة ومرجعًا إلى الإطار السابق ومكدس بيانات ومكدس كتل والتعليمة الأخيرة المنفَّذة. يحتاج الوصول إلى مساحة الأسماء المضمنة إلى عمل إضافي لأن بايثون يعامل هذه المساحة بطريقة مختلفة لكل وحدة، ولكن لا تُعَد هذه التفاصيل مهمة للآلة الافتراضية.
class Frame(object): def __init__(self, code_obj, global_names, local_names, prev_frame): self.code_obj = code_obj # كائن الكود الذي سيتم تنفيذه self.global_names = global_names # مساحة الأسماء العامة self.local_names = local_names # مساحة الأسماء المحلية self.prev_frame = prev_frame # الإطار السابق (للاستدعاءات المتداخلة) self.stack = [] # مكدّس القيم أثناء التنفيذ if prev_frame: # وراثة مساحة الأسماء المضمنة من الإطار السابق self.builtin_names = prev_frame.builtin_names else: # في أول إطار: أخذ مساحة الأسماء المضمنة من __builtins__ self.builtin_names = local_names['__builtins__'] # إذا كانت كائنًا يحتوي قاموس أسماء، نستخدم قاموسه مباشرة if hasattr(self.builtin_names, '__dict__'): self.builtin_names = self.builtin_names.__dict__ self.last_instruction = 0 # مؤشر آخر تعليمة تم تنفيذها self.block_stack = [] # مكدّس الكتل (مثل الحلقات/الاستثناءات) لإدارة القفزات والخروج
نحتاج بعد ذلك إلى إضافة معالجةٍ للإطارات إلى الآلة الافتراضية، حيث توجد ثلاث دوال مساعدة للإطارات هي: دالة إنشاء إطارات جديدة تفرز مساحات الأسماء للإطار الجديد، ودالة لدفع الإطارات، ودالة لسحبها من مكدس الإطارات. تنجز الدالة الرابعة run_frame العمل الأساسي المتمثل في تنفيذ الإطار، والذي سنشرحه بمزيدٍ من التفصيل لاحقًا.
class VirtualMachine(object): [... snip ...] # إنشاء إطار جديد للتنفيذ def make_frame(self, code, callargs={}, global_names=None, local_names=None): # تحديد مساحات الأسماء التي سيعمل عليها هذا الإطار if global_names is not None and local_names is not None: # عند تمريرهما معًا: اجعل المحلية نفس العامة (مساحة واحدة) local_names = global_names elif self.frames: # إذا كان هناك إطار حالي: ورّث العامة منه وابدأ محلية جديدة global_names = self.frame.global_names local_names = {} else: # إذا كان هذا أول إطار: أنشئ مساحة أسماء ابتدائية global_names = local_names = { '__builtins__': __builtins__, '__name__': '__main__', '__doc__': None, '__package__': None, } # إدخال معاملات الاستدعاء داخل المحلية local_names.update(callargs) # بناء الإطار وربطه بالإطار الحالي كإطار سابق frame = Frame(code, global_names, local_names, self.frame) return frame def push_frame(self, frame): # دفع الإطار الجديد ليصبح الإطار الحالي self.frames.append(frame) self.frame = frame def pop_frame(self): # إزالة الإطار الحالي والعودة للإطار السابق إن وُجد self.frames.pop() if self.frames: self.frame = self.frames[-1] else: self.frame = None def run_frame(self): pass # تنفيذ الإطار (سنضيف منطق التنفيذ لاحقًا)
الصنف Function
يُعَد تنفيذ الكائن Function معقدًا بعض الشيء، ولكن ليست معظم هذه التفاصيل ذات أهمية كبيرة لفهم المفسر، فالأمر المهم الذي يجب ملاحظته هو أن استدعاء دالة أو استدعاء التابع __call__ يؤدي إلى إنشاء كائن Frame جديد وبدء تشغيله.
class Function(object): """ إنشاء كائن دالة قريب من دوال بايثون الحقيقية، بالشكل الذي يتوقعه المفسّر. """ __slots__ = [ 'func_code', 'func_name', 'func_defaults', 'func_globals', 'func_locals', 'func_dict', 'func_closure', '__name__', '__dict__', '__doc__', '_vm', '_func', ] # تحديد الحقول المسموح بها لتقليل الذاكرة ومنع إضافة حقول عشوائية def __init__(self, name, code, globs, defaults, closure, vm): """لا تحتاج لفهم كل التفاصيل هنا لفهم المفسّر.""" self._vm = vm # مرجع للآلة الافتراضية التي ستنفّذ هذه الدالة self.func_code = code # كائن الكود الخاص بالدالة self.func_name = self.__name__ = name or code.co_name # اسم الدالة (المعطى أو من كائن الكود) self.func_defaults = tuple(defaults) # القيم الافتراضية للوسطاء self.func_globals = globs # مساحة الأسماء العامة self.func_locals = self._vm.frame.f_locals # مساحة الأسماء المحلية من الإطار الحالي self.__dict__ = {} # قاموس خصائص إضافية للدالة self.func_closure = closure # معلومات الإغلاق (إن وُجدت) self.__doc__ = code.co_consts[0] if code.co_consts else None # نص التوثيق إن وُجد # إنشاء دالة بايثون "حقيقية" لنستفيد منها في ربط الوسطاء والتحقق من التواقيع kw = { 'argdefs': self.func_defaults, # تمرير القيم الافتراضية } if closure: # تجهيز خلايا للإغلاق بشكل صوري عند الحاجة kw['closure'] = tuple(make_cell(0) for _ in closure) self._func = types.FunctionType(code, globs, **kw) # بناء كائن دالة بايثون من كائن الكود def __call__(self, *args, **kwargs): """عند استدعاء الدالة: ننشئ إطارًا جديدًا وننفّذه.""" callargs = inspect.getcallargs(self._func, *args, **kwargs) # ربط القيم بأسماء الوسطاء حسب التوقيع # استخدام هذا الربط لتمرير الوسطاء إلى الإطار الجديد frame = self._vm.make_frame( self.func_code, callargs, self.func_globals, {} ) return self._vm.run_frame(frame) # تنفيذ الإطار وإرجاع الناتج def make_cell(value): """إنشاء خلية إغلاق حقيقية (لاستخراج مرجع خلية من إغلاق بايثون).""" # إنشاء دالة داخل دالة لإجبار بايثون على صنع خلية إغلاق fn = (lambda x: lambda: x)(value) return fn.__closure__[0] # استخراج الخلية نفسها
نعود الآن إلى كائن VirtualMachine، ونضيف بعض التوابع المساعدة لمعالجة مكدس البيانات، حيث يشغّل البايت كود الذي يعالج المكدس دائمًا مكدسَ بيانات الإطار الحالي، مما يؤدي إلى جعل تنفيذ تعليمة POP_TOP و LOAD_FAST وجميع التعليمات الأخرى التي تتعامل مع المكدس أكثر قابلية للقراءة.
class VirtualMachine(object): [... snip ...] # عمليات على مكدّس القيم def top(self): # إرجاع آخر قيمة في المكدّس دون إزالتها return self.frame.stack[-1] def pop(self): # إزالة آخر قيمة من المكدّس وإرجاعها return self.frame.stack.pop() def push(self, *vals): # إضافة قيمة أو أكثر إلى المكدّس self.frame.stack.extend(vals) def popn(self, n): """إزالة عدد من القيم من المكدّس وإرجاعها كقائمة.""" if n: ret = self.frame.stack[-n:] # أخذ آخر n قيم self.frame.stack[-n:] = [] # حذفها من المكدّس return ret else: # إذا كان صفرًا، نرجع قائمة فارغة return []
نحتاج إلى تابعين آخرين قبل تشغيل الإطار وهما: التابع الأول parse_byte_and_args الذي يأخذ بايت كود، ويتحقق من احتوائه على وسطاء، ويحلل هذه الوسطاء عند وجودها، ويحدّث هذا التابع سمة الإطار last_instruction التي هي مرجع إلى آخر تعليمة مُنفَّذة. يبلغ طول التعليمة بايتًا واحدًا عند عدم وجود وسيط، وثلاثة بايتات إذا كان لها وسيط بحيث يمثل البايتان الأخيران الوسيط. يعتمد معنى الوسيط لكل تعليمة على نوعها، فمثلًا يمثل وسيط التعليمة POP_JUMP_IF_FALSE هدف القفز، ويمثل وسيط التعليمة BUILD_LIST عدد العناصر في القائمة، ويمثل وسيط التعليمة LOAD_CONST فهرسًا في قائمة الثوابت.
تستخدم بعض التعليمات أعدادًا بسيطة كوسطاء لها، بينما يجب على الآلة الافتراضية إنجاز عمل إضافي لاكتشاف معنى الوسطاء بالنسبة لتعليمات أخرى. تعطي وحدة dis في المكتبة المعيارية شرحًا يوضح معنى الوسطاء، مما يحسن من شيفرتنا البرمجية، فمثلًا تخبرنا القائمة dis.hasname أن الوسطاء الخاصة بالتعليمات LOAD_NAME و IMPORT_NAME و LOAD_GLOBAL وتسعة تعليمات أخرى لها المعنى نفسه، حيث يمثل وسيط هذه التعليمات فهرسًا في قائمة الأسماء الموجودة في كائن الشيفرة.
class VirtualMachine(object): [... snip ...] def parse_byte_and_args(self): f = self.frame opoffset = f.last_instruction byteCode = f.code_obj.co_code[opoffset] f.last_instruction += 1 byte_name = dis.opname[byteCode] if byteCode >= dis.HAVE_ARGUMENT: # فهرس إلى البايت كود arg = f.code_obj.co_code[f.last_instruction:f.last_instruction+2] f.last_instruction += 2 # تقدّم مؤشر التعليمة arg_val = arg[0] + (arg[1] * 256) if byteCode in dis.hasconst: # البحث عن ثابت arg = f.code_obj.co_consts[arg_val] elif byteCode in dis.hasname: # البحث عن اسم arg = f.code_obj.co_names[arg_val] elif byteCode in dis.haslocal: # البحث عن اسم محلي arg = f.code_obj.co_varnames[arg_val] elif byteCode in dis.hasjrel: # حساب القفزة النسبية arg = f.last_instruction + arg_val else: arg = arg_val argument = [arg] else: argument = [] return byte_name, argument
التابع الثاني هو dispatch الذي يبحث عن العمليات لتعليمة معينة وينفّذها. يُنفَّذ هذا التابع للإرسال في مفسّر CPython باستخدام تعليمة switch ضخمة بحجم 1500 سطر، ولكن ستكون الأمور أفضل بكثير عند كتابة الشيفرة البرمجية باستخدام بايثون، حيث سنعرّف تابعًا لكل اسم تعليمة ثم نستخدم الدالة getattr للبحث عنه، فمثلًا إذا كان اسم التعليمة FOO_BAR كما في مثالنا للمفسر البسيط السابق، فسيكون اسم التابع المقابل هو byte_FOO_BAR. يعيد كل تابع بايت كود القيمةَ None أو سلسلةً نصية اسمها why تمثل جزءًا إضافيًا من الحالة التي قد يحتاجها المفسّر في بعض الحالات. تُستخدَم هذه القيم المعادة لتوابع التعليمات كمؤشرات داخلية فقط لحالة المفسّر، لذا يجب عدم الخلط بينها وبين القيم المعادة من إطارات التنفيذ.
class VirtualMachine(object): [... snip ...] def dispatch(self, byte_name, argument): """ Dispatch by bytename to the corresponding methods. Exceptions are caught and set on the virtual machine.""" # نحتاج إلى تتبع سبب فك مكدس الكتل لاحقًا why = None try: bytecode_fn = getattr(self, 'byte_%s' % byte_name, None) if bytecode_fn is None: if byte_name.startswith('UNARY_'): self.unaryOperator(byte_name[6:]) elif byte_name.startswith('BINARY_'): self.binaryOperator(byte_name[7:]) else: raise VirtualMachineError( "unsupported bytecode type: %s" % byte_name ) else: why = bytecode_fn(*argument) except: # التعامل مع الاستثناءات التي واجهتنا أثناء تنفيذ العملية self.last_exception = sys.exc_info()[:2] + (None,) why = 'exception' return why def run_frame(self, frame): """Run a frame until it returns (somehow). Exceptions are raised, the return value is returned. """ self.push_frame(frame) while True: byte_name, arguments = self.parse_byte_and_args() why = self.dispatch(byte_name, arguments) # التعامل مع إدارة الكتل التي نحتاجها while why and frame.block_stack: why = self.manage_block_stack(why) if why: break self.pop_frame() if why == 'exception': exc, val, tb = self.last_exception e = exc(val) e.__traceback__ = tb raise e return self.return_value
الصنف Block
سنوضح الآن الكتل بإيجاز قبل تنفيذ التوابع لكل تعليمة بايت كود، حيث تُستخدَم الكتلة لأنواع معينة من التحكم في التدفق وخاصة معالجة الاستثناءات والتكرار ضمن حلقة، إذ تُعَد الكتلة مسؤولة عن التأكد من أن مكدس البيانات في الحالة المناسبة عند انتهاء العملية، فمثلًا يبقى كائن تكرار خاص في المكدس أثناء تشغيل الحلقة، ولكنه يخرج عند انتهائها، لذا يجب على المفسر تتبع ما إذا كانت الحلقة مستمرة أم أنها انتهت.
يتتبع المفسر هذه المعلومة الإضافية من خلال وضع رايةٍ Flag للإشارة إلى حالتها، حيث تكون هذه الراية متغيرًا بالاسم why، والذي يمكن أن تكون قيمته None أو إحدى السلاسل النصية "continue" أو "break" أو "exception" أو "return"، إذ تشير هذه القيم إلى كيفية التعامل مع مكدس الكتل ومكدس البيانات، حيث إذا كان الجزء العلوي من مكدس الكتل في مثالنا هو كتلة loop وكانت قيمة why هي continue، فيجب أن يبقى كائن التكرار في مكدس البيانات، ولكن إذا كانت قيمة why هي break، فيجب إخراجه من هذا المكدس.
Block = collections.namedtuple("Block", "type, handler, stack_height") # بنية خفيفة لتمثيل كتلة تنفيذ (نوعها + وجهة القفز + ارتفاع المكدّس) class VirtualMachine(object): [... snip ...] # معالجة مكدّس الكتل def push_block(self, b_type, handler=None): # حفظ ارتفاع مكدّس القيم عند دخول الكتلة لنستطيع تنظيفه عند الخروج stack_height = len(self.frame.stack) self.frame.block_stack.append(Block(b_type, handler, stack_height)) # دفع كتلة جديدة إلى مكدّس الكتل def pop_block(self): # إزالة أحدث كتلة من مكدّس الكتل return self.frame.block_stack.pop() def unwind_block(self, block): """تنظيف القيم من مكدّس البيانات بما يناسب الكتلة.""" if block.type == 'except-handler': # في معالجة الاستثناء: تُحجز ثلاث قيم عادة (نوع/قيمة/تتبّع) offset = 3 else: offset = 0 # حذف أي قيم زائدة في مكدّس القيم للوصول لارتفاع الكتلة وقت الدخول while len(self.frame.stack) > block.level + offset: self.pop() if block.type == 'except-handler': # استرجاع معلومات الاستثناء لتخزينها كآخر استثناء traceback, value, exctype = self.popn(3) self.last_exception = exctype, value, traceback def manage_block_stack(self, why): """تقرير ما يجب فعله عند الخروج من كتلة بسبب سبب معيّن (كسر/متابعة/استثناء/إرجاع).""" frame = self.frame block = frame.block_stack[-1] # النظر إلى أحدث كتلة دون حذفها if block.type == 'loop' and why == 'continue': # متابعة الحلقة: قفز إلى بداية الحلقة (العنوان مخزّن في return_value هنا) self.jump(self.return_value) why = None return why # الخروج من الكتلة: حذفها ثم تنظيف مكدّس القيم المرتبط بها self.pop_block() self.unwind_block(block) if block.type == 'loop' and why == 'break': # كسر الحلقة: القفز إلى نهاية الحلقة (المعالج هو وجهة الخروج) why = None self.jump(block.handler) return why if (block.type in ['setup-except', 'finally'] and why == 'exception'): # عند وقوع استثناء: تجهيز كتلة معالجة الاستثناء ودفع معلوماته للمكدّس ثم القفز لمعالج الاستثناء self.push_block('except-handler') exctype, value, tb = self.last_exception self.push(tb, value, exctype) self.push(tb, value, exctype) # تُدفع مرتين لتوافق تهيئة بايثون في هذا التبسيط why = None self.jump(block.handler) return why elif block.type == 'finally': # عند finally: نمرّر سبب الخروج، وأحيانًا قيمة الإرجاع/المتابعة if why in ('return', 'continue'): self.push(self.return_value) self.push(why) # حفظ سبب الخروج ليُستخدم داخل finally why = None self.jump(block.handler) # الانتقال إلى بداية finally return why # إن لم تُعالج الحالة هنا، نُعيد السبب كما هو return why
التعليمات
نحتاج الآن إلى تنفيذ التوابع الخاصة بالتعليمات، والذي يُعَد جزءًا مملًا من بناء المفسر، لذا إليك بعضًا منها، والتي تتضمن تعليمات كافية لتنفيذ الشيفرة البرمجية الموضحة سابقًا في هذا المقال، ولكن التنفيذ الكامل متاح على GitHub.
class VirtualMachine(object): [... snip ...] ## معالجة المكدس def byte_LOAD_CONST(self, const): # وضع ثابت (قيمة جاهزة) في المكدّس self.push(const) def byte_POP_TOP(self): # حذف أعلى قيمة من المكدّس دون استخدامها self.pop() ## الأسماء def byte_LOAD_NAME(self, name): # تحميل اسم: نبحث عنه محليًا ثم عالميًا ثم ضمن الأسماء المضمنة frame = self.frame if name in frame.f_locals: val = frame.f_locals[name] elif name in frame.f_globals: val = frame.f_globals[name] elif name in frame.f_builtins: val = frame.f_builtins[name] else: # إذا لم يُعثر عليه في أي مكان نرمي خطأ اسم غير معرّف raise NameError("name '%s' is not defined" % name) self.push(val) def byte_STORE_NAME(self, name): # تخزين قيمة في اسم محلي self.frame.f_locals[name] = self.pop() def byte_LOAD_FAST(self, name): # تحميل متغير محلي "سريع" (يجب أن يكون قد أُسنِد مسبقًا) if name in self.frame.f_locals: val = self.frame.f_locals[name] else: # استخدام متغير محلي قبل إسناده raise UnboundLocalError( "local variable '%s' referenced before assignment" % name ) self.push(val) def byte_STORE_FAST(self, name): # تخزين قيمة في متغير محلي "سريع" self.frame.f_locals[name] = self.pop() def byte_LOAD_GLOBAL(self, name): # تحميل اسم من المجال العام أو من الأسماء المضمنة f = self.frame if name in f.f_globals: val = f.f_globals[name] elif name in f.f_builtins: val = f.f_builtins[name] else: raise NameError("global name '%s' is not defined" % name) self.push(val) ## المعاملات BINARY_OPERATORS = { 'POWER': pow, 'MULTIPLY': operator.mul, 'FLOOR_DIVIDE': operator.floordiv, 'TRUE_DIVIDE': operator.truediv, 'MODULO': operator.mod, 'ADD': operator.add, 'SUBTRACT': operator.sub, 'SUBSCR': operator.getitem, 'LSHIFT': operator.lshift, 'RSHIFT': operator.rshift, 'AND': operator.and_, 'XOR': operator.xor, 'OR': operator.or_, } # جدول يربط اسم العملية بدالة تنفيذها def binaryOperator(self, op): # أخذ قيمتين من المكدّس وتنفيذ العملية الثنائية ثم إعادة الناتج للمكدّس x, y = self.popn(2) self.push(self.BINARY_OPERATORS[op](x, y)) COMPARE_OPERATORS = [ operator.lt, operator.le, operator.eq, operator.ne, operator.gt, operator.ge, lambda x, y: x in y, lambda x, y: x not in y, lambda x, y: x is y, lambda x, y: x is not y, lambda x, y: issubclass(x, Exception) and issubclass(x, y), ] # جدول عمليات المقارنة حسب رقم العملية def byte_COMPARE_OP(self, opnum): # مقارنة قيمتين من المكدّس حسب رقم عملية المقارنة x, y = self.popn(2) self.push(self.COMPARE_OPERATORS[opnum](x, y)) ## السمات والفهرسة def byte_LOAD_ATTR(self, attr): # قراءة سمة من كائن (مثل obj.attr) obj = self.pop() val = getattr(obj, attr) self.push(val) def byte_STORE_ATTR(self, name): # تعيين سمة لكائن (مثل obj.attr = val) val, obj = self.popn(2) setattr(obj, name, val) ## البناء def byte_BUILD_LIST(self, count): # بناء قائمة من عدد عناصر مأخوذة من المكدّس elts = self.popn(count) self.push(elts) def byte_BUILD_MAP(self, size): # إنشاء قاموس فارغ (الحجم هنا غير مستخدم في هذا التبسيط) self.push({}) def byte_STORE_MAP(self): # إضافة (مفتاح، قيمة) إلى قاموس موجود ثم إرجاعه للمكدّس the_map, val, key = self.popn(3) the_map[key] = val self.push(the_map) def byte_LIST_APPEND(self, count): # إضافة عنصر إلى قائمة موجودة أعمق في المكدّس val = self.pop() the_list = self.frame.stack[-count] # الوصول للقائمة دون سحبها the_list.append(val) ## القفزات def byte_JUMP_FORWARD(self, jump): # الانتقال إلى تعليمة أخرى للأمام self.jump(jump) def byte_JUMP_ABSOLUTE(self, jump): # الانتقال إلى تعليمة برقم محدد self.jump(jump) def byte_POP_JUMP_IF_TRUE(self, jump): # سحب قيمة شرطية: إذا كانت صحيحة نقفز val = self.pop() if val: self.jump(jump) def byte_POP_JUMP_IF_FALSE(self, jump): # سحب قيمة شرطية: إذا كانت خاطئة نقفز val = self.pop() if not val: self.jump(jump) ## الكتل def byte_SETUP_LOOP(self, dest): # تسجيل كتلة حلقة ومعرفة مكان الخروج منها self.push_block('loop', dest) def byte_GET_ITER(self): # تحويل قيمة إلى مُكرّر ووضعه في المكدّس self.push(iter(self.pop())) def byte_FOR_ITER(self, jump): # جلب العنصر التالي من المُكرّر، أو القفز عند انتهاء التكرار iterobj = self.top() try: v = next(iterobj) self.push(v) except StopIteration: # عند انتهاء التكرار: إزالة المُكرّر والقفز للخروج self.pop() self.jump(jump) def byte_BREAK_LOOP(self): # إشارة إلى رغبة في كسر الحلقة return 'break' def byte_POP_BLOCK(self): # إنهاء أحدث كتلة مسجّلة self.pop_block() ## الدوال def byte_MAKE_FUNCTION(self, argc): # إنشاء دالة من كائن الكود + الاسم + القيم الافتراضية name = self.pop() code = self.pop() defaults = self.popn(argc) globs = self.frame.f_globals fn = Function(name, code, globs, defaults, None, self) self.push(fn) def byte_CALL_FUNCTION(self, arg): # استدعاء دالة بعد تجهيز معاملات الموضع فقط (دون معاملات مسماة هنا) lenKw, lenPos = divmod(arg, 256) # المعاملات المسماة غير مدعومة هنا posargs = self.popn(lenPos) func = self.pop() retval = func(*posargs) # تنفيذ الدالة فعليًا self.push(retval) # وضع الناتج في المكدّس def byte_RETURN_VALUE(self): # إنهاء الدالة الحالية وإرجاع قيمة self.return_value = self.pop() return "return"
تحديد الأنواع الديناميكي Dynamic Typing
تُعَد لغة بايثون لغةً ديناميكية، إذ يمكن تحديد الأنواع ديناميكيًا، فأحد المعاني التي تعنيها كلمة ديناميكية في هذا السياق هو إنجاز الكثير من العمل في وقت التشغيل. رأينا سابقًا أن مصرِّف بايثون لا يمتلك كثيرًا من المعلومات حول ما تفعله الشيفرة البرمجية، فمثلًا إذا كان لدينا الدالة mod الآتية، حيث تأخذ هذه الدالة وسيطين وتعيد باقي قسمة قيمة الوسيط الأول على قيمة الوسيط الثاني، حيث نرى في البايت كود تحميل المتغيرين a و b، ثم يجري البايت كود BINARY_MODULO عملية إيجاد باقي القسمة.
>>> def mod(a, b): ... return a % b >>> dis.dis(mod) 2 0 LOAD_FAST 0 (a) 3 LOAD_FAST 1 (b) 6 BINARY_MODULO 7 RETURN_VALUE >>> mod(19, 5) 4
تعطي عملية حساب 19 % 5 النتيجة 4، ولكن قد نتساءل عمّا يحدث عند استدعائها مع وسطاء مختلفة كما يلي:
>>> mod("by%sde", "teco") 'bytecode'
هذا شيء غريب، ولكنه يشبه ما يحدث في سياق مختلف كما يلي:
>>> print("by%sde" % "teco") bytecode
يؤدي استخدام الرمز % في تنسيق سلسلة نصية لطباعتها إلى استدعاء التعليمة BINARY_MODULO التي تعطي ناتج القسمة لأعلى قيمتين في المكدس عند تنفيذ التعليمة بغض النظر عمّا إذا كانت سلاسل نصية أو أعدادًا صحيحة أو نسخًا من صنف عرّفناه بأنفسنا، حيث يولّد البايت كود عند تصريف الدالة أو عند تعريفها ويُستخدَم البايت كود نفسه مع أنواع مختلفة من الوسطاء.
يجهل مصرّف بايثون التأثير الذي يحدثه البايت كود، لذا فالأمر متروك للمفسر لتحديد نوع الكائن الذي تعمل عليه التعليمة BINARY_MODULO وإجراء الأمر الصحيح المناسب لهذا النوع، وهذا هو سبب وصف بايثون بأنها تحدد الأنواع ديناميكيًا، إذ لن تعرف أنواع وسطاء الدالة حتى تشغيلها، بينما يخبر المبرمج المُصرِّف مسبقًا بنوع الوسطاء أو يكتشفها بنفسه في اللغات ذات الأنواع الثابتة.
يُعَد جهل المصرف من التحديات التي تواجه تحسين أو تحليل بايثون بطريقة ثابتة، إذ لا نعرف ما الذي ستفعله التعليمات عند النظر إلى البايت كود فقط دون تشغيل الشيفرة البرمجية فعليًا، ولكن يمكننا تعريف صنف ينفذ التابع __mod__، وسيستدعي بايثون هذا التابع إذا استخدمنا % مع الكائنات الخاصة بنا، وبالتالي يمكن للتعليمة BINARY_MODULO تشغيل أيّ شيفرة برمجية نريدها.
سنعرف أن العملية التالية a % b بدون فائدة بنظرة واحدة إليها:
def mod(a,b): a % b return a %b
لا يمكن لتحليل هذه الشيفرة البرمجية الثابت -الذي يمكن تطبيقه دون تشغيلها- التأكد من أن التعليمة a % b لا تفعل شيئًا، ولكن قد يؤدي استدعاء التابع __mod__ مع % إلى الكتابة في ملف أو التفاعل مع جزء آخر من برنامجنا أو فعل أي شيء آخر ممكن في بايثون، إذ من الصعب تحسين دالة عندما لا نعرف ما تفعله. وضع كل من Russell Power و Alex Rubinsteyn في ورقتهما العلمية التي تدرس مقدار السرعة التي يمكن الوصول إليها لتفسير بايثون ملاحظةً مفادها بأنه: يجب التعامل مع كل تعليمة على أنها INVOKE_ARBITRARY_METHOD مع عدم وجود معلومات عن النوع.
الخلاصة
Byterun هو مفسر بايثون أبسط وأسهل في الفهم من CPython، حيث يكرر هذا المفسر تفاصيل CPython البنيوية الرئيسية، فهو مفسر قائم على المكدس ويعمل على مجموعات من التعليمات تسمى البايت كود، حيث يتنقل أو يقفز عبر هذه التعليمات، ويدفعها إلى مكدس من البيانات ويسحبها منه، وينشئ المفسر الإطارات ويدمرها ويقفز بينها عند استدعاء الدوال والمولدات والعودة منها. يتشارك Byterun في قيود المفسر الحقيقي، إذ يجب أن يعمل المفسر في وقت التشغيل لتحديد السلوك الصحيح للبرنامج لأن بايثون يستخدم تحديد الأنواع الديناميكي.
يُنصَح بتفكيك برامجنا الخاصة وتشغيلها باستخدام Byterun، ولكن سنعثر على تعليمات لا تنفذها هذه النسخة القصيرة من Byterun، لذا يمكن العثور على التنفيذ الكامل على Github أو من خلال القراءة الدقيقة لملف ceval.c الخاص بمفسر CPython الحقيقي.
ملاحظة: يعتمد هذا المقال على البايت كود الناتج من إصدار Python 3.5 أو الإصدارات الأقدم، حيث كانت هناك بعض التغييرات على مواصفات البايت كود في الإصدار Python 3.6.
ترجمة -وبتصرّف- للمقال A Python Interpreter Written in Python لصاحبته Allison Kaptur.

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