أهم 10 أخطاء C ++ شيوعًا يرتكبها المطورون
نشرت: 2022-03-11هناك العديد من المزالق التي قد يواجهها مطور C ++. هذا يمكن أن يجعل البرمجة عالية الجودة صعبة للغاية والصيانة باهظة الثمن. إن تعلم بناء جملة اللغة وامتلاك مهارات برمجة جيدة بلغات مماثلة ، مثل C # و Java ، لا يكفي فقط للاستفادة من الإمكانات الكاملة لـ C ++. يتطلب سنوات من الخبرة والانضباط الكبير لتجنب الأخطاء في C ++. في هذه المقالة ، سنلقي نظرة على بعض الأخطاء الشائعة التي يرتكبها المطورون من جميع المستويات إذا لم يكونوا حريصين بما فيه الكفاية في تطوير C ++.
الخطأ الشائع الأول: استخدام أزواج "جديد" و "حذف" بشكل غير صحيح
بغض النظر عن مقدار المحاولة ، من الصعب جدًا تحرير كل الذاكرة المخصصة ديناميكيًا. حتى لو تمكنا من القيام بذلك ، فغالباً ما يكون غير آمن من الاستثناءات. دعونا نلقي نظرة على مثال بسيط:
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }
إذا تم طرح استثناء ، فلن يتم حذف الكائن "a" مطلقًا. يوضح المثال التالي طريقة أكثر أمانًا وأقصر للقيام بذلك. يستخدم auto_ptr الذي تم إهماله في C ++ 11 ، ولكن لا يزال المعيار القديم مستخدمًا على نطاق واسع. يمكن استبداله بـ C ++ 11 unique_ptr أو scoped_ptr من Boost إن أمكن.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }
بغض النظر عما يحدث ، بعد إنشاء الكائن "a" ، سيتم حذفه بمجرد خروج تنفيذ البرنامج من النطاق.
ومع ذلك ، كان هذا فقط أبسط مثال على مشكلة C ++ هذه. هناك العديد من الأمثلة عندما يجب أن يتم الحذف في مكان آخر ، ربما في وظيفة خارجية أو في مؤشر ترابط آخر. هذا هو السبب في أنه يجب تجنب استخدام جديد / حذف في أزواج تمامًا ويجب استخدام المؤشرات الذكية المناسبة بدلاً من ذلك.
الخطأ الشائع الثاني: المدمر الافتراضي المنسي
يعد هذا أحد الأخطاء الأكثر شيوعًا التي تؤدي إلى تسرب الذاكرة داخل الفئات المشتقة إذا كانت هناك ذاكرة ديناميكية مخصصة بداخلها. هناك بعض الحالات التي يكون فيها التدمير الظاهري غير مرغوب فيه ، أي عندما لا تكون الفئة مخصصة للوراثة ويكون حجمها وأدائها أمرًا بالغ الأهمية. تقدم أداة التدمير الافتراضية أو أي وظيفة افتراضية أخرى بيانات إضافية داخل بنية فئة ، أي مؤشر إلى جدول افتراضي يجعل حجم أي مثيل للفئة أكبر.
ومع ذلك ، في معظم الحالات ، يمكن توريث الفئات حتى لو لم يكن ذلك مقصودًا في الأصل. لذلك من الجيد جدًا إضافة أداة تدمير افتراضية عند الإعلان عن فئة. بخلاف ذلك ، إذا كان يجب ألا تحتوي الفئة على وظائف افتراضية لأسباب تتعلق بالأداء ، فمن الجيد وضع تعليق داخل ملف إعلان للفصل يشير إلى أنه لا ينبغي توريث الفئة. أحد أفضل الخيارات لتجنب هذه المشكلة هو استخدام IDE الذي يدعم إنشاء أداة تدمير افتراضية أثناء إنشاء فئة.
نقطة إضافية للموضوع هي الفصول / القوالب من المكتبة القياسية. ليست مخصصة للميراث وليس لديهم مدمر افتراضي. إذا أنشأنا ، على سبيل المثال ، فئة سلسلة محسّنة جديدة ترث علنًا من std :: string ، فهناك احتمال أن يستخدمها شخص ما بشكل غير صحيح مع مؤشر أو مرجع إلى std :: string ويسبب تسربًا للذاكرة.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }
لتجنب مشكلات C ++ هذه ، فإن الطريقة الأكثر أمانًا لإعادة استخدام فئة / قالب من المكتبة القياسية هي استخدام الميراث أو التكوين الخاص.
الخطأ الشائع # 3: حذف مصفوفة باستخدام "حذف" أو باستخدام مؤشر ذكي
غالبًا ما يكون إنشاء مصفوفات مؤقتة ذات حجم ديناميكي ضروريًا. بعد عدم الحاجة إليها ، من المهم تحرير الذاكرة المخصصة. المشكلة الكبيرة هنا هي أن C ++ تتطلب عامل حذف خاص مع أقواس [] ، والتي يتم نسيانها بسهولة بالغة. لن يقوم عامل الحذف [] فقط بحذف الذاكرة المخصصة لمصفوفة ، ولكنه سيقوم أولاً باستدعاء مدمرات جميع الكائنات من المصفوفة. كما أنه من الخطأ استخدام عامل الحذف بدون [] الأقواس للأنواع الأولية ، على الرغم من عدم وجود مدمر لهذه الأنواع. ليس هناك ما يضمن لكل مترجم أن يشير مؤشر المصفوفة إلى العنصر الأول من المصفوفة ، لذا فإن استخدام الحذف بدون أقواس [] يمكن أن يؤدي إلى سلوك غير محدد أيضًا.
استخدام المؤشرات الذكية ، مثل auto_ptr و unique_ptr <T> و shared_ptr مع المصفوفات غير صحيح أيضًا. عندما يخرج مثل هذا المؤشر الذكي من النطاق ، فإنه يستدعي عامل حذف بدون أقواس [] مما ينتج عنه نفس المشكلات الموضحة أعلاه. إذا كان استخدام مؤشر ذكي مطلوبًا لصفيف ، فمن الممكن استخدام scoped_array أو shared_array من Boost أو unique_ptr <T []> التخصص.
إذا كانت وظيفة العد المرجعي غير مطلوبة ، وهو ما يحدث غالبًا للمصفوفات ، فإن الطريقة الأكثر أناقة هي استخدام متجهات STL بدلاً من ذلك. إنهم لا يهتمون بإطلاق الذاكرة فحسب ، بل يقدمون أيضًا وظائف إضافية.
الخطأ الشائع الرابع: إرجاع كائن محلي حسب المرجع
هذا غالبًا خطأ مبتدئ ، لكن من الجدير بالذكر نظرًا لوجود الكثير من التعليمات البرمجية القديمة التي تعاني من هذه المشكلة. دعنا نلقي نظرة على الكود التالي حيث أراد المبرمج القيام بنوع من التحسين عن طريق تجنب النسخ غير الضروري:
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);
سيشير الكائن "مجموع" الآن إلى "نتيجة" الكائن المحلي. ولكن أين يقع الكائن "نتيجة" بعد تنفيذ وظيفة SumComplex؟ لا مكان. كان موجودًا في المكدس ، ولكن بعد إرجاع الوظيفة ، تم تفكيك المكدس وتم إتلاف جميع الكائنات المحلية من الوظيفة. سيؤدي هذا في النهاية إلى سلوك غير محدد ، حتى بالنسبة للأنواع البدائية. لتجنب مشاكل الأداء ، من الممكن أحيانًا استخدام تحسين قيمة الإرجاع:
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);
بالنسبة لمعظم مترجمي اليوم ، إذا احتوى سطر الإرجاع على مُنشئ كائن ، فسيتم تحسين الكود لتجنب كل النسخ غير الضروري - سيتم تنفيذ المُنشئ مباشرةً على الكائن "sum".
الخطأ الشائع الخامس: استخدام مرجع لمصدر محذوف
تحدث مشكلات C ++ هذه أكثر مما تعتقد ، وعادة ما تظهر في التطبيقات متعددة مؤشرات الترابط. دعونا نفكر في الكود التالي:
الموضوع 1:
Connection& connection= connections.GetConnection(connectionId); // ...
الموضوع 2:
connections.DeleteConnection(connectionId); // …
الموضوع 1:
connection.send(data);
في هذا المثال ، إذا كان كلا الموضوعين يستخدمان نفس معرف الاتصال ، فسيؤدي ذلك إلى سلوك غير محدد. غالبًا ما يكون من الصعب جدًا العثور على أخطاء انتهاك الوصول.
في هذه الحالات ، عندما يصل أكثر من مؤشر ترابط واحد إلى نفس المورد ، يكون من الخطورة جدًا الاحتفاظ بالمؤشرات أو المراجع إلى الموارد ، لأن بعض مؤشرات الترابط الأخرى يمكن أن تحذفها. يعتبر استخدام المؤشرات الذكية مع حساب المرجع أكثر أمانًا ، على سبيل المثال shared_ptr من Boost. يستخدم العمليات الذرية لزيادة / إنقاص العداد المرجعي ، لذلك فهو آمن للخيط.
الخطأ الشائع السادس: السماح بالاستثناءات لترك المواد المدمرة
ليس من الضروري في كثير من الأحيان استبعاد استثناء من أداة تدمير. حتى مع ذلك ، هناك طريقة أفضل للقيام بذلك. ومع ذلك ، لا يتم طرح الاستثناءات في الغالب من المدمرات صراحة. يمكن أن يحدث أن يؤدي أمر بسيط لتسجيل تدمير كائن ما إلى استثناء رمي. دعنا نفكر في الكود التالي:
class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }
في الكود أعلاه ، إذا حدث الاستثناء مرتين ، مثل أثناء تدمير كلا الكائنين ، فلن يتم تنفيذ تعليمة catch مطلقًا. نظرًا لوجود استثناءين على التوازي ، بغض النظر عما إذا كانا من نفس النوع أو نوع مختلف ، فإن بيئة وقت تشغيل C ++ لا تعرف كيفية التعامل معها وتستدعي وظيفة إنهاء تؤدي إلى إنهاء تنفيذ البرنامج.

لذا فإن القاعدة العامة هي: لا تسمح أبدًا للاستثناءات بمغادرة المدمرات. حتى لو كان قبيحًا ، يجب حماية الاستثناء المحتمل على النحو التالي:
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}
الخطأ الشائع السابع: استخدام "auto_ptr" (خطأ)
تم إهمال قالب auto_ptr من C ++ 11 لعدة أسباب. لا يزال يستخدم على نطاق واسع ، لأن معظم المشاريع لا تزال قيد التطوير في C ++ 98. لها خاصية معينة قد لا تكون مألوفة لدى جميع مطوري C ++ ، ويمكن أن تسبب مشاكل خطيرة لشخص غير حريص. سيؤدي نسخ كائن auto_ptr إلى نقل الملكية من كائن إلى آخر. على سبيل المثال ، الكود التالي:
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error
… سينتج عن خطأ انتهاك وصول. سيحتوي الكائن "b" فقط على مؤشر إلى كائن من الفئة A ، بينما سيكون "a" فارغًا. ستؤدي محاولة الوصول إلى أحد أعضاء فئة الكائن "a" إلى حدوث خطأ انتهاك وصول. هناك العديد من الطرق لاستخدام auto_ptr بشكل غير صحيح. أربعة أشياء مهمة للغاية يجب تذكرها عنها هي:
لا تستخدم مطلقًا auto_ptr داخل حاويات STL. سيؤدي نسخ الحاويات إلى ترك بيانات غير صالحة في حاويات المصدر. يمكن أن تؤدي بعض خوارزميات STL أيضًا إلى إبطال "auto_ptr".
لا تستخدم أبدًا auto_ptr كوسيطة دالة لأن هذا سيؤدي إلى النسخ ، وترك القيمة التي تم تمريرها إلى الوسيطة غير صالحة بعد استدعاء الوظيفة.
إذا تم استخدام auto_ptr لأعضاء البيانات في فصل دراسي ، فتأكد من عمل نسخة مناسبة داخل مُنشئ نسخة وعامل تخصيص ، أو عدم السماح بهذه العمليات بجعلها خاصة.
كلما أمكن ، استخدم بعض المؤشرات الذكية الحديثة الأخرى بدلاً من auto_ptr.
الخطأ الشائع الثامن: استخدام المراجع والتكرار المبطل
سيكون من الممكن كتابة كتاب كامل حول هذا الموضوع. كل حاوية STL لها بعض الشروط المحددة التي تبطل التكرارات والمراجع. من المهم أن تكون على دراية بهذه التفاصيل أثناء استخدام أي عملية. تمامًا مثل مشكلة C ++ السابقة ، يمكن أن تحدث هذه المشكلة أيضًا بشكل متكرر في البيئات متعددة مؤشرات الترابط ، لذلك يلزم استخدام آليات المزامنة لتجنبها. دعنا نرى الرمز التسلسلي التالي كمثال:
vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element
من وجهة نظر منطقية ، يبدو الرمز جيدًا تمامًا. ومع ذلك ، فإن إضافة العنصر الثاني إلى المتجه قد يؤدي إلى إعادة تخصيص ذاكرة المتجه مما يجعل كلاً من المكرر والمرجع غير صالحين وينتج عنه خطأ انتهاك وصول عند محاولة الوصول إليهما في السطرين الأخيرين.
الخطأ الشائع # 9: تمرير كائن بالقيمة
ربما تعلم أنها فكرة سيئة أن تمرر الأشياء بالقيمة بسبب تأثيرها على الأداء. يترك الكثيرون الأمر على هذا النحو لتجنب كتابة أحرف إضافية ، أو ربما يفكرون في العودة لاحقًا للقيام بالتحسين. عادة لا يتم ذلك أبدًا ، ونتيجة لذلك يؤدي إلى رمز ورمز أقل أداءً يكون عرضة لسلوك غير متوقع:
class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);
سيتم ترجمة هذا الرمز. استدعاء الوظيفة "func1" سينشئ نسخة جزئية من الكائن "b" ، أي أنها ستنسخ فقط جزء الفئة "A" من الكائن "b" إلى الكائن "a" ("مشكلة التقطيع"). لذا داخل الوظيفة ، سوف تستدعي أيضًا طريقة من الفئة "A" بدلاً من طريقة من الفئة "B" والتي على الأرجح ليست ما يتوقعه شخص ما يستدعي الوظيفة.
تحدث مشكلات مماثلة عند محاولة التقاط الاستثناءات. علي سبيل المثال:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }
عندما يتم طرح استثناء من النوع ExceptionB من الوظيفة "func2" ، فسيتم اكتشافه بواسطة كتلة catch ، ولكن بسبب مشكلة التقطيع لن يتم نسخ سوى جزء من فئة ExceptionA ، وسيتم استدعاء طريقة غير صحيحة وأيضًا إعادة رميها سوف يطرح استثناء غير صحيح لكتلة محاولة التقاط خارجية.
للتلخيص ، قم دائمًا بتمرير الكائنات حسب المرجع وليس بالقيمة.
الخطأ الشائع رقم 10: استخدام التحويلات التي يحددها المستخدم بواسطة المنشئ ومشغلي التحويل
حتى التحويلات التي يحددها المستخدم مفيدة جدًا في بعض الأحيان ، لكنها يمكن أن تؤدي إلى تحويلات غير متوقعة يصعب تحديد موقعها. لنفترض أن شخصًا ما أنشأ مكتبة بها فئة سلسلة نصية:
class String { public: String(int n); String(const char *s); …. }
تهدف الطريقة الأولى إلى إنشاء سلسلة طولها n ، وتهدف الطريقة الثانية إلى إنشاء سلسلة تحتوي على الأحرف المحددة. لكن المشكلة تبدأ بمجرد أن يكون لديك شيء مثل هذا:
String s1 = 123; String s2 = 'abc';
في المثال أعلاه ، ستصبح s1 سلسلة بحجم 123 ، وليست سلسلة تحتوي على الأحرف "123". يحتوي المثال الثاني على علامات اقتباس مفردة بدلاً من علامات الاقتباس المزدوجة (والتي قد تحدث بالصدفة) والتي ستؤدي أيضًا إلى استدعاء المنشئ الأول وإنشاء سلسلة ذات حجم كبير جدًا. هذه أمثلة بسيطة حقًا ، وهناك العديد من الحالات الأكثر تعقيدًا التي تؤدي إلى الارتباك والتحويلات غير المتوقعة التي يصعب العثور عليها. هناك قاعدتان عامتان حول كيفية تجنب مثل هذه المشاكل:
حدد مُنشئًا باستخدام كلمة رئيسية صريحة لعدم السماح بالتحويلات الضمنية.
بدلاً من استخدام عوامل التحويل ، استخدم طرق محادثة واضحة. إنها تتطلب مزيدًا من الكتابة قليلاً ، لكنها أكثر نظافة للقراءة ويمكن أن تساعد في تجنب النتائج غير المتوقعة.
خاتمة
C ++ لغة قوية. في الواقع ، من المحتمل أن يتم إنشاء العديد من التطبيقات التي تستخدمها يوميًا على جهاز الكمبيوتر الخاص بك والتي أصبحت تحبها باستخدام C ++. كلغة ، توفر C ++ قدرًا هائلاً من المرونة للمطور ، من خلال بعض الميزات الأكثر تطورًا في لغات البرمجة الموجهة للكائنات. ومع ذلك ، يمكن أن تصبح هذه الميزات أو المرونة المتطورة غالبًا سببًا للارتباك والإحباط للعديد من المطورين إذا لم يتم استخدامها بشكل مسؤول. نأمل أن تساعدك هذه القائمة على فهم كيفية تأثير بعض هذه الأخطاء الشائعة على ما يمكنك تحقيقه باستخدام C ++.
مزيد من القراءة على مدونة Toptal Engineering:
- كيف تتعلم لغات C و C ++: القائمة النهائية
- C # مقابل C ++: ماذا يوجد في الجوهر؟