آشنایی با مفهوم اسکوپ و حافظه استک (سی‌پلاس‌پلاس)

قسمت 11 از دوره C++

معرفی

اسکوپ و محدوده چیست؟

حافظه استک چیست؟

زبان‌های بدون حلقه

جمع‌بندی

معرفی
اسکوپ و محدوده چیست؟
حافظه استک چیست؟
زبان‌های بدون حلقه
جمع‌بندی

در این قسمت از دوره سی‌پلاس‌پلاس در رابطه با مدیریت متغیرها در حافظه استک و محدوده وجودی آن‌ها (اسکوپ آن‌ها) صحبت می‌کنیم.

در سی‌پلاس‌پلاس اسکوپ یا محدوده وجودی یک متغیر، نزدیک‌ترین آکولاد‌ها به آن خواهد بود. برای مثال بلوک شرط زیر را بررسی می‌کنیم:

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)، و چگونگی استفاده از حلقه‌ها به‌عنوان جایگزین در برخی موارد را بررسی کردیم.

اگرچه این مقاله بیشتر روی مفاهیم تمرکز داشت تا ساخت یک پروژه، درک این سازوکارهای پایه برای نوشتن برنامه‌های کارآمد و قابل اعتماد ضروری است.

وارد شوید تا پیشرفت خود را ثبت کنید

وارد شوید تا پروژه‌هایی که تکمیل می‌کنید را علامت گذاری کنید و فرایند یادگیری خود را ثبت کنید

دیدگاهتان را بنویسید

برای نوشتن نظر٬ اول باید وارد شوید

مرحله بعد

معرفی

اطلاعات شما با موفقیت ثبت شد. کارشناسان ما در اسرع وقت با شما تماس خواهند گرفت.

از شکیبایی شما متشکریم.