الكائنات القابلة للتغيير وغير القابلة للتغيير في بايثون: دليل عملي ومفصل
Python) لغة برمجة رائعة، وقد اختارها الكثيرون لتكون بوابتهم الأولى لعالم البرمجة بفضل بساطتها ووضوحها. كما يعتمد عليها المبرمجون المحترفون بشكل واسع، مستفيدين من مجتمعها الضخم، ووفرة مكتباتها، وقواعدها النحوية الواضحة. ومع ذلك، هناك جانب واحد غالبًا ما يثير الحيرة لدى المبتدئين وحتى بعض المطورين ذوي الخبرة: كائنات بايثون (Python objects). وبالتحديد، الفارق الجوهري بين الكائنات القابلة للتغيير (Mutable Objects) والكائنات غير القابلة للتغيير (Immutable Objects).
في هذا المقال، سنتعمق في فهم كائنات بايثون، ونتعلم الفروق الدقيقة بين هذين النوعين، وسنرى كيف يمكننا استخدام مترجم بايثون (Python interpreter) لفهم آلية عمل اللغة بشكل أفضل. سنتناول دوالًا وكلمات مفتاحية مهمة مثل id() و is، وسنستكشف الفارق بين التعبيرين x == y و x is y. هل أنت مستعد؟ هيا بنا نبدأ.
كل شيء في بايثون هو كائن
على عكس لغات البرمجة الأخرى التي قد تدعم الكائنات كجزء من ميزاتها، في بايثون، كل شيء حرفيًا هو كائن – وهذا يشمل الأعداد الصحيحة (integers)، والقوائم (lists)، وحتى الدوال (functions). يمكننا التحقق من ذلك باستخدام مترجم بايثون (interpreter) كالتالي:
>>> isinstance(1, object)
True
>>> isinstance(False, object)
True
>>> def my_func():
... return "hello"
...
>>> isinstance(my_func, object)
True
تتضمن بايثون دالة مدمجة تُسمى id()، والتي تُرجع عنوان الكائن في الذاكرة. على سبيل المثال:
>>> x = 1
>>> id(x)
1470416816
في المثال أعلاه، قمنا بإنشاء كائن بالاسم x، وخصصنا له القيمة 1. ثم استخدمنا الدالة id(x) واكتشفنا أن هذا الكائن موجود في العنوان 1470416816 في الذاكرة. تتيح لنا هذه الخاصية فحص جوانب مثيرة للاهتمام حول كيفية تعامل بايثون مع الكائنات.
الفرق بين == و is: مقارنة القيمة والهوية
لنفترض أننا أنشأنا متغيرين في بايثون، أحدهما بالاسم x والآخر بالاسم y، وخصصنا لهما نفس القيمة. على سبيل المثال:
>>> x = "أنا أحب بايثون!"
>>> y = "أنا أحب بايثون!"
يمكننا استخدام عامل المساواة (==) للتحقق من أنهما يحملان نفس القيمة من منظور بايثون:
>>> x == y
True
ولكن هل هما نفس الكائن في الذاكرة؟ نظريًا، يمكن أن يكون هناك سيناريوهان مختلفان تمامًا هنا:
- لدينا كائنان مختلفان بالفعل، أحدهما بالاسم
xوالآخر بالاسمy، ويصادف أن لهما نفس القيمة. - تقوم بايثون بتخزين كائن واحد فقط، ولهذا الكائن اسمان (
xوy) يشيران إليه.
الصورة التالية توضح هذين السيناريوهين:

