الفصل 15

القواميس — أزواج المفاتيح والقيم

تخزين القيم وجلبها بالاسم، تجنب KeyError باستخدام .get()، إضافة المفاتيح وتعديلها وحذفها، التكرار باستخدام .items()، ولماذا لا يمكن للقائمة أن تكون مفتاحاً.

32 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

نريد تخزين درجات ثلاثة طلاب — مع تحديد درجة كل طالب بدقة.

توفر لنا القوائم طريقة واحدة فقط للقيام بذلك: قائمتان متوازيتان جنباً إلى جنب.

python
names = ["rafi", "ahmed", "bilal"]
marks = [72, 45, 90]

print(names[1], marks[1])
text
ahmed 45

هذا الكود يعمل، لكن هذا الترتيب بالكامل يعتمد فقط على انضباطك الشخصي كمبرمج، دون أي مساعدة أو ضمانة من بايثون.

ماذا لو أضفت اسماً جديداً إلى names ونسيت إضافة درجته إلى marks؟ ستصبح القائمتان بطولين مختلفين، ومن تلك اللحظة فصاعداً سيشير names[2] و marks[2] إلى شخصين مختلفين تماماً! ولن تعترض بايثون أو تنبهك بأي خطأ. وماذا لو رتّبت قائمة names؟ ستنهار العلاقة بين الأسماء والدرجات تماماً وفي صمت تام!

وهنا يكمن أصل المشكلة: العلاقة بين الشيئين غير مسجلة في أي مكان في الكود. العلاقة مبنية فقط على افتراض "وجودهما في نفس الفهرس" — وهي مجرد صدفة هشة معرضة للانهيار في أي لحظة.

يأتي القاموس (Dictionary) ليكتب هذه العلاقة مباشرة في صلب الكود: أسماء صريحة بدلاً من الفهارس الرقمية، والزوج (المفتاح والقيمة) محفوظان معاً في مكان واحد آمن.

في نهاية هذا الدرس ستكون قادراً على

  • إنشاء قاموس واسترجاع القيم باستخدام المفاتيح
  • تجنب خطأ KeyError باستخدام دالة .get() ومعرفة متى تستخدم كلاً منهما
  • إضافة المفاتيح وتعديلها وحذفها
  • التنقل والتكرار عبر القاموس باستخدام .keys() و .values() و .items()
  • شرح لماذا لا يمكن للقائمة أن تكون مفتاحاً بينما يمكن للصف (tuple) أن يكون كذلك

المتطلبات السابقة: الصفوف وفك الحزم — Tuples and Unpacking.


القاموس — أزواج من المفاتيح والقيم

نستخدم الأقواس المعقوفة، مع كتابة أزواج key: value بداخلها:

python
marks = {"rafi": 72, "ahmed": 45, "bilal": 90}

print(marks)
print(marks["ahmed"])
print(len(marks))
text
{'rafi': 72, 'ahmed': 45, 'bilal': 90}
45
3

marks["ahmed"] — اسم صريح، وليس مجرد رقم فهرس. والاسم مكتوب مباشرة في الكود، لذا حتى بعد مرور ستة أشهر سيظل واضحاً تماماً ما الذي يطلبه الكود.

وتحسب دالة len() عدد الأزواج: ثلاثة أزواج.

ملاحظة حول الترتيب: بدءاً من إصدار بايثون 3.7، أصبح القاموس يحتفظ بترتيب إدخال العناصر، ويطبعها بذلك الترتيب. ومع ذلك، فالقاموس مخصص للبحث عن العناصر بالاسم، وليس بالموضع — فإذا كتبت marks[0] ستبحث بايثون عن مفتاح يحمل الرقم صفر، ولن تجده، وسيتوقف البرنامج!

مفتاح غير موجود — خطأ KeyError ودالة .get()

python
marks = {"rafi": 72}
print(marks["dia"])
text
KeyError: 'dia'

يعني خطأ KeyError أنه "لا يوجد مفتاح بهذا الاسم". الخطأ المماثل في القوائم كان يعطي IndexError — فهما اسمان مختلفان للنوع نفسه من الأخطاء المنطقية.

للحصول على إجابة آمنة بدلاً من إيقاف البرنامج، استخدم الدالة .get():

python
marks = {"rafi": 72}

print(marks.get("dia"))
print(marks.get("dia", 0))
print(marks.get("rafi", 0))
text
None
0
72

الوسيط الثاني يحدد "القيمة التي تُعاد إذا كان المفتاح مفقوداً". ومن دون تحديده، ستحصل على None.

أيهما تختار ومتى؟ إذا كان المفتاح يجب أن يكون موجوداً بطبيعة الحال، فاكتب marks["rafi"] — لأن غيابه في هذه الحالة يُعد خللاً برمجياً، وسيظهره لك خطأ KeyError فوراً لإصلاحه. أما إذا كان غياب المفتاح أمراً طبيعياً ومتوقعاً، فاستخدم .get(). فالإفراط في استخدام .get() في كل مكان يجعل البيانات الفاسدة تمر في صمت، والبيانات الفاسدة هي بالتحديد ما تحاول اكتشافه وتصحيحه.

