أصبح النقاش حول أمان الذاكرة بين C++ و Rust من أكثر المواضيع نشاطاً في هندسة البرمجيات. فقد أدلت وكالات حكومية بدلوها، وخُصِّصت محاضرات في المؤتمرات لهذا الموضوع، والآراء قوية لدى كلا الطرفين.
دعوني أكون صريحاً من البداية: Rust لغة ممتازة. نموذج الملكية ومدقق الاستعارة فيها مبتكران حقاً، ويكتشفان فئات كاملة من الأخطاء في وقت التجميع. إذا كنت تبدأ مشروعاً جديداً و Rust تناسب فريقك ومنظومتك البرمجية، فهذا خيار رائع.
في الوقت ذاته، تظل C++ العمود الفقري لأكثر البرمجيات حساسية للأداء في العالم: نواة أنظمة التشغيل، ومحركات الألعاب، والمتصفحات، وقواعد البيانات، والأنظمة المالية. هذا ليس من قبيل الصدفة، وليس لأنّ تلك الفرق لم تسمع بـ Rust.
ما أريد استكشافه في هذا المقال هو نقطة كثيراً ما تضيع وسط النقاش: توفّر C++ الحديثة (C++11 وما بعدها) أدوات متينة لكتابة كود آمن من حيث الذاكرة. المؤشرات الذكية، و RAII، والحاويات ذات فحص الحدود، والمكتبة القياسية الغنية تعالج نفس فئات الأخطاء التي يفرضها مُجمِّع Rust. اللغتان ببساطة تتبعان نهجين تصميميين مختلفين لحل المشكلة ذاتها: الإلزام على مستوى المُجمِّع مقابل الأنماط التي يختارها المطوّر.
بدلاً من الجدل النظري، دعوني أريكم ما أعنيه بأمثلة عملية قابلة للتشغيل.
إخلاء مسؤولية: الآراء الواردة في هذا المقال تعبّر عن رأيي الشخصي وتستند إلى خبرتي المهنية. جميع الأمثلة البرمجية مبسّطة عمداً لأغراض تعليمية. لكل لغة مزايا وعيوب؛ يهدف هذا المقال إلى تشجيع نقاش مدروس، لا إلى التقليل من شأن أي لغة أو مجتمعها. قد تختلف النتائج حسب حالتك.
المثال 1: المؤشر المعلّق (الاستخدام بعد التحرير)
هذا أحد أكثر أخطاء أمان الذاكرة شيوعاً. يستمر استخدام مؤشر بعد تحرير الذاكرة التي يشير إليها.
كود C++ السيّئ (ممارسة خاطئة)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(42);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 // ptr is now dangling (it still holds the address of freed memory)
8 delete ptr;
9
10 // Undefined behavior: reading freed memory
11 std::cout << "After delete: " << *ptr << std::endl;
12
13 return 0;
14}
ما الخطأ الذي حدث:
خصّص المطوّر الذاكرة يدوياً باستخدام new، وحرّرها يدوياً باستخدام delete، ثم واصل استخدام المؤشر الخام. هذا مثال نموذجي لخطأ الاستخدام بعد التحرير. المشكلة ليست أنّ C++ “سمحت” بحدوث ذلك، بل أنّ المطوّر استخدم نمطاً يعود لتسعينيات القرن الماضي في حين تتوفر بدائل حديثة.
كود C++ الجيّد (ممارسة حديثة)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(42);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Explicitly releases the memory
9 ptr.reset();
10
11 if (!ptr) {
12 std::cout << "Pointer is null, safe to handle." << std::endl;
13 }
14
15 return 0;
16}
ما الذي تغيّر:
يمتلك std::unique_ptr المورد. عندما يخرج عن النطاق أو يُعاد تعيينه، تُحرَّر الذاكرة و يصبح المؤشر قابلاً للفحص مقابل null. لا يوجد مؤشر معلّق. لا سلوك غير محدد. هذا جزء من معيار C++ منذ عام 2011.
المثال 2: تجاوز سعة المخزن المؤقت
القراءة أو الكتابة خارج حدود المصفوفة مصدر كلاسيكي آخر للثغرات الأمنية.
كود C++ السيّئ (ممارسة خاطئة)
1#include <iostream>
2#include <cstring>
3
4int main() {
5 char buffer[10];
6
7 // Copying more data than the buffer can hold
8 const char* source = "This string is way too long for the buffer";
9 std::strcpy(buffer, source);
10
11 std::cout << "Buffer: " << buffer << std::endl;
12 return 0;
13}
ما الخطأ الذي حدث:
استخدم المطوّر دالة من حقبة لغة C (وهي strcpy) مع مصفوفة C ثابتة الحجم دون أي فحص للحدود. هذا النوع من الكود هو ما يعطي C++ سمعة سيئة، لكنه ليس C++ حديثة. إنه كود C يُجمَّع بمُجمِّع C++.
كود C++ الجيّد (ممارسة حديثة)
1#include <iostream>
2#include <string>
3
4int main() {
5 std::string buffer;
6
7 // std::string manages its own memory and grows as needed
8 buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
9
10 std::cout << "Buffer: " << buffer << std::endl;
11
12 // Need a substring? Bounds-checked:
13 try {
14 std::string sub = buffer.substr(0, 10);
15 std::cout << "Substring: " << sub << std::endl;
16
17 // This would throw std::out_of_range:
18 // std::string bad_sub = buffer.substr(9999, 10);
19 } catch (const std::out_of_range& e) {
20 std::cout << "Caught: " << e.what() << std::endl;
21 }
22
23 return 0;
24}
ما الذي تغيّر:
يدير std::string ذاكرته بنفسه. لا يمكن أن يحدث فيه تجاوز. محاولة إجراء عملية خارج الحدود باستخدام .at() أو .substr() بمؤشرات غير صالحة تطرح استثناءً يمكن التقاطه. هذا موجود في المعيار منذ C++98.
المثال 3: الوصول خارج حدود المصفوفة
كود C++ السيّئ (ممارسة خاطئة)
1#include <iostream>
2
3int main() {
4 int arr[5] = {10, 20, 30, 40, 50};
5
6 // Accessing index 10 (way out of bounds)
7 std::cout << "arr[10] = " << arr[10] << std::endl;
8
9 return 0;
10}
ما الخطأ الذي حدث:
مصفوفات C الخام لا تملك أي فحص للحدود على الإطلاق. وصل المطوّر إلى فهرس غير صالح وحصل على قيم عشوائية (أو أسوأ من ذلك، ثغرة أمنية). مرة أخرى، هذا اختيار المطوّر لاستخدام أبسط بنية بيانات متاحة.
كود C++ الجيّد (ممارسة حديثة)
1#include <iostream>
2#include <array>
3#include <stdexcept>
4
5int main() {
6 std::array<int, 5> arr = {10, 20, 30, 40, 50};
7
8 try {
9 // .at() performs bounds checking and throws on invalid access
10 std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11 std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12 } catch (const std::out_of_range& e) {
13 std::cout << "Caught out-of-range: " << e.what() << std::endl;
14 }
15
16 return 0;
17}
ما الذي تغيّر:
std::array حاوية ثابتة الحجم تعرف حجمها. تُجري الدالة .at() فحصاً للحدود في وقت التشغيل وتطرح استثناءً إذا تجاوزت الحدود. متاحة منذ C++11.
المثال 4: التحرير المزدوج
كود C++ السيّئ (ممارسة خاطئة)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(100);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 delete ptr;
8 // Double free (undefined behavior, potential crash or exploit)
9 delete ptr;
10
11 return 0;
12}
ما الخطأ الذي حدث:
حرّر المطوّر نفس الذاكرة مرتين. يمكن أن يُفسد ذلك الحالة الداخلية لمخصّص الكومة وهو ثغرة استغلال معروفة. إنّه مرة أخرى خطأ في الإدارة اليدوية للذاكرة.
كود C++ الجيّد (ممارسة حديثة)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(100);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Memory is freed exactly once when ptr goes out of scope.
9 // You literally cannot double-free with unique_ptr.
10 // Calling reset() is also safe:
11 ptr.reset();
12 // No-op. No crash. No UB.
13 ptr.reset();
14
15 std::cout << "Done safely." << std::endl;
16 return 0;
17}
ما الذي تغيّر:
يضمن std::unique_ptr دلالات الملكية الفردية. تُحرَّر الذاكرة مرة واحدة بالضبط؛ إما عندما يخرج المؤشر عن النطاق أو عند استدعاء reset(). استدعاء reset() على unique_ptr فارغ بالفعل هو عملية آمنة بلا أثر.
المثال 5: الملكية المشتركة - الطريقة الخاطئة مقابل الصحيحة
كود C++ السيّئ (ممارسة خاطئة)
1#include <iostream>
2
3void use(int* p) {
4 std::cout << "Using: " << *p << std::endl;
5}
6
7int main() {
8 int* shared = new int(99);
9
10 use(shared);
11
12 delete shared;
13
14 // Another function tries to use the same pointer (use-after-free)
15 use(shared);
16
17 return 0;
18}
ما الخطأ الذي حدث:
أجزاء متعددة من الكود تتشارك مؤشراً خاماً دون ملكية واضحة. بمجرد أن يحذف أحد الأجزاء الذاكرة، تبقى جميع الأجزاء الأخرى تحمل مؤشراً معلّقاً.
كود C++ الجيّد (ممارسة حديثة)
1#include <iostream>
2#include <memory>
3
4void use(std::shared_ptr<int> p) {
5 std::cout << "Using: " << *p
6 << " (ref count: " << p.use_count() << ")" << std::endl;
7}
8
9int main() {
10 auto shared = std::make_shared<int>(99);
11
12 use(shared);
13 use(shared);
14
15 // Memory freed automatically when the last shared_ptr goes out of scope.
16 std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18 return 0;
19}
ما الذي تغيّر:
يستخدم std::shared_ptr عدّ المراجع لإدارة الملكية المشتركة. لا تُحرَّر الذاكرة إلا عند تدمير آخر shared_ptr يشير إليها. لا مؤشرات معلّقة. لا delete يدوي. متاح منذ C++11.
المثال 6: RAII - أمان C++ الحديثة
RAII (الحصول على الموارد عند التهيئة) هو ربما أهم نمط تصميمي في C++ على الإطلاق. الفكرة بسيطة: اربط عمر المورد بعمر الكائن. عند إنشاء الكائن، يحصل على المورد. وعند تدمير الكائن (خروجه عن النطاق)، يُحرِّر المورد. لا تنظيف يدوي. لا فرصة للنسيان.
المؤشرات الذكية ليست سوى تطبيق واحد لنمط RAII. يعمل هذا النمط مع أي مورد: الملفات، وأقفال المزامنة، ومقابس الشبكة، واتصالات قواعد البيانات، ومقابض GPU - سمِّ ما شئت.
كود C++ السيّئ (إدارة الموارد يدوياً)
1#include <iostream>
2#include <fstream>
3#include <stdexcept>
4
5void process_file(const std::string& filename) {
6 FILE* f = fopen(filename.c_str(), "r");
7 if (!f) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 char buf[256];
12 if (!fgets(buf, sizeof(buf), f)) {
13 // Oops - if we throw or return here, we leak the file handle
14 throw std::runtime_error("Cannot read file");
15 }
16
17 // If any exception is thrown above, fclose is never called
18 fclose(f);
19 std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23 try {
24 process_file("example.txt");
25 } catch (const std::exception& e) {
26 std::cerr << "Error: " << e.what() << std::endl;
27 }
28 return 0;
29}
ما الخطأ الذي حدث:
استخدم المطوّر fopen/fclose بأسلوب لغة C. إذا طُرِح استثناء بين عملية الفتح والإغلاق، يتسرّب مقبض الملف. مع تزايد الموارد ومسارات الأخطاء، يصبح إغلاق كل شيء بشكل موثوق كابوساً في الصيانة.
كود C++ الجيّد (RAII باستخدام std::ifstream)
1#include <iostream>
2#include <fstream>
3#include <string>
4
5void process_file(const std::string& filename) {
6 std::ifstream file(filename);
7 if (!file.is_open()) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 std::string line;
12 if (!std::getline(file, line)) {
13 throw std::runtime_error("Cannot read file");
14 }
15
16 // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17 std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21 try {
22 process_file("example.txt");
23 } catch (const std::exception& e) {
24 std::cerr << "Error: " << e.what() << std::endl;
25 }
26 return 0;
27}
ما الذي تغيّر:
std::ifstream هو غلاف RAII حول مقبض ملف. يُفتَح الملف في المُنشئ ويُغلَق في المُدمِّر بضمان كامل، بغض النظر عن كيفية خروج الدالة (عودة طبيعية، استثناء، أو عودة مبكرة). صفر تسريب للموارد بالتصميم.
RAII للموارد المخصصة (قفل المزامنة)
لا يقتصر RAII على المكتبة القياسية. إليك كيف يعمل مع مزامنة الخيوط:
1#include <iostream>
2#include <mutex>
3#include <thread>
4#include <vector>
5
6std::mutex mtx;
7int shared_counter = 0;
8
9void increment(int times) {
10 for (int i = 0; i < times; ++i) {
11 // std::lock_guard locks the mutex on construction,
12 // unlocks it on destruction (end of scope, even if an exception occurs)
13 std::lock_guard<std::mutex> lock(mtx);
14 ++shared_counter;
15 }
16}
17
18int main() {
19 std::vector<std::thread> threads;
20 for (int i = 0; i < 4; ++i) {
21 threads.emplace_back(increment, 10000);
22 }
23
24 for (auto& t : threads) {
25 t.join();
26 }
27
28 // Always prints 40000 (no data race, no forgotten unlock)
29 std::cout << "Counter: " << shared_counter << std::endl;
30
31 return 0;
32}
كتابة غلاف RAII خاص بك
يمكنك تطبيق RAII على أي مورد. إليك مثالاً بسيطاً:
1#include <iostream>
2#include <stdexcept>
3
4// A generic RAII wrapper (acquires in constructor, releases in destructor)
5class SocketHandle {
6 int fd_;
7public:
8 explicit SocketHandle(int fd) : fd_(fd) {
9 if (fd_ < 0) throw std::runtime_error("Invalid socket");
10 std::cout << "Socket " << fd_ << " acquired." << std::endl;
11 }
12
13 ~SocketHandle() {
14 if (fd_ >= 0) {
15 std::cout << "Socket " << fd_ << " released." << std::endl;
16 fd_ = -1;
17 }
18 }
19
20 // Prevent copying (single ownership, like unique_ptr)
21 SocketHandle(const SocketHandle&) = delete;
22 SocketHandle& operator=(const SocketHandle&) = delete;
23
24 // Allow moving
25 SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26 other.fd_ = -1;
27 }
28
29 int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33 SocketHandle sock(42); // "Acquired"
34 std::cout << "Using socket " << sock.get() << std::endl;
35 // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39 do_network_stuff();
40 std::cout << "Back in main - no resource leak." << std::endl;
41 return 0;
42}
الخلاصة الأساسية:
RAII ليس حلاً بديلاً أو “نصيحة من أفضل الممارسات” - بل هو نمط تصميمي جوهري في C++ يجعل تسريب الموارد مستحيلاً هيكلياً عند تطبيقه بشكل متسق. إنّه يسبق نموذج الملكية في Rust، بل وقد ألهمه في جوانب عديدة.
كيف تقارن Rust؟
يكتشف مدقق الاستعارة في Rust كثيراً من هذه المشكلات قبل تشغيل الكود أصلاً، أي في وقت التجميع. هذه ميزة حقيقية وجوهرية تستحق التقدير.
إليك كيف أصيغ المقايضات بصدق:
Rust تفرض الأمان على مستوى المُجمِّع؛ C++ توفّر الأمان من خلال مكتبتها القياسية وأنماطها التصميمية. كلا النهجين صالح؛ وهما يعكسان فلسفات تصميمية مختلفة.
C++ الحديثة تمنع نفس فئات الأخطاء عند استخدام أدواتها الموصى بها. المؤشرات الذكية، و RAII، و
std::array، وstd::string، وstd::span، وstd::optional؛ هذه ليست ميزات مكتبة غامضة. إنها الطريقة الموصى بها لكتابة C++ منذ أكثر من عقد.لا توجد لغة تحلّ كل شيء. الكلمة المفتاحية
unsafeفي Rust موجودة لسبب؛ فأحياناً تحتاج إلى تجاوز شبكة الأمان من أجل واجهات FFI، أو الأداء، أو العمليات منخفضة المستوى. كما أنّ الأخطاء المنطقية، وحالات السباق (في بعض الأنماط)، وأخطاء التصميم موجودة في كل لغة.البراغماتية مهمة. مليارات الأسطر من كود C++ المُختبَر ميدانياً تعمل في بيئات الإنتاج اليوم. بالنسبة لكثير من الفرق، يُعدّ تبنّي ممارسات C++ الحديثة وأدوات أفضل والتحليل الساكن مساراً أكثر واقعية من إعادة كتابة كاملة؛ وهو يحقق تحسينات حقيقية في الأمان.
ماذا عن الأدوات؟
يمتلك مطوّرو C++ الحديثة أدوات أمان قوية تكتشف مشكلات تتجاوز ما تفرضه اللغة نفسها:
| الأداة | الغرض |
|---|---|
| AddressSanitizer (ASan) | يكتشف الاستخدام بعد التحرير، وتجاوز المخزن المؤقت، وتجاوز المكدس |
| MemorySanitizer (MSan) | يكتشف قراءة الذاكرة غير المهيّأة |
| UndefinedBehaviorSanitizer (UBSan) | يكتشف الطفحان في الأعداد المُوقَّعة، وإلغاء مرجعية null، والمزيد |
| Valgrind | كشف تسريبات الذاكرة وتحليل الأداء |
| Clang-Tidy | تحليل ساكن مع اقتراحات للتحديث |
| C++ Core Guidelines | إرشادات معيارية صناعية لكتابة C++ حديثة وآمنة |
تجميع الأمثلة “السيّئة” من هذا المقال باستخدام -fsanitize=address سيُبلّغ فوراً عن المشكلات في وقت التشغيل. الأدوات موجودة، والسؤال هو ما إذا كان المطوّرون يستخدمونها.
الخلاصة
تتعامل C++ و Rust مع أمان الذاكرة من زوايا مختلفة:
- Rust تجعل الأمان هو الوضع الافتراضي وتتطلب منك الإلغاء صراحةً باستخدام
unsafe. - C++ تمنح المطوّر التحكم الكامل وتوفّر الأمان من خلال مكتبتها القياسية و RAII والأنماط الراسخة.
معظم مشكلات أمان الذاكرة في قواعد أكواد C++ تأتي من استخدام أنماط قديمة؛ new/delete الخام، ومصفوفات بأسلوب C، ودوال مثل strcpy؛ بدلاً من الأدوات الحديثة التي وفّرتها اللغة منذ أكثر من عقد.
الخبر الجيد أنّ المسار للأمام في C++ واضح وموثّق جيداً:
- استخدم المؤشرات الذكية (
std::unique_ptr،std::shared_ptr) بدلاً منnew/deleteالخام - استخدم الحاويات القياسية (
std::string،std::vector،std::array) بدلاً من المخازن المؤقتة بأسلوب C - اعتمد RAII لإدارة جميع الموارد
- استفد من أدوات الفحص (ASan، MSan، UBSan) والتحليل الساكن في مسارات التكامل المستمر
- اتّبع إرشادات C++ الأساسية
كلتا اللغتين أداتان قويتان، ولكل منهما مكان في تطوير البرمجيات الحديثة. الاختيار الأفضل يعتمد على مشروعك وفريقك وقيودك؛ لا على النقاشات عبر الإنترنت.
إذا وجدت هذا المقال مفيداً أو مثيراً للتفكير، فلا تتردد في مشاركته. وإذا كان لديك منظور مختلف، يسعدني سماعه في قسم التعليقات أدناه. أفضل النقاشات الهندسية تحدث عندما نحتجّ بالكود، لا بالآراء فحسب.
التعليقات