يمكننا استخدام الدالة id() التي قدمناها سابقًا للتحقق من ذلك:
>>> x = "أنا أحب بايثون!"
>>> y = "أنا أحب بايثون!"
>>> x == y
True
>>> id(x)
52889984
>>> id(y)
52889384
كما نرى، يتوافق سلوك بايثون في هذا المثال مع السيناريو الأول الموضح أعلاه. على الرغم من أن x == y (أي أن x و y لهما نفس القيم)، إلا أنهما كائنان مختلفان في الذاكرة. هذا لأن id(x) != id(y)، كما يمكننا التحقق صراحةً:
>>> id(x) == id(y)
False
هناك طريقة أقصر لإجراء المقارنة أعلاه، وهي استخدام عامل الهوية is في بايثون. التحقق مما إذا كان x is y هو نفس التحقق مما إذا كان id(x) == id(y)، مما يعني ما إذا كان x و y يشيران إلى نفس الكائن في الذاكرة:
>>> x == y
True
>>> id(x) == id(y)
False
>>> x is y
False
هذا يوضح الفرق المهم بين عامل المساواة == وعامل الهوية is. كما ترون في المثال أعلاه، من الممكن تمامًا أن يرتبط اسمان في بايثون (x و y) بكائنين مختلفين (وبالتالي، x is y ستكون False)، بينما يكون لهذين الكائنين نفس القيمة (وبالتالي، x == y ستكون True).
كيفية جعل متغيرين يشيران إلى نفس الكائن
كيف يمكننا إنشاء متغير آخر يشير إلى نفس الكائن الذي يشير إليه x؟ يمكننا ببساطة استخدام عامل التخصيص =، كالتالي:
>>> x = "أنا أحب بايثون!"
>>> z = x
للتحقق من أنهما يشيران بالفعل إلى نفس الكائن، يمكننا استخدام عامل is:
>>> x is z
True
بالطبع، هذا يعني أن لديهما نفس العنوان في الذاكرة، كما يمكننا التحقق صراحةً باستخدام id():
>>> id(x)
54221824
>>> id(z)
54221824
وبالطبع، لديهما نفس القيمة، لذلك نتوقع أن تُرجع x == z القيمة True أيضًا:
>>> x == z
True
الكائنات القابلة للتغيير وغير القابلة للتغيير في بايثون
لقد ذكرنا أن كل شيء في بايثون هو كائن، ومع ذلك، هناك تمييز مهم بين الكائنات. فبعض الكائنات قابلة للتغيير (mutable) بينما البعض الآخر غير قابل للتغيير (immutable). وكما أشرت سابقًا، تسبب هذه الحقيقة ارتباكًا للكثيرين الجدد على بايثون، لذلك سنتأكد من توضيحها بشكل كامل.
الكائنات غير القابلة للتغيير (Immutable Objects)
بالنسبة لبعض الأنواع في بايثون، بمجرد إنشاء نسخ (instances) منها، لا تتغير أبدًا. إنها كائنات غير قابلة للتغيير. على سبيل المثال، كائنات الأعداد الصحيحة (int objects) غير قابلة للتغيير في بايثون. ماذا سيحدث إذا حاولنا تغيير قيمة كائن من نوع int؟
>>> x = 24601
>>> x
24601
>>> x = 24602
>>> x
24602
حسنًا، يبدو أننا قمنا بتغيير قيمة x بنجاح. وهذا بالضبط هو مصدر الارتباك للكثيرين. ما الذي حدث بالضبط “تحت الغطاء” هنا؟ دعنا نستخدم الدالة id() لمزيد من التحقيق:
>>> x = 24601
>>> x
24601
>>> id(x)
1470416816
>>> x = 24602
>>> x
24602
>>> id(x)
1470416832
نلاحظ أنه من خلال تعيين x = 24602، لم نقم بتغيير قيمة الكائن الذي كان x مرتبطًا به من قبل. بل قمنا بإنشاء كائن جديد، وربطنا الاسم x بهذا الكائن الجديد.
إذًا، بعد تعيين القيمة 24601 للمتغير x باستخدام x = 24601، كان لدينا هذه الحالة:

وبعد استخدام x = 24602، أنشأنا كائنًا جديدًا، وربطنا الاسم x بهذا الكائن الجديد. الكائن الآخر الذي يحمل القيمة 24601 لم يعد يمكن الوصول إليه بواسطة x (أو أي اسم آخر في هذه الحالة):