الإضافة والتعديل والحذف

python
marks = {"rafi": 72}

marks["dia"] = 88
marks["rafi"] = 75

print(marks)

del marks["rafi"]
print(marks)
text
{'rafi': 75, 'dia': 88}
{'dia': 88}

يؤدي نفس السطر مهمتين مختلفتين: إذا كان المفتاح غير موجود تتم إضافته، وإذا كان موجوداً مسبقاً يتم استبدال قيمته. ولا توجد دالة منفصلة مخصصة لـ "الإضافة".

ومن هنا تنبع قاعدة جوهرية — المفتاح لا يمكن أن يتكرر أكثر من مرة واحدة:

python
marks = {"rafi": 72, "rafi": 90}
print(marks)
text
{'rafi': 90}

لا يظهر أي خطأ؛ لكن القيمة الثانية قامت بمسح القيمة الأولى بهدوء. يحدث هذا الأمر باستمرار عند قراءة البيانات من الملفات، وهو أحد الأسباب الشائعة لاختفاء بعض السجلات دون انتباه.

المعامل in يبحث في المفاتيح وليس القيم

python
marks = {"rafi": 72, "ahmed": 45}

print("rafi" in marks)
print(72 in marks)
text
True
False

العدد 72 موجود بالتأكيد كقيمة، لكن المعامل in يبحث افتراضياً بين المفاتيح فقط. وللبحث في القيم يجب أن تحدد ذلك صراحة: 72 in marks.values().

التكرار عبر القاموس بثلاث طرق

python
marks = {"rafi": 72, "ahmed": 45}

for name in marks:
    print(name)

print("---")
for name, mark in marks.items():
    print(name, mark)

print("---")
print(list(marks.keys()))
print(list(marks.values()))
text
rafi
ahmed
---
rafi 72
ahmed 45
---
['rafi', 'ahmed']
[72, 45]

الطريقة الأولى تستحق الانتباه: for name in marks تعطيك المفاتيح، وليس القيم! وهو أمر يفاجئ كل مبتدئ في المرة الأولى.

وعندما تريد المفتاح والقيمة معاً، تقدم لك دالة .items() صفاً (tuple) في كل دورة — وتقوم العبارة name, mark بفك حزمته فوراً. وهنا تظهر القيمة العملية لفك الحزم الذي تعلمناه في الدرس السابق.

ولإجراء الحسابات على القيم وحدها، نستخدم .values():

python
marks = {"rafi": 72, "ahmed": 45, "bilal": 90}

print(sum(marks.values()))
print(max(marks.values()))
print(round(sum(marks.values()) / len(marks), 2))
text
207
90
69.0

ما الذي يصلح لأن يكون مفتاحاً؟

لكي يصلح أي كائن ليكون مفتاحاً، يجب أن يكون غير قابل للتعديل (immutable). النصوص والأرقام والصفوف كلها تصلح تماماً. أما القوائم فلا تصلح:

python
d = {}
d[["a", "b"]] = 1
text
TypeError: unhashable type: 'list'

كلمة "unhashable" تستحق التوضيح: يعثر القاموس على العناصر بسرعة هائلة لأنه يقوم بحساب رقم خاص (hash) لكل مفتاح، ويستخدم هذا الرقم للذهاب مباشرة إلى المكان الصحيح في الذاكرة. وإذا كان المفتاح قابلاً للتعديل بعد إنشائه، فإن هذا الرقم سيتغير وسيضيع العنصر داخل الذاكرة ولا يمكن الوصول إليه. لذلك ترفض بايثون بشكل قاطع استخدام أي كائن قابل للتعديل كمفتاح.

أما الصف (tuple) فلا يمكن تعديله، مما يجعله مفتاحاً ممتازاً:

python
grid = {}
grid[(0, 0)] = "start"
grid[(1, 2)] = "tree"

print(grid)
print(grid[(1, 2)])
text
{(0, 0): 'start', (1, 2): 'tree'}
tree

في الدرس السابق قلنا إن عدم قابلية الصف للتعديل ميزة وليست عيباً. وهذا هو أول إثبات عملي ملموس على ذلك!

النمط الأكثر فائدة على الإطلاق — نمط العد والإحصاء

python
words = ["pen", "bag", "pen", "ink", "pen"]
counts = {}

for word in words:
    counts[word] = counts.get(word, 0) + 1

print(counts)
text
{'pen': 3, 'bag': 1, 'ink': 1}

اقرأ السطر من الداخل إلى الخارج: counts.get(word, 0) تعني "أعطني عدد مرات ظهور هذه الكلمة حتى الآن، وإذا لم تكن موجودة بعد فأعطني صفراً"، ثم نضيف واحداً ونخزن النتيجة في القاموس.

