في هذه المقالة، سنتعمق أكثر في التفاصيل، وننظر إلى ما يحدث بالضبط خلف الكواليس في وقت تشغيل بايثون لتنفيذ شيء بسيط مثل “a + b”. بعبارة أخرى، سنتعلم تفاصيل التنفيذ وراء الأنواع والمشغلات والإرسال الديناميكي في CPython.
لاحظ أنه على الرغم من أننا سنتابع تنفيذ الإرسال الديناميكي لمشغل معين، فإن الأفكار نفسها تنطبق على جميع المشغلات التي يدعمها CPython. وبالتالي، يمكنك باستخدام هذه المعرفة تنفيذ مشغلك الجديد أو نوعك الجديد.
تصميم عالي المستوى للإرسال الديناميكي في CPython
عندما نكتب كود مثل a + b في بايثون، فإن أنواع a وb تحدد السلوك الدقيق لعملية +. كل نوع في بايثون له تنفيذه الخاص لمشغل + (إذا كان هذا النوع يدعم +) ويحدد مفسّر بايثون التنفيذ الصحيح الذي يجب استدعاؤه بناءً على نوع المتغيرات. تسمى هذه العملية بالكامل الإرسال الديناميكي في لغات البرمجة. يقدم الرسم التخطيطي التالي نظرة عامة عالية المستوى حول كيفية عملها في CPython:
دعونا نناقش الأجزاء المختلفة بشكل موجز:
- يتم تجميع كود بايثون إلى كود ثنائي، والذي يتم تنفيذه بواسطة آلة افتراضية (VM) قائمة على المكدس في CPython. تكون تعليمة
BINARY_OP
مسؤولة عن تنفيذ عملية + على المتعاملين، a وb. - لا تعرف الآلة الافتراضية نفسها كيفية تنفيذ الأمر + على كائنين. وبدلاً من ذلك، تقوم بتفويض هذه المهمة إلى واجهة كائن مجردة للتعامل معها.
- تحدد واجهة الكائن المجردة في CPython واجهة تدعم جميع عمليات مستوى الكائن الشائعة في CPython. وهذا يمنح الآلة الافتراضية طريقة موحدة واحدة لتنفيذ جميع المشغلات دون معرفة أي تفاصيل تنفيذ لنظام الكائن. ترسل الواجهة المجردة التنفيذ إلى التنفيذ الملموس داخل الأنواع عبر البحث في جدول مؤشر الوظيفة في رأس الكائن (المزيد حول هذا لاحقًا).
سنبدأ بالنظر في كيفية تنفيذ الأنواع المختلفة للمشغلين المختلفين، ثم سننظر إلى واجهة الكائن المجرد ونرى كيف تستدعي هذه التنفيذات الملموسة، وأخيرًا، سنرى كيف يتكامل CPython VM مع واجهة الكائن المجرد.
تشريح بنية PyTypeObject
بنية PyTypeObject هي الكتلة الثانية من نظام كائنات CPython (الأولى هي PyObject
). وهي تحتوي على معلومات النوع وقت التشغيل حول الكائن. قبل أن نلقي نظرة على الإرسال الديناميكي في CPython، يجب أن نفهم أولاً ما بداخل PyTypeObject
.
لكن أولاً، دعنا نراجع ونرى تعريف PyObject، وهو المكان الذي يظهر فيه PyTypeObject
:
بالإضافة إلى ذلك، يتضمن كل تعريف نوع PyObject
كحقل أول كرأس. على سبيل المثال، هذا هو تعريف النوع العائم:
يعني هذا أنه يمكن تحويل كل كائن من هذه الكائنات إلى PyObject
ولأن PyObject
يتضمن مؤشرًا إلى PyTypeObject
، فإن وقت تشغيل CPython يحتوي على جميع المعلومات المتعلقة بالنوع حول الكائن المتاحة له في جميع الأوقات.
الآن، دعنا نلقي نظرة على PyTypeObject
. إنه كائن كبير جدًا يحتوي على عشرات الحقول. يوضح الشكل التالي تعريفه الكامل:
يخزن هيكل PyTypeObject
تفاصيل نوع وقت التشغيل حول الكائن، مثل اسم النوع وحجم النوع والوظائف لتخصيص وإلغاء تخصيص كائن من هذا النوع.
بصرف النظر عن ذلك، فإنه يخزن أيضًا جداول مؤشرات الوظائف لدعم سلوكيات مختلفة خاصة بأنواع معينة. على سبيل المثال، يعد الحقل tp_as_number أحد هذه الجداول. وهو مؤشر إلى كائن من نوع PyNumberMethods يحدد جدول مؤشرات الوظائف للعمليات العددية.
نظرًا لأننا مهتمون بفهم كيفية تنفيذ CPython لعامل الجمع الثنائي (+)، فسوف نقترب وننظر إلى ما بداخل PyNumberMethods
. يوضح الشكل التالي تعريفه:
يحتاج كل تنفيذ نوع في CPython إلى إنشاء مثيل لبنية PyNumberMethods
وملئها بمؤشرات إلى الوظائف التي ينفذها لدعم المشغلات الرقمية. إذا كان النوع لا يدعم العمليات الرقمية، فيمكنه ببساطة تعيين حقل tp_as_number
في PyTypeObject
إلى NULL
، مما يخبر وقت تشغيل CPython أن هذا الكائن لا يدعم أيًا من هذه العمليات.
بعد ذلك، كمثال ملموس، دعنا نرى كيف يقوم نوع float بتنفيذ هذه الوظائف ثم يقوم بإنشاء PyTypeObject
عند إنشاء كائن float جديد.
إنشاء أنواع Float باستخدام PyNumberMethods
يوضح الشكل التالي الكود من Objects/floatobject.c والذي يحتوي على تنفيذ نوع float في CPython.
دعونا نحللها:
- يُظهر المربع الموجود على الجانب الأيسر الوظائف التي تنفذ عمليات الجمع والطرح والضرب.
- بعد ذلك، يعرض المربع الأوسط مثيلًا لبنية
PyNumberMethods
(تسمىfloat_as_number
) لنوع البيانات float. لاحظ كيف تتضمن مؤشرات الوظيفة لوظائف الجمع والضرب والطرح. - يُظهِر المربع الموجود في أقصى اليمين مثالاً لـ
PyTypeObject
لإنشاء كائنات من نوع float. لاحظ كيف يتضمن مؤشرًا إلى كائنfloat_as_number
.
ويتم تضمين مؤشر إلى float_as_number
في رأس كل كائن عائم (أي كقيمة لحقل ob_type
في PyObject
). يوضح الشكل التالي الدالة PyFloat_FromDouble، التي تنشئ كائنات جديدة من النوع العائم، وتستخدم float_as_number
لتهيئة رأس الكائن.
الشكل مفصل للغاية وموضح، لذا لن أقضي المزيد من الوقت عليه. ولكن هذا هو الكود الذي يتم تنفيذه عندما تكتب “a = 3.14
” في كود بايثون.
ملاحظة جانبية: تحتفظ CPython بمخبأ من كائنات النوع العائمة الحرة غير المستخدمة وتعيد استخدامها عندما تستطيع. ربما يوفر هذا بعض الوقت الذي يتم إنفاقه في تخصيص الذاكرة. هناك مخابئ مماثلة لكائنات أخرى، مثل القوائم والمجموعات والقواميس.
في هذه المرحلة، نفهم أن كل نوع ينفذ عوامل تشغيل مختلفة كوظائف ويستخدمها لملء جدول مؤشر الوظيفة في PyTypeObject
، والذي يتم تضمينه في رأس الكائن. لقد رأينا كيف يعمل هذا المخطط في تنفيذ النوع العائم.
بعد ذلك، ننتقل إلى طبقة واحدة لأعلى ونرى واجهة الكائن المجرد، والتي تقوم فعليًا بالإرسال الديناميكي.
واجهة الكائن المجرد في CPython
يحدد CPython واجهة كائن مجردة لتوحيد الوصول إلى تنفيذات النوع الملموس. وهذا يحافظ على نظافة كود VM لأنه ببساطة يفوض تنفيذ عامل إلى هذه الواجهة.
تم تعريف هذه الواجهة المجردة في ملف Include/abstract.h. ويُظهر الشكل التالي الدوال الرقمية المعلنة فيه:
الآن، ملف abstract.h
هو ملف رأس، لذا فهو يعلن فقط عن النماذج الأولية لهذه الوظائف. توجد تنفيذات هذه الوظائف في الملف Objects/abstract.c. سنركز فقط على تنفيذ وظيفة PyNumber_Add
فيه، والتي يتم استدعاؤها بواسطة VM للتعامل مع تنفيذ عامل +. يوضح الشكل التالي الكود الخاص به، وتشرح التعليقات التوضيحية ما يحدث:
يتم دعم عملية + بواسطة فئتين من أنواع البيانات في بايثون: الأنواع الرقمية (int، float، complex وما إلى ذلك) وأنواع التسلسل (list، tuple، وما إلى ذلك).
تحاول دالة PyNumber_Add
أولاً استدعاء تنفيذ الإضافة الثنائية على الوسائط. إذا كانت هذه الأنواع لا تدعم الإضافة الثنائية، فإنها تحاول التحقق مما إذا كانت هذه الأنواع أنواع تسلسل، وإذا كانت كذلك، فإنها تحاول استدعاء دالة التجميع عليها.
دعنا نركز على الأنواع الرقمية هنا. بالنسبة للأنواع الرقمية، تستدعي الدالة PyNumber_Add
الماكرو BINARY_OP1
، الذي يستدعي ببساطة الدالة binary_op1
. يوضح الشكل التالي binary_op1
:
تؤدي الوظيفة الكثير من الأشياء، لكن التعليقات التوضيحية تشرح كل شيء. والنقطة الأساسية هنا هي أن abstract.c
تقوم ببساطة بالبحث عن مؤشر الوظيفة في جدول التوابع الموجود في رأس الكائن، ثم تستدعي تلك الوظيفة.
ربط تنفيذ المشغل بواجهة الكائن المجرد في CPython VM
هذا هو الفصل الأخير حيث تقوم آلة CPython الافتراضية بدمج تنفيذ المشغل مع واجهة الكائن المجرد.
يوضح الشكل التالي دالة بايثون بسيطة وتعليمات البايت كود الخاصة بها:
تعليمات البايت كود التي سنركز عليها هي BINARY_OP
. توضح الصورة التالية كيفية التعامل معها بواسطة الآلة الافتراضية:
فلنلق نظرة على هذا الكود لأن هذا هو المكان الذي تفوض فيه الآلة الافتراضية إلى abstract.c.
في الكود أعلاه، نرى هذا السطر من الكود:
res = binary_ops[oparg](lhs, rhs);
يقوم هذا الكود بالبحث عن مؤشر الدالة في جدول يسمى binary_ops
، باستخدام التعليمات البرمجية للعملية الثنائية كمؤشر، واستدعاء هذه الدالة. دعنا نلقي نظرة على هذا الجدول المحدد في الملف ceval.c (حيث يتم تنفيذ معظم التعليمات البرمجية لتنفيذ الآلة الافتراضية).
يشير كل مؤشر دالة في جدول binary_ops
إلى دالة تم تنفيذها في Objects/abstract.c
. في القسم السابق، رأينا بالفعل تعريف PyNumber_Add
في abstract.c
، وفهمنا كيف يقوم بالإرسال الديناميكي إلى التنفيذ الصحيح للمشغل بناءً على أنواع المتغيرات.
وبالتالي، هذه هي الطريقة التي يفوض بها الجهاز الافتراضي تنفيذ المشغلات الثنائية إلى تنفيذ الواجهة المجردة، والذي يقوم في النهاية بتنفيذ الإرسال الديناميكي عبر البحث عن مؤشر الوظيفة في الجداول الموجودة في رؤوس الكائنات.
كانت هذه جولة قصيرة لجميع أكواد CPython التي يتم تنفيذها عند تنفيذ شيء بسيط مثل “a + b” في كود بايثون. على الرغم من أن هذا قد يكون أمرًا صعبًا للغاية، إلا أنه ليس معقدًا للغاية إذا كنت تفهم مؤشرات الوظيفة.
باستخدام هذه المعرفة، يمكنك تنفيذ مشغلاتك الخاصة، ولكنك ستحتاج أيضًا إلى تعديل أداة التجزئة والمحلل، وهو ما لم نتحدث عنه بعد. ربما سنتناول هذه الأمور قريبًا، إذا كنت مهتمًا بمعرفة المزيد عن مكونات CPython الداخلية. أخبرني من خلال تعليقاتك وردودك وإعجاباتك ومشاركاتك.
اكتشاف المزيد من بايثون العربي
اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.