كلما قمنا بتعيين قيمة جديدة لاسم (في المثال أعلاه – x) مرتبط بكائن من نوع int، فإننا في الواقع نغير ارتباط هذا الاسم بكائن آخر. وينطبق الشيء نفسه على الكائنات من نوع الصفوف (tuples)، والسلاسل النصية (str objects)، والقيم المنطقية (bools). بعبارة أخرى، كائنات int (وأنواع الأرقام الأخرى مثل float)، و tuple، و bool، و str هي كائنات غير قابلة للتغيير.
دعنا نختبر هذه الفرضية. ماذا يحدث إذا أنشأنا كائن tuple، ثم أعطيناه قيمة مختلفة؟
>>> my_tuple = (1, 2, 3)
>>> id(my_tuple)
54263304
>>> my_tuple = (3, 4, 5)
>>> id(my_tuple)
56898184
تمامًا مثل كائن int، نرى أن عملية التخصيص لدينا غيرت بالفعل الكائن الذي يرتبط به الاسم my_tuple. ماذا يحدث إذا حاولنا تغيير أحد عناصر tuple؟
>>> my_tuple[0] = 'a new value'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
كما نرى، لا تسمح لنا بايثون بتعديل محتويات my_tuple، لأنه كائن غير قابل للتغيير بطبيعته.
الكائنات القابلة للتغيير (Mutable Objects)
بعض الأنواع في بايثون يمكن تعديلها بعد إنشائها، وتُسمى كائنات قابلة للتغيير. على سبيل المثال، نعلم أنه يمكننا تعديل محتويات كائن من نوع القائمة (list object):
>>> my_list = [1, 2, 3]
>>> my_list[0] = 'قيمة جديدة'
>>> my_list
['قيمة جديدة', 2, 3]
هل يعني ذلك أننا أنشأنا كائنًا جديدًا بالفعل عندما خصصنا قيمة جديدة للعنصر الأول من my_list؟ مرة أخرى، يمكننا استخدام الدالة id() للتحقق:
>>> my_list = [1, 2, 3]
>>> id(my_list)
55834760
>>> my_list
[1, 2, 3]
>>> my_list[0] = 'قيمة جديدة'
>>> id(my_list)
55834760
>>> my_list
['قيمة جديدة', 2, 3]
إذًا، عملية التخصيص الأولى my_list = [1, 2, 3] أنشأت كائنًا في العنوان 55834760، بالقيم 1 و 2 و 3:

ثم قمنا بتعديل العنصر الأول من كائن القائمة هذا باستخدام my_list[0] = 'قيمة جديدة'، أي دون إنشاء كائن قائمة جديد:

الآن، دعنا ننشئ اسمين – x و y، وكلاهما مرتبط بنفس كائن القائمة. يمكننا التحقق من ذلك إما باستخدام عامل is، أو بالتحقق صراحةً من معرفات id() الخاصة بهما:
>>> x = y = [1, 2]
>>> x is y
True
>>> id(x)
18349096
>>> id(y)
18349096
>>> id(x) == id(y)
True
ماذا يحدث الآن إذا استخدمنا x.append(3)؟ أي، إذا أضفنا عنصرًا جديدًا (3) إلى الكائن بالاسم x؟ هل سيتغير x؟ وهل سيتغير y؟ حسنًا، كما نعلم بالفعل، هما في الأساس اسمان لنفس الكائن:

نظرًا لأن هذا الكائن قد تغير، فعندما نتحقق من اسميه، يمكننا رؤية القيمة الجديدة:
>>> x.append(3)
>>> x
[1, 2, 3]
>>> y
[1, 2, 3]
لاحظ أن x و y لهما نفس id() كما كان من قبل – لأنهما لا يزالان مرتبطين بنفس كائن القائمة:
>>> id(x)
18349096
>>> id(y)
18349096

