فهم المراجع الضعيفة في بايثون

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

يحدث هذا لأن لغة بايثون تُنشئ دائمًا مراجع قوية للكائنات، مما يعني أن الكائن سيظل موجودًا طالما وُجدت جميع هذه المراجع القوية في البرنامج. ولكن عند استخدامها في هياكل البيانات الدورية، أو في ذاكرة التخزين المؤقت، قد تُؤخر هذه المراجع القوية تحرير هذه الكائنات دون داعٍ.

تُتيح المراجع الضعيفة إمكانية الإشارة إلى الكائنات دون منع جمعها بواسطة جامع البيانات المهملة. فهي تُمكّنك من إنشاء ذاكرات تخزين مؤقتة تُفرغ تلقائيًا، وقوائم مشتركين تُنظف نفسها تلقائيًا، وهياكل بيانات أخرى لا تُطيل عمر الكائنات عن طريق الخطأ.

سنتناول في هذه المقالة مفهوم المراجع الضعيفة، وأهميتها، وكيفية استخدامها في لغة بايثون. سنبدأ بمراجعة آلية عدّ المراجع، ثم نستعرض قيودها، ثم نتعمق في المراجع الضعيفة واستخداماتها العملية.

مراجعة لتقنية عد المراجع

تستخدم العديد من اللغات إما عد المراجع كآلية لإدارة ذاكرة وقت التشغيل أو أنها توفر عناصر أساسية من الدرجة الأولى لاستخدام عد المراجع.

في هذا النظام، يرتبط كل كائن بعدد من المراجع، أي عدد المواضع التي يُستخدم فيها. على سبيل المثال، عند إنشاء كائن وتعيينه لمتغير، يكون عدد المراجع الخاص به 1. وعند تعيينه لمتغير آخر أو تمريره إلى دالة أخرى، يزداد عدد المراجع بمقدار 1.

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

تستخدم لغة CPython عدّ المراجع لإدارة ذاكرة وقت التشغيل. لكن لغات أخرى توفر هذه الميزة أيضاً. على سبيل المثال، في لغتي C++ أو Rust، عند استخدام مؤشر ذكي، يتم استخدام عدّ المراجع داخلياً، حيث يقوم المترجم بتوليد شيفرة برمجية تزيد وتنقص عدّ المراجع للكائنات.

قيود العدّ المرجعي

يُعدّ عدّ المراجع فعالاً في معظم الحالات، ولكنه ليس حلاً كاملاً. فبساطته تأتي مصحوبة ببعض العيوب، وفهم هذه القيود يُفسّر سبب توفير بايثون أيضاً للمراجع الضعيفة.

من بين هذه القيود المراجع الدورية. تنشأ المراجع الدورية عندما تحتفظ الكائنات بمراجع لبعضها البعض في حلقة، كما هو الحال في بنية بيانات الرسم البياني. ولكن قد تنشأ المراجع الدورية أيضًا عن طريق الخطأ في الأنظمة المعقدة. في مثل هذه الحالات، لن يتم تحرير الكائنات التي تشكل جزءًا من الحلقة حتى يتم كسر الحلقة. لهذا السبب، تُطبّق لغة CPython أيضًا جامع قمامة (GC) لكسر الحلقات، يعمل دوريًا، ويفحص الكائنات بحثًا عن الحلقات، وإذا اكتشف حلقات لم تعد مُشارًا إليها من أي مكان آخر، فإنه يكسرها حتى يمكن تحرير تلك الكائنات.

يمكن أن تكون المراجع الدورية مشكلة بالنسبة للأداء لأن استخدام الذاكرة يظل مرتفعًا حتى يتم تشغيل جامع البيانات المهملة، ويمكن أن يكون فحص جامع البيانات المهملة نفسه مكلفًا (اعتمادًا على عدد الكائنات التي يحتاج إلى فحصها).

يمكننا فهم ذلك بمساعدة مثال. انظر إلى الكود التالي

عرض توضيحي لكيفية عمل عد المراجع في بايثون

