الغوص في الأنظمة › 2. غوص أعمق في برمجة C 3 / 18 السابق التالي

2. غوص أعمق في البرمجة بلغة C

بعد أن غطّينا كثيراً من أساسيات البرمجة بلغة C في الفصل السابق، نغوص الآن أعمق في تفاصيل C. نعود في هذا الفصل إلى كثير من موضوعات الفصل السابق، مثل المصفوفات والسلاسل النصية والبنى، ونناقشها بمزيد من التفصيل. كما نقدّم متغيّرات المؤشّرات في C والتخصيص الديناميكي للذاكرة. وتوفّر المؤشّرات (pointers) مستوى من الإحالة غير المباشرة إلى حالة البرنامج، ويتيح التخصيص الديناميكي للذاكرة (dynamic memory allocation) للبرنامج التكيّف مع تغيّرات الحجم واحتياجات المساحة أثناء تشغيله، فيخصّص مساحة أكبر عند الحاجة ويحرّر المساحة التي لم يعد يحتاجها. وبفهم مبرمج C كيفية استخدام متغيّرات المؤشّرات والتخصيص الديناميكي للذاكرة ومتى يفعل ذلك، يستطيع تصميم برامج قوية وفعّالة معاً.

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

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

/* An example C program with local and global variables */
#include <stdio.h>

int max(int n1, int n2); /* function prototypes */
int change(int amt);

int g_x;  /* global variable: declared outside function bodies */

int main(void) {
    int x, result;   /* local variables: declared inside function bodies */

    printf("Enter a value: ");
    scanf("%d", &x);
    g_x = 10;       /* global variables can be accessed in any function */

    result = max(g_x, x);
    printf("%d is the largest of %d and %d\n", result, g_x, x);

    result = change(10);
    printf("g_x's value was %d and now is %d\n", result, g_x);

    return 0;
}

int max(int n1, int n2) {  /* function with two parameters */
    int val;    /* local variable */

    val = n1;
    if ( n2 > n1 ) {
        val = n2;
    }
    return val;
}

int change(int amt) {
    int val;

    val = g_x;  /* global variables can be accessed in any function */
    g_x += amt;
    return val;
}

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

يؤدي الإعلان عن متغيّر خارج أي جسم دالة إلى إنشاء متغيّر عام (global variable). وتبقى المتغيّرات العامة في النطاق دائماً ويمكن لأي شيفرة في البرنامج استخدامها لأنها مرتبطة دائماً بموقع ذاكرة محدد واحد. ويجب أن يكون لكل متغيّر عام اسم فريد — إذ يحدد اسمه بشكل فريد موقع تخزين محدداً في ذاكرة البرنامج طوال مدة البرنامج.

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

ولأن الوسائط والمتغيّرات المحلية في النطاق فقط داخل الدالة المعرَّفة فيها، يمكن لدوال مختلفة استخدام الأسماء نفسها للمتغيّرات المحلية والوسائط. فمثلاً، تحتوي كل من الدالتين change وmax على متغيّر محلي باسم val. وعندما تشير شيفرة في الدالة max إلى val فإنها تشير إلى متغيّرها المحلي val لا إلى المتغيّر المحلي val الخاص بالدالة change (وهو ليس في النطاق داخل الدالة max).

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

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

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

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

يُحجز أعلى ذاكرة البرنامج لاستخدام نظام التشغيل، أما الأجزاء المتبقية فهي قابلة للاستخدام من البرنامج قيد التشغيل. وتُخزَّن تعليمات البرنامج في قسم الشيفرة (code) من الذاكرة. فمثلاً، يخزّن البرنامج المذكور أعلاه تعليمات الدوال main وmax وchange في هذه المنطقة من الذاكرة.

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

تُخزَّن المتغيّرات العامة في قسم البيانات (data). وخلافاً للمكدّس، لا تنمو منطقة البيانات ولا تتقلص — إذ تبقى مساحة تخزين المتغيّرات العامة طوال تشغيل البرنامج.

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

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

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

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

2.2.1. متغيّرات المؤشّرات

يخزّن متغيّر المؤشّر (pointer variable) عنوان موقع ذاكرة يمكن تخزين قيمة من نوع محدد فيه. فمثلاً، يمكن لمتغيّر مؤشّر تخزين قيمة عنوان int تُخزَّن فيه القيمة الصحيحة 12. ويشير متغيّر المؤشّر إلى القيمة (يرتبط بها). ويوفّر المؤشّر مستوى من الإحالة غير المباشرة للوصول إلى القيم المخزّنة في الذاكرة. ويوضّح الشكل 1 مثالاً على ما قد يبدو عليه متغيّر مؤشّر في الذاكرة:

مؤشّر اسمه "ptr" يشير إلى موقع في الذاكرة يخزّن القيمة الصحيحة 12. الشكل 1. يخزّن متغيّر المؤشّر عنوان موقع في الذاكرة. وهنا يخزّن المؤشّر عنوان متغيّر صحيح يحمل العدد 12.

من خلال متغيّر المؤشّر ptr، يمكن الوصول بشكل غير مباشر إلى القيمة (12) المخزّنة في موقع الذاكرة الذي يشير إليه. وأكثر ما تستخدم برامج C متغيّرات المؤشّرات فيه:

  1. وسائط «التمرير بالمؤشّر» (pass by pointer)، لكتابة دوال تستطيع تعديل قيمة وسيطتها عبر وسيط مؤشّر
  2. التخصيص الديناميكي للذاكرة، لكتابة برامج تخصّص (وتحرّر) مساحة أثناء تشغيل البرنامج. ويُستخدم التخصيص الديناميكي للذاكرة عادةً لتخصيص المصفوفات ديناميكياً. وهو مفيد عندما لا يعرف المبرمج حجم بنية البيانات وقت التصريف (مثلاً، يعتمد حجم المصفوفة على إدخال المستخدم وقت التشغيل). كما يتيح تغيير حجم بنى البيانات أثناء تشغيل البرنامج.

قواعد استخدام متغيّرات المؤشّرات

تشبه قواعد استخدام متغيّرات المؤشّرات قواعد المتغيّرات العادية، إلا أنك تحتاج إلى التفكير في نوعين: نوع متغيّر المؤشّر، والنوع المخزّن في عنوان الذاكرة الذي يشير إليه متغيّر المؤشّر.

أولاً، أعلن عن متغيّر مؤشّر باستخدام type_name *var_name:

int *ptr;   // stores the memory address of an int (ptr "points to" an int)
char *cptr; // stores the memory address of a char (cptr "points to" a char)

ملاحظة — أنواع المؤشّرات

لاحظ أنه رغم أن ptr وcptr كلاهما مؤشّران، فإنهما يشيران إلى نوعين مختلفين:

  • نوع ptr هو «مؤشّر إلى int» (int *). ويمكنه الإشارة إلى موقع ذاكرة يخزّن قيمة int.
  • نوع cptr هو «مؤشّر إلى char» (char *). ويمكنه الإشارة إلى موقع ذاكرة يخزّن قيمة char.

بعد ذلك، هيّئ متغيّر المؤشّر (اجعله يشير إلى شيء ما). وتخزّن متغيّرات المؤشّرات قيم عناوين. وينبغي تهيئة المؤشّر ليخزّن عنوان موقع ذاكرة يطابق نوعه النوعَ الذي يشير إليه متغيّر المؤشّر. وإحدى طرق تهيئة المؤشّر استخدام معامل العنوان (address operator) (&) مع متغيّر للحصول على قيمة عنوان المتغيّر:

int x;
char ch;

ptr = &x;    // ptr gets the address of x, pointer "points to" x
cptr = &ch;  // cptr gets the address of ch, pointer "points to" ch

تهيئة ptr على عنوان x وcptr على عنوان ch (لكي يشيرا إلى x وch على التوالي). الشكل 2. يمكن للبرنامج تهيئة مؤشّر بإسناد عنوان متغيّر موجود من النوع المناسب إليه.

وفيما يلي مثال على تهيئة مؤشّر غير صالحة بسبب عدم تطابق الأنواع:

cptr = &x;   // ERROR: cptr can hold a char memory location
             // (&x is the address of an int)

ورغم أن مصرّف C قد يسمح بهذا النوع من الإسناد (مع تحذير بشأن أنواع غير متوافقة)، فإن سلوك الوصول إلى x وتعديله عبر cptr لن يكون على الأرجح كما يتوقع المبرمج. وبدلاً من ذلك، ينبغي للمبرمج استخدام متغيّر int * للإشارة إلى موقع تخزين int.

ويمكن أيضاً إسناد قيمة خاصة، هي NULL، إلى جميع متغيّرات المؤشّرات، وهي تمثّل عنواناً غير صالح. ورغم أنه لا ينبغي استخدام المؤشّر الفارغ (null pointer) (أي الذي قيمته NULL) أبداً للوصول إلى الذاكرة، فإن القيمة NULL مفيدة لاختبار متغيّر مؤشّر لمعرفة ما إذا كان يشير إلى عنوان ذاكرة صالح. أي أن مبرمجي C كثيراً ما يفحصون مؤشّراً للتأكد من أن قيمته ليست NULL قبل محاولة الوصول إلى موقع الذاكرة الذي يشير إليه. ولضبط مؤشّر على NULL:

ptr = NULL;
cptr = NULL;

تهيئة ptr وcptr على NULL. الشكل 3. يمكن إعطاء أي مؤشّر القيمة الخاصة NULL، التي تشير إلى أنه لا يشير إلى أي عنوان بعينه. ولا ينبغي أبداً إلغاء الإشارة إلى المؤشّرات الفارغة.

وأخيراً، استخدم متغيّر المؤشّر: يتبع معامل إلغاء الإشارة (dereference operator) (*) متغيّر مؤشّر إلى موقع الذاكرة الذي يشير إليه ويصل إلى القيمة في ذلك الموقع:

/* Assuming an integer named x has already been declared, this code sets the
   value of x to 8. */

ptr = &x;   /* initialize ptr to the address of x (ptr points to variable x) */
*ptr = 8;   /* the memory location ptr points to is assigned 8 */

إلغاء الإشارة إلى ptr للوصول إلى الذاكرة التي يشير إليها (وهي x التي قيمتها 8). الشكل 4. يؤدي إلغاء الإشارة إلى مؤشّر إلى الوصول إلى القيمة التي يشير إليها المؤشّر.

أمثلة على المؤشّرات

وفيما يلي تسلسل مثالي من عبارات C يستخدم متغيّري مؤشّر:

int *ptr1, *ptr2, x, y;

x = 8;
ptr2 = &x;     // ptr2 is assigned the address of x
ptr1 = NULL;

نهيئة x على 8، وptr2 على عنوان x، وتحصل ptr1 على NULL.

*ptr2 = 10;     // the memory location ptr2 points to is assigned 10
y = *ptr2 + 3;  // y is assigned what ptr2 points to plus 3

إلغاء الإشارة إلى ptr2 لتغيير x إلى 10 وإسناد 13 إلى y.

ptr1 = ptr2;   // ptr1 gets the address value stored in ptr2 (both point to x)

إسناد قيمة ptr2 إلى ptr1 (يشيران الآن كلاهما إلى الموقع نفسه).

*ptr1 = 100;

إلغاء الإشارة إلى ptr1 وإسناد 100 إلى القيمة التي يشير إليها. ملاحظة: يغيّر هذا الإسناد القيمة التي يشير إليها كل من ptr1 وptr2، إذ يشيران كلاهما إلى الموقع نفسه.

ptr1 = &y;     // change ptr1's value (change what it points to)
*ptr1 = 80;

إعادة تعيين ptr1 ليشير إلى عنوان y ثم إلغاء الإشارة إليه لتغيير قيمة y إلى 80.

عند استخدام متغيّرات المؤشّرات، تأمّل بعناية أنواع المتغيّرات ذات الصلة. ويمكن أن يساعد رسم صور للذاكرة (مثل المعروضة أعلاه) في فهم ما تفعله شيفرة المؤشّرات. وتشمل بعض الأخطاء الشائعة إساءة استخدام معامل إلغاء الإشارة (*) أو معامل العنوان (&). فمثلاً:

ptr = 20;       // ERROR?:  this assigns ptr to point to address 20
ptr = &x;
*ptr = 20;      // CORRECT: this assigns 20 to the memory pointed to by ptr

وإذا ألغى برنامجك الإشارة إلى متغيّر مؤشّر لا يحتوي عنواناً صالحاً، فسينهار البرنامج:

ptr = NULL;
*ptr = 6;    // CRASH! program crashes with a segfault (a memory fault)

ptr = 20;
*ptr = 6;    // CRASH! segfault (20 is not a valid address)

ptr = x;
*ptr = 6;   // likely CRASH or may set some memory location with 6
            // (depends on the value of x which is used as an address value)

ptr = &x;   // This is probably what the programmer intended
*ptr = 6;

وتوضح هذه الأنواع من الأخطاء أحد أسباب تهيئة متغيّرات المؤشّرات على NULL؛ إذ يمكن للبرنامج حينها اختبار قيمة المؤشّر لمعرفة ما إذا كانت NULL قبل إلغاء الإشارة إليه:

if (ptr != NULL) {
    *ptr = 6;
}

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

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

ملاحظة — التمرير بالقيمة

تُمرَّر جميع الوسائط في C بالقيمة وتتبع دلالات التمرير بالقيمة: يحصل الوسيط على نسخة من قيمة وسيطته، ولا يؤدي تعديل قيمة الوسيط إلى تغيير قيمة وسيطته. وعند تمرير قيم الأنواع الأساسية، مثل قيمة متغيّر int، يحصل وسيط الدالة على نسخة من قيمة وسيطته (قيمة int المحددة)، ولا يمكن لتغيير القيمة المخزّنة في الوسيط أن يغيّر القيمة المخزّنة في وسيطته.

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

وفيما يلي خطوات تنفيذ دالة ذات وسيط تمرير بالمؤشّر واستدعائها، مع مقتطفات شيفرة مثالية توضح كل خطوة:

أعلن عن وسيط الدالة كمؤشّر إلى نوع المتغيّر:

/* input: an int pointer that stores the address of a memory
 *        location that can store an int value (it points to an int)
 */
int change_value(int *input) {

عند استدعاء الدالة، مرّر عنوان متغيّر كوسيط:

int x;
change_value(&x);

في المثال السابق، بما أن نوع الوسيط هو int *، يجب أن يكون العنوان الممرَّر عنوان متغيّر int.

وفي جسم الدالة، ألغِ الإشارة إلى وسيط المؤشّر لتغيير قيمة الوسيطة:

*input = 100;  // the location input points to (x's memory) is assigned 100

بعد ذلك، لنفحص برنامجاً مثالياً أكبر:

#include <stdio.h>

int change_value(int *input);

int main(void) {
    int x;
    int y;

    x = 30;
    y = change_value(&x);
    printf("x: %d y: %d\n", x, y);  // prints x: 100 y: 30

    return 0;
}

/*
 * changes the value of the argument
 *     input: a pointer to the value to change
 *     returns: the original value of the argument
 */
int change_value(int *input) {
    int val;

    val = *input; /* val gets the value input points to */

    if (val < 100) {
        *input = 100;  /* the value input points to gets 100 */
    } else {
        *input =  val * 2;
    }

    return val;
}

وعند التشغيل يكون الناتج:

x: 100 y: 30

يعرض الشكل 1 كيف يبدو مكدّس الاستدعاء قبل تنفيذ الإرجاع في change_value.

يُخزّن معامل الإدخال في change_value عنوان المتغيّر 'x' في main. الشكل 1. لقطة لمكدّس الاستدعاء قبل العودة من change_value.

يحصل وسيط الإدخال على نسخة من قيمة وسيطته (عنوان x). وقيمة x هي 30 عند إجراء استدعاء الدالة. وداخل الدالة change_value، يُلغى الإشارة إلى الوسيط لإسناد القيمة 100 إلى موقع الذاكرة الذي يشير إليه الوسيط (*input = 100;، أي «الموقع الذي يشير إليه input يحصل على القيمة 100»). ولأن الوسيط يخزّن عنوان متغيّر محلي في إطار مكدّس الدالة main، يمكن تغيير القيمة المخزّنة في المتغيّر المحلي للمستدعي من خلال إلغاء الإشارة إلى الوسيط. وعند عودة الدالة، تعكس قيمة الوسيطة التغيير الذي أُجري عليها عبر وسيط المؤشّر (تغيّرت قيمة x في main إلى 100 بفعل الدالة change_value عبر وسيطها input).

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

يمنح التخصيص الديناميكي للذاكرة مرونة للبرامج التي:

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

2.4.1. ذاكرة الكومة

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

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

يوضّح الشكل 1 أجزاء ذاكرة برنامج قيد التشغيل مع مثال على متغيّر مؤشّر (ptr) على المكدّس يخزّن عنوان ذاكرة كومة مخصَّصة ديناميكياً (أي يشير إلى ذاكرة الكومة).

أجزاء ذاكرة البرنامج التي تُظهر متغيّراً على المكدّس يشير إلى ذاكرة كومة مخصَّصة ديناميكياً. الشكل 1. مؤشّر على المكدّس يشير إلى كتلة ذاكرة خُصِّصت من الكومة.

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

2.4.2. malloc وfree

malloc وfree دالتان في مكتبة C القياسية (stdlib) يمكن للبرنامج استدعاؤهما لتخصيص الذاكرة في الكومة وتحريرها. ويجب على برنامج C تخصيص ذاكرة الكومة صراحةً (بـ malloc) وتحريرها (بـ free).

لتخصيص ذاكرة كومة، استدعِ malloc ومرّر العدد الكلي لبايتات ذاكرة الكومة المتجاورة المطلوب تخصيصها. واستخدم معامل sizeof لحساب عدد البايتات المطلوب طلبه. فمثلاً، لتخصيص مساحة على الكومة لتخزين عدد صحيح واحد، يمكن للبرنامج استدعاء:

// Determine the size of an integer and allocate that much heap space.
malloc(sizeof(int));

تُرجع الدالة malloc عنوان أساس ذاكرة الكومة المخصَّصة إلى المستدعي (أو NULL إذا حدث خطأ). وفيما يلي برنامج مثال كامل يتضمن استدعاءً لـ malloc لتخصيص مساحة كومة لتخزين قيمة int واحدة:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p;

    p = malloc(sizeof(int));  // allocate heap memory for storing an int

    if (p != NULL) {
        *p = 6;   // the heap memory p points to gets the value 6
    }
}

تُرجع الدالة malloc نوعاً هو void *، ويمثّل مؤشّراً عاماً إلى نوع غير محدد (أو إلى أي نوع). وعندما يستدعي برنامج malloc ويسند الناتج إلى متغيّر مؤشّر، يربط البرنامج الذاكرة المخصَّصة بنوع متغيّر المؤشّر.

قد ترى أحياناً استدعاءات لـ malloc تعيد صراحةً تحويل نوعها المرجَع من void * ليطابق نوع متغيّر المؤشّر. فمثلاً:

p = (int *) malloc(sizeof(int));

تخبر (int *) قبل malloc المصرّفَ بأن نوع void * الذي ترجعه malloc سيُستخدم كـ int * في هذا الاستدعاء (أي تعيد تحويل نوع إرجاع malloc إلى int *). ونناقش إعادة تحويل الأنواع ونوع void * بمزيد من التفصيل لاحقاً في هذا الفصل.

يفشل الاستدعاء malloc إذا لم توجد ذاكرة كومة حرة كافية لتلبية عدد البايتات المطلوب تخصيصها. وعادةً ما يشير فشل malloc إلى خطأ في البرنامج مثل تمرير طلب كبير جداً إلى malloc، أو تمرير عدد بايتات سالب، أو استدعاء malloc في حلقة لا نهائية ونفاد ذاكرة الكومة. ولأن أي استدعاء لـ malloc قد يفشل، ينبغي أن تختبر دائماً قيمته المرجَعة بحثاً عن NULL (ما يشير إلى فشل malloc) قبل إلغاء الإشارة إلى قيمة المؤشّر. فإلغاء الإشارة إلى مؤشّر NULL سيجعل برنامجك ينهار! فمثلاً:

int *p;

p = malloc(sizeof(int));
if (p == NULL) {
    printf("Bad malloc error\n");
    exit(1);   // exit the program and indicate error
}
*p = 6;

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

free(p);
p = NULL;

2.4.3. المصفوفات والسلاسل النصية المخصَّصة ديناميكياً

كثيراً ما يخصّص مبرمجو C الذاكرة ديناميكياً لتخزين المصفوفات. ويخصّص الاستدعاء الناجح لـ malloc قطعة واحدة متجاورة من ذاكرة الكومة بالحجم المطلوب. ويرجع عنوان بداية قطعة الذاكرة هذه إلى المستدعي، ما يجعل قيمة العنوان المرجَعة مناسبة كعنوان أساس لمصفوفة مخصَّصة ديناميكياً في ذاكرة الكومة.

لتخصيص مساحة ديناميكياً لمصفوفة من العناصر، مرّر إلى malloc العدد الكلي للبايتات في المصفوفة المطلوبة. أي ينبغي للبرنامج أن يطلب من malloc العدد الكلي للبايتات في كل عنصر مصفوفة مضروباً في عدد عناصر المصفوفة. ومرّر إلى malloc تعبيراً عن العدد الكلي للبايتات بالصيغة sizeof() * . فمثلاً:

int *arr;
char *c_arr;