بالإضافة إلى القوائم (lists)، تشمل أنواع بايثون الأخرى القابلة للتغيير المجموعات (sets) والقواميس (dicts).
تداعيات الكائنات القابلة للتغيير على مفاتيح القواميس في بايثون
تُستخدم القواميس (dict objects) بشكل شائع في بايثون. كتذكير سريع، نقوم بتعريفها كالتالي:
my_dict = {"name": "عمر", "number_of_pets": 1}
يمكننا بعد ذلك الوصول إلى عنصر معين باستخدام اسم مفتاحه:
>>> my_dict["name"]
'عمر'
القواميس هي كائنات قابلة للتغيير (mutable)، لذا يمكننا تغيير محتوياتها بعد الإنشاء. في أي لحظة، يمكن أن يشير المفتاح في القاموس إلى عنصر واحد فقط:
>>> my_dict["name"] = "جون"
>>> my_dict["name"]
'جون'
من المثير للاهتمام ملاحظة أن مفاتيح القاموس يجب أن تكون غير قابلة للتغيير (immutable):
>>> my_dict = {[1, 2]: "مرحبًا"}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
لماذا هذا هو الحال؟ دعنا نفكر في السيناريو الافتراضي التالي (ملاحظة: المقتطف أدناه لا يمكن تشغيله فعليًا في بايثون):
>>> x = [1, 2]
>>> y = [1, 2, 3]
>>> my_dict = {x: 'a', y: 'b'}
حتى الآن، لا تبدو الأمور سيئة للغاية. قد نفترض أنه إذا قمنا بالوصول إلى my_dict باستخدام المفتاح [1, 2]، فسنحصل على القيمة المقابلة 'a'، وإذا قمنا بالوصول إلى المفتاح [1, 2, 3]، فسنحصل على القيمة 'b'.
الآن، ماذا سيحدث إذا حاولنا استخدام:
>>> x.append(3)
في هذه الحالة، ستكون قيمة x هي [1, 2, 3]، وستكون قيمة y أيضًا [1, 2, 3]. فماذا يجب أن نحصل عليه عندما نطلب my_dict[[1, 2, 3]]؟ هل ستكون 'a' أم 'b'؟ لتجنب مثل هذه الحالات الغامضة، لا تسمح بايثون ببساطة بأن تكون مفاتيح القواميس قابلة للتغيير. هذا يضمن أن المفتاح يمكن “حشره” (hashable) بشكل ثابت، مما يسمح بالبحث الفعال والموثوق في القواميس.
تطبيق عملي: تفاعلات الكائنات المتداخلة
دعنا نحاول تطبيق معرفتنا على حالة أكثر إثارة للاهتمام. أدناه، سنقوم بتعريف قائمة (list) وهي كائن قابل للتغيير، وصف (tuple) وهو كائن غير قابل للتغيير. تحتوي القائمة على صف، ويحتوي الصف على قائمة:
>>> my_list = [(1, 1), 2, 3]
>>> my_tuple = ([1, 1], 2, 3)
>>> type(my_list)
<class 'list'>
>>> type(my_list[0])
<class 'tuple'>
>>> type(my_tuple)
<class 'tuple'>
>>> type(my_tuple[0])
<class 'list'>
حتى الآن الأمور جيدة. الآن، حاول أن تفكر بنفسك – ماذا سيحدث عندما نحاول تنفيذ كل من العبارتين التاليتين؟
>>> my_list[0][0] = 'تغيرت!'>>> my_tuple[0][0] = 'تغيرت!'
في العبارة (1)، ما نحاول فعله هو تغيير العنصر الأول من my_list، وهو صف (tuple). نظرًا لأن الصف كائن غير قابل للتغيير، فإن هذه المحاولة محكوم عليها بالفشل:
>>> my_list[0][0] = 'تغيرت!'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
لاحظ أن ما كنا نحاول فعله ليس تغيير القائمة نفسها، بل تغيير محتويات عنصرها الأول، والذي هو صف.
دعنا ننتقل إلى العبارة (2). في هذه الحالة، نقوم بالوصول إلى العنصر الأول من my_tuple، والذي يصادف أنه قائمة (list)، ونقوم بتعديله. دعنا نتحقق من هذه الحالة بشكل أعمق وننظر إلى عناوين هذه العناصر:
>>> my_tuple = ([1, 1], 2, 3)
>>> id(my_tuple)
20551816
>>> type(my_tuple[0])
<class 'list'>
>>> id(my_tuple[0])
20446248

عندما نغير my_tuple[0][0]، فإننا لا نغير my_tuple على الإطلاق! في الواقع، بعد التغيير، سيظل العنصر الأول من my_tuple هو الكائن الذي عنوانه في الذاكرة هو 20446248. ومع ذلك، فإننا نغير قيمة هذا الكائن:
>>> my_tuple[0][0] = 'تغيرت!'
>>> id(my_tuple)
20551816
>>> id(my_tuple[0])
20446248
>>> my_tuple
(['تغيرت!', 1], 2, 3)

نظرًا لأننا قمنا فقط بتعديل قيمة my_tuple[0]، وهو كائن قائمة قابل للتغيير، فقد سمحت بايثون بهذه العملية بالفعل. هذا يبرهن على أن قابلية التغيير تعتمد على نوع الكائن نفسه، وليس على الكائن الذي يحتويه.
خاتمة المقال
في هذا المقال، استكشفنا عالم كائنات بايثون. تعلمنا أن كل شيء في بايثون هو كائن، واستخدمنا الدالتين id() و is لتعميق فهمنا لما يحدث “تحت الغطاء” عند إنشاء الكائنات وتعديلها. كما تعلمنا الفرق الجوهري بين الكائنات القابلة للتغيير (mutable objects)، التي يمكن تعديلها بعد إنشائها، والكائنات غير القابلة للتغيير (immutable objects)، التي لا يمكن تعديلها.
رأينا أنه عندما نطلب من بايثون تعديل كائن غير قابل للتغيير مرتبط باسم معين، فإننا في الواقع ننشئ كائنًا جديدًا ونربط هذا الاسم به. ثم تعلمنا لماذا يجب أن تكون مفاتيح القواميس في بايثون غير قابلة للتغيير لضمان الاتساق والفعالية.
إن فهم كيفية “رؤية” بايثون للكائنات هو مفتاح لتصبح مبرمج بايثون أفضل وأكثر كفاءة. نأمل أن يكون هذا المقال قد أضاف قيمة إلى رحلتك في إتقان بايثون.
الخلاصة التقنية
يُعد التمييز بين الكائنات القابلة للتغيير وغير القابلة للتغيير في بايثون من المفاهيم المحورية التي تؤثر بشكل مباشر على تصميم وتنفيذ البرامج. فإدراك أن أنواعًا مثل الأعداد الصحيحة والسلاسل النصية والصفوف هي كائنات غير قابلة للتغيير يوضح لماذا تؤدي عمليات التخصيص التي تبدو وكأنها تعديل إلى إنشاء كائنات جديدة في الذاكرة. في المقابل، تتيح الكائنات القابلة للتغيير مثل القوائم والمجموعات والقواميس تعديل محتوياتها دون تغيير هوية الكائن نفسه. هذا الفهم ضروري لتجنب الأخطاء الشائعة المتعلقة بالآثار الجانبية غير المتوقعة (side effects) عند تمرير الكائنات كمعاملات للدوال أو عند التعامل مع المراجع المتعددة لنفس الكائن. كما أنه يفسر القيود المفروضة على مفاتيح القواميس، مما يضمن استقرار وظائف التجزئة (hashing) اللازمة لكفاءة عمليات البحث.