دعونا نحلل الأمر:

  • تُنفذ فئة MyNode عقدة قائمة مرتبطة بحقل next.
  • دالة print_node_objects هي دالة مساعدة. تقوم هذه الدالة بالعثور على جميع كائنات MyNode النشطة حاليًا ثم تطبع الكائنات التي تشير إليها، أي الكائنات التي تحتفظ بمرجع إليها.
    • يستخدم ()gc.get_objects للحصول على قائمة بجميع الكائنات النشطة حاليًا في مترجم بايثون ويقوم بتصفيتها عن طريق التحقق من نوعها واختيار كائنات نوع MyNode فقط.
    • يُستخدم التابع gc.get_referrers() للعثور على المُحيلين إلى كائن ما، حيث يُعيد هذا التابع قائمةً بكائنات المُحيلين. نقوم بتصفية هذه القائمة حسب النوع، لأنه أثناء الاستدعاء، تُصبح وحدة gc نفسها مُحيلاً، ونريد استبعادها.
  • في الدالة الرئيسية، نستدعي الدالة ()test1 التي تُنشئ كائنين من نوع MyNode، وتطبع عدد المراجع لكل منهما، ثم تُنهي عملها. بعد إنهاء الدالة test1، نستدعي الدالة ()print_node_objects للتحقق من وجود أي كائنات من نوع MyNode لا تزال نشطة.

إذا قمت بتشغيل هذا البرنامج، فسترى مخرجات مشابهة لما يلي:

➜ uv run --python 3.13 --  cycles.py
n1 refcount: 2
n2 refcount: 2
n1 is being deleted
n2 is being deleted
No MyNode objects found

هذا هو الناتج المتوقع إلى حد كبير، ولكن دعونا نقضي لحظة للتأكد من أننا لا نفوت أي شيء.

  • نلاحظ أن عدد المراجع لكل من n1 و n2 هو 2. قد تتوقع أن يكون 1 ولكنه 2 لأنه أثناء استدعاء sys.getrefcount، يتم زيادة عدد مراجع الكائن.
  • نلاحظ أن دالة __del__ للكائنين تُستدعى وتطبع رسالة. يحدث هذا لأن n1 و n2 متغيران محليان داخل الدالة ()test1، وعندما تُنهي الدالة عملها، يُحذف إطار مكدسها، مما يؤدي إلى إنقاص عدد المراجع لجميع كائناتها المحلية (المعاملات والمتغيرات المُنشأة محليًا). في هذه الحالة، ولأن n1 و n2 وصلا إلى عدد المراجع 0، فقد تم تحريرهما واستُدعيت دالة __del__ الخاصة بهما.
  • وأخيرًا، في الدالة الرئيسية ()main، عند استدعاء الدالة ()print_node_objects، نرى أنها لا تجد أي كائنات MyNode على الكومة لا تزال موجودة.

بعد ذلك، يمكننا إجراء اختبار آخر يُنشئ حلقة بين n1 و n2، ونرى أن الكائنات تبقى موجودة بعد انتهاء دالة الاختبار. يوضح الشكل التالي الكود المُحدَّث حيث أضفتُ دالة جديدة باسم ()test2، ثم استدعيتها من الدالة الرئيسية main.

توضيح المراجع الدورية. في الدالة test2، نقوم بإنشاء حلقة بين n1 و n2 ونرى أنها تبقى نشطة حتى بعد انتهاء الدالة test2.

إذا قمنا بتشغيل هذا البرنامج، فسنرى المخرجات التالية:

➜ uv run --python 3.13 --  cycles.py
n1 refcount: 2
n2 refcount: 2
n1 is being deleted
n2 is being deleted
No MyNode objects found
---------------------
n1 refcount: 3
n2 refcount: 3
n1 exists with referrers: [’n2’]
n2 exists with referrers: [’n1’]
n1 is being deleted
n2 is being deleted

