كيف يعمل C ++: فهم التجميع
نشرت: 2022-03-11لغة البرمجة C ++ لبيارن ستروستروب بها فصل بعنوان "جولة في C ++: الأساسيات" —معيار C ++. هذا الفصل ، في 2.2 ، يذكر في نصف صفحة عملية التجميع والربط في C ++. التجميع والربط هما عمليتان أساسيتان للغاية تحدثان طوال الوقت أثناء تطوير برمجيات C ++ ، ولكن الغريب أنهما لم يتم فهمهما جيدًا من قبل العديد من مطوري C ++.
لماذا يتم تقسيم الكود المصدري C ++ إلى ملفات رأس ومصدر؟ كيف يرى المترجم كل جزء؟ كيف يؤثر ذلك على التجميع والربط؟ هناك العديد من الأسئلة مثل هذه التي ربما تكون قد فكرت فيها ولكنك تقبلتها على أنها اتفاقية.
سواء كنت تقوم بتصميم تطبيق C ++ ، أو تنفيذ ميزات جديدة له ، أو محاولة معالجة الأخطاء (خاصة بعض الأخطاء الغريبة) ، أو محاولة جعل كود C و C ++ يعملان معًا ، ومعرفة كيف أن أعمال التجميع والربط ستوفر لك الكثير من الوقت و اجعل تلك المهام أكثر متعة. في هذه المقالة سوف تتعلم ذلك بالضبط.
تشرح المقالة كيفية عمل مترجم C ++ مع بعض تركيبات اللغة الأساسية ، والإجابة على بعض الأسئلة الشائعة المتعلقة بعملياتها ، ومساعدتك في التغلب على بعض الأخطاء ذات الصلة التي يرتكبها المطورون غالبًا في تطوير C ++.
ملاحظة: تحتوي هذه المقالة على بعض أمثلة التعليمات البرمجية المصدر التي يمكن تنزيلها من https://bitbucket.org/danielmunoz/cpp-article
تم تجميع الأمثلة في جهاز CentOS Linux:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
باستخدام إصدار g ++:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
يجب أن تكون ملفات المصدر المتوفرة محمولة إلى أنظمة تشغيل أخرى ، على الرغم من أن ملفات Makefiles المصاحبة لها لعملية الإنشاء الآلي يجب أن تكون محمولة فقط لأنظمة تشبه Unix.
خط أنابيب البناء: العملية المسبقة ، والترجمة ، والربط
يجب تحويل كل ملف مصدر لـ C ++ إلى ملف كائن. يتم بعد ذلك ربط ملفات الكائنات الناتجة عن تجميع ملفات المصدر المتعددة في ملف تنفيذي أو مكتبة مشتركة أو مكتبة ثابتة (آخرها مجرد أرشيف لملفات الكائنات). تحتوي ملفات المصدر C ++ بشكل عام على لاحقات الملحق .cpp أو .cxx أو .cc.
يمكن أن يشتمل ملف مصدر C ++ على ملفات أخرى ، تُعرف باسم ملفات الرأس ، باستخدام التوجيه #include
. تحتوي ملفات الرأس على امتدادات مثل .h أو .hpp أو .hxx ، أو ليس لها امتداد على الإطلاق كما هو الحال في مكتبة C ++ القياسية وملفات رأس المكتبات الأخرى (مثل Qt). لا يهم الامتداد بالنسبة للمعالج المسبق لـ C ++ ، والذي سيحل حرفياً محل السطر الذي يحتوي على التوجيه #include
بالمحتوى الكامل للملف المضمن.
الخطوة الأولى التي سيقوم بها المترجم على الملف المصدر هي تشغيل المعالج الأولي عليه. يتم تمرير ملفات المصدر فقط إلى المترجم (للمعالجة الأولية وتجميعها). لا يتم تمرير ملفات الرأس إلى المترجم. بدلاً من ذلك ، يتم تضمينها من ملفات المصدر.
يمكن فتح كل ملف رأس عدة مرات أثناء مرحلة المعالجة المسبقة لجميع الملفات المصدر ، اعتمادًا على عدد ملفات المصدر التي تتضمنها ، أو عدد ملفات الرأس الأخرى المضمنة من الملفات المصدر التي تتضمنها أيضًا (يمكن أن يكون هناك العديد من مستويات المراوغة) . من ناحية أخرى ، يتم فتح ملفات المصدر مرة واحدة فقط بواسطة المترجم (والمعالج المسبق) ، عندما يتم تمريرها إليه.
لكل ملف مصدر C ++ ، سيقوم المعالج المسبق ببناء وحدة ترجمة عن طريق إدخال المحتوى فيه عندما يجد التوجيه #include في نفس الوقت الذي يقوم فيه بتجريد الكود من الملف المصدر والرؤوس عندما يعثر على الترجمة الشرطية الكتل التي يتم تقييم توجيهها إلى false
. ستقوم أيضًا ببعض المهام الأخرى مثل عمليات الاستبدال الكلي.
بمجرد انتهاء المعالج المسبق من إنشاء وحدة الترجمة (الضخمة في بعض الأحيان) ، يبدأ المترجم مرحلة التجميع وينتج ملف الكائن.
للحصول على وحدة الترجمة هذه (الكود المصدري المعالج مسبقًا) ، يمكن تمرير الخيار -E
إلى مترجم g ++ ، جنبًا إلى جنب مع الخيار -o
لتحديد الاسم المطلوب للملف المصدر المعالج مسبقًا.
في دليل cpp-article/hello-world
، يوجد مثال لملف "hello-world.cpp":
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
قم بإنشاء الملف المعالج مسبقًا عن طريق:
$ g++ -E hello-world.cpp -o hello-world.ii
وانظر عدد الأسطر:
$ wc -l hello-world.ii 17558 hello-world.ii
بها 17588 خطًا في جهازي. يمكنك أيضًا تشغيل make
على هذا الدليل وسيقوم بتنفيذ هذه الخطوات نيابةً عنك.
يمكننا أن نرى أن المترجم يجب أن يجمع ملفًا أكبر بكثير من ملف المصدر البسيط الذي نراه. هذا بسبب العناوين المضمنة. وفي مثالنا ، قمنا بتضمين رأس واحد فقط. تصبح وحدة الترجمة أكبر وأكبر مع استمرار تضمين الرؤوس.
هذه العملية التمهيدية والتجميع مماثلة للغة سي. إنه يتبع قواعد C الخاصة بالتجميع ، والطريقة التي يتضمن بها ملفات الرأس وينتج رمز الكائن هي نفسها تقريبًا.
كيف تقوم الملفات المصدر باستيراد وتصدير الرموز
لنرى الآن الملفات الموجودة في دليل cpp-article/symbols/c-vs-cpp-names
.
يوجد ملف مصدر بسيط C (وليس C ++) يسمى sum.c يقوم بتصدير وظيفتين ، واحدة لإضافة عددين صحيحين والأخرى لإضافة عددين عشوائيين:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
قم بتجميعها (أو قم بتشغيل make
وجميع الخطوات لإنشاء التطبيقين المثالين المطلوب تنفيذهما) لإنشاء ملف كائن sum.o:
$ gcc -c sum.c
انظر الآن إلى الرموز التي تم تصديرها واستيرادها بواسطة ملف الكائن هذا:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
لا يتم استيراد أي رموز ويتم تصدير رمزين: sumF
و sumI
. يتم تصدير هذه الرموز كجزء من مقطع النص. (T) ، لذلك فهي أسماء وظائف ، رمز قابل للتنفيذ.
إذا أرادت ملفات المصدر الأخرى (على حد سواء C أو C ++) استدعاء هذه الوظائف ، فعليهم الإعلان عنها قبل الاتصال.
الطريقة القياسية للقيام بذلك هي إنشاء ملف رأس يصرح بها ويدرجها في أي ملف مصدر نريد تسميته. يمكن أن يكون للرأس أي اسم وامتداد. اخترت sum.h
:
#ifdef __cplusplus extern "C" { #endif int sumI(int a, int b); float sumF(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
ما هي ifdef
/ endif
؟ إذا قمت بتضمين هذا الرأس من ملف مصدر C ، فأنا أريده أن يصبح:
int sumI(int a, int b); float sumF(float a, float b);
ولكن إذا قمت بتضمينها من ملف مصدر C ++ ، فأنا أريد أن يصبح:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
لا تعرف لغة C أي شيء عن التوجيه extern "C"
، لكن لغة C ++ تعرفها ، وتحتاج إلى تطبيق هذا التوجيه على إعلانات الدوال C. هذا بسبب أسماء دالة C ++ mangles (والطريقة) لأنها تدعم وظيفة / طريقة التحميل الزائد ، بينما C لا.
يمكن رؤية ذلك في ملف المصدر C ++ المسمى print.cpp:
#include <iostream> // std::cout, std::endl #include "sum.h" // sumI, sumF void printSum(int a, int b) { std::cout << a << " + " << b << " = " << sumI(a, b) << std::endl; } void printSum(float a, float b) { std::cout << a << " + " << b << " = " << sumF(a, b) << std::endl; } extern "C" void printSumInt(int a, int b) { printSum(a, b); } extern "C" void printSumFloat(float a, float b) { printSum(a, b); }
هناك وظيفتان بنفس الاسم ( printSum
) تختلفان فقط في نوع معلماتهما: int
أو float
. التحميل الزائد للوظيفة هو ميزة C ++ غير موجودة في C. لتنفيذ هذه الميزة والتمييز بين تلك الوظائف ، يقوم C ++ بتشكيل اسم الوظيفة ، كما نرى في اسم الرمز الذي تم تصديره (سأختار فقط ما هو مناسب من إخراج nm) :
$ g++ -c print.cpp $ nm print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T _Z8printSumff 0000000000000000 T _Z8printSumii U _ZSt4cout
يتم تصدير هذه الوظائف (في نظامي) كـ _Z8printSumff
للإصدار العائم و _Z8printSumii
للإصدار int. كل اسم دالة في C ++ مشوه ما لم يتم التصريح به على أنه extern "C"
. هناك وظيفتان تم الإعلان عنهما باستخدام رابط C في print.cpp
: printSumInt
و printSumFloat
.
لذلك ، لا يمكن تحميلها فوق طاقتها ، أو أن أسماءها المصدرة ستكون هي نفسها لأنها ليست مشوهة. كان علي أن أميزهم عن بعضهم البعض عن طريق postfixing Int أو Float في نهاية أسمائهم.
نظرًا لأنها ليست مشوهة ، يمكن استدعاؤها من رمز C ، كما سنرى قريبًا.
لرؤية الأسماء المشوهة كما نراها في الكود المصدري C ++ ، يمكننا استخدام الخيار -C
(demangle) في الأمر nm
. مرة أخرى ، سأقوم فقط بنسخ نفس الجزء ذي الصلة من الإخراج:
$ nm -C print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T printSum(float, float) 0000000000000000 T printSum(int, int) U std::cout
باستخدام هذا الخيار ، بدلاً من _Z8printSumff
، نرى printSum(float, float)
، وبدلاً من _ZSt4cout
نرى std :: cout ، وهي أسماء أكثر ملاءمةً للإنسان.
نرى أيضًا أن كود C ++ الخاص بنا يستدعي كود C: print.cpp
يستدعي sumI
و sumF
، وهما دالتان C تم الإعلان عنهما على أنهما رابط C في sum.h
يمكن ملاحظة ذلك في إخراج nm من print.o أعلاه ، والذي يُعلم ببعض الرموز غير المحددة (U): sumF
و sumI
و std::cout
. من المفترض أن يتم توفير هذه الرموز غير المحددة في أحد ملفات الكائنات (أو المكتبات) التي سيتم ربطها مع إخراج ملف الكائن هذا في مرحلة الارتباط.
حتى الآن قمنا للتو بتجميع التعليمات البرمجية المصدر في التعليمات البرمجية الهدف ، ولم نقم بالربط بعد. إذا لم نربط ملف الكائن الذي يحتوي على تعريفات تلك الرموز المستوردة مع ملف الكائن هذا ، فسيتوقف الرابط مع ظهور خطأ "رمز مفقود".
لاحظ أيضًا أنه نظرًا لأن print.cpp
هو ملف مصدر C ++ ، تم تجميعه باستخدام مترجم C ++ (g ++) ، يتم تجميع جميع التعليمات البرمجية الموجودة فيه على شكل كود C ++. الوظائف مع ربط C مثل printSumInt
و printSumFloat
هي أيضًا وظائف C ++ يمكنها استخدام ميزات C ++. تتوافق أسماء الرموز فقط مع C ، لكن الكود هو C ++ ، والذي يمكن ملاحظته من خلال حقيقة أن كلا الوظيفتين تستدعي وظيفة محملة بشكل زائد ( printSum
) ، والتي لا يمكن أن تحدث إذا تم تجميع printSumInt
أو printSumFloat
في C.
دعونا نرى الآن print.hpp
، وهو ملف رأس يمكن تضمينه من ملفات المصدر C أو C ++ ، والذي سيسمح printSumInt
و printSumFloat
من C ومن C ++ ، و printSum
ليتم استدعاؤها من C ++:
#ifdef __cplusplus void printSum(int a, int b); void printSum(float a, float b); extern "C" { #endif void printSumInt(int a, int b); void printSumFloat(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
إذا قمنا بتضمينه من ملف مصدر C ، فنحن نريد فقط أن نرى:
void printSumInt(int a, int b); void printSumFloat(float a, float b);
لا يمكن رؤية printSum
من رمز C نظرًا لأن اسمه مشوه ، لذلك ليس لدينا طريقة (قياسية ومحمولة) للإعلان عن رمز C. نعم ، يمكنني التصريح عنهم على أنهم:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
ولن يشتكي الرابط لأن هذا هو الاسم الدقيق الذي اخترعه المترجم المثبت حاليًا من أجله ، لكنني لا أعرف ما إذا كان سيعمل مع الرابط الخاص بك (إذا كان المترجم الخاص بك يولد اسمًا مختلفًا مشوهًا) ، أو حتى لـ الإصدار التالي من الرابط الخاص بي. لا أعرف حتى ما إذا كانت المكالمة ستعمل كما هو متوقع بسبب وجود اصطلاحات استدعاء مختلفة (كيفية تمرير المعلمات وإرجاع القيم المرتجعة) والتي تكون خاصة بالمترجم وقد تكون مختلفة لمكالمات C و C ++ (خاصة لوظائف C ++ التي هي وظائف عضو وتتلقى هذا المؤشر كمعامل).
يمكن أن يستخدم المترجم الخاص بك اصطلاح استدعاء واحد لوظائف C ++ العادية وواحد مختلف إذا تم الإعلان عن وجود ارتباط "C" خارجي. لذا ، فإن خداع المترجم بالقول إن إحدى الوظائف تستخدم اصطلاح استدعاء C بينما تستخدم بالفعل C ++ لأنها يمكن أن تقدم نتائج غير متوقعة إذا كانت الاصطلاحات المستخدمة لكل منها مختلفة في سلسلة أدوات الترجمة الخاصة بك.
هناك طرق قياسية لخلط كود C و C ++ والطريقة القياسية لاستدعاء وظائف C ++ المحملة بشكل زائد من C هي لفها في وظائف مع C linkage كما فعلنا عن طريق التفاف printSum
مع printSumInt
و printSumFloat
.
إذا قمنا بتضمين print.hpp
من ملف مصدر C ++ ، فسيتم تعريف ماكرو المعالج المسبق __cplusplus
الملف على النحو التالي:
void printSum(int a, int b); void printSum(float a, float b); extern "C" { void printSumInt(int a, int b); void printSumFloat(float a, float b); } // end extern "C"
سيسمح هذا لرمز C ++ باستدعاء الوظيفة المحملة بشكل زائد printSum أو printSumInt
و printSumFloat
.
لنقم الآن بإنشاء ملف مصدر C يحتوي على الوظيفة الرئيسية ، وهي نقطة الدخول للبرنامج. ستستدعي وظيفة C الرئيسية هذه printSumInt
و printSumFloat
، أي أنها ستستدعي وظائف C ++ مع ارتباط C. تذكر أن هذه وظائف C ++ (الهيئات الوظيفية الخاصة بها تنفذ كود C ++) التي لا تحتوي فقط على أسماء C ++ مشوهة. الملف يسمى c-main.c
:
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
قم بتجميعه لإنشاء ملف الكائن:
$ gcc -c c-main.c
وانظر الرموز المستوردة / المصدرة:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
تقوم بتصدير main واستيراد printSumFloat
و printSumInt
، كما هو متوقع.
لربطها جميعًا معًا في ملف قابل للتنفيذ ، نحتاج إلى استخدام رابط C ++ (g ++) ، نظرًا لأن ملفًا واحدًا على الأقل سنقوم بربطه ، print.o
، تم تجميعه في C ++:
$ g++ -o c-app sum.o print.o c-main.o
ينتج عن التنفيذ النتيجة المتوقعة:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
الآن دعنا نحاول استخدام ملف رئيسي C ++ ، المسمى cpp-main.cpp
:
#include "print.hpp" int main(int argc, char* argv[]) { printSum(1, 2); printSum(1.5f, 2.5f); printSumInt(3, 4); printSumFloat(3.5f, 4.5f); return 0; }
قم بترجمة وانظر الرموز التي تم استيرادها / تصديرها لملف كائن cpp-main.o
:
$ g++ -c cpp-main.cpp $ nm -C cpp-main.o 0000000000000000 T main U printSumFloat U printSumInt U printSum(float, float) U printSum(int, int)
تقوم بتصدير رئيسي واستيراد C linkage printSumFloat
و printSumInt
، وكلاهما من الإصدارات المشوهة من printSum
.
قد تتساءل عن سبب عدم تصدير الرمز الرئيسي كرمز مشوه مثل main(int, char**)
من مصدر C ++ هذا لأنه ملف مصدر C ++ ولم يتم تعريفه على أنه extern "C"
. حسنًا ، main
هي وظيفة محددة تنفيذية خاصة ويبدو أن تطبيقي قد اختار استخدام ارتباط C من أجلها بغض النظر عما إذا كان محددًا في ملف مصدر C أو C ++.
يعطي ربط وتشغيل البرنامج النتيجة المتوقعة:
$ g++ -o cpp-app sum.o print.o cpp-main.o $ ./cpp-app 1 + 2 = 3 1.5 + 2.5 = 4 3 + 4 = 7 3.5 + 4.5 = 8
كيف تعمل حراس الرأس
حتى الآن ، كنت حريصًا على عدم تضمين الرؤوس مرتين ، بشكل مباشر أو غير مباشر ، من نفس الملف المصدر. ولكن نظرًا لأن رأس واحد يمكن أن يتضمن رؤوسًا أخرى ، يمكن تضمين نفس الرأس بشكل غير مباشر عدة مرات. ونظرًا لأن محتوى العنوان يتم إدراجه فقط في المكان الذي تم تضمينه فيه ، فمن السهل إنهاء الإعلانات المكررة.
راجع ملفات الأمثلة في cpp-article/header-guards
.
// unguarded.hpp class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; // guarded.hpp: #ifndef __GUARDED_HPP #define __GUARDED_HPP class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; #endif // __GUARDED_HPP
يتمثل الاختلاف في أنه ، في guarded.hpp ، نحيط الرأس بالكامل بشرط يتم تضمينه فقط إذا لم يتم تعريف ماكرو المعالج المسبق __GUARDED_HPP
. في المرة الأولى التي يتضمن فيها المعالج هذا الملف ، لن يتم تعريفه. ولكن ، نظرًا لتعريف الماكرو داخل هذا الكود المحمي ، في المرة التالية التي يتم تضمينها فيها (من نفس الملف المصدر ، بشكل مباشر أو غير مباشر) ، سيرى المعالج الأولي الخطوط بين #ifndef و #endif وسيتجاهل كل الكود بين هم.
لاحظ أن هذه العملية تحدث لكل ملف مصدر نقوم بتجميعه. هذا يعني أنه يمكن تضمين ملف الرأس هذا مرة واحدة فقط لكل ملف مصدر. حقيقة أنه تم تضمينه من ملف مصدر واحد لن يمنع تضمينه من ملف مصدر مختلف عند تجميع هذا الملف المصدر. سيؤدي فقط إلى منع تضمينه أكثر من مرة من نفس الملف المصدر.
يحتوي ملف المثال main-guarded.cpp
guarded.hpp
مرتين:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
لكن الإخراج المُعالج مسبقًا يظهر فقط تعريفًا واحدًا للفئة A
:
$ g++ -E main-guarded.cpp # 1 "main-guarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-guarded.cpp" # 1 "guarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-guarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
لذلك ، يمكن تجميعها دون مشاكل:
$ g++ -o guarded main-guarded.cpp
لكن ملف main-unguarded.cpp
يتضمن unguarded.hpp
مرتين:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
ويظهر الإخراج المُعالج مسبقًا تعريفين للفئة A:
$ g++ -E main-unguarded.cpp # 1 "main-unguarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-unguarded.cpp" # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-unguarded.cpp" 2 # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 3 "main-unguarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
سيؤدي هذا إلى مشاكل عند تجميع:

$ g++ -o unguarded main-unguarded.cpp
في الملف المضمن من main-unguarded.cpp:2:0
:
unguarded.hpp:1:7: error: redefinition of 'class A' class A { ^ In file included from main-unguarded.cpp:1:0: unguarded.hpp:1:7: error: previous definition of 'class A' class A { ^
من أجل الإيجاز ، لن أستخدم الرؤوس المحمية في هذه المقالة إذا لم تكن ضرورية لأن معظمها أمثلة قصيرة. لكن احرص دائمًا على حماية ملفات الرأس الخاصة بك. ليست ملفات المصدر الخاصة بك ، والتي لن يتم تضمينها من أي مكان. فقط رأس الملفات.
تمرير بالقيمة وكونستنس للمعلمات
انظر إلى ملف by-value.cpp
في cpp-article/symbols/pass-by
:
#include <vector> #include <numeric> #include <iostream> // std::vector, std::accumulate, std::cout, std::endl using namespace std; int sum(int a, const int b) { cout << "sum(int, const int)" << endl; const int c = a + b; ++a; // Possible, not const // ++b; // Not possible, this would result in a compilation error return c; } float sum(const float a, float b) { cout << "sum(const float, float)" << endl; return a + b; } int sum(vector<int> v) { cout << "sum(vector<int>)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const vector<float> v) { cout << "sum(const vector<float>)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }
نظرًا لأنني أستخدم التوجيه using namespace std
، فليس من الضروري تحديد أسماء الرموز (الوظائف أو الفئات) داخل مساحة الاسم std في بقية وحدة الترجمة ، والتي في حالتي هي بقية الملف المصدر. إذا كان هذا هو ملف رأس ، فلا يجب أن أدرج هذا التوجيه لأنه من المفترض أن يتم تضمين ملف الرأس من ملفات مصدر متعددة ؛ هذا التوجيه سيجلب إلى النطاق العالمي لكل ملف مصدر مساحة اسم الأمراض المنقولة جنسياً بأكملها من النقطة التي تتضمن فيها الرأس.
حتى العناوين المضمنة بعد خاصتي في تلك الملفات ستحتوي على تلك الرموز في النطاق. يمكن أن يؤدي هذا إلى تضارب الأسماء لأنهم لم يتوقعوا حدوث ذلك. لذلك ، لا تستخدم هذا التوجيه في الرؤوس. استخدمه فقط في ملفات المصدر إذا أردت ، وبعد أن تقوم بتضمين كل الرؤوس فقط.
لاحظ كيف أن بعض المعلمات ثابتة. هذا يعني أنه لا يمكن تغييرها في جسم الوظيفة إذا حاولنا ذلك. سيعطي خطأ تجميع. لاحظ أيضًا أن جميع المعلمات في هذا الملف المصدر يتم تمريرها حسب القيمة ، وليس عن طريق المرجع (&) أو عن طريق المؤشر (*). هذا يعني أن المتصل سينسخ منها ويمررها إلى الوظيفة. لذلك ، لا يهم المتصل ما إذا كانت ثابتة أم لا ، لأننا إذا قمنا بتعديلها في جسم الوظيفة ، فسنقوم فقط بتعديل النسخة ، وليس القيمة الأصلية التي مررها المتصل إلى الوظيفة.
نظرًا لأن ثبات المعلمة التي يتم تمريرها بواسطة القيمة (نسخة) لا تهم المتصل ، فإنها لا تتشوه في توقيع الوظيفة ، حيث يمكن رؤيتها بعد تجميع وفحص كود الكائن (فقط المخرجات ذات الصلة):
$ g++ -c by-value.cpp $ nm -C by-value.o 000000000000001e T sum(float, float) 0000000000000000 T sum(int, int) 0000000000000087 T sum(std::vector<float, std::allocator<float> >) 0000000000000048 T sum(std::vector<int, std::allocator<int> >)
لا تعبر التوقيعات عما إذا كانت المعلمات المنسوخة ثابتة أم لا في نصوص الوظيفة. لا يهم. كان الأمر مهمًا بالنسبة لتعريف الوظيفة فقط ، لإظهار لمحة سريعة لقارئ الجسم الوظيفي ما إذا كانت هذه القيم ستتغير على الإطلاق. في المثال ، تم التصريح عن نصف المعلمات فقط على أنها ثابتة ، لذلك يمكننا رؤية التباين ، ولكن إذا أردنا أن نكون تصحيحًا ثابتًا ، فيجب أن يتم التصريح عنها جميعًا ، حيث لم يتم تعديل أي منها في جسم الوظيفة (وهم لا ينبغي).
نظرًا لأنه لا يهم إعلان الوظيفة وهو ما يراه المتصل ، يمكننا إنشاء رأس by-value.hpp
:
#include <vector> int sum(int a, int b); float sum(float a, float b); int sum(std::vector<int> v); int sum(std::vector<float> v);
يُسمح بإضافة مؤهلات const هنا (يمكنك حتى التأهل كمتغيرات ثابتة ليست ثابتة في التعريف وستعمل) ، لكن هذا ليس ضروريًا ولن يؤدي إلا إلى جعل التصريحات مطولة دون داعٍ.
تمر بالمرجع
دعونا نرى by-reference.cpp
.
#include <vector> #include <iostream> #include <numeric> using namespace std; int sum(const int& a, int& b) { cout << "sum(const int&, int&)" << endl; const int c = a + b; ++b; // Will modify caller variable // ++a; // Not allowed, but would also modify caller variable return c; } float sum(float& a, const float& b) { cout << "sum(float&, const float&)" << endl; return a + b; } int sum(const std::vector<int>& v) { cout << "sum(const std::vector<int>&)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const std::vector<float>& v) { cout << "sum(const std::vector<float>&)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }
كونستانس عند تمرير المرجع مهم للمتصل ، لأنه سيخبر المتصل ما إذا كان سيتم تعديل وسيطته من قبل المستدعي أم لا. لذلك ، يتم تصدير الرموز بثباتها:
$ g++ -c by-reference.cpp $ nm -C by-reference.o 0000000000000051 T sum(float&, float const&) 0000000000000000 T sum(int const&, int&) 00000000000000fe T sum(std::vector<float, std::allocator<float> > const&) 00000000000000a3 T sum(std::vector<int, std::allocator<int> > const&)
يجب أن ينعكس ذلك أيضًا في العنوان الذي سيستخدمه المتصلون:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
لاحظ أنني لم أكتب اسم المتغيرات في التصريحات (في الرأس) كما كنت أفعل حتى الآن. هذا أيضًا قانوني ، في هذا المثال وللمثال السابق. أسماء المتغيرات غير مطلوبة في الإعلان ، لأن المتصل لا يحتاج إلى معرفة كيف تريد تسمية المتغير الخاص بك. لكن أسماء المعلمات مرغوبة بشكل عام في الإعلانات حتى يتمكن المستخدم من معرفة ما تعنيه كل معلمة في لمح البصر ، وبالتالي ما الذي يجب إرساله في المكالمة.
من المدهش أن أسماء المتغيرات ليست ضرورية في تعريف الوظيفة. تكون مطلوبة فقط إذا كنت تستخدم بالفعل المعامل في الوظيفة. ولكن إذا لم تستخدمه مطلقًا ، فيمكنك ترك المعلمة بالنوع ولكن بدون الاسم. لماذا تعلن الدالة عن معلمة لن تستخدمها أبدًا؟ في بعض الأحيان ، تكون الدوال (أو الطرق) مجرد جزء من واجهة ، مثل واجهة رد الاتصال ، التي تحدد معلمات معينة يتم تمريرها إلى المراقب. يجب على المراقب إنشاء رد اتصال مع جميع المعلمات التي تحددها الواجهة حيث سيتم إرسالها جميعًا بواسطة المتصل. لكن المراقب قد لا يكون مهتمًا بها جميعًا ، لذلك بدلاً من تلقي تحذير مترجم حول "معلمة غير مستخدمة" ، يمكن لتعريف الوظيفة تركها بدون اسم.
المرور بالمؤشر
// by-pointer.cpp: #include <iostream> #include <vector> #include <numeric> using namespace std; int sum(int const * a, int const * const b) { cout << "sum(int const *, int const * const)" << endl; const int c = *a+ *b; // *a = 4; // Can't change. The value pointed to is const. // *b = 4; // Can't change. The value pointed to is const. a = b; // I can make a point to another const int // b = a; // Can't change where b points because the pointer itself is const. return c; } float sum(float * const a, float * b) { cout << "sum(int const * const, float const *)" << endl; return *a + *b; } int sum(const std::vector<int>* v) { cout << "sum(std::vector<int> const *)" << endl; // v->clear(); // I can't modify the const object pointed by v const int c = accumulate(v->begin(), v->end(), 0); v = NULL; // I can make v point to somewhere else return c; } float sum(const std::vector<float> * const v) { cout << "sum(std::vector<float> const * const)" << endl; // v->clear(); // I can't modify the const object pointed by v // v = NULL; // I can't modify where the pointer points to return accumulate(v->begin(), v->end(), 0.0f); }
للإعلان عن مؤشر لعنصر ثابت (int في المثال) ، يمكنك تعريف النوع على أنه إما:
int const * const int *
إذا كنت تريد أيضًا أن يكون المؤشر نفسه ثابتًا ، أي أنه لا يمكن تغيير المؤشر للإشارة إلى شيء آخر ، يمكنك إضافة ثابت بعد النجمة:
int const * const const int * const
إذا كنت تريد أن يكون المؤشر نفسه ثابتًا ، ولكن ليس العنصر الذي يشير إليه:
int * const
قارن تواقيع الوظيفة بالفحص غير المتشابك لملف الكائن:
$ g++ -c by-pointer.cpp $ nm -C by-pointer.o 000000000000004a T sum(float*, float*) 0000000000000000 T sum(int const*, int const*) 0000000000000105 T sum(std::vector<float, std::allocator<float> > const*) 000000000000009c T sum(std::vector<int, std::allocator<int> > const*)
كما ترى ، تستخدم أداة nm
التدوين الأول (const بعد الكتابة). لاحظ أيضًا أن الثبات الوحيد الذي يتم تصديره ، وهو مهم للمتصل ، هو ما إذا كانت الوظيفة ستعدل العنصر المشار إليه بالمؤشر أم لا. ثبات المؤشر نفسه غير ذي صلة بالمستدعي لأن المؤشر نفسه يتم تمريره دائمًا كنسخة. يمكن للوظيفة فقط إنشاء نسختها الخاصة من المؤشر للإشارة إلى مكان آخر ، وهو أمر غير ذي صلة بالمتصل.
لذلك ، يمكن إنشاء ملف رأس على النحو التالي:
#include <vector> int sum(int const* a, int const* b); float sum(float* a, float* b); int sum(std::vector<int>* const); float sum(std::vector<float>* const);
يشبه التمرير بالمؤشر المرور بالمرجع. يتمثل أحد الاختلافات في أنه عندما تقوم بالتمرير حسب المرجع ، فمن المتوقع أن يكون المتصل قد مر بمرجع عنصر صالح ، ولا يشير إلى NULL أو عنوان آخر غير صالح ، بينما يمكن أن يشير المؤشر إلى NULL على سبيل المثال. يمكن استخدام المؤشرات بدلاً من المراجع عندما يكون لتمرير NULL معنى خاص.
نظرًا لأنه يمكن أيضًا تمرير قيم C ++ 11 باستخدام دلالات النقل. لن يتم التعامل مع هذا الموضوع في هذه المقالة ولكن يمكن دراسته في مقالات أخرى مثل Argument Passing في C ++.
موضوع آخر ذي صلة لن يتم تناوله هنا هو كيفية استدعاء كل هذه الوظائف. إذا تم تضمين كل هذه الرؤوس من ملف مصدر ولكن لم يتم استدعاؤها ، سينجح التجميع والربط. ولكن إذا كنت تريد استدعاء جميع الوظائف ، فستكون هناك بعض الأخطاء لأن بعض الاستدعاءات ستكون غامضة. سيتمكن المترجم من اختيار أكثر من نسخة واحدة من المجموع لوسائط معينة ، خاصة عند اختيار التمرير بالنسخة أو بالإشارة (أو المرجع الثابت). هذا التحليل خارج نطاق هذه المقالة.
تجميع أعلام مختلفة
لنرى الآن موقفًا واقعيًا متعلقًا بهذا الموضوع حيث يمكن أن تظهر أخطاء يصعب العثور عليها.
انتقل إلى الدليل cpp-article/diff-flags
وانظر إلى Counters.hpp
:
class Counters { public: Counters() : #ifndef NDEBUG // Enabled in debug builds m_debugAllCounters(0), #endif m_counter1(0), m_counter2(0) { } #ifndef NDEBUG // Enabled in debug build #endif void inc1() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter1; } void inc2() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter2; } #ifndef NDEBUG // Enabled in debug build int getDebugAllCounters() { return m_debugAllCounters; } #endif int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: #ifndef NDEBUG // Enabled in debug builds int m_debugAllCounters; #endif int m_counter1; int m_counter2; };
يحتوي هذا الفصل على عدَّادَين يبدأان بالرقم صفر ويمكن زيادتهما أو قراءتهما. بالنسبة إلى عمليات إنشاء التصحيح ، وهي الطريقة التي سأطلق عليها البنيات حيث لم يتم تعريف ماكرو NDEBUG
، أقوم أيضًا بإضافة عداد ثالث ، والذي سيتم زيادته في كل مرة يتم فيها زيادة أي من العدادات الأخرى. سيكون هذا نوعًا من مساعد التصحيح لهذه الفئة. تستخدم العديد من فئات المكتبات التابعة لجهات خارجية أو حتى رؤوس C ++ المضمنة (اعتمادًا على المترجم) حيلًا مثل هذه للسماح بمستويات مختلفة من التصحيح. يسمح هذا لإصدارات تصحيح الأخطاء باكتشاف التكرارات التي تخرج عن النطاق والأشياء الأخرى المثيرة للاهتمام التي يمكن أن يفكر فيها صانع المكتبة. سأطلق على إصدارات الإصدار "الإنشاءات التي يتم فيها تعريف ماكرو NDEBUG
."
بالنسبة إلى إصدارات الإصدارات ، يبدو الرأس المترجم مسبقًا (أستخدم grep
لإزالة الأسطر الفارغة):
$ g++ -E -DNDEBUG Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_counter1(0), m_counter2(0) { } void inc1() { ++m_counter1; } void inc2() { ++m_counter2; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_counter1; int m_counter2; };
أثناء إنشاءات التصحيح ، سيبدو كما يلي:
$ g++ -E Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_debugAllCounters(0), m_counter1(0), m_counter2(0) { } void inc1() { ++m_debugAllCounters; ++m_counter1; } void inc2() { ++m_debugAllCounters; ++m_counter2; } int getDebugAllCounters() { return m_debugAllCounters; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_debugAllCounters; int m_counter1; int m_counter2; };
هناك عداد آخر في تصميمات تصحيح الأخطاء ، كما أوضحت سابقًا.
لقد قمت أيضًا بإنشاء بعض الملفات المساعدة.
// increment1.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment1(Counters&); // increment1.cpp: #include "Counters.hpp" void increment1(Counters& c) { c.inc1(); }
// increment2.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment2(Counters&); // increment2.cpp: #include "Counters.hpp" void increment2(Counters& c) { c.inc2(); }
// main.cpp: #include <iostream> #include "Counters.hpp" #include "increment1.hpp" #include "increment2.hpp" using namespace std; int main(int argc, char* argv[]) { Counters c; increment1(c); // 3 times increment1(c); increment1(c); increment2(c); // 4 times increment2(c); increment2(c); increment2(c); cout << "c.get1(): " << c.get1() << endl; // Should be 3 cout << "c.get2(): " << c.get2() << endl; // Should be 4 #ifndef NDEBUG // For debug builds cout << "c.getDebugAllCounters(): " << c.getDebugAllCounters() << endl; // Should be 3 + 4 = 7 #endif return 0; }
Makefile
يمكنه تخصيص أعلام المترجم لـ increment2.cpp
فقط:
all: main.o increment1.o increment2.o g++ -o diff-flags main.o increment1.o increment2.o main.o: main.cpp increment1.hpp increment2.hpp Counters.hpp g++ -c -O2 main.cpp increment1.o: increment1.cpp Counters.hpp g++ -c $(CFLAGS) -O2 increment1.cpp increment2.o: increment2.cpp Counters.hpp g++ -c -O2 increment2.cpp clean: rm -f *.o diff-flags
لذلك ، دعونا نجمع كل شيء في وضع التصحيح ، دون تعريف NDEBUG
:
$ CFLAGS='' make g++ -c -O2 main.cpp g++ -c -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o
شغّل الآن:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
الإخراج كما هو متوقع. الآن دعنا نجمع ملفًا واحدًا فقط من الملفات مع تعريف NDEBUG
، والذي سيكون وضع الإصدار ، ونرى ما سيحدث:
$ make clean rm -f *.o diff-flags $ CFLAGS='-DNDEBUG' make g++ -c -O2 main.cpp g++ -c -DNDEBUG -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o $ ./diff-flags c.get1(): 0 c.get2(): 4 c.getDebugAllCounters(): 7
الإخراج ليس كما هو متوقع. increment1
function saw a release version of the Counters class, in which there are only two int member fields. So, it incremented the first field, thinking that it was m_counter1
, and didn't increment anything else since it knows nothing about the m_debugAllCounters
field. I say that increment1
incremented the counter because the inc1 method in Counter
is inline, so it was inlined in increment1
function body, not called from it. The compiler probably decided to inline it because the -O2
optimization level flag was used.
So, m_counter1
was never incremented and m_debugAllCounters
was incremented instead of it by mistake in increment1
. That's why we see 0 for m_counter1
but we still see 7 for m_debugAllCounters
.
Working in a project where we had tons of source files, grouped in many static libraries, it happened that some of those libraries were compiled without debugging options for std::vector
, and others were compiled with those options.
Probably at some point, all libraries were using the same flags, but as time passed, new libraries were added without taking those flags into consideration (they weren't default flags, they had been added by hand). We used an IDE to compile, so to see the flags for each library, you had to dig into tabs and windows, having different (and multiple) flags for different compilation modes (release, debug, profile…), so it was even harder to note that the flags weren't consistent.
This caused that in the rare occasions when an object file, compiled with one set of flags, passed a std::vector
to an object file compiled with a different set of flags, which did certain operations on that vector, the application crashed. Imagine that it wasn't easy to debug since the crash was reported to happen in the release version, and it didn't happen in the debug version (at least not in the same situations that were reported).
The debugger also did crazy things because it was debugging very optimized code. The crashes were happening in correct and trivial code.
The Compiler Does a Lot More Than You May Think
In this article, you have learned about some of the basic language constructs of C++ and how the compiler works with them, starting from the processing stage to the linking stage. Knowing how it works can help you look at the whole process differently and give you more insight into these processes that we take for granted in C++ development.
From a three-step compilation process to mangling of function names and producing different function signatures in different situations, the compiler does a lot of work to offer the power of C++ as a compiled programming language.
I hope you will find the knowledge from this article useful in your C++ projects.
مزيد من القراءة على مدونة Toptal Engineering:
- كيف تتعلم لغات C و C ++: القائمة النهائية
- C # مقابل C ++: ماذا يوجد في الجوهر؟