اكتشف كيف صاغ بيارنه ستروستروب لغة C++ حول مبدأ التجريدات ذات التكلفة الصفرية، ولماذا لا تزال البرمجيات الحساسة للأداء تعتمد على تحكّمها، أدواتها، ونظامها البيئي.

تم تصميم C++ بوعد محدد: يجب أن تكون قادرًا على كتابة كود معبر وعالي المستوى—فئات، حاويات، خوارزميات عامة—دون أن تدفع تلقائيًا تكلفة وقت تشغيل إضافية مقابل تلك التعبيرية. إذا لم تستخدم ميزة، فلا ينبغي أن تُفرض عليك. وإذا استخدمتها، فالمفروض أن تكون التكلفة قريبة مما ستكتبه يدويًا بأسلوب منخفض المستوى.
هذه التدوينة تحكي قصة كيف صاغ بيارنه ستروستروب هذا الهدف في لغة، ولماذا لا تزال الفكرة ذات أهمية. كما أنها دليل عملي لأي شخص يهتم بالأداء ويريد أن يفهم ما الذي يحاول C++ تحسينه—أبعد من الشعارات.
"عالي الأداء" ليس مجرد رفع رقم على اختبار أداء. ببساطة، عادةً ما يعني أن واحدًا أو أكثر من هذه القيود حقيقية:
عندما تكون هذه القيود مهمة، فإن العبء المخفي—تخصيصات إضافية، نسخ غير ضروري، أو تفويض افتراضي حيث لا حاجة له—يمكن أن يكون الفرق بين "يعمل" و"يفشل في تلبية الهدف".
C++ خيار شائع في برمجة الأنظمة والمكوّنات الحساسة للأداء: محركات الألعاب، المتصفحات، قواعد البيانات، مسارات الرسوميات، أنظمة التداول، الروبوتات، الاتصالات، وأجزاء من أنظمة التشغيل. ليست الخيار الوحيد، والعديد من المنتجات الحديثة تمزج لغات. لكن C++ تبقى أداة "الدورة الداخلية" عندما يحتاج الفريق إلى تحكّم مباشر في كيفية ترجمة الكود إلى الجهاز.
سنفكك الآن فكرة التكلفة الصفرية بلغة بسيطة، ثم نربطها بتقنيات C++ المحددة (مثل RAII والقوالب) والتنازلات الواقعية التي تواجهها الفرق.
لم يبدأ بيارنه ستروستروب بهدف "اختراع لغة جديدة" لذاتها. في أواخر السبعينات وبدايات الثمانينات، كان يعمل على أنظمة حيث كانت C سريعة وقريبة من الآلة، لكن البرامج الكبيرة كانت صعبة التنظيم، صعبة التغيير، وسهلة الكسر.
كان هدفه بسيطًا في البيان وصعب التحقيق: جلب طرق أفضل لبناء برامج كبيرة—أنواع، وحدات، التغليف—دون التخلي عن الأداء والوصول إلى الأجهزة الذي جعل C ذات قيمة.
الخطوة الأولى كانت حرفيًا تُسمى "C مع فئات". هذا الاسم يوحي بالاتجاه: ليس إعادة تصميم من الصفر، بل تطور. احتفظ بما كانت C تفعله جيدًا (أداء متوقع، وصول مباشر للذاكرة، اتفاقيات استدعاء بسيطة)، ثم أضف الأدوات المفقودة لبناء أنظمة كبيرة.
مع نضوج اللغة إلى C++، لم تكن الإضافات مجرد "مزيد من الميزات". كانت تهدف إلى جعل الكود عالي المستوى يتحوّل إلى نفس نوع رمز الآلة الذي كنت ستكتبه يدويًا في C، عند الاستخدام الجيد.
التوتر المركزي عند ستروستروب كان—وما زال—بين:
تختار لغات كثيرة جانبًا عبر إخفاء التفاصيل (مما قد يخفي العبء). تحاول C++ أن تترك لك بناء التجريدات مع إمكانية السؤال "ما تكلفة هذا؟" وعند الحاجة النزول إلى عمليات منخفضة المستوى.
هذا الدافع—التجريد دون عقوبة—هو الخيط الذي يربط دعم الفئات المبكر إلى أفكار لاحقة مثل RAII والقوالب ومكتبة القوالب القياسية (STL).
عبارة "التجريدات ذات التكلفة الصفرية" قد تبدو شعارًا، لكنها وعد بشأن التنازلات. النسخة اليومية منه:
إذا لم تستخدمها، فلا تدفع ثمنها. وإذا استخدمتها، فيجب أن تدفع تقريبًا ما لو كتبت الكود منخفض المستوى بنفسك.
مصطلح الأداء يشمل أي شيء يجعل البرنامج يقوم بعمل إضافي في وقت التشغيل. قد يشمل ذلك:
تهدف التجريدات ذات التكلفة الصفرية إلى أن تتيح لك كتابة كود عالي المستوى نظيف—أنواع، فئات، دوال، خوارزميات عامة—مع إخراج لرمز آلة يكون مباشرًا كما لو كتبت حلقات يدوية ومعالجة موارد يدوية.
C++ لا تجعل كل شيء سريعًا سحريًا. تجعل من الممكن كتابة كود عالي المستوى يتحول إلى تعليمات فعالة—لكنك لا تزال قادرًا على اختيار أنماط مكلفة.\n إذا خصصت داخل حلقة ساخنة، أو نسخت كائنات كبيرة مرارًا، أو أفسدت تنسيقات الذاكرة الصديقة للكاش، أو بنيت طبقات من الإحالات التي تمنع التحسين، سيتباطأ برنامجك. C++ لن تمنعك. الهدف من "التكلفة الصفرية" هو تجنب العبء المفروض قسريًا، وليس ضمان اتخاذ قرارات جيدة دائمًا.
بقية المقال يجعل الفكرة ملموسة. سننظر كيف يقوم المجمّع بمحو عبء التجريد، لماذا RAII يمكن أن تكون أكثر أمانًا وأسرع، كيف تولّد القوالب كودًا يعمل مثل النسخ المكتوب يدويًا، وكيف توفر STL مكوّنات قابلة لإعادة الاستخدام دون عمل سري وقت التشغيل—عند الاستخدام بحذر.
تعتمد C++ على صفقة بسيطة: ادفع أكثر وقت البناء لكي تدفع أقل وقت التشغيل. عند الترجمة، لا يترجم المجمّع كودك فحسب—بل يحاول بقوة إزالة العبء الذي كان سيظهر في وقت التشغيل.
أثناء الترجمة، يمكن للمجمّع "سداد" كثير من النفقات مقدمًا:
الهدف أن تتحول بنية الكود الواضحة إلى رمز آلة يقترب مما كنت ستكتبه يدويًا.
دالة مساعدة صغيرة مثل:
int add_tax(int price) { return price * 108 / 100; }
غالبًا ما تصبح بدون استدعاء على الإطلاق بعد الترجمة. بدلًا من "قفزة إلى الدالة، إعداد الوسائط، إرجاع" قد يلصق المجمّع الحساب مباشرة حيث استخدمتها. التجريد (دالة ذات اسم جميل) يختفي عمليًا.
تحظى الحلقات أيضًا باهتمام. يمكن لمجمّع التحسين أن يزيل فحوص الحدود عندما تكون قابلة للإثبات، أن يخرج حسابات متكررة من الحلقة، وأن يعيد تنظيم جسم الحلقة لاستخدام وحدة المعالجة بشكل أكثر كفاءة.
هذه هي المعنى العملي للتجريدات ذات التكلفة الصفرية: تحصل على كود أكثر وضوحًا دون دفع ثمن دائم في وقت التشغيل للبنية التي استخدمتها للتعبير عن الكود.
لا شيء مجاني. المزيد من التحسين والمتلاعبات لإخفاء التجريد قد يعني أوقات ترجمة أطول وأحيانًا ثنائيات أكبر (مثلاً عند إدماج كثير من مواقع الاستدعاء). تمنحك C++ الاختيار—ومعها المسؤولية—لموازنة تكلفة البناء مقابل سرعة وقت التشغيل.
RAII (Resource Acquisition Is Initialization) قاعدة بسيطة ذات نتائج كبيرة: عمر المورد مرتبط بنطاق. عندما يُنشأ كائن، يكتسب المورد. عندما يخرج الكائن من النطاق، يُطلق المورد في المُدمر—تلقائيًا.
يمكن أن يكون "المورد" أي شيء يجب تنظيفه بشكل موثوق: ذاكرة، ملفات، أقفال mutex، مقابض قواعد بيانات، مقابس، مؤشرات GPU، وغير ذلك. بدلًا من تذكُّر استدعاء close() أو unlock() أو free() على كل مسار، تضع التنظيف في مكان واحد (المُدمر) وتترك اللغة تضمن تشغيله.
التنظيف اليدوي يميل إلى إنتاج "كود ظل": فحوص if إضافية، تكرار معالجة return، واستدعاءات تنظيف موزعة بعد كل فشل محتمل. من السهل أن تفوت فرعًا، خاصة عندما يتطور الدالة.
RAII عادةً ما ينتج كودًا خطيًا: اكتساب، عمل، ودع خروج النطاق يتولّى التنظيف. ذلك يقلل الأخطاء (تسريبات، تحرّرات مزدوجة، أقفال منسية) ويقلل حمل وقت التشغيل من أعمال الحماية. من ناحية الأداء، قلة فروع معالجة الأخطاء في المسار الساخن يمكن أن تحسّن سلوك كاش التعليمات وتقلل الأخطاء في توقع الفروع.
التسريبات والأقفال التي لم تُطلق ليست مجرد قضايا صحة؛ إنها قنابل زمنية للأداء. يجعل RAII إطلاق الموارد متوقعًا، مما يساعد الأنظمة على البقاء مستقرة تحت الحمل.
RAII يتألّق مع الاستثناءات لأن فكّ المكدس يستدعي المُدمرات، لذا تُطلق الموارد حتى عندما يقفز تدفق التحكم بشكل غير متوقع. الاستثناءات أداة: تكلفتها تعتمد على كيفية استخدامها وإعدادات المجمّع/المنصة. النقطة الأساسية أن RAII يجعل التنظيف حتميًا بغض النظر عن كيفية الخروج من النطاق.
غالبًا ما توصف القوالب بأنها "توليد كود وقت الترجمة"، وهذا نموذج ذهني مفيد. تكتب خوارزمية مرة—مثلاً "رتّب هذه العناصر" أو "خزن العناصر في حاوية"—ويولّد المجمّع نسخة مكيّفة مع الأنواع الدقيقة التي تستخدمها.
بما أن المجمّع يعرف الأنواع الملموسة، يمكنه إدماج الدوال، اختيار العمليات الصحيحة، والتحسين بحدة. في كثير من الحالات، تتجنّب الاستدعاءات الافتراضية، فحوصات الأنواع وقت التشغيل، والتفويض الديناميكي الذي قد تحتاجه لإسقاط كود عام في وقت التشغيل.
مثال: max(a, b) قالبي للأعداد الصحيحة يمكن أن يصبح بضع تعليمات آلة. نفس القالب مستخدمًا مع بنية صغيرة قد يتحول أيضًا إلى مقارنات ونقل مباشر—بدون مؤشرات واجهة أو فحوص "ما نوع هذا؟" في وقت التشغيل.
المكتبة القياسية تعتمد كثيرًا على القوالب لأنها تجعل المكوّنات قابلة لإعادة الاستخدام دون عمل مخفي:
std::vector<T> وstd::array<T, N> تخزّن T الخاص بك مباشرة.\n- خوارزميات مثل std::sort تعمل على أنواع بيانات متعددة طالما يمكن مقارنتها.\n- المؤشرات (iterators) تسمح لنفس الخوارزمية بالعمل على vector وarray وجمعيات مخصصة.النتيجة هي كود غالبًا ما يكون سريعًا مثل نسخة مكتوبة يدويًا ومحددة النوع—لأنه فعليًا يصبح واحدة.
القوالب ليست مجانية للمطورين. يمكن أن تزيد أوقات الترجمة (مزید من الكود لتوليده وتحسينه)، وعندما يحدث خطأ قد تكون رسائل الأخطاء طويلة وصعبة القراءة. تتعامل الفرق عادةً مع ذلك عبر إرشادات الكود، أدوات جيدة، والحفاظ على تعقيد القوالب حيث يعود بالفائدة.
مكتبة القوالب القياسية (STL) هي صندوق أدوات C++ المدمج لكتابة كود قابل لإعادة الاستخدام يمكنه أيضًا أن يُترجم إلى تعليمات آلة مضبوطة. ليست إطارًا منفصلاً تضيفه—إنها جزء من المكتبة القياسية، ومصمّمة حول فكرة التكلفة الصفرية: استخدم مكوّنات عالية المستوى دون دفع ثنائي لعمل لم تطلبه.
vector, string, array, map, unordered_map, list, وغير ذلك.\n- الخوارزميات تعمل على نطاقات عناصر: sort, find, count, transform, accumulate, إلخ.\n- المؤشرات (iterators) هي "الغراء" الذي يسمح للخوارزميات بالعمل على أنواع حاويات متعددة باستخدام واجهة مشتركة.هذا الفصل مهم. بدل أن يعيد كل حاوية اختراع "الفرز" أو "البحث"، تعطيك STL مجموعة خوارزميات مجرّبة جيدًا يمكن للمجمّع تحسينها بشدة.
رمز STL يمكن أن يكون سريعًا لأن الكثير من القرارات تُتخذ في وقت الترجمة. إذا فرزت vector<int>، يعرف المجمّع نوع العنصر ونوع المؤشر، ويمكنه إدماج المقارنات وتحسين الحلقات مثل الكود المكتوب يدويًا. السر هو اختيار هياكل بيانات تتوافق مع نمط الوصول.
vector مقابل list: vector غالبًا الاختيار الافتراضي لأن العناصر متجاورة في الذاكرة، ما يجعلها صديقة للكاش وسريعة للعبور والوصول العشوائي. list قد يساعد عندما تحتاج فعلاً إلى مؤشرات ثابتة للعناصر وكثير من التشذيب/الدمج في المنتصف بدون تحريك العناصر—لكنه يدفع تكلفة لكل عقدة وقد يكون أبطأ في التجاوز.
unordered_map مقابل map: unordered_map عادةً خيار جيد للبحث السريع حسب المفتاح في المتوسط. map يحافظ على ترتيب المفاتيح، وهو مفيد لاستعلامات النطاق أو الترتيب المتوقع، لكن عمليات البحث عادةً أبطأ من جدول التجزئة الجيد.
لمزيد من دليل القرار، انظر أيضًا: /blog/choosing-cpp-containers
لم يتخلَّ C++ الحديث عن فكرة ستروستروب الأصلية "التجريد دون عقوبة". بدلاً من ذلك، تركز العديد من الميزات الأحدث على أنك تستطيع كتابة كود أوضح بينما تمنح المجمّع فرصة لإنتاج رمز آلة مضبوطة.
مصدر شائع للبطء هو النسخ غير الضروري—تكرار سلاسل كبيرة أو بافرات أو هياكل بيانات فقط لتمريرها. فكرة حركات النقل بسيطة: "لا تنسخ إذا كنت تقوم حقًا بنقل الشيء." عندما يكون الكائن مؤقتًا (أو انتهيت من استخدامه)، يمكن لـ C++ نقل داخلية الكائن إلى المالك الجديد بدلًا من تكرارها. في الكود اليومي، يعني ذلك غالبًا تخصيصات أقل، حركة بيانات أقل، وتنفيذ أسرع—دون الحاجة لإدارة البايتات يدويًا.
constexpr: احسب مبكرًا ليقل عمل وقت التشغيلبعض القيم والقرارات لا تتغير أبداً (أحجام جداول، ثوابت إعداد، جداول بحث). مع constexpr يمكنك مطالبة C++ بحساب بعض النتائج أثناء الترجمة—لذلك يقوم البرنامج وقت التشغيل بعمل أقل.
الفائدة سرعة وبساطة: يمكن أن يقرأ الكود كعملية عادية، بينما قد ينتهي المطاف بالنتيجة "مخبوزة" كثابت.
النطاقات (والميزات المرتبطة مثل views) تتيح لك التعبير عن "خذ هذه العناصر، صفِّها، حوّلها" بطريقة قابلة للقراءة. إذا استُخدمت جيدًا، يمكن ترجمتها إلى حلقات مباشرة—دون طبقات وقت تشغيل مفروضة.
تدعم هذه الميزات الاتّجاه نحو التكلفة الصفرية، لكن الأداء لا يزال يعتمد على كيفية استخدامها ومدى قدرة المجمّع على تحسين البرنامج النهائي. كثيرًا ما يتحسن الكود العالي المستوى بشكل جميل—ولكن جدير بالقياس عندما يصبح الأداء أمرًا حاسمًا.
يمكن لـ C++ أن تُترجم الكود "عالي المستوى" إلى تعليمات آلة سريعة جدًا—لكنها لا تضمن نتائج سريعة افتراضيًا. غالبًا ما يُفقد الأداء لأن تكلفة صغيرة تتسلل إلى المسارات الساخنة وتتضاعف ملايين المرات.
بعض الأنماط تتكرر:
لا شيء من هذا "مشكلة C++" بالضرورة. إنها عادةً مشكلات تصميم واستخدام—ويمكن أن توجد في أي لغة. الفرق أن C++ تمنحك تحكمًا كافيًا لإصلاحها، وحبلًا كافيًا لارتكابها.
«التجريدات ذات التكلفة الصفرية» هي هدف تصميمي: إذا لم تستخدم ميزة فلا ينبغي أن تضيف عبئًا وقت التشغيل، وإذا استخدمتها فينبغي أن يكون الكود المولد قريبًا مما تكتبه يدويًا بأسلوب منخفض المستوى.
عمليًا، يعني ذلك أنك تستطيع كتابة كود أوضح (أنواع، دوال، خوارزميات عامة) دون أن تدفع تلقائيًا تكلفة إضافية مثل تخصيصات الذاكرة غير اللازمة أو الإحالات أو التفويض الديناميكي.
في هذا السياق، «التكلفة» تعني العمل الإضافي في وقت التشغيل، مثل:
الهدف هو إبقاء هذه التكاليف مرئية وتجنّب فرضها على كل برنامج.
ينجح ذلك عندما يتمكّن المجمّع (compiler) من رؤية التجريد خلال وقت الترجمة—أمثلة شائعة: دوال صغيرة تُدرج تلقائيًا (inlined)، ثوابت وقت الترجمة (constexpr)، وقوالب تُستنسخ بأنواع واضحة.
أقل فاعلية عندما تسود الإحالات وقت التشغيل (مثل التفويض الافتراضي المكثف في حلقة ساخنة) أو عندما تدخل تخصيصات متكررة وهياكل بيانات تكثر فيها تنقلات المؤشرات.
تؤجّل C++ كثيرًا من النفقات إلى وقت البناء لكي يبقى وقت التشغيل خفيفًا. أمثلة نموذجية:
لتستفيد، قم بالترجمة مع خيارات تحسين (مثل /) واحتفظ ببنية الكود بحيث يستطيع المجمّع الاستدلال عليها.
RAII يربط عمر المورد بنطاق الحياة (scope): يُكتسب المورد في المُنشئ (constructor) ويُطلق في المُدمر (destructor). استخدمه للذاكرات، الملفات، الأقفال (mutexes)، مقابض قواعد البيانات، المقابس (sockets)، وذاكرات GPU وغيرها.
عادات عملية:
std::vector, std::string).RAII مفيد بشكل خاص مع الاستثناءات لأنّ عمليات فكّ المكدس (stack unwinding) تنفّذ المُدمرات، وبالتالي تُطلق الموارد حتى لو تغيّر مسار التحكم.
من حيث الأداء، تكون الاستثناءات عادةً مكلفة عند رميها فعليًا، وليس عند إمكانية رميها. إذا كان مسار التنفيذ الحار يرمي كثيرًا، أعد التصميم نحو رموز خطأ أو أنواع شبيهة بـ expected؛ إذا كانت الرميات استثنائية حقًا، فـRAII مع الاستثناءات يحافظ على بساطة المسار السريع.
القوالب تتيح كتابة كود عام يتحول إلى كود محدد النوع أثناء الترجمة، مما يمكّن الإدماج وتجنّب فحوصات الأنواع وقت التشغيل.
المقايضات:
حافظ على تعقيد القوالب حيث يعود بالنفع (الخوارزميات الأساسية، المكوّنات القابلة لإعادة الاستخدام)، وتجنّب الإفراط في استخدام القوالب في طبقات الربط الخاصة بالتطبيق.
افتراضيًا اختر std::vector للتخزين المتجاور والعبور السريع؛ فكّر في std::list فقط إذا كنت بحاجة فعلاً إلى مؤشرات ثابتة للعناصر وعديد من عمليات الدمج/الإدراج في المنتصف دون تحريك العناصر.
بالنسبة للخرائط:
std::unordered_map للبحث السريع في المتوسطstd::map للمفاتيح المرتبة واستعلامات النطاق (مثل "كل المفاتيح بين A وB")لمزيد من الإرشادات انظر /blog/choosing-cpp-containers.
أنماط شائعة تسبب فقدان الأداء:
reserve()).تحقّق دائمًا باستخدام ملف قياس أداء (profiler) بدل التخمين.
ضع قواعد وقائية مبكّرة حتى لا يعتمد الأداء والسلامة على بطولات فردية:
-O2-O3new/deletestd::unique_ptr/std::shared_ptr مستخدمة عن قصد).clang-tidy.هذه العادات تساعدك على الحفاظ على تحكّم C++ مع تقليل السلوكيات غير المعرفة والمفاجآت في الأداء.