لنركز على الناتج بعد استدعاء الدالة ()test2.

  • نلاحظ في الدالة ()test2 أن عدد المراجع لكل من n1 و n2 هو 3، أي بزيادة واحدة عما كان عليه في الدالة ()test1. ويعود ذلك إلى أن n1.next تُنشئ مرجعًا إلى n2، و n2.next تُنشئ مرجعًا إلى n1.
  • نلاحظ أيضًا أنه عند انتهاء دالة ()test2، لا يتم استدعاء دالة __del__ الخاصة بالكائنين n1 و n2، مما يعني أن هذين الكائنين لم يتم تحريرهما من الذاكرة وما زالا موجودين. حدث هذا لأن المفسر يقوم عادةً بإنقاص عدد المراجع أثناء عملية الإرجاع، ولكن هذه المرة لا يصل عدد المراجع إلى الصفر.
  • بعد انتهاء الدالة ()test2، وعند استدعاء الدالة ()print_node_objects، نلاحظ أنها تُشير إلى أن كائنات MyNode التي أنشأناها لـ n1 و n2 لا تزال موجودة. ويتضح ذلك من خلال وجود مرجع دائري بينها.
  • يتم تدمير n1 و n2 في النهاية عند انتهاء البرنامج لأن مترجم CPython يقوم بتشغيل جامع البيانات المهملة قبل الإغلاق.

لتجنب تسرب الذاكرة الناتج عن المراجع الدورية، يتضمن CPython جامعًا للبيانات المهملة يعمل دوريًا، ويكشف عن الحلقات التي لم تعد موجودة في أي مكان آخر، ويقطعها لتحرير الكائنات التي كانت جزءًا من الحلقة. يمكنك التحقق من ذلك بنفسك بإضافة استدعاء ()gc.collect بعد استدعاء ()test2 في البرنامج أعلاه.

مع ذلك، توجد طرق أخرى لتجنب هذه المآزق المتعلقة بعدّ المراجع، ومنها المراجع الضعيفة. دعونا نفهم ماهيتها وكيفية عملها.

فهم المراجع الضعيفة

تُعتبر المراجع الضعيفة نقيضًا للمراجع القوية. فالمرجع الضعيف لا يزيد من عدد المراجع للكائن الأساسي، مما يُمكّنك من استخدام الكائن دون إطالة عمره.

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

في لغة بايثون، لإنشاء مراجع ضعيفة، نحتاج إلى استخدام الدالة ()weakref.ref من وحدة weakref وتمرير الكائن الذي نريد إنشاء مرجع ضعيف له. على سبيل المثال:

n1_weakref = weakref.ref(n1)

تُنشئ الدالة ()weakref.ref مرجعًا ضعيفًا للكائن المُعطى وتُعيد دالة قابلة للاستدعاء. للوصول إلى الكائن الأساسي، نحتاج إلى استدعاء هذه الدالة في كل مرة. إذا كان الكائن لا يزال موجودًا، تُعيد الدالة مؤشرًا إليه، وإلا تُعيد القيمة None. على سبيل المثال:

if n1_weakref():
  print(f"name: {n1_weakref().name}")
else:
  print("n1 no longer exists")

يوضح الشكل التالي مثالاً كاملاً لإنشاء مرجع ضعيف والوصول إليه في مثال القائمة المرتبطة الجارية لدينا.

عرض توضيحي لإنشاء مرجع ضعيف واستخدامه
➜ uv run --python 3.13 --  weakref_cycles.py
n1 refcount: 2
n1 refcount: 2
n1’s name: n1
n1 is being deleted
n1 no longer exists
---------------------
No MyNode objects found

يمكننا من خلال المخرجات تأكيد بعض الأمور:

  • إنشاء مرجع ضعيف لا يزيد من عدد مراجع الكائن
  • لا يمنع المرجع الضعيف تحرير الكائن إذا وصل عدد المراجع إلى 0 (في المثال قمنا بحذف n1 وبعد ذلك لم نتمكن من الوصول إليه باستخدام المرجع الضعيف).

أترك لكم مشكلة إصلاح المرجع الدوري الذي أنشأناه في ()test2 كتمرين.

حالات استخدام أخرى للمراجع الضعيفة

