اشاره‌گرها در برنامه‌نویسی — راهنمای جامع

۱۲۹۳ بازدید
آخرین به‌روزرسانی: ۱۰ خرداد ۱۴۰۲
زمان مطالعه: ۸ دقیقه
اشاره‌گرها در برنامه‌نویسی — راهنمای جامع

بخش عمده‌ای از برنامه‌هایی که مورد استفاده قرار می‌دهیم، به ترتیبی از اشاره‌گرها بهره می‌گیرند. ممکن است تاکنون با خطای NullPointerException مواجه شده باشید. به عنوان یک برنامه‌نویس باید بدانید که کدی که می‌نویسید، احتمالاً بسیار بیشتر از آنچه تصور می‌کنید، از اشاره‌گرها استفاده می‌کند.

در این نوشته به نحوه عملکرد اشاره‌گرها خواهیم پرداخت. گرچه در این نوشته بیشتر از حد معمول به تئوری توجه داشته‌ایم؛ ولی این کار ضروری بوده است چون اشاره‌گرها کاملاً پیچیده هستند.

کامپایل کردن کد

پیش از آن‌که وارد بحث اشاره‌گرها بشویم باید درکی از نحوه ساخت و اجرای کد داشته باشیم. این بخش ماهیتی تقریباً عمومی دارد و می‌توان گفت که در مورد اکثر زبان‌ها مصداق دارد، اما ممکن است زبان‌هایی وجود داشته باشند که این توضیحات در مورد آن‌ها صحیح نباشد.

توضیح خود را از سطوح بنیادین آغاز می‌کنیم. هر رایانه‌ای از زبان باینری استفاده می‌کند. این زبان مجموعه‌ای از یک و صفرها است که همه فناوری‌های مدرنی که می‌شناسیم را تشکیل داده‌اند. کدنویسی در زبان باینری کار دشواری است، چون فایل‌ها می‌توانند کاملاً سردرگم کننده باشند. در این زبان دستورالعمل‌های خامی که واحد پردازش مرکزی (CPU) رایانه برای اجرا نیاز دارد، نوشته می‌شوند. این زبان به نام کد ماشین شناخته می‌شود.

یک سطح بالاتر از کد ماشین، زبان اسمبلی قرار دارد. این زبان تا حدی برای انسان قابل درک است. با این‌که برنامه‌نویسی به این زبان نیز کار دشواری است، اما غیر ممکن نیست. اسمبلی مجموعه‌ای از دستورهای ساده برای اجرای وظایف مختلف است و به عنوان زبان برنامه‌نویسی سطح پایین شناخته می‌شود. با استفاده از این زبان می‌توان برنامه‌های پیچیده‌ای نوشت، اما بیان مفاهیم انتزاعی کاری دشوار و نیازمند ملاحظات بسیار است.

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

پس اگر نوشتن کد ماشین دشوار است و برنامه‌نویسی اسمبلی نیز کار سختی است، کدهای خود را باید چگونه بنویسیم؟ اینک نوبت آن رسیده است که با زبان‌های سطح بالا آشنا شوید. می‌توان برنامه‌ها را به زبانی نوشت که شبیه زبان انسانی باشد و با آن بتوان الگوریتم‌های پیچیده را بیان کرد. احتمالاً در مورد زبان‌های سطح بالا چیزهای زیادی شنیده‌اید و قطعاً برنامه‌ای را استفاده کرده‌اید که به یکی از این زبان‌ها نوشته شده است:

  • BASIC
  • ++C
  • Lisp

این زبان‌ها کاملاً قدیمی هستند و بسیاری از آن‌ها در اوایل دهه 1950 میلادی توسعه یافته‌اند. تقریباً همه زبان‌های برنامه‌نویسی امروزی مانند PHP و Python، زبان سطح بالا محسوب می‌شوند. با این‌که تعداد زبان‌هایی که الان وجود دارند، تقریباً برای هر منظوری کافی هستند؛ ولی هر روزه زبان‌های برنامه‌نویسی زیادی ابداع می‌شوند. اما اگر رایانه‌ها فقط کد ماشین را می‌فهمند، پس برنامه‌ای که به زبان‌های سطح بالا نوشته می‌شود، چگونه به طرز صحیحی کار خواهد کرد؟

