انتقل إلى المحتوى

المتغيرات والنطاق والعمر

تعرف على فترات التخزين الأربعة، وكيف أن النطاق والعمر هما مفهومان مختلفان، ومزالق التهيئة، والفشل الذريع في أمر التهيئة الثابتة.

النطاق والعمر ليسا نفس الشيء

أحد المفاهيم الخاطئة الأكثر شيوعًا في C++ هو الخلط بين النطاق والعمر. إنها مفاهيم مرتبطة ولكنها متميزة:

النطاق هو خاصية وقت الترجمة — فهو يحدد مكان ظهور الاسم في الكود المصدري. المتغير المعلن داخل كتلة{}يكون مرئيًا فقط داخل تلك الكتلة وأي كتل متداخلة.

مدة الحياة هي خاصية وقت التشغيل — فهي تحدد متى يكون الكائن موجودًا في الذاكرة. يبدأ عمر الكائن عندما يكتمل منشئه وينتهي عندما يبدأ تدميره.

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

فترات التخزين الأربعة

يحدد C++ أربع فترات تخزين تتحكم في وقت إنشاء الكائنات وتدميرها:

storage.cppc++23
#include <iostream>
#include <string>

// Static storage duration — lives for the entire program
int global_count = 0;                    // Initialized before main()
static int file_local = 42;              // Same, but only visible in this file

void demonstrate_storage() {
    // Automatic storage duration — created on entry, destroyed on exit
    int local = 10;                      // Born here, dies at closing brace

    // Static storage duration (local scope)
    static int call_count = 0;           // Initialized ONCE, persists across calls
    ++call_count;
    std::cout << "Call #" << call_count << '\n';

    // Thread-local storage duration (C++11)
    // thread_local int per_thread = 0;  // One copy per thread, lives until thread ends
}

int main() {
    demonstrate_storage();  // Call #1
    demonstrate_storage();  // Call #2 — call_count is still alive!
    demonstrate_storage();  // Call #3

    // Dynamic storage duration — you control the lifetime
    int* heap_int = new int{99};         // Born here
    std::cout << *heap_int << '\n';
    delete heap_int;                     // Dies here — you must not forget!
    heap_int = nullptr;                  // Good practice: nullify after delete

    // In modern C++, prefer smart pointers over raw new/delete
    // auto ptr = std::make_unique<int>(99);  // Covered in later modules
}
المخرجات

اضغط على تشغيل لترجمة هذا المثال وتشغيله.

نماذج التهيئة والتهيئة الصفرية

لدى C++ ثلاثة مفاهيم تهيئة أساسية:

التهيئة الافتراضية — يتم إنشاء المتغير بدون مُهيئ. بالنسبة للأنواع المضمنة (int، double، وما إلى ذلك)، فهذا يعني أن القيمة غير محددة (قراءتها هي سلوك غير محدد!). بالنسبة لأنواع الفئات، يتم استدعاء المنشئ الافتراضي.

تهيئة القيمة — يتم تشغيلها بواسطة الأقواس الفارغةT()أو الأقواس الفارغةT{}. بالنسبة للأنواع المضمنة، التهيئة الصفرية: تصبح الأعداد الصحيحة 0، وتصبح المؤشرات فارغة، وتصبح القيم المنطقية خاطئة. بالنسبة لأنواع الفئات، يتم استدعاء المنشئ الافتراضي (وتتم تهيئة قيمة الأعضاء إذا لم يقم أي مُنشئ بتهيئتهم).

التهيئة الصفرية — مرحلة محددة تحدث للمتغيرات الثابتة/المحلية قبل أي تهيئة أخرى. تتم تهيئة كافة المتغيرات الثابتة قبل بدء تشغيل البرنامج.

هذا هو السبب في ضمان أن يكونstatic int x;0، لكنint x;المحلي ليس — المتغير المحلي تتم تهيئته افتراضيًا (غير محدد)، بينما يمر المتغير الثابت عبر التهيئة الصفرية أولاً.

التهيئة في الممارسة

يعد فهم متى يكون المتغير صفرًا مقابل غير محدد أمرًا بالغ الأهمية لتجنب الأخطاء:

init_forms.cppc++23
#include <iostream>

struct Point {
    int x;
    int y;
};

static int global_int;          // Zero-initialized → 0
static Point global_point;      // Zero-initialized → {0, 0}

int main() {
    int a;                      // Default-initialized → INDETERMINATE (UB to read!)
    int b{};                    // Value-initialized → 0
    int c = int();              // Value-initialized → 0
    int d = {};                 // Value-initialized → 0

    Point p1;                   // Default-initialized → members are INDETERMINATE
    Point p2{};                 // Value-initialized → {0, 0}
    Point p3{1, 2};             // Aggregate-initialized → {1, 2}

    // Array initialization
    int arr1[5];                // Default-initialized → INDETERMINATE values!
    int arr2[5]{};              // Value-initialized → {0, 0, 0, 0, 0}
    int arr3[5]{1, 2};          // Partially initialized → {1, 2, 0, 0, 0}

    std::cout << "global_int: " << global_int << '\n';  // Safe: 0
    std::cout << "b: " << b << '\n';                    // Safe: 0
    // std::cout << a;  // DANGER: UB — reading uninitialized variable!

    std::cout << "p2: (" << p2.x << ", " << p2.y << ")\n";  // Safe: (0, 0)
    std::cout << "p3: (" << p3.x << ", " << p3.y << ")\n";  // Safe: (1, 2)
}
المخرجات

