مدیریت حافظه (Memory Management) یعنی برنامه چگونه برای دادههایش حافظه میگیرد و وقتی دیگر به آن دادهها نیازی نیست آن را پس میدهد. زبان برنامهنویسی سلام زبالهروب ندارد، پس بخشی از این کار بر عهدهی برنامهنویس است، اما این کار بسیار کوچکتر از C است. مقدارهای ساده روی پشته زندگی میکنند و هیچ مراقبتی لازم ندارند. ظرفهای هیپ مانند وکتور و نگاشت با .آزادکن() آزاد میشوند، معمولاً از راه دیرکن (در انگلیسی defer)، و کامپایلر برای ظرفهای محلیای که هرگز از حوزهشان بیرون نمیروند خودش این فراخوانی را میگذارد. افزون بر این، یک وارسیگر مالکیت اشتباههای استفاده پس از آزادسازی و آزادسازی دوباره را در زمان کامپایل پیدا میکند و salam memcheck نشتها را در زمان اجرا. این مقاله هر بخش را با برنامهی فارسی و خروجی واقعی کامپایلر مرور میکند.
مدل حافظهی زبان برنامهنویسی سلام در یک بند
عددها، منطقیها، نویسهها، ساختارها و آرایههای با اندازهی ثابت مقدار هستند: مقداردهی رونوشتشان میکند، فرستادنشان به تابع رونوشتشان میکند (مگر پارامتر ارجاعی باشد) و با پایان حوزهشان ناپدید میشوند. وکتور، نگاشت، مجموعه (Set) و دیگر مجموعهها، جعبههای پویا، رشتههایی که در زمان اجرا ساخته میشوند و بلوکهایی که از حافظه.تخصیص (در انگلیسی mem.Allocate) گرفته میشوند روی هیپ زندگی میکنند: متغیر یک سرآیند کوچک نگه میدارد که به فضای ذخیرهای روی هیپ اشاره میکند، و آن فضا تا وقتی کسی آزادش نکند میماند. قاعدهی هیپ این است: «هر کس میسازد آزاد میکند، یا آن را به کسی میسپارد که آزاد خواهد کرد». هر چیز دیگری در این مقاله ابزاری پیرامون همین قاعده است. درس حافظه همین مدل را گام به گام آموزش میدهد.
مقدارهای پشته: ساختارها و آرایهها رونوشت میشوند
چون ساختارها مقدارند، تغییر یک رونوشت هرگز اصل را تغییر نمیدهد. وقتی تابعی باید متغیر صدازننده را تغییر دهد، پارامتر با &: ارجاعی تعریف میشود. برای یک مجموعه، رونوشت() (در انگلیسی copy()) یک وکتور مستقل با فضای ذخیرهی خودش میسازد که خودش هم یک آزادکن() جداگانه لازم دارد:
ساختار نقطه:
همگانی ایکس: صحیح
همگانی ایگرگ: صحیح
پایان
تابع حرکت به راست(ن &: نقطه، گام: صحیح):
ن.ایکس += گام
پایان
تابع ریشه:
الف := نقطه { ایکس = ۱، ایگرگ = ۲ }
ناپایا ب := الف
ب.ایکس = ۱۰
سرچاپ الف.ایکس، ب.ایکس
ناپایا ج := الف
حرکت به راست(ج، ۵)
سرچاپ ج.ایکس
ناپایا سبد := وکتور {} برگردان وکتور<رشته>
دیرکن سبد.آزادکن()
سبد.بیفزا("نان")
ناپایا زنبیل := سبد.رونوشت()
دیرکن زنبیل.آزادکن()
زنبیل.بیفزا("شیر")
سرچاپ سبد.طول()، زنبیل.طول()
پایانبدون رونوشت()، زنبیل := سبد فقط سرآیند را رونوشت میکرد. هر دو نام به عضوهای یکسانی اشاره میکردند و یک بیفزا از راه یکی میتوانست فضای ذخیره را از زیر پای دیگری جابهجا کند. همین حالت اشتراکی منشأ بیشتر باگهای حافظه در زبانهای بدون زبالهروب است، و دقیقاً همان چیزی است که وارسیگر مالکیت زیر نظر دارد (پایینتر میآید). رونوشت عمیق() یک گام جلوتر میرود و ظرفهایی را که درون یک ظرف نگه داشته شدهاند هم رونوشت میکند.
دیرکن: پاکسازیای که در هر خروجی اجرا میشود
دیرکن یک دستور را زمانبندی میکند تا هنگام پایان بلوک جاری اجرا شود، به هر شکلی که بلوک پایان یابد: رسیدن به انتها، برگشت، بشکن یا گذر. چند دیرکن به ترتیب وارونه اجرا میشوند، آخرین ثبتشده اول. defer در Go به حوزهی تابع بسته است و در Zig به حوزهی بلوک؛ دیرکن سلام مانند Zig به بلوک بسته است، پس یک دیرکن درون بدنهی حلقه در پایان هر دور اجرا میشود.
واردسازی حافظه
تابع آزادسازی واژهها(واژهها: وکتور<رشته>):
هر واژه در واژهها:
حافظه.آزادسازی(واژه برگردان تهی*)
پایان
واژهها.آزادکن()
پایان
تابع شمارش واژهها(سطرها: رشته[]): صحیح:
ناپایا مجموع := ۰
هر سطر در سطرها:
واژهها := سطر.بشکاف(" ")
دیرکن آزادسازی واژهها(واژهها)
مجموع += واژهها.طول()
پایان
برگشت مجموع
پایان
تابع ریشه:
دیرکن سرچاپ "ریشه: آخرین"
دیرکن سرچاپ "ریشه: یکی مانده به آخر"
سرچاپ "واژهها:"، شمارش واژهها(["صلح بر شما باد"، "سلام"])
پایاننوشتن دیرکن x.آزادکن() درست در سطر پس از ساختن x مهمترین عادت در سلام است. پاکسازی کنار تخصیص مینشیند، جایی که بازبین آن را میبیند، و یک برگشت زودهنگام که بعدها افزوده شود نمیتواند از رویش بپرد. درس دیرکن نمونههای بیشتری دارد.
به آزادسازی واژهها دقت کنید. سطر.بشکاف(" ") یک وکتور<رشته> برمیگرداند که رشتههایش خودشان تخصیصهای هیپاند، و آزادکن() روی یک وکتور فضای ذخیرهی خود وکتور را آزاد میکند، نه آنچه عضوهایش در اختیار دارند. پس تابع کمکی اول هر رشته را آزاد میکند و بعد وکتور را. این جزئیات بهسادگی از چشم میافتد، و به همین دلیل ابزارهای بخشهای بعد وجود دارند.
یافتن نشت حافظه با salam memcheck
salam memcheck app.salam برنامه را با اطلاعات اشکالزدایی میسازد و اگر Valgrind نصب باشد زیر آن اجرا میکند، وگرنه با AddressSanitizer، و نشتها و دسترسیهای نامعتبر را با نام فایل و شمارهی سطر سلام گزارش میکند. این گزارش آن برای نسخهی قدیمیتری از شمارش واژهها است که در d1.salam ذخیره شده و به جای تابع کمکی یک دیرکن واژهها.آزادکن() ساده داشت و برای همین هرگز رشتههای واژهها را آزاد نمیکرد. گزارش دو رکورد از این دست دارد، یکی برای هر محل تخصیص در بشکاف، که روی هم ۳۵ بایت در ۵ بلوک میشوند، یعنی یک بلوک برای هر واژه؛ این یکی از آن دو است (دو قاب درونی پشته حذف شدهاند):
==334694== 19 bytes in 3 blocks are definitely lost in loss record 4 of 4
==334694== at 0x4944E43: calloc (vg_replace_malloc.c:1678)
==334694== by 0x4003CF9: _Salam_mem_AllocateZeroed_u64 (mem.salam:158)
==334694== by 0x400C38F: salam_str_split (text.salam:150)
==334694== by 0x400367F: _Salam_main__d8_b4_d9_85_d8_a7_d8_b1_d8_b4_20_d9_88_d8_a7_da_98_d9_87_20_d9_87_d8_a7_slice_3cstr_3e (d1.salam:4)
==334694== by 0x4003588: main (d1.salam:14)
==334694==
salam memcheck: errors detected (exit 1). Review the output above.ردگیری از راه نام دگرگونشدهی شمارش واژهها (که بایتهای UTF-8 نام فارسی را در خود دارد) به سطر ۴ آن فایل، یعنی فراخوانی بشکاف، اشاره میکند. با تابع کمکی، همین فرمان definitely lost: 0 bytes in 0 blocks و salam memcheck: no memory errors detected. را چاپ میکند. آن را روی هر چیزی که قرار است مدت طولانی اجرا شود، مانند یک سرور، یک سرویس پسزمینه یا حلقهی یک بازی، اجرا کنید. salam build --asan بررسیهای AddressSanitizer را به جای آن درون یک فایل اجرایی معمولی میسازد.
آزادسازی خودکار: آزادسازیهایی که کامپایلر برایتان مینویسد
از اکتبر ۲۰۲۶ (PR #2288)، کامپایلر یک وکتور یا نگاشت محلی را در پایان حوزهاش آزاد میکند، وقتی ببیند هیچ چیز دیگری نمیتواند آن را نگه دارد. شرطها، بر اساس docs/design/OWNERSHIP.md: متغیر محلی با یک لیترال خالی یا با خروجی تابعی آغاز شود که همیشه ظرف تازهای برمیگرداند؛ مستقیم در یک بلوک تعریف شده باشد؛ و هرگز دوباره مقداردهی، برگردانده، ذخیره، در بستار (Closure) گرفته یا دستی آزاد نشود و به چیزی که آن را نگه میدارد یا آزاد میکند داده نشود. خواندنش، پیمایشش، افزودن به آن و فرستادنش به تابعهایی که فقط آن را میخوانند یا بزرگش میکنند، همه مجازند.
تابع مربعها(ن: صحیح): وکتور<صحیح>:
ناپایا خروجی := وکتور {} برگردان وکتور<صحیح>
تکرار ن در ای:
خروجی.بیفزا(ای * ای)
پایان
برگشت خروجی
پایان
تابع ریشه:
م := مربعها(۵)
سرچاپ م.طول()، م.بگیر(۴)
پایاندر این برنامه هیچ آزادکنی نیست، اما نشتی هم ندارد. کامپایلر فهمیده که مربعها همیشه ظرف تازهای برمیگرداند و ریشه هرگز اجازه نمیدهد م بیرون برود. کد C که برای ریشه تولید میکند با آزادسازیای که خودش گذاشته تمام میشود (نام م در C به شکل بایتهایش، _d9_85، نوشته شده است):
_Salam_mem_Free_void_ptr((void*)(_d9_85).data);
return 0;همین تحلیل رشتهها را هم پوشش میدهد: زنجیرهای مانند الف + ب + ج با یک تخصیص ساخته میشود، نه یکی برای هر +، پس هیچ رشتهی میانیای جا نمیماند، و یک متغیر محلی تغییرناپذیر که رشتهی تازهای نگه میدارد اگر فقط خوانده شود در پایان حوزهاش آزاد میشود. یک آزادکن() یا دیرکن دستنویس بهسادگی نسخهی خودکار را برای آن متغیر خاموش میکند، پس کدهای موجود هرگز دو بار آزاد نمیکنند.
این زبالهروب نیست، و تخریبگرهایی (Destructor) به سبک C++ یا Rust برای هر گونهای هم نیست: فقط حالت ساده و بسیار رایجِ مجموعه یا رشتهی محلیای را پوشش میدهد که در یک حوزه زاده میشود و میمیرد. هر چیزی که در یک ساختار ذخیره، برگردانده یا به اشتراک گذاشته شود همچنان آزاد کردنش با شماست.
چه کسی یک رشته را آزاد میکند
یک لیترال رشتهای مانند "سلام" بخشی از برنامه است و هرگز آزاد نمیشود. رشتهای که در زمان اجرا ساخته شود روی هیپ زندگی میکند. کتابخانهی استاندارد از یک قاعده پیروی میکند و کد شما هم باید پیروی کند: تابعی که یک رشته برمیگرداند، در هر مسیری، رشتهی تازهای برمیگرداند که صاحبش صدازننده است. صدازننده پس از پایان کار آن را با حافظه.آزادسازی(ر برگردان تهی*) آزاد میکند:
واردسازی حافظه
واردسازی رشته
تابع نامک(عنوان: رشته): رشته:
کوچکشده := رشته.کوچک(عنوان)
دیرکن حافظه.آزادسازی(کوچکشده برگردان تهی*)
برگشت رشته.جایگزینی(کوچکشده، " "، "-")
پایان
تابع ریشه:
عنوانها := ["Hello World"، "حافظه در سلام"]
هر ع در عنوانها:
ن := نامک(ع)
سرچاپ "/blog/" + ن + "/"
حافظه.آزادسازی(ن برگردان تهی*)
پایان
پایاننامک رشتهی میانی خودش (کوچکشده) را آزاد میکند و نتیجه را به صدازننده میسپارد که پس از استفاده آزادش میکند. رشته.کوچک حرفهای لاتین را کوچک میکند و متن فارسی را دستنخورده میگذارد، چون فارسی حرف بزرگ و کوچک ندارد. salam memcheck برای این برنامه هیچ نشتی گزارش نمیکند. برای متنی که در یک حلقه از تکههای زیاد ساخته میشود، رشته.سازنده() (در انگلیسی str.NewBuilder()) جلوی ساختن رشتهی تازه در هر گام را میگیرد.
اشارهگرها و حافظهی خام
برای کتابخانههای C و کد سطح پایین، سلام اشارهگر خام دارد: حافظه.تخصیص(بایتها) از بستهی حافظه یک بلوک برمیگرداند، پ[ای] عضو ایام را میخواند و مینویسد، حافظه.آزادسازی آن را پس میدهد و اندازهگونه(T) اندازهی هر عضو را میدهد.
واردسازی حافظه
تابع ریشه:
ن := ۵
پ := حافظه.تخصیص(اندازهگونه(صحیح۶۴) * ن برگردان طبیعی۶۴) برگردان صحیح۶۴*
دیرکن حافظه.آزادسازی(پ برگردان تهی*)
تکرار ن در ای:
پ[ای] = (ای * ای) برگردان صحیح۶۴
پایان
سرچاپ پ[۰]، پ[۲]، پ[۴]
پایاناشارهگرها خانهی باگهای کلاسیک C هستند و کامپایلر هر چه بتواند بررسی میکند. وقتی اندازهی بلوک را بداند (اینجا ۳۲ بایت، یعنی چهار صحیح۶۴)، نوشتن فراتر از انتها خطاست، و بلوکی که هیچ چیز آن را آزاد نمیکند، برنمیگرداند، ذخیره نمیکند یا به جای دیگری نمیدهد هشدار است:
خطا[خطا۱۴۶]: اندیس خارج از محدوده است: اندیس برابر ۴ است، اما 'پ' ۴ عضو دارد
--> ptr3.salam:6:5
هشدار[هشدار۱۵۸]: حافظهای که 'پ' در خط ۴ از mem.Allocate میگیرد هرگز آزاد نمیشود: پیش از پایان تابع هیچ چیز آن را آزاد نمیکند، برنمیگرداند، نگه نمیدارد یا به جای دیگری نمیدهد
--> ptr2.salam:4:5
|
4 | پ := حافظه.تخصیص(اندازهگونه(صحیح۶۴) * ۴) برگردان صحیح۶۴*
| ^
|
= راهنما: آن را با 'defer mem.Free(پ as void*)' در خط پس از گرفتن حافظه آزاد کنیدوقتی اندازه ثابتی نباشد که کامپایلر بتواند دنبالش کند، این بررسی ساکت میماند و مسئولیت با شماست. کد معمولی سلام بهندرت به اشارهگر نیاز دارد؛ اول سراغ وکتور بروید، و برای موردهایی که واقعاً لازم است درس کار با C را ببینید.
وارسیگر مالکیت زبان برنامهنویسی سلام: یافتن استفاده پس از آزادسازی در زمان کامپایل
وارسیگر مالکیت (compiler/semantic/sema_own.salam) یک تحلیل حساس به جریان است که روی هر تابعی اجرا میشود که حافظه آزاد میکند یا آن را به کدی میسپارد که آزاد میکند. هر بافر هیپ را در مقداردهیها، رونوشتها، دیرکنها، شاخهها و حلقهها دنبال میکند، و به جای حاشیهنویسیهای طول عمر که Rust از شما میخواهد، از خلاصهی تابعها استفاده میکند که کامپایلر برای هر تابع استنتاج میکند (آیا این پارامتر را آزاد میکند، نگه میدارد، بزرگ میکند، برمیگرداند؟). در این نمونه یک وکتور میان دو نام مشترک است، یکی از آن دو آزادش میکند و دیگری پس از آن به کار میرود:
تابع ریشه:
ناپایا الف := وکتور {} برگردان وکتور<صحیح>
الف.بیفزا(۱)
ب := الف
الف.آزادکن()
سرچاپ ب.بگیر(۰)
پایانهشدار[هشدار۱۶۳]: 'الف' پس از جابهجا شدن بافرش در خط ۴ استفاده شده است؛ اکنون هر دو نام یک بافر را به اشتراک میگذارند، پس هرجا هر دو لازم است از 'الف.copy()' استفاده کنید
--> o2.salam:5:5
|
5 | الف.آزادکن()
| ^
|
= راهنما: از اینجا به بعد از نام جدید استفاده کنید، یا پیش از سپردن Vector یا HashMap یک کپی از آن بگیرید
خطا[خطا۱۳۹]: 'ب' پس از آزاد شدن حافظهاش در خط ۵ استفاده شده است
--> o2.salam:6:11
|
6 | سرچاپ ب.بگیر(۰)
| ^
|
= راهنما: کپی یک Vector، HashMap، str یا اشارهگر همان حافظهٔ heap را به اشتراک میگذارد؛ آن را فقط پس از آخرین استفادهٔ همهٔ کپیها آزاد کنیدکدها حالتهای اصلی را پوشش میدهند: E139 استفاده پس از آزادسازی، E140 آزادسازی دوباره، E141 مقدار برگشتیای که دیرکن خود تابع آزادش میکند؛ W142 و W143 وقتی فقط برخی مسیرها این کار را میکنند؛ W144 رونوشت، اشارهگر .ارجاع() یا پیمایشگری که در طول فراخوانیای نگه داشته شده که ممکن است بافر را جابهجا کند؛ W163 ظرفی که پس از سپرده شدن به نامی دیگر به کار رفته است. --ownership=warn پیشفرض است، error هشدارها را به خطا تبدیل میکند و off بررسیها را همراه با آزادسازیهای خودکار خاموش میکند. هیچکدام از اینها در زمان اجرا هزینهای ندارد؛ تحلیل فقط برنامه را میخواند.
مهم است دربارهی چیستی آن دقیق باشیم. این وارسیگر قرض (Borrow Checker) نیست. وارسیگر قرض Rust ثابت میکند کد ایمن نمیتواند استفاده پس از آزادسازی یا رقابت داده (Data Race) داشته باشد و برنامههایی را که نتواند ثابت کند رد میکند. وارسیگر سلام اشتباههایی را که میبیند گزارش میکند و در غیر این صورت ساکت میماند. متغیرهای سراسری، فراخوانیهای غیرمستقیم از راه مقدارهای تابعی و پویا، و عضوهای درون ظرفها، همانطور که docs/design/OWNERSHIP.md فهرست کرده، بیرون از دید آناند. بدون هیچ حاشیهنویسی و نحو تازهای خیلی چیزها را میگیرد، اما یک تور ایمنی محکم است، نه یک اثبات.
مقایسهی مدیریت حافظه در سلام با زبانهای دیگر
| زبان | حافظهی هیپ چگونه آزاد میشود | ایمنی در زمان کامپایل |
|---|---|---|
| C | free با دست | چیزی درون زبان نیست |
| C++ | تخریبگرها (RAII)، اشارهگرهای هوشمند | جزئی، بیشتر بر پایهی قرارداد |
| Rust | مالکیت و Drop | وارسیگر قرض ایمنی حافظه را در کد ایمن ثابت میکند |
| Zig | تخصیصدهندههای صریح و defer | اندک؛ بررسیهای ایمنی در زمان اجرا در ساختهای اشکالزدایی |
| Go | زبالهروب | از اساس ایمن در حافظه |
| سلام | آزادکن با دیرکن، بهعلاوهی آزادسازیهایی که کامپایلر برای متغیرهای محلی ساده میگذارد | تحلیلهای مالکیت و بازه خطاهای رایج را گزارش میکنند؛ اثبات نیستند |
جایگاه سلام میان Zig و Rust است: به سبک defer صریح Zig نزدیکتر است، با تحلیلهایی در زمان کامپایل که بخشی از ایدههای Rust (مالکیت، رفتار استنتاجشدهی تابعها) را بدون حاشیهنویسیها و بدون تضمینهای آن برمیگیرند. مقالهی کامپایل بومی بدون زبالهروب توضیح میدهد این طراحی از نظر سرعت و ردپای حافظه چه میدهد، و مقالهی گونهبندی ایستا بررسیهای بازه را که در کنار وارسیگر مالکیت کار میکنند پوشش میدهد.
پرسشهای پرتکرار
آیا مدیریت حافظه در زبان سلام دستی است؟
تا حدی. مقدارهای روی پشته به هیچ کاری نیاز ندارند. مجموعههای هیپ و رشتههای زمان اجرا با .آزادکن() یا حافظه.آزادسازی آزاد میشوند، معمولاً از راه دیرکن، به جز مجموعهها و رشتههای محلی ساده که کامپایلر خودکار آزادشان میکند. در برنامههای کامپایلشده زبالهروبی وجود ندارد.
دیرکن (defer) در سلام چه کار میکند؟
یک دستور را هنگام پایان بلوکی که در آن است اجرا میکند، در هر مسیر خروج از آن، به ترتیب وارونهی ثبت. کاربرد معمولش دیرکن و۱.آزادکن() درست پس از ساختن و۱ است.
آیا سلام میتواند استفاده پس از آزادسازی را در زمان کامپایل پیدا کند؟
در بسیاری از موارد، بله. وارسیگر مالکیت استفاده پس از آزادسازی (E139) یا آزادسازی دوباره (E140) را اگر در همهی مسیرها رخ دهد خطا گزارش میکند و همان اشتباهها را در برخی مسیرها هشدار. همه چیز را نمیگیرد، پس salam memcheck همچنان سودمند است.
چطور نشت حافظه را در یک برنامهی سلام پیدا کنم؟
salam memcheck app.salam را اجرا کنید. اگر Valgrind نصب باشد از آن و وگرنه از AddressSanitizer استفاده میکند، و به سطری از کد سلام اشاره میکند که هر بلوک نشتکرده را تخصیص داده است.