الفصل 17

البيانات المتداخلة — قوائم القواميس

الشكل الحقيقي للبيانات: قواميس داخل قوائم، وقوائم داخل قواميس. الوصول للبيانات وتجميعها، ولماذا لا تكفي copy() هنا.

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

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

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

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

text
customer   item     quantity   price
rafi       pen      3          15.0
ahmed      bag      1          850.0

هناك مستويان من البيانات هنا. في الخارج لدينا تجميع كلي — عدد الطلبات غير معروف مسبقاً، لذا نستخدم قائمة. وفي الداخل، يمثل كل طلب سجلاً يحتوي على أجزاء يحمل كل منها اسماً محدداً — لذا نستخدم قاموساً.

ما يصلك من واجهات برمجة التطبيقات (APIs)، وما تحصل عليه عند قراءة ملفات CSV، وما تعيده قواعد البيانات: يأتي دائماً تقريباً بهذا الشكل: قائمة من القواميس (A list of dictionaries). لا يقدم درس اليوم أي أوعية بيانات جديدة؛ بل يدور حول وضع الأوعية التي تعلمتها بالفعل داخل بعضها البعض، والتعرف على الأخطاء التي تترتب على ذلك.

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

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

المتطلبات السابقة: المجموعات — تجميع العناصر دون تكرار.


التعرف على البنية الهيكلية

python
orders = [
    {"customer": "rafi", "item": "pen", "quantity": 3, "price": 15.0},
    {"customer": "ahmed", "item": "bag", "quantity": 1, "price": 850.0},
]

print(len(orders))
print(orders[0])
print(orders[0]["customer"])
print(orders[1]["price"])
text
2
{'customer': 'rafi', 'item': 'pen', 'quantity': 3, 'price': 15.0}
rafi
850.0

السطر orders[0]["customer"] يمثل المهارة الجوهرية في هذا الدرس، ويُقرأ من اليسار إلى اليمين، خطوة بخطوة:

  • orders — قائمة
  • orders[0] — العنصر الأول فيها، وهو عبارة عن قاموس
  • orders[0]["customer"] — قيمة المفتاح customer داخل ذلك القاموس

وعند الشك، توقف واطبع الخطوة الوسيطة. كتابة print(orders[0]) لكي ترى بنفسك أنه قاموس هي عادة ممتازة تزيل نصف المتاعب والارتباك مع البيانات المتداخلة.

التكرار عبر الهيكل المتداخل

python
orders = [
    {"customer": "rafi", "item": "pen", "quantity": 3, "price": 15.0},
    {"customer": "ahmed", "item": "bag", "quantity": 1, "price": 850.0},
]

for order in orders:
    total = order["quantity"] * order["price"]
    print(f"{order['customer']:<8} {order['item']:<6} {total:>8.2f}")
text
rafi     pen       45.00
ahmed    bag      850.00

تعطيك الحلقة for order in orders قاموساً في كل دورة، وبداخله يمكنك الوصول إلى القيم بالاسم مباشرة. لا توجد أي حسابات للفهارس الرقمية في أي مكان.

علامات الاقتباس داخل نصوص f-string: يستخدم التعبير f"{order['customer']}" علامات اقتباس مفردة بالداخل لأن نص f-string الخارجي كُتب بعلامات اقتباس مزدوجة. إذا استخدمت نفس النوع في الداخل والخارج، فلن تتمكن بايثون من معرفة أين ينتهي النص وأين يبدأ. اجعل دائماً نوعاً بالداخل والآخر بالخارج.

السجلات غير المكتملة

في البيانات الواقعية، لا تحتوي جميع السجلات بالضرورة على كافة المفاتيح:

python
orders = [{"customer": "rafi"}]
print(orders[0]["discount"])
text
KeyError: 'discount'

يخبرك خطأ KeyError بأن المفتاح مفقود، لكنه لا يخبرك أي السجلات تحديداً هو الذي ينقصه هذا المفتاح. وعندما يحدث هذا في طلب واحد من أصل اثني عشر ألف طلب، يصبح العثور عليه أمراً شاقاً للغاية — ولهذا السبب فإن المرور باستخدام enumerate يتيح لك معرفة رقم السجل فوراً.

وعندما يكون غياب المفتاح أمراً طبيعياً ومحتملاً، نستخدم .get():

python
orders = [{"customer": "rafi"}]
print(orders[0].get("discount", 0))
text
0

تنطبق قاعدة الدرس الخامس عشر هنا تماماً: استخدم الأقواس المربعة عندما يجب أن يكون المفتاح موجوداً، واستخدم .get() عندما يكون غيابه أمراً طبيعياً. والإغراء باستخدام .get() يكون أقوى في البيانات المتداخلة لأنها تخفي كل الأخطاء — ولكن عندها تتحول البيانات الناقصة بهدوء إلى أصفار وتتسلل إلى حساباتك دون أن تشعر!

الشكل الآخر — قوائم داخل قاموس

python
by_customer = {
    "rafi": ["pen", "ink"],
    "ahmed": ["bag"],
}