اضغط على تشغيل لترجمة هذا المثال وتشغيله.

أمر التدمير: LIFO

يتم تدمير الكائنات ذات مدة التخزين التلقائي (المتغيرات المحلية) بـ الترتيب العكسي لتكوينها — آخر ما يدخل، يخرج أولاً (LIFO). هذه ليست مجرد اتفاقية. المعيار يضمن ذلك.

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

بالنسبة للمتغيرات الثابتة، يحدث التدمير بالترتيب العكسي للبناء بعد عودةmain(). يتم إنشاء المتغيرات العامة المحددة في نفس وحدة الترجمة من الأعلى إلى الأسفل ويتم تدميرها من الأسفل إلى الأعلى. ولكن عبر وحدات الترجمة، يكون ترتيب إنشاء العناصر العالمية غير محدد — وهذا هو الفشل الذريع في ترتيب التهيئة الثابتة.

خطأ شائع

يعد الفشل الذريع في أمر التهيئة الثابتة أحد أكثر الأخطاء شهرة في C++. عندما يكون لديك متغيرات عامة أو ثابتة في وحدات ترجمة مختلفة (ملفات .cpp مختلفة)، فإن معيار C++ لا** يحدد الترتيب الذي تتم به تهيئتها. إذا كان أحد العموميات يعتمد على آخر من وحدة ترجمة مختلفة، فيمكن استخدامه قبل إنشائه.

الإصلاح الكلاسيكي هو لغة الإنشاء عند الاستخدام الأول: استبدل المتغير العام بوظيفة تُرجع مرجعًا إلى متغير ثابت محلي. يتم ضمان تهيئة الإحصائيات المحلية في المرة الأولى التي يتم فيها استدعاء الوظيفة (وهذا آمن لمؤشر الترابط منذ C++11).

  • The order of initialization of globals across translation units is unspecified
  • If global A (in file1.cpp) depends on global B (in file2.cpp), B might not exist yet when A is constructed
  • Fix: use the Construct on First Use idiom — wrap globals in functions with local statics
  • تعد التهيئة الثابتة المحلية آمنة لمؤشر الترابط منذ C++11 (المعروفة باسم "الإحصائيات السحرية")

البناء على مصطلح الاستخدام الأول

يحل هذا النمط فشل أمر التهيئة الثابتة عن طريق تأجيل البناء حتى الاستخدام الأول:

construct_on_first_use.cppc++23
#include <string>
#include <iostream>

// BAD: global depends on another global — order is unspecified across files
// std::string bad_prefix = "[LOG] ";  // What if this is used before construction?

// GOOD: Construct on First Use idiom
std::string& log_prefix() {
    static std::string prefix = "[LOG] ";  // Constructed on first call, thread-safe
    return prefix;
}

std::string& app_name() {
    static std::string name = "MyApp";
    return name;
}

void log(const std::string& message) {
    // Safe: log_prefix() and app_name() are guaranteed to be initialized
    std::cout << log_prefix() << app_name() << ": " << message << '\n';
}

int main() {
    log("Starting up");
    log("Ready");
}
المخرجات

اضغط على تشغيل لترجمة هذا المثال وتشغيله.

النقاط الرئيسية
  • النطاق (حيث يكون الاسم مرئيًا) والعمر (عند وجود كائن) هما مفهومان متميزان يمكن أن يتباعدا
  • يحتوي C++ على أربع فترات تخزين: تلقائي (مكدس)، ثابت (عمر البرنامج)، مؤشر ترابط محلي، وديناميكي (كومة).
  • الأنواع المضمنة التي تمت تهيئتها افتراضيًا لها قيم غير محددة، وقراءتها هي سلوك غير محدد
  • تضمن تهيئة القيمة باستخدام {} عدم التهيئة للأنواع المضمنة
  • يتم إتلاف المتغيرات المحلية بترتيب LIFO — قم بإعلان التبعيات قبل العناصر التابعة لها
  • استخدم لغة البناء عند الاستخدام الأول لتجنب الفشل الذريع في أمر التهيئة الثابتة عبر وحدات الترجمة

اختبار — اختبر معرفتك

(15 نقطة خبرة)

1. ما هي قيمة`int x;`المحلي (بدون مُهيئ) في C++؟

2. ما هو الفشل الذريع في أمر التهيئة الثابتة؟

3. كيف يختلف`static int count = 0;`داخل الوظيفة عن`int count = 0;`؟