// allocate an array of 20 ints on the heap:
arr = malloc(sizeof(int) * 20);

// allocate an array of 10 chars on the heap:
c_arr = malloc(sizeof(char) * 10);

بعد الاستدعاءين malloc في هذا المثال، يخزّن متغيّر المؤشّر int arr عنوان أساس مصفوفة من 20 موقع تخزين صحيح متجاورة في ذاكرة الكومة، ويخزّن متغيّر مؤشّر المحارف c_arr عنوان أساس مصفوفة من 10 مواقع تخزين محارف متجاورة في ذاكرة الكومة. ويصوّر الشكل 2 كيف قد يبدو ذلك.

يحتوي مكدّس main على متغيّري مؤشّر: الأول arr يحوي عنوان كتلة ذاكرة على الكومة تتسع لـ20 عدداً صحيحاً، والثاني c_arr يحوي عنوان كتلة ذاكرة أخرى على الكومة تتسع لـ10 محارف. الشكل 2. مصفوفة صحيحة من 20 عنصراً ومصفوفة محارف من 10 عناصر مخصَّصتان على الكومة.

لاحظ أنه رغم أن malloc ترجع مؤشّراً إلى مساحة مخصَّصة ديناميكياً في ذاكرة الكومة، فإن برامج C تخزّن المؤشّر إلى مواقع الكومة على المكدّس. وتحتوي متغيّرات المؤشّرات على عنوان الأساس فقط (عنوان البداية) لمساحة تخزين المصفوفة في الكومة. وكما في المصفوفات المعلَنة ساكنةً، تكون مواقع الذاكرة للمصفوفات المخصَّصة ديناميكياً في مواقع ذاكرة متجاورة. ورغم أن استدعاءً واحداً لـ malloc يؤدي إلى تخصيص قطعة ذاكرة بعدد البايتات المطلوب، فإن استدعاءات متعددة لـ malloc لن تؤدي إلى عناوين كومة متجاورة (على معظم الأنظمة). وفي المثال أعلاه، قد تكون عناصر مصفوفة char وعناصر مصفوفة int عند عناوين متباعدة في الكومة.

بعد تخصيص مساحة الكومة ديناميكياً لمصفوفة، يمكن للبرنامج الوصول إلى المصفوفة عبر متغيّر المؤشّر. ولأن قيمة متغيّر المؤشّر تمثّل عنوان أساس المصفوفة في الكومة، يمكننا استخدام الصياغة نفسها للوصول إلى عناصر المصفوفات المخصَّصة ديناميكياً كما نستخدمها للوصول إلى عناصر المصفوفات المعلَنة ساكنةً. وفيما يلي مثال:

int i;
int s_array[20];
int *d_array;

d_array = malloc(sizeof(int) * 20);
if (d_array == NULL) {
    printf("Error: malloc failed\n");
    exit(1);
}

for (i=0; i < 20; i++) {
    s_array[i] = i;
    d_array[i] = i;
}

printf("%d %d \n", s_array[3], d_array[3]);  // prints 3 3

قد لا يكون واضحاً سبب إمكانية استخدام الصياغة نفسها للوصول إلى عناصر المصفوفات المخصَّصة ديناميكياً كما تُستخدم للوصول إلى عناصر المصفوفات المعلَنة ساكنةً. غير أنه رغم اختلاف نوعيهما، تُقيَّم قيمتا s_array وd_array كلتاهما إلى عنوان أساس المصفوفة في الذاكرة.

التعبير القيمة النوع
s_array عنوان أساس المصفوفة في الذاكرة مصفوفة (ساكنة) من int
d_array عنوان أساس المصفوفة في الذاكرة مؤشّر int (int *)

ولأن اسمي المتغيّرين يُقيَّمان إلى عنوان أساس المصفوفة في الذاكرة (عنوان ذاكرة العنصر الأول)، تبقى دلالات صياغة [i] بعد اسم المتغيّر نفسها لكليهما: فـ [i] تلغي الإشارة إلى موقع تخزين العدد الصحيح عند الإزاحة i من عنوان أساس المصفوفة في الذاكرة — أي أنها تصل إلى العنصر رقم i. في معظم الأغراض، نوصي باستخدام صياغة [i] للوصول إلى عناصر مصفوفة مخصَّصة ديناميكياً. غير أن البرامج يمكنها أيضاً استخدام صياغة إلغاء الإشارة إلى المؤشّر (المعامل *) للوصول إلى عناصر المصفوفة. فمثلاً، وضع * قبل مؤشّر يشير إلى مصفوفة مخصَّصة ديناميكياً سيُلغي الإشارة إلى المؤشّر للوصول إلى العنصر 0 في المصفوفة:

/* these two statements are identical: both put 8 in index 0 */
d_array[0] = 8; // put 8 in index 0 of the d_array
*d_array = 8;   // in the location pointed to by d_array store 8

يصف قسم المصفوفات المصفوفات بمزيد من التفصيل، ويناقش قسم حساب المؤشّرات الوصول إلى عناصر المصفوفة عبر متغيّرات المؤشّرات.

عندما ينتهي البرنامج من استخدام مصفوفة مخصَّصة ديناميكياً، ينبغي أن يستدعي free لتحرير مساحة الكومة. وكما ذكرنا سابقاً، نوصي بضبط المؤشّر على NULL بعد تحريره:

free(arr);
arr = NULL;

free(c_arr);
c_arr = NULL;

free(d_array);
d_array = NULL;

إدارة ذاكرة الكومة، malloc وfree

تنفّذ مكتبة C القياسية الدالتين malloc وfree، اللتين تمثّلان الواجهة البرمجية لمدير ذاكرة الكومة فيها. وعند استدعائها، تحتاج malloc إلى إيجاد قطعة متجاورة من مساحة ذاكرة الكومة غير المخصَّصة يمكنها تلبية حجم الطلب. ويحتفظ مدير ذاكرة الكومة بـ قائمة حرة (free list) من امتدادات (extents) ذاكرة الكومة غير المخصَّصة، حيث يحدد كل امتداد عنوان البداية وحجم قطعة متجاورة غير مخصَّصة من مساحة الكومة.

تكون ذاكرة الكومة كلها في البداية فارغة، أي أن القائمة الحرة تحتوي على امتداد واحد يتألف من منطقة الكومة بأكملها. وبعد أن يُجري البرنامج بعض الاستدعاءات لـ malloc وfree قد تصبح ذاكرة الكومة مفتّتة (fragmented)، أي توجد قطع من مساحة كومة حرة متخللة بين قطع من مساحة كومة مخصَّصة. ويحتفظ مدير ذاكرة الكومة عادةً بقوائم لمجالات مختلفة من أحجام مساحة الكومة لتمكين البحث السريع عن امتداد حر بحجم معيّن. وبالإضافة إلى ذلك، ينفّذ سياسة أو أكثر للاختيار من بين امتدادات حرة متعددة يمكن استخدامها لتلبية طلب.

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

لمزيد من المعلومات عن إدارة ذاكرة الكومة، انظر كتاباً عن أنظمة التشغيل (فمثلاً، الفصل 17 «إدارة المساحة الحرة» في OS in Three Easy Pieces يغطي هذه التفاصيل).

2.4.4. المؤشّرات إلى ذاكرة الكومة والدوال

عند تمرير مصفوفة مخصَّصة ديناميكياً إلى دالة، تُمرَّر قيمة وسيطة متغيّر المؤشّر إلى الدالة (أي يُمرَّر عنوان أساس المصفوفة في الكومة إلى الدالة). وهكذا، عند تمرير مصفوفات معلَنة ساكنةً أو مخصَّصة ديناميكياً إلى الدوال، يحصل الوسيط على القيمة نفسها بالضبط — عنوان أساس المصفوفة في الذاكرة. ونتيجةً لذلك، يمكن استخدام الدالة نفسها للمصفوفات المعلَنة ساكنةً والمخصَّصة ديناميكياً من النوع نفسه، ويمكن استخدام الصياغة نفسها داخل الدالة للوصول إلى عناصر المصفوفة. وتعريفتا الوسيط int *arr وint arr[] متكافئتان. غير أنه بالاصطلاح، تُستخدم صياغة المؤشّر عادةً للدوال التي قد تُستدعى بمصفوفات مخصَّصة ديناميكياً:

int main(void) {
    int *arr1;

    arr1 = malloc(sizeof(int) * 10);
    if (arr1 == NULL) {
        printf("malloc error\n");
        exit(1);
    }

    /* pass the value of arr1 (base address of array in heap) */
    init_array(arr1, 10);
    ...
}

void init_array(int *arr, int size) {
    int i;
    for (i = 0; i < size; i++) {
        arr[i] = i;
    }
}

عند النقطة قبل العودة من الدالة init_array مباشرة، سيبدو محتوى الذاكرة كما في الشكل 3. لاحظ أنه عندما يمرّر main arr1 إلى init_array فإنه يمرّر عنوان أساس المصفوفة فقط. وتبقى الكتلة الكبيرة المتجاورة لذاكرة المصفوفة على الكومة، ويمكن للدالة الوصول إليها بإلغاء الإشارة إلى وسيط المؤشّر arr. كما يمرّر حجم المصفوفة حتى تعرف init_array عدد العناصر المطلوب الوصول إليها.

يخزّن كل من arr1 في main والمتغيّر arr في init_array العنوان الأساسي نفسه لكتلة ذاكرة الكومة. الشكل 3. محتويات الذاكرة قبل العودة من init_array. يشير كل من arr1 في main والمتغيّر arr في init_array إلى كتلة ذاكرة الكومة نفسها.

قدّمنا في الفصل السابق المصفوفات أحادية البعد المعلَنة ساكنةً في C وناقشنا دلالات تمرير المصفوفات إلى الدوال. وفي قسم التخصيص الديناميكي للذاكرة في هذا الفصل، قدّمنا المصفوفات أحادية البعد المخصَّصة ديناميكياً وناقشنا دلالات تمريرها إلى الدوال.

نأخذ في هذا القسم نظرة أعمق على المصفوفات في C. فنصف المصفوفات المعلَنة ساكنةً والمخصَّصة ديناميكياً بمزيد من التفصيل، ونناقش المصفوفات ثنائية الأبعاد.

2.5.1. المصفوفات أحادية البعد

المخصَّصة ساكنةً

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

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

عند تمرير مصفوفة إلى دالة، تنسخ C قيمة عنوان الأساس إلى الوسيط. أي أن الوسيط والوسيطة يشيران إلى مواقع الذاكرة نفسها — فمؤشّر الوسيط يشير إلى عناصر مصفوفة الوسيطة في الذاكرة. ونتيجةً لذلك، يؤدي تعديل القيم المخزّنة في المصفوفة عبر وسيط مصفوفة إلى تعديل القيم المخزّنة في مصفوفة الوسيطة.

وفيما يلي بعض الأمثلة على إعلان المصفوفات الساكنة واستخدامها:

// declare arrays specifying their type and total capacity
float averages[30];   // array of float, 30 elements
char  name[20];       // array of char, 20 elements
int i;

// access array elements
for (i = 0; i < 10; i++) {
    averages[i] = 0.0 + i;
    name[i] = 'a' + i;
}
name[10] = '\0';    // name is being used for storing a C-style string

// prints: 3 d abcdefghij
printf("%g %c %s\n", averages[3], name[3], name);

strcpy(name, "Hello");
printf("%s\n", name);  // prints: Hello

المخصَّصة ديناميكياً

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

يخصّص استدعاء الدالة malloc مصفوفة على الكومة ديناميكياً وقت التشغيل. ويمكن إسناد عنوان مساحة الكومة المخصَّصة إلى متغيّر مؤشّر عام أو محلي، فيشير حينها إلى العنصر الأول في المصفوفة. ولتخصيص المساحة ديناميكياً، مرّر إلى malloc العدد الكلي للبايتات المطلوب تخصيصها للمصفوفة (باستخدام المعامل sizeof للحصول على حجم نوع معيّن). ويخصّص الاستدعاء الواحد لـ malloc قطعة متجاورة من مساحة الكومة بالحجم المطلوب. فمثلاً:

// declare a pointer variable to point to allocated heap space
int    *p_array;
double *d_array;

// call malloc to allocate the appropriate number of bytes for the array

p_array = malloc(sizeof(int) * 50);      // allocate 50 ints
d_array = malloc(sizeof(double) * 100);  // allocate 100 doubles

// always CHECK RETURN VALUE of functions and HANDLE ERROR return values
if ( (p_array == NULL) || (d_array == NULL) ) {
    printf("ERROR: malloc failed!\n");
    exit(1);
}

// use [] notation to access array elements
for (i = 0; i < 50; i++) {
    p_array[i] = 0;
    d_array[i] = 0.0;
}

// free heap space when done using it
free(p_array);
p_array = NULL;

free(d_array);
d_array = NULL;

تخطيط ذاكرة المصفوفة

سواء أُعلنت المصفوفة ساكنةً أم خُصِّصت ديناميكياً باستدعاء واحد لـ malloc، فإن عناصر المصفوفة تمثّل مواقع ذاكرة متجاورة (عناوين):

 array [0]:  base address
 array [1]:  next address
 array [2]:  next address
   ...            ...
 array [99]: last address

يقع موقع العنصر i عند إزاحة i من عنوان أساس المصفوفة. ويعتمد العنوان الدقيق للعنصر رقم i على عدد بايتات النوع المخزّن في المصفوفة. فمثلاً، تأمّل إعلانات المصفوفات التالية:

int  iarray[6];  // an array of six ints, each of which is four bytes
char carray[4];  // an array of four chars, each of which is one byte

قد تبدو عناوين عناصر كل مصفوفة شيئاً كهذا:

 addr   element
 ----   -------
 1230:  iarray[0]
 1234:  iarray[1]
 1238:  iarray[2]
 1242:  iarray[3]
 1246:  iarray[4]
 1250:  iarray[5]
     ...
 1280:  carray[0]
 1281:  carray[1]
 1282:  carray[2]
 1283:  carray[3]

في هذا المثال، 1230 هو عنوان أساس iarray، و1280 عنوان أساس carray. لاحظ أن العناصر الفردية لكل مصفوفة تُخصَّص على عناوين ذاكرة متجاورة: فكل عنصر في iarray يخزّن قيمة int من 4 بايتات، لذا تختلف عناوين عناصره بمقدار 4، وكل عنصر في carray يخزّن قيمة char من بايت واحد، لذا تختلف عناوينه بمقدار 1. ولا يوجد ضمان بأن تُخصَّص مجموعة المتغيّرات المحلية على مواقع ذاكرة متجاورة على المكدّس (لذا قد توجد فجوة في العناوين بين نهاية iarray وبداية carray، كما هو موضح في هذا المثال.)

2.5.2. المصفوفات ثنائية الأبعاد

تدعم C المصفوفات متعددة الأبعاد، لكننا نحصر مناقشتنا للمصفوفات متعددة الأبعاد في المصفوفات ثنائية الأبعاد (2D)، لأن المصفوفات أحادية البعد وثنائية الأبعاد هي الأكثر استخداماً لدى مبرمجي C.

المصفوفات ثنائية الأبعاد المعلَنة ساكنةً

للإعلان الساكن عن متغيّر مصفوفة متعددة الأبعاد، حدد حجم كل بُعد. فمثلاً:

int   matrix[50][100];
short little[10][10];

هنا matrix مصفوفة ثنائية الأبعاد من قيم int بـ 50 صفاً و100 عمود، وlittle مصفوفة ثنائية الأبعاد من قيم short بـ 10 صفوف و10 أعمدة.

للوصول إلى عنصر فردي، حدد كلاً من فهرس الصف وفهرس العمود:

int   val;
short num;

val = matrix[3][7];  // get int value in row 3, column 7 of matrix
num = little[8][4];  // get short value in row 8, column 4 of little

يوضّح الشكل 1 المصفوفة ثنائية الأبعاد كمصفوفة (matrix) من القيم الصحيحة، حيث يُفهرَس عنصر محدد في المصفوفة ثنائية الأبعاد بقيمتي فهرس الصف والعمود.

الوصول إلى matrix[2][3] يشبه الفهرسة في شبكة عند الصف 2 والعمود 3. الشكل 1. مصفوفة ثنائية الأبعاد ممثَّلة كمصفوفة. الوصول إلى matrix[2][3] يشبه الفهرسة في شبكة عند الصف 2 والعمود 3.

كثيراً ما تصل البرامج إلى عناصر مصفوفة ثنائية الأبعاد بالتكرار عبر حلقات متداخلة. فمثلاً، تهيّئ الحلقة المتداخلة التالية جميع العناصر في matrix إلى 0:

int i, j;

for (i = 0; i < 50; i++) {  // for each row i
    for (j = 0; j < 100; j++) { // iterate over each column element in row i
        matrix[i][j] = 0;
    }
}

وسائط المصفوفات ثنائية الأبعاد

تنطبق القواعد نفسها لتمرير وسائط المصفوفات أحادية البعد إلى الدوال على تمرير وسائط المصفوفات ثنائية الأبعاد: يحصل الوسيط على قيمة عنوان أساس المصفوفة ثنائية الأبعاد (&arr[0][0]). وبعبارة أخرى، يشير الوسيط إلى عناصر مصفوفة الوسيطة، وبالتالي يمكن للدالة تغيير القيم المخزّنة في المصفوفة الممرَّرة.

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

// a C constant definition: COLS is defined to be the value 100
#define COLS  (100)

/*
 * init_matrix: initializes the passed matrix elements to the
 *              product of their index values
 *   m: a 2D array (the column dimension must be 100)
 *   rows: the number of rows in the matrix
 *   return: does not return a value
 */
void init_matrix(int m[][COLS], int rows) {
    int i, j;
    for (i = 0; i < rows; i++) {
        for (j = 0; j < COLS; j++) {
            m[i][j] = i*j;
        }
    }
}

