سمات فئة بايثون: دليل شامل للغاية
نشرت: 2022-03-11أجريت مقابلة برمجة مؤخرًا ، وهي شاشة هاتف استخدمنا فيها محرر نصوص تعاونيًا.
لقد طُلب مني تنفيذ واجهة برمجة تطبيقات معينة ، واخترت القيام بذلك في بايثون. باستخلاص بيان المشكلة ، دعنا نقول أنني كنت بحاجة إلى فصل تخزن other_data
بعض data
وبعض البيانات الأخرى.
أخذت نفسا عميقا وبدأت في الكتابة. بعد بضعة أسطر ، كان لدي شيء مثل هذا:
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...
أوقفني القائم بإجراء المقابلة:
- المقابل: "هذا الخط:
data = []
. لا أعتقد أن هذا صحيح بايثون؟ " - أنا: "أنا متأكد من ذلك. إنه مجرد تعيين قيمة افتراضية لسمة المثيل ".
- المقابل: "متى يتم تنفيذ هذا الرمز؟"
- أنا: "لست متأكدًا حقًا. سأقوم فقط بإصلاحه لتجنب الالتباس ".
كمرجع ، ولإعطائك فكرة عما كنت أسعى إليه ، إليك كيفية تعديل الكود:
class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...
كما اتضح ، كنا على خطأ. تكمن الإجابة الحقيقية في فهم التمييز بين سمات فئة Python وصفات مثيل Python.
ملاحظة: إذا كان لديك خبير في سمات الصف ، فيمكنك التخطي إلى الأمام لاستخدام الحالات.
سمات فئة بايثون
كان القائم بإجراء المقابلة مخطئًا في أن الكود أعلاه صالح من الناحية التركيبية.
أنا أيضًا كنت مخطئًا في عدم تعيين "قيمة افتراضية" لسمة المثيل. بدلاً من ذلك ، يتم تعريف data
كسمة فئة ذات قيمة []
.
من واقع خبرتي ، فإن سمات صف بايثون هي موضوع يعرف الكثير من الناس شيئًا عنه ، لكن القليل منهم يفهم تمامًا.
متغير فئة بايثون مقابل متغير المثيل: ما هو الفرق؟
سمة فئة Python هي سمة من سمات الفئة (دائرية ، أعرف) ، وليست سمة لمثيل فئة.
دعنا نستخدم مثال صنف بايثون لتوضيح الفرق. هنا ، class_var
هي سمة class ، و i_var
هي سمة مثيل:
class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var
لاحظ أن جميع مثيلات الفئة لديها وصول إلى class_var
، وأنه يمكن أيضًا الوصول إليها كخاصية للفئة نفسها :
foo = MyClass(2) bar = MyClass(3) foo.class_var, foo.i_var ## 1, 2 bar.class_var, bar.i_var ## 1, 3 MyClass.class_var ## <— This is key ## 1
بالنسبة لمبرمجي Java أو C ++ ، تكون سمة الفئة مشابهة - ولكنها ليست متطابقة - للعضو الثابت. سنرى كيف تختلف فيما بعد.
فئة مقابل مساحات أسماء المثيل
لفهم ما يحدث هنا ، دعنا نتحدث بإيجاز عن مساحات أسماء Python .
مساحة الاسم هي تعيين من الأسماء إلى الكائنات ، مع خاصية عدم وجود علاقة صفرية بين الأسماء في مساحات الأسماء المختلفة. عادة ما يتم تنفيذها كقواميس بايثون ، على الرغم من أن هذا يتم تجريده بعيدًا.
اعتمادًا على السياق ، قد تحتاج إلى الوصول إلى مساحة اسم باستخدام صيغة النقطة (على سبيل المثال ، object.name_from_objects_namespace
) أو كمتغير محلي (على سبيل المثال ، object_from_namespace
). كمثال ملموس:
class MyClass(object): ## No need for dot syntax class_var = 1 def __init__(self, i_var): self.i_var = i_var ## Need dot syntax as we've left scope of class namespace MyClass.class_var ## 1
تحتوي كل فئة من فئات Python ومثيلاتها على مساحات الأسماء المميزة الخاصة بها والتي يتم تمثيلها من خلال سمات MyClass.__dict__
المحددة مسبقًا instance_of_MyClass.__dict__
عندما تحاول الوصول إلى سمة من مثيل لفئة ، فإنها تنظر أولاً إلى مساحة اسم مثيلها. إذا عثرت على السمة ، فإنها ترجع القيمة المرتبطة. إذا لم يكن الأمر كذلك ، فإنه يبحث في مساحة اسم الفئة ويعيد السمة (إذا كانت موجودة ، مما يؤدي إلى حدوث خطأ بخلاف ذلك). علي سبيل المثال:
foo = MyClass(2) ## Finds i_var in foo's instance namespace foo.i_var ## 2 ## Doesn't find class_var in instance namespace… ## So look's in class namespace (MyClass.__dict__) foo.class_var ## 1
تأخذ مساحة اسم المثيل السيادة على مساحة اسم الفئة: إذا كانت هناك سمة تحمل نفس الاسم في كليهما ، فسيتم التحقق من مساحة اسم المثيل أولاً وإرجاع قيمتها. إليك نسخة مبسطة من الكود (المصدر) للبحث عن السمات:
def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]
وفي الشكل المرئي:
كيف تتعامل سمات الفصل مع الواجب
مع وضع هذا في الاعتبار ، يمكننا فهم كيفية تعامل سمات فئة Python مع المهمة:
إذا تم تعيين سمة فئة عن طريق الوصول إلى الفئة ، فستتجاوز القيمة لجميع المثيلات. علي سبيل المثال:
foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2
على مستوى مساحة الاسم ... نقوم بإعداد
MyClass.__dict__['class_var'] = 2
. (ملاحظة: هذه ليست الكود الدقيق (الذي سيكونsetattr(MyClass, 'class_var', 2)
) حيث أن__dict__
تُرجِع دكتبروكسي ، وهو غلاف غير قابل للتغيير يمنع التعيين المباشر ، ولكنه يساعد من أجل العرض التوضيحي). بعد ذلك ، عندما نصل إلىfoo.class_var
،class_var
لها قيمة جديدة في مساحة اسم الفئة وبالتالي يتم إرجاع 2.إذا تم تعيين متغير فئة Paython عن طريق الوصول إلى مثيل ، فسيتم تجاوز القيمة لهذا المثال فقط . هذا يتخطى بشكل أساسي متغير الفئة ويحوله إلى متغير حالة متاح ، بشكل حدسي ، لهذه الحالة فقط . علي سبيل المثال:
foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1
على مستوى مساحة الاسم ... نضيف السمة
class_var
إلىfoo.__dict__
، لذلك عندما نبحث عنfoo.class_var
، نرجع 2. وفي الوقت نفسه ، لن تحتوي مثيلاتMyClass
الأخرى علىclass_var
في مساحات أسماء المثيلات الخاصة بها ، لذلك يستمرون في العثور علىclass_var
فيMyClass.__dict__
وبالتالي يتم إرجاع 1.
التحولية
سؤال الاختبار: ماذا لو كانت سمة الفصل لديك بها نوع قابل للتغيير ؟ يمكنك معالجة (تشويه؟) سمة الفئة من خلال الوصول إليها من خلال مثيل معين ، وبالتالي ينتهي الأمر بمعالجة الكائن المشار إليه الذي تصل إليه جميع الحالات (كما أشار تيموثي وايزمان).
هذا أفضل من خلال المثال. دعنا نعود إلى Service
التي حددتها سابقًا ونرى كيف يمكن أن يؤدي استخدامي لمتغير فئة إلى مشاكل في المستقبل.
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...
كان هدفي هو الحصول على القائمة الفارغة ( []
) كقيمة افتراضية data
، ولكل مثيل Service
الحصول على بياناتها الخاصة التي يمكن تغييرها بمرور الوقت على أساس كل حالة على حدة. لكن في هذه الحالة ، نحصل على السلوك التالي (تذكر أن Service
تأخذ بعض الوسيطة other_data
، وهو أمر تعسفي في هذا المثال):
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data.append(1) s1.data ## [1] s2.data ## [1] s2.data.append(2) s1.data ## [1, 2] s2.data ## [1, 2]
هذا ليس جيدًا - تغيير متغير الفئة عبر مثيل واحد يغيره لجميع المتغيرات الأخرى!
على مستوى مساحة الاسم ... تقوم جميع مثيلات Service
بالوصول إلى نفس القائمة وتعديلها في Service.__dict__
بدون إنشاء سمات data
الخاصة بهم في مساحات أسماء المثيلات الخاصة بهم.
يمكننا الالتفاف حول هذا باستخدام التخصيص ؛ أي ، بدلاً من استغلال قابلية التعديل في القائمة ، يمكننا تعيين كائنات Service
لدينا للحصول على قوائمها الخاصة ، على النحو التالي:
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]
في هذه الحالة ، نضيف s1.__dict__['data'] = [1]
، لذا تظل Service.__dict__['data']
دون تغيير.
لسوء الحظ ، يتطلب هذا أن يكون لدى مستخدمي Service
معرفة وثيقة بمتغيراتها ، وبالتأكيد عرضة للأخطاء. بمعنى ما ، سنقوم بمعالجة الأعراض وليس السبب. نفضل شيئًا كان صحيحًا من خلال البناء.
الحل الشخصي الخاص بي: إذا كنت تستخدم فقط متغير فئة لتعيين قيمة افتراضية لمتغير مثيل Python المحتمل ، فلا تستخدم قيمًا قابلة للتغيير . في هذه الحالة ، كان كل مثيل من Service
سيتجاوز Service.data
المثيل الخاصة به في النهاية ، لذا فإن استخدام قائمة فارغة كإعداد افتراضي أدى إلى خطأ صغير تم التغاضي عنه بسهولة. بدلاً من ما سبق ، يمكننا إما:
- تمسك بسمات المثال تمامًا ، كما هو موضح في المقدمة.
تجنب استخدام القائمة الفارغة (قيمة قابلة للتغيير) على أنها "افتراضية":
class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...
بالطبع ، سيتعين علينا التعامل مع قضية
None
بشكل مناسب ، لكن هذا ثمن ضئيل يجب دفعه.
إذن متى يجب عليك استخدام سمات فئة بايثون؟
تعتبر سمات الفصل صعبة ، ولكن دعنا نلقي نظرة على بعض الحالات التي قد تكون مفيدة فيها:
تخزين الثوابت . نظرًا لأنه يمكن الوصول إلى سمات الفئة كسمات للفصل نفسه ، فمن الجيد غالبًا استخدامها لتخزين الثوابت الخاصة بالفصل على مستوى الفصل. علي سبيل المثال:
class Circle(object): pi = 3.14159 def __init__(self, radius): self.radius = radius def area(self): return Circle.pi * self.radius * self.radius Circle.pi ## 3.14159 c = Circle(10) c.pi ## 3.14159 c.area() ## 314.159
تحديد القيم الافتراضية . كمثال تافه ، قد ننشئ قائمة محدودة (على سبيل المثال ، قائمة يمكن أن تحتوي فقط على عدد معين من العناصر أو أقل) ونختار أن يكون لها حد أقصى افتراضي من 10 عناصر:
class MyClass(object): limit = 10 def __init__(self): self.data = [] def item(self, i): return self.data[i] def add(self, e): if len(self.data) >= self.limit: raise Exception("Too many elements") self.data.append(e) MyClass.limit ## 10
يمكننا بعد ذلك إنشاء مثيلات بحدودها الخاصة ، أيضًا ، من خلال التعيين إلى سمة
limit
المثيل.foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10
يكون هذا منطقيًا فقط إذا كنت تريد أن يحتوي مثيلك النموذجي لـ
MyClass
على 10 عناصر فقط أو أقل - إذا كنت تعطي كل مثيلاتك حدودًا مختلفة ، فيجب أن يكونlimit
متغير حالة. (تذكر ، على الرغم من ذلك: توخي الحذر عند استخدام القيم القابلة للتغيير كإعدادات افتراضية.)تتبع جميع البيانات عبر جميع مثيلات فئة معينة . هذا نوع من التحديد ، لكن يمكنني رؤية سيناريو قد ترغب فيه في الوصول إلى جزء من البيانات المتعلقة بكل مثيل موجود لفصل معين.
لجعل السيناريو أكثر واقعية ، دعنا نقول أن لدينا فئة
Person
، ولكل شخصname
. نريد أن نتتبع جميع الأسماء التي تم استخدامها. قد يكون أحد الأساليب هو تكرار قائمة كائنات جامع القمامة ، ولكن من الأسهل استخدام متغيرات الفئة.لاحظ أنه في هذه الحالة ، سيتم الوصول إلى
names
فقط كمتغير فئة ، لذا فإن الإعداد الافتراضي القابل للتغيير مقبول.class Person(object): all_names = [] def __init__(self, name): self.name = name Person.all_names.append(name) joe = Person('Joe') bob = Person('Bob') print Person.all_names ## ['Joe', 'Bob']
يمكننا حتى استخدام نمط التصميم هذا لتتبع جميع الحالات الموجودة لفئة معينة ، بدلاً من مجرد بعض البيانات المرتبطة.
class Person(object): all_people = [] def __init__(self, name): self.name = name Person.all_people.append(self) joe = Person('Joe') bob = Person('Bob') print Person.all_people ## [<__main__.Person object at 0x10e428c50>, <__main__.Person object at 0x10e428c90>]
الأداء (نوع من ... انظر أدناه).
تحت الغطاء
ملحوظة: إذا كنت قلقًا بشأن الأداء في هذا المستوى ، فقد لا ترغب في استخدام Python في المقام الأول ، حيث ستكون الاختلافات في حدود أعشار من المللي ثانية - ولكن لا يزال من الممتع أن تتجول قليلاً ، ويساعد من أجل التوضيح.
تذكر أنه تم إنشاء مساحة اسم الفصل وتعبئتها في وقت تعريف الفصل. هذا يعني أننا نقوم بتخصيص واحد فقط - على الإطلاق - لمتغير فئة معين ، بينما يجب تعيين متغيرات الحالة في كل مرة يتم فيها إنشاء مثيل جديد. لنأخذ مثالا.
def called_class(): print "Class assignment" return 2 class Bar(object): y = called_class() def __init__(self, x): self.x = x ## "Class assignment" def called_instance(): print "Instance assignment" return 2 class Foo(object): def __init__(self, x): self.y = called_instance() self.x = x Bar(1) Bar(2) Foo(1) ## "Instance assignment" Foo(2) ## "Instance assignment"
نقوم بتعيين Bar.y
مرة واحدة فقط ، ولكن instance_of_Foo.y
في كل مكالمة إلى __init__
.
كدليل إضافي ، دعنا نستخدم مُفكك Python:
import dis class Bar(object): y = 2 def __init__(self, x): self.x = x class Foo(object): def __init__(self, x): self.y = 2 self.x = x dis.dis(Bar) ## Disassembly of __init__: ## 7 0 LOAD_FAST 1 (x) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (x) ## 9 LOAD_CONST 0 (None) ## 12 RETURN_VALUE dis.dis(Foo) ## Disassembly of __init__: ## 11 0 LOAD_CONST 1 (2) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (y) ## 12 9 LOAD_FAST 1 (x) ## 12 LOAD_FAST 0 (self) ## 15 STORE_ATTR 1 (x) ## 18 LOAD_CONST 0 (None) ## 21 RETURN_VALUE
عندما ننظر إلى كود البايت ، من الواضح مرة أخرى أن Foo.__init__
يجب أن تقوم بمهمتين ، بينما Bar.__init__
تقوم بواحد فقط.
من الناحية العملية ، كيف يبدو هذا المكسب حقًا؟ سأكون أول من يعترف بأن اختبارات التوقيت تعتمد بشكل كبير على عوامل لا يمكن السيطرة عليها في كثير من الأحيان ، وغالبًا ما يصعب شرح الاختلافات بينها بدقة.
ومع ذلك ، أعتقد أن هذه المقتطفات الصغيرة (يتم تشغيلها باستخدام وحدة Python timeit) تساعد في توضيح الاختلافات بين متغيرات الفئة والمثيل ، لذلك قمت بتضمينها على أي حال.
ملاحظة: أنا أستخدم جهاز MacBook Pro يعمل بنظام OS X 10.8.5 و Python 2.7.2.
التهيئة
10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s
تكون عمليات تهيئة Bar
أسرع بما يزيد عن ثانية ، لذا يبدو أن الاختلاف هنا ذو دلالة إحصائية.
اذا لماذا هذة الحالة؟ تفسير واحد للمضاربة : نقوم بمهمتين في Foo.__init__
، ولكن واحدة فقط في Bar.__init__
.
مهمة
10000000 calls to `Bar(2).y = 15`: 6.232s 10000000 calls to `Foo(2).y = 15`: 6.855s 10000000 `Bar` assignments: 6.232s - 4.940s = 1.292s 10000000 `Foo` assignments: 6.855s - 6.043s = 0.812s
ملاحظة: لا توجد طريقة لإعادة تشغيل رمز الإعداد الخاص بك في كل تجربة بمرور الوقت ، لذلك يتعين علينا إعادة تهيئة متغيرنا في الإصدار التجريبي الخاص بنا. يمثل السطر الثاني من الأوقات الأوقات المذكورة أعلاه مع خصم أوقات التهيئة المحسوبة مسبقًا.
مما سبق ، يبدو أن Foo
يستغرق حوالي 60٪ فقط من Bar
للتعامل مع المهام.
لماذا هذا هو الحال؟ تفسير واحد للمضاربة : عندما نسند إلى Bar(2).y
، ننظر أولاً في مساحة اسم المثيل ( Bar(2).__dict__[y]
) ، ونفشل في العثور على y
، ثم ننظر في مساحة اسم الفئة ( Bar.__dict__[y]
) ، ثم إجراء المهمة الصحيحة. عندما نسند إلى Foo(2).y
، نقوم بإجراء نصف عدد عمليات البحث ، كما نقوم بتعيين مساحة اسم المثيل على الفور ( Foo(2).__dict__[y]
).
باختصار ، على الرغم من أن مكاسب الأداء هذه ليست مهمة في الواقع ، فإن هذه الاختبارات مثيرة للاهتمام على المستوى المفاهيمي. إذا كان هناك أي شيء ، آمل أن تساعد هذه الاختلافات في توضيح الفروق الميكانيكية بين متغيرات الفئة ومتغيرات الحالة.
ختاما
يبدو أن سمات الطبقة غير مستخدمة بشكل كافٍ في بايثون ؛ الكثير من المبرمجين لديهم انطباعات مختلفة عن كيفية عملهم ولماذا قد يكونون مفيدون.
رأيي: متغيرات فصل بايثون لها مكانها في مدرسة الشفرة الجيدة. عند استخدامها بعناية ، يمكنها تبسيط الأشياء وتحسين إمكانية القراءة. ولكن عندما يتم إلقاؤهم بلا مبالاة في فصل دراسي معين ، فمن المؤكد أنهم سيتسببون في تعثرك.
الملحق : متغيرات المثيل الخاص
شيء واحد أردت إدراجه ولكن لم يكن لدي نقطة دخول طبيعية ...
لا تحتوي Python على متغيرات خاصة إذا جاز التعبير ، ولكن هناك علاقة أخرى مثيرة للاهتمام بين الفئة وتسمية المثيلات تأتي مع تشويه الأسماء.
في دليل أسلوب بايثون ، قيل أن المتغيرات الزائفة الخاصة يجب أن تبدأ بشرطة سفلية مزدوجة: "__". هذه ليست فقط إشارة للآخرين على أنه من المفترض أن يتم التعامل مع المتغير الخاص بك بشكل خاص ، ولكن أيضًا وسيلة لمنع الوصول إليه ، من نوع ما. هذا ما أعنيه:
class Bar(object): def __init__(self): self.__zap = 1 a = Bar() a.__zap ## Traceback (most recent call last): ## File "<stdin>", line 1, in <module> ## AttributeError: 'Bar' object has no attribute '__baz' ## Hmm. So what's in the namespace? a.__dict__ {'_Bar__zap': 1} a._Bar__zap ## 1
انظر إلى ذلك: سمة المثيل __zap
مسبوقة تلقائيًا باسم الفئة لإنتاج _Bar__zap
.
بينما لا يزال قابلاً للتعيين ويمكن الحصول عليه باستخدام a._Bar__zap
، فإن هذا الاسم هو وسيلة لإنشاء متغير "خاص" لأنه يمنعك أنت والآخرين من الوصول إليه عن طريق الصدفة أو عن طريق الجهل.
تحرير: كما أشار بيدرو ويرنيك بلطف ، فإن هذا السلوك يهدف إلى حد كبير إلى المساعدة في التصنيف الفرعي. في دليل أسلوب PEP 8 ، يرون أنه يخدم غرضين: (1) منع الفئات الفرعية من الوصول إلى سمات معينة ، و (2) منع تضارب مساحة الاسم في هذه الفئات الفرعية. على الرغم من كونه مفيدًا ، إلا أنه يجب ألا يُنظر إلى التشويش المتغير على أنه دعوة لكتابة تعليمات برمجية ذات تمييز مفترض بين القطاعين العام والخاص ، كما هو الحال في Java.