by_customer["rafi"].append("bottle")
by_customer["dia"] = ["eraser"]

print(by_customer)

for customer, items in by_customer.items():
    print(f"{customer}: {len(items)} item(s) - {', '.join(items)}")
text
{'rafi': ['pen', 'ink', 'bottle'], 'ahmed': ['bag'], 'dia': ['eraser']}
rafi: 3 item(s) - pen, ink, bottle
ahmed: 1 item(s) - bag
dia: 1 item(s) - eraser

السطر by_customer["rafi"].append("bottle") يدمج خطوتين في خطوة واحدة: يستخرج by_customer["rafi"] القائمة أولاً، ثم تضيف append عنصراً إلى تلك القائمة.

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

التجميع والتصنيف (Grouping) — النمط الأكثر فائدة

تحويل قائمة من القواميس إلى صيغة "من اشترى ماذا":

python
orders = [
    {"customer": "rafi", "item": "pen"},
    {"customer": "ahmed", "item": "bag"},
    {"customer": "rafi", "item": "ink"},
]

grouped = {}
for order in orders:
    customer = order["customer"]
    if customer not in grouped:
        grouped[customer] = []
    grouped[customer].append(order["item"])

print(grouped)
text
{'rafi': ['pen', 'ink'], 'ahmed': ['bag']}

الأسطر الثلاثة في المنتصف هي جوهر هذه التقنية بالكامل: إذا كان المفتاح غير موجود، ضع قائمة فارغة هناك أولاً، ثم أضف العنصر إليها براحة بال. ومن دون هذه الخطوة، كان الطلب الأول ذاته سيطلق خطأ KeyError.

يمكن اختصار الشيء نفسه في سطر واحد باستخدام setdefault:

python
orders = [
    {"customer": "rafi", "item": "pen"},
    {"customer": "rafi", "item": "ink"},
]

grouped = {}
for order in orders:
    grouped.setdefault(order["customer"], []).append(order["item"])

print(grouped)
text
{'rafi': ['pen', 'ink']}

تخبر العبارة setdefault(key, []) بايثون: "أعطني قيمة هذا المفتاح، وإذا لم تكن موجودة فأنشئ قائمة فارغة كقيمة له ثم أعطني إياها" — وأياً كان ما يُعاد، يتم استدعاء append عليه. والفارق بينها وبين .get() جوهري ومهم: دالة .get() لا تضيف أي شيء إلى القاموس، بينما setdefault تنشئ المفتاح وتخزن القيمة فيه بالفعل.

صيغة الأسطر الثلاثة أوضح وأسهل في القراءة، وصيغة السطر الواحد أسرع في الكتابة. وستصادف كلتيهما في الأكواد الواقعية.

مستويات أعمق من التداخل

python
shop = {
    "name": "Corner Store",
    "staff": [
        {"name": "rafi", "shifts": ["mon", "tue"]},
        {"name": "dia", "shifts": ["wed"]},
    ],
}

print(shop["staff"][0]["shifts"][1])
print(len(shop["staff"]))

for person in shop["staff"]:
    print(f"{person['name']}: {len(person['shifts'])} shift(s)")
text
tue
2
rafi: 2 shift(s)
dia: 1 shift(s)

العبارة shop["staff"][0]["shifts"][1] تتكون من أربع خطوات: قاموس ← قائمة ← قاموس ← قائمة. إذا قرأتها من اليسار إلى اليمين فليست معقدة، بل طويلة فقط.

وعند تجاوز ثلاثة مستويات من التداخل، توقف وفكر في التبسيط. إن تفكيك هذه السلسلة وإعطاء الخطوة الوسيطة اسماً واضحاً يجعل الكود أكثر قابلية للقراءة:

python
first_person = shop["staff"][0]
print(first_person["shifts"][1])

دالة copy() لا تكفي مع البيانات المتداخلة

أوضح الدرس الثاني عشر أن b = a تجعل الاسمين يشيران إلى القائمة نفسها، وأن copy() هي الحل. لكن مع البيانات المتداخلة، تحمي copy() مستوى واحداً فقط:

python
import copy

template = {"name": "", "items": []}

a = template.copy()
b = copy.deepcopy(template)

a["items"].append("pen")
b["items"].append("bag")

print(template)
print(a)
print(b)
text
{'name': '', 'items': ['pen']}
{'name': '', 'items': ['pen']}
{'name': '', 'items': ['bag']}

أُنشئ الكائن a باستخدام copy() — وهو قاموس خارجي جديد ومستقل، لكن القائمة الداخلية بداخله لا تزال نفس القائمة القديمة المشتركة في الذاكرة. ولذلك فإن a["items"].append("pen") قامت بتعديل القالب الأصلي template أيضاً!

أما الكائن b فأُنشئ باستخدام deepcopy() (النسخ العميق)، والتي تقوم بنسخ كل شيء متداخل بالداخل أيضاً. ولذلك لم تنتقل التعديلات التي طرأت على b إلى أي مكان آخر.

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

