المشكلة التي تحلّها المؤشرات الذكية
في الصفحة السابقة خصّصت الذاكرة باستخدام new وحرّرتها باستخدام delete. هذا ينجح، لكنه يلقي العبء عليك: كل new يحتاج إلى delete مقابل له، على كل مسار في الشيفرة، بما في ذلك المسارات التي يُرمى فيها استثناء في منتصف الطريق. فإن فاتك واحد سرّبت الذاكرة؛ وإن نفّذت delete مرتين أفسدت الكومة.
تعالج المؤشرات الذكية هذا بربط عمر ذاكرة الكومة بكائن عادي على المكدّس. فعندما يخرج ذلك الكائن من النطاق، يشغّل هادمه delete نيابة عنك، وهذا مضمون حتى لو فكّ استثناءٌ المكدّس. تُسمّى هذه الفكرة RAII (Resource Acquisition Is Initialization)، وتوجد المؤشرات الذكية في الترويسة <memory>.
تستخدم *p و p->member تمامًا كما تفعل مع مؤشر خام. الفرق أنك لا تستدعي delete أبدًا، فالمؤشر الذكي يفعل ذلك.
unique_ptr: مالك واحد، دون مشاركة
unique_ptr هو المؤشر الذكي الذي ينبغي أن تلجأ إليه افتراضيًا. وهو يمثّل ملكية حصرية: مؤشر unique_ptr واحد بالضبط يمتلك الكائن في كل لحظة، وعندما يُدمَّر ذلك المؤشر يموت الكائن معه. وله كلفة تشغيل صفرية مقارنة بالمؤشر الخام.
أنشئ واحدًا باستخدام make_unique (C++14). يأخذ وسائط الباني ويعطيك مؤشرًا جاهزًا للاستخدام:
بما أنه لا يمكن أن يوجد سوى مالك واحد، فإن unique_ptr لا يمكن نسخه. محاولة نسخه خطأٌ في الترجمة، وهذا الخطأ هو اللغة وهي تحميك من مالكَين يحاول كلاهما تنفيذ delete على الكائن نفسه:
auto a = make_unique<int>(10);
auto b = a; // error: call to deleted copy constructor of unique_ptr
لتسليم الملكية لشخص آخر، تنقلها باستخدام std::move. بعد النقل يصبح المؤشر الأصلي فارغًا (يحمل nullptr):
هذا هو النموذج الذي ستريده في أغلب الأحيان: هناك دائمًا مالك واحد واضح بالضبط، والمترجم يفرض ذلك.
shared_ptr: ملكية مشتركة عبر عدّ المراجع
أحيانًا تحتاج أجزاء عدّة من برنامجك فعليًا إلى مشاركة الكائن نفسه، ولا يعرف أيٌّ منها أيها سينتهي أخيرًا. لهذا وُجد shared_ptr. فهو يحتفظ بـ_عدّاد مراجع_: كل نسخة ترفع العدّاد، وكل تدمير يخفضه، ولا يُحرَّر الكائن إلا عندما يبلغ العدّاد صفرًا.
أنشئها باستخدام make_shared:
على عكس unique_ptr، لا بأس بنسخ shared_ptr، فهذا هو الهدف كله. والمقابل هو الكلفة: يُخزَّن عدّاد المراجع على الكومة ويُحدَّث بشكل ذرّي (آمن مع الخيوط)، لذا فإن shared_ptr أثقل من unique_ptr. لا تلجأ إليه إلا حين تكون الملكية مشتركة حقًا، لا لمجرد تجنّب التفكير في من يملك ماذا.
كما أن make_shared أكثر كفاءة من shared_ptr<T>(new T(...)): فهو يخصّص الكائن وكتلة التحكم في تخصيص واحد بدل اثنين.
weak_ptr وكسر حلقات المراجع
لـ shared_ptr فخّ كلاسيكي واحد: إذا احتفظ كائنان بـ shared_ptr يشير كل منهما إلى الآخر، فلن يبلغ عدّادا مراجعهما صفرًا أبدًا، فلا يُحرَّر أيٌّ منهما، وهو تسريب ذاكرة رغم استخدامك للمؤشرات الذكية.
struct Node {
shared_ptr<Node> next; // إذا أشار عقدتان إلى بعضهما،
}; // أبقتا بعضهما حيّتين إلى الأبد
الحل هو weak_ptr: مراقب غير مالك لـ shared_ptr. وهو لا يرفع عدّاد المراجع، لذا لا يُبقي كائنًا حيًا أبدًا. ولاستخدام الكائن تستدعي .lock()، فيعطيك shared_ptr إن كان الكائن ما زال موجودًا، أو مؤشرًا فارغًا إن كان قد زال بالفعل.
استخدم weak_ptr للمؤشرات العكسية والذواكر المؤقتة (caches)، في كل موضع تريد فيه الإشارة إلى كائن دون أن تدّعي ملكيته.
الأخطاء الشائعة والمزالق
تزيل المؤشرات الذكية معظم أخطاء الذاكرة، لكن تبقى بضعة مزالق:
لا تخلط الملكية الذكية والخام للذاكرة نفسها. لا تبنِ أبدًا مؤشرين ذكيين من المؤشر الخام نفسه، فسيحاول كلٌّ منهما تنفيذ delete عليه:
int* raw = new int(5);
unique_ptr<int> a(raw);
unique_ptr<int> b(raw); // كارثة: كلاهما سيحذف الـ int نفسه (تحرير مزدوج)
ولهذا بالضبط تفضّل make_unique/make_shared، إذ لا يوجد مؤشر خام سائب يمكن إساءة استخدامه.
unique_ptr قابل للنقل فقط، لذا مرّره بالقيمة لنقل الملكية. إن كان على دالة أن تستخدم الكائن لا أن تمتلكه، فخذ مرجعًا عاديًا أو T* خامًا بدلًا من ذلك، فالمؤشر الخام الذي يكتفي بالمراقبة لا بأس به تمامًا:
void consume(unique_ptr<int> p); // يأخذ الملكية (نقل إلى الداخل)
void observe(int* p); // ينظر فقط، لا يملك شيئًا
لا تلجأ إلى shared_ptr افتراضيًا. إنه مغرٍ لأنه يُنسخ بحرية، لكن عدّ المراجع الذرّي يكلّف أداءً حقيقيًا، والملكية المشتركة تجعل أعمار الكائنات أصعب على الاستدلال. اجعل unique_ptr هو الافتراضي، وارتقِ إلى shared_ptr فقط عندما تحتاج فعلًا إلى أكثر من مالك.
unique_ptr للمصفوفات يحتاج صيغة المصفوفة. يعطيك make_unique<int[]>(n) مؤشر unique_ptr<int[]> يستدعي delete[] بشكل صحيح. وعمليًا، فضّل std::vector للمصفوفات الديناميكية، فهو يدير الذاكرة نيابة عنك ويمنحك تتبّع الحجم فوق ذلك.
التالي: السلاسل النصية
أصبحت إدارة الذاكرة الآن تحت سيطرتك: تمنحك المؤشرات الذكية تخصيصًا على الكومة دون التسريبات. ومن أكثر الأشياء شيوعًا التي ستخصّصها وتمرّرها هو النص، وتمنحك C++ أداة أكثر أمانًا بكثير من المخازن الخام char*. تتناول الصفحة التالية std::string: كيف ينمو من تلقاء نفسه، والعمليات التي ستستخدمها كل يوم، ولماذا يحرّرك تمامًا من العمل اليدوي مع الذاكرة.
الأسئلة الشائعة
ما هي المؤشرات الذكية في C++؟
المؤشرات الذكية كائنات من ترويسة <memory> (unique_ptr و shared_ptr و weak_ptr) تغلّف مؤشرًا خامًا وتنفّذ delete على الذاكرة تلقائيًا عند خروجها من النطاق. تمنحك تخصيصًا على الكومة دون الحاجة إلى delete اليدوي ودون التسريبات التي تنتج عن نسيانه.
ما الفرق بين unique_ptr و shared_ptr؟
unique_ptr هو المالك الوحيد لكائنه، فلا يمكن نسخه بل نقله فقط، وهو يحرّر الذاكرة في اللحظة التي يُدمّر فيها. أما shared_ptr فيسمح بالملكية المشتركة عبر عدّ المراجع: يمكن لعدة مؤشرات shared_ptr أن تشير إلى الكائن نفسه، ولا يُحرَّر الكائن إلا عند تدمير آخرها. فضّل unique_ptr ما لم تكن بحاجة فعلية إلى ملكية مشتركة.
هل أستخدم make_unique أم new في C++ الحديثة؟
استخدم make_unique و make_shared. فهما يخصّصان الكائن ويغلّفانه في خطوة واحدة، فلا يوجد new خام قد تتسرّب نتيجته قبل وصولها إلى مؤشر ذكي. وكقاعدة عامة، يجب ألا تحتوي قاعدة شيفرة C++ الحديثة على أي new أو delete عاريين تقريبًا على الإطلاق.