متغیرها، دامنه و طول عمر
چهار مدت ذخیرهسازی، تمایز دامنه و طول عمر، دامهای مقداردهی اولیه، و فاجعه ترتیب مقداردهی اولیه ایستا را بیاموزید.
دامنه و طول عمر یکی نیستند
یکی از رایجترین تصورات غلط در C++ یکی دانستن دامنه (Scope) با طول عمر (Lifetime) است. اینها مفاهیم مرتبط اما متمایزی هستند:
دامنه یک ویژگی زمان کامپایل است — تعیین میکند یک نام کجا قابل مشاهده است در کد منبع. متغیری که داخل یک بلوک {} تعریف شده فقط در آن بلوک و بلوکهای تو در توی آن قابل مشاهده است.
طول عمر یک ویژگی زمان اجرا است — تعیین میکند یک شیء چه زمانی در حافظه وجود دارد. طول عمر یک شیء وقتی سازندهاش تکمیل میشود شروع و وقتی مخرباش آغاز میشود پایان مییابد.
در بسیاری موارد، دامنه و طول عمر همزمان هستند: یک متغیر محلی در بلوکش قابل مشاهده است و تا پایان بلوک زنده میماند. اما میتوانند واگرا شوند. یک متغیر محلی static فقط داخل تابعش قابل مشاهده است (دامنه محدود) اما برای تمام طول عمر برنامه زنده میماند (طول عمر نامحدود). یک شیء تخصیصیافته پویا طول عمری دارد که فراتر از دامنه اشارهگری است که آن را ایجاد کرده.
چهار مدت ذخیرهسازی
C++ چهار مدت ذخیرهسازی تعریف میکند که کنترل میکنند اشیاء چه زمانی ایجاد و نابود میشوند:
#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 میشوند، اشارهگرها nullptr میشوند، بولینها false میشوند. برای انواع کلاس، سازنده پیشفرض فراخوانی میشود (و اعضا مقداردهی مقداری میشوند اگر سازندهای آنها را مقداردهی نکند).
مقداردهی صفر — مرحله خاصی است که برای متغیرهای ایستا/محلی نخ قبل از هر مقداردهی دیگری رخ میدهد. تمام متغیرهای ایستا قبل از شروع برنامه مقداردهی صفر میشوند.
به همین دلیل static int x; تضمین شده 0 است، اما int x; محلی نیست — متغیر محلی مقداردهی پیشفرض میشود (نامعین)، در حالی که متغیر ایستا ابتدا مقداردهی صفر میشود.
مقداردهی اولیه در عمل
درک اینکه چه زمانی یک متغیر صفر و چه زمانی نامعین است برای جلوگیری از باگها حیاتی است:
#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++ ترتیب مقداردهی اولیه آنها را مشخص نمیکند. اگر یک متغیر سراسری به متغیر دیگری از واحد ترجمه متفاوت وابسته باشد، ممکن است قبل از ساخته شدنش استفاده شود.
راهحل کلاسیک الگوی ساخت در اولین استفاده (Construct on First Use) است: متغیر سراسری را با تابعی جایگزین کنید که ارجاعی به متغیر ایستای محلی برمیگرداند. متغیرهای ایستای محلی تضمین شده است که اولین بار که تابع فراخوانی میشود مقداردهی شوند (و این از C++11 ایمن از نظر نخ است).
- ترتیب مقداردهی اولیه متغیرهای سراسری بین واحدهای ترجمه مشخص نیست
- اگر A سراسری (در file1.cpp) به B سراسری (در file2.cpp) وابسته باشد، B ممکن است هنگام ساخت A هنوز وجود نداشته باشد
- راهحل: از الگوی ساخت در اولین استفاده استفاده کنید — متغیرهای سراسری را در توابع با متغیرهای ایستای محلی بپیچید
- مقداردهی اولیه ایستای محلی از C++11 ایمن از نظر نخ است (معروف به 'ایستاهای جادویی')
الگوی ساخت در اولین استفاده
این الگو فاجعه ترتیب مقداردهی اولیه ایستا را با به تعویق انداختن ساخت تا اولین استفاده حل میکند:
#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;` دارد؟