این کار بر عهده کامپایلرها است. کامپایلر برنامه‌ای است که کد سطح بالا را به شکلی درمی‌آورد که رایانه می‌تواند اجرا کند. این شکل جدید، خود می‌تواند یک زبان سطح بالا باشد؛ اما معمولاً از زبان اسمبلی استفاده می‌شود. برخی زبان‌ها (مانند پایتون و جاوا) کد را به یک مرحله میانی به نام بایت‌کد تبدیل می‌کنند. این کد متعاقباً در یک زمان دیگر مثلاً زمانی که برنامه اجرا می‌شود، دوباره کامپایل می‌شود. این مرحله کامپایل just in time نامیده می‌شود و کاملاً متداول است.

مدیریت حافظه

اینک که با نحوه عملکرد زبان‌های برنامه‌نویسی آشنا شدید، نگاهی به مدیریت حافظه در زبان‌های برنامه‌نویسی سطح بالا خواهیم داشت. در نمونه‌هایی که در ادامه ارائه شده‌اند، از شِبه کد استفاده شده است، یعنی کدی که به هیچ زبان خاصی نوشته نشده است، بلکه به جای نمایش ساختار دقیق زبان برنامه‌نویسی از آن برای نمایش مفهوم کد استفاده می‌شود. شبه کد ما بیشتر شبیه زبان C++ است، چون به نظر ما اکثر افراد این زبان برنامه‌نویسی سطح بالا را بهتر می‌فهمند.

برای درک بهتر این بخش، باید دست‌کم اطلاعات مختصری در مورد RAM رایانه داشته باشید.

اغلب زبان‌های برنامه‌نویسی مفاهیمی به نام متغیر دارند که در واقع مکان‌های از حافظه برای ذخیره برخی داده‌ها هستند.. لازم است که نوع این داده را به طور صریح تعیین کنید. برخی زبان‌های با انواع دینامیک مانند پایتون و PHP مدیریت این مسئله را به جای شما بر عهده می‌گیرند، اما به هر حال باز لازم است که این کار انجام شود.

فرض کنیم یک متغیر داریم:

int myNumber;

کد فوق یک متغیر به نام myNumber تعریف می‌کند و نوع integer را برای آن تعیین می‌کند. زمانی که کد کامپایل شود، رایانه این کد را به صورت زیر تفسیرمی کند:

«یک مقدار حافظه خالی پیدا بکن و فضایی به اندازه ذخیره‌سازی یک عدد صحیح را رزرو کن.»

زمانی که دستور اجرا شود، برنامه دیگری نمی‌تواند از این بیت حافظه استفاده کند. با این‌که این حافظه هنوز حاوی مقداری نیست، ولی برای متغیر ما که myNumber نام دارد رزرو شده است.

اینک به متغیر خود یک مقدار می‌دهیم:

myNumber = 10;

رایانه برای انجام این وظیفه به موقعیت رزرو شده حافظه مراجعه می‌کند و هر مقداری که در آن ذخیره شده باشد را تغییر داده و مقدار جدید را ذخیره می‌کند.

اینک، کاملاً متوجه فرایند کار شدیم، ولی حافظه‌هایی که رزرو شده‌اند، چگونه آزاد می‌شوند؟ اگر برنامه‌ها هر مقدار که دوست داشته باشند از حافظه رزرو کنند، در این صورت RAM خیلی زود پر می‌شود و عملکرد سیستم بسیار کند خواهد شد.

برای جلوگیری از این مشکل، بسیاری از زبان‌های برنامه‌نویسی از مفهومی به نام زباله روب (garbage collector) استفاده می‌کنند که برای تخریب متغیرها استفاده می‌شود و بدین ترتیب موقعیت‌های رزرو شده حافظه را که خارج از حوزه برنامه قرار گرفته‌اند، آزاد می‌کند.

شاید بپرسید که حوزه متغیر چیست و چرا تا این حد مهم است. حوزه متغیر در واقع محدوده و طول عمر متغیرها یا بخش‌های حافظه مورد استفاده برنامه را تعریف می‌کند. یک متغیر زمانی «خارج از حوزه» گفته می‌شود که دیگر هیچ کدی به آن دسترسی نخواهد یافت (و همین موقع است که زباله روب آن را متوقف می‌کند). نمونه‌ای از آن در ادامه آمده است:

function maths() {
int firstNumber = 1;
}
int secondNumber = 2;
print(firstNumber + secondNumber); // will not work

این نمونه کامپایل نخواهد شد. متغیر firstNumber درون تابع maths قرار دارد و بنابراین حوزه تعریف آن داخل تابع است. این متغیر نمی‌تواند خارج از تابعی که در آن تعریف شده است، مورد دسترسی قرار گیرد و از این رو حوزه متغیر آن درون تابع است. این یک مفهوم مهم برنامه‌نویسی است و درک آن برای کار با اشاره‌گرها کاملاً ضروری است.

این روش مدیریت حافظه، پشته (stack) نامیده می‌شود. غالب برنامه‌ها از همین روش برای مدیریت حافظه استفاده می‌کنند. شما برای استفاده از اشاره‌گرها نیازی ندارید که نحوه عملکرد آن‌ها را درک کنید و این مسئله کاملاً ساخت‌یافته است. عیب پشته در سرعت آن است. از آنجا که رایانه مسئول تخصیص حافظه، ردگیری وضعیت متغیرها، و اجرای فرایند زباله روبی است، قدری هزینه سربار ایجاد می‌شود. این مقدار در برنامه‌های کوچک مشکلی ایجاد نمی‌کند؛ اما در وظایفی که به عملکرد بالایی نیاز دارند یا کاربردهای داده سنگینی دارند مشکل‌زا است.

در این موارد از اشاره‌گرها استفاده می‌شود.

اشاره‌گرها

اشاره‌گرها در ظاهر ساده به نظر می‌آیند. آن‌ها به یک موقعیت حافظه ارجاع می‌دهند (اشاره می‌کنند)، شاید به نظر بیاید این کار تفاوتی با متغیرهای معمولی در پشته ندارد؛ اما باید بدانید که تفاوت بسیار زیادی وجود دارد. اشاره‌گرها بر روی هیپ (heap) ذخیره می‌شوند. هیپ دقیقاً متضاد پشته است چون سازمان‌دهی کمتری دارد اما بسیار سریع‌تر است.

متغیرها چگونه در پشته تخصیص می‌یابند؟

int numberOne = 1;
int numberTwo = numberOne;

این یک ساختار ساده است؛ متغیر numberTwo شامل عدد یک است. مقدار آن در زمان انتساب به متغیر numberOne کپی می‌شود.

اگر بخواهید آدرس حافظه یک متغیر را به دست آورید، به جای مقدار آن باید از علامت & استفاده کنید. این عبارت عملگر آدرس (address of) نامیده می‌شود و بخش اساسی استفاده از اشاره‌گرها محسوب می‌شود.

int numberOne = 1;
int numberTwo = &numberOne;

اینک متغیر numberTwo به جای این‌که مقدار numberOne را که کپی شده است بگیرد، به یک مکان حافظه اشاره می‌کند. اگر بخواهید از این متغیر خروجی بگیرید، مقدار آن یک نخواهد بود (علیرغم این‌که در موقعیت حافظه ذخیره شده است).

این متغیر موقعیت حافظه را در خروجی ارائه می‌دهد (احتمالاً چیزی شبیه 2167 که مقدار آن به فضای RAM و نوع سیستم بستگی دارد.) برای دسترسی به مقدار ذخیره شده در یک اشاره‌گر، به جای موقعیت حافظه باید اشاره‌گر را ابتدا ارجاع زدایی (dereference) کنید. به این ترتیب مستقیماً به مقدار مورد نظر دسترسی ایجاد می‌شود که مثال فوق ، مقدار یک است. در ادامه نحوه ارجاع زدایی اشاره‌گر آمده است:

int numberTwo = *numberOne;

عملگر ارجاع زدایی علامت ستاره (*) است.

درک این مفهوم اندکی دشوار است، بنابراین یک بار دیگر آن را مرور می‌کنیم:

  • عملگر آدرس (&) آدرس حافظه را ذخیره می‌کند.
  • عملگر ارجاع زدایی (*) به مقدار دسترسی می‌یابد.

زمانی که اشاره‌گرها را اعلان می‌کنیم، ساختار کمی تغییر می‌یابد:

int * myPointer;

نوع داده int در این مثال در واقع نوع داده‌ای است که اشاره‌گر به آن ارجاع می‌دهد و نه نوع خود اشاره‌گر.

اینک که دانستید اشاره‌گرها چه هستند، می‌توانید کارهای جالبی با آن‌ها بکنید. وقتی حافظه پر شده باشد، رایانه شروع به کاری می‌کند که عملکرد متوالی (sequentially) نامیده می‌شود. RAM رایانه را می‌توان یک لانه کبوتر در نظر گرفت. در این لانه کبوتر، خانه‌های زیادی برای ذخیره‌سازی انواع مختلف وجود دارد، اما هر بار تنها یکی می‌تواند مورد استفاده قرار گیرد. اختلاف اینجاست که این لانه‌های کبوتر همگی ردیفی هستند. زمانی که حافظه تخصیص می‌یابد، سیستم‌عامل شروع به استفاده از اعداد پایین‌تر می‌کند و به سمت بالا حرکت می‌کند. سیستم هرگز خانه‌ای را تصادفاً رد نمی‌کند.

زمانی که با اشاره‌گرها کار می‌کنیم، اگر یک آرایه را تخصیص دهیم، می‌توانیم به سادگی با افزایش شماره اشاره‌گر به خانه بعدی آرایه اشاره کنیم.

نکته جالب اشاره‌گرها همین‌جاست. وقتی مقادیر را (با استفاده از متغیرهای ذخیره شده در پشته) به یک تابع ارسال می‌کنیم، این مقادیر به تابع کپی می‌شوند. اگر این مقادیر بزرگ باشند، برنامه همه آن‌ها را دوبار ذخیره می‌کند. وقتی کار تابع پایان می‌یابد به روشی نیاز خواهیم داشت که این مقادیر را بازگردانیم. تابع‌ها می‌توانند تنها یک چیز را بازگردانند. پس اگر بخواهیم دو یا چند چیز را بازگردانیم چه باید بکنیم؟

اگر یک اشاره‌گر را به یک تابع ارسال کنیم، در واقع تنها یک آدرس حافظه را کپی کرده‌ایم (که مقدار کوچکی است). این کار باعث صرفه‌جویی زیادی در CPU می‌شود. ممکن است اشاره‌گر به یک تصویر بزرگ اشاره داشته باشد. با این کار نه تنها تابع می‌تواند دقیقاً بر روی همان داده‌ها و در همان محل کار کند، بلکه لازم نیست که چیزی را هم بازگرداند!

با این حال باید در استفاده از اشاره‌گر کاملاً احتیاط کرد. اشاره‌گرها ممکن است خارج از حوزه متغیر باشند و از سوی زباله روب حذف شوند. با این حال، مقادیری که در حافظه ذخیره می‌شوند از سوی زباله روب جمع‌آوری نخواهند شد. این کار نشت حافظه نامیده می‌شود. شما دیگر نمی‌توانید به داده‌ها دسترسی داشته باشید (چون اشاره‌گرها تخریب شده‌اند) اما آن‌ها هنوز در حافظه هستند. این یکی از مهم‌ترین دلیل از کار افتادن بسیاری از برنامه‌ها است و درصورتی که مقادیر داده‌ها بالا باشد، می‌تواند کاملاً مخرب باشد. اغلب اوقات سیستم‌عامل در صورتی که برنامه نشت حافظه زیادی داشته باشد (یعنی حافظه‌ای بیشتر از مقدار RAM استفاده کند)، سیستم‌عامل برنامه را متوقف می‌کند که امر نامطلوبی است.

دیباگ کردن اشاره‌گرها می‌تواند یک کابوس باشد به خصوص اگر با حجم بالایی داده‌ها سروکار داشته باشیم یا بر روی حلقه‌ها کار کنیم. معایب و دشواری درک عمکلرد اشاره‌گرها به افزایش عملکردی که به دست می‌آید کاملاً می‌ارزد. با این حال باید به خاطر داشته باشید که ممکن است در همه موارد استفاده از اشاره‌گرها لازم نباشد.

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

==

بر اساس رای ۱۱ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
makeuseof
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *