> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Python من الداخل: الأنواع Types والذاكرة والاستثناءات
- URL: https://arabroot.io/articles/python-الانواع-والذاكرة-والاستثناءات/
- Published: 2026-10-31T07:01:20.000Z
- Updated: 2026-10-31T08:02:55.000Z
- Description: ما هو المتغير فعلاً في Python، ومن يحرر الذاكرة، وكيف تعبر اللغة عن الخطأ، بقياسات حقيقية على Python 3.15 من حجم مليون سجل إلى Container قتلته النواة دون رسالة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

في المقال السابق «Python للمطورين» بنينا الوحدة `shop` التي تحسب أسعار الطلبات في متجر صغير يبيع القهوة والتمور، ومرت الاختبارات الثلاثة بنجاح، ثم انتقل الكود إلى بيئة التجربة فظهرت مشكلتان في نفس الأسبوع. الأولى أن عملية الاستيراد الليلية التي تقرأ ملف أسعار المورد وفيه مليون سطر صارت تختفي في منتصف الليل، فالـ Container يعيد التشغيل كل مرة دون أي Traceback في السجل، ولا يوجد إلا رقم واحد في حالة الـ Container هو 137\. والثانية أن عميلاً كتب الكمية بالحروف «ثلاثة» في نموذج الطلب، فرجعت له صفحة خطأ 500 بدل رسالة واضحة، وفي السجل خطأ `ValueError` لا يقول للفريق أي طلب وأي سطر. وكلا المشكلتين لا علاقة لهما بالتركيب الذي تعلمته في اليوم الأول، وإنما بثلاثة أسئلة لا بد أن يجيب عليها كل من يأتي إلى Python من لغة أخرى: ما هو المتغير فعلاً وما نوعه؟ ومن يحجز الذاكرة ومن يحررها؟ وكيف تعبر اللغة عن الخطأ؟

وهذا المقال الثاني في مرحلة [لغة Python للمطورين](https://arabroot.io/start/#stage-lang-python)، وقد شرحنا المفاهيم العامة لكل اللغات في مقالات [أنظمة الأنواع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) و[إدارة الذاكرة](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/) و[التعامل مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)، أما هنا فنأخذ Python 3.15.2 بالتحديد، ونقيس كل شيء داخل Container محدود بمعالجين و512 ميجابايت من الذاكرة. وسوف نناقش في هذا المقال ما يلي:

- الأسماء والكائنات Names and Objects: الهوية والمساواة، والأنواع التي تتغير والتي لا تتغير، والنسخ السطحي والعميق، والنوع الجديد `frozendict`.
- نظام الأنواع Type System: ديناميكي وقوي، وتلميحات الأنواع Type Hints التي لا يفحصها CPython، وما يكشفه mypy منها.
- من يدير الذاكرة في CPython: عداد المراجع Reference Counting وجامع الدورات Cycle Collector، مع قياس حقيقي.
- الأعداد والمال: لماذا لا تصلح `round` لتقريب الضريبة، ومتى تستعمل `Decimal` أو الهللة.
- كم تأخذ مليون قيمة من الذاكرة، ومتى تفيد `__slots__` و`array`، وهل ترجع الذاكرة إلى النظام بعد الحذف.
- ماذا يحدث إذا توقف جامع الدورات داخل Container محدود الذاكرة، ولماذا لا ترى أي رسالة خطأ.
- الاستثناءات Exceptions: التشغيل الفاشل وقراءته، والسلسلة `raise from`، ومجموعات الأخطاء ExceptionGroup، وثمن `try`.

## كل شيء كائن Object: ما هو المتغير في Python؟

في Java وC# عندما تكتب `int x = 5` فأنت تحجز مكاناً بحجم معين في الذاكرة اسمه `x` وتضع فيه القيمة 5، أما في Python فالسطر `x = 5` يعمل بطريقة أخرى تماماً: يوجد في الذاكرة كائن Object من نوع `int` قيمته 5، والسطر يربط الاسم `x` بهذا الكائن، فالاسم مثل بطاقة معلقة على الكائن وليس صندوقاً يحتويه. وكل شيء في Python كائن بهذا المعنى: الأعداد والنصوص والدوال والكلاسات والوحدات نفسها، ولكل كائن ثلاث صفات كما يذكر [نموذج البيانات في مرجع اللغة](https://docs.python.org/3/reference/datamodel.html?ref=arabroot.io): هوية Identity لا تتغير طوال عمره وتعطيها الدالة `id()`، ونوع Type يحدد ما يمكن فعله به، وقيمة Value. وهذا الفهم يفسر أغلب المفاجآت التي يقع فيها القادمون من لغات أخرى، والشكل التالي يلخص ما سوف نراه في المختبر:

![ثلاثة صناديق: الأول اسمان cart وsame يشيران بسهمين إلى قائمة واحدة فيها coffee وdates والمخرج cart is same يعطي True، والثاني دالة add_gift تعدل القائمة بـ append ثم تسند قائمة جديدة لاسم محلي لا يصل إلى المستدعي، والثالث جدول للهوية والمساواة: 256 و257 يعطيان True في == وis، و1025 يعطي True في == وFalse في is، وتحتها بطاقتان للأنواع التي لا تتغير مثل int وstr وtuple وfrozendict والتي تتغير مثل list وdict وset](https://arabroot.io/content/images/2026/10/python-types-memory-01-names.webp)

الاسم يشير إلى كائن، وأكثر من اسم قد يشير إلى نفس الكائن

والملف `objects.py` في المختبر يمر على هذه النقاط، وجزؤه الأول كما يلي:

```python
import copy

print("== identity vs equality")
a = 256; b = int("256")
c = 257; d = int("257")
print("256:", a == b, a is b, "| 257:", c == d, c is d)
x = None
print("None is a single object:", x is None, id(None) == id(x))

print("== aliasing: two names, one list")
cart = ["coffee"]
same = cart
same.append("dates")
print(cart, same, cart is same)

print("== function arguments are passed as object references")
def add_gift(items):
    items.append("gift")
    items = ["replaced"]          # rebinding the local name does not touch the caller
    return items
r = add_gift(cart)
print(cart, r)

print("== shallow vs deep copy")
order = {"id": 1, "lines": [{"sku": "c1", "qty": 2}]}
shallow = copy.copy(order)
deep = copy.deepcopy(order)
order["lines"][0]["qty"] = 99
print("shallow sees", shallow["lines"][0]["qty"], "| deep sees", deep["lines"][0]["qty"])
```

شغلناه داخل الـ Container بالأمر `python objects.py`، والمخرج من هذا الجزء كما يلي:

```text
== identity vs equality
256: True True | 257: True True
None is a single object: True True
== aliasing: two names, one list
['coffee', 'dates'] ['coffee', 'dates'] True
== function arguments are passed as object references
['coffee', 'dates', 'gift'] ['replaced']
== shallow vs deep copy
shallow sees 99 | deep sees 2
```

### الهوية Identity والمساواة Equality: متى تستعمل is؟

العامل `==` يسأل: هل القيمتان متساويتان؟ ويستدعي لذلك الدالة `__eq__` في الكلاس، أما `is` فيسأل: هل هذان الاسمان يشيران إلى نفس الكائن؟ ويقارن الهوية فقط. وفي السطر الأول من المخرج أعطت `256 is int("256")` القيمة `True`، وهذا متوقع لمن قرأ عن Python من قبل، فالمفسر يحفظ الأعداد الصغيرة مسبقاً ويعيد نفس الكائن كل مرة لكي لا ينشئ ملايين الكائنات للعدد 1 أو 0\. ولكن المفاجأة أن `257` أعطى `True` أيضاً، وأغلب الشروحات في الإنترنت تقول إن الحد هو 256\. فقمنا بفحص سريع يبحث عن أول عدد لا يعود فيه نفس الكائن:

```python
n = next(i for i in range(0, 100000) if int(str(i)) is not int(str(i)))
m = next(i for i in range(0, -100000, -1) if int(str(i)) is not int(str(i)))
```

والنتيجة على Python 3.15.2 كانت كما يلي:

```text
first non-cached: 1025
first non-cached negative: -6
```

أي أن الأعداد المحفوظة في هذا الإصدار من ‎-5 إلى 1024، وهذا رقم تغير عما كتبته الشروحات القديمة، والدرس الحقيقي ليس الرقم نفسه، وإنما أن هذا تفصيل داخلي في CPython وليس وعداً من اللغة، فقد يتغير في أي إصدار، ولذلك لا تقارن الأعداد أو النصوص بـ `is` أبداً، فالكود الذي يعمل عندك لأن القيمة صغيرة سوف يفشل في الإنتاج عندما تكبر القيمة. والاستعمال الصحيح الوحيد لـ `is` هو مع الكائنات التي يوجد منها نسخة واحدة في البرنامج كله، وأشهرها `None` و`True` و`False`، كما رأينا في السطر الثاني من المخرج، ولذلك نكتب `if x is None` وليس `if x == None`، وقد رأينا في المقال السابق أن ruff يعترض على الشكل الثاني.

### الأسماء المتعددة Aliasing والتمرير إلى الدوال

في القسم الثاني من المخرج عدلنا القائمة عبر الاسم `same` فظهر التعديل في `cart`، والسبب أن السطر `same = cart` لا ينسخ القائمة وإنما يعلق اسماً ثانياً على نفس الكائن، و`cart is same` أعطت `True`. وهذا يشبه ما يحدث مع الكائنات في Java وJavaScript، ولكن الفرق أن Python لا يوجد فيها أنواع أولية Primitives تنسخ بالقيمة، فكل شيء يعامل بنفس الطريقة.

والقسم الثالث يبين كيف تمرر المعاملات إلى الدوال، وهي نقطة يختلف فيها الناس كثيراً: هل Python تمرر بالقيمة By Value أم بالمرجع By Reference؟ والإجابة أنها لا هذه ولا تلك، وإنما تمرر مرجعاً إلى الكائن Call by Object Reference: الدالة `add_gift` استلمت اسماً محلياً `items` يشير إلى نفس قائمة المستدعي، فعندما استدعت `items.append("gift")` عدلت القائمة نفسها وظهر التعديل في `cart`، أما السطر `items = ["replaced"]` فهو إسناد Rebinding يعلق الاسم المحلي على كائن جديد، ولا يغير شيئاً عند المستدعي، ولذلك بقيت `cart` كما هي ورجعت `["replaced"]` فقط كقيمة إرجاع. والقاعدة العملية: إذا كانت دالتك تعدل مجموعة وصلتها كمعامل فاكتب ذلك في اسمها أو في توثيقها، أو الأفضل أن تنشئ نسخة جديدة وترجعها، لأن التعديل الخفي على معاملات الدالة من أصعب الأخطاء تتبعاً.

### النسخ السطحي Shallow Copy والنسخ العميق Deep Copy

والقسم الرابع يبين خطأ نراه كثيراً في الكود الذي يعالج الطلبات: نسخنا الطلب بـ `copy.copy` لنعدل النسخة بأمان، ثم غيرنا الكمية في الطلب الأصلي إلى 99، فظهرت 99 في النسخة السطحية أيضاً. والسبب أن النسخ السطحي ينشئ قاموساً جديداً، ولكن قيمه هي نفس الكائنات القديمة، فالقائمة `lines` وما بداخلها مشتركة بين الأصل والنسخة، أما `copy.deepcopy` فينسخ كل مستوى إلى أعمق نقطة، ولذلك بقيت الكمية فيه 2\. والنسخ العميق أبطأ وأكبر في الذاكرة، فلا تستعمله في كل مكان، وإنما عندما تحتاج فعلاً أن تعدل بنية متداخلة دون أن تؤثر على الأصل، كما في حفظ نسخة من الطلب قبل تطبيق الخصومات عليه.

### ما يتغير Mutable وما لا يتغير Immutable، والنوع frozendict

الجزء الثاني من `objects.py` يبين الأنواع التي لا تتغير:

```python
s = "قهوة"; before = id(s)
s += " عربية"
print("str id changed:", before != id(s))
t = (1, [2, 3])
t[1].append(4)
print("tuple holding a list still changed:", t)
try:
    t[0] = 9
except TypeError as e:
    print("TypeError:", e)
try:
    print(hash(t))
except TypeError as e:
    print("TypeError:", e)

prices = frozendict(coffee=4500, dates=990)
print(prices, hash(prices) == hash(frozendict(dates=990, coffee=4500)))
try:
    prices["coffee"] = 1
except TypeError as e:
    print("TypeError:", e)
cache = {prices: "price list v1"}
print(cache[frozendict(coffee=4500, dates=990)])
```

والمخرج سوف يكون كما يلي:

```text
str id changed: True
tuple holding a list still changed: (1, [2, 3, 4])
TypeError: 'tuple' object does not support item assignment
TypeError: unhashable type: 'list'
frozendict({'coffee': 4500, 'dates': 990}) True
TypeError: 'frozendict' object does not support item assignment
price list v1
```

في المخرج أعلاه لاحظ التالي:

- النص لا يتغير، فالعملية `s += " عربية"` أنشأت نصاً جديداً وعلقت عليه الاسم، وتغيرت الهوية. ولهذا السبب بالذات يكون دمج النصوص بـ `+=` داخل حلقة طويلة بطيئاً، لأن كل دورة تنشئ نصاً جديداً، والطريقة الصحيحة أن تجمع الأجزاء في قائمة ثم تستدعي `"".join(parts)` مرة واحدة.
- الـ tuple لا يتغير، ولكن هذا يعني أن مراجعه لا تتغير، وليس أن ما تشير إليه لا يتغير، ولذلك استطعنا أن نضيف عنصراً إلى القائمة التي داخله، بينما رفض `t[0] = 9`.
- ولأن داخله قائمة، رفض `hash(t)` بالخطأ `unhashable type: 'list'`، فالكائن يصلح مفتاحاً في قاموس أو عنصراً في مجموعة فقط إذا كان كل ما فيه لا يتغير، والسبب أن القاموس يحسب مكان المفتاح من الهاش Hash، فإذا تغير المفتاح بعد إدخاله ضاع مكانه.
- النوع `frozendict` جديد في Python 3.15 بحسب [PEP 814](https://peps.python.org/pep-0814/?ref=arabroot.io)، وهو قاموس لا يتغير، ولاحظ أن هاشه لا يعتمد على ترتيب الإدخال، فالقاموسان بترتيبين مختلفين أعطيا نفس الهاش، ولذلك استعملناه مفتاحاً في `cache`. وكان المطورون قبله يستعملون `types.MappingProxyType` أو tuple من الأزواج، وكلاهما غير مريح، أما الآن فقائمة الأسعار الثابتة أو الإعدادات التي تقرأ مرة واحدة مكانها الطبيعي `frozendict`.

## نظام الأنواع Type System: ديناميكي وقوي، والتلميحات للأدوات

رأينا في المقال السابق أن Python ديناميكية الأنواع Dynamically Typed، أي أن النوع يتحدد وقت التشغيل مع الكائن، وقوية الأنواع Strongly Typed، أي أنها لا تحول نوعاً إلى آخر في العمليات من تلقاء نفسها. وتضيف Python فوق ذلك فكرة اسمها Duck Typing: لا يهم ما هو نوع الكائن، وإنما يهم هل يملك الدوال التي تحتاجها، فالدالة التي تمر على شيء بـ `for` تقبل القائمة والملف والمولد وأي كلاس عرف الدالة `__iter__`، دون أن تحتاج واجهة Interface مشتركة كما في Java.

ومنذ Python 3.5 أضاف [PEP 484](https://peps.python.org/pep-0484/?ref=arabroot.io) تلميحات الأنواع Type Hints، وهي طريقة لكتابة الأنواع في الكود مثل `def find(products: list[Product], pid: int) -> Product | None`، ولكن النقطة التي يجب أن تفهمها من البداية أن CPython لا يفحص هذه التلميحات عند التشغيل إطلاقاً، فهي تحفظ كمعلومات في الدالة وتقرؤها الأدوات والمحررات، أما المفسر فيتجاهلها. وهذا ما يبينه الملف `hints.py`:

```python
from dataclasses import dataclass
from typing import Protocol

@dataclass
class Product:
    id: int
    name_ar: str
    price_halalas: int

def find(products: list[Product], pid: int) -> Product | None:
    for p in products:
        if p.id == pid:
            return p
    return None

def line_total(p: Product, qty: int) -> int:
    return p.price_halalas * qty

class HasPrice(Protocol):
    price_halalas: int

def cheapest(items: list[HasPrice]) -> int:
    return min(i.price_halalas for i in items)

catalog = [Product(1, "قهوة", 4500), Product(2, "تمر", 990)]
print(line_total(catalog[1], "3"))                 # runs: 990 * "3" repeats the string
p = find(catalog, 7)
print(line_total(p, 2))                            # p may be None
print(cheapest(catalog))
```

شغلناه بالأمر `python hints.py`، والمخرج كما يلي بعد اختصار السطر الأول:

```text
333333333333333333333333333333333333333333333333333333…3
Traceback (most recent call last):
  File "/tmp/w/hints.py", line 35, in <module>
    print(line_total(p, 2))                            # p may be None
          ~~~~~~~~~~^^^^^^
  File "/tmp/w/hints.py", line 21, in line_total
    return p.price_halalas * qty
           ^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'price_halalas'
exit code: 1
```

السطر الأول هو الأخطر في المخرج كله: الدالة `line_total` قالت إن `qty` عدد صحيح، ومررنا لها النص `"3"`، فلم يعترض أحد، وضربت Python العدد 990 في النص، والضرب بين عدد ونص في Python معناه التكرار، فطبع البرنامج النص «3» مكرراً 990 مرة. وهذا هو بالضبط خطأ `parse_qty` من المقال السابق لو وصل إلى الإنتاج: لا يوجد استثناء ولا رسالة، وإنما قيمة خاطئة تمر إلى الفاتورة. والسطر الثاني أوقف البرنامج لأن `find` لم تجد المنتج 7 فرجعت `None`، ولاحظ أن Python وضعت العلامات `~~~^^^` تحت الجزء الذي فشل بالضبط في السطر، وهذا يوفر عليك وقتاً كبيراً عندما يكون في السطر أكثر من استدعاء.

الآن نشغل mypy على نفس الملف:

```bash
pip install --root-user-action=ignore -q mypy==2.4.1
mypy hints.py
```

والمخرج سوف يكون كما يلي:

```text
hints.py:33: error: Argument 2 to "line_total" has incompatible type "str"; expected "int"  [arg-type]
hints.py:35: error: Argument 1 to "line_total" has incompatible type "Product | None"; expected "Product"  [arg-type]
hints.py:36: error: Argument 1 to "cheapest" has incompatible type "list[Product]"; expected "list[HasPrice]"  [arg-type]
hints.py:36: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
hints.py:36: note: Consider using "Sequence" instead, which is covariant
Found 3 errors in 1 file (checked 1 source file)
```

![عمودان: على اليمين وقت التشغيل حيث يتجاهل CPython التلميحات، فالاستدعاء line_total مع النص 3 يطبع النص مكرراً 990 مرة، والاستدعاء مع None يعطي AttributeError وexit code 1، وعلى اليسار mypy 2.4.1 قبل التشغيل يجد ثلاثة أخطاء: str بدل int في السطر 33، وProduct | None بدل Product في السطر 35، وlist[Product] بدل list[HasPrice] في السطر 36 مع ملاحظة أن list ثابتة النوع invariant](https://arabroot.io/content/images/2026/10/python-types-memory-06-hints.webp)

نفس الملف: وقت التشغيل لا يرى إلا ما يتوقف، وmypy يرى الأخطاء الثلاثة قبل التشغيل

في المخرج أعلاه لاحظ التالي:

- الخطأ الأول هو الذي مر بصمت وقت التشغيل، وmypy رآه في السطر 33 قبل أن يعمل الكود، وهذا أهم سبب لتشغيل mypy في CI.
- الخطأ الثاني يقول إن `find` قد ترجع `None`، وأنت مررت الناتج إلى دالة لا تقبل `None`، والإصلاح أن تتحقق قبل الاستدعاء `if p is None: ...`، وبعد هذا الشرط يفهم mypy أن `p` صار `Product`، وهذا ما يسمى التضييق Narrowing.
- الخطأ الثالث خطأ في التلميح نفسه وليس في الكود: `Product` يطابق البروتوكول `HasPrice` لأن فيه الحقل `price_halalas`، وهذه هي الأنواع البنيوية Structural Typing التي [عرفها PEP 544](https://peps.python.org/pep-0544/?ref=arabroot.io)، أي أن Duck Typing صار له شكل يفحصه mypy. ولكن `list[Product]` لا تقبل كـ `list[HasPrice]`، لأن القائمة تتغير، ولو قبلها mypy لاستطاعت الدالة أن تضيف إليها كائناً آخر فيه `price_halalas` وليس `Product`. والحل كما يقترح mypy نفسه أن تكتب `Sequence[HasPrice]`، لأن `Sequence` للقراءة فقط.
- الخيار `--strict` أعطى نفس الأخطاء الثلاثة في هذا الملف، لأنه مكتوب بتلميحات كاملة أصلاً، أما في مشروع حقيقي فهو يضيف فحوصاً مثل منع الدوال بدون تلميحات، ولذلك ابدأ به في المشاريع الجديدة.

وقد يتساءل البعض: إذا كانت التلميحات لا تفحص وقت التشغيل، فكيف أحمي الخدمة من بيانات خاطئة تأتي من الخارج؟ والإجابة أن ما يأتي من JSON أو نموذج أو ملف يحتاج تحققاً وقت التشغيل Runtime Validation، إما بكود تكتبه بنفسك مثل `parse_qty` الذي سوف نراه في قسم الاستثناءات، أو بمكتبة تقرأ التلميحات وتتحقق بها عند الحدود، وهذا ما يفعله FastAPI مع Pydantic في مقال الخدمة الحقيقية من هذه المرحلة. وهي نفس الفكرة التي رأيناها مع [TypeScript](https://arabroot.io/articles/typescript-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%85%D8%A7-%D8%A7%D9%84%D8%B0%D9%8A-%D8%AA%D9%83%D8%B4%D9%81%D9%87-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9/): الأنواع تحمي ما بداخل البرنامج، والتحقق يحمي حدوده. ومرجع كل ما يخص التلميحات هو [توثيق الوحدة typing](https://docs.python.org/3/library/typing.html?ref=arabroot.io).

### الأعداد Numeric Types والمال: round أم Decimal أم الهللة؟

وفي نظام الأنواع نقطة تمس متجرنا مباشرة، وهي الأنواع الرقمية: `int` بحجم غير محدود، و`float` عدد عشري بمعيار IEEE 754 بدقة مزدوجة كما في Java وJavaScript، و`decimal.Decimal` عدد عشري بالنظام العشري الذي نكتب به الأسعار. وقد يظن المطور القادم من لغة أخرى أن الدالة المدمجة `round` تكفي لتقريب الضريبة، والملف `rss_numbers.py` في المختبر يبين لماذا لا تكفي:

```python
print("round(0.5), round(1.5), round(2.5):", round(0.5), round(1.5), round(2.5))
print("round(4.455, 2):", round(4.455, 2), "| exact value:", Decimal(4.455))
print("0.1 + 0.2 == 0.3:", 0.1 + 0.2 == 0.3, "|", 0.1 + 0.2)
vat = Decimal("29.70") * Decimal("0.15")
print("Decimal VAT:", vat, "->", vat.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
print("integer halalas:", (2970 * 15 + 50) // 100)
```

والمخرج سوف يكون كما يلي:

```text
round(0.5), round(1.5), round(2.5): 0 2 2
round(4.455, 2): 4.46 | exact value: 4.4550000000000000710542735760100185871124267578125
0.1 + 0.2 == 0.3: False | 0.30000000000000004
Decimal VAT: 4.4550 -> 4.46
integer halalas: 446
```

في المخرج أعلاه لاحظ التالي:

- الدالة `round` في Python 3 لا تقرب النصف لأعلى كما تعلمنا في المدرسة، وإنما إلى أقرب عدد زوجي، وهذا ما يسمى تقريب المصرفيين Banker's Rounding، ولذلك أعطت `round(0.5)` صفراً و`round(2.5)` اثنين، وهذا موثق في [توثيق الدالة round](https://docs.python.org/3/builtins/functions.html?ref=arabroot.io#round). والسبب أن هذا التقريب لا يميل لأعلى في المتوسط عند جمع ملايين القيم، ولكنه ليس ما تطلبه منك قاعدة الفاتورة.
- السطر الثاني أعطى 4.46 بالصدفة، فالعدد 4.455 لا يمثل في `float` بدقة، وقيمته الحقيقية المخزنة أكبر بقليل كما طبعها `Decimal(4.455)`، فلو كانت أصغر بقليل لأعطى 4.45\. أي أن النتيجة الصحيحة جاءت من خطأ التمثيل وليس من قاعدة التقريب، وهذا أسوأ من الخطأ الواضح، لأنه يمر في الاختبار ثم يفشل مع رقم آخر.
- `Decimal` المبني من نص يحسب بالضبط 4.4550، ومع `ROUND_HALF_UP` يعطي 4.46 لأن القاعدة نفسها صحيحة، وهو خيار جيد إذا احتجت كسوراً عشرية في الحساب.
- والطريقة التي نعتمدها في كل خدمات هذا المسار هي الأعداد الصحيحة بالهللة، فالسطر الأخير أعطى 446 هللة بعملية صحيحة واحدة، ولا يوجد فيه أي تمثيل عشري يمكن أن يخطئ، وهو نفس الحساب في كل اللغات التي نقارن بينها.

## من يدير الذاكرة Memory Management في CPython؟

في C تحجز الذاكرة بـ `malloc` وتحررها بـ `free` بنفسك، وفي Java وC# يوجد جامع قمامة Garbage Collector يمر على الذاكرة من وقت لآخر ويحرر ما لم يعد مستعملاً، وفي Rust يحدد المترجم وقت التحرير من قواعد الملكية Ownership، وقد قارنا بين هذه الطرق في مقال [إدارة الذاكرة](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/). أما CPython فيستعمل طريقتين معاً: الأساس هو عداد المراجع Reference Counting، أي أن كل كائن يحمل عداداً بعدد الأسماء والمراجع التي تشير إليه، فكلما علقت عليه اسماً أو وضعته في قائمة زاد العداد، وكلما زال مرجع نقص، وعندما يصل العداد إلى صفر يحرر الكائن في نفس اللحظة. وفوقه جامع الدورات Cycle Collector الذي يبحث فقط عن الكائنات التي يشير بعضها إلى بعض في دائرة، لأن هذه لا يصل عدادها إلى صفر أبداً حتى لو لم يعد أحد يستعملها. والشكل التالي يبين الطريقتين بأرقام المختبر:

![عمودان: الأول عداد المراجع حيث يبدأ refcount بـ 2 ويصبح 3 مع اسم ثان، ثم يحرر الكائن عند del c قبل طباعة after del c، والثاني دورة بين Node a وNode b بسهمين a.peer وb.peer، ومع gc.disable بقي 407.4 MB محجوزاً بعد 20,000 دورة حتى وجد gc.collect 39998 كائناً وحررها في 185 إلى 251 ms، وتحتها حدود الجامع 2000 و10 و10](https://arabroot.io/content/images/2026/10/python-types-memory-03-refcount.webp)

العداد يحرر فوراً، والجامع ينظف الدورات التي لا يصل عدادها إلى صفر

### عداد المراجع Reference Counting في المختبر

الجزء التالي من `memory.py` يتابع عداد كائن واحد، ويسجل دالة تطبع رسالة عند تحريره بالوحدة `weakref`:

```python
class Conn:
    pass
c = Conn()
weakref.finalize(c, print, "   finalizer: Conn freed")
print("refcount:", sys.getrefcount(c))
other = c
print("refcount after second name:", sys.getrefcount(c))
del other
print("before del c")
del c
print("after del c")
```

والمخرج سوف يكون كما يلي:

```text
refcount: 2
refcount after second name: 3
before del c
   finalizer: Conn freed
after del c
```

أعطى العداد 2 وليس 1 لأن الدالة `getrefcount` نفسها تحمل مرجعاً مؤقتاً للكائن أثناء الاستدعاء، ثم صار 3 مع الاسم الثاني. والنقطة المهمة هي ترتيب الأسطر الثلاثة الأخيرة: رسالة التحرير ظهرت بين `before del c` و`after del c`، أي أن الكائن حرر في نفس السطر الذي حذف فيه آخر مرجع، ولم ينتظر أي جامع. وهذا يعطي CPython سلوكاً يمكن توقعه، فالملف الذي ينتهي آخر مرجع له يغلق فوراً، ولكن لا تقم بالاعتماد على ذلك لإغلاق الموارد، والسبب أن هذا تفصيل في CPython وحده، وأن مرجعاً منسياً في قائمة أو في Traceback محفوظ يبقي الكائن حياً، ولذلك نستعمل `with` للملفات والاتصالات كما رأينا في المقال السابق.

### الدورات Cycles وجامعها

والجزء الذي يليه ينشئ 20,000 زوج من الكائنات، كل كائن يشير إلى صاحبه، ومع كل كائن عشرة آلاف بايت من البيانات، بعد أن أوقفنا الجامع بـ `gc.disable()`:

```python
class Node:
    def __init__(self):
        self.peer = None
        self.payload = bytearray(10_000)
gc.disable()
gc.collect()
tracemalloc.start()
for _ in range(20_000):
    a, b = Node(), Node()
    a.peer, b.peer = b, a            # a cycle: refcounts never reach zero
cur, _ = tracemalloc.get_traced_memory()
print(f"after 20,000 cycles, gc disabled: {cur / 1e6:6.1f} MB still held")
t = time.perf_counter()
found = gc.collect()
cur2, _ = tracemalloc.get_traced_memory()
print(f"gc.collect() found {found} objects in {(time.perf_counter() - t) * 1000:.0f} ms, now {cur2 / 1e6:.1f} MB")
tracemalloc.stop()
gc.enable()
print("gc thresholds:", gc.get_threshold())
```

والمخرج من تشغيلين منفصلين كما يلي:

```text
after 20,000 cycles, gc disabled:  407.4 MB still held
gc.collect() found 39998 objects in 251 ms, now 0.0 MB
gc thresholds: (2000, 10, 10)
peak RSS of this process: 466 MB

after 20,000 cycles, gc disabled:  407.4 MB still held
gc.collect() found 39998 objects in 185 ms, now 0.0 MB
gc thresholds: (2000, 10, 10)
peak RSS of this process: 468 MB
```

في المخرج أعلاه لاحظ التالي:

- في كل دورة من الحلقة يعلق الاسمان `a` و`b` على زوج جديد، فلم يعد أحد يستعمل الزوج القديم، ولكن كل كائن فيه ما زال يشير إلى صاحبه، فعداده لا يصل إلى صفر، وبقي 407.4 ميجابايت محجوزاً لبيانات لا يستطيع أحد الوصول إليها.
- الأمر `gc.collect()` وجد 39998 كائناً، أي كل الأزواج تقريباً عدا الزوج الأخير الذي ما زال الاسمان يشيران إليه، وحررها كلها في 185 إلى 251 ميلي ثانية، والفرق بين الرقمين هو انشغال الجهاز وقت التشغيل.
- الحدود `(2000, 10, 10)` تعني أن الجامع يعمل تلقائياً بعد زيادة عدد الكائنات المتتبعة بمقدار معين، وتفاصيل هذه الحدود وطريقة عمل الأجيال Generations في [توثيق الوحدة gc](https://docs.python.org/3/library/gc.html?ref=arabroot.io)، والقاعدة العملية أن تتركها على قيمها الافتراضية حتى يقول لك قياس حقيقي غير ذلك.
- أعلى استهلاك للعملية وصل 466 و468 ميجابايت، وهذا قريب جداً من حد الـ Container البالغ 512 ميجابايت، ومن هنا جاءت التجربة التالية.

ومن الأسباب الحقيقية التي تجعل فريقاً يوقف الجامع أو يغير حدوده أن الجامع عندما يمر على الكائنات يكتب في رؤوسها، وهذا يفسد مشاركة الذاكرة بين العمليات التي تنشأ بـ `fork()`، كما في سيرفرات الويب التي تحمل التطبيق مرة ثم تنشئ عدة Workers منه. ولذلك أضافت Python 3.7 الدالة `gc.freeze()` التي تنقل كل الكائنات الموجودة إلى جيل دائم لا يمر عليه الجامع، ويشرح توثيقها الترتيب الذي ينصح به: إيقاف الجامع مبكراً في العملية الأم، ثم `gc.freeze()` قبل `fork()` مباشرة، ثم تفعيل الجامع في العمليات الأبناء.

## كم تأخذ مليون قيمة من الذاكرة Memory Footprint؟

نعود إلى عملية الاستيراد الليلية التي تقرأ مليون سطر من ملف المورد، فالسؤال الأول عند أي مشكلة ذاكرة هو: كم يأخذ كل سطر؟ وفي Python الإجابة مفاجئة للقادمين من C أو Go، لأن كل قيمة كائن كامل له رأس Header فيه نوعه وعداد مراجعه. والجزء الأول من `memory.py` يطبع حجم كائنات مفردة بالدالة `sys.getsizeof`:

```text
                                  0  28
                                  1  28
                         1073741824  32
    1267650600228229401496703205376  40
                                1.5  24
                                 ''  41
                                'a'  42
                                'ب'  60
                                 []  56
                                 {}  64
                                 ()  48
```

العدد 0 يأخذ 28 بايت بينما يأخذ في C أربعة أو ثمانية، والعدد الكبير يكبر كلما احتاج خانات أكثر، والنص الفارغ 41 بايت، ولاحظ أن نصاً فيه حرف عربي واحد أخذ 60 بايت بينما الحرف اللاتيني 42، والسبب أن CPython يختار حجم الحرف الداخلي بحسب أكبر حرف في النص: بايت واحد إذا كانت كل الحروف من Latin-1، وبايتان إذا كان فيه حرف مثل العربية، وأربعة إذا كان فيه رمز تعبيري Emoji، ومع كل حجم رأس مختلف، ولذلك النصوص العربية تأخذ تقريباً ضعف المساحة.

بعد ذلك نبني مليون عنصر بست طرق مختلفة، ونقيس ما بقي محجوزاً بعد البناء بالوحدة [tracemalloc](https://docs.python.org/3/library/tracemalloc.html?ref=arabroot.io)، ونقيس زمن البناء في مرة ثانية بعد إيقاف tracemalloc لأنها تبطئ كل عملية حجز:

```python
class Plain:
    def __init__(self, pid, price):
        self.pid = pid; self.price = price

class Slotted:
    __slots__ = ("pid", "price")
    def __init__(self, pid, price):
        self.pid = pid; self.price = price

@dataclass(slots=True)
class DSlots:
    pid: int
    price: int

measure("list of int (range)", lambda: list(range(N)))
measure("array('q') of int", lambda: array.array("q", range(N)))
measure("list of tuple (pid, price)", lambda: [(i, i * 10) for i in range(N)])
measure("list of Plain objects", lambda: [Plain(i, i * 10) for i in range(N)])
measure("list of __slots__ objects", lambda: [Slotted(i, i * 10) for i in range(N)])
measure("list of dataclass(slots=True)", lambda: [DSlots(i, i * 10) for i in range(N)])
```

والمخرج سوف يكون كما يلي:

```text
== 1,000,000 items: memory held (tracemalloc) and build time
list of int (range)                       40.0 MB      367 ms
array('q') of int                          8.2 MB      398 ms
list of tuple (pid, price)               136.4 MB      708 ms
list of Plain objects                    160.4 MB     2199 ms
list of __slots__ objects                120.4 MB     3242 ms
list of dataclass(slots=True)            120.4 MB     2772 ms
```

![أعمدة أفقية لمليون عنصر: array('q') بأعداد خام 8.2 MB، وقائمة أعداد 40.0 MB، وكائنات __slots__ 120.4 MB، وdataclass(slots=True) 120.4 MB، وقائمة tuples بحقلين 136.4 MB، وكائنات كلاس عادي 160.4 MB، وتحتها بطاقات بحجم كائن واحد: العدد 0 أو 1 28 بايت والعدد 2**100 40 بايت و1.5 24 بايت، والنص الفارغ 41 و'a' 42 و'ب' 60 بايت، والقائمة الفارغة 56 والقاموس 64 والـ tuple 48 بايت](https://arabroot.io/content/images/2026/10/python-types-memory-02-sizes.webp)

نفس المليون قيمة، والفرق بين أصغر طريقة وأكبرها حوالي عشرين ضعفاً

في المخرج أعلاه لاحظ التالي:

- قائمة مليون عدد أخذت 40 ميجابايت، أي حوالي 40 بايت لكل عدد: ثمانية للمرجع في القائمة، و28 للكائن نفسه، والباقي مساحة احتياطية تحجزها القائمة لتكبر. أما `array('q')` فتحفظ الأعداد كقيم خام بثمانية بايتات كما في C، فأخذت 8.2 ميجابايت فقط، أي خمس المساحة، وهذا هو نفس السبب الذي يجعل مكتبات مثل NumPy أسرع وأصغر بكثير مع الأرقام.
- الكائن من كلاس عادي أخذ حوالي 160 بايت للسطر، ومع `__slots__` نزل إلى 120، أي توفير الربع، والسبب أن الكلاس العادي يحجز مكاناً لقاموس الخصائص `__dict__` الذي يسمح لك أن تضيف أي خاصية في أي وقت، أما `__slots__` فيحدد الخصائص مسبقاً ويحجز لها أماكن ثابتة، ويمنع إضافة غيرها، والتفاصيل في [مرجع اللغة](https://docs.python.org/3/reference/datamodel.html?ref=arabroot.io#slots).
- الـ `dataclass(slots=True)` أعطت نفس حجم `__slots__` المكتوب يدوياً، فهي أسهل طريقة للحصول على التوفير مع كود أقصر، ولذلك نستعملها لكائنات البيانات الكثيرة مثل أسطر الطلبات.
- أزمنة البناء في العمود الأخير تختلف من تشغيل لآخر لأن الجهاز كان مشغولاً بأعمال أخرى، فالتشغيل الأول أعطى أرقاماً أعلى بكثير لنفس الحالات، ولذلك لا تبن قراراً على الزمن هنا، أما أرقام الذاكرة فكانت متطابقة بالخانة في التشغيلين.

والقرار العملي لعملية الاستيراد: إذا كنت تمر على السطور واحداً بعد الآخر فلا تحملها كلها في قائمة أصلاً، واستعمل مولداً Generator يقرأ سطراً ويعالجه ويتركه، فالذاكرة تبقى ثابتة مهما كبر الملف. وإذا احتجت أن تحتفظ بها كلها في الذاكرة فاستعمل `dataclass(slots=True)` بدل القواميس أو الكلاسات العادية، وإذا كانت أرقاماً فقط فاستعمل `array` أو مكتبة بيانات عمودية.

## هل ترجع الذاكرة إلى النظام؟ المخصص pymalloc والـ Arenas

بعد أن تحرر Python كائناً، هل يعود حجمه إلى نظام التشغيل؟ هذا سؤال يهمك عندما تراقب ذاكرة الخدمة في لوحة المراقبة وترى أنها لا تنزل بعد انتهاء عملية الاستيراد. والإجابة تحتاج أن تعرف أن CPython لا يطلب الذاكرة من النظام لكل كائن صغير، وإنما يستعمل مخصصاً خاصاً اسمه pymalloc للكائنات التي حجمها 512 بايت أو أقل، وهذا المخصص يحجز من النظام قطعاً كبيرة اسمها Arenas حجم كل واحدة 1 ميجابايت على أنظمة 64 بت كما في [توثيق إدارة الذاكرة](https://docs.python.org/3/c-api/memory.html?ref=arabroot.io)، ثم يقسمها إلى صفحات وكتل صغيرة يوزعها على الكائنات. والـ Arena لا تعود إلى النظام إلا إذا صارت فارغة بالكامل، فإذا بقي فيها كائن واحد حي بقيت كلها محجوزة. قسنا ذلك بقراءة `VmRSS` من الملف `/proc/self/status`، أي الذاكرة الفعلية التي تحجزها العملية من النظام، والمخرج من `rss_numbers.py` كما يلي:

```text
start                         RSS   13.4 MB
after 1,000,000 objects       RSS  173.8 MB
after del + gc.collect()      RSS   15.5 MB
built again                   RSS  173.7 MB
after 150 x 1 MB bytearrays   RSS  159.4 MB
after del of the bytearrays   RSS   29.9 MB
```

عندما حذفنا مليون كائن بالكامل نزلت الذاكرة من 173.8 إلى 15.5 ميجابايت، أي أن CPython أرجع الـ Arenas الفارغة إلى النظام، وهذا عكس ما يقوله كثير من الناس من أن Python لا ترجع الذاكرة أبداً. والكائنات الكبيرة مثل `bytearray` بحجم ميجابايت لا تمر على pymalloc أصلاً، وإنما تحجز مباشرة من مخصص النظام، ولذلك رجعت هي أيضاً عند حذفها. ولكن الحالة التي تراها في الإنتاج غالباً مختلفة، والملف `rss_frag.py` يبينها: نبني نفس المليون كائن، ثم نحتفظ بكائن واحد من كل مئة ونحذف الباقي:

```python
rows = [Plain(i, i * 10) for i in range(1_000_000)]
keep = rows[::100]
del rows
gc.collect()
```

```text
start                         RSS    9.7 MB
after 1,000,000 objects       RSS  170.7 MB
kept 10,000 (1 in 100)        RSS  163.1 MB
after del of the kept ones    RSS   11.7 MB
```

بقي في الذاكرة عشرة آلاف كائن فقط، أي 1% من البيانات، ولكن الذاكرة الفعلية بقيت 163.1 ميجابايت، لأن الكائنات الباقية موزعة على كل الـ Arenas تقريباً، فلا توجد Arena فارغة يمكن إرجاعها، وهذا ما يسمى التجزئة Fragmentation. وعندما حذفنا الكائنات الباقية نزلت الذاكرة مباشرة إلى 11.7 ميجابايت. والدرس لعملية الاستيراد: لا تحتفظ بجزء صغير من نتائج عملية كبيرة داخل نفس العملية، كقاموس يحفظ آخر سعر لكل منتج مع كل سطر تقرؤه، بل انسخ ما تحتاجه في بنية جديدة أو اكتبه في قاعدة البيانات، أو شغل الأعمال الكبيرة في عملية منفصلة تنتهي عند انتهاء العمل فتعود كل ذاكرتها إلى النظام.

## ماذا لو What If: توقف جامع الدورات في Container محدود الذاكرة؟

الآن نصل إلى المشكلة الأولى في القصة: عملية تختفي دون أي رسالة ورقم 137 في حالة الـ Container. ولكي نرى ما حدث بالضبط كتبنا الملف `cycles_oom.py`، وهو نفس حلقة الدورات ولكن بمئة ألف زوج، مع خيار لتشغيل الجامع أو إيقافه، وشغلناه في Container بحد 256 ميجابايت دون أي ذاكرة Swap:

```python
import gc, sys
if "gc-on" not in sys.argv:
    gc.disable()
class Node:
    def __init__(self):
        self.peer = None
        self.payload = bytearray(10_000)
for i in range(1, 100_001):
    a, b = Node(), Node()
    a.peer, b.peer = b, a
    if i % 2_000 == 0:
        print(f"{i:>6} cycles, about {i * 2 * 10_000 / 1e6:.0f} MB of payload", flush=True)
print("finished, peak RSS", __import__("resource").getrusage(__import__("resource").RUSAGE_SELF).ru_maxrss // 1024, "MB")
```

```bash
docker run --name slp-py-oom --cpus 2 --memory 256m --memory-swap 256m \
  -v "$PWD:/lab" python:3.15.2-slim-trixie python /lab/cycles_oom.py
echo "docker run exit code: $?"
docker inspect slp-py-oom --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
```

والمخرج مع الجامع متوقفاً كما يلي:

```text
  2000 cycles, about 40 MB of payload
  4000 cycles, about 80 MB of payload
  6000 cycles, about 120 MB of payload
  8000 cycles, about 160 MB of payload
 10000 cycles, about 200 MB of payload
 12000 cycles, about 240 MB of payload
docker run exit code: 137
OOMKilled=true ExitCode=137
```

ثم شغلنا نفس الملف مع الوسيط `gc-on`، فوصل إلى نهايته:

```text
 98000 cycles, about 1960 MB of payload
100000 cycles, about 2000 MB of payload
finished, peak RSS 29 MB
```

![عمودان: على اليمين الجامع متوقف مع رسم لشريحة ذاكرة، وآخر سطر 12000 cycles about 240 MB ثم توقف المخرج، وdocker run exit code 137 وOOMKilled=true، بدون Traceback ولا MemoryError لأن النواة قتلت العملية، وعلى اليسار الجامع يعمل مع رسم ترس، ووصل الكود إلى 100000 دورة و2000 MB من البيانات على مر الوقت مع finished وpeak RSS 29 MB](https://arabroot.io/content/images/2026/10/python-types-memory-04-oom.webp)

نفس الكود: بدون الجامع قتلت النواة العملية عند 240 MB، ومعه انتهى بذروة 29 MB

في المخرج أعلاه لاحظ التالي:

- آخر سطر طبعه البرنامج كان عند 240 ميجابايت تقريباً، ثم توقف بدون أي Traceback، وبدون `MemoryError` الذي قد تتوقعه، والسبب أن Python لم تفشل في حجز الذاكرة، وإنما تجاوز الـ Container حده فقتلت نواة Linux العملية من الخارج بالإشارة `SIGKILL`، وهذه الإشارة لا يمكن التقاطها، فلا يجد البرنامج فرصة ليكتب شيئاً.
- الرقم 137 هو 128 + 9، أي أن العملية انتهت بالإشارة رقم 9 وهي `SIGKILL`، و`OOMKilled=true` في حالة الـ Container تؤكد أن السبب حد الذاكرة، والتفاصيل في [توثيق Docker لحدود الموارد](https://docs.docker.com/engine/containers/resource%5Fconstraints/?ref=arabroot.io). ولذلك عندما ترى 137 في Kubernetes أو Docker فأول ما تفحصه هو الذاكرة وليس الكود.
- مع الجامع يعمل أنشأ الكود 2000 ميجابايت من البيانات على مر الوقت، ولكن الذروة لم تتجاوز 29 ميجابايت، لأن الجامع كان يعمل كلما زاد عدد الكائنات الجديدة فوق الحد ويحرر الدورات القديمة.

وفي القصة الحقيقية لم يكن أحد قد أوقف الجامع بنفسه، ولكن النتيجة من نفس النوع: العملية تجمع كائنات لا تتحرر، إما لأنها في دورات مع مكتبة أوقفت الجامع لتسريع التحميل، أو لأنها محفوظة في قاموس عام يستعمل كذاكرة مؤقتة Cache بدون حد. والطريقة التي تكشف بها ذلك هي نفسها في المختبر: `tracemalloc` يقول لك كم حجزت ومن أين، و`gc.collect()` يرجع عدد الكائنات التي وجدها في دورات، فإذا كان الرقم كبيراً في كل مرة فعندك دورات تنشأ باستمرار.

## الأخطاء Exceptions: الطريقة التي تعبر بها Python عن الفشل

اللغات تختلف في طريقة التعبير عن الخطأ كما شرحنا في مقال [التعامل مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/): Go ترجع الخطأ كقيمة ثانية، وRust ترجع نوع `Result`، أما Python فتعتمد على الاستثناءات Exceptions بالكامل، والدوال المدمجة نفسها ترمي استثناء عند الفشل بدل أن ترجع قيمة خاصة، فالدالة `int("abc")` ترمي `ValueError`، والوصول إلى مفتاح غير موجود في قاموس يرمي `KeyError`. ولهذا السبب يشيع في Python أسلوب يسمى EAFP، أي «أسهل أن تطلب السماح من أن تستأذن» كما في [مسرد المصطلحات الرسمي](https://docs.python.org/3/glossary.html?ref=arabroot.io): جرب العملية والتقط الاستثناء، بدل أن تفحص كل الشروط قبلها LBYL. والشكل التالي يبين كيف نربط ذلك بخدمة الطلبات:

![صندوقان: الأول شجرة الاستثناءات من Exception إلى ShopError وتحته BadInput مقابل 400 وOutOfStock مقابل 409، ثم KeyError مقابل 404 وValueError من int abc وExceptionGroup لعدة أخطاء، والثاني مسار try except else finally للقيم 3 وabc و-2 مع السبب cause، وتحتهما بطاقتان عن ExceptionGroup مع except* وadd_note، وعن ثمن try حيث يكون try/except أبطأ بحوالي 3.5 مرة عندما يكون المفتاح غير موجود](https://arabroot.io/content/images/2026/10/python-types-memory-05-exceptions.webp)

استثناءات المتجر وما يقابلها من رموز HTTP، ومسار كل قيمة في try

### استثناءات خاصة بالمتجر Custom Exceptions

الملف `errors.py` يعرف كلاساً أساسياً لأخطاء المتجر، ومنه نوعان، ثم دالتين تستعملانهما:

```python
class ShopError(Exception):
    """Base class for errors the API turns into HTTP responses."""

class OutOfStock(ShopError):
    def __init__(self, product_id: int, wanted: int, left: int):
        super().__init__(f"product {product_id}: wanted {wanted}, only {left} left")
        self.product_id, self.wanted, self.left = product_id, wanted, left

class BadInput(ShopError):
    pass

def parse_qty(text: str) -> int:
    try:
        qty = int(text)
    except ValueError as e:
        raise BadInput(f"qty must be a whole number, got {text!r}") from e
    if qty <= 0:
        raise BadInput(f"qty must be positive, got {qty}")
    return qty

def reserve(stock: dict[int, int], pid: int, qty: int) -> None:
    left = stock[pid]                      # KeyError if the product does not exist
    if qty > left:
        raise OutOfStock(pid, qty, left)
    stock[pid] = left - qty

stock = {1: 10, 2: 0}
for raw in ["3", "abc", "-2"]:
    try:
        q = parse_qty(raw)
    except BadInput as e:
        print("400:", e, "| cause:", repr(e.__cause__))
    else:
        print("ok qty", q)
    finally:
        print("   (finally runs every time)")

for pid, qty in [(1, 4), (2, 1), (9, 1)]:
    try:
        reserve(stock, pid, qty)
        print("reserved", pid, qty, "->", stock)
    except OutOfStock as e:
        print("409:", e, "| fields:", e.product_id, e.wanted, e.left)
    except KeyError as e:
        print("404: no product", e)
```

والمخرج سوف يكون كما يلي:

```text
ok qty 3
   (finally runs every time)
400: qty must be a whole number, got 'abc' | cause: ValueError("invalid literal for int() with base 10: 'abc'")
   (finally runs every time)
400: qty must be positive, got -2 | cause: None
   (finally runs every time)
reserved 1 4 -> {1: 6, 2: 0}
409: product 2: wanted 1, only 0 left | fields: 2 1 0
404: no product 9
```

في الكود والمخرج أعلاه لاحظ التالي:

- الكتلة `else` بعد `try` لا تعمل إلا إذا لم يحدث استثناء، وهي المكان الصحيح للكود الذي يعتمد على نجاح المحاولة، والسبب أنك إذا وضعته داخل `try` فسوف تلتقط بالخطأ استثناءات من كود لم تقصد حمايته. أما `finally` فتعمل في كل الحالات كما ظهر بعد كل قيمة، ومكانها تحرير الموارد.
- السطر `raise BadInput(...) from e` يرمي خطأ المتجر ويحفظ الخطأ الأصلي في الخاصية `__cause__`، ولذلك ظهر `ValueError` كسبب في الحالة `"abc"`، بينما كان السبب `None` في الحالة `"-2"` لأنها رميت مباشرة دون خطأ سابق.
- الاستثناء `OutOfStock` يحمل حقولاً منظمة `product_id` و`wanted` و`left` وليس نصاً فقط، فطبقة الـ API تستطيع أن تبني منها رداً JSON واضحاً للعميل دون أن تحلل نص الرسالة.
- الكلاس الأساسي `ShopError` يسمح للخدمة أن تلتقط كل أخطاء المتجر المتوقعة في مكان واحد وتحولها إلى رمز HTTP مناسب، أي 400 للمدخلات الخاطئة و409 لنقص المخزون، وأي استثناء آخر غير متوقع يذهب إلى 500 مع السجل الكامل، وهذا هو التصميم الذي سوف نبني عليه خدمة الطلبات.
- التقطنا `KeyError` لنرجع 404، وهذا مقبول في مثال صغير، أما في الخدمة فالأفضل أن تحول `reserve` هذا الخطأ إلى استثناء خاص مثل `NotFound`، والسبب أن `KeyError` قد يأتي من أي سطر فيه قاموس، فتعطي 404 لخطأ برمجي حقيقي في مكان آخر.

### قراءة الـ Traceback لخطأ غير ملتقط

الآن نعود إلى المشكلة الثانية في القصة: العميل الذي كتب «ثلاثة». الملف `chained.py` يستدعي `parse_qty("ثلاثة")` دون أن يلتقط شيئاً، وهو ما يحدث عندما ينسى أحدهم معالجة الخطأ في طبقة الـ API:

```text
Traceback (most recent call last):
  File "/tmp/w/errors_lib.py", line 19, in parse_qty
    qty = int(text)
ValueError: invalid literal for int() with base 10: 'ثلاثة'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/w/chained.py", line 4, in <module>
    parse_qty("ثلاثة")
    ~~~~~~~~~^^^^^^^^^
  File "/tmp/w/errors_lib.py", line 21, in parse_qty
    raise BadInput(f"qty must be a whole number, got {text!r}") from e
errors_lib.BadInput: qty must be a whole number, got 'ثلاثة'
exit code: 1
```

والـ Traceback في Python يقرأ من الأسفل إلى الأعلى، وهذا عكس ما تعوده بعض مطوري Java: السطر الأخير هو الخطأ النهائي ونوعه `errors_lib.BadInput` مع الرسالة، وفوقه سلسلة الاستدعاءات من الأقدم في الأعلى إلى الأحدث في الأسفل. وبسبب `from e` ظهر Traceback ثان في الأعلى للخطأ الأصلي `ValueError`، يفصل بينهما السطر `The above exception was the direct cause of the following exception`، فترى في السجل القصة كاملة: ماذا كتب العميل، وأين فشل التحويل، وأي خطأ رماه الكود بسببه. وانتهت العملية برمز الخروج 1، وهو ما ترجعه Python لأي استثناء لم يلتقط. وقد شرحنا طريقة قراءة الـ Stack Trace بالتفصيل في مقال [قراءة ال Stack Trace](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/).

### مجموعات الأخطاء ExceptionGroup والملاحظات add\_note

عندما يرسل العميل طلباً فيه عشرة أسطر، وثلاثة منها خاطئة، فالأفضل أن تخبره بالأخطاء الثلاثة معاً بدل أن يصلحها واحداً بعد الآخر في ثلاث محاولات. ولهذا أضافت Python 3.11 الكلاس `ExceptionGroup` والصيغة `except*` بحسب [PEP 654](https://peps.python.org/pep-0654/?ref=arabroot.io)، وأضافت الدالة `add_note` بحسب [PEP 678](https://peps.python.org/pep-0678/?ref=arabroot.io) لكي تضيف سياقاً إلى استثناء موجود دون أن تغير نوعه:

```python
def validate(lines):
    errors = []
    for i, (pid, raw) in enumerate(lines):
        try:
            parse_qty(raw)
        except BadInput as e:
            e.add_note(f"line {i}, product {pid}")
            errors.append(e)
    if errors:
        raise ExceptionGroup("order has bad lines", errors)

try:
    validate([(1, "2"), (2, "x"), (3, "0")])
except* BadInput as eg:
    for e in eg.exceptions:
        print("  -", e, e.__notes__)
```

والمخرج سوف يكون كما يلي:

```text
  - qty must be a whole number, got 'x' ['line 1, product 2']
  - qty must be positive, got 0 ['line 2, product 3']
```

جمعت الدالة الخطأين في مجموعة واحدة، والتقطت `except* BadInput` كل استثناءات هذا النوع من المجموعة، ولكل واحد ملاحظته برقم السطر والمنتج. وهذه الصيغة هي نفسها التي تستعملها `asyncio.TaskGroup` عندما تفشل عدة مهام متزامنة معاً، وسوف نراها في مقال التزامن.

### كم يكلف try؟ LBYL مقابل EAFP

وقد يتساءل البعض: إذا كانت Python تشجع على `try`، أليس الاستثناء مكلفاً؟ قسنا ذلك على قاموس فيه مفتاح واحد، بطريقتين: فحص المفتاح قبل الوصول إليه، أو الوصول إليه والتقاط `KeyError`، مرة مع مفتاح موجود ومرة مع مفتاح غير موجود:

```python
d = {'coffee': 4500}
def lbyl(k):
    return d[k] if k in d else 0
def eafp(k):
    try:
        return d[k]
    except KeyError:
        return 0
```

والنتيجة، أقل زمن من سبع محاولات في كل واحدة مليون استدعاء:

```text
key 'coffee' if-check    399.2 ns
key 'coffee' try/except  388.9 ns
key 'tea'    if-check    341.2 ns
key 'tea'    try/except 1190.6 ns
```

عندما يكون المفتاح موجوداً كانت الطريقتان متقاربتين، فدخول `try` نفسه شبه مجاني في الإصدارات الحديثة، أما عندما يكون المفتاح غير موجود فكانت `try/except` أبطأ بحوالي 3.5 مرة، لأن إنشاء كائن الاستثناء ورميه والبحث عن معالج له عمل حقيقي. والأرقام المطلقة هنا عالية لأن الجهاز كان مشغولاً وقت القياس، فانظر إلى النسبة وليس إلى النانوثانية. والقاعدة: استعمل `try` عندما يكون الفشل نادراً، كتحويل كمية كتبها العميل، واستعمل الفحص أو `dict.get(k, 0)` عندما يكون الفشل جزءاً عادياً من العمل، كالبحث عن منتج قد لا يكون موجوداً في نصف الحالات.

## أخطاء شائعة Common Mistakes

- مقارنة الأعداد أو النصوص بـ `is`: تعمل مع القيم الصغيرة لأن CPython يحفظها مسبقاً، ثم تفشل عندما تكبر القيمة، والحد نفسه تغير بين الإصدارات.
- تعديل قائمة أو قاموس وصل كمعامل إلى دالة دون أن يعرف المستدعي، ثم البحث طويلاً عن سبب تغير البيانات.
- استعمال `copy.copy` لبنية متداخلة ثم تعديل مستوى داخلي فيها.
- الاعتقاد أن تلميحات الأنواع تحمي الكود وقت التشغيل، فلا يشغل أحد mypy ولا يتحقق من المدخلات.
- تحميل ملف كبير كله في قائمة من القواميس، بدل المرور عليه بمولد أو حفظه في `dataclass(slots=True)`.
- التقاط `except Exception` أو `except:` بدون نوع، فيختفي الخطأ الحقيقي، والأسوأ أن `except:` وحدها تلتقط حتى `KeyboardInterrupt` و`SystemExit`.
- رمي استثناء جديد داخل `except` بدون `from e`، فتضيع معلومة السبب أو تظهر برسالة أقل وضوحاً.
- تقريب المبالغ بالدالة `round`، فهي تقرب النصف إلى العدد الزوجي، وتعمل على `float` الذي لا يمثل أغلب الأسعار بدقة.
- الاحتفاظ بجزء صغير من نتائج عملية كبيرة في نفس العملية، فتبقى الذاكرة محجوزة كلها بسبب التجزئة حتى لو كان الباقي 1% فقط.
- تفسير رقم الخروج 137 كخطأ في الكود والبحث عن Traceback غير موجود، بدل فحص حد الذاكرة.

## جدول القرار Decision Table: أي بنية تحفظ بها بياناتك؟

| الحالة                                        | البنية المناسبة              | السبب من المختبر                           |
| --------------------------------------------- | ---------------------------- | ------------------------------------------ |
| ملايين الأرقام من نفس النوع                   | array أو مكتبة عمودية        | 8.2 MB مقابل 40 MB لقائمة الأعداد          |
| ملايين السجلات بحقول ثابتة                    | dataclass(slots=True)        | 120.4 MB مقابل 160.4 MB للكلاس العادي      |
| سجل تحتاج أن تضيف له حقولاً وقت التشغيل       | كلاس عادي أو dict            | المرونة لها ثمن في الذاكرة                 |
| إعدادات أو قائمة أسعار لا تتغير وتصلح مفتاحاً | frozendict                   | جديد في 3.15، وله هاش لا يعتمد على الترتيب |
| ملف كبير تمر عليه مرة واحدة                   | مولد Generator               | الذاكرة ثابتة مهما كبر الملف               |
| خطأ متوقع من المستخدم                         | استثناء خاص يرث من ShopError | يحمل حقولاً ويتحول إلى 400 أو 409          |
| عدة أخطاء في نفس الطلب                        | ExceptionGroup مع add\_note  | العميل يرى كل الأسطر الخاطئة مرة واحدة     |

## تدريب عملي

- غير التلميح في `cheapest` إلى `Sequence[HasPrice]` من `collections.abc`، وأضف شرط `if p is None` قبل السطر 35، ثم شغل mypy حتى لا يبقى أي خطأ.
- عدل الدالة `measure` لتقيس قائمة من مليون قاموس `{"pid": i, "price": i * 10}`، وقارن الرقم بالكائنات العادية و`__slots__`.
- شغل `cycles_oom.py` بحد 512 ميجابايت بدل 256، وتوقع قبل التشغيل عند أي سطر سوف يتوقف.
- أضف إلى `reserve` استثناء `NotFound` يرث من `ShopError`، واجعل الكود الذي يستدعيها يلتقط `ShopError` فقط ويحول كل نوع إلى رمز HTTP من قاموس واحد.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- المتغير في Python اسم يشير إلى كائن، والنوع للكائن، والتمرير إلى الدوال يمرر المرجع، فالتعديل داخل الدالة يظهر عند المستدعي والإسناد لا يظهر.
- `is` للهوية و`==` للقيمة، والأعداد المحفوظة مسبقاً في 3.15.2 من ‎-5 إلى 1024 وليس 256 كما تقول الشروحات القديمة، وهذا سبب إضافي لعدم استعمال `is` إلا مع `None`.
- تلميحات الأنواع لا تفحص وقت التشغيل، والنص `"3"` مر إلى دالة تنتظر عدداً فأعطى النص مكرراً 990 مرة، بينما وجد mypy الأخطاء الثلاثة قبل التشغيل.
- CPython يحرر الذاكرة بعداد المراجع فوراً، وجامع الدورات ينظف الدوائر، وبدونه بقي 407 ميجابايت محجوزاً حتى حررها في أقل من ربع ثانية.
- مليون سجل أخذت 160 ميجابايت ككائنات عادية و120 مع `slots=True`، ومليون عدد أخذ 40 ميجابايت في قائمة و8.2 في `array`.
- CPython يرجع الذاكرة إلى النظام عندما تفرغ الـ Arena بالكامل، فنزلت من 173.8 إلى 15.5 ميجابايت بعد الحذف، ولكن كائناً واحداً من كل مئة أبقى 163 ميجابايت محجوزة.
- عندما يتجاوز الـ Container حد الذاكرة تقتل النواة العملية برمز 137 دون أي رسالة من Python، فابحث عن السبب في الذاكرة وليس في الكود.
- الاستثناءات الخاصة مع `raise from` و`ExceptionGroup` تجعل الخطأ واضحاً في السجل وفي الرد على العميل، و`try` رخيص عندما ينجح ومكلف عندما يفشل.

وفي المقال التالي من مرحلة [لغة Python للمطورين](https://arabroot.io/start/#stage-lang-python) سوف ننتقل إلى التزامن وقفل GIL، ونقيس نفس المهمة بالخيوط وasyncio والعمليات والبناء الخالي من القفل:

[![](https://arabroot.io/content/images/2026/10/python-for-devs-cover-cover.webp)دليلPython للمطورين: التركيب Syntax والأدوات في يوم واحددليل لمن يبرمج بلغة أخرى ويريد أن يبدأ بـ Python 3.15 من أول يوم: التثبيت، وما يختلف في التركيب، والأدوات uv وruff وmypy وpytest على مشروع حقيقي بمخرجه الفعلي.](https://arabroot.io/articles/python-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D8%AA%D8%B1%D9%83%D9%8A%D8%A8-%D9%88%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D9%81%D9%8A-%D9%8A%D9%88%D9%85-%D9%88%D8%A7%D8%AD%D8%AF/)[![](https://arabroot.io/content/images/2026/10/memory-management-cover-cover.webp)دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)[![](https://arabroot.io/content/images/2026/10/error-handling-cover-cover.webp)دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على Python 3.15.2 في الـ Image الرسمية python:3.15.2-slim-trixie داخل Container محدود بـ 2 CPU و512 MB، وتجربة حد الذاكرة بـ 256 MB، مع mypy 2.4.1.