كنت أقوم بالتمرير للأسفل على X/Twitter عندما لاحظت المنشور التالي المتعلق بـبايثون والذي حصل على 2.5 ألف إعجاب.
للوهلة الأولى، يبدو الأمر وكأنه نتيجة مفاجئة وأنا أتفق مع الرأي القائل بأنه يخالف البديهة. لن أناقش ما إذا كان هذا هو السلوك الصحيح أم لا. بدلاً من ذلك، أريد أن أغتنم هذه الفرصة لشرح السبب وراء هذا السلوك وإظهار بعض التفاصيل الداخلية لـ CPython كجزء من العملية.
سنبدأ بإجابة عالية المستوى بمجرد إجراء بعض عمليات التفتيش في REPL، ثم سننتقل إلى مستوى أعمق ونرى تفاصيل تنفيذ القائمة في CPython لمعرفة سبب حدوث ذلك، وأخيرًا سننتقل إلى مستوى آخر لمعرفة كيف يستدعي CPython هذا السلوك.
الإجابة المختصرة
يمكن تفسير هذا السلوك الغريب للغة بايثون مباشرة من REPL نفسها. لذا، أولاً، دعنا نحصل على الإجابة المختصرة باستخدام REPL.
في بايثون، عند استخدام عامل * على كائن من نوع التسلسل (مثل القوائم والسلاسل)، فإنه يكرر عناصر الكائن x عدد من المرات. على سبيل المثال: “a” * 3 ينتج “aaa”. وبالمثل، [[]] * 4 ينتج [[], [], [], []].
ومع ذلك، كما قد تعلم، فإن كل شيء هو كائن في بايثون ويتم الوصول إلى كل كائن من خلال مرجع إليه. لذا في [[]]، القائمة الداخلية هي مرجع لكائن قائمة فارغة. ويقوم عامل * ببساطة بنسخ نفس المرجع أربع مرات، مما ينتج عنه [[], [], [], []]. وكل هذه القوائم الداخلية المكررة هي مراجع لنفس كائن القائمة الفارغة الأولي.
يمكننا التحقق من هذه النظرية عن طريق طباعة معرفات كل قائمة فارغة كما هو موضح أدناه:
>>> l = [[]] * 4
>>> l
[[], [], [], []]
>>> [id(x) for x in l]
[140035530892992, 140035530892992, 140035530892992, 140035530892992]
كما ترى، فإن كل قائمة داخلية لها نفس معرف الكائن، مما يعني أنها مراجع لنفس الكائن. ولهذا السبب فإن تعديل إحدى هذه القوائم الداخلية يعكس تحديثًا لكل قائمة أخرى.
كانت هذه الإجابة المختصرة. الآن، بالنسبة لأولئك المهتمين بتفاصيل تنفيذ CPython وراء هذا، فلنبدأ!
فهم بنية كائنات القائمة في CPython
لنبدأ بالنظر إلى تعريف كائن القائمة المحدد في الملف Include/cpython/listobject.h. يوضح الرسم التوضيحي التالي بنيته.
يتكون من ثلاثة حقول:
- الحقل الأول هو مثيل لبنية
PyObject
(يُطلق عليها أيضًا رأس الكائن لأنها الحقل الأول لكل كائن CPython) — فهو يحتوي على عدد مراجع الكائن وتفاصيل تنفيذ النوع المتعلقة بالكائن. - الحقل الثالث هو السعة الحالية للقائمة، أي عدد الكائنات التي يمكنها استيعابها. يتم تغيير حجم القائمة عندما تكون على وشك تجاوز سعتها.
- الحقل الثاني عبارة عن مجموعة من أنواع
PyObject *
. هذه المجموعة هي المكان الذي تخزن فيه القائمة داخليًا كل هذه الكائنات. دعنا نناقشها بمزيد من التفصيل.
آلية تخزين الكائنات الداخلية في قائمة CPython
لذا، نعلم أن نوع القائمة يستخدم داخليًا مصفوفة لتخزين عناصرها. نوع البيانات لهذا المصفوفة هو PyObject *
، مما يعني أن كل عنصر في هذه المصفوفة هو مؤشر إلى كائن PyObect
. وهذا يعني في حد ذاته أن القائمة تخزن ببساطة مراجع إلى الكائنات بدلاً من تخزين الكائنات الفعلية. يوضح الرسم التوضيحي التالي كيف يبدو الأمر:
دعونا نرى كيف تبدو القائمة بعد إضافة عنصر إليها.
l = []
l.append(3.14)
كما ترى في الرسم التوضيحي — عندما نضيف عنصرًا جديدًا إلى القائمة، فإن القائمة داخليًا تخزن فقط مؤشرًا (أو مرجعًا) إلى هذا الكائن. الآن، دعنا نرى كيف يتعامل كائن القائمة في CPython مع عامل * وما يحدث لهذه المصفوفة في هذه الحالة.
تنفيذ عامل النجمة (*) في أنواع القائمة
مع هذا الفهم الأساسي لكيفية تعريف نوع القائمة في CPython، دعنا نلقي نظرة على الدالة التي تنفذ فيها معالجة عامل *. التنفيذ الكامل لكائن القائمة في CPython موجود في الملف Objects/listobject.c، ويُظهر الرسم التوضيحي التالي الوظيفة التي تتولى معالجة عملية *.
تبدأ هذه الدالة بإنشاء قائمة جديدة، ثم تقوم بنسخ المراجع المخزنة في القائمة الأصلية إلى القائمة الجديدة، وأخيرًا تقوم بتكرار هذه المراجع n مرة في القائمة الجديدة.
على الرغم من أنني قمت بشرح الدالة بالكامل بالتعليق عليها، فلنخصص بعض الوقت للحديث عن جوهرها حيث يتم نسخ عناصر القائمة الأولى وتكرارها في القائمة الجديدة. فلنبدأ بالنظر إلى كيف تبدو القائمة التي تخزن قائمة فارغة كعنصرها الوحيد في الذاكرة:
l1 = []
l1.append([])
هكذا تبدو الأمور قبل أن نستخدم عامل *. تحتوي القائمة الخارجية l1 على قائمة أخرى كعنصر أول. ويتضح هذا من خلال وجود إدخال في مصفوفة ob_item الخاصة بـ l1 يشير إلى القائمة الأخرى. تحتوي هذه القائمة الأخرى حاليًا على عدد مرجعي 1. وسنرى كيف يتغير هذا بعد تطبيق عامل *.
>>> l2 = l1 * 4
>>> print(l2)
[[], [], [], []]
دعونا نرى كيف تبدو الأشياء بعد تطبيق عامل * على l1 وفقًا لمقتطف الكود أعلاه.
يوضح الرسم التوضيحي أعلاه نتيجة تنفيذ l1 * 4. لاحظ أن عامل * في القوائم ينشئ قائمة جديدة عن طريق نسخ المراجع المخزنة في القائمة الأصلية n مرة في القائمة الجديدة. وكما ترى، تحتوي مجموعة ob_item
في هذه القائمة الجديدة على 4 مؤشرات (أو مراجع) لنفس كائن القائمة الفارغة. كما ارتفع عدد مراجع القائمة التي يشيرون إليها من واحد إلى خمسة — أربعة مراجع من l2 ومرجع واحد من l1.
أخيرًا، ماذا يحدث عندما نقوم بتنفيذ l2[0].append()
؟ يوضح الرسم التوضيحي التالي ذلك
l2[0].append(3.14)
لقد أضفنا القيمة العائمة 3.14 إلى العنصر الأول من l2. يوضح الشكل أعلاه التغييرات التي نتجت عن ذلك. لا تزال العناصر الأربعة في l2 تشير إلى نفس القائمة المشتركة ولكن الآن تحتوي مجموعة ob_item
في هذه القائمة على مرجع لكائن من النوع العائم بقيمة 3.14.
هذا هو كل ما يحدث عندما نستخدم عامل * في القوائم. ولكن هناك سؤال آخر – كيف يعرف وقت تشغيل CPython أنه يحتاج إلى استدعاء هذه الدالة المعينة عندما يتم استخدام عامل * في كائن من نوع القائمة؟ هذا قسم إضافي لا علاقة له بفهم سؤالنا الأصلي. يمكنك الاستمرار في القراءة إذا كنت مهتمًا بالتعمق في تفاصيل CPython.
كيف تقوم الآلة الافتراضية لـ CPython بتنفيذ عامل النجمة (*) للقوائم
إذن كيف تعرف الآلة الافتراضية CPython أنها بحاجة إلى استدعاء الدالة list_repeat
من داخل listobject.c
(رأينا هذه الدالة في القسم السابق)؟ دعنا نتعلم هذا من البداية.
إعداد الرأس لكائنات القائمة في CPython
في القسم الأول رأينا تعريف كائن القائمة في CPython، ورأينا أن العنصر الأول فيه كان مثيلًا لبنية PyObject. اتضح أن كل تعريف نوع في CPython يتضمن هذا كحقل أول ونتيجة لذلك يُطلق على هذا الحقل أيضًا رأس الكائن (لأنه يقع في بداية كل كائن).
تتضمن بنية PyObject
عدد المراجع للكائن وتتضمن أيضًا مؤشرًا لكائن من نوع PyTypeObject. الآن، تعد بنية PyTypeObject
ذات أهمية هنا لأنها تتضمن حقولًا لتخزين معلومات متعلقة بالنوع حول الكائن.
من بين هذه الحقول مجموعة من جداول مؤشرات الدوال للتعامل مع مختلف المشغلات وبروتوكولات الكائنات. ومن بين جداول مؤشرات الدوال هذه، يوجد جدول يسمى tp_as_sequence
. ويحتوي على مؤشرات وظائف للتعامل مع العمليات التي يتوقعها كائنات نوع التسلسل. وتتضمن هذه العمليات أشياء مثل التقطيع والفهرسة والتكرار والتسلسل، وما إلى ذلك.
يوضح الرسم التوضيحي التالي تعريفًا جزئيًا لهيكل PyTypeObject
(نظرًا لأنه كبير جدًا بحيث لا يمكن عرضه بالكامل هنا) ويسلط الضوء على جدولين لمؤشرات الدالة، أي tp_as_number
(للمشغلات الرقمية) و tp_as_sequence
(للتعامل مع مشغلات نوع التسلسل).
يحتاج كل كائن في CPython (اعتمادًا على نوعه، أي ما إذا كان رقميًا أو من نوع تسلسل وما إلى ذلك) إلى تنفيذ الوظائف ذات الصلة للتعامل مع العمليات المختلفة. على سبيل المثال، يحتاج كائن من نوع تسلسل إلى تنفيذ وظائف للتعامل مع الفهرسة والتقطيع والتكرار وما إلى ذلك وملء حقل tp_as_sequence
في مثيل PyTypeObject
الخاص به بمؤشرات إلى تلك الوظائف. يوضح الرسم التوضيحي التالي كيف يحدث كل هذا داخل listobject.c
.
يوضح الرسم التوضيحي الدوال التي ينفذها كائن القائمة للتعامل مع عمليات len و+ و* في القوائم. تُستخدم المؤشرات إلى هذه الوظائف لتعبئة مثيل من بنية PySequenceMethods
، وأخيرًا، يذهب مؤشر إلى هذا الكائن داخل مثيل PyTypeObject
(يُسمى PyList_Type
).
بعد ذلك، يتم استخدام مؤشر إلى كائن PyList_Type
هذا لملء رأس كل كائن قائمة جديد عند التهيئة. يوضح الرسم التوضيحي التالي الكود الذي يتم استدعاؤه في كل مرة يحتاج فيها وقت تشغيل CPython إلى إنشاء كائن قائمة جديد.
هذا يعني أن كل كائن قائمة يحتوي على مؤشرات الوظائف هذه في رؤوسه في وقت التشغيل، والتي يمكن للآلة الافتراضية البحث عنها لتنفيذها للتعامل مع عمليات مختلفة. في الواقع، يتبع كل كائن CPython بروتوكولًا مشابهًا في تنفيذه لملء جداول مؤشرات الوظائف في رؤوسها. الآن دعنا نرى كيف تبحث الآلة الافتراضية CPython عن هذا الرأس لأداء عمليات مختلفة على الكائنات.
البحث في جدول مؤشرات الدوال بواسطة VM الخاص بـ CPython
دعونا نفهم هذا بمساعدة مثال حيث لدينا دالة تقوم بتنفيذ عامل * على كائن القائمة.
>>> def repeat():
... l = []
... return l * 4
...
>>> dis.dis(repeat)
1 0 RESUME 0
2 2 BUILD_LIST 0
4 STORE_FAST 0 (l)
3 6 LOAD_FAST 0 (l)
8 LOAD_CONST 1 (4)
10 BINARY_OP 5 (*)
14 RETURN_VALUE
كما تعرض القائمة أعلاه أيضًا البايت كود المجمّع لهذه الدالة باستخدام وحدة dis. يستخدم CPython آلة افتراضية قائمة على المكدس (VM) لتنفيذ تعليمات البايت كود هذه. تستخدم الآلة الافتراضية مكدسًا لتخزين الوسائط لتنفيذ هذه التعليمات.
بصرف النظر عن المكدس، يوجد جدول locals
لتخزين المتغيرات المحلية ووسائط الوظيفة، وجدول globals
لتخزين الكائنات العالمية. بناءً على هذه المعلومات، دعنا نفهم ما يحدث في تسلسل البايت كود هذا.
BUILD_LIST
: ينشئ كائن قائمة جديد.STORE_FAST
: يضع كائن القائمة في جدول locals (أي المتغير l) عند الفهرس 0.LOAD_FAST
: يقوم بتحميل الكائن الموجود عند الفهرس 0 من جدول locals ويدفعه إلى المكدس.LOAD_CONST
: يدفع الثابت 4 إلى المكدس.BINARY_OP
: يقوم بإخراج الكائنين العلويين من المكدس وينفذ عملية * عليهما، أي أنه يقوم بإخراج 4 وl من المكدس وينفذ عملية * عليهما. دعنا نناقش هذا بمزيد من التفصيل.
يوجد التنفيذ الخاص بالتعامل مع كل تعليمات البايت كود هذه في الملف Python/generated_cases.c.h. ويبدو الكود الخاص بالتعامل مع تعليمة BINARY_OP
على النحو التالي:
إذا نحن نرى أن الآلة الافتراضية تحتوي على جدول مؤشرات وظيفية للتعامل مع كل عامل. ولتنفيذ عملية ثنائية، تبحث الآلة الافتراضية في هذا الجدول وتستدعي الدالة المقابلة. والآن دعنا نرى كيف يبدو هذا الجدول.
يتم تعريف جميع مؤشرات الدوال الموضحة في هذا الجدول في ملف Objects/abstract.c. هذا الملف abstract.c عبارة عن واجهة كائن مجردة تخفي تفاصيل تنفيذ نظام النوع عن الجهاز الظاهري. لا يحتاج الجهاز الظاهري إلى معرفة كيفية تنفيذ الأنواع المختلفة لمشغلات مختلفة، فهو يسمح لواجهة الكائن المجرد بالتعامل مع هذا الجزء. الآن دعنا نلقي نظرة على تنفيذ دالة PyNumber_Multiply في الملف Objects/abstract.c.
يسرد الرسم التوضيحي أعلاه الدالة من abstract.c
التي تتعامل مع عامل *. وكما ترى، فإن هذه الدالة تدرك تمامًا كيف يعرض نظام النوع في CPython الدوال الخاصة بمعالجة العوامل عبر جدول مؤشر الدالة في رأس الكائن.
على الرغم من أن اسم الدالة هو PyNumber_Multiply
، إلا أنها تتعامل مع عامل * لكل من الأنواع الرقمية وأنواع التسلسل. تحاول أولاً البحث في جدول مؤشر الدالة للعمليات الرقمية (والتي يتم تعريفها داخل حقل PyNumberMethods
في PyTypeOjbect
)، وعندما تفشل في ذلك، تحاول البحث في جدول مؤشر الدالة لأنواع التسلسل. بمجرد العثور على الدالة، تستدعي تلك الدالة وتعيد القيمة.
اكتشاف المزيد من بايثون العربي
اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.