int main(void) {
    int matrix[50][COLS];
    int bigger[90][COLS];

    init_matrix(matrix, 50);
    init_matrix(bigger, 90);
    ...

يمكن تمرير كل من مصفوفتي matrix وbigger كوسيطتين إلى الدالة init_matrix لأنهما تمتلكان بُعد الأعمدة نفسه الموجود في تعريف الوسيط.

ملاحظة

يجب تحديد بُعد الأعمدة في تعريف وسيط المصفوفة ثنائية الأبعاد ليتمكن المصرّف من حساب الإزاحة من عنوان أساس المصفوفة ثنائية الأبعاد إلى بداية صف معيّن من العناصر. ويتبع حساب الإزاحة من تخطيط المصفوفات ثنائية الأبعاد في الذاكرة.

تخطيط ذاكرة المصفوفة ثنائية الأبعاد

تُرتَّب المصفوفات ثنائية الأبعاد المخصَّصة ساكنةً في الذاكرة بترتيب الصفوف (row-major order)، أي أن جميع عناصر الصف 0 تأتي أولاً، ثم جميع عناصر الصف 1، وهكذا. فمثلاً، بالنظر إلى الإعلان التالي لمصفوفة ثنائية الأبعاد من الأعداد الصحيحة:

int arr[3][4];  // int array with 3 rows and 4 columns

قد يبدو تخطيطها في الذاكرة كما في الشكل 2.

إعلان مصفوفة على شكل "int arr[3][4]" ينتج ثلاثة صفوف، لكل منها أربعة عناصر. ويتكوّن الصف 0 من arr[0][0] وarr[0][1] وarr[0][2] وarr[0][3]. ويتكوّن الصف 1 من arr[1][0] وarr[1][1] وما إلى ذلك. الشكل 2. تخطيط مصفوفة ثنائية الأبعاد بترتيب الصفوف.

لاحظ أن جميع عناصر المصفوفة تُخصَّص على عناوين ذاكرة متجاورة. أي أن عنوان أساس المصفوفة ثنائية الأبعاد هو عنوان ذاكرة العنصر [0][0] (&arr[0][0])، وتُخزَّن العناصر التالية بشكل متجاور بترتيب الصفوف (مثلاً، يأتي الصف 1 بأكمله مباشرة بعد الصف 0 بأكمله، وهكذا).

المصفوفات ثنائية الأبعاد المخصَّصة ديناميكياً

يمكن تخصيص المصفوفات ثنائية الأبعاد ديناميكياً بطريقتين. فلمصفوفة ثنائية الأبعاد بحجم NxM، إما:

  1. أن تُجري استدعاءً واحداً لـ malloc، فتخصّص كتلة كبيرة واحدة من مساحة الكومة لتخزين جميع عناصر المصفوفة البالغ عددها NxM.
  2. أن تُجري استدعاءات متعددة لـ malloc، فتخصّص مصفوفة من مصفوفات. أولاً، خصّص مصفوفة أحادية البعد من N مؤشّراً إلى نوع العنصر، بمصفوفة أحادية البعد من المؤشّرات لكل صف في المصفوفة ثنائية الأبعاد. ثم خصّص N مصفوفات أحادية البعد بحجم M لتخزين مجموعة قيم الأعمدة لكل صف في المصفوفة ثنائية الأبعاد. وأسند عناوين كل من هذه المصفوفات الـ N إلى عناصر المصفوفة الأولى المكوّنة من N مؤشّراً.

تختلف إعلانات المتغيّرات وشيفرة التخصيص وصياغة الوصول إلى عناصر المصفوفة بحسب الطريقة التي يختار المبرمج استخدامها من هاتين الطريقتين.

الطريقة 1: تخصيص موفّر للذاكرة

في هذه الطريقة، يخصّص استدعاء واحد لـ malloc العدد الكلي للبايتات اللازمة لتخزين مصفوفة القيم NxM. ولهذه الطريقة ميزة كونها أكثر كفاءة في استخدام الذاكرة لأن المساحة كلها لجميع عناصر NxM ستُخصَّص دفعة واحدة، في مواقع ذاكرة متجاورة.

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

وفيما يلي مقتطف شيفرة C يخصّص مصفوفة ثنائية الأبعاد ديناميكياً بالطريقة 1:

#define N 3
#define M 4

int main(void) {
    int *two_d_array;    // the type is a pointer to an int (the element type)

    // allocate in a single malloc of N x M int-sized elements:
    two_d_array = malloc(sizeof(int) * N * M);

    if (two_d_array == NULL) {
        printf("ERROR: malloc failed!\n");
        exit(1);
    }

    ...

يعرض الشكل 3 مثالاً على تخصيص مصفوفة ثنائية الأبعاد بهذه الطريقة ويوضح كيف قد تبدو الذاكرة بعد الاستدعاء malloc.

يمكننا تخصيص مصفوفة باستخدام malloc(sizeof(int) * (3*4)) وتخزين العنوان الأساسي في متغيّر مؤشّر على المكدّس. ولأن malloc تعيد كتلة ذاكرة متصلة، يمكننا معاملة الذاكرة كمجموعة من الصفوف والأعمدة بترتيب الصفوف الأول، تمامًا كالمصفوفة المخصَّصة ساكناً. الشكل 3. نتائج تخصيص مصفوفة ثنائية الأبعاد باستدعاء واحد لـ malloc.

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

ولأن هذه الطريقة تستخدم قطعة واحدة من المساحة المخصَّصة بـ malloc للمصفوفة ثنائية الأبعاد، فإن تخصيص الذاكرة يكون بأكبر كفاءة ممكنة (فهو يحتاج استدعاءً واحداً فقط لـ malloc للمصفوفة ثنائية الأبعاد بأكملها). وهي الطريقة الأكثر كفاءة للوصول إلى الذاكرة لأن جميع العناصر تقع متقاربة في ذاكرة متجاورة، وكل وصول يحتاج مستوى واحداً فقط من الإحالة غير المباشرة من متغيّر المؤشّر.

غير أن مصرّف C لا يعرف الفرق بين تخصيص مصفوفة ثنائية أو أحادية الأبعاد بهذه الطريقة. ونتيجةً لذلك، لا يمكن استخدام صياغة الفهرسة المزدوجة ([i][j]) الخاصة بالمصفوفات ثنائية الأبعاد المعلَنة ساكنةً عند تخصيص مصفوفة ثنائية الأبعاد بهذه الطريقة. وبدلاً من ذلك، يجب على المبرمج أن يحسب صراحةً الإزاحة في القطعة المتجاورة من ذاكرة الكومة باستخدام دالة في قيمتي فهرس الصف والعمود ([i*M + j]، حيث M هو بُعد الأعمدة).

وفيما يلي مثال على كيفية تنظيم المبرمج للشيفرة لتهيئة جميع عناصر مصفوفة ثنائية الأبعاد:

// access using [] notation:
//   cannot use [i][j] syntax because the compiler has no idea where the
//   next row starts within this chunk of heap space, so the programmer
//   must explicitly add a function of row and column index values
//   (i*M+j) to map their 2D view of the space into the 1D chunk of memory
for (i = 0; i < N; i++) {
    for (j = 0; j < M; j++) {
        two_d_array[i*M + j] = 0;
    }
}

الطريقة 1 (malloc واحد) ووسائط الدوال

عنوان أساس مصفوفة من نوع int مخصَّصة باستدعاء malloc واحد هو مؤشّر إلى int، لذا يمكن تمريره إلى دالة بوسيط (int *). وبالإضافة إلى ذلك، يجب تمرير بُعدي الصفوف والأعمدة إلى الدالة لتستطيع حساب الإزاحات في المصفوفة ثنائية الأبعاد حساباً صحيحاً. فمثلاً:

/*
 * initialize all elements in a 2D array to 0
 *  arr: the array
 *  rows: number of rows
 *  cols: number of columns
 */
void init2D(int *arr, int rows, int cols) {
    int i, j;
    for (i = 0; i < rows; i++) {
        for (j = 0; j < cols; j++) {
            arr[i*cols + j] = 0;
        }
    }
}

int main(void) {
    int *array;
    array = malloc(sizeof(int) * N * M);
    if (array != NULL) {
        init2D(array, N, M);
    }
    ...

الطريقة 2: الطريقة الملائمة للمبرمج

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

وفيما يلي مثال على تخصيص مصفوفة ثنائية الأبعاد بالطريقة 2 (مع حذف شيفرة كشف الأخطاء ومعالجتها للوضوح):

// the 2D array variable is declared to be `int **` (a pointer to an int *)
// a dynamically allocated array of dynamically allocated int arrays
// (a pointer to pointers to ints)
int **two_d_array;
int i;

// allocate an array of N pointers to ints
// malloc returns the address of this array (a pointer to (int *)'s)
two_d_array = malloc(sizeof(int *) * N);

// for each row, malloc space for its column elements and add it to
// the array of arrays
for (i = 0; i < N; i++) {
// malloc space for row i's M column elements
    two_d_array[i] = malloc(sizeof(int) * M);
}

في هذا المثال، لاحظ أنواع المتغيّرات والأحجام الممرَّرة إلى الاستدعاءات malloc. وللإشارة إلى المصفوفة ثنائية الأبعاد المخصَّصة ديناميكياً، يعلن المبرمج متغيّراً (two_d_array) من النوع int ** سيخزّن عنوان مصفوفة مخصَّصة ديناميكياً من قيم العناصر int *. ويخزّن كل عنصر في two_d_array عنوان مصفوفة مخصَّصة ديناميكياً من قيم int (ونوع two_d_array[i] هو int *).

يعرض الشكل 4 كيف قد تبدو الذاكرة بعد استدعاءات N+1 لـ malloc في المثال أعلاه.

two_d_array متغيّر على المكدّس يشير إلى مصفوفة من المؤشّرات مخصَّصة ديناميكياً. ويشير كل مؤشّر من تلك المؤشّرات إلى مصفوفة أحادية الأبعاد من الأعداد الصحيحة. الشكل 4. ترتيب الذاكرة بعد تخصيص مصفوفة ثنائية الأبعاد بـ N+1 استدعاءً لـ malloc.

لاحظ أنه عند استخدام هذه الطريقة، لا تكون متجاورة في الذاكرة إلا العناصر المخصَّصة كجزء من استدعاء واحد لـ malloc. أي أن العناصر داخل كل صف متجاورة، لكن العناصر من صفوف مختلفة (حتى الصفوف المتجاورة) ليست كذلك.

بعد التخصيص، يمكن الوصول إلى العناصر الفردية في المصفوفة ثنائية الأبعاد باستخدام صياغة الفهرسة المزدوجة. ويحدد الفهرس الأول عنصراً في المصفوفة الخارجية من مؤشّرات int * (أي صف)، ويحدد الفهرس الثاني عنصراً في مصفوفة int الداخلية (أي عمود داخل الصف).

int i, j;

for (i = 0; i < N; i++) {
    for (j = 0; j < M; j++) {
        two_d_array[i][j] = 0;
    }
}

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

       two_d_array: an array of int pointers, it stores the base address of an
                 array of (int *) values. Its type is int** (a pointer to int *).

    two_d_array[i]: the ith index into the array of arrays, it stores an (int *)
                 value that represents the base address of an array of (int)
                 values.  Its type is int*.

 two_d_array[i][j]: the jth element pointed to by the ith element of the array of
                 arrays, it stores an int value (the value in row i, column j
                 of the 2D array).  Its type is int.

الطريقة 2 (مصفوفة من مصفوفات) ووسائط الدوال

نوع وسيطة المصفوفة هو int ** (مؤشّر إلى مؤشّر إلى int)، ويطابق وسيط الدالة نوع وسيطته. وبالإضافة إلى ذلك، ينبغي تمرير أحجام الصفوف والأعمدة إلى الدالة. ولأن هذا نوع مختلف عن نوع الطريقة 1، لا يمكن للمصفوفتين استخدام دالة مشتركة (فهما ليستا نوع C نفسه).

وفيما يلي دالة مثال تأخذ مصفوفة ثنائية الأبعاد من نوع الطريقة 2 (مصفوفة من مصفوفات) كوسيط:

/*
 * initialize a 2D array
 * arr: the array
 * rows: number of rows
 * cols: number of columns
 */
void init2D_Method2(int **arr, int rows, int cols) {
    int i,j;

    for (i = 0; i < rows; i++) {
        for (j = 0; j < cols; j++) {
            arr[i][j] = 0;
        }
    }
}

/*
 * main: example of calling init2D_Method2
 */
int main(void) {
    int **two_d_array;

    // some code to allocate the row array and multiple col arrays
    // ...

    init2D_Method2(two_d_array, N, M);
    ...

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

قدّمنا في الفصل السابق المصفوفات والسلاسل النصية في C. ونناقش في هذا الفصل سلاسل C المخصَّصة ديناميكياً واستخدامها مع مكتبة سلاسل C. ونقدّم أولاً نظرة موجزة على السلاسل النصية المعلَنة ساكنةً.

2.6.1. دعم C للسلاسل النصية المخصَّصة ساكنةً (مصفوفات char)

لا تدعم C نوعاً منفصلاً للسلاسل النصية، لكن يمكن تنفيذ سلسلة نصية في برامج C باستخدام مصفوفة من قيم char تنتهي بقيمة محرف فارغ خاصة '\0'. ويحدد المحرف الفارغ في النهاية نهاية تسلسل قيم المحارف التي تتكوّن منها السلسلة النصية. وليست كل مصفوفة محارف سلسلة C، لكن كل سلسلة C مصفوفة من قيم char.

ولأن السلاسل النصية تظهر كثيراً في البرامج، توفّر C مكتبات بها دوال للتلاعب بالسلاسل. ويجب على البرامج التي تستخدم مكتبة سلاسل C تضمين string.h. وتتطلب معظم دوال مكتبة السلاسل أن يخصّص المبرمج مساحة لمصفوفة المحارف التي تتلاعب بها الدوال. وعند طباعة قيمة سلسلة نصية، استخدم العنصر النائب %s.

وفيما يلي برنامج مثال يستخدم السلاسل النصية وبعض دوال مكتبة السلاسل:

#include <stdio.h>
#include <string.h>   // include the C string library

int main(void) {
    char str1[10];
    char str2[10];

    str1[0] = 'h';
    str1[1] = 'i';
    str1[2] = '\0';   // explicitly add null terminating character to end

    // strcpy copies the bytes from the source parameter (str1) to the
    // destination parameter (str2) and null terminates the copy.
    strcpy(str2, str1);
    str2[1] = 'o';
    printf("%s %s\n", str1, str2);  // prints: hi ho

    return 0;
}

2.6.2. تخصيص السلاسل النصية ديناميكياً

يمكن تخصيص مصفوفات المحارف ديناميكياً (كما نوقش في قسمي المؤشّرات والمصفوفات). وعند تخصيص مساحة ديناميكياً لتخزين سلسلة نصية، من المهم تذكّر تخصيص مساحة في المصفوفة للمحرف '\0' الذي تنتهي به السلسلة.

يوضح برنامج المثال التالي سلاسل نصية معلَنة ساكنةً ومخصَّصة ديناميكياً (لاحظ القيمة الممرَّرة إلى malloc):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    int size;
    char str[64];         // statically allocated
    char *new_str = NULL; // for dynamically allocated

    strcpy(str, "Hello");
    size = strlen(str);   // returns 5

    new_str = malloc(sizeof(char) * (size+1)); // need space for '\0'
    if(new_str == NULL) {
        printf("Error: malloc failed!  exiting.\n");
        exit(1);
    }
    strcpy(new_str, str);
    printf("%s %s\n", str, new_str);    // prints "Hello Hello"

    strcat(str, " There");  // concatenate " There" to the end of str
    printf("%s\n", str);    // prints "Hello There"

    free(new_str);  // free malloc'ed space when done
    new_str = NULL;

    return 0;
}

تحذير — دوال سلاسل C وذاكرة الوجهة

كثير من دوال سلاسل C (وأبرزها strcpy وstrcat) تخزّن نتائجها بتتبع وسيط مؤشّر سلسلة وجهة (char *) والكتابة إلى الموقع الذي يشير إليه. وتفترض هذه الدوال أن الوجهة تحتوي ذاكرة كافية لتخزين النتيجة. لذا، بصفتك مبرمجاً، يجب أن تضمن توفر ذاكرة كافية في الوجهة قبل استدعاء هذه الدوال.

وسيؤدي الفشل في تخصيص ذاكرة كافية إلى نتائج غير معرّفة تتراوح بين انهيار البرنامج وثغرات أمنية كبيرة. فمثلاً، توضح الاستدعاءات التالية لـ strcpy وstrcat أخطاء يرتكبها كثيراً مبرمجو C المبتدئون:

// Attempt to write a 12-byte string into a 5-character array.
char mystr[5];
strcpy(mystr, "hello world");

// Attempt to write to a string with a NULL destination.
char *mystr = NULL;
strcpy(mystr, "try again");

// Attempt to modify a read-only string literal.
char *mystr = "string literal value";
strcat(mystr, "string literals aren't writable");

2.6.3. مكتبات التلاعب بسلاسل C والمحارف

توفّر C عدة مكتبات بها دوال للتلاعب بالسلاسل النصية والمحارف. وتُعدّ مكتبة السلاسل (string.h) مفيدة بوجه خاص عند كتابة برامج تستخدم سلاسل C. وتحتوي مكتبتا stdlib.h وstdio.h أيضاً على دوال للتلاعب بالسلاسل، وتحتوي مكتبة ctype.h على دوال للتلاعب بقيم المحارف الفردية.

عند استخدام دوال مكتبة سلاسل C، من المهم تذكّر أن معظمها لا يخصّص مساحة للسلاسل التي يتلاعب بها، ولا يتحقق من أنك تمرّر سلاسل صالحة؛ فيجب على برنامجك تخصيص مساحة للسلاسل التي ستستخدمها مكتبة سلاسل C. وزيادةً على ذلك، إذا عدّلت دالة المكتبة السلسلة الممرَّرة، فعلى المستدعي ضمان أن تكون السلسلة منسّقة بشكل صحيح (أي أن يكون لها محرف \0 في نهايتها). وكثيراً ما يؤدي استدعاء دوال مكتبة السلاسل بقيم وسائط مصفوفات سيئة إلى انهيار البرنامج. وتحدد وثائق دوال المكتبات المختلفة (مثل صفحات الدليل) ما إذا كانت دالة المكتبة تخصّص مساحة أم أن المستدعي مسؤول عن تمرير مساحة مخصَّصة إلى دالة المكتبة.

ملاحظة — وسائط char[] وchar * ونوع الإرجاع char *

يمكن تمرير مصفوفات المحارف المعلَنة ساكنةً والمخصَّصة ديناميكياً معاً إلى وسيط char * لأن اسم أي من نوعي المتغيّرين يُقيَّم إلى عنوان أساس المصفوفة في الذاكرة. وسيعمل الإعلان عن الوسيط بالنوع char [] أيضاً مع قيم الوسائط المعلَنة ساكنةً والمخصَّصة ديناميكياً، لكن char * أكثر استخداماً لتحديد نوع وسائط السلاسل النصية (مصفوفة char).

وإذا أرجع دالة سلسلة نصية (أي كان نوع إرجاعها char *)، فلا يمكن إسناد قيمتها المرجَعة إلا إلى متغيّر نوعه هو أيضاً char *؛ ولا يمكن إسنادها إلى متغيّر مصفوفة معلَن ساكنةً. ويوجد هذا القيد لأن اسم متغيّر مصفوفة معلَن ساكنةً ليس قيمة يسارية صالحة (لا يمكن تغيير عنوان أساسه في الذاكرة)، لذا لا يمكن إسناد قيمة مرجَعة char * إليه.

strlen وstrcpy وstrncpy

توفّر مكتبة السلاسل دوالاً لنسخ السلاسل النصية وإيجاد طول السلسلة:

// returns the number of characters in the string (not including the null character)
int strlen(char *s);

// copies string src to string dst up until the first '\0' character in src
// (the caller needs to make sure src is initialized correctly and
// dst has enough space to store a copy of the src string)
// returns the address of the dst string
char *strcpy(char *dst, char *src);

// like strcpy but copies up to the first '\0' or size characters
// (this provides some safety to not copy beyond the bounds of the dst
// array if the src string is not well formed or is longer than the
// space available in the dst array); size_t is an unsigned integer type
char *strncpy(char *dst, char *src, size_t size);

استخدام الدالة strcpy غير آمن في الحالات التي قد تكون فيها سلسلة المصدر أطول من السعة الكلية لسلسلة الوجهة. وفي هذه الحالة، ينبغي استخدام strncpy. ويوقف الوسيط size الدالة strncpy عن نسخ أكثر من size محرفاً من سلسلة src إلى سلسلة dst. وعندما يكون طول سلسلة src أكبر من أو يساوي size، تنسخ strncpy المحارف size الأولى من src إلى dst ولا تضيف محرفاً فارغاً إلى نهاية dst. ونتيجةً لذلك، ينبغي للمبرمج إضافة محرف فارغ صراحةً إلى نهاية dst بعد استدعاء strncpy.

وفيما يلي بعض الاستخدامات المثالية لهذه الدوال في برنامج:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>   // include the string library

int main(void) {
    // variable declarations that will be used in examples
    int len, i, ret;
    char str[32];
    char *d_str, *ptr;

    strcpy(str, "Hello There");
    len = strlen(str);  // len is 11

    d_str = malloc(sizeof(char) * (len+1));
    if (d_str == NULL) {
        printf("Error: malloc failed\n");
        exit(1);
    }

    strncpy(d_str, str, 5);
    d_str[5] = '\0';   // explicitly add null terminating character to end

    printf("%d:%s\n", strlen(str), str);      // prints 11:Hello There
    printf("%d:%s\n", strlen(d_str), d_str);  // prints 5:Hello

    free(d_str);

    return 0;
}

ملاحظة — strlcpy

تشبه الدالة strlcpy الدالة strncpy، إلا أنها تضيف دائماً المحرف '\0' إلى نهاية سلسلة الوجهة. وجعل السلسلة منتهية دائماً يجعلها بديلاً أكثر أماناً من strncpy لأنه لا يتطلب من المبرمج تذكر إنهاء السلسلة صراحةً بمحرف فارغ.

// like strncpy but copies up to the first '\0' or size-1 characters
// and null terminates the dest string (if size > 0).
char *strlcpy(char *dest, char *src, size_t size);

أضافت مكتبة GNU C في Linux الدالة strlcpy في إصدار حديث (2.38). وهي متاحة حالياً على بعض الأنظمة فقط، لكن توافرها سيزداد مع انتشار إصدارات أحدث من مكتبة C. ونوصي باستخدام strlcpy متى كان متاحاً.

وعلى الأنظمة التي تتوفر فيها strlcpy يمكن استبدال الاستدعاء التالي لـ strncpy من المثال أعلاه:

  // copy up to 5 chars from str to d_str
  strncpy(d_str, str, 5);
  d_str[5] = '\0';   // explicitly add null terminating character to end

بالاستدعاء التالي لـ strlcpy:

  // copy up to 5 chars from str to d_str
  strlcpy(d_str, str, 6);  // strlcpy always adds '\0' to the end

strcmp وstrncmp

توفّر مكتبة السلاسل أيضاً دالة لمقارنة سلسلتين نصيتين. ومقارنة متغيّري سلسلة باستخدام المعامل == لا تقارن المحارف في السلسلتين — بل تقارن عنواني الأساس للسلسلتين فقط. فمثلاً، التعبير:

if (d_str == str) { ...

يقارن عنوان أساس مصفوفة char في الكومة الذي يشير إليه d_str بعنوان أساس مصفوفة str char المخصَّصة على المكدّس.

لمقارنة قيم السلسلتين، يحتاج المبرمج إما إلى كتابة شيفرة يدوياً لمقارنة قيم العناصر المتناظرة، أو إلى استخدام الدالتين strcmp أو strncmp من مكتبة السلاسل:

int strcmp(char *s1, char *s2);
// returns 0 if s1 and s2 are the same strings
// a value < 0 if s1 is less than s2
// a value > 0 if s1 is greater than s2

int strncmp(char *s1, char *s2, size_t n);
// compare s1 and s2 up to at most n characters

تقارن الدالة strcmp السلاسل محرفاً محرفاً استناداً إلى تمثيلها ASCII. وبعبارة أخرى، تقارن قيم char في المواضع المتناظرة من مصفوفتي الوسيطين لإنتاج نتيجة مقارنة السلسلتين، وهو ما يعطي أحياناً نتائج غير بديهية. فمثلاً، ترميز ASCII لقيمة char 'a' أكبر من الترميز لقيمة char 'Z'. ونتيجةً لذلك، ترجع strcmp("aaa", "Zoo") قيمة موجبة تشير إلى أن "aaa" أكبر من "Zoo" ويرجع الاستدعاء strcmp("aaa", "zoo") قيمة سالبة تشير إلى أن "aaa" أقل من "zoo".

وفيما يلي بعض أمثلة مقارنة السلاسل:

strcpy(str, "alligator");
strcpy(d_str, "Zebra");

ret =  strcmp(str,d_str);
if (ret == 0) {
    printf("%s is equal to %s\n", str, d_str);
} else if (ret < 0) {
    printf("%s is less than %s\n", str, d_str);
} else {
    printf("%s is greater than %s\n", str, d_str);  // true for these strings
}

ret = strncmp(str, "all", 3);  // returns 0: they are equal up to first 3 chars

strcat وstrstr وstrchr

يمكن لدوال مكتبة السلاسل ربط السلاسل النصية (لاحظ أن على المستدعي ضمان أن سلسلة الوجهة تمتلك مساحة كافية لتخزين النتيجة):

// append chars from src to end of dst
// returns ptr to dst and adds '\0' to end
char *strcat(char *dst, char *src)

// append the first chars from src to end of dst, up to a maximum of size
// returns ptr to dst and adds '\0' to end
char *strncat(char *dst, char *src, size_t size);

كما توفّر دوالاً لإيجاد السلاسل الفرعية أو قيم المحارف في السلاسل:

// locate a substring inside a string
// (const means that the function doesn't modify string)
// returns a pointer to the beginning of substr in string
// returns NULL if substr not in string
char *strstr(const char *string, const char *substr);

// locate a character (c) in the passed string (s)
// (const means that the function doesn't modify s)
// returns a pointer to the first occurrence of the char c in string
// or NULL if c is not in the string
char *strchr(const char *s, int c);

وفيما يلي بعض الأمثلة على استخدام هذه الدوال (نحذف بعض معالجة الأخطاء للوضوح):

char str[32];
char *ptr;

strcpy(str, "Zebra fish");
strcat(str, " stripes");  // str gets "Zebra fish stripes"
printf("%s\n", str);     // prints: Zebra fish stripes

strncat(str, " are black.", 8);
printf("%s\n", str);     // prints: Zebra fish stripes are bla  (spaces count)

ptr = strstr(str, "trip");
if (ptr != NULL) {
    printf("%s\n", ptr);   // prints: tripes are bla
}

ptr = strchr(str, 'e');
if (ptr != NULL) {
    printf("%s\n", ptr);   // prints: ebra fish stripes are bla
}

تُرجع الاستدعاءات strchr وstrstr عنوان العنصر الأول في مصفوفة الوسيط بقيمة محرف مطابقة أو بقيمة سلسلة فرعية مطابقة على الترتيب. وعنوان هذا العنصر هو بداية مصفوفة من قيم char تنتهي بمحرف \0. وبعبارة أخرى، يشير ptr إلى بداية سلسلة فرعية داخل سلسلة أخرى. وعند طباعة قيمة ptr كسلسلة نصية بـ printf، تُطبع قيم المحارف بدءاً من الفهرس الذي يشير إليه ptr، ما يعطي النتائج المذكورة أعلاه.

strtok وstrtok_r

توفّر مكتبة السلاسل أيضاً دوالاً تقسّم سلسلة نصية إلى رموز (tokens). ويشير الرمز (token) إلى متتالية فرعية من المحارف في سلسلة تفصل بينها أي عدد من محارف الفصل التي يختارها المبرمج.

char *strtok(char *str, const char *delim);

// a reentrant version of strtok (reentrant is defined in later chapters):
char *strtok_r(char *str, const char *delim, char **saveptr);

تبحث الدالتان strtok (أو strtok_r) عن رموز فردية داخل سلسلة أكبر. فمثلاً، ضبط محارف فصل strtok على مجموعة محارف المسافات البيضاء يعطي الكلمات في سلسلة تحتوي أصلاً على جملة إنجليزية. أي أن كل كلمة في الجملة رمز في السلسلة.

فيما يلي برنامج مثال يستخدم strtok لإيجاد الكلمات الفردية كرموز في سلسلة إدخال. (ويمكن نسخه أيضاً من هنا: strtokexample.c).

/*
 * Extract whitespace-delimited tokens from a line of input
 * and print them one per line.
 *
 * to compile:
 *   gcc -g -Wall strtokexample.c
 *
 * example run:
 *   Enter a line of text:        aaaaa             bbbbbbbbb          cccccc
 *
 *   The input line is:
 *         aaaaa             bbbbbbbbb          cccccc
 *   Next token is aaaaa
 *   Next token is bbbbbbbbb
 *   Next token is cccccc
 */

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main(void) {
     /* whitespace stores the delim string passed to strtok.  The delim
      * string  is initialized to the set of characters that delimit tokens
      * We initialize the delim string to the following set of chars:
      *   ' ': space  '\t': tab  '\f': form feed  '\r': carriage return
      *   '\v': vertical tab  '\n': new line
      * (run "man ascii" to list all ASCII characters)
      *
      * This line shows one way to statically initialize a string variable
      * (using this method the string contents are constant, meaning that they
      *  cannot be modified, which is fine for the way we are using the
      *  whitespace string in this program).
      */
    char *whitespace = " \t\f\r\v\n";  /* Note the space char at beginning */

    char *token;  /* The next token in the line. */
    char *line;   /* The line of text read in that we will tokenize. */

    /* Allocate some space for the user's string on the heap. */
    line = malloc(200 * sizeof(char));
    if (line == NULL) {
        printf("Error: malloc failed\n");
        exit(1);
    }

    /* Read in a line entered by the user from "standard in". */
    printf("Enter a line of text:\n");
    line = fgets(line, 200 * sizeof(char), stdin);
    if (line == NULL) {
        printf("Error: reading input failed, exiting...\n");
        exit(1);
    }
    printf("The input line is:\n%s\n", line);

    /* Divide the string into tokens. */
    token = strtok(line, whitespace);       /* get the first token */
    while (token != NULL) {
        printf("Next token is %s\n", token);
        token = strtok(NULL, whitespace);     /* get the next token */
    }

    free(line);

    return 0;
}

sprintf

توفّر مكتبة stdio في C أيضاً دوالاً تتلاعب بسلاسل C. وربما يكون أكثرها فائدة الدالة sprintf، التي «تطبع» إلى سلسلة نصية بدلاً من طباعة الإخراج إلى طرفية:

// like printf(), the format string allows for placeholders like %d, %f, etc.
// pass parameters after the format string to fill them in
int sprintf(char *s, const char *format, ...);

تهيّئ sprintf محتويات سلسلة نصية من قيم أنواع مختلفة. ويشبه وسيطها format وسائط printf وscanf. وفيما يلي بعض الأمثلة:

char str[64];
float ave = 76.8;
int num = 2;

// initialize str to format string, filling in each placeholder with
// a char representation of its arguments' values
sprintf(str, "%s is %d years old and in grade %d", "Henry", 12, 7);
printf("%s\n", str);  // prints: Henry is 12 years old and in grade 7

sprintf(str, "The average grade on exam %d is %g", num, ave);
printf("%s\n", str);  // prints: The average grade on exam 2 is 76.8

دوال قيم المحارف الفردية

تحتوي مكتبة C القياسية (stdlib.h) على مجموعة دوال للتلاعب بقيم char الفردية واختبارها، منها:

#include <stdlib.h>   // include stdlib and ctypes to use these
#include <ctype.h>

int islower(ch);
int isupper(ch);       // these functions return a non-zero value if the
int isalpha(ch);       // test is TRUE, otherwise they return 0 (FALSE)
int isdigit(ch);
int isalnum(ch);
int ispunct(ch);
int isspace(ch);
char tolower(ch);     // returns ASCII value of lower-case of argument
char toupper(ch);

وفيما يلي بعض الأمثلة على استخدامها:

char str[64];
int len, i;

strcpy(str, "I see 20 ZEBRAS, GOATS, and COWS");

if ( islower(str[2]) ){
    printf("%c is lower case\n", str[2]);   // prints: s is lower case
}

len = strlen(str);
for (i = 0; i < len; i++) {
    if ( isupper(str[i]) ) {
        str[i] = tolower(str[i]);
    } else if( isdigit(str[i]) ) {
        str[i] = 'X';
    }
}
printf("%s\n", str);  // prints: i see XX zebras, goats, and cows

دوال تحويل السلاسل إلى أنواع أخرى

تحتوي stdlib.h أيضاً على دوال للتحويل بين السلاسل النصية وأنواع C الأخرى. فمثلاً:

#include <stdlib.h>

int atoi(const char *nptr);     // convert a string to an integer
double atof(const char *nptr);  // convert a string to a float

وفيما يلي مثال:

printf("%d %g\n", atoi("1234"), atof("4.56"));

لمزيد من المعلومات عن هذه الدوال وغيرها من دوال مكتبة C (بما في ذلك ما تفعله، وصيغة وسائطها، وما ترجعه، والترويسات التي يجب تضمينها لاستخدامها)، انظر صفحات الدليل man. فمثلاً، لعرض صفحة دليل strcpy، شغّل:

$ man strcpy

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

نبدأ بنظرة سريعة على البنى المعلَنة ساكنةً. انظر الفصل السابق لمزيد من التفاصيل.

2.7.1. مراجعة نوع البنية struct في C

يمثّل نوع البنية (struct) مجموعة غير متجانسة من البيانات؛ وهو آلية لمعالجة مجموعة من الأنواع المختلفة كوحدة واحدة متماسكة.

ويتضمّن تعريف أنواع struct واستخدامها في برامج C ثلاث خطوات:

  1. عرّف نوع struct يحدد قيم الحقول وأنواعها.
  2. أعلن متغيّرات من النوع struct.
  3. استخدم صيغة النقطة للوصول إلى قيم الحقول الفردية في المتغيّر.

في C، البنى قيم يسارية (يمكن أن تظهر في الجانب الأيسر من عبارة إسناد). وقيمة متغيّر struct هي محتوى ذاكرته (كل البايتات المكوّنة لقيم حقوله). وعند استدعاء دوال بوسائط struct، تُنسخ قيمة وسيطة struct (نسخة من كل بايتات جميع حقولها) إلى وسيط الدالة struct.

عند البرمجة بالبنى، وخصوصاً عند الجمع بين البنى والمصفوفات، من الجوهري التفكير بعناية في نوع كل تعبير. فكل حقل في struct يمثّل نوعاً محدداً، وتتبع صياغة الوصول إلى قيم الحقول ودلالات تمرير قيم الحقول الفردية إلى الدوال النوع المحدد لتلك الحقول.

يوضح برنامج المثال الكامل التالي تعريف نوع struct، والإعلان عن متغيّرات من ذلك النوع، والوصول إلى قيم الحقول، وتمرير البنى وقيم الحقول الفردية إلى الدوال. (نحذف بعض معالجة الأخطاء والتعليقات للوضوح).

#include <stdio.h>
#include <string.h>

/* define a new struct type (outside function bodies) */
struct studentT {
    char  name[64];
    int   age;
    float gpa;
    int   grad_yr;
};

/* function prototypes */
int checkID(struct studentT s1, int min_age);
void changeName(char *old, char *new);

int main(void) {
    int can_vote;
    // declare variables of struct type:
    struct studentT student1, student2;

    // access field values using .
    strcpy(student1.name, "Ruth");
    student1.age = 17;
    student1.gpa = 3.5;
    student1.grad_yr = 2021;

    // structs are lvalues
    student2 = student1;
    strcpy(student2.name, "Frances");
    student2.age = student1.age + 4;

    // passing a struct
    can_vote = checkID(student1, 18);
    printf("%s %d\n", student1.name, can_vote);

    can_vote = checkID(student2, 18);
    printf("%s %d\n", student2.name, can_vote);

    // passing a struct field value
    changeName(student2.name, "Kwame");
    printf("student 2's name is now %s\n", student2.name);

    return 0;
}

int checkID(struct studentT s, int min_age) {
    int ret = 1;

    if (s.age < min_age) {
        ret = 0;
        // changes age field IN PARAMETER COPY ONLY
        s.age = min_age + 1;
    }
    return ret;
}

void changeName(char *old, char *new) {
    if ((old == NULL) || (new == NULL)) {
        return;
    }
    strcpy(old,new);
}

وعند التشغيل، ينتج البرنامج:

Ruth 0
Frances 1
student 2's name is now Kwame

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

وعند تمرير حقل من struct إلى دالة، تطابق الدلالات نوع الحقل (نوع وسيط الدالة). فمثلاً، في الاستدعاء changeName، تُنسخ قيمة حقل name (عنوان أساس مصفوفة name داخل بنية student2) إلى الوسيط old، أي أن الوسيط يشير إلى مجموعة عناصر المصفوفة نفسها في الذاكرة التي تشير إليها وسيطته. وهكذا، يؤدي تغيير عنصر من المصفوفة في الدالة إلى تغيير قيمة العنصر في الوسيطة أيضاً؛ فتطابق دلالات تمرير حقل name نوع حقل name.

2.7.2. المؤشّرات والبنى

كما في أنواع C الأخرى، يمكن للمبرمجين الإعلان عن متغيّر كمؤشّر إلى نوع struct معرَّف من المستخدم. وتشبه دلالات استخدام متغيّر مؤشّر struct دلالات أنواع المؤشّرات الأخرى مثل int *.

تأمّل نوع struct studentT المقدَّم في مثال البرنامج السابق:

struct studentT {
    char  name[64];
    int   age;
    float gpa;
    int   grad_yr;
};

يستطيع المبرمج الإعلان عن متغيّرات من النوع struct studentT أو struct studentT * (مؤشّر إلى struct studentT):

struct studentT s;
struct studentT *sptr;

// think very carefully about the type of each field when
// accessing it (name is an array of char, age is an int ...)
strcpy(s.name, "Freya");
s.age = 18;
s.gpa = 4.0;
s.grad_yr = 2020;

// malloc space for a struct studentT for sptr to point to:
sptr = malloc(sizeof(struct studentT));
if (sptr == NULL) {
    printf("Error: malloc failed\n");
    exit(1);
}

لاحظ أن الاستدعاء malloc يهيّئ sptr ليشير إلى بنية مخصَّصة ديناميكياً في ذاكرة الكومة. واستخدام المعامل sizeof لحساب حجم الطلب الذي تطلبه malloc (مثل sizeof(struct studentT)) يضمن أن malloc تخصّص مساحة لكل قيم الحقول في البنية.

للوصول إلى الحقول الفردية في مؤشّر إلى struct، يجب أولاً إلغاء الإشارة إلى متغيّر المؤشّر. وبناءً على قواعد إلغاء الإشارة إلى المؤشّرات، قد تُغريك نفسك بالوصول إلى حقول struct هكذا:

// the grad_yr field of what sptr points to gets 2021:
(*sptr).grad_yr = 2021;

// the age field of what sptr points to gets s.age plus 1:
(*sptr).age = s.age + 1;

غير أنه لأن المؤشّرات إلى البنى شائعة الاستخدام جداً، توفّر C معاملاً خاصاً (→) يلغي الإشارة إلى struct ويصل إلى إحدى قيم حقوله معاً. فمثلاً، sptr→year يكافئ (*sptr).year. وفيما يلي بعض الأمثلة على الوصول إلى قيم الحقول باستخدام هذه الصياغة:

// the gpa field of what sptr points to gets 3.5:
sptr->gpa = 3.5;

// the name field of what sptr points to is a char *
// (can use strcpy to init its value):
strcpy(sptr->name, "Lars");

يرسم الشكل 1 تصوراً لما قد يبدو عليه المتغيّران s وsptr في الذاكرة بعد تنفيذ الشيفرة أعلاه. تذكّر أن malloc تخصّص ذاكرة من الكومة، وتُخصَّص المتغيّرات المحلية على المكدّس.

تُخزَّن جميع حقول البنية s (Freya) على المكدّس. ويخزّن المؤشّر sptr على المكدّس عنوان كومة لبنية طالب أخرى (Lars). الشكل 1. الفروق في تخطيط الذاكرة بين بنية مخصَّصة ساكنةً (بيانات على المكدّس) وبنية مخصَّصة ديناميكياً (بيانات على الكومة).

2.7.3. حقول المؤشّرات في البنى

يمكن أيضاً تعريف البنى بحيث تكون لها أنواع مؤشّرات كقيم حقول. فمثلاً:

struct personT {
    char *name;     // for a dynamically allocated string field
    int  age;
};

int main(void) {
    struct personT p1, *p2;

    // need to malloc space for the name field:
    p1.name = malloc(sizeof(char) * 8);
    strcpy(p1.name, "Zhichen");
    p1.age = 22;

    // first malloc space for the struct:
    p2 = malloc(sizeof(struct personT));

    // then malloc space for the name field:
    p2->name = malloc(sizeof(char) * 4);
    strcpy(p2->name, "Vic");
    p2->age = 19;
    ...

    // Note: for strings, we must allocate one extra byte to hold the
    // terminating null character that marks the end of the string.
}

في الذاكرة، ستبدو هذه المتغيّرات كما في الشكل 2 (لاحظ أي الأجزاء مخصَّص على المكدّس وأيها على الكومة).

مثال على بنية ذات حقل من نوع مؤشّر الشكل 2. تخطيط بنية ذات حقل مؤشّر في الذاكرة.

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

الجدول 1. أمثلة على الوصول إلى حقول البنية

التعبير

 p1

النوع

 struct personT

صياغة الوصول إلى الحقل

 p1.age, p1.name

التعبير

 p2

النوع

 struct personT *

صياغة الوصول إلى الحقل

 p2->age, p2->name

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

الجدول 2. الوصول إلى أنواع حقول بنية مختلفة

التعبير

 p1.age

النوع

 int

صياغة الوصول المثالية

 p1.age = 18;

التعبير

 p2->age

النوع

 int

صياغة الوصول المثالية

 p2->age = 18;

التعبير

 p1.name

النوع

 char *

صياغة الوصول المثالية

 printf("%s", p1.name);

التعبير

 p2->name

النوع

 char *

صياغة الوصول المثالية

 printf("%s", p2->name);

التعبير

 p1.name[2]

النوع

 char

صياغة الوصول المثالية

 p1.name[2] = 'a';

التعبير

 p2->name[2]

النوع

 char

صياغة الوصول المثالية

 p2->name[2] = 'a';

في فحص المثال الأخير، ابدأ بالتفكير في نوع المتغيّر الخارجي (p2 مؤشّر إلى struct personT). لذا، للوصول إلى قيمة حقل في البنية، يحتاج المبرمج إلى استخدام صياغة → (p2→name). ثم تأمّل نوع الحقل name، وهو char * يستخدم في هذا البرنامج للإشارة إلى مصفوفة من قيم char. وللوصول إلى موقع تخزين char محدد عبر الحقل name، استخدم صياغة فهرسة المصفوفة: p2→name[2] = 'a'.

2.7.4. مصفوفات البنى

يمكن دمج المصفوفات والمؤشّرات والبنى لإنشاء بنى بيانات أكثر تعقيداً. وفيما يلي بعض الأمثلة على الإعلان عن متغيّرات من أنواع مختلفة من مصفوفات البنى:

struct studentT classroom1[40];   // an array of 40 struct studentT

struct studentT *classroom2;      // a pointer to a struct studentT
                                  // (for a dynamically allocated array)

struct studentT *classroom3[40];  // an array of 40 struct studentT *
                                  // (each element stores a (struct studentT *))

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

// classroom1 is an array:
//    use indexing to access a particular element
//    each element in classroom1 stores a struct studentT:
//    use dot notation to access fields
classroom1[3].age = 21;

// classroom2 is a pointer to a struct studentT
//    call malloc to dynamically allocate an array
//    of 15 studentT structs for it to point to:
classroom2 = malloc(sizeof(struct studentT) * 15);

// each element in array pointed to by classroom2 is a studentT struct
//    use [] notation to access an element of the array, and dot notation
//    to access a particular field value of the struct at that index:
classroom2[3].year = 2013;

// classroom3 is an array of struct studentT *
//    use [] notation to access a particular element
//    call malloc to dynamically allocate a struct for it to point to
classroom3[5] = malloc(sizeof(struct studentT));

// access fields of the struct using -> notation
// set the age field pointed to in element 5 of the classroom3 array to 21
classroom3[5]->age = 21;

قد تبدو دالة تأخذ مصفوفة من النوع struct studentT كوسيط هكذا:

void updateAges(struct studentT *classroom, int size) {
    int i;

    for (i = 0; i < size; i++) {
        classroom[i].age += 1;
    }
}

ويمكن لبرنامج تمرير مصفوفة من struct studentT معلَنة ساكنةً أو مخصَّصة ديناميكياً إلى هذه الدالة:

updateAges(classroom1, 40);
updateAges(classroom2, 15);

تطابق دلالات تمرير classroom1 (أو classroom2) إلى updateAges دلالات تمرير مصفوفة معلَنة ساكنةً (أو مخصَّصة ديناميكياً) إلى دالة: إذ يشير الوسيط إلى مجموعة العناصر نفسها التي تشير إليها الوسيطة، وبالتالي تؤثر التغييرات في قيم المصفوفة داخل الدالة في عناصر الوسيطة.

يعرض الشكل 3 كيف قد يبدو المكدّس في الاستدعاء الثاني للدالة updateAges (مع إظهار مصفوفة classroom2 الممرَّرة بقيم حقول مثالية للبنية في كل عنصر من عناصرها).

يشير المتغيّر classroom2 في main إلى مصفوفة من بنى studentT على الكومة. وعند تمرير classroom2 إلى updateAges تُنسخ قيم المؤشّر، فيُتحصَّل على مؤشّر آخر يشير إلى مصفوفة الكومة نفسها. الشكل 3. تخطيط الذاكرة لمصفوفة من بنى studentT مُرِّرت إلى دالة.

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

لا يمكن تمرير مصفوفة classroom3 إلى الدالة updateAges لأن نوعها ليس نوع وسيط الدالة نفسه: فـ classroom3 مصفوفة من struct studentT * لا مصفوفة من struct studentT.

2.7.5. البنى ذاتية الإشارة

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

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

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

ولبناء الأخيرة، يعرّف المبرمج بنية node تحتوي عنصر قائمة واحداً ورابطاً إلى العقدة التالية في القائمة. وفيما يلي مثال يمكنه تخزين قائمة مترابطة من القيم الصحيحة:

struct node {
    int data;           // used to store a list element's data value
    struct node *next;  // used to point to the next node in the list
};

يمكن ربط نسخ هذا النوع struct معاً عبر الحقل next لإنشاء قائمة مترابطة.

ينشئ مقتطف الشيفرة المثال هذا قائمة مترابطة تحتوي ثلاثة عناصر (ويُشار إلى القائمة نفسها بالمتغيّر head الذي يشير إلى العقدة الأولى في القائمة):

struct node *head, *temp;
int i;

head = NULL;  // an empty linked list

head = malloc(sizeof(struct node));  // allocate a node
if (head == NULL) {
    printf("Error malloc\n");
    exit(1);
}
head->data = 10;    // set the data field
head->next = NULL;  // set next to NULL (there is no next element)

// add 2 more nodes to the head of the list:
for (i = 0; i < 2; i++) {
    temp = malloc(sizeof(struct node));  // allocate a node
    if (temp == NULL) {
        printf("Error malloc\n");
        exit(1);
    }
    temp->data = i;     // set data field
    temp->next = head;  // set next to point to current first node
    head = temp;        // change head to point to newly added node
}

لاحظ أن المتغيّر temp يشير مؤقتاً إلى node مخصَّصة بـ malloc تُهيَّأ ثم تُضاف إلى بداية القائمة بضبط حقلها next ليشير إلى العقدة التي يشير إليها حالياً head، ثم بتغيير head ليشير إلى هذه العقدة الجديدة.

ستبدو نتيجة تنفيذ هذه الشيفرة في الذاكرة كما في الشكل 4.

يحتوي متغيّرا المكدّس head وtemp على عنوان العقدة الأولى على الكومة. ويشير حقل next في العقدة الأولى إلى العقدة الثانية التي يشير حقل next فيها إلى الثالثة. أمّا مؤشّر next في العقدة الثالثة فهو null، ما يدل على نهاية القائمة. الشكل 4. تخطيط ثلاث عقد مثال لقائمة مترابطة في الذاكرة.

تدعم C دوالاً كثيرة لتنفيذ الإدخال/الإخراج القياسي وإدخال/إخراج الملفات. ونناقش في هذا القسم بعض الواجهات الأكثر استخداماً للإدخال/الإخراج في C.

2.8.1. الإدخال/الإخراج القياسي

يبدأ كل برنامج قيد التشغيل بثلاثة تدفقات إدخال/إخراج افتراضية: المخرج القياسي (stdout)، والدخل القياسي (stdin)، والخطأ القياسي (stderr). ويمكن للبرنامج كتابة (طباعة) الإخراج إلى stdout وstderr، ويمكنه قراءة قيم الإدخال من stdin. ويُعرَّف stdin عادةً لقراءة الإدخال من لوحة المفاتيح، بينما يُخرج stdout وstderr إلى الطرفية.

توفّر مكتبة stdio.h في C الدالة printf المستخدمة للطباعة إلى المخرج القياسي والدالة scanf التي يمكن استخدامها لقراءة القيم من الدخل القياسي. وتمتلك C أيضاً دوالاً لقراءة وكتابة محرف واحد في كل مرة (getchar وputchar)، إضافة إلى دوال ومكتبات أخرى لقراءة وكتابة المحارف إلى تدفقات الإدخال/الإخراج القياسية. ويجب على برنامج C تضمين stdio.h صراحةً لاستدعاء هذه الدوال.

يمكنك تغيير الموقع الذي يقرأ منه stdin وstdout و/أو stderr في برنامج قيد التشغيل أو يكتب إليه. وإحدى طرق فعل ذلك إعادة توجيه واحد منها أو كلها للقراءة من ملف أو الكتابة إليه. وفيما يلي بعض أوامر الصدفة المثالية لإعادة توجيه stdin أو stdout أو stderr في برنامج إلى ملف (حيث $ هو موجّه الصدفة):

#  redirect a.out's stdin to read from file infile.txt:
$ ./a.out < infile.txt

#  redirect a.out's stdout to print to file outfile.txt:
$ ./a.out > outfile.txt

# redirect a.out's stdout and stderr to a file out.txt
$ ./a.out &> outfile.txt

# redirect all three to different files:
#   (< redirects stdin, 1> stdout, and 2> stderr):
$ ./a.out < infile.txt 1> outfile.txt 2> errorfile.txt

printf

تشبه دالة printf في C استدعاءات print المنسّقة في Python، حيث يحدد المستدعي سلسلة تنسيق للطباعة. وغالباً ما تحتوي سلسلة التنسيق على محددات تنسيق خاصة، منها محارف خاصة تطبع علامات جدولة (\t) أو أسطراً جديدة (\n)، أو تحدد عناصر نائبة للقيم في الإخراج (% متبوعاً بمحدد نوع). وعند إضافة عناصر نائبة في سلسلة تنسيق تُمرَّر إلى printf، مرّر قيمها المقابلة كوسائط إضافية بعد سلسلة التنسيق. وفيما يلي بعض الاستدعاءات المثالية لـ printf:

printf.c

int x = 5, y = 10;
float pi = 3.14;

printf("x is %d and y is %d\n", x, y);

printf("%g \t %s \t %d\n", pi, "hello", y);

وعند التشغيل، تُخرج عبارات printf هذه:

x is 5 and y is 10
3.14 	 hello 	 10

لاحظ كيف تُطبع محارف الجدولة (\t) في الاستدعاء الثاني، واختلاف العناصر النائبة للتنسيق باختلاف أنواع القيم (%g و%s و%d).

وفيما يلي مجموعة من العناصر النائبة للتنسيق لأنواع C الشائعة. لاحظ أن العناصر النائبة لقيم long وlong long تتضمن بادئة l أو ll.

%f, %g: placeholders for a float or double value
%d:     placeholder for a decimal value (char, short, int)
%u:     placeholder for an unsigned decimal
%c:     placeholder for a single character
%s:     placeholder for a string value
%p:     placeholder to print an address value

%ld:    placeholder for a long value
%lu:    placeholder for an unsigned long value
%lld:   placeholder for a long long value
%llu:   placeholder for an unsigned long long  value

وفيما يلي بعض الأمثلة على استخدامها:

float labs;
int midterm;

labs = 93.8;
midterm = 87;

printf("Hello %s, here are your grades so far:\n", "Tanya");
printf("\t midterm: %d (out of %d)\n", midterm, 100);
printf("\t lab ave: %f\n", labs);
printf("\t final report: %c\n", 'A');

وعند التشغيل، سيبدو الناتج هكذا:

Hello Tanya, here are your grades so far:
	 midterm: 87 (out of 100)
	 lab ave: 93.800003
	 final report: A

تتيح لك C أيضاً تحديد عرض الحقل مع العناصر النائبة للتنسيق. وفيما يلي بعض الأمثلة:

%5.3f: print float value in space 5 chars wide, with 3 places beyond decimal
%20s:  print the string value in a field of 20 chars wide, right justified
%-20s: print the string value in a field of 20 chars wide, left justified
%8d:   print the int value in a field of 8 chars wide, right justified
%-8d:  print the int value in a field of 8 chars wide, left justified

وفيما يلي مثال أكبر يستخدم محددات عرض الحقل مع العناصر النائبة في سلسلة التنسيق:

printf_format.c

#include <stdio.h> // library needed for printf

int main(void) {
    float x, y;
    char ch;

    x = 4.50001;
    y = 5.199999;
    ch = 'a';      // ch stores ASCII value of 'a' (the value 97)

    // .1: print x and y with single precision
    printf("%.1f %.1f\n", x, y);

    printf("%6.1f \t %6.1f \t %c\n", x, y, ch);

    // ch+1 is 98, the ASCII value of 'b'
    printf("%6.1f \t %6.1f \t %c\n", x+1, y+1, ch+1);

    printf("%6.1f \t %6.1f \t %c\n", x*20, y*20, ch+2);
    return 0;
}

وعند التشغيل، يبدو ناتج البرنامج هكذا:

4.5 5.2
   4.5 	    5.2 	 a
   5.5 	    6.2 	 b
  90.0 	  104.0 	 c

لاحظ كيف يؤدي استخدام علامات الجدولة وعرض الحقل في عبارات printf الثلاث الأخيرة إلى إخراج جدولي.

وأخيراً، تعرّف C عناصر نائبة لعرض القيم بتمثيلات مختلفة:

%x:     print value in hexadecimal (base 16)
%o:     print value in octal (base 8)
%d:     print value in signed decimal  (base 10)
%u:     print value in unsigned decimal (unsigned base 10)
%e:     print float or double in scientific notation
(there is no formatting option to display a value in binary)

وفيما يلي مثال يستخدم عناصر نائبة لطباعة القيم بتمثيلات مختلفة:

int x;
char ch;

x = 26;
ch = 'A';

printf("x is %d in decimal, %x in hexadecimal and %o in octal\n", x, x, x);
printf("ch value is %d which is the ASCII value of  %c\n", ch, ch);

وعند التشغيل، يبدو ناتج البرنامج هكذا:

x is 26 in decimal, 1a in hexadecimal and 32 in octal
ch value is 65 which is the ASCII value of  A

scanf

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

تشبه وسائط الدالة scanf وسائط printf: تأخذ scanf سلسلة تنسيق تحدد عدد قيم الإدخال المراد قراءتها ونوعها، تتبعها مواقع متغيّرات البرنامج التي ينبغي تخزين القيم فيها. وكثيراً ما تجمع البرامج بين معامل عنوان (&) واسم متغيّر لإنتاج موقع المتغيّر في ذاكرة البرنامج — أي عنوان الذاكرة الخاص بالمتغيّر. وفيما يلي استدعاء مثال لـ scanf يقرأ قيمتين (int وfloat):

scanf_ex.c

int x;
float pi;

// read in an int value followed by a float value ("%d%g")
// store the int value at the memory location of x (&x)
// store the float value at the memory location of pi (&pi)
scanf("%d%g", &x, &pi);

يجب فصل قيم الإدخال الفردية بمحرف مسافة بيضاء واحد على الأقل (مثل المسافات وعلامات الجدولة والأسطر الجديدة). غير أن scanf تتجاوز محارف المسافات البيضاء البادئة واللاحقة أثناء إيجادها بداية كل قيمة حرفية عددية ونهايتها. ونتيجةً لذلك، يمكن للمستخدم إدخال القيمتين 8 و3.14 بأي مقدار من المسافات البيضاء قبلهما أو بعدهما (ومحرف مسافة بيضاء واحد أو أكثر بينهما)، وستقرأ scanf دائماً 8 وتسنِدها إلى x، وتقرأ 3.14 وتسنِدها إلى pi. فمثلاً، هذا الإدخال بمسافات كثيرة بين القيمتين سيؤدي إلى قراءة 8 وتخزينها في x، و3.14 وتخزينها في pi:

           8                   3.14

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

// read in an int and a float separated by at least one white space character
scanf("%d%g",&x, &pi);

getchar وputchar

تقرأ الدالتان getchar وputchar في C وتكتبان على التوالي قيمة محرف واحد من stdin وإلى stdout. وتُفيد getchar بوجه خاص في برامج C التي تحتاج إلى دعم كشف الأخطاء ومعالجة إدخال المستخدم السيئ التكوين بعناية (فـ scanf ليست متينة بهذه الطريقة).

ch = getchar();  // read in the next char value from stdin
putchar(ch);     // write the value of ch to stdout

2.8.2. إدخال/إخراج الملفات

تتضمن مكتبة الإدخال/الإخراج القياسية في C (stdio.h) واجهة تدفق (stream) لإدخال/إخراج الملفات. ويخزّن الملف (file) بيانات دائمة: أي بيانات تبقى بعد تنفيذ البرنامج الذي أنشأها. ويمثّل الملف النصي تدفقاً من المحارف، ويتتبع كل ملف مفتوح موضعه الحالي في تدفق المحارف. وعند فتح ملف، يبدأ الموضع الحالي من أول محرف في الملف، ويتحرك نتيجة كل محرف يُقرأ (أو يُكتب) في الملف. ولقراءة المحرف العاشر في ملف، يجب أولاً قراءة المحارف التسعة الأولى (أو نقل الموضع الحالي صراحةً إلى المحرف العاشر باستخدام الدالة fseek).

ترى واجهة ملفات C الملف كتدفق إدخال أو إخراج، وتقرأ دوال المكتبة من الموضع التالي في تدفق الملف أو تكتب إليه. وتقوم الدالتان fprintf وfscanf مقام نظيرَي إدخال/إخراج الملفات للدالتين printf وscanf. وهما تستخدمان سلسلة تنسيق لتحديد ما يُكتب أو يُقرأ، وتتضمنان وسائط توفّر قيماً أو مساحة تخزين للبيانات التي تُكتب أو تُقرأ. وبالمثل، توفّر المكتبة الدوال fputc وfgetc وfputs وfgets لقراءة وكتابة محارف أو سلاسل نصية فردية إلى تدفقات الملفات. ورغم وجود مكتبات كثيرة تدعم إدخال/إخراج الملفات في C، فإننا نعرض بالتفصيل واجهة تدفق مكتبة stdio.h للملفات النصية فقط.

قد تحتوي الملفات النصية على محارف خاصة مثل تدفقي stdin وstdout: أسطر جديدة ('\n') وعلامات جدولة ('\t') وغيرها. وإضافةً إلى ذلك، عند الوصول إلى نهاية بيانات ملف، تولّد مكتبة الإدخال/الإخراج في C محرف نهاية ملف خاصاً (EOF)، يمثّل نهاية الملف. ويمكن للدوال التي تقرأ من ملف اختبار EOF لتحديد متى وصلت إلى نهاية تدفق الملف.

2.8.3. استخدام الملفات النصية في C

لقراءة ملف في C أو الكتابة إليه، اتبع هذه الخطوات:

أعلن عن متغيّر FILE *:

FILE *infile;
FILE *outfile;

تنشئ هذه الإعلانات متغيّرات مؤشّرات إلى نوع FILE * معرَّف في المكتبة. ولا يمكن إلغاء الإشارة إلى هذه المؤشّرات في برنامج تطبيقي. وبدلاً من ذلك، تشير إلى تدفق ملف محدد عند تمريرها إلى دوال مكتبة الإدخال/الإخراج.

افتح الملف: اربط المتغيّر بتدفق ملف فعلي باستدعاء fopen. وعند فتح ملف، يحدد وسيط الوضع ما إذا كان البرنامج يفتحه للقراءة ("r") أو الكتابة ("w") أو الإلحاق ("a"):

infile = fopen("input.txt", "r");  // relative path name of file, read mode
if (infile == NULL) {
    printf("Error: unable to open file %s\n", "input.txt");
    exit(1);
}

// fopen with absolute path name of file, write mode
outfile = fopen("/home/me/output.txt", "w");
if (outfile == NULL) {
    printf("Error: unable to open outfile\n");
    exit(1);
}

ترجع الدالة fopen القيمة NULL للإبلاغ عن الأخطاء، وقد تحدث الأخطاء إذا أُعطي اسم ملف غير صالح أو لم يكن للمستخدم إذن بفتح الملف المحدد (مثلاً، عدم امتلاك أذونات كتابة لملف output.txt).

استخدم عمليات الإدخال/الإخراج للقراءة أو الكتابة أو نقل الموضع الحالي في الملف:

int ch;  // EOF is not a char value, but is an int.
         // since all char values can be stored in int, use int for ch

ch = getc(infile);      // read next char from the infile stream
if (ch != EOF) {
    putc(ch, outfile);  // write char value to the outfile stream
}

أغلق الملف: استخدم fclose لإغلاق الملف عندما لا يعود البرنامج بحاجة إليه:

fclose(infile);
fclose(outfile);

توفّر مكتبة stdio أيضاً دوالاً لتغيير الموضع الحالي في ملف:

// to reset current position to beginning of file
void rewind(FILE *f);

rewind(infile);

// to move to a specific location in the file:
fseek(FILE *f, long offset, int whence);

fseek(f, 0, SEEK_SET);    // seek to the beginning of the file
fseek(f, 3, SEEK_CUR);    // seek 3 chars forward from the current position
fseek(f, -3, SEEK_END);   // seek 3 chars back from the end of the file

2.8.4. دوال الإدخال/الإخراج القياسية وللملفات في stdio.h

تحتوي مكتبة stdio.h في C على دوال كثيرة للقراءة والكتابة في الملفات وإلى التدفقات القياسية الشبيهة بالملفات (stdin وstdout وstderr). ويمكن تصنيف هذه الدوال إلى دوال إدخال/إخراج محرفية وسلسلية ومنسّقة. وباختصار، فيما يلي بعض التفاصيل الإضافية عن مجموعة فرعية من هذه الدوال:

// ---------------
// Character Based
// ---------------

// returns the next character in the file stream (EOF is an int value)
int fgetc(FILE *f);

// writes the char value c to the file stream f
// returns the char value written
int fputc(int c, FILE *f);

// pushes the character c back onto the file stream
// at most one char (and not EOF) can be pushed back
int ungetc(int c, FILE *f);

// like fgetc and fputc but for stdin and stdout
int getchar();
int putchar(int c);

// -------------
// String  Based
// -------------

// reads at most n-1 characters into the array s stopping if a newline is
// encountered, newline is included in the array which is '\0' terminated
char *fgets(char *s, int n, FILE *f);

// writes the string s (make sure '\0' terminated) to the file stream f
int fputs(char *s, FILE *f);

// ---------
// Formatted
// ---------

// writes the contents of the format string to file stream f
//   (with placeholders filled in with subsequent argument values)
// returns the number of characters printed
int fprintf(FILE *f, char *format, ...);

// like fprintf but to stdout
int printf(char *format, ...);

// use fprintf to print stderr:
fprintf(stderr, "Error return value: %d\n", ret);

// read values specified in the format string from file stream f
//   store the read-in values to program storage locations of types
//   matching the format string
// returns number of input items converted and assigned
//   or EOF on error or if EOF was reached
int fscanf(FILE *f, char *format, ...);

// like fscanf but reads from stdin
int scanf(char *format, ...);

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

يمكن أن تتضمن سلسلة تنسيق fscanf الصياغة التالية التي تحدد أنواعاً مختلفة من القيم وطرقاً مختلفة للقراءة من تدفق الملف:

%d integer
%f float
%lf double
%c character
%s string, up to first white space

%[...] string, up to first character not in brackets
%[0123456789] would read in digits
%[^...] string, up to first character in brackets
%[^\n] would read everything up to a newline

قد يكون من الصعب ضبط سلسلة تنسيق fscanf بشكل صحيح، خصوصاً عند قراءة مزيج من الأنواع العددية والسلسلية أو المحرفية من ملف.

وفيما يلي بضعة استدعاءات مثالية لـ fscanf (وواحد لـ fprintf) بسلاسل تنسيق مختلفة (لنفترض أن استدعاءات fopen أعلاه نُفِّذت بنجاح):

int x;
double d;
char c, array[MAX];

// write int & char values to file separated by colon with newline at the end
fprintf(outfile, "%d:%c\n", x, c);

// read an int & char from file where int and char are separated by a comma
fscanf(infile, "%d,%c", &x, &c);

// read a string from a file into array (stops reading at whitespace char)
fscanf(infile,"%s", array);

// read a double and a string up to 24 chars from infile
fscanf(infile, "%lf %24s", &d, array);

// read in a string consisting of only char values in the specified set (0-5)
// stops reading when...
//   20 chars have been read OR
//   a character not in the set is reached OR
//   the file stream reaches end-of-file (EOF)
fscanf(infile, "%20[012345]", array);

// read in a string; stop when reaching a punctuation mark from the set
fscanf(infile, "%[^.,:!;]", array);

// read in two integer values: store first in long, second in int
// then read in a char value following the int value
fscanf(infile, "%ld %d%c", &x, &b, &c);

في المثال الأخير أعلاه، تقرأ سلسلة التنسيق صراحةً قيمة محرف بعد عدد لضمان تقدّم الموضع الحالي لتدفق الملف تقدماً صحيحاً لأي استدعاءات لاحقة لـ fscanf. فمثلاً، كثيراً ما يُستخدم هذا النمط لقراءة محرف مسافة بيضاء (مثل '\n') صراحةً (والتخلص منه)، لضمان أن يبدأ الاستدعاء التالي لـ fscanf من السطر التالي في الملف. وتكون قراءة محرف إضافي ضرورية إذا حاول الاستدعاء التالي لـ fscanf قراءة قيمة محرف. وإلا، فبعدم استهلاك محرف السطر الجديد، سيقرأ الاستدعاء التالي لـ fscanf محرف السطر الجديد بدلاً من المحرف المقصود. أما إذا قرأ الاستدعاء التالي قيمة عددية، فسيُتخلص تلقائياً من محارف المسافات البيضاء البادئة بواسطة fscanf، ولا يحتاج المبرمج إلى قراءة المحرف \n صراحةً من تدفق الملف.

عُرضت تقريباً كل لغة البرمجة C في الأقسام السابقة. ونغطي في هذا القسم بعض ميزات لغة C المتقدمة المتبقية وبعض موضوعات البرمجة والتصريف المتقدمة في C:

الثوابت وعبارات switch والأنواع المعدودة وtypedef ميزات من لغة C مفيدة لإنشاء شيفرة أكثر قابلية للقراءة وأكثر قابلية للصيانة. وتُستخدم الثوابت والأنواع المعدودة وtypedef لتعريف أسماء بديلة للقيم الحرفية والأنواع في البرامج. ويمكن استخدام عبارات switch بدلاً من بعض سلاسل عبارات if-else if المتتابعة.

ثوابت C

الثابت (constant) اسم بديل لقيمة حرفية في C. وتُستخدم الثوابت بدلاً من القيمة الحرفية لجعل الشيفرة أكثر قابلية للقراءة وأسهل في التعديل. وفي C، تُعرَّف الثوابت خارج جسم دالة بالصياغة التالية:

#define const_name (literal_value)

وفيما يلي مثال على برنامج جزئي يعرّف ثلاثة ثوابت (N وPI وNAME) ويستخدمها:

#include <stdio.h>
#include <stdlib.h>

#define N    (20)        // N:  alias for the literal value 20
#define PI   (3.14)      // PI: alias for the literal value 3.14
#define NAME ("Sarita")  // NAME: alias for the string literal "Sarita"

int main(void) {
  int array[N];   // an array of 20 ints
  int *d_arr, i;
  double area, circ, radius;

  radius = 12.3;
  area = PI*radius*radius;
  circ = 2*PI*radius;

  d_arr = malloc(sizeof(int)*N);
  if(d_arr == NULL) {
    printf("Sorry, %s, malloc failed!\n", NAME);
    exit(1);
  }
  for(i=0; i < N; i++) {
    array[i] = i;
    d_arr[i] = i*2;
  }
  ...

استخدام الثوابت يجعل الشيفرة أكثر قابلية للقراءة (ففي تعبير ما، لـ PI معنى أكبر من 3.14). كما يجعل استخدام الثوابت الشيفرة أسهل في التعديل. فمثلاً، لتغيير حدود المصفوفات ودقة قيمة pi في البرنامج أعلاه، لا يحتاج المبرمج إلا إلى تغيير تعريفات ثوابتها وإعادة التصريف؛ وستستخدم كل الشيفرة التي تستعمل الثابت قيمه الجديدة. فمثلاً:

#define N    (50)        // redefine N from 20 to 50
#define PI   (3.14159)   // redefine PI to higher precision

int main(void) {
  int array[N];  // now allocates an array of size 50
  ...
  area = PI*radius*radius;        // now uses 3.14159 for PI
  d_arr = malloc(sizeof(int)*N);  // now mallocs array of 50 ints
  ...
  for(i=0; i < N; i++) {    // now iterates over 50 elements
  ...

من المهم تذكّر أن الثوابت ليست قيماً يسارية — فهي أسماء بديلة لقيم حرفية من أنواع C. ونتيجةً لذلك، لا يمكن تغيير قيمها وقت التشغيل كما يُفعل بقيم المتغيّرات. فالشيفرة التالية مثلاً تسبب خطأ تصريف:

#define N  20

int main(void) {
  ...
  N = 50;  // compilation error: `20 = 50` is not valid C

عبارات switch

يمكن استخدام عبارة switch في C بدلاً من بعض تسلسلات شيفرة if-else if المتسلسلة، لا كلها. ورغم أن switch لا توفّر قدرة تعبيرية إضافية للغة البرمجة C، فإنها كثيراً ما تعطي تسلسلات تفرّع شيفرة أكثر إيجازاً. وقد تتيح أيضاً للمصرّف إنتاج شيفرة تفرّع تنفَّذ بكفاءة أعلى من شيفرة if-else if المتسلسلة المكافئة.

تبدو صياغة عبارة switch في C هكذا:

switch (<expression>) {

   case <literal value 1>:
        <statements>;
        break;         // breaks out of switch statement body
   case <literal value 2>:
        <statements>;
        break;         // breaks out of switch statement body
   ...
   default:            // default label is optional
        <statements>;
}

وتُنفَّذ عبارة switch كما يلي:

  1. تُقيَّم expression أولاً.
  2. بعد ذلك، يبحث switch عن قيمة حرفية case تطابق قيمة التعبير.
  3. عند إيجاد قيمة حرفية case مطابقة، يبدأ تنفيذ العبارات التي تليها مباشرة.
  4. وإذا لم توجد case مطابقة، يبدأ تنفيذ العبارات في التسمية default إن وُجدت.
  5. وإلا فلا تُنفَّذ أي عبارات في جسم عبارة switch.

وبعض القواعد المتعلقة بعبارات switch:

  • يجب أن تكون القيمة المرتبطة بكل case قيمة حرفية — إذ لا يمكن أن تكون تعبيراً. ويُطابق التعبير الأصلي في التساوي فقط مع القيم الحرفية المرتبطة بكل case.
  • يوقف الوصول إلى عبارة break تنفيذ جميع العبارات المتبقية داخل جسم عبارة switch. أي أن break يخرج من جسم عبارة switch ويواصل التنفيذ بالعبارة التالية بعد كتلة switch بأكملها.
  • تحدد عبارة case ذات القيمة المطابقة نقطة البداية في تسلسل عبارات C التي ستُنفَّذ — إذ ينقل التنفيذ إلى موضع داخل جسم switch لبدء تنفيذ الشيفرة. وهكذا، إذا لم توجد عبارة break في نهاية case معيّن، تُنفَّذ العبارات الواقعة تحت عبارات case التالية بالترتيب حتى تُنفَّذ عبارة break أو يُبلَغ نهاية جسم عبارة switch.
  • التسمية default اختيارية. وإذا وُجدت، فيجب أن تكون في النهاية.

وفيما يلي برنامج مثال يحتوي عبارة switch:

#include <stdio.h>

int main(void) {
    int num, new_num = 0;

    printf("enter a number between 6 and 9: ");
    scanf("%d", &num);

    switch(num) {
        case 6:
            new_num = num + 1;
            break;
        case 7:
            new_num = num;
            break;
        case 8:
            new_num = num - 1;
            break;
        case 9:
            new_num = num + 2;
            break;
        default:
            printf("Hey, %d is not between 6 and 9\n", num);
    }
    printf("num %d  new_num %d\n", num, new_num);
    return 0;
}

وفيما يلي بعض تشغيلات هذا المثال:

./a.out
enter a number between 6 and 9: 9
num 9  new_num 11

./a.out
enter a number between 6 and 9: 6
num 6  new_num 7

./a.out
enter a number between 6 and 9: 12
Hey, 12 is not between 6 and 9
num 12  new_num 0

الأنواع المعدودة

النوع المعدود (enumerated type) (enum) طريقة لتعريف مجموعة من ثوابت الأعداد الصحيحة المترابطة. وكثيراً ما تُستخدم عبارات switch والأنواع المعدودة معاً.

وينبغي تعريف النوع المعدود خارج جسم دالة، بالصياغة التالية (enum كلمة مفتاحية في C):

enum type_name {
   CONST_1_NAME,
   CONST_2_NAME,
   ...
   CONST_N_NAME
};

لاحظ أن حقول الثوابت تُحدَّد بقائمة أسماء مفصولة بفواصل ولا تُعطى قيماً صراحةً. وبشكل افتراضي، يُسنَد إلى الثابت الأول في القائمة القيمة 0، وإلى الثاني القيمة 1، وهكذا.

وفيما يلي مثال على تعريف نوع معدود لأيام الأسبوع:

enum days_of_week {
   MON,
   TUES,
   WED,
   THURS,
   FRI
};

يُعلَن متغيّر من قيمة نوع معدود باستخدام اسم النوع enum type_name، ويمكن استخدام قيم الثوابت التي يعرّفها في التعبيرات. فمثلاً:

enum days_of_week day;

day = THURS;

if (day > WED) {
  printf("The weekend is arriving soon!\n");
}

يشبه النوع المعدود تعريف مجموعة ثوابت باستخدام #define هكذا:

#define MON    0
#define TUES   1
#define WED    2
#define THURS  3
#define FRI    4

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

ومن الميزات الجميلة الأخرى للأنواع المعدودة سهولة إضافة ثوابت إلى المجموعة أو حذفها دون الحاجة إلى تغيير قيمها كلها. فمثلاً، إذا أراد المستخدم إضافة السبت والأحد إلى مجموعة الأيام والحفاظ على الترتيب النسبي للأيام، فيمكنه إضافتهما إلى تعريف النوع المعدود دون الحاجة إلى إعادة تعريف قيم الأيام الأخرى صراحةً كما قد يحتاج مع تعريفات ثوابت #define:

enum days_of_week {
   SUN,        // SUN will now be 0
   MON,        // MON will now be 1, and so on
   TUES,
   WED,
   THURS,
   FRI,
   SAT
};

ورغم إسناد القيم ضمنياً إلى ثوابت النوع المعدود، يمكن للمبرمج أيضاً إسناد قيم محددة إليها باستخدام صياغة = val. فمثلاً، إذا أراد المبرمج أن تبدأ قيم أيام الأسبوع من 1 بدلاً من 0، فيمكنه فعل ما يلي:

enum days_of_week {
   SUN = 1,  // start the sequence at 1
   MON,      // this is 2 (next value after 1)
   TUES,     // this is 3, and so on
   WED,
   THURS,
   FRI,
   SAT
};

ولأن النوع المعدود يعرّف أسماء بديلة لمجموعة قيم حرفية int، تُطبع قيمة النوع المعدود بقيمته int لا باسم الاسم البديل. فمثلاً، بالنظر إلى التعريف أعلاه لـ enum days_of_week، يطبع ما يلي 3 لا السلسلة "TUES":

enum days_of_week day;

day = TUES;
printf("Today is %d\n", day);

كثيراً ما تُستخدم الأنواع المعدودة مع عبارات switch كما في الشيفرة المثالية أدناه. ويوضح المثال أيضاً عبارة switch بعدة حالات مرتبطة بمجموعة العبارات نفسها، وعبارة case لا تحتوي break قبل عبارة case التالية (عندما يكون val هو FRI، تُنفَّذ عبارتا printf قبل مصادفة break؛ وعندما يكون val هو MON أو WED، تُنفَّذ عبارة واحدة فقط من عبارات printf قبل break):

// an int because we are using scanf to assign its value
int val;

printf("enter a value between %d and %d: ", SUN, SAT);
scanf("%d", &val);

switch (val) {
  case FRI:
     printf("Orchestra practice today\n");
  case MON:
  case WED:
     printf("PSYCH 101 and CS 231 today\n");
     break;
  case TUES:
  case THURS:
     printf("Math 311 and HIST 140 today\n");
     break;
  case SAT:
     printf("Day off!\n");
     break;
  case SUN:
     printf("Do weekly pre-readings\n");
     break;
  default:
     printf("Error: %d is not a valid day\n", val);
};

typedef

توفّر C طريقة لتعريف نوع جديد يكون اسماً بديلاً لنوع موجود باستخدام الكلمة المفتاحية typedef. وبعد التعريف، يمكن الإعلان عن متغيّرات باستخدام هذا الاسم البديل الجديد للنوع. وتُستخدم هذه الميزة عادةً لجعل البرنامج أكثر قابلية للقراءة واستخدام أسماء أنواع أقصر، غالباً للبنى والأنواع المعدودة. وفيما يلي صيغة تعريف نوع جديد بـ typedef:

typedef existing_type_name new_type_alias_name;

وفيما يلي برنامج جزئي مثال يستخدم typedef:

#define MAXNAME  (30)
#define MAXCLASS (40)

enum class_year {
  FIRST = 1,
  SECOND,
  JUNIOR,
  SENIOR,
  POSTGRAD
};

// classYr is an alias for enum class_year
typedef enum class_year classYr;

struct studentT {
  char name[MAXNAME];
  classYr year;     // use classYr type alias for field type
  float gpa;
};

// studentT is an alias for struct studentT
typedef struct studentT  studentT;

// ull is an alias for unsigned long long
typedef unsigned long long ull;

int main(void) {

  // declare variables using typedef type names
  studentT class[MAXCLASS];
  classYr yr;
  ull num;

  num = 123456789;
  yr = JUNIOR;
  strcpy(class[0].name, "Sarita");
  class[0].year = SENIOR;
  class[0].gpa = 3.75;

  ...

ولأن typedef يُستخدم كثيراً مع البنى، توفّر C صياغة لدمج typedef وتعريف بنية معاً بوضع typedef قبل تعريف البنية وسرد اسم الاسم البديل للنوع بعد } المغلقة في تعريف البنية. فمثلاً، يعرّف ما يلي نوع بنية struct studentT واسماً بديلاً للنوع باسم studentT معاً:

typedef struct studentT {
  char name[MAXNAME];
  classYr year;     // use classYr type alias for field type
  float gpa;
} studentT;

ويعادل هذا التعريف كتابة typedef منفصلاً بعد تعريف البنية كما في المثال السابق.

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

تُمرَّر أي وسائط سطر أوامر يقدمها المستخدم إلى الدالة main كقيم وسائط. ولكتابة برنامج يأخذ وسائط سطر أوامر، يجب أن يتضمن تعريف الدالة main وسيطين، هما argc وargv:

int main(int argc, char *argv[]) { ...

لاحظ أنه يمكن أيضاً تمثيل نوع الوسيط الثاني بـ char **argv.

يخزّن الوسيط الأول argc عدد الوسائط. وتمثّل قيمته عدد وسائط سطر الأوامر الممرَّرة إلى الدالة main (بما فيها اسم البرنامج). فمثلاً، إذا أدخل المستخدم

./a.out 10 11 200

فسيحمل argc القيمة 4 (يُحتسب a.out كوسيط سطر الأوامر الأول، و10 و11 و200 كالوسائط الثلاثة الأخرى).

ويخزّن الوسيط الثاني argv متجه الوسائط. وهو يحتوي قيمة كل وسيط من وسائط سطر الأوامر. ويُمرَّر كل وسيط سطر أوامر كقيمة سلسلة نصية، لذا فإن نوع argv مصفوفة سلاسل نصية (أو مصفوفة من مصفوفات char). وتحتوي مصفوفة argv العناصر argc + 1. وتخزّن العناصر argc الأولى سلاسل وسائط سطر الأوامر، ويخزّن العنصر الأخير NULL دلالةً على نهاية قائمة وسائط سطر الأوامر. فمثلاً، في سطر الأوامر المدخل أعلاه، ستبدو مصفوفة argv كما في الشكل 1:

مثال على قائمة argv من 5 عناصر: عناصر للقيم المدخلة الثلاث (10 و11 و200) مع ملف التنفيذ بوصفه العنصر الأول، وNULL بوصفه العنصر الأخير. الشكل 1. الوسيط argv الممرَّر إلى main مصفوفة سلاسل نصية. ويُمرَّر كل وسيط سطر أوامر كعنصر سلسلة منفصل في المصفوفة. وقيمة العنصر الأخير NULL، دلالةً على نهاية قائمة وسائط سطر الأوامر.

كثيراً ما يريد البرنامج تفسير وسيط سطر أوامر ممرَّر إلى main كنوع غير السلسلة النصية. وفي المثال أعلاه، قد يريد البرنامج استخراج القيمة الصحيحة 10 من قيمة السلسلة "10" في وسيط سطر أوامره الأول. وتوفّر مكتبة C القياسية دوالاً لتحويل السلاسل إلى أنواع أخرى. فمثلاً، الدالة atoi («a to i»، أي «ASCII to integer») تحوّل سلسلة من محارف الأرقام إلى قيمتها الصحيحة المقابلة:

int x;
x = atoi(argv[1]);  // x gets the int value 10

انظر قسم السلاسل النصية ومكتبة السلاسل لمزيد من المعلومات عن هذه الدوال. وبرنامج commandlineargs.c لمثال آخر على وسائط سطر الأوامر في C.

يمثّل نوع void * في C مؤشّراً عاماً — مؤشّراً إلى أي نوع، أو مؤشّراً إلى نوع غير محدد. وتسمح C بنوع مؤشّر عام لأن عناوين الذاكرة على النظام تُخزَّن دائماً بالعدد نفسه من البايتات (مثلاً، العناوين أربعة بايتات على أنظمة 32 بت وثمانية بايتات على أنظمة 64 بت). ونتيجةً لذلك، يحتاج كل متغيّر مؤشّر إلى عدد بايتات التخزين نفسه، ولأنها كلها بالحجم نفسه، يستطيع المصرّف تخصيص مساحة لمتغيّر void * دون معرفة النوع الذي يشير إليه. وفيما يلي مثال:

void *gen_ptr;
int x;
char ch;

gen_ptr = &x;  // gen_ptr can be assigned the address of an int
gen_ptr = &ch; // or the address of a char (or the address of any type)

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

ولأن void * نوع مؤشّر عام، لا يمكن إلغاء الإشارة إليه مباشرةً — إذ لا يعرف المصرّف حجم الذاكرة التي يشير إليها العنوان. فمثلاً، قد يشير العنوان إلى موقع تخزين int من أربعة بايتات أو إلى موقع تخزين char من بايت واحد في الذاكرة. لذا يجب على المبرمج إعادة تحويل مؤشّر void * صراحةً إلى مؤشّر من نوع محدد قبل إلغاء الإشارة إليه. وتخبر إعادة التحويل المصرّف بنوع متغيّر المؤشّر المحدد، ما يتيح له توليد شيفرة الوصول الصحيحة إلى الذاكرة لإلغاء الإشارة.

وفيما يلي مثالان على استخدام void *:

يعيد الاستدعاء malloc تحويل نوع إرجاعه void * إلى نوع المؤشّر المحدد للمتغيّر المستخدم لتخزين عنوان ذاكرة الكومة المرجَع:

int *array;
char *str;

array = (int *)malloc(sizeof(int) * 10); // recast void * return value
str = (char *)malloc(sizeof(char) * 20);

*array = 10;
str[0] = 'a';

كثيراً ما يصادف الطلاب void * عند إنشاء الخيوط. ويسمح استخدام نوع وسيط void * في دالة خيط للخيط بأخذ أي نوع من المؤشّرات الخاص بالتطبيق. وتمتلك الدالة pthread_create وسيطاً لدالة الخيط الرئيسية ووسيطاً void * لقيمة الوسيطة التي تمرّرها إلى دالة الخيط الرئيسية التي سينفّذها الخيط المنشأ حديثاً. ويجعل استخدام الوسيط void * الدالة pthread_create دالة إنشاء خيوط عامة؛ إذ يمكن استخدامها للإشارة إلى أي نوع من مواقع الذاكرة. وبالنسبة لبرنامج محدد يستدعي pthread_create، يعرف المبرمج نوع الوسيطة الممرَّرة إلى الوسيط void *، لذا يجب عليه إعادة تحويلها إلى نوعها المعروف قبل إلغاء الإشارة إليها. وفي هذا المثال، افترض أن العنوان الممرَّر إلى الوسيط args يحتوي عنوان متغيّر صحيح:

/*
 * an application-specific pthread main function
 * must have this function prototype: int func_name(void *args)
 *
 * any given implementation knows what type is really passed in
 *  args: pointer to an int value
 */
int my_thr_main(void *args) {
    int num;

    // first recast args to an int *, then dereference to get int value
    num = *((int *)args);  // num gets 6
    ...
}

int main(void) {
    int ret, x;
    pthread_t tid;

    x = 6;
    // pass the address of int variable (x) to pthread_create's void * param
    // (we recast &x as a (void *) to match the type of pthread_create's param)
    ret = pthread_create(&tid, NULL,
                         my_thr_main,    // a thread main function
                         (void *)(&x));  // &x will be passed to my_thr_main
    // ...

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

عند زيادته، يشير المؤشّر إلى موقع التخزين التالي من النوع الذي يشير إليه. فمثلاً، زيادة مؤشّر صحيح (int *) تجعله يشير إلى عنوان تخزين int التالي (أي العنوان الذي يبعد أربعة بايتات عن قيمته الحالية)، وزيادة مؤشّر محارف تجعله يشير إلى عنوان تخزين char التالي (أي العنوان الذي يبعد بايتاً واحداً عن قيمته الحالية).

في برنامج المثال التالي، نوضح كيفية استخدام حساب المؤشّرات للتلاعب بمصفوفة. أولاً أعلن متغيّرات مؤشّرات يطابق نوعها نوع عناصر المصفوفة:

#define N 10
#define M 20

int main(void) {
    // array declarations:
    char letters[N];
    int numbers[N], i, j;
    int matrix[N][M];

    // declare pointer variables that will access int or char array elements
    // using pointer arithmetic (the pointer type must match array element type)
    char *cptr = NULL;
    int *iptr = NULL;
    ...

بعد ذلك، هيّئ متغيّرات المؤشّرات على عنوان أساس المصفوفات التي ستتكرر عليها:

// make the pointer point to the first element in the array
cptr = &(letters[0]); //  &(letters[0])  is the address of element 0
iptr = numbers;       // the address of element 0 (numbers is &(numbers[0]))

ثم، باستخدام إلغاء الإشارة إلى المؤشّر، يستطيع برنامجنا الوصول إلى عناصر المصفوفة. وهنا نلغي الإشارة لإسناد قيمة إلى عنصر مصفوفة ثم نزيد متغيّر المؤشّر بمقدار واحد لتقديمه ليشير إلى العنصر التالي:

// initialized letters and numbers arrays through pointer variables
for (i = 0; i < N; i++) {
    // dereference each pointer and update the element it currently points to
    *cptr = 'a' + i;
    *iptr = i * 3;

    // use pointer arithmetic to set each pointer to point to the next element
    cptr++;  // cptr points to the next char address (next element of letters)
    iptr++;  // iptr points to the next int address  (next element of numbers)
}

لاحظ أن قيم المؤشّرات تُزاد داخل الحلقة في هذا المثال. وبذلك تجعل زيادة قيمتها تشير إلى العنصر التالي في المصفوفة. وهذا النمط يسير فعلياً عبر كل عنصر من عناصر المصفوفة بالطريقة نفسها التي يفعلها الوصول إلى cptr[i] أو iptr[i] في كل تكرار.

ملاحظة — دلالات حساب المؤشّرات ودالة الحساب الأساسية

دلالات حساب المؤشّرات مستقلة عن النوع: فتغيير قيمة أي نوع من المؤشّرات بمقدار N (أو ptr = ptr + N) يجعل المؤشّر يشير إلى مواقع تخزين N بعد قيمته الحالية (أو يجعله يشير إلى عناصر N بعد العنصر الحالي الذي يشير إليه). ونتيجةً لذلك، تؤدي زيادة مؤشّر من أي نوع إلى جعله يشير إلى موقع الذاكرة التالي مباشرةً من النوع الذي يشير إليه.

غير أن دالة الحساب الفعلية التي يولّدها المصرّف لتعبير حساب مؤشّر تختلف بحسب نوع متغيّر المؤشّر (وبحسب عدد البايتات التي يستخدمها النظام لتخزين النوع الذي يشير إليه). فمثلاً، زيادة مؤشّر char ستزيد قيمته بمقدار واحد لأن عنوان char الصالح التالي يبعد بايتاً واحداً عن الموقع الحالي. أما زيادة مؤشّر int فستزيد قيمته بأربعة لأن عنوان العدد الصحيح الصالح التالي يبعد أربعة بايتات عن الموقع الحالي.

يمكن للمبرمج ببساطة أن يكتب ptr++ لجعل مؤشّر يشير إلى قيمة العنصر التالي. ويولّد المصرّف شيفرة تضيف العدد المناسب من البايتات للنوع المقابل الذي يشير إليه. وتضبط عملية الجمع فعلياً قيمته على العنوان الصالح التالي في الذاكرة من ذلك النوع.

يمكنك رؤية كيف عدّلت الشيفرة أعلاه عناصر المصفوفة بطباعة قيمها (نعرض ذلك أولاً باستخدام فهرسة المصفوفة ثم باستخدام حساب المؤشّرات للوصول إلى قيمة كل عنصر مصفوفة):

printf("\n array values using indexing to access: \n");
// see what the code above did:
for (i = 0; i < N; i++) {
    printf("letters[%d] = %c, numbers[%d] = %d\n",
           i, letters[i], i, numbers[i]);
}

// we could also use pointer arith to print these out:
printf("\n array values using pointer arith to access: \n");
// first: initialize pointers to base address of arrays:
cptr = letters;  // letters == &letters[0]
iptr = numbers;
for (i = 0; i < N; i++) {
    // dereference pointers to access array element values
    printf("letters[%d] = %c, numbers[%d] = %d\n",
            i, *cptr, i, *iptr);

    // increment pointers to point to the next element
    cptr++;
    iptr++;
}

وهذا ما يبدو عليه الناتج:

 array values using indexing to access:
letters[0] = a, numbers[0] = 0
letters[1] = b, numbers[1] = 3
letters[2] = c, numbers[2] = 6
letters[3] = d, numbers[3] = 9
letters[4] = e, numbers[4] = 12
letters[5] = f, numbers[5] = 15
letters[6] = g, numbers[6] = 18
letters[7] = h, numbers[7] = 21
letters[8] = i, numbers[8] = 24
letters[9] = j, numbers[9] = 27

 array values using pointer arith to access:
letters[0] = a, numbers[0] = 0
letters[1] = b, numbers[1] = 3
letters[2] = c, numbers[2] = 6
letters[3] = d, numbers[3] = 9
letters[4] = e, numbers[4] = 12
letters[5] = f, numbers[5] = 15
letters[6] = g, numbers[6] = 18
letters[7] = h, numbers[7] = 21
letters[8] = i, numbers[8] = 24
letters[9] = j, numbers[9] = 27

يمكن استخدام حساب المؤشّرات للتكرار على أي قطعة متجاورة من الذاكرة. وفيما يلي مثال يستخدم حساب المؤشّرات لتهيئة مصفوفة ثنائية الأبعاد معلَنة ساكنةً:

// sets matrix to:
// row 0:   0,   1,   2, ...,  99
// row 1: 100, 110, 120, ..., 199
//        ...
iptr = &(matrix[0][0]);
for (i = 0; i < N*M; i++) {
    *iptr = i;
    iptr++;
}

// see what the code above did:
printf("\n 2D array values inited using pointer arith: \n");
for (i = 0; i < N; i++) {
    for (j = 0; j < M; j++) {
        printf("%3d ", matrix[i][j]);
    }
    printf("\n");
}

return 0;
}

وسيبدو الناتج هكذا:

 2D array values initialized using pointer arith:
  0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19
 20  21  22  23  24  25  26  27  28  29  30  31  32  33  34  35  36  37  38  39
 40  41  42  43  44  45  46  47  48  49  50  51  52  53  54  55  56  57  58  59
 60  61  62  63  64  65  66  67  68  69  70  71  72  73  74  75  76  77  78  79
 80  81  82  83  84  85  86  87  88  89  90  91  92  93  94  95  96  97  98  99
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199

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

iptr = &numbers[2];
*iptr = -13;
iptr += 4;
*iptr = 9999;

بعد تنفيذ الشيفرة السابقة، ستبدو طباعة قيم مصفوفة numbers هكذا (لاحظ أن القيمتين عند الفهرس 2 والفهرس 6 تغيّرتا):

numbers[0] = 0
numbers[1] = 3
numbers[2] = -13
numbers[3] = 9
numbers[4] = 12
numbers[5] = 15
numbers[6] = 9999
numbers[7] = 21
numbers[8] = 24
numbers[9] = 27

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

تطبّق المكتبة (library) مجموعة من الدوال والتعريفات التي يمكن لبرامج أخرى استخدامها. وتتألف مكتبة C من جزأين:

  1. واجهة برمجة التطبيقات (API) للمكتبة، وهي تُعرَّف في ملف ترويسة واحد أو أكثر (ملفات .h) يجب تضمينها في ملفات شيفرة C المصدرية التي تخطط لاستخدام المكتبة. وتعرّف الترويسات ما تصدّره المكتبة لمستخدميها. وتشمل هذه التعريفات عادةً نماذج دوال المكتبة، وقد تشمل أيضاً إعلانات أنواع أو ثوابت أو متغيّرات عامة.
  2. تنفيذ وظائف المكتبة، ويُتاح للبرامج غالباً بصيغة ثنائية مصرَّفة مسبقاً تُربط (تضاف) إلى الملف التنفيذي الثنائي الذي ينشئه gcc. وقد تكون شيفرة المكتبة المصرَّفة مسبقاً في ملف أرشيف (libsomelib.a) يحتوي عدة ملفات .o يمكن ربطها ربطاً ساكناً في الملف التنفيذي وقت التصريف. أو قد تتألف بدلاً من ذلك من ملف كائن مشترك (libsomelib.so) يمكن ربطه ربطاً ديناميكياً وقت التشغيل في برنامج قيد التشغيل.

فمثلاً، تطبّق مكتبة سلاسل C مجموعة من الدوال للتلاعب بسلاسل C. ويعرّف ملف الترويسة string.h واجهتها، لذا يجب على أي برنامج يريد استخدام دوال مكتبة السلاسل أن يضمّن #include . وتنفيذ مكتبة سلاسل C جزء من مكتبة C القياسية الأكبر (libc) التي يربطها مصرّف gcc تلقائياً في كل ملف تنفيذي ينشئه.

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

هناك مكتبات عديدة متاحة لمبرمجي C لاستخدامها. فمثلاً، مكتبة خيوط POSIX (التي نناقشها في الفصل 10) تمكّن برامج C متعددة الخيوط. ويمكن لمبرمجي C أيضاً تنفيذ مكتباتهم الخاصة واستخدامها (وهو ما نناقشه في القسم التالي). وتميل برامج C الكبيرة إلى استخدام مكتبات C كثيرة، بعضها يربطه gcc ضمنياً، بينما يحتاج بعضها الآخر إلى ربط صريح بخيار سطر الأوامر -l لـ gcc.

لا تحتاج مكتبات C القياسية عادةً إلى ربط صريح بخيار -l أما المكتبات الأخرى فتحتاج. وكثيراً ما تحدد وثائق دالة مكتبة ما إذا كانت المكتبة تحتاج إلى ربط صريح عند التصريف. فمثلاً، مكتبة خيوط POSIX (pthread) ومكتبة readline تحتاجان إلى ربط صريح في سطر أوامر gcc:

$ gcc -o myprog myprog.c -pthread -lreadline

لاحظ أن ربط مكتبة خيوط POSIX حالة خاصة لا تتضمن البادئة -l. غير أن معظم المكتبات تُربط صراحةً في الملف التنفيذي باستخدام صياغة -l في سطر أوامر gcc. ولاحظ أيضاً أنه لا ينبغي تضمين الاسم الكامل لملف المكتبة في وسيطة -l لـ gcc؛ فملفات المكتبات تُسمّى شيئاً مثل libreadline.so أو libreadline.a، لكن البادئة lib واللاحقة .so أو .a في أسماء الملفات لا تُضمَّن. وقد يحتوي اسم ملف المكتبة الفعلي على أرقام إصدار (مثل libreadline.so.8.0) لا تُضمَّن أيضاً في خيار سطر الأوامر -l (-lreadline). وبعدم إجبار المستخدم على تحديد (أو حتى معرفة) الاسم الدقيق لملفات المكتبة المطلوب ربطها وموقعها، يكون gcc حراً في إيجاد أحدث إصدار من مكتبة في مسار مكتبات المستخدم. ويتيح ذلك أيضاً للمصرّف اختيار الربط الديناميكي عندما يتوفر كل من ملف كائن مشترك (.so) وملف أرشيف (.a) لمكتبة ما. وإذا أراد المستخدمون ربط المكتبات ربطاً ساكناً، فيمكنهم تحديد الربط الساكن صراحةً في سطر أوامر gcc. ويوفّر خيار --static طريقة لطلب الربط الساكن:

$ gcc -o myprog myprog.c --static -pthread -lreadline

خطوات التصريف

سيساعد توصيف خطوات تصريف برنامج C في توضيح كيفية ربط شيفرة المكتبات في ملف تنفيذي ثنائي. نعرض أولاً خطوات التصريف ثم نناقش (مع أمثلة) أنواع الأخطاء المختلفة التي قد تحدث عند تصريف برامج تستخدم مكتبات.

يحوّل مصرّف C ملف شيفرة C مصدرية (مثل myprog.c) إلى ملف تنفيذي ثنائي (مثل a.out) في أربع خطوات متميزة (إضافة إلى خطوة خامسة تحدث وقت التشغيل).

تعمل خطوة ما قبل التصريف أولاً وتوسّع توجيهات المعالج الأولي (preprocessor directives): أي توجيهات # التي تظهر في برنامج C، مثل #define و#include. وتشمل أخطاء التصريف في هذه الخطوة أخطاء صياغة في توجيهات المعالج الأولي أو عدم إيجاد gcc لملفات الترويسة المرتبطة بتوجيهات #include. ولعرض النتائج الوسيطة لخطوة ما قبل التصريف، مرّر العلامة -E إلى gcc (يمكن إعادة توجيه الناتج إلى ملف يمكن عرضه بمحرّر نصوص):

$ gcc -E  myprog.c
$ gcc -E  myprog.c  > out
$ vim out

تعمل خطوة التصريف بعد ذلك وتقوم بمعظم مهمة التصريف. فهي تحوّل شيفرة برنامج C المصدرية (myprog.c) إلى شيفرة تجميع خاصة بالآلة (myprog.s). ولغة التجميع صورة مقروءة بشرياً من تعليمات شيفرة الآلة الثنائية التي يستطيع الحاسوب تنفيذها. وتشمل أخطاء التصريف في هذه الخطوة أخطاء صياغة لغة C، وتحذيرات الرموز غير المعرَّفة، والأخطاء الناتجة عن التعريفات والنماذج الأولية للدوال المفقودة. ولعرض النتائج الوسيطة لخطوة التصريف، مرّر العلامة -S إلى gcc (ينشئ هذا الخيار ملفاً نصياً باسم myprog.s يحتوي ترجمة لغة التجميع لـ myprog.c، ويمكن عرضه في محرّر نصوص):

$ gcc -S  myprog.c
$ vim myprog.s

تحوّل خطوة التجميع شيفرة لغة التجميع إلى شيفرة كائن ثنائية قابلة لإعادة التوطين (myprog.o). ويحتوي ملف الكائن الناتج تعليمات شيفرة الآلة، لكنه ليس برنامجاً تنفيذياً كاملاً يمكن تشغيله وحده. وينتج مصرّف gcc على أنظمة Unix وLinux ملفات ثنائية بصيغة محددة تسمى ELF (صيغة قابلة للتنفيذ والربط Executable and Linkable Format). ولإيقاف التصريف بعد هذه الخطوة، مرّر العلامة -c إلى gcc (وهذا ينتج ملفاً باسم myprog.o). ويمكن عرض الملفات الثنائية (مثل ملفات a.out و.o) باستخدام objdump أو أدوات مشابهة لعرض الملفات الثنائية:

$ gcc -c  myprog.c

# disassemble functions in myprog.o with objdump:
$ objdump -d myprog.o

تعمل خطوة تحرير الربط أخيراً وتنشئ ملفاً تنفيذياً واحداً (a.out) من الثنائيات القابلة لإعادة التوطين (.o) والمكتبات (.a أو .so). وفي هذه الخطوة، يتحقق الرابط من أن أي مراجع إلى أسماء (رموز) في ملف .o موجودة في ملفات .o أو .a أو .so أخرى. فمثلاً، سيجد الرابط الدالة printf في مكتبة C القياسية (libc.so). وإذا لم يستطع الرابط إيجاد تعريف رمز ما، تفشل هذه الخطوة بخطأ يفيد بأن الرمز غير معرَّف. وتشغيل gcc دون علامات للتصريف الجزئي ينفّذ خطوات التصريف الأربع كلها لملف شيفرة C مصدرية (myprog.c) إلى ملف تنفيذي ثنائي (a.out) يمكن تشغيله:

$ gcc myprog.c
$ ./a.out

# disassemble functions in a.out with objdump:
$ objdump -d a.out

إذا ربط الملف التنفيذي الثنائي (a.out) شيفرة مكتبة ربطاً ساكناً (من ملفات مكتبة .a)، فإن gcc يضمّن نسخاً من دوال المكتبة من ملف .a في ملف a.out الناتج. وتُربط (bound) جميع الاستدعاءات لدوال المكتبة من التطبيق بالمواقع في ملف a.out التي نُسخت إليها دالة المكتبة. ويربط الربط اسماً بموقع في ذاكرة البرنامج. فمثلاً، ربط استدعاء لدالة مكتبة باسم gofish يعني استبدال استخدام اسم الدالة بعنوان الدالة في الذاكرة (نناقش عناوين الذاكرة بمزيد من التفصيل في فصول لاحقة).

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

تُحتاج خطوة الربط وقت التشغيل إذا رُبط a.out بملفات كائن مشتركة أثناء تحرير الربط (الخطوة 4). وفي مثل هذه الحالات، يجب تحميل شيفرة المكتبة الديناميكية (في ملفات .so) وقت التشغيل وربطها بالبرنامج قيد التشغيل. ويسمى هذا التحميل والربط وقت التشغيل لمكتبات الكائن المشترك الربط الديناميكي (dynamic linking). وعندما يشغّل المستخدم ملفاً تنفيذياً a.out بتبعيات كائن مشترك، ينفّذ النظام الربط الديناميكي قبل أن يبدأ البرنامج تنفيذ دالته main.

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

تسرد أداة ldd تبعيات الكائن المشترك لملف تنفيذي:

$ ldd a.out

يستطيع منقّح GNU (GDB) فحص برنامج قيد التشغيل وإظهار شيفرة الكائن المشترك المحمَّلة والمربوطة وقت التشغيل. نغطي GDB في الفصل 3. غير أن تفاصيل فحص جدول البحث عن الإجراءات (PLT)، المستخدم للربط وقت التشغيل لاستدعاءات دوال المكتبات المربوطة ديناميكياً، خارج نطاق هذا الكتاب.

لمزيد من التفاصيل عن مراحل التصريف وعن أدوات فحص المراحل المختلفة، انظر: مراحل التصريف.

أخطاء التصريف الشائعة المتعلقة بتصريف المكتبات وربطها

قد تحدث عدة أخطاء تصريف وربط لأن المبرمج نسي تضمين ملفات ترويسة المكتبة أو نسي ربط شيفرة المكتبة صراحةً. وسيساعد تحديد خطأ أو تحذير مصرّف gcc المرتبط بكل من هذه الأخطاء في تنقيح الأخطاء المتعلقة باستخدام مكتبات C.

تأمّل برنامج C التالي الذي يستدعي دالة libraryfunc من مكتبة examplelib المتاحة كملف كائن مشترك، libexamplelib.so:

#include <stdio.h>
#include <examplelib.h>

int main(int argc, char *argv[]) {
    int result;
    result = libraryfunc(6, MAX);
    printf("result is %d\n", result);
    return 0;
}

افترض أن ملف الترويسة examplelib.h يحتوي التعريفات في المثال التالي:

#define MAX 10   // a constant exported by the library

// a function exported by the library
extern int libraryfunc(int x, int y);

تعني البادئة extern قبل نموذج الدالة أن تعريف الدالة يأتي من ملف آخر — فهو ليس في ملف examplelib.h، بل توفّره إحدى ملفات .c في تنفيذ المكتبة.

نسيان تضمين ملف ترويسة

إذا نسي المبرمج تضمين examplelib.h في برنامجه، أنتج المصرّف تحذيرات وأخطاء بشأن استخدام البرنامج دوال وثوابت مكتبة لا يعرفها. فمثلاً، إذا صرّف المستخدم برنامجه دون #include سينتج gcc الناتج التالي:

# '-g': add debug information, '-c': compile to .o
gcc -g -c myprog.c

myprog.c: In function main:
myprog.c:8:12: warning: implicit declaration of function libraryfunc
   result = libraryfunc(6, MAX);
            ^~~~~~~~~~~

myprog.c:8:27: error: MAX undeclared (first use in this function)
   result = libraryfunc(6, MAX);
                           ^~~

يخبر تحذير المصرّف الأول (implicit declaration of function libraryfunc) المبرمج بأن المصرّف لا يستطيع إيجاد نموذج دالة للدالة libraryfunc. وهذا مجرد تحذير من المصرّف لأن gcc سيفترض أن نوع إرجاع الدالة عدد صحيح وسيواصل تصريف البرنامج. غير أنه لا ينبغي للمبرمجين إغفال تحذيرات كهذه! فهي تشير إلى أن البرنامج لا يضمّن نموذج دالة قبل استخدامها في ملف myprog.c، وهو غالباً بسبب عدم تضمين ملف ترويسة يحتوي نموذج الدالة.

وينتج خطأ المصرّف الثاني (MAX undeclared (first use in this function)) عن تعريف ثابت مفقود. ولا يستطيع المصرّف تخمين قيمة الثابت المفقود، لذا يفشل هذا التعريف المفقود بخطأ. وكثيراً ما تشير رسالة «غير معلَن» هذه إلى أن ملف ترويسة يعرّف ثابتاً أو متغيّراً عاماً مفقود أو لم يُضمَّن على النحو الصحيح.

نسيان ربط مكتبة

إذا ضمّن المبرمج ملف ترويسة المكتبة (كما في القائمة السابقة)، لكنه نسي ربط المكتبة صراحةً في خطوة تحرير الربط (الخطوة 4) من التصريف، فسيشير gcc إلى ذلك بخطأ undefined reference:

$ gcc -g myprog.c

In function main:
myprog.c:9: undefined reference to libraryfunc
collect2: error: ld returned 1 exit status

ينشأ هذا الخطأ من ld، وهو مكوّن الرابط في المصرّف. ويشير إلى أن الرابط لا يستطيع إيجاد تنفيذ دالة المكتبة libraryfunc التي تُستدعى في السطر 9 في myprog.c. ويشير خطأ undefined reference إلى أن مكتبة تحتاج إلى ربط صريح في الملف التنفيذي. وفي هذا المثال، سيصلح تحديد -lexamplelib في سطر أوامر gcc الخطأ:

$ gcc -g myprog.c  -lexamplelib

تعذّر على gcc إيجاد ملفات ترويسة أو مكتبة

سيفشل التصريف أيضاً بأخطاء إذا لم تكن ملفات ترويسة مكتبة أو تنفيذها موجودة في المجلدات التي يبحث فيها gcc افتراضياً. فمثلاً، إذا لم يستطع gcc إيجاد ملف examplelib.h، فسينتج رسالة خطأ كهذه:

$ gcc -c myprog.c -lexamplelib
myprog.c:1:10: fatal error: examplelib.h: No such file or directory
 #include <examplelib.h>
          ^~~~~~~
compilation terminated.

وإذا لم يستطع الرابط إيجاد نسخة .a أو .so من المكتبة لربطها في خطوة تحرير الربط من التصريف، فسيخرج gcc بخطأ كالتالي:

$ gcc -c myprog.c -lexamplelib
/usr/bin/ld: cannot find -lexamplelib
collect2: error: ld returned 1 exit status

وبالمثل، إذا لم يستطع ملف تنفيذي مربوط ديناميكياً تحديد موقع ملف كائن مشترك (مثل libexamplelib.so)، فسيفشل في التنفيذ وقت التشغيل بخطأ كالتالي:

$ ./a.out
./a.out: error while loading shared libraries:
        libexamplelib.so: cannot open shared object file: No such file or directory

لحل هذه الأنواع من الأخطاء، يجب على المبرمجين تحديد خيارات إضافية لـ gcc للإشارة إلى مكان إيجاد ملفات المكتبة. وقد يحتاجون أيضاً إلى تعديل متغيّر البيئة LD_LIBRARY_PATH ليجد الرابط وقت التشغيل ملف .so لمكتبة.

مسارات المكتبات والتضمين

يبحث المصرّف تلقائياً في مواقع المجلدات القياسية عن ملفات الترويسة والمكتبات. فمثلاً، تخزّن الأنظمة عادةً ملفات الترويسة القياسية في /usr/include وملفات المكتبات في /usr/lib ويبحث gcc تلقائياً عن الترويسات والمكتبات في هذه المجلدات؛ كما يبحث gcc تلقائياً عن ملفات الترويسة في مجلد العمل الحالي.

وإذا لم يستطع gcc إيجاد ملف ترويسة أو مكتبة، فيجب على المستخدم توفير المسارات صراحةً في سطر الأوامر باستخدام -I و-L. فمثلاً، لنفترض وجود مكتبة باسم libexamplelib.so في /home/me/lib، وملف ترويستها examplelib.h في /home/me/include. ولأن gcc لا يعرف شيئاً عن هذين المسارين افتراضياً، يجب إخباره صراحةً بتضمين الملفات هناك لتصريف برنامج يستخدم هذه المكتبة بنجاح:

$ gcc  -I/home/me/include -o myprog myprog.c -L/home/me/lib -lexamplelib

لتحديد موقع مكتبة ديناميكية (مثل libexamplelib.so) عند إطلاق ملف تنفيذي مربوط ديناميكياً، اضبط متغيّر البيئة LD_LIBRARY_PATH ليتضمن المسار إلى المكتبة. وفيما يلي أمر bash مثال يمكن تشغيله في موجّه الصدفة أو إضافته إلى ملف .bashrc:

export LD_LIBRARY_PATH=/home/me/lib:$LD_LIBRARY_PATH

عندما تطول أسطر أوامر gcc أو عندما يحتاج ملف تنفيذي إلى ملفات مصدرية وترويسة كثيرة، يساعد تبسيط التصريف باستخدام make وMakefile. وفيما يلي مزيد من المعلومات عن make وملفات Makefile.

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

في قسم استخدام المكتبات وتصريفها وربطها، نصف كيفية استخدام شيفرة مكتبة C وتصريفها وربطها في برامج C. وفي هذا القسم، نناقش كيفية كتابة مكتباتك الخاصة في C واستخدامها. وينطبق ما نعرضه هنا أيضاً على هيكلة برامج C الأكبر المكوَّنة من ملفات مصدرية وترويسة متعددة وتصريفها.

لإنشاء مكتبة في C:

  1. عرّف واجهة للمكتبة في ملف ترويسة (.h). ويجب أن يضمّن هذا الملف أي برنامج يريد استخدام المكتبة.
  2. أنشئ تنفيذاً للمكتبة في ملف .c واحد أو أكثر. وتنفّذ مجموعة تعريفات الدوال هذه وظائف المكتبة. وقد تكون بعض الدوال دوال واجهة سيستدعيها مستخدمو المكتبة، وقد تكون أخرى دوال داخلية لا يمكن لمستخدمي المكتبة استدعاؤها (والدوال الداخلية جزء من التصميم المعياري الجيد لتنفيذ المكتبة).
  3. صرّف صيغة ثنائية من المكتبة يمكن ربطها في البرامج التي تستخدم المكتبة.

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

بدلاً من ذلك، يمكن تصريف مكتبة إلى ملف أرشيف ثنائي (.a) أو ملف كائن مشترك (.so) للبرامج التي تريد استخدام المكتبة. وفي هذه الحالات، غالباً لا يملك مستخدمو المكتبة حق الوصول إلى ملفات شيفرة C المصدرية للمكتبة، وبالتالي لا يستطيعون تصريف شيفرة المكتبة مباشرةً مع شيفرة التطبيق الذي يستخدمها. وعندما يستخدم برنامج مكتبة مصرَّفة مسبقاً كهذه (مثل .a أو .so)، يجب ربط شيفرة المكتبة صراحةً في الملف التنفيذي باستخدام خيار سطر الأوامر -l لـ gcc.

نركّز مناقشتنا التفصيلية لكتابة شيفرة المكتبات وتصريفها وربطها على الحالة التي يملك فيها المبرمج وحدات المكتبة الفردية (سواء ملفات .c أو .o). وينطبق هذا التركيز أيضاً على تصميم برامج C الكبيرة المقسَّمة إلى ملفات .c و.h متعددة وتصريفها. ونعرض بإيجاز أوامر بناء صيغ الأرشيف والكائن المشترك للمكتبات. وتتوفر معلومات إضافية عن بناء هذه الأنواع من ملفات المكتبات في وثائق gcc، بما في ذلك صفحات الدليل لـ gcc وar.

تفاصيل المكتبات بالأمثلة

فيما يلي نعرض بعض الأمثلة على إنشاء مكتباتك الخاصة واستخدامها.

عرّف واجهة المكتبة:

ملفات الترويسة (ملف .h) ملفات نصية تحتوي نماذج دوال C وتعريفات أخرى — وتمثّل واجهة المكتبة. ويجب تضمين ملف ترويسة في أي تطبيق ينوي استخدام المكتبة. فمثلاً، تُخزَّن ملفات ترويسة مكتبة C القياسية عادةً في /usr/include/ ويمكن عرضها بمحرّر:

$ vi /usr/include/stdio.h

وفيما يلي ملف ترويسة مثال (mylib.h) من مكتبة يحتوي بعض التعريفات لمستخدمي المكتبة.

#ifndef _MYLIB_H_
#define _MYLIB_H_

// a constant definition exported by library:
#define MAX_FOO  20

// a type definition exported by library:
struct foo_struct {
    int x;
    float y;
};

// a global variable exported by library
// "extern" means that this is not a variable declaration,
// but it defines that a variable named total_times of type
// int exists in the library implementation and is available
// for use by programs using the library.
// It is unusual for a library to export global variables
// to its users, but if it does, it is important that
// extern appears in the definition in the .h file
extern int total_times;

// a function prototype for a function exported by library:
// extern means that this function definition exists
// somewhere else.
/*
 * This function returns the larger of two float values
 *  y, z: the two values
 *  returns the value of the larger one
 */
extern float bigger(float y, float z);

#endif

عادةً ما تحتوي ملفات الترويسة شيفرة «قالبية» خاصة حول محتوياتها:

#ifndef

// header file contents

#endif

تضمن هذه الشيفرة القالبية أن المعالج الأولي للمصرّف يضمّن محتويات mylib.h مرة واحدة بالضبط في أي ملف C يضمّنها. ومن المهم تضمين محتويات ملف .h مرة واحدة فقط لتجنّب أخطاء التعريف المكرر وقت التصريف. وبالمثل، إذا نسيت تضمين ملف .h في برنامج C يستخدم المكتبة، فسيولّد المصرّف تحذير undefined symbol.

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

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

نفّذ وظائف المكتبة:

ينفّذ المبرمجون المكتبات في ملف .c واحد أو أكثر (وأحياناً ملفات .h داخلية). ويتضمن التنفيذ تعريفات جميع نماذج الدوال في ملف .h إضافة إلى دوال أخرى داخلية في تنفيذه. وكثيراً ما تُعرَّف هذه الدوال الداخلية بالكلمة المفتاحية static التي تحصر إتاحتها في الوحدة (ملف .c) المعرَّفة فيها. وينبغي أن يتضمن تنفيذ المكتبة أيضاً تعريفات متغيّرات لأي إعلانات متغيّرات عامة extern في ملف .h. وفيما يلي مثال على تنفيذ مكتبة (mylib.c):

#include <stdlib.h>

// Include the library header file if the implementation needs
// any of its definitions (types or constants, for example.)
// Use " " instead of < > if the mylib.h file is not in a
// default  library path with other standard library header
// files (the usual case for library code you write and use.)
#include "mylib.h"

// declare the global variable exported by the library
int total_times = 0;

// include function definitions for each library function:
float bigger(float y, float z) {
    total_times++;
    if (y > z) {
        return y;
    }
    return z;
}

أنشئ صيغة ثنائية للمكتبة:

لإنشاء صيغة ثنائية للمكتبة (ملف .o)، صرّف مع الخيار -c:

$ gcc -o mylib.o -c mylib.c

يمكن لملف .o واحد أو أكثر بناء نسخة أرشيف (.a) أو كائن مشترك (.so) من المكتبة.

  • لبناء مكتبة ساكنة استخدم أداة الأرشيف (ar):
ar -rcs libmylib.a mylib.o
  • ولبناء مكتبة مربوطة ديناميكياً، يجب بناء ملف/ملفات كائن mylib.o في المكتبة باستخدام شيفرة مستقلة عن الموقع (position independent code) (باستخدام -fPIC). ويمكن إنشاء ملف كائن مشترك libmylib.so من mylib.o بتحديد العلامة -shared إلى gcc:
gcc -fPIC -o mylib.o -c mylib.c
gcc -shared -o libmylib.so mylib.o
  • غالباً ما تُبنى مكتبات الكائن المشترك والأرشيف من ملفات .o متعددة، فمثلاً (تذكّر أن .o للمكتبات المربوطة ديناميكياً تحتاج إلى بناء باستخدام العلامة -fPIC):
gcc -shared -o libbiglib.so file1.o file2.o file3.o file4.o
ar -rcs libbiglib.a file1.o file2.o file3.o file4.o

استخدم المكتبة واربطها:

في ملفات .c أخرى تستخدم هذه المكتبة:

  1. ضمّن ملف ترويستها (#include)، و
  2. اربط التنفيذ صراحةً (ملف .o) أثناء التصريف.

بعد تضمين ملف ترويسة المكتبة، يمكن لشيفرتك حينها استدعاء دوال المكتبة (مثل myprog.c):

#include <stdio.h>
#include "mylib.h"   // include library header file

int main(void) {
    float val1, val2, ret;
    printf("Enter two float values: ");
    scanf("%f%f", &val1, &val2);
    ret = bigger(val1, val2);   // use a library function
    printf("%f is the biggest\n", ret);

    return 0;
}

ملاحظة — صياغة #include والمعالج الأولي

لاحظ أن صياغة #include لتضمين mylib.h تختلف عن صياغة تضمين stdio.h. والسبب أن mylib.h ليس موجوداً مع ملفات الترويسة الخاصة بالمكتبات القياسية. وللمعالج الأولي أماكن افتراضية يبحث فيها عن ملفات الترويسة القياسية. وعند تضمين ملف بصياغة `` syntax instead of the "file.h"، يبحث المعالج الأولي عن ملف الترويسة في تلك الأماكن القياسية.

وعند تضمين mylib.h بين علامتي اقتباس مزدوجتين، يبحث المعالج الأولي أولاً في المجلد الحالي عن ملف mylib.h، ثم في أماكن أخرى تحتاج إلى إخباره صراحةً بالبحث فيها، بتحديد مسار تضمين (-I) لـ gcc. فمثلاً، إذا كان ملف الترويسة في مجلد /home/me/myincludes (وليس في المجلد نفسه الذي فيه ملف myprog.c)، فيجب تحديد المسار إلى هذا المجلد في سطر أوامر gcc ليجد المعالج الأولي ملف mylib.h:

$ gcc -I/home/me/myincludes -c myprog.c

لتصريف برنامج (myprog.c) يستخدم المكتبة (mylib.o) إلى ملف تنفيذي ثنائي:

$ gcc -o myprog myprog.c mylib.o

أو، إذا كانت ملفات تنفيذ المكتبة متاحة وقت التصريف، فيمكن بناء البرنامج مباشرةً من ملفات .c الخاصة بالبرنامج والمكتبة:

$ gcc -o myprog myprog.c mylib.c

أو، إذا كانت المكتبة متاحة كملف أرشيف أو كائن مشترك، فيمكن ربطها باستخدام -l (-lmylib: لاحظ أن اسم المكتبة libmylib.[a,so]، ولكن يُضمَّن فقط الجزء mylib في سطر أوامر gcc):

$ gcc -o myprog myprog.c -L. -lmylib

يحدد الخيار -L. المسار إلى ملفات libmylib.[so,a] (و. بعد -L يشير إلى أنه ينبغي البحث في المجلد الحالي). وبشكل افتراضي، سيربط gcc مكتبة ربطاً ديناميكياً إذا استطاع إيجاد نسخة .so. انظر قسم استخدام مكتبات C لمزيد من المعلومات عن الربط ومسارات الربط.

ويمكن بعد ذلك تشغيل البرنامج:

$ ./myprog

إذا شغّلت النسخة المربوطة ديناميكياً من myprog، فقد تصادف خطأً كهذا:

/usr/bin/ld: cannot find -lmylib
collect2: error: ld returned 1 exit status

يعني هذا الخطأ أن الرابط وقت التشغيل لا يستطيع إيجاد libmylib.so وقت التشغيل. ولحل هذه المشكلة، اضبط متغيّر البيئة LD_LIBRARY_PATH ليتضمن المسار إلى ملف libmylib.so. وتستخدم تشغيلات myprog اللاحقة المسار الذي تضيفه إلى LD_LIBRARY_PATH لإيجاد ملف libmylib.so وتحميله وقت التشغيل. فمثلاً، إذا كان libmylib.so في المجلد الفرعي /home/me/mylibs/، شغّل هذا (مرة واحدة فقط) في موجّه صدفة bash لضبط متغيّر البيئة LD_LIBRARY_PATH:

$ export LD_LIBRARY_PATH=/home/me/mylibs:$LD_LIBRARY_PATH

يمكن للمصرّف تصريف شيفرة C إلى شيفرة لغة تجميع، ويمكنه تصريف شيفرة لغة التجميع إلى صيغة ثنائية تُربط في برنامج تنفيذي ثنائي. نستخدم لغة تجميع IA32 وgcc كمثالينا على لغة التجميع والمصرّف، لكن أي مصرّف C يدعم هذه الوظيفة، ومعظم المصرّفات تدعم التصريف إلى عدد من لغات التجميع المختلفة. انظر الفصل 8 لتفاصيل عن شيفرة لغة التجميع والبرمجة بلغة التجميع.

تأمّل برنامج C البسيط جداً هذا:

simpleops.c

int main(void) {
    int x, y;
    x = 1;
    x = x + 2;
    x = x - 14;
    y = x*100;
    x = x + y * 6;

    return 0;
}

سيصرّفه مصرّف gcc إلى ملف نصي بلغة تجميع IA32 (.s) باستخدام خيار سطر الأوامر -S لتحديد التصريف إلى لغة التجميع وخيار سطر الأوامر -m32 لتحديد توليد تجميع IA32:

$ gcc -m32 -S simpleops.c   # runs the assembler to create a .s text file

ينشئ هذا الأمر ملفاً باسم simpleops.s يحتوي ترجمة المصرّف للشيفرة C إلى تجميع IA32. ولأن ملف .s ملف نصي، يمكن للمستخدم عرضه (وتحريره) بأي محرّر نصوص. فمثلاً:

$ vim simpleops.s

تمرير علامات مصرّف إضافية يوفّر توجيهات إلى gcc باستخدامه ميزات أو تحسينات معينة في ترجمته للشيفرة من C إلى تجميع IA32.

يمكن لمصرّف gcc تصريف ملف شيفرة لغة تجميع، سواء أُنشئ من gcc أم كتبه المبرمج يدوياً، إلى صيغة شيفرة آلة ثنائية باستخدام الخيار -c:

$ gcc -m32 -c simpleops.s   # compiles to a relocatable object binary file (.o)

ويمكن بعد ذلك ربط ملف simpleops.o الناتج في ملف تنفيذي ثنائي (ملاحظة: يتطلب هذا تثبيت النسخة 32 بت من مكتبات النظام على نظامك):

$ gcc -m32 -o simpleops simpleops.o  # creates a 32-bit executable file

ينشئ هذا الأمر ملفاً تنفيذياً ثنائياً، simpleops، لمعماريات IA32 (وx86-64).

يمكن أن يتضمن سطر أوامر gcc لبناء ملف تنفيذي ملفات .o و.c التي ستُصرَّف وتُربط معاً لإنشاء الملف التنفيذي الثنائي الواحد.

توفّر الأنظمة أدوات تتيح للمستخدمين عرض الملفات الثنائية. فمثلاً، يعرض objdump خرائط شيفرة الآلة وشيفرة لغة التجميع في ملفات .o:

$ objdump -d simpleops.o

يمكن مقارنة هذا الناتج بملف لغة التجميع:

$ cat simpleops.s

ينبغي أن ترى شيئاً كهذا (أضفنا تعليقات إلى بعض شيفرة لغة التجميع بمقابلها من البرنامج بلغة C):

        .file   "simpleops.c"
        .text
        .globl main
        .type   main, @function
main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $16, %esp
        movl    $1, -8(%ebp)      # x = 1
        addl    $2, -8(%ebp)      # x = x + 2
        subl    $14, -8(%ebp)     # x = x - 14
        movl    -8(%ebp), %eax    # load x into R[%eax]
        imull   $100, %eax, %eax  # into R[%eax] store result of x*100
        movl    %eax, -4(%ebp)    # y = x*100
        movl    -4(%ebp), %edx
        movl    %edx, %eax
        addl    %eax, %eax
        addl    %edx, %eax
        addl    %eax, %eax
        addl    %eax, -8(%ebp)
        movl    $0, %eax
        leave
        ret
        .size   main, .-main
        .ident	"GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
        .section	.note.GNU-stack,"",@progbits

كتابة شيفرة لغة التجميع وتصريفها

يمكن للمبرمجين كتابة شيفرة لغة التجميع الخاصة بهم يدوياً وتصريفها بـ gcc إلى برنامج تنفيذي ثنائي. فمثلاً، لتنفيذ دالة بلغة التجميع، أضف شيفرة إلى ملف .s واستخدم gcc لتصريفها. ويوضح المثال التالي البنية الأساسية لدالة بلغة تجميع IA32. وستُكتب شيفرة كهذه في ملف (مثلاً myfunc.s) لدالة بنموذج int myfunc(int param);. وقد تختلف الدوال ذات الوسائط الأكثر أو التي تحتاج مساحة أكبر للمتغيّرات المحلية قليلاً في شيفرة مقدمتها.

        .text                   # this file contains instruction code
.globl myfunc                   # myfunc is the name of a function
        .type   myfunc, @function
myfunc:                         # the start of the function
        pushl   %ebp            # function preamble:
        movl    %esp, %ebp      #  the 1st three instrs set up the stack
        subl    $16, %esp

        # A programmer adds specific IA32 instructions
        # here that allocate stack space for any local variables
        # and then implements code using parameters and locals to
        # perform the functionality of the myfunc function
        #
        # the return value should be stored in %eax before returning

        leave    # function return code
        ret

سيحتاج برنامج C يريد استدعاء هذه الدالة إلى تضمين نموذج دالتها:

#include <stdio.h>

int myfunc(int param);

int main(void) {
    int ret;

    ret = myfunc(32);
    printf("myfunc(32) is %d\n", ret);

    return 0;
}

تبني أوامر gcc التالية ملفاً تنفيذياً (myprog) من ملفي الشيفرة المصدرية myfunc.s وmain.c:

$ gcc -m32 -c myfunc.s
$ gcc -m32 -o myprog myfunc.o main.c

شيفرة لغة تجميع مكتوبة يدوياً

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

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

غطّينا في هذا الفصل لغة البرمجة C بعمق وناقشنا أيضاً بعض موضوعات البرمجة المتقدمة في C. وفي الفصل التالي، نقدّم أداتين مفيدتين جداً لتنقيح C: منقّح GNU GDB لتنقيح برامج C على وجه العموم، ومنقّح الذاكرة Valgrind لإيجاد أخطاء الوصول إلى الذاكرة في برامج C. وبهذه الأدوات البرمجية ومعرفة لغة البرمجة C الجوهرية المعروضة في هذا الفصل، يستطيع مبرمج C تصميم برمجيات قوية وفعّالة ومتينة.