المؤشرات والمصفوفات والسلاسل النصية
درس 4 من 8
4المؤشرات والمصفوفات والسلاسل النصية
المؤشرات (Pointers) هي المفهوم الذي يميّز C فعليًا: بدل التعامل مع القيم فقط، تتعامل مباشرة مع عناوين مكانها في الذاكرة.
ماذا تشاهد في الرسم: x تسكن العنوان 0x1000، والمؤشر p يحفظ هذا العنوان، فالسطر *p = 7 يكتب في خانة x مباشرة.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
الذاكرة شارع طويل من البيوت، ولكل بيت رقم. المتغير العادي هو ما في داخل البيت، أما المؤشر فورقة مكتوب عليها رقم البيت. بالورقة تستطيع أن تذهب إلى البيت وتغيّر ما فيه، أو تنتقل إلى البيت المجاور. والمصفوفة صفّ بيوت متلاصقة.
المؤشر int *p يحمل عنوانًا، و&x يعطي عنوان المتغير و*p يصل إلى القيمة. المصفوفة كتلة متصلة واسمها يتحول إلى مؤشر لأول عنصر، وp + 1 يتقدم بحجم النوع. والسلسلة النصية في C مصفوفة char تنتهي بالحرف \0.
قبل أن تبدأ
⚙️أساسيات C
تحتاج راحة تامة مع المتغيرات والدوال من الجزء الرابع قبل هذا الجزء.
الفكرة الجوهرية: المؤشر كعنوان
شكل 1، المؤشر ليس القيمة نفسها؛ هو متغيّر يخزّن عنوان مكان تلك القيمة في الذاكرة.
1& و*
&x تعطيك عنوان المتغيّر x. *ptr "تفكّ الإشارة" (Dereference) وتصل للقيمة الموجودة في العنوان الذي يخزّنه ptr. ميكانيكيًا، *ptr يترجمها المُصرِّف لتعليمة معالج واحدة من نوع "اقرأ من الذاكرة في هذا العنوان"، بلا أي بحث أو فهرسة إضافية؛ وهذا جزء من سبب سرعة C: الوصول عبر المؤشر عملية مباشرة بقدر ما يسمح به العتاد نفسه.
أمسك مؤشرًا واحدًا أولًا
قبل المصفوفات والسلاسل، برنامج من عشرة أسطر فيه كل الفكرة: متغيّر، وعنوانه، وتغييره عبر العنوان.
#include <stdio.h>
int main(void)
{
int x;
int *p;
x = 5;
p = &x;
printf("x = %d\n", x);
printf("*p = %d\n", *p);
*p = 9;
printf("x = %d\n", x);
return (0);
}
p = &x خزّن عنوان x في p. و*p يعني «القيمة الموجودة في ذلك العنوان»، فطُبعت 5. ثم *p = 9 كتب في العنوان نفسه، فتغيّر x دون أن نلمسه بالاسم. إن فهمت هذه الأسطر الثلاثة، فهمت المؤشرات؛ الباقي تطبيقات.
2المصفوفة = مؤشر لأول عنصر
اسم المصفوفة arr يتصرّف عمليًا كعنوان أول عنصر فيها؛ arr[i] هو اختصار حرفي لـ*(arr + i). والمهم هنا أن "+1" على مؤشر لا يعني "أضف بايتًا واحدًا"، المُصرِّف يضرب الرقم في sizeof نوع العنصر تلقائيًا؛ فـarr + 1 على مصفوفة int يتقدّم 4 بايتات كاملة (حجم int عادة)، لا بايت واحد، لأنه يجب أن يقفز للعنصر التالي كاملًا لا لمنتصفه. هذا بالضبط ما يجعل arr[i] يعمل بشكل صحيح بغض النظر عن نوع عناصر المصفوفة.
3السلاسل النصية = مصفوفة أحرف منتهية بـ\0
لا يوجد نوع "نص" مستقل في C، السلسلة النصية مجرد مصفوفة char تنتهي بحرف خاص قيمته صفر (Null Terminator) يخبر الدوال أين تتوقف عن القراءة. لماذا هذا التصميم بالذات؟ لأن C لا تخزّن طول السلسلة في مكان منفصل كما تفعل لغات أخرى (Pascal مثلًا تخزّن الطول قبل المحتوى)، الاعتماد فقط على "استمر بالقراءة حتى الصفر" يوفّر بايتات الذاكرة المستخدَمة لتخزين الطول، لكنه يكلّفك مقابلًا: حساب طول سلسلة (مثل ما تفعله strlen) يحتاج المرور على كل حرف حرفًا بحرف حتى يصادف \0، بزمن O(n)، بخلاف لغة تخزّن الطول مسبقًا وتقرؤه مباشرة بزمن O(1).
4Struct و typedef
struct يجمع عدة قيم من أنواع مختلفة تحت اسم واحد؛ typedef يعطي ذلك النوع المُركَّب اسمًا مختصرًا لاستخدامه بسهولة لاحقًا. في الذاكرة، حقول struct تُخزَّن متجاورة بالترتيب الذي أعلنتها به تقريبًا (مع احتمال حشو صغير بين الحقول يسمى Padding، تضيفه C لمحاذاة كل حقل على عنوان يناسب نوعه ويسرّع وصول المعالج إليه)، وهذا يختلف جوهريًا عن typedef الذي لا يغيّر أي بايت واحد في الذاكرة، هو فقط اسم مستعار يفهمه المُصرِّف وقت التصريف ثم يختفي تمامًا من البرنامج النهائي.
مثال مباشر: نحتاج تمثيل نقطة بإحداثيَين x و y معًا كوحدة واحدة بدل متغيّرين منفصلين تتنقّل بينهما يدويًا:
struct point_s
{
int x;
int y;
};
typedef struct point_s point_t; /* الآن point_t اسم مختصر لـstruct point_s */
point_t p;
p.x = 3;
p.y = 7; /* الوصول لكل حقل عبر النقطة (.) */
شكل 2، بدون typedef كنت ستكتب struct point_s p; في كل مرة: typedef فقط يوفّر عليك تكرار كلمة struct، ولا يضيف أي سلوك جديد للبرنامج.
\0 النهائي عند بناء سلسلة نصية يدويًا. أي دالة تتعامل مع السلاسل (مثل printf("%s")) تستمر بالقراءة خارج حدود المصفوفة المخصَّصة فعليًا حتى تصادف بايت صفر عشوائيًا في الذاكرة، سلوك غير مُعرَّف (Undefined Behavior) خطير.
كيف يعمل، خطوة بخطوة: تبديل قيمتين عبر المؤشرات
void swap(int *a, int *b)
{
int temp;
temp = *a; /* احفظ القيمة التي يشير لها a */
*a = *b; /* ضع في مكان a القيمة التي يشير لها b */
*b = temp; /* ضع في مكان b القيمة المحفوظة أصلًا */
}
int main(void)
{
int x = 5, y = 10;
swap(&x, &y); /* نمرّر عنوانَي x و y، لا قيمتيهما */
printf("x=%d y=%d\n", x, y); /* x=10 y=5 */
return (0);
}
شكل 3، لو مرّرنا x و y مباشرة بدل عنوانيهما، ستُنسخ قيمتاهما محليًا داخل الدالة ولن يتغيّر أي شيء في main: هذا بالضبط سبب وجود المؤشرات: تمرير القدرة على التعديل، لا مجرد نسخة من القيمة.
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| المصفوفة والمؤشر نفس الشيء تمامًا دائمًا. | يتصرّفان بنفس الطريقة في أغلب السياقات، لكن sizeof(arr) يعطي حجم المصفوفة الكامل بينما sizeof(ptr) يعطي حجم المؤشر نفسه فقط (عادة 8 بايت). |
| المؤشرات مفهوم متقدّم يمكن تأجيله لاحقًا. | هي أساس كل شيء لاحق في C: تمرير البيانات الكبيرة بكفاءة، بناء هياكل بيانات، وإدارة الذاكرة الديناميكية كلها مبنية على فهم المؤشرات. |
تعمّق: ذاكرة العملية وحساب المؤشرات و argv
هنا نغوص في الفكرة التي تحكم باقي المسار: العنوان. نرى أين تقع الأشياء في ذاكرة العملية، وكيف تُخزَّن المصفوفات ثنائية الأبعاد، وما معنى مؤشر إلى مؤشر، وما الذي تحمله argv، وما أمان دوال السلاسل النصية.
أولًا: تخطيط ذاكرة العملية
عندما تعمل عمليتك يقسّم النظام ذاكرتها إلى مناطق لكل منها غرض. النص (Text) يحمل تعليمات البرنامج للقراءة فقط. البيانات (Data) تحمل المتغيرات العامة والثابتة المُهيّأة، وBSS غير المُهيّأة. الكومة (Heap) منطقة تنمو للأعلى لما تحجزه بـ malloc. المكدس (Stack) ينمو للأسفل لمتغيرات الدوال المحلية ووسائطها وعناوين العودة، فيُنشأ إطار عند كل نداء ويُمحى عند الرجوع. والنصوص الحرفية مثل "Hello" تُحفظ في منطقة للقراءة فقط، ولذلك تعديلها يسبب انهيارًا.
شكل 4، تخطيط ذاكرة العملية: المكدس والكومة ينموان باتجاهين متعاكسين.
ثانيًا: حساب المؤشرات
جمع عدد صحيح n إلى مؤشر لا يضيف n بايت بل n × sizeof(النوع). فإذا كان p مؤشرًا إلى int (4 بايت) فـp + 1 يقفز 4 بايتات إلى العنصر التالي. ومن هنا العلاقة بين المصفوفة والمؤشر: التعبير a[i] معرَّف بأنه *(a + i). والمصفوفة ليست مؤشرًا (sizeof تعطي حجم المصفوفة كلها)، لكنها تتحول إلى مؤشر لأول عنصر عندما تُمرَّر إلى دالة، ولذلك لا تعرف الدالة طولها وعليك تمريره.
شكل 5، مصفوفة int: كل خطوة مؤشر تقفز بمقدار حجم النوع (4 بايت هنا).
ثالثًا: المصفوفة ثنائية الأبعاد في الذاكرة
الذاكرة خطية، فكيف تُخزَّن جدولًا؟ تضع C الصفوف صفًا بعد صف متجاورة (ترتيب الصف أولًا). فالمصفوفة int m[2][3] هي ستة أعداد متتالية، والعنصر m[i][j] يقع بعد i × 3 + j عنصرًا من البداية. ولهذا يجب أن يُعرف عدد الأعمدة عند تمرير مصفوفة ثنائية لدالة، لأنه ما يحسب به المترجم الموضع.
| الفهرس | m[0][0] | m[0][1] | m[0][2] | m[1][0] | m[1][1] | m[1][2] |
|---|---|---|---|---|---|---|
| الإزاحة | 0 | 1 | 2 | 3 | 4 | 5 |
رابعًا: مؤشر إلى مؤشر و argv
المؤشر متغير يحمل عنوانًا، ولذلك له عنوانه الخاص، ويمكن لمؤشر آخر أن يحمل هذا العنوان: هذا مؤشر إلى مؤشر (int **). فائدته الأولى أن تعدّل من داخل دالة المؤشر نفسه وليس ما يشير إليه، لأن الدالة تأخذ نسخة. وفائدته الثانية تمثيل مصفوفة من السلاسل النصية. وبالضبط هذا ما تُمرّره النواة إلى برنامجك: argv مصفوفة من المؤشرات إلى سلاسل نصية (char **argv) تنتهي بمؤشر NULL، و argc عدد عناصرها. العنصر argv[0] هو اسم البرنامج نفسه، لذلك فالوسائط الفعلية تبدأ من argv[1].
شكل 6، استدعاء ./prog hello 42: argc تساوي 3 و argv مصفوفة مؤشرات تنتهي بـ NULL.
وكل وسيط يصلك نصًا حتى لو كتبت رقمًا، فالقيمة "42" سلسلة من حرفين، وتحتاج تحويلها إلى عدد قبل الحساب. وإن كانت الدالة لا تستعمل argc أو argv فاسمح للمترجم بعلامة (void)argc أو __attribute__((unused)) حتى لا يحذّر من متغير غير مستخدم.
خامسًا: السلاسل النصية: ما يضمنه الصفر النهائي
السلسلة مصفوفة أحرف تنتهي بالحرف الصفري \0، وكل دوال المكتبة تعتمد عليه لمعرفة النهاية. لهذا فإن strlen تعدّ حتى الصفر ولا تحسبه، وحجز المصفوفة يحتاج حرفًا زائدًا له. المشكلة في الدوال التي تنسخ دون معرفة حجم الوجهة: strcpy و strcat تكتبان حتى تجدا الصفر في المصدر حتى لو تجاوزتا الوجهة، وهذا تجاوز مخزن (Buffer Overflow). النسخ المقيّد strncpy لا يضيف صفرًا نهائيًا إذا وصل الحد، فيحتاج حذرًا. القاعدة: اعرف دائمًا حجم الوجهة، وتأكد أن النص ينتهي بصفر.
الاعتقاد بأن sizeof(s) تعطي طول سلسلة وصلت إلى الدالة كمؤشر. داخل الدالة تعطي حجم المؤشر (8 بايت)، لا عدد الأحرف؛ استعمل strlen أو مرّر الطول.
جرّب بنفسك
ماذا يفعل *ptr؟
& يعطي عنوان المتغيّر، و* يفكّ الإشارة فيصل للقيمة.
arr[i] في C يكافئ:
arr[i] اختصار حرفي لـ*(arr + i). وإضافة 1 لمؤشر تتقدّم بحجم النوع لا ببايت واحد.
ما الذي يجعل مصفوفة char «سلسلة نصية» في C؟
لا يوجد نوع نص مستقل؛ السلسلة مصفوفة char تنتهي بـ\0.
بطاقات مراجعة سريعة
arr[i] يكافئ أي تعبير مؤشري؟sizeof(int) أي 4 بايت غالبًا.m[i][j] عند الإزاحة i×cols+j.argv[0]؟تحقق من فهمك
لو نسيت & عند استدعاء swap(x, y) بدل swap(&x, &y)، ماذا يحدث؟
خطأ تصريف على الأرجح (لأن الدالة تتوقع مؤشرات int* لا قيم int)، أو في أسوأ الحالات سلوك غير مُعرَّف، والأهم: لن يتبدّل شيء فعليًا لأن الدالة ستعمل على نسخ محلية لا على x و y الأصليتين.
هل typedef يُنشئ نوعًا جديدًا فعليًا أم اسمًا بديلًا فقط؟
اسمًا بديلًا فقط (Alias)، لا يغيّر شيئًا في البنية الفعلية للبيانات، فقط يسهّل قراءة الكود واختصار كتابة struct المطوَّل في كل مرة.
ما موضع m[1][2] في مصفوفة int m[2][3]؟
1×3+2 = 5، أي آخر عنصر.
لماذا يكسر الكود ./prog 42 إن عاملت argv[1] كرقم مباشرة؟
لأنه نص "42" لا عدد، ويلزم تحويله أولًا.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي1
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.