لقد رأينا حتى الآن المراجع الضعيفة كأداة لتجنب الحلقات، لكن فائدتها تتجاوز ذلك بكثير. توفر وحدة weakref أيضًا حاويات جاهزة مبنية على المراجع الضعيفة. تساعدك هذه الحاويات، WeakValueDictionary وWeakSet، في إدارة هياكل البيانات المساعدة التي لا ينبغي أن تُطيل عمر محتوياتها. وهي تحل مشاكل عملية مثل التخزين المؤقت، والسجلات، وقوائم المشتركين، حيث لا يُعد التنظيف التلقائي مريحًا فحسب، بل ضروريًا لتجنب تسريبات الذاكرة.

قاموس القيم الضعيفة

توفر وحدة weakref قاموس WeakValueDictionary، الذي يبدو ويتصرف كقاموس عادي، لكن مع ميزة مهمة: تُحفظ قيمه فقط من خلال مراجع ضعيفة. إذا لم تعد هناك إشارة قوية لقيمة ما في أي مكان آخر، يختفي مدخل القاموس تلقائيًا.

هذا يجعل WeakValueDictionary خيارًا مثاليًا للتخزين المؤقت والذاكرة. تخيل أنك تُجري عمليات حسابية مُكلفة أو تُحمّل هياكل بيانات ضخمة وترغب في إعادة استخدامها إذا كانت لا تزال موجودة في الذاكرة. في الوقت نفسه، لا تريد أن تحتفظ ذاكرة التخزين المؤقت بها إلى الأبد. يُحقق WeakValueDictionary هذا التوازن: فهو يحتفظ بالنتائج فقط طالما أن باقي البرنامج موجود.

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

إليكم مثال توضيحي بسيط:

import weakref

class Data:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Data({self.name})"

cache = weakref.WeakValueDictionary()
obj = Data("expensive_result")
cache["key"] = obj

print("Before deletion:", dict(cache))

# Drop the strong reference
obj = None

print("After deletion:", dict(cache))

الناتج:

Before deletion: {'key': Data(expensive_result)}
After deletion: {}

لاحظ كيف يختفي إدخال ذاكرة التخزين المؤقت تلقائيًا بمجرد زوال آخر مرجع قوي. لا حاجة للتنظيف اليدوي. يتم تنفيذ ذلك داخليًا باستخدام دوال رد نداء للمراجع الضعيفة – وهي نفس الآلية التي سنراها في قسم دوال رد النداء.

WeakSet

يوفر مُكوّن weakref حاوية أخرى تُسمى WeakSet. وهي تُشبه المجموعة العادية، إلا أنها تحتوي على مراجع ضعيفة لعناصرها. إذا تم جمع كائن بواسطة جامع البيانات المهملة، فسيتم حذفه تلقائيًا من المجموعة.

يُعدّ هذا الأمر مفيدًا للغاية في بعض الحالات، مثل تتبع المشتركين أو المراقبين أو المستمعين. هذه كائنات تُسجّل اهتمامها بالأحداث التي يُنتجها كائن آخر (يُسمى غالبًا الناشر). على سبيل المثال:

  • أطر عمل واجهة المستخدم الرسومية: تستمع الأدوات إلى الأحداث مثل تغييرات المظهر أو تغيير حجم النافذة.
  • حافلات الأحداث: الخدمات التي تشترك في تسجيل الأحداث أو المقاييس أو أحداث المجال.
  • أنظمة الإضافات: تقوم الإضافات بتسجيل ردود الاتصال في وقت التحميل للاستجابة للخطافات.
  • خدمات الخلفية: تستمع الجلسات العابرة (مثل اتصالات WebSocket) إلى التحديثات من مدير طويل الأمد.

في جميع هذه الحالات، غالبًا ما يكون عمر المشتركين قصيرًا، بينما يدوم الناشر لفترة أطول بكثير. استخدام مجموعة عادية لتخزينهم يُعرّض النظام لخطر تسرب الذاكرة، لأن وجود مرجع قوي في المجموعة سيُبقي المشترك نشطًا حتى بعد أن ينساه باقي البرنامج. مع WeakSet، يقوم جامع البيانات المهملة تلقائيًا بإزالة المشتركين الذين لم يعد هناك مرجع قوي لهم في أي مكان آخر، لذا لا تحتاج إلى منطق إلغاء اشتراك صريح في كل مسار إيقاف تشغيل.

