إدارة الذاكرة الديناميكية
درس 5 من 8
5إدارة الذاكرة الديناميكية
في C أنت المسؤول شخصيًا عن طلب الذاكرة وإعادتها؛ لا جامع قمامة (Garbage Collector) يفعل ذلك نيابة عنك كما في بايثون أو جافاسكريبت.
ماذا تشاهد في الرسم: malloc يحجز كتلة، تستخدمها، ثم free يعيدها. بعد free يبقى المؤشر معلّقًا، والكتابة عبره خطأ.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
في بايثون يأتي عامل نظافة ويجمع الأوراق التي لم تعد تحتاجها. في C لا يوجد عامل: تطلب الغرفة بنفسك حين تحتاجها، وتعيدها بنفسك حين تنتهي. لو نسيت إعادتها تمتلئ البناية تدريجيًا، ولو أعدتها ثم دخلتها مرة أخرى قد تجد فيها شخصًا آخر.
malloc وcalloc يحجزان ذاكرة على الكومة heap ويعيدان مؤشرًا (أو NULL عند الفشل)، وrealloc يغيّر الحجم، وfree يحررها. الأخطاء الكلاسيكية: التسرّب memory leak، واستخدام الذاكرة بعد تحريرها use-after-free، والتحرير المزدوج، وتجاوز الحدود.
الفكرة الجوهرية: المكدّس مقابل الكومة (Stack vs Heap)
شكل 1، متغيرات المكدّس محدودة الحجم وتُدار تلقائيًا؛ ذاكرة الكومة أكبر بكثير لكنها مسؤوليتك الكاملة من الحجز إلى التحرير.
1malloc
يطلب كتلة ذاكرة بحجم محدد بالبايت من الكومة، ويُعيد مؤشرًا لبدايتها (أو NULL إن فشل الطلب). خلف الكواليس، المُخصِّص (Allocator) يحجز مساحة إضافية صغيرة قبل المؤشر الذي تستلمه فعليًا، يخزّن فيها حجم الكتلة نفسه، هذا بالضبط كيف تعرف free لاحقًا كم بايت تُعيد للنظام دون أن تخبرها أنت بذلك مرة أخرى.
2free
يعيد تلك الكتلة للنظام ليعيد استخدامها. كل malloc ناجح يجب أن يقابله free واحد بالضبط. فعليًا، free لا "يمحو" الذاكرة أو يصفّرها، هو فقط يضيف تلك الكتلة لقائمة داخلية من "الكتل المتاحة" يحتفظ بها المُخصِّص (Allocator)، ليعيد استخدامها في طلب malloc قادم بدل طلب ذاكرة جديدة من نظام التشغيل. هذا يعني أن القيم القديمة قد تبقى فعليًا في تلك البايتات لفترة قصيرة بعد التحرير، وهذا بالضبط لماذا استخدام الذاكرة بعد تحريرها قد "يبدو يعمل" في بعض الأحيان قبل أن يُعاد تخصيصها لشيء آخر فيتغيّر محتواها فجأة.
3تسريب الذاكرة (Memory Leak)
نسيان استدعاء free لكتلة لم تعد مستخدَمة، الذاكرة تبقى محجوزة "ضائعة" حتى ينتهي البرنامج بالكامل. الخطورة الحقيقية تظهر في برنامج يعمل لفترة طويلة أو يستدعي دالة التخصيص ملايين المرات (مثل خادم يعمل بلا توقف): كل تسريب صغير يتراكم فوق الآخر، حتى تستهلك العملية كل الذاكرة المتاحة فعليًا ويبدأ النظام برفض طلبات malloc جديدة أو يتباطأ بشدة، وهذا غالبًا سبب برامج "تعمل بسلاسة لأيام ثم تنهار فجأة" في بيئة الإنتاج.
4المؤشر المتدلّي (Dangling Pointer)
استخدام مؤشر بعد تحرير الذاكرة التي كان يشير إليها بـfree، سلوك غير مُعرَّف خطير قد يبدو أنه "يعمل" أحيانًا ثم ينهار فجأة. الخطر الأكبر يظهر عند استدعاء free مرتين على نفس المؤشر (Double Free): لأن المُخصِّص يحتفظ بمعلومات داخلية (مثل حجم الكتلة ومكانها في قائمة الكتل المتاحة) مباشرة في تلك الذاكرة نفسها، فتحريرها مرتين يفسد تلك المعلومات الداخلية، وقد يسبب انهيارًا فوريًا أو، في أسوأ الحالات، ثغرة أمنية حقيقية يمكن استغلالها لتنفيذ كود غير مرغوب فيه، وهذا سبب تصنيف Double Free كأحد أخطاء الذاكرة الأكثر خطورة في C.
كيف يعمل، خطوة بخطوة: مصفوفة بحجم يتقرر وقت التشغيل
int *create_array(int size)
{
int *arr;
int i;
arr = malloc(sizeof(int) * size); /* اطلب size من الأعداد الصحيحة */
if (arr == NULL)
return (NULL); /* تحقق دائمًا من فشل malloc */
for (i = 0; i < size; i++)
arr[i] = i * i;
return (arr);
}
int main(void)
{
int *nums = create_array(5);
if (nums != NULL)
{
/* استخدم nums[0..4] هنا: 0, 1, 4, 9, 16 */
free(nums); /* حرّرها فور الانتهاء منها */
nums = NULL; /* عادة جيدة: لا يبقى مؤشرًا متدليًا */
}
return (0);
}
malloc(size) بدل malloc(sizeof(int) * size) عند حجز مصفوفة أعداد صحيحة. هذا يحجز size بايت فقط بدل size عنصرًا كاملًا (كل عنصر int عادة 4 بايت)، كتابة خارج الذاكرة المخصَّصة فعليًا.
مثال محلول: الدوال المتغيّرة الوسائط (Variadic) والمؤشرات للدوال
#include <stdarg.h>
/* تجمع عددًا غير معروف مسبقًا من الأعداد الصحيحة */
int sum_all(int count, ...)
{
va_list args;
int total = 0, i;
va_start(args, count); /* ابدأ القراءة بعد آخر وسيط معروف (count) */
for (i = 0; i < count; i++)
total += va_arg(args, int); /* اقرأ الوسيط التالي كـint في كل مرة */
va_end(args); /* أنهِ القراءة ونظّف */
return (total);
}
/* مؤشر لدالة: يسمح بتمرير سلوك كامل كوسيط لدالة أخرى */
int compare_asc(int a, int b) { return (a - b); }
int compare_desc(int a, int b) { return (b - a); }
void apply_compare(int a, int b, int (*cmp)(int, int))
{
printf("%d\n", cmp(a, b)); /* cmp قد يكون compare_asc أو compare_desc */
}
شكل 2، كلا المفهومين يبنيان مرونة فوق أساس المؤشرات: Variadic تسمح بعدد وسائط غير ثابت، ومؤشرات الدوال تسمح بتمرير "أي سلوك" كوسيط بدل قيمة عادية.
تعمّق: الحجز والملكية ومؤشرات الدوال
الذاكرة الديناميكية تمنحك القوة وتحمّلك المسؤولية معًا. نفصّل هنا لماذا نحتاجها أصلًا، وكيف تتصرف كل دالة حجز، وما الذي تعنيه ملكية الذاكرة، ثم ننتقل إلى مؤشرات الدوال والدوال متغيرة الوسائط كأدوات تجعل الشيفرة مرنة.
أولًا: التخصيص التلقائي مقابل الديناميكي
| المعيار | تلقائي (المكدس) | ديناميكي (الكومة) |
|---|---|---|
| من يحجز ويحرر | المترجم تلقائيًا | أنت بـ malloc و free |
| عمر القيمة | ينتهي بخروج الدالة | حتى تحرره أنت |
| الحجم | يجب أن يُعرف وقت الترجمة عادة | يُحدَّد وقت التشغيل |
| السعة | محدودة (نحو 8 ميغابايت) | تقاس بالذاكرة المتاحة |
| السرعة | سريع جدًا | أبطأ |
نحتاج الكومة في موقفين: حين لا نعرف الحجم إلا وقت التشغيل (عدد ما يدخله المستخدم)، وحين نريد أن تعيش القيمة بعد انتهاء الدالة التي أنشأتها. ومن الخطأ الشائع أن تعيد دالة مؤشرًا إلى متغير محلي: فهو يموت بخروج الدالة ويصبح المؤشر متدليًا.
ثانيًا: أسرة دوال الحجز
| الدالة | ما تفعله | ما يجب أن تتذكره |
|---|---|---|
malloc(n) | تحجز n بايت غير مهيّأة (قيم عشوائية) | تعيد NULL عند الفشل: افحصها دائمًا |
calloc(k, s) | تحجز k × s بايت وتصفّرها | تتحقق من تجاوز الضرب لحدود العدد |
realloc(p, n) | تغيّر حجم كتلة موجودة، وتحافظ على محتواها | قد تنقل الكتلة إلى عنوان جديد فيُعتمد على القيمة المُعادة |
free(p) | تعيد الكتلة للنظام | لا تفعل شيئًا لـ NULL، وخطر عند التحرير مرتين |
وأدق ما في realloc أنها قد تفشل، فتعيد NULL وتترك الكتلة الأصلية سليمة. لو كتبت p = realloc(p, n) مباشرة ثم فشلت، فقدت المؤشر الأصلي وتسرّبت الذاكرة. الحل: ضع النتيجة في مؤشر مؤقت أولًا وتحقق منه قبل الاستبدال.
شكل 3، نمط إعادة الحجز الآمن: لا تفقد المؤشر القديم قبل أن تتأكد من نجاح الجديد.
ثالثًا: الملكية والتحرير
في كل لحظة يجب أن يكون لكل كتلة حجزتها مالك واحد مسؤول عن تحريرها. إن نسخت المؤشر إلى مؤشر آخر فهما اسمان مستعاران (Aliases) لنفس الكتلة، فلا تحررها مرتين. ومن الأخطاء المهمة نسيان التحرير في مسارات الخروج المبكر، لأن كل return وسط الدالة مكان محتمل للتسريب. وعند الفشل الجزئي (حجزت ثلاث كتل وفشلت الرابعة) عليك تحرير الثلاث السابقة قبل الخروج. أما الدالة exit(n) فتنهي البرنامج كله فورًا برمز خروج، وتفتح لك طريقًا للتوقف عند خطأ لا يمكن التعافي منه، لكنها لا تُنفّذ عنك منطق التنظيف الذي تحتاجه.
رابعًا: مؤشرات الدوال
الدالة أيضًا تعيش في الذاكرة: تعليماتها في منطقة النص، ولها عنوان بداية. مؤشر الدالة متغير يحمل هذا العنوان، ما يتيح تمرير سلوك كاملٍ كوسيط. صيغته: int (*op)(int, int) تعني "op مؤشر إلى دالة تأخذ عددين صحيحين وتعيد عددًا صحيحًا". وفائدته أن تكتب شيفرة عامة لا تعرف التفاصيل: مثل دالة تطبّق عملية على كل عنصر في مصفوفة وتتلقى العملية نفسها كوسيط، أو جدول يربط اسم الأمر بدالته. ومكتبة C تستخدمها في qsort التي تفرز أي نوع لأن المقارنة تُمرَّر لها.
شكل 4، مؤشر الدالة يحمل عنوان بداية دالة في منطقة النص، ويمكن تبديله وقت التشغيل.
خامسًا: الدوال متغيرة الوسائط و const
الدالة متغيرة الوسائط تستقبل عددًا غير محدد من الوسائط بعد وسيط ثابت واحد على الأقل. تعتمد على ثلاثة عناصر من stdarg.h: va_start يبدأ القراءة، و va_arg يسحب الوسيط التالي بالنوع الذي تحدده، و va_end ينظّف. والدالة لا تعرف عدد وسائطها ولا أنواعها، لذلك يجب أن تُخبرها بهما بوسيلة متفق عليها: عدد صريح في وسيط ثابت، أو سلسلة تنسيق، أو قيمة حارسة تنهي القائمة. أي اختلاف بين ما وعدت به وما مررته سلوك غير معرّف.
أما const فهو وعد للمترجم بأنك لن تعدّل القيمة عبر هذا الاسم. فـconst char *s تعني أن النص الذي يشير إليه s للقراءة فقط، بينما char *const s تعني أن المؤشر نفسه لا يتغير لكن النص قابل للتعديل. والقاعدة: اقرأ التصريح من اليمين إلى اليسار. استعماله في وسائط الدوال يوضح لقارئ الشيفرة أي وسيط تقرؤه الدالة فقط وأيها قد تغيره.
جرّب بنفسك
ماذا يحدث إن لم تستدعِ free على ذاكرة لم تعد تحتاجها؟
كل malloc ناجح يجب أن يقابله free واحد. التسريع يتراكم في البرنامج الذي يعمل طويلًا.
ما «المؤشر المتدلّي» (Dangling Pointer)؟
سلوك غير معرّف: قد «يعمل» أحيانًا ثم ينهار. القاعدة: اجعل المؤشر NULL بعد free.
متى يُعيد malloc القيمة NULL؟
فحص الناتج إلزامي قبل الاستخدام: استعمال NULL يؤدي إلى انهيار.
بطاقات مراجعة سريعة
realloc(p, n) مباشرة؟تحقق من فهمك
لماذا نضع nums = NULL بعد free(nums) في المثال أعلاه رغم أن الذاكرة تحررت فعلًا؟
لمنع استخدام nums بالخطأ لاحقًا كمؤشر متدلٍ يشير لذاكرة محرَّرة، قراءة أو كتابة عبر مؤشر NULL تسبب انهيارًا فوريًا وواضحًا بدل سلوك غير مُعرَّف صامت وخطير.
ماذا يحدث لو استدعيت free على نفس المؤشر مرتين؟
سلوك غير مُعرَّف يُسمى Double Free، قد يُفسد بنية إدارة الذاكرة الداخلية للبرنامج ويسبب انهيارًا لاحقًا يصعب ربطه بسببه الحقيقي.
دالة تعيد عنوان مصفوفة محلية، ما المشكلة؟
المصفوفة تموت بخروج الدالة فيصبح المؤشر متدليًا.
حجزت ثلاث كتل ففشلت الرابعة، ماذا تفعل قبل الخروج؟
تحرر الكتل الثلاث التي نجحت، ثم تعالج الفشل.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي2
مقالات1
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.