يمكن أن تخدع صيغة بايثون البسيطة وسهلة التعلم مطوري لغة بايثون Python وخاصة الجدد منهم، مما يؤدي إلى تفويت بعض التفاصيل الدقيقة والتقليل من قوة اللغة، لذا سنقدّم في هذا المقال قائمة بأكثر 10 أخطاء شيوعًا، والتي تكون دقيقة ويصعب اكتشافها ويمكن أن تخدع حتى مطور بايثون الأكثر تقدمًا.
مقدمة إلى بايثون
تُعَد بايثون لغة برمجة مُفسَّرة Interpreted وكائنية التوجّه Object-oriented وعالية المستوى ولها دلالات Semantics ديناميكية، وتجعل هياكلُ البيانات المُضمَّنة عالية المستوى، والتحقق الديناميكي من الأنواع، والربط الديناميكي من لغة بايثون جذابة للغاية لتطوير التطبيقات بسرعة، بالإضافة إلى استخدامها بوصفها لغة برمجة لكتابة السكربتات أو لغة لاصقة Glue Language لوصل المكونات أو الخدمات الموجودة مسبقًا مع بعضها البعض. كما تدعم لغة بايثون الوحدات والحزم، وبالتالي تشجع التقسيم إلى وحدات Modularity وإعادة استخدام الشيفرة البرمجية.
ملاحظة: هذا المقال مخصَّص للمبرمجين المحترفين في بايثون، وليس موجَّهًا للمطورين الجدد الذين قد يكونون أقل دراية بأخطاء بايثون الشائعة.
الخطأ 1: استخدام التعابير بوصفها قيمًا افتراضية لوسطاء الدوال بطريقة خاطئة
تسمح لغة بايثون بتحديد وسيط الدالة بأنه اختياري من خلال توفير قيمة افتراضية له، ولكن قد تؤدي هذه الميزة إلى بعض الارتباك عندما تكون القيمة الافتراضية متغيرة بالرغم من أن هذه ميزة رائعة لهذه اللغة. إليك تعريف دالة بايثون التالي مثلًا:
>>> def foo(bar=[]): # يُعد الوسيط bar اختياريًا وقيمته الافتراضية هي [] عند عدم تحديدها ... bar.append("baz") # ولكن يمكن أن يسبّب هذا السطر مشكلة كما سنرى لاحقًا... ... return bar
من الأخطاء الشائعة أن نعتقد أن الوسيط الاختياري مضبوط على التعبير الافتراضي المحدَّد في كل مرة تُستدعَى فيها الدالة دون توفير قيمة لهذا الوسيط الاختياري، فمثلًا قد نتوقع في الشيفرة البرمجية السابقة أن استدعاء الدالة foo()
بصورة متكررة (أي بدون تحديد الوسيط bar
) سيؤدي دائمًا إلى إعادة القيمة 'baz'
، بما أننا اعتقدنا أن الوسيط bar
مضبوط على القيمة []
(أي قائمة فارغة جديدة) في كل مرة نستدعي فيها الدالة foo()
(بدون تحديد الوسيط bar
)، ولكن لنلقِ نظرة على ما يحدث فعليًا:
>>> foo() ["baz"] >>> foo() ["baz", "baz"] >>> foo() ["baz", "baz", "baz"]
لاحظ استمرار إلحاق القيمة الافتراضية "baz"
إلى القائمة الموجودة مسبقًا في كل مرة نستدعي فيها الدالة foo()
بدلًا من إنشاء قائمة جديدة في كل مرة، إذ تُقيَّم القيمة الافتراضية لوسيط الدالة مرة واحدة فقط في وقت تعريف الدالة، وبالتالي يُهيَّأ الوسيط bar
على قيمته الافتراضية (أي قائمة فارغة) عند تعريف الدالة foo()
لأول مرة فقط، ولكن ستستمر بعد ذلك استدعاءات الدالة foo()
(بدون تحديد الوسيط bar
) في استخدام القائمة نفسها التي هيّأنا بها الوسيط bar
في الأصل.
الحل الشائع لهذه المشكلة هو ما يلي:
>>> def foo(bar=None): ... if bar is None: # أو if not bar: ... bar = [] ... bar.append("baz") ... return bar ... >>> foo() ["baz"] >>> foo() ["baz"] >>> foo() ["baz"]
الخطأ 2: استخدام متغيرات الصنف Class استخدامًا خاطئًا
ليكن لدينا المثال التالي:
>>> class A(object): ... x = 1 ... >>> class B(A): ... pass ... >>> class C(A): ... pass ... >>> print A.x, B.x, C.x 1 1 1
وبالتالي سيكون لدينا أيضًا ما يلي كما هو متوقع:
>>> B.x = 2 >>> print A.x, B.x, C.x 1 2 1
ولكن سيكون لدينا ما يلي:
>>> A.x = 3 >>> print A.x, B.x, C.x 3 2 3
لاحظ تغيير قيمة C.x
بالرغم من أننا غيرنا قيمة A.x
فقط، حيث تُعامَل متغيرات الصنف داخليًا على أنها قواميس في لغة بايثون وتتبع ما يشار إليه غالبًا باسم ترتيب تحليل التوابع أو ترتيب استبيان التوابع Method Resolution Order -أو MRO اختصارًا وهو الآلية التي تستخدمها لغات البرمجة ومن ضمنها بايثون لتحديد ترتيب البحث عن التوابع في التسلسل الهرمي hierarchy الخاص بالكائنات في حالة استخدام الوراثة المتعددة، أي أنه يحدد المسار الذي سيتبعه البرنامج عند محاولة استدعاء دالة معينة موجودة في أكثر من صنف أو الوراثة من عدة أصناف.
لذلك سنبحث عن السمة Attribute التي هي x
في أصنافها الأساسية (أي الصنف A
فقط في المثال السابق بالرغم من أن لغة بايثون تدعم الوراثة المتعددة) بما أننا لم نعثر على هذه السمة في الصنف C
. يمكن القول أيضًا أن الصنف C
ليس لديه الخاصية x
الخاصة به والمستقلة عن الصنف A
، وبالتالي لا يُعَد المرجع إلى C.x
هو المرجع نفسه إلى A.x
، ويؤدي ذلك إلى حدوث مشكلة في بايثون إن لم نتعامل معها بطريقة صحيحة.
الخطأ 3: تحديد المعاملات لكتلة الاستثناء Exception بطريقة خاطئة
لنفترض أن لدينا الشيفرة البرمجية التالية:
>>> try: ... l = ["a", "b"] ... int(l[2]) ... except ValueError, IndexError: # لالتقاط الاستثناءَين ... pass ... Traceback (most recent call last): File "<stdin>", line 3, in <module> IndexError: list index out of range
المشكلة في المثال السابق هي أن تعليمة except
لا تأخذ قائمة الاستثناءات المُحدَّدة بهذه الطريقة، حيث تستخدم بايثون الصيغة except Exception, e
لربط الاستثناء بالمعامل الثاني الاختياري المُحدَّد (هو e
في هذه الحالة)، وبالتالي يمكن إتاحته لمزيد من الفحص. لم تلتقط التعليمة except
الاستثناء IndexError
، بل يُربَط الاستثناء بمعاملٍ اسمه IndexError
، وتُعَد مثل هذه الأخطاء شائعة في شيفرة بايثون البرمجية.
الطريقة الصحيحة لالتقاط الاستثناءات المتعددة في التعليمة except
هي تحديد المعامل الأول بوصفه مجموعة Tuple تحتوي على جميع الاستثناءات المُلتقَطة. يمكن تحقيق أقصى قدر من قابلية النقل من خلال استخدام الكلمة المفتاحية as
لأن هذه الصيغة تدعمها Python 2 و Python 3:
>>> try: ... l = ["a", "b"] ... int(l[2]) ... except (ValueError, IndexError) as e: ... pass ... >>>
الخطأ 4: سوء فهم قواعد نطاق Scope بايثون
يعتمد تحليل Resolution نطاق بايثون على قاعدة LEGB، وهي اختصار للكلمات محلي Local وشامل Enclosing وعام Global ومُضمَّن Built-in. توجد بعض التفاصيل الدقيقة للطريقة التي تعمل بها هذه القاعدة في بايثون، مما يقودنا إلى مشكلة برمجة بايثون الشائعة الأكثر تقدمًا التالية، فليكن لدينا ما يلي مثلًا:
>>> x = 10 >>> def foo(): ... x += 1 ... print x ... >>> foo() Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 2, in foo UnboundLocalError: local variable 'x' referenced before assignment
يحدث الخطأ السابق لأنه إذا أسندتَ قيمة إلى متغير في نطاقٍ ما، فستَعُد لغة بايثون هذا المتغير متغيرًا محليًا لذلك النطاق تلقائيًا وتظلّل أيّ متغير يحمل الاسم نفسه في أيّ نطاق خارجي.
يتفاجأ الكثير من المبرمجين بالحصول على الخطأ UnboundLocalError
في الشيفرة البرمجية التي عملت بنجاح سابقًا عند تعديلها من خلال إضافة تعليمة إسناد في مكانٍ ما من جسم الدالة، فمن الشائع أن يؤدي ذلك إلى أن يخطئ المطورون عند استخدام القوائم خاصةً كما في المثال التالي:
>>> lst = [1, 2, 3] >>> def foo1(): ... lst.append(5) # تعمل هذه التعليمة بنجاح ... >>> foo1() >>> lst [1, 2, 3, 5] >>> lst = [1, 2, 3] >>> def foo2(): ... lst += [5] # ولكن تعطي هذه التعليمة خطأً ... >>> foo2() Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 2, in foo UnboundLocalError: local variable 'lst' referenced before assignment
لاحظ أن الدالة foo1
تعمل بنجاح بينما تعطي الدالة foo2
خطأ، والسبب في ذلك هو مماثل لمشكلة المثال السابق ولكنه أكثر دقة، حيث لا تسند الدالة foo1
قيمة إلى المتغير lst
على عكس الدالة foo2
، فالتعليمة lst += [5]
هي مجرد اختصار للتعليمة
lst = lst + [5]
التي تمثّل محاولة إسناد قيمة إلى المتغير lst
، وبالتالي تفترض لغة بايثون أن هذا المتغير موجود في النطاق المحلي، ولكن تعتمد القيمة التي نريد إسنادها إلى المتغير lst
على المتغير lst
نفسه الذي يُفترَض وجوده في النطاق المحلي ولم نعرّفه بعد.
الخطأ 5: تعديل القائمة أثناء المرور عليها
يجب أن تكون مشكلة الشيفرة البرمجية التالية واضحة إلى حدٍ ما:
>>> odd = lambda x : bool(x % 2) >>> numbers = [n for n in range(10)] >>> for i in range(len(numbers)): ... if odd(numbers[i]): ... del numbers[i] # تصرف سيء: حذف عنصر من القائمة أثناء المرور عليها ... Traceback (most recent call last): File "<stdin>", line 2, in <module> IndexError: list index out of range
يُعَد حذف عنصر من قائمة أو مصفوفة أثناء المرور عليها مشكلةً معروفةً جيدًا في بايثون لمطوري البرمجيات أصحاب الخبرة، ولكن بالرغم من أن المثال السابق قد يكون واضحًا إلى حدٍ ما، إلّا أنه يمكن أن يرتكب حتى المطورون المتقدمون عن غير قصد هذا الخطأ في الشيفرة البرمجية الأكثر تعقيدًا.
لحسن الحظ، تتضمن لغة بايثون عددًا من نماذج البرمجة الأنيقة التي يمكن أن تؤدي إلى شيفرة برمجية مبسطة ومنظَّمة بصورة كبيرة عند استخدامها استخدامًا صحيحًا، ممّا يقلل من احتمالية وجود خطأ الحذف غير المقصود لعنصر القائمة أثناء المرور عليها في هذه الشيفرة البرمجية الأبسط. أحد هذه النماذج هو نموذج استيعاب القوائم List Comprehensions الذي يُعَد مفيدًا خاصةً لتجنب هذه المشكلة كما هو موضّح في التطبيق البديل التالي للشيفرة البرمجية السابقة والذي يعمل بطريقة مثالية:
>>> odd = lambda x : bool(x % 2) >>> numbers = [n for n in range(10)] >>> numbers[:] = [n for n in numbers if not odd(n)] # الحل هنا >>> numbers [0, 2, 4, 6, 8]
الخطأ 6: عدم وضوح كيفية ربط Bind بايثون للمتغيرات في المنغلقات Closures
ليكن لدينا المثال التالي:
>>> def create_multipliers(): ... return [lambda x : i * x for i in range(5)] >>> for multiplier in create_multipliers(): ... print multiplier(2) ...
قد نتوقع الخرج التالي للشيفرة البرمجية السابقة:
0 2 4 6 8
لكننا سنحصل على ما يلي:
8 8 8 8 8
يحدث ذلك بسبب سلوك الربط المتأخر Late Binding لبايثون الذي يبحث عن قيم المتغيرات المُستخدَمة في المنغلقات Closures في وقت استدعاء الدالة الداخلية، لذلك إذا استدعينا أيًا من الدوال المُعادة في المثال السابق، فسيُجرَى البحث عن قيمة المتغير i
في النطاق المحيط في وقت استدعائها، حيث ستكون الحلقة قد اكتملت عندها، لذلك أُسنِدت القيمة 4 إلى المتغير i
فعليًا.
ويكون حل هذه المشكلة الشائعة في بايثون كما يلي:
>>> def create_multipliers(): ... return [lambda x, i=i : i * x for i in range(5)] ... >>> for multiplier in create_multipliers(): ... print multiplier(2) ... 0 2 4 6 8
استفدنا من الوسطاء الافتراضية لإنشاء دوال مجهولة لتحقيق السلوك المطلوب. قد يَعُد البعض هذه الطريقة مناسبة، وقد يَعدها البعض رائعة، وقد يكرهها البعض الآخر، ولكن من المهم أن تفهمها إذا كنت مطور بايثون.
الخطأ 7: إنشاء اعتماديات Dependencies الوحدات الدائرية
لنفترض أن لدينا الملفان a.py
و b.py
، حيث يستورد كلّ منهما الآخر كما يلي:
في الملف a.py
:
import b def f(): return b.x print f()
وفي الملف b.py
:
import a x = 1 def g(): print a.f()
أولًا، لنحاول استيراد الوحدة a.py
كما يلي:
>>> import a 1
لاحظ أن عملية الاستيراد نجحت، وقد يكون ذلك مفاجأة لك، فلدينا استيراد دائري هنا والذي يُفترَض أن يمثل مشكلة، أليس كذلك؟ ولكن لا يمثّل مجرد وجود استيراد دائري في حد ذاته مشكلة في بايثون، فلغة بايثون ذكية بما يكفي لعدم محاولة إعادة استيراد وحدة إذا كانت مستوردةً فعليًا، ولكنك قد تواجه مشكلات اعتمادًا على النقطة التي تحاول فيها كل وحدة الوصول إلى الدوال أو المتغيرات المُعرَّفة في الوحدة الأخرى.
لم يكن هناك مشكلة في استيراد الوحدة b.py
لأنها لا تتطلب تعريف أيّ شيء من الوحدة a.py
في وقت استيرادها عندما استوردنا الوحدة a.py
في المثال السابق، فالإشارة الوحيدة إلى الوحدة a
في الملف b.py
هو استدعاء الدالة a.f()
، ولكن هذا الاستدعاء موجود في الدالة g()
ولا يوجد شيء في الملفين a.py
أو b.py
يستدعي الدالة g()
، لذا لا يوجد شيء يدعو للقلق.
ولكن إذا حاولنا استيراد الوحدة b.py
دون استيراد الوحدة a.py
مسبقًا كما يلي:
>>> import b Traceback (most recent call last): File "<stdin>", line 1, in <module> File "b.py", line 1, in <module> import a File "a.py", line 6, in <module> print f() File "a.py", line 4, in f return b.x AttributeError: 'module' object has no attribute 'x'
فستظهر مشكلة تتمثّل في أن الوحدة b.py
تحاول استيراد الوحدة a.py
عند عملية استيراد الوحدة b.py
، وتستدعي الوحدة a.py
بدورها الدالة f()
التي تحاول الوصول إلى المتغير b.x
الذي لم نعرّفه بعد، وبالتالي سيظهر الاستثناء AttributeError
.
توجد حلول مختلفة لهذا الخطأ، وسيكون أحد هذه الحلول على الأقل بسيطًا، فمثلًا عدّل الوحدة b.py
لتستورد الوحدة a.py
ضمن الدالة g()
:
x = 1 def g(): import a # ستُقيَّم هذه التعليمة عند استدعاء الدالة g() فقط print a.f()
وإذا استوردناه هذه الوحدة، فسيكون كل شيء على ما يرام كما يلي:
>>> import b >>> b.g() 1 # يُطبَع لأول مرة بسبب استدعاء الوحدة 'a' للتعليمة 'print f()' في النهاية 1 # يُطبَع مرة ثانية، حيث يمثّل استدعاء الدالة 'g'
الخطأ 8: تعارض الأسماء مع وحدات مكتبة بايثون المعيارية
تتميز لغة بايثون بوفرة وحدات المكتبات التي تأتي معها، ولكن قد يؤدي ذلك إلى الوقوع في تعارض في الأسماء بين اسم إحدى الوحدات الخاصة بك ووحدة أخرى تحمل الاسم نفسه في المكتبة المعيارية التي تأتي مع لغة بايثون إن لم تكن حذرًا، فمثلًا قد يكون لديك وحدة بالاسم email.py
في شيفرتك البرمجية، والتي قد تتعارض مع وحدة المكتبة المعيارية التي تحمل الاسم نفسه.
يمكن أن يؤدي ذلك إلى مشكلات خطيرة مثل استيراد مكتبة أخرى، والتي تحاول بدورها استيراد إصدارٍ من وحدة خاصة بمكتبة بايثون المعيارية، ولكن إذا كان لديك وحدة تحمل الاسم نفسه، فستستورد الحزمة الأخرى الإصدار الخاص بك عن طريق الخطأ بدلًا من الإصدار الموجود في مكتبة بايثون المعيارية، مما يؤدي إلى حدوث أخطاء، لذا يجب توخي الحذر لتجنب استخدام الأسماء نفسها الخاصة بوحدات مكتبة بايثون المعيارية. من الأسهل بالنسبة لك تغيير اسم الوحدة ضمن الحزمة الخاصة بك بدلًا من تقديم اقتراح تحسين بايثون Python Enhancement Proposal -أو PEP اختصارًا- لطلب تغيير الاسم ومحاولة الحصول على الموافقة على ذلك.
الخطأ 9: الفشل في معالجة الاختلافات بين الإصدارين Python 2 و Python 3
ليكن لدينا الملف foo.py
التالي مثلًا:
import sys def bar(i): if i == 1: raise KeyError(1) if i == 2: raise ValueError(2) def bad(): e = None try: bar(int(sys.argv[1])) except KeyError as e: print('key error') except ValueError as e: print('value error') print(e) bad()
يعمل ما يلي بنجاح في Python 2:
$ python foo.py 1 key error 1 $ python foo.py 2 value error 2
ولكنه يعطي خطأً في Python 3 كما يلي:
$ python3 foo.py 1 key error Traceback (most recent call last): File "foo.py", line 19, in <module> bad() File "foo.py", line 17, in bad print(e) UnboundLocalError: local variable 'e' referenced before assignment
المشكلة هي أنه لا يمكن الوصول إلى كائن الاستثناء خارج نطاق كتلة التعليمة except
في Python 3، وإلّا فيجب الاحتفاظ بدورة مرجعية مع إطار المكدس في الذاكرة حتى تشغيل كانس المهملات Garbage Collector وإزالة المراجع من الذاكرة.
إحدى الطرق لتجنب هذه المشكلة هي الاحتفاظ بمرجع إلى كائن الاستثناء خارج نطاق كتلة التعليمة except
بحيث يبقى قابلًا للوصول. إليك فيما يلي نسخة من المثال السابق الذي يستخدم هذه التقنية، وبالتالي ستنتج شيفرة برمجية متوافقة مع Python 2 و Python 3:
import sys def bar(i): if i == 1: raise KeyError(1) if i == 2: raise ValueError(2) def good(): exception = None try: bar(int(sys.argv[1])) except KeyError as e: exception = e print('key error') except ValueError as e: exception = e print('value error') print(exception) good()
لنشغّل هذه الشيفرة البرمجية على الإصدار Py3k:
$ python3 foo.py 1 key error 1 $ python3 foo.py 2 value error 2
اطّلع على مقال كيفية ترحيل شيفرة بايثون 2 إلى بايثون 3 لمزيد من المعلومات.
الخطأ 10: استخدام التابع del بطريقة خاطئة
لنفترض أن لدينا ما يلي في ملفٍ اسمه mod.py
:
import foo class Bar(object): ... def __del__(self): foo.cleanup(self.myhandle)
ثم حاولنا استيراده من الملف another_mod.py
كما يلي:
import mod mybar = mod.Bar()
فسنحصل على الاستثناء AttributeError
، والسبب هو ضبط جميع متغيرات الوحدة العامة على القيمة None
عند إيقاف تشغيل المفسّر Interpreter، لذلك ضُبِط الاسم foo
على القيمة None
عند استدعاء التابع __del__
في المثال السابق. الحل لهذه المشكلة هو استخدام الدالة atexit.register()
بدلًا من ذلك، وبالتالي ستُشغَّل معالجاتك المسجَّلة قبل إيقاف تشغيل المفسِّر عندما ينتهي برنامجك من التنفيذ (أي عند الخروج منه بطريقة طبيعية).
إذًا لنصلِح شيفرة mod.py
البرمجية السابقة كما يلي:
import foo import atexit def cleanup(handle): foo.cleanup(handle) class Bar(object): def __init__(self): ... atexit.register(cleanup, self.myhandle)
يوفّر هذا المثال طريقة نظيفة وموثوقة لاستدعاء أيّ دالة تنظيف مطلوبة عند إنهاء البرنامج العادي، ومن الواضح أن الأمر متروك للدالة foo.cleanup
لتحديد ما يجب فعله بالكائن المرتبط بالاسم self.myhandle
.
مخاطر بايثون يمكن تجنبها من خلال معرفة الفروق الأساسية
تُعَد بايثون لغة قوية ومرنة وتحتوي على العديد من الآليات والنماذج التي يمكن أن تحسّن الإنتاجية بصورة كبيرة، ولكن يمكن أن يكون الفهم أو التقدير المحدود لقدراتها في بعض الأحيان عائقًا أكثر من كونه فائدة كما هو الحال مع أيّ أداة أو لغة برمجية، حيث يعتقد الشخص في أن يعلم ما يكفي، ولكنه يشكّل خطرًا. سيساعد التعرف على الفروق الأساسية في لغة بايثون -مثل مشاكل البرمجة المتقدمة التي ذكرناها في هذا المقال- على تحسين استخدام اللغة مع تجنب بعض الأخطاء في بايثون.
ترجمة -وبتصرُّف- للمقال The 10 Most Common Python Code Mistakes لصاحبه Martin Chikilian.
أفضل التعليقات
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.