در این قسمت از دوره سیپلاسپلاس در رابطه با مدیریت متغیرها در حافظه استک و محدوده وجودی آنها (اسکوپ آنها) صحبت میکنیم.
در سیپلاسپلاس اسکوپ یا محدوده وجودی یک متغیر، نزدیکترین آکولادها به آن خواهد بود. برای مثال بلوک شرط زیر را بررسی میکنیم:
if (condition) {
int x = 10; // x is declared inside this block
}
// x is no longer accessible here
همینطور که کد بالا بیان میکند متغیر x بعد از شرط در دسترس نخواهد بود و اگر سعی کنیم به آن دسترسی پیدا کنیم، به طور مثال چاپش کنیم، اروری مبنی بر عدم وجود این متغیر دریافت خواهیم کرد چرا که x متعلق به اسکوپ یا محدوده بلوک شرط بوده و پس از آن در دسترس نیست.
اسکوپها و توابع
متغیرهایی که در یک تابع تعریف شدهاند در توابع دیگر در دسترس نخواهند بود، برای مثال:
#include <iostream>
#include <string>
using namespace std;
void sayHello() {
cout << "Hello, " << name;
}
int main() {
string name;
cin >> name;
sayHello();
}
این کد با خطا مواجه خواهد شد چرا که متغیر name
فقط در تابع main
در دسترس است و تابع sayHello
به آن دسترسی نخواهد داشت. اگر نیاز باشد مشابه این مثال متغیری را بین دو تابع به اشتراک بگذاریم، میتوانیم مشابه عملکردی که در مقاله مربوط به توابع داشتیم آن را به عنوان یک پارامتر به تابع پاس کنیم و بفرستیم:
#include <iostream>
#include <string>
using namespace std;
void sayHello(string name) {
cout << "Hello, " << name;
}
int main() {
string name;
cin >> name;
sayHello(name);
}
یا این متغیر را به عنوان یک متغیر global (جهانی) تعریف کنیم. متغیرهای گلوبال در بالای کد ما و خارج از هر تابعی تعریف میشوند و در تمام طول کد و برای تمام توابع در دسترس خواهند بود:
#include <iostream>
#include <string>
using namespace std;
string name;
void sayHello() {
cout << "Hello, " << name;
}
int main() {
cin >> name;
sayHello();
}
سایه شدن یک متغیر
اگر دو متغیر با اسم یکسان در دو اسکوپ مجزا تعریف شوند متغیری که در نزدیک ترین اسکوپ قرار دارد انتخاب میشود. برای مثال:
#include <iostream>
#include <string>
using namespace std;
string name;
void sayHello() {
cout << "Hello, " << name;
}
int main() {
string name;
cin >> name;
sayHello();
}
در این مثال متغیر name
ای که cin
انتخاب میکند موردی است که در نزدیکترین اسکوپ، یعنی اسکوپ تابع main
، قرار دارد. این باعث میشود کد بالا همواره فقط "Hello, "
را چاپ کند، چرا که cin
فقط name
داخل تابع main
را تغییر میدهد و name
گلوبال که در اسکوپ دورتری قرار دارد دست نمیخورد و بیتغییر و معادل یک متن خالی باقی میماند. این یعنی name
ای که تابع sayHello
چاپ میکند همیشه خالی خواهد بود.
متغیرهای حلقه و اسکوپ دسترسی آنها
یکی از دلایل دیگری که حلقه for
میتواند نسبت به حلقه while
ترجیح داشته باشد، محدودتر بودن حد و اسکوپ وجود متغیر حلقه است. به این دو مثال دقت کنید:
#include <iostream>
#include <string>
using namespace std;
string name;
void sayHello() {
cout << "Hello, " << name;
}
int main() {
string name;
cin >> name;
sayHello();
}
#include <iostream>
using namespace std;
int main() {
int i = 0;
while (i < 10) {
cout << i << endl;
i++;
}
}
مزیت حلقه for
این است که متغیر i
که در آن تعریف شده، پس از پایان حلقه حذف میشود و اصطلاحا از اسکوپ خارج میشود. اما وقتی از حلقه while
استفاده میکنیم، متغیر شمارنده i
را بیرون حلقه و در اسکوپ بالایی تعریف میکنیم که یعنی این متغیر حتی پس از پایان این حلقه وجود خواهد داشت.
ادامه دادن یک حلقه for
فرض کنید تصمیم داریم از حلقه for
استفاده کنیم اما دو حلقه داشته باشیم که حلقه دوم از ادامه حلقه اول ادامه دهد. به عنوان مثال:
#include <iostream>
using namespace std;
int main() {
for (int i = 0; i < 10; i++) {
cout << i << endl;
}
cout << "Do you want to continue? ";
char ans;
cin >> ans;
}
این کد را نوشتیم و تصمیم داریم اگر کاربر ‘y’ را به عنوان ورودی وارد کرد، شمارش را ادامه دهیم، مشخصا سادهترین راه نوشتن یک حلقه دیگر است که ادامه اعداد را چاپ میکند. به این شکل:
if (ans == 'y') {
for (int i = 10; i <= 20; i++) {
cout << i << endl;
}
}
اما مساله وقتی جالب میشود که تصمیم میگیریم حلقه دوم از همان متغیر i
حلقه اول استفاده کند، برای این کار متغیر i
را بیرون هر دو حلقه و در اسکوپی بالاتر یعنی اسکوپ تابع main
تعریف میکنیم تا متغیر برای هر دو آنها در دسترس باشد. بخش مربوط به تعریف متغیر را هم در حلقهها خالی میگزاریم، این گونه متغیری در حلقهها تعریف نشده و میتوانیم از متغیری که قبلا تعریف کرده بودیم استفاده کنیم.
#include <iostream>
using namespace std;
int main() {
int i = 0;
for (; i < 10; i++) {
cout << i << endl;
}
cout << "Do you want to continue? ";
char ans;
cin >> ans;
if (ans == 'y') {
for (; i <= 20; i++) {
cout << i << endl;
}
}
}
در این مثال i
بیرون حلقه اول تعریف شده و در نتیجه برای حلقه دوم نیز قابل دسترسی خواهد بود و ما بخش اول (تعریف متغیر) حلقهها را خالی گذاشتیم تا از متغیری دیگر در آنها استفاده کنیم. اگر متغیری با همان اسم i
در حلقه دوم تعریف میکردیم این متغیر، متغیر بیرونی متعلق به تابع main
را shadow یا سایه میکند و دیگر نمیتوانستیم به آن دسترسی پیدا کنیم.
حالا که مفهوم محدودهها (Scopes) را بررسی کردیم، بیایید در مورد حافظه پشته (Stack Memory) صحبت کنیم. هر محدوده در یک برنامه بخشی از حافظه مخصوص به خود دارد که به آن فریم پشته یا استک فریم (Stack Frame) گفته میشود. برای مثال، وقتی برنامه شما اجرای تابع main
را شروع میکند، یک استک فریم برای آن ایجاد میشود. این فریم تمامی متغیرهایی که در تابع main
تعریف شدهاند را در خود نگه میدارد.
زمانی که تابع main
تابع دیگری مانند sayHello
را فراخوانی میکند، یک استک فریم جدید برای تابع sayHello
ایجاد میشود. این فریم روی فریم تابع main
قرار میگیرد. پشته دقیقاً همانطور که نامش نشان میدهد کار میکند: به صورت یک پشته و دسته از فریمها، که جدیدترین فراخوانی تابع در بالای پشته قرار میگیرد. وقتی یک تابع اجرای خود را به پایان میرساند، استک فریم مربوط به آن از پشته حذف میشود و حافظهای که به آن اختصاص داده شده بود آزاد میگردد.
به همین دلیل است که در زبان سیپلاسپلاس ، متغیرهایی که داخل یک تابع یا هر نوع بلوک دیگری (مانند بلوکهای
if
یا while
) تعریف میشوند، پس از پایان تابع یا بلوک از محدوده خارج شده و از حافظه حذف میگردند. وقتی فریم پشته حذف میشود، حافظهای که متغیرهای آن فریم استفاده کرده بودند به طور خودکار آزاد میشود.
حافظه استک و توابع بازگشتی
یکی از نکات مورد توجه در رابطه با حافظه استک به توابع بازگشتی و مشکلاتی که تکرار اجرای مجدد یک تابع میتواند ایجاد کند باز میگردد. اگر یک تابع به طور مدام اجرا شود و برای هر اجرای خود یک استک فریم بسازد، نهایتا حافظه استکی که به برنامه ما تعلق دارد پر شده و اصطلاحا سرریز یا overflow میکند، در این حالت برنامه کرش میکند و با خطای segmentation fault خارج میشود. این خطا به این اشاره میکند که برنامه ما به بخشی از حافظه دسترسی پیدا کرده که مجاز به انجام آن نبوده
بگذارید مثال drawRectangle را مجددا بررسی کنیم، هر بار اجرای recursive یا بازگشتی این تابع یک استک فریم جدید برای متغیرها و پارامترهای آن اجرا ایجاد میکند. این فرایند تا جایی ادامه پیدا میکند که base case اتفاق بیفتد و اجرای بازگشتی متوقف شود، در آن نقطه استک فریمها به ترتیب بسته شده و حافظه مربوط به اجراهای بازگشتی آزاد میشود.
در حالی که Recursion یک تکنیک قدرتمند است، مشکلاتی نیز به همراه دارد، از جمله تمام کردن حافظه پشته (Stack Overflow). اگر عمق بازگشت بیش از حد زیاد شود، حافظه پشته تمام میشود. برای مثال، اگر تابعی مانند drawRectangle(4, 4000000)
را فراخوانی کنید، برنامه ۴۰۰۰۰۰۰ فریم پشته ایجاد خواهد کرد و نهایتا کرش میکند.
اگر حافظه موجود در پشته برای این تعداد فریم کافی نباشد، برنامه با خطای تقسیمبندی حافظه (Segmentation Fault) متوقف میشود. این خطا نشان میدهد که برنامه تلاش کرده به حافظهای دسترسی پیدا کند که مجاز به استفاده از آن نبوده است.
جلوگیری از استک اورفلو
ممکن است این سوال برای شما پیش بیاید که اصلا ریکرژن با این مشکلات کاربردی است یا خیر؟ ریکرژن همچنان یک ابزار بسیار مفید در بسیاری از موارد است، بهویژه کد را برای مسائلی که میتوان به زیرمسائل کوچکتر و تکراری تقسیم کرد، ساده میکند. با این حال، باید در مورد اینکه کجا و چگونه از آن استفاده میکنید، دقت داشته باشید.
برای مثال، اگر در حال نوشتن کدی برای برنامهای هستید که روی سختافزار محدود اجرا میشود، مانند یک میکروکنترلر که داخل یک ربات قرار میگیرد، باید به استفاده بهینه از حافظه توجه کنید. همینطور، اگر کد شما بخشی از یک برنامه است که سرعت و پرفورمنس آن مهم است، مانند منطق رندر در یک بازی ویدیویی، ریکرژن ممکن است ایدهآل نباشد، زیرا میتواند اجرای برنامه را کند کرده و نسبت به حلقه حافظه بیشتری مصرف کند.
در مواردی که توابع بازگشتی مناسب نیستند، حلقهها میتوانند جایگزین مناسبی باشند. برخلاف ریکرژن، حلقهها از همان فریم پشته برای هر تکرار استفاده میکنند و خطر تمام شدن حافظه پشته را از بین میبرند. به عنوان مثال، اگر تابع drawRectangle
را با استفاده از یک حلقه for
بازنویسی کنیم، این تابع دیگر با کمبود حافظه پشته مواجه نخواهد شد، حتی اگر مستطیل بسیار بزرگی رسم شود.
شاید برایتان جالب باشد بدانید که زبانهایی وجود دارند که ساختار مشخصی برای نمایش حلقهها ندارند و به طور کامل بر توابع بازگشتی برای فرایندهای تکرارپذیر تکیه میکنند، یکی از این زبانها Haskell نام دارد که اصطلاحا یک زبان Functional است، این زبانها برای شرح دستورات و عملکردها عمدتا از توابع استفاده میکنند و ابزارهای مرسومی مثل حلقهها را در اختیار برنامهنویسان قرار نمیدهند.
به عنوان مثال، یک تابع در Haskell برای جمع کردن تمام اعداد کوچکتر از یک مقدار n
به این شکل خواهد بود:
sumNumbers :: Int -> Int
sumNumbers 0 = 0
sumNumbers n = n + sumNumbers (n - 1)
در سیپلاسپلاس، کد معادل ممکن است از حلقه استفاده کند، اما میتوان آن را با استفاده از توابع بازگشتی نیز پیادهسازی کرد:
#include <iostream>
using namespace std;
int sumNumbers(int n);
int main() {
cout << sumNumbers(5) << endl;
}
int sumNumbers(int n) {
if (n == 0) {
return 0;
}
return n + sumNumbers(n - 1);
}
با وجود این که ما در سیپلاسپلاس امکان انتخاب بین هر دو گزینه ریکرژن و حلقه را داریم، دانستن مزایا و معایب هر دو روش بسیار دارای اهمیت است.
در این مقاله، دو مفهوم مهم را پوشش دادیم: محدودهها (Scopes) و حافظه استک (Stack Memory). اسکوپها تعیین میکنند که متغیرها در کجا قابل دسترسی هستند و چه مدت وجود دارند، در حالی که حافظه استک وظیفه ذخیرهسازی و پاکسازی این متغیرها را در طول اجرای برنامه بر عهده دارد. همچنین چالشهای استفاده از توابع بازگشتی (Recursion)، از جمله خطر پُر شدن استک (Stack Overflow)، و چگونگی استفاده از حلقهها بهعنوان جایگزین در برخی موارد را بررسی کردیم.
اگرچه این مقاله بیشتر روی مفاهیم تمرکز داشت تا ساخت یک پروژه، درک این سازوکارهای پایه برای نوشتن برنامههای کارآمد و قابل اعتماد ضروری است.