متى تحتاج إلى deepcopy؟ عندما تأخذ نسخة من بنية متداخلة بهدف تعديلها، وتريد ترك الأصل دون أي مساس به. وتذكر أن deepcopy بطيئة وتستهلك موارد أكثر، وفي معظم الأوقات لا تحتاج إليها — فالأكواد البرمجية الجيدة تبني بيانات جديدة عادة بدلاً من تعديل البيانات الموجودة.

مثال متكامل

orders.py:

python
# The shape data actually arrives in: a list of records
orders = [
    {"customer": "rafi", "item": "pen", "quantity": 3, "price": 15.0},
    {"customer": "ahmed", "item": "bag", "quantity": 1, "price": 850.0},
    {"customer": "rafi", "item": "ink", "quantity": 2, "price": 120.0},
    {"customer": "dia", "item": "pen", "quantity": 5, "price": 15.0},
]

print("Customer  Item    Qty      Total")
grand_total = 0.0
for order in orders:
    total = order["quantity"] * order["price"]
    grand_total = grand_total + total
    print(f"{order['customer']:<9} {order['item']:<6} {order['quantity']:>3} {total:>10.2f}")

print(f"{'':<20}{grand_total:>10.2f}")
print()

# Group the spend by customer: the key may not exist yet, so .get supplies a start
spend = {}
for order in orders:
    customer = order["customer"]
    spend[customer] = spend.get(customer, 0.0) + order["quantity"] * order["price"]

# Sort by amount by putting the amount first in a tuple
pairs = []
for customer, amount in spend.items():
    pairs.append((amount, customer))
pairs.sort(reverse=True)

print("Spend per customer, highest first:")
for amount, customer in pairs:
    print(f"  {customer:<8} {amount:>8.2f}")

distinct = set()
for order in orders:
    distinct.add(order["item"])

print()
print("Distinct items:", sorted(distinct))
print("Best customer :", pairs[0][1])
text
Customer  Item    Qty      Total
rafi      pen      3      45.00
ahmed     bag      1     850.00
rafi      ink      2     240.00
dia       pen      5      75.00
                       1210.00

Spend per customer, highest first:
  ahmed      850.00
  rafi       285.00
  dia         75.00

Distinct items: ['bag', 'ink', 'pen']
Best customer : ahmed

يستخدم هذا البرنامج الصغير كل أوعية البيانات من الدروس الأربعة الماضية، ويوظف كلاً منها في مكانه الصحيح تماماً.

قائمة لتخزين الطلبات، لأن عددها غير معروف والترتيب مهم.

قواميس لكل طلب، لأن كل جزء يحتاج إلى اسم محدد؛ وللقاموس spend، لأنه يقوم بتجميع وتراكم الإنفاق حسب الاسم.

صفوف (Tuples) لكل زوج داخل pairs. وهناك حيلة ذكية هنا: تم وضع المبلغ أولاً في الصف، لأن بايثون ترتب الصفوف بناءً على قيمتها الأولى تلقائياً. لذا فإن استدعاء .sort(reverse=True) العادي يرتب حسب المبالغ المالية مباشرة دون الحاجة لاستخدام key=.

مجموعة (Set) للمتغير distinct، لأن السؤال كان "كم منتجاً متميزاً"، والمنتج pen تكرر ظهوره مرتين.

أما spend.get(customer, 0.0) فهو نفس نمط العد والإحصاء من الدرس الخامس عشر ولكن بصورة أخرى — إضافة مبلغ مالي بدلاً من إضافة واحد.


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

KeyError: 'discount' — ولكن في أي سجل بالتحديد؟ استخدم التكرار مع for i, order in enumerate(orders): لمعرفة رقم الفهرس i عند حدوث الخطأ. أو ضع print(order) داخل الحلقة؛ فسيكون السجل الأخير المطبوع هو المسؤول عن الخطأ.

TypeError: string indices must be integers في معظم الأحيان، يعني هذا أنك اعتقدت أن لديك قاموساً، ولكنك في الواقع تحمل نصاً (string). فإذا كانت orders قائمة من الأسماء وليست قائمة من القواميس، فإن كتابة order["customer"] تعطي هذا الخطأ تماماً. تحقق من ذلك باستخدام print(type(order)).

TypeError: list indices must be integers or slices, not str الخطأ المعاكس تماماً — لديك قائمة وتحاول الوصول إلى عناصرها بالاسم. وعادة ما يكون هناك [0] منسياً: مثل كتابة orders["customer"] بدلاً من orders[0]["customer"].

AttributeError: 'list' object has no attribute 'get' من نفس العائلة من الأخطاء: الكائن الذي استدعيت عليه .get() هو في الحقيقة قائمة وليس قاموساً.

عدلت نسخة فتغير الأصل أيضاً دالة copy() سطحية (shallow copy) — فالقوائم والقواميس الداخلية تظل مشتركة في الذاكرة. اكتب import copy واستخدم copy.deepcopy(...).

خطأ KeyError في أول دورة أثناء التجميع تم تنفيذ grouped[key].append(...) قبل أن يكون المفتاح موجوداً أصلاً في القاموس. أنشئه أولاً باستخدام if key not in grouped: مع قائمة فارغة، أو استخدم setdefault.