توابع سره در C++
یکی از امکانات بهینهسازی که بین برنامهنویسهای سی کمتر شناخته شده؛ امکان تعریف توابع بهصورت سره هست. این امکان دامنهٔ گستردهای از بهینهسازیها رو امکانپذیر میکنه و حتماً باید جایی که کارایی مهم هست از این ویژگی استفاده کنید. در این پست به معرفی و بررسی اثر توابع سره میپردازم.
اهمیت اثر جانبی
زبان برنامهنویسی C++ دقیقا مثل پدربزرگش C ؛ یک زبان دستوری است. بنابراین توابع این زبان میتونن اثر جانبی داشته باشند. اثر جانبی یک تابع یعنی اثری که اون تابع در حالت سیستم ایجاد میکنه؛ البته مقدار برگشتی از تابع جزو اثر جانبی حساب نمیشه. زبانهای برنامهنویسی تابعی، اثر جانبی رو محدود میکنند. یعنی هر تابع یا اثر جانبی نداره یا فقط محدود به IO هست. برای مثال تغییر یک متغیر global یک اثر جانبی از اجرای یک تابع حساب میشه. به تابعی که اثر جانبی نداشته باشه تابع سره یا تابع محض گفته میشه. توابع محض ویژگیهای بسیار جالبی دارند که یکی از این ویژگیها یعنی «شفافیت ارجاع» کمک بسیار بزرگی به بهینهسازی کدها میکنه. بهطور خلاصه اصل شفافیت مرجع میگه که اگر یک تابع، سره باشه، همیشه میشه نتیجهٔ اجرای اون تابع با آرگومانهای یکسان رو با کد فراخوانیِ تابع جایگزین کرد.
برای مثال تابع pow2 رو درنظر بگیرید. این تابع بسیار ساده است، صرفاً یک عدد میگیره و توان دوم اون رو برمیگردونه:
double pow2(double x) {
retutn x*x;
}
pow2(5)
رو با نتیجه، یعنی 25
، جایگزین کنید. این ویژگی برای بهینهسازی بسیار مفید
هست. به این صورت که کامپایلر، زمان کامپایل برنامه میتونه فراخوانیهای تکراری رو
حذف کنه.
از آنجایی که C++ یک زبان برنامهنویسی تابعی نیست؛ توابع سره نداره. در مورد تمام توابع، کامپایلر فرض میکنه اثر جانبی وجود داره. و هیچ بهینهسازیای برمبنای شفافیت ارجاع انجام نمیده.
بررسی شفافیت ارجاع در C++
برای این که ببینیم شفافیت ارجاع در C++ وجود نداره، نتیجهٔ خروجی کامپایل کد برای همین تابع سادهٔ توان دو رو بررسی میکنیم. خروجی اسمبلی کد پایین رو ملاحظه کنید:
double pow2(double x) { return x*x; }
// ...
double caller() {
const double x = pow2(5.0) + pow2(5.0);
const double y = pow2(5.0) + pow2(5.0);
return x+y;
}
این کد با gcc نسخهٔ ۱۰ و با آخرین درجهٔ بهینهسازی (-O3
) کامپایل شده.
قبل از این که خودتون بخواهید این رو تست کنید باید اشاره کنم که حتما باید
پیادهسازی تابع pow2
خارج از برنامه باشه. مثلاً توی یک کتابخونه یا آبجکت
متفاوت. در غیر این صورت کامپایلر طوری بهینهسازی میکنه که مثال سادهٔ ما بیارزش
میشه (: بنابراین ما فرض میکنیم در لحظهٔ کامپایل؛ کامپایلر ما اطلاعی از پیادهسازی تابع
pow2
نداره. خروجی اسمبلی تابع caller
رو مشاهده کنید:
caller():
sub rsp, 24
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
movsd QWORD PTR [rsp], xmm0
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
movsd xmm1, QWORD PTR [rsp]
addsd xmm1, xmm0
movsd xmm0, QWORD PTR .LC0[rip]
movsd QWORD PTR [rsp], xmm1
call pow2(double)
movsd QWORD PTR [rsp+8], xmm0
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
addsd xmm0, QWORD PTR [rsp+8]
addsd xmm0, QWORD PTR [rsp]
add rsp, 24
ret
.LC0:
.long 0
.long 1075052544
همونطور که دیده میشه، pow2
چهار بار فراخوانی شده. (خطهایی که علامت زده
شده) در حالی که فقط یک بار کافی بود. خوشبختانه امکاناتی برای بهینهسازی این
وضعیت وجود داره.
اعلان توابع سره
در g++ و clang میشه تابع رو به روش زیر بهصورت محض اعلان کرد:
[[gnu::pure]] double pow2(double x);
اضافه کردن ویژگی pure
به تابع، به کامپایلر اعلام میکنه که تابع قرار نیست
وضعیت برنامه رو عوض کنه و تابع سره یا محض است. بنابراین با این کار به کامپایلر
اجازه میدیم از بهینهسازیهای مبنیبر شفافیت مرجع استفاده کنه. این کار توی
برنامهنویسی سیستمهای امبدد و میکروکنترلرها، و همچنین برنامههای محاسباتی موازی
و سنگین خیلی میتونه مفید باشه. کد تغییر یافته و خروجی اسمبلی همین برنامه رو ببینیم:
caller():
sub rsp, 8
movsd xmm0, QWORD PTR .LC0[rip]
call pow2(double)
add rsp, 8
addsd xmm0, xmm0
addsd xmm0, xmm0
ret
.LC0:
.long 0
.long 1075052544
همونطور که مشاهده میکنید؛ فقط یک فراخوانی از تابع pow2
وجود داره. چون
همهٔ آرگومانها 5.0
بودند؛ کامپایلر سه فرخوانی رو حذف کرده. دقت داشته باشید که
ترتیب فراخوانی و مکان فراخوانی هم هیچ اهمیتی نداره. یعنی اگر تابع pow2
رو
توی تابع دیگهای هم با همین آرگومان فراخوانی کنید؛ کامپایلر باز میتونه
بهینهسازی انجام بده.
هرچند اعلانهای pure و همخانوادههای اون هنوز استاندارد نیستند، ولی استفاده از آنها نسبتاً ساده و ایمن هست. کامپایلرهای مدرن معمولاً سینتکس مشابهی رو برای این بهینهسازی استفاده میکنند. در مواردی که سینتکس متفاوت هست شما میتونید اعلان رو براساس پیشپردازندههای زمان کامپایل؛ بهصورت اختصاصی کامپایل کنید.
اعلان توابع ثابت
توابع سره تضمین میکنند که فراخوانی آنها تغییری در وضعیت برنامه ایجاد نمیکنه. یک سطح بالاتری از تضمین هم وجود داره که میگه وضعیت برنامه هم تغییری در مقدار محاسبه شدهٔ تابع ایجاد نمیکنه. این نوع توابع ثابت (const) هستند. دقت داشته باشید که این const با cv specifier تفاوت داره. اعلان یک تابع بهصورت ثابت باعث میشه که خوندن وضعیت برنامه (مثلاً متغیرهای read-only)، بهنحوی که بتونه توی خروجی تابع تأثیر بذاره نامعتبر بشه. در واقع تضمین قویتری برای محض بودن تابع هست و بهینهسازیهای بیشتری رو ممکن میکنه. برای تعریف تابع ثابت از این کد استفاده کنید:
[[gnu::const]] double pow2(double x);
مقایسه با constexpr
ممکنه فکر کنید که توابع const و یا pure همون توابع constexpr هستند. این درست نیست. تفاوتهای زیادی بین این سه تا وجود داره. مهمترینش این که تابع constexpr زمان کامپایل باید قابل ارزیابی باشه. بنابراین تمام ورودیها باید از نوع لیترال باشند یا همون خود constexpr باشند. در حالی که توابع سره؛ توابع کاملاً معمولی هستند. هر نوع آرگومانی میتونید بهشون بدید و مهم نیست تابع و آرگومانهای ورودی اون زمان کامپایل قابل ارزیابی باشه یا نه. صرفاً این تضمین وجود داره که فراخوانی با آرگومانهای یکسان، اثر یکسان خواهد داشت.
استاندارد
همونطور که قبلاً اشاره کردم؛ این امکانات استاندارد نیستند. بحثهایی وجود داشت که توابع سره وارد استاندارد C++20 بشه اما این اتفاق نیفتاد. دلیلش اینه که بررسی سره بودن تابع بسیار سخت هست. اگر تابع سره نباشه ولی شما اون به شکل سره علامتگذاری کنید؛ صرفاً نتایج محاسباتی شما غلط از آب در میاد و کامپایلر راهی برای warning دادن یا خطا گرفتن (به شکل مؤثر) نداره. بنابراین این ویژگی فعلاً بهصورت غیراستاندارد در دسترس قرار گرفته.