القيمة الافتراضية في .get() تنجز كل العمل هنا — فمن دونها، كانت كل كلمة جديدة ستطلق خطأ KeyError، وكنت ستضطر لكتابة if word in counts: وتفريع الكود إلى مسارين. ستكتب هذا النمط المكون من سطر واحد مرات لا حصر لها في مسيرتك البرمجية.


مثال متكامل

marks.py:

python
# A dictionary keeps a name attached to its value
marks = {"rafi": 72, "ahmed": 45, "bilal": 90, "dia": 88}

print("Everyone:")
for name, mark in marks.items():
    status = "pass" if mark >= 40 else "fail"
    print(f"  {name:<8} {mark:>3}  {status}")

print()
print("Count    :", len(marks))
print("Total    :", sum(marks.values()))
print("Average  :", round(sum(marks.values()) / len(marks), 2))
print("Highest  :", max(marks.values()))

best = max(marks, key=marks.get)
print("Best     :", best, marks[best])

print("Has dia  :", "dia" in marks)
print("Missing  :", marks.get("nadia", "not recorded"))

marks["dia"] = 91
marks["nadia"] = 60
del marks["ahmed"]

print()
print("After edits:", marks)
text
Everyone:
  rafi      72  pass
  ahmed     45  pass
  bilal     90  pass
  dia       88  pass

Count    : 4
Total    : 295
Average  : 73.75
Highest  : 90
Best     : bilal 90
Has dia  : True
Missing  : not recorded

After edits: {'rafi': 72, 'bilal': 90, 'dia': 91, 'nadia': 60}

ثلاث ملاحظات بارزة:

status = "pass" if mark >= 40 else "fail" هي طريقة لكتابة الشرط في سطر واحد، وتُقرأ من المنتصف: ""pass" إذا كان mark >= 40، وإلا فـ "fail"". جملة if/else المكونة من أربعة أسطر في الدرس التاسع تُستخدم هنا لاختيار قيمة واحدة، ولذلك يكفي سطر واحد. اكتب جملة if كاملة للقرارات المعقدة — أما هذه الصيغة المختصرة فمخصصة فقط للاختيار السريع بين قيمتين.

max(marks, key=marks.get) يوضح وسيط key= من الدرس الثالث عشر وهو يؤدي مهمة جديدة. كتابة max(marks) وحدها كانت ستعيد المفتاح الأكبر، أي الاسم الأخير أبجدياً. أما key=marks.get فتخبر بايثون بمقارنة المفاتيح بناءً على قيمها في القاموس، مما يعيد اسم صاحب أعلى درجة. لاحظ أن الحصول على أعلى درجة أمر سهل (max(marks.values()))؛ أما معرفة صاحب تلك الدرجة فيتطلب هذه الحيلة الذكية.

الترتيب في السطر الأخير. كان الاسم dia موجوداً بالفعل، لذا تغيرت قيمته لكن موضعه بقي ثابتاً. والاسم nadia جديد، لذا أُضيف في النهاية. وحُذف ahmed. فالقاموس يحفظ ترتيب الإدخال، واستبدال قيمة مفتاح موجود لا يغير موضعه.


حالات الخطأ الشائعة

KeyError: 'dia' لا يوجد مفتاح بهذا الاسم. تأكد من صحة الحروف وحالتها — فالمفتاحان "Rafi" و "rafi" هما مفتاحان مختلفان تماماً. وغالباً ما تحمل المفاتيح المقروءة من ملفات مسافة فارغة زائدة في النهاية؛ استخدم print(list(d.keys())) لتظهر علامات الاقتباس حول كل مفتاح وتكشف الفراغات. وإذا كان غياب المفتاح متوقعاً، فاستخدم .get().

TypeError: unhashable type: 'list' محاولة استخدام قائمة كمفتاح. استخدم صَفاً (tuple) بدلاً منها — التعبير tuple(my_list) ينشئ صفاً منها.

الحلقة for x in my_dict لا تعطيني القيم وهذا هو التصرف الصحيح — فهذه الحلقة تدور على المفاتيح فقط. للحصول على القيم استخدم .values()، وللحصول على كليهما استخدم .items().

تشابه مفتاحان فاختفى أحد السجلات المفتاح لا يمكن أن يوجد إلا مرة واحدة، والمفتاح اللاحق استبدل السابق في صمت. للاحتفاظ بعدة سجلات تحت اسم واحد، اجعل القيمة عبارة عن قائمة — وهو موضوع الدرس السابع عشر.

TypeError: 'dict' object is not subscriptable، أو KeyError: 0 محاولة كتابة my_dict[0]. القاموس لا يتعامل بالفهارس الرقمية الموضعية؛ وسيتم البحث عن 0 كمفتاح بالاسم.

دالة .sort() لا تعمل على القاموس هذا صحيح — فالقواميس لا تملك دالة .sort(). استدعاء sorted(d.items()) يعيد قائمة مرتبة من الأزواج، وهو ما تحتاجه عادة.