إليك مثال بسيط:

import weakref

class Listener:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Listener({self.name})"

listeners = weakref.WeakSet()

l1 = Listener("A")
l2 = Listener("B")
listeners.add(l1)
listeners.add(l2)

print("Before deletion:", list(listeners))

# Remove one listener
l1 = None
import gc; gc.collect()

print("After deletion:", list(listeners))

الناتج:

Before deletion: [Listener(A), Listener(B)]
After deletion: [Listener(B)]

غالباً ما يتم توسيع هذا النمط ليشمل نموذج الناشر والمشترك:

class Publisher:
    def __init__(self):
        self._subs = weakref.WeakSet()
    def subscribe(self, sub):
        self._subs.add(sub)
    def notify(self, payload):
        for s in list(self._subs):
            s.handle(payload)

class Subscriber:
    def __init__(self, name):
        self.name = name
    def handle(self, payload):
        print(self.name, "got:", payload)

pub = Publisher()
sub = Subscriber("one")
pub.subscribe(sub)

pub.notify({"event": 1})  # delivered
sub = None                  # drop last strong ref
import gc; gc.collect()

pub.notify({"event": 2})  # nothing printed; WeakSet cleaned itself

يُجنّب استخدام WeakSet هنا حدوث تسريبات ويُبسّط إدارة دورة حياة الكائن. مع ذلك، لا يُمكن إضافة سوى الكائنات التي يُمكن الوصول إليها بشكل ضعيف (أي الفئات المُعرّفة من قِبل المستخدم)؛ ولن تعمل الأنواع المُضمّنة مثل int أو tuple. إذا كانت فئتك تستخدم __slots__، فقم بتضمين __weakref__ للسماح بالوصول الضعيف.

ردود الاتصال على المراجع الضعيفة

من الميزات المفيدة الأخرى لـ weakref.ref إمكانية إرفاق دالة رد نداء. دالة رد النداء هي دالة تُستدعى تلقائيًا عند اقتراب إنهاء الكائن المُشار إليه. قد يكون هذا مفيدًا إذا كنت ترغب في تنظيف هياكل البيانات المساعدة أو تحرير الموارد عند إزالة كائن ما.

import weakref

class Resource:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Resource({self.name})"

def on_finalize(wr):
    print("Resource has been garbage collected:", wr)

obj = Resource("temp")
wr = weakref.ref(obj, on_finalize)

print("Created weak reference:", wr)

# Drop strong reference
obj = None

# Force GC for demo purposes
import gc; gc.collect()

الناتج:

Created weak reference: <weakref at 0x75f6773870b0; to ‘Resource’ at 0x75f677c4ee40>
Resource has been garbage collected: <weakref at 0x75f6773870b0; dead>

هنا، يتم استدعاء دالة on_finalize عندما يكون مثيل المورد على وشك الإزالة. يصبح المرجع الضعيف نفسه غير فعال بعد ذلك. يُعد هذا النمط مفيدًا عند الرغبة في تطبيق منطق تنظيف مخصص مرتبط بدورة حياة الكائن.

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

المراجع الضعيفة ليست أداة ستستخدمها يوميًا، ولكنها تحل مشاكل حقيقية عند الحاجة إليها. على المستوى الأدنى، يسمح لك weakref.ref بالإشارة إلى كائن دون التأثير على دورة حياته، ويمكنك حتى إرفاق دالة رد نداء لتشغيل رمز تنظيف عند جمعه. بالاعتماد على هذه الخاصية الأساسية، توفر لك WeakValueDictionary وWeakSet في بايثون حاويات عالية المستوى لذاكرات التخزين المؤقت والسجلات وقوائم المشتركين التي تنظف نفسها تلقائيًا عند اختفاء محتوياتها.

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


اكتشاف المزيد من بايثون العربي

اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *

Scroll to Top

اكتشاف المزيد من بايثون العربي

اشترك الآن للاستمرار في القراءة والحصول على حق الوصول إلى الأرشيف الكامل.

Continue reading