رفتن به مطلب
مرجع رسمی سی‌پلاس‌پلاس ایران

فناوری

  • نوشته‌
    21
  • دیدگاه
    6
  • مشاهده
    9,548

مشارکت‌کنندگان این وبلاگ

سی++ قرن بیست و یکم

کامبیز اسدزاده

153 بازدید


ارائه مکانیزم‌های کلیدی سی++ امروزی که برای حفظ سازگاری در طول دهه‌ها طراحی شده‌اند

نویسنده: بیارنه استراستروپ
منتشرشده در: ۴ فوریه ۲۰۲۵

خلاصه:
بیش از ۴۵ سال از زمان پیدایش سی++ می‌گذرد. همان‌طور که برنامه‌ریزی شده بود، این زبان برای پاسخگویی به چالش‌ها تکامل یافته است، اما بسیاری از توسعه‌دهندگان همچنان از سی++ به‌گونه‌ای استفاده می‌کنند که گویی هنوز در هزاره گذشته هستیم. این رویکرد از نظر سهولت بیان ایده‌ها، عملکرد، قابلیت اطمینان و قابلیت نگهداری بهینه نیست. در این مقاله، مفاهیم کلیدی برای ساخت نرم‌افزارهای سی++ با عملکرد بالا، ایمن از نظر نوع داده، و انعطاف‌پذیر ارائه می‌شود: مدیریت منابع، مدیریت طول عمر، مدیریت خطاها، مدولاریتی، و برنامه‌نویسی جنریک. در پایان، روش‌هایی برای اطمینان از به‌روز بودن کد ارائه می‌شود تا از تکنیک‌های قدیمی، ناامن و دشوار برای نگهداری اجتناب گردد: راهنماها و پروفایل‌ها.

012225.BLOG_.21st-Century-C-G.jpg

۱. مقدمه

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

اگر سیستم‌عامل شما سازگاری را در طول دهه‌ها حفظ کرده باشد، می‌توانید برنامه‌های سی++ نوشته‌شده در سال ۱۹۸۵ را امروز روی یک رایانه مدرن اجرا کنید. پایداری – یعنی سازگاری با نسخه‌های قدیمی‌تر سی++ – به‌ویژه برای سازمان‌هایی که سیستم‌های نرم‌افزاری را برای دهه‌ها نگهداری می‌کنند، بسیار مهم است. با این حال، در تقریباً تمام موارد، سی++ امروزی (C++30) می‌تواند ایده‌های موجود در کدهای قدیمی را بسیار ساده‌تر بیان کند، با تضمین‌های ایمنی نوع داده بهتر، و آن‌ها را سریع‌تر و با مصرف حافظه کمتر اجرا کند.

این مقاله مکانیزم‌های کلیدی سی++ امروزی را که برای این منظور طراحی شده‌اند، ارائه می‌دهد. در بخش ششم، تکنیک‌هایی برای اطمینان از استفاده مدرن از سی++ شرح داده می‌شود.

مثال ساده:
برنامه‌ای را در نظر بگیرید که هر خط یکتا را از ورودی به خروجی می‌نویسد:

import std;// دسترسی به تمام کتابخانه استاندارد
using namespace std;

int main() // چاپ خطوط یکتا از ورودی
{        
    unordered_map<string,int> m;  // جدول هش
    for (string line; getline(cin,line); )
        if (m[line]++ == 0)
            cout << line << '\n';
}

علاقه‌مندان ممکن است این را به‌عنوان برنامه AWK با ساختار (!a[$0]++) بشناسند. این برنامه از unordered_map، نسخه کتابخانه استاندارد سی++ از جدول هش، استفاده می‌کند تا خطوط یکتا را نگه دارد و فقط زمانی که خطی برای اولین بار دیده می‌شود، آن را چاپ کند.

حلقه for برای محدود کردن محدوده متغیر حلقه (line) به خود حلقه استفاده شده است.

در مقایسه با سبک‌های قدیمی‌تر سی++، نکته قابل‌توجه غیبت موارد زیر است:

  • تخصیص/آزادسازی صریح حافظه
  • اندازه‌ها
  • مدیریت خطا
  • تبدیل‌های نوع (کست‌ها)
  • اشاره‌گرها
  • اندیس‌گذاری ناامن
  • استفاده از پیش‌پردازنده (به‌ویژه بدون #include).

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

مثال دیگر:
نسخه‌ای از برنامه که خطوط یکتا را برای استفاده بعدی جمع‌آوری می‌کند:

import std;                               
using namespace std; // دسترسی به تمام کتابخانه استاندارد
vector<string> collect_lines(istream& is) // جمع‌آوری خطوط یکتا از ورودی
{
    unordered_set s; // جدول هش
    for (string line; getline(is,line); )
        s.insert(line);
    return vector{from_range, s}; // کپی عناصر مجموعه به یک بردار
}
auto lines = collect_lines(cin);

چون نیازی به شمارش نبود، از set به‌جای map استفاده شده است. به‌جای set، یک vector برگردانده شده چون vector پرکاربردترین ظرف (container) است. نیازی به مشخص کردن نوع عناصر vector نبود، زیرا کامپایلر آن را از نوع عناصر set استنباط کرد.

پارامتر from_range به کامپایلر و خواننده انسانی نشان می‌دهد که از یک محدوده (range) استفاده شده است، نه روش‌های دیگر برای مقداردهی اولیه vector. نویسنده ترجیح می‌داد از vector{m} استفاده کند که منطقاً ساده‌تر است، اما کمیته استاندارد تصمیم گرفت که استفاده از from_range برای بسیاری از کاربران مفیدتر است.

برنامه‌نویسان با تجربه متوجه خواهند شد که این نسخه از collect_lines() کاراکترهای خوانده‌شده را کپی می‌کند. این می‌تواند مشکل عملکردی ایجاد کند، بنابراین در بخش ۳.۲ نشان داده می‌شود که چگونه می‌توان collect_lines() را بهینه کرد تا از این کپی‌ها جلوگیری شود.

هدف این مثال‌های کوچک چیست؟
نمایش سی++ امروزی پیش از ورود به جزئیات فنی و امیدوارانه، خارج کردن برخی افراد از پیش‌فرض‌های قدیمی و نادرست چند دهه‌ای.


۲. آرمان‌های سی++

هدف‌های من برای سی++ را می‌توان به‌صورت زیر خلاصه کرد:

  • بیان مستقیم ایده‌ها
  • ایمنی نوع داده در زمان کامپایل
  • ایمنی منابع (یعنی بدون نشت منابع)
  • دسترسی مستقیم به سخت‌افزار
  • عملکرد (یعنی کارایی بالا)
  • گسترش‌پذیری مقرون‌به‌صرفه (یعنی انتزاع با هزینه صفر)
  • قابلیت نگهداری (یعنی کد قابل‌فهم)
  • استقلال از پلتفرم (یعنی قابلیت حمل)
  • پایداری (یعنی سازگاری با نسخه‌های قبلی)

این اهداف از روزهای اولیه سی++ تغییر نکرده‌اند، اما سی++ برای تکامل طراحی شده بود، و سی++ امروزی می‌تواند این ویژگی‌ها را در کد بسیار بهتر از نسخه‌های قبلی ارائه دهد.

کد سی++ که این آرمان‌ها را در بر می‌گیرد، صرفاً با استفاده از تمام ویژگی‌های جدید به‌دست نمی‌آید. برخی ویژگی‌ها و تکنیک‌های کلیدی قدیمی هستند:

  • کلاس‌ها با سازنده‌ها و تخریب‌کننده‌ها
  • استثناها
  • قالب‌ها (Templates)
  • std::vector
  • ...

ویژگی‌های کلیدی جدیدتر عبارتند از:

  • ماژول‌ها (بخش ۴)
  • مفاهیم (Concepts) برای مشخص کردن رابط‌های جنریک (بخش ۵.۱)
  • عبارات لامبدا برای تولید اشیاء تابعی (بخش ۵.۱)
  • محدوده‌ها (Ranges) (بخش ۵.۱)
  • constexpr و consteval برای محاسبات در زمان کامپایل (بخش ۵.۲)
  • پشتیبانی از همزمانی و الگوریتم‌های موازی
  • کوروتین‌ها (که سال‌ها غایب بودند، با وجود اینکه در نسخه‌های اولیه سی++ ضروری تلقی می‌شدند)
  • std::shared_ptr
  • ...

آنچه اهمیت دارد، استفاده از ویژگی‌های زبان و کتابخانه به‌صورت یک کل منسجم و متناسب با مسئله‌ای است که باید حل شود.

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

در ادامه این مقاله، بر موارد زیر تمرکز می‌کنم:

  • مدیریت منابع (شامل کنترل طول عمر و مدیریت خطاها)
  • ماژول‌ها (شامل حذف پیش‌پردازنده)
  • برنامه‌نویسی جنریک (شامل مفاهیم)
  • راهنماها و اجرا (چگونه می‌توانیم تضمین کنیم که کد ما واقعاً «سی++ قرن بیست و یکم» است؟)

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


۳. مدیریت منابع

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

تکنیک پایه سی++ برای مدیریت منابع، ریشه‌دادن آن در یک دسته (handle) است که تضمین می‌کند منبع هنگام خروج از محدوده دسته آزاد می‌شود. برای اطمینان، نمی‌توانیم به عملیات صریح مانند delete، free()، unlock() و غیره در کد برنامه وابسته باشیم. چنین عملیاتی باید در دسته‌های منابع قرار گیرند. به مثال زیر توجه کنید:

 
template<typename T>
class Vector { // بردار از عناصر نوع T
public:
    Vector(initializer_list<T>); // سازنده: تخصیص حافظه؛ مقداردهی اولیه عناصر
    ~Vector(); // تخریب‌کننده: نابودی عناصر؛ آزادسازی حافظه
    // ...
private:
    T* elem; // اشاره‌گر به عناصر
    int sz; // تعداد عناصر
};

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

 
void fct()
{
    Vector<double> constants {1, 1.618, 3.14, 2.99e8};
    Vector<string> designers {"Strachey", "Richards", "Ritchie"};
    // ...
    Vector<pair<string,jthread>> vp { {"producer",prod}, {"consumer",cons}};
}

در اینجا، constants با چهار مقدار ریاضی و فیزیکی، designers با سه طراح زبان برنامه‌نویسی شناخته‌شده، و vp با یک جفت تولیدکننده-مصرف‌کننده مقداردهی اولیه می‌شوند. همه توسط سازنده‌های مناسب مقداردهی شده و هنگام خروج از محدوده توسط تخریب‌کننده‌های مناسب آزاد می‌شوند. مقداردهی اولیه و آزادسازی توسط جفت‌های سازنده-تخریب‌کننده به‌صورت بازگشتی انجام می‌شود. برای مثال، ساخت و تخریب vp ساده نیست زیرا شامل یک Vector، یک pair، رشته‌ها (دسته‌های کاراکترها)، و jthreadها (دسته‌های نخ‌های سیستم‌عامل) است. با این حال، همه این‌ها به‌صورت ضمنی مدیریت می‌شوند.

این استفاده از جفت‌های سازنده-تخریب‌کننده (که اغلب به‌عنوان RAII – «تخصیص منبع یعنی مقداردهی اولیه» شناخته می‌شود) نه‌تنها آزادسازی منابع را تضمین می‌کند، بلکه نگهداری منابع را نیز به حداقل می‌رساند، که در مقایسه با بسیاری از تکنیک‌های دیگر، مانند مدیریت حافظه مبتنی بر جمع‌آوری زباله، مزیت عملکردی قابل‌توجهی ارائه می‌دهد.

۳.۱. کنترل طول عمر

کنترل طول عمر اشیاء نمایانگر منابع برای مدیریت ساده و کارآمد منابع ضروری است. سی++ چهار نقطه کنترل را به‌صورت عملیات روی یک کلاس (اینجا به نام X) ارائه می‌دهد:

  • ساخت: پیش از اولین استفاده فراخوانی می‌شود: ایجاد ثابت کلاس (در صورت وجود). نام: سازنده، X(optional_arguments)
  • تخریب: پس از آخرین استفاده فراخوانی می‌شود: آزادسازی هر منبع (در صورت وجود). نام: تخریب‌کننده، ~X()
  • کپی: ساخت یک شیء جدید با همان مقدار شیء دیگر؛ a=b به این معناست که a==b (برای انواع منظم). نام‌ها: سازنده کپی، X(const X&) و تخصیص کپی، X::operator=(const X&)
  • انتقال: انتقال منابع از یک شیء به شیء دیگر، اغلب بین محدوده‌ها. نام‌ها: سازنده انتقال، X(X&&) و تخصیص انتقال، X::operator=(X&&)

برای مثال، می‌توانیم Vector خود را به این صورت گسترش دهیم:

template<typename T>
class Vector { // بردار از عناصر نوع T
public:
    Vector(); // سازنده پیش‌فرض: ساخت یک بردار خالی
    Vector(initializer_list<T>); // سازنده: تخصیص حافظه؛ مقداردهی اولیه عناصر
    Vector(const Vector& a); // سازنده کپی: کپی a به *this
    Vector& operator=(const Vector& a); // تخصیص کپی: کپی a به *this
    Vector(Vector&& a); // سازنده انتقال: انتقال a به *this
    Vector& operator=(Vector&& a); // تخصیص انتقال: انتقال a به *this
    ~Vector(); // تخریب‌کننده: نابودی عناصر؛ آزادسازی حافظه
    // ...
};

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

۳.۲. حذف کپی‌های اضافی

با توجه به این چارچوب، بیایید دوباره به مثال collect_lines از بخش ۱ نگاه کنیم. ابتدا می‌توانیم آن را کمی ساده‌تر کنیم:

vector<string> collect_lines(istream& is) // جمع‌آوری خطوط یکتا از ورودی
{
    unordered_set s {from_range,istream_iterator<string>{is}}; // مقداردهی اولیه s از is
    return vector{from_range,s};
}
auto lines = collect_lines(cin);

بخش istream_iterator<string>{is} به ما اجازه می‌دهد ورودی از is را به‌عنوان یک محدوده از عناصر در نظر بگیریم، به‌جای اینکه عملیات ورودی را به‌صورت صریح روی جریان اعمال کنیم.

در اینجا، بردار به‌جای کپی، از collect_lines() منتقل می‌شود. بدترین حالت هزینه سازنده انتقال یک بردار، کپی ۶ کلمه است: سه کلمه برای کپی نمایندگی و سه کلمه برای صفر کردن نمایندگی اصلی. این حتی اگر بردار یک میلیون عنصر داشته باشد، صدق می‌کند.

حتی این هزینه کوچک در بسیاری از موارد حذف می‌شود. از حدود سال ۱۹۸۳، کامپایلرها می‌دانند که مقدار برگشتی (اینجا، vector{from_range,s}) را در مقصد (اینجا، lines) بسازند. این به‌عنوان «حذف کپی» شناخته می‌شود.

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

vector<string> collect_lines(istream& is) // جمع‌آوری خطوط یکتا از ورودی
{
    unordered_set s {from_range,istream_iterator<string>{is}}; // مقداردهی اولیه s از is
    return vector{from_range,std::move(s)}; // انتقال عناصر
}

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

۳.۳. منابع و خطاها

یکی از اهداف کلیدی سی++ ایمنی منابع است: هیچ منبعی نباید نشت کند. این به این معناست که باید از نشت منابع در شرایط خطا جلوگیری کنیم. قوانین پایه عبارتند از:

  • هیچ منبعی نباید نشت کند.
  • هیچ منبعی نباید در حالت نامعتبر رها شود.

بنابراین، وقتی خطایی که نمی‌توان به‌صورت محلی مدیریت کرد شناسایی می‌شود، پیش از خروج از تابع باید:

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

این به این معناست که «اشاره‌گرهای خام» نمی‌توانند به‌طور قابل‌اعتماد به‌عنوان دسته‌های منابع استفاده شوند. به نوع Gadget توجه کنید که ممکن است منابعی مانند حافظه، قفل‌ها و دسته‌های فایل را نگه دارد:

void f(int n, int x)
{
    Gadget g {n}; 
    Gadget* pg = new Gadget{n}; // استفاده صریح از new: نکنید!
    // ...
    if (x<100) throw std::runtime_error{"Weird!"}; // نشت *pg؛ اما نه g
    if (x<200) return; // نشت *pg؛ اما نه g
    // ...
}

استفاده صریح از new برای قرار دادن Gadget روی هیپ، مشکلی ایجاد می‌کند همان لحظه‌ای که نتیجه آن در یک «اشاره‌گر خام» به‌جای یک دسته منبع با تخریب‌کننده مناسب ذخیره می‌شود. اشیاء محلی ساده‌تر و معمولاً سریع‌تر از استفاده صریح از new هستند.

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

  • از کدهای خطا و آزمایش‌ها برای خطاهایی که رایج هستند و می‌توانند به‌صورت محلی مدیریت شوند استفاده کنید.
  • از استثناها برای خطاهای نادر («استثنایی») که نمی‌توانند به‌صورت محلی مدیریت شوند استفاده کنید.

جایگزین، «جهان کد خطا» پرهزینه است که در آن هر فراخواننده در زنجیره فراخوانی باید به‌یاد بیاورد که آزمایش کند.
عدم بررسی یک استثنا منجر به خاتمه می‌شود، نه نتایج اشتباه.
در برخی کاربردهای مهم، خاتمه فوری بی‌قیدوشرط گزینه‌ای نیست. در این صورت، باید هر کد خطای بازگشتی را آزمایش کنیم و هر استثنا را در جایی (مثلاً در main()) بگیریم و پاسخ مناسب را انجام دهیم.

به‌طور شگفت‌انگیزی برای بسیاری، استثناها حتی برای سیستم‌های کوچک می‌توانند ارزان‌تر و سریع‌تر از استفاده مداوم از کدهای خطا باشند.

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

void fct(jthread& prod, jthread& cons, string name)
{
    ifstream in { name };
    if (!in) { /* ... */ } // احتمال شکست مورد انتظار
    // ...
    vector<double> constants {1, 1.618, 3.14, 2.99e8};
    vector<string> designers {"Strachey", "Richards", "Ritchie"};
    auto dmr = "Dennis M. " + designers[2];
    // ...
    pair<string,jthread&> pipeline[] { {"producer", prod}, {"consumer", cons}};
    // ...
}

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

متأسفانه، استثناها به‌طور جهانی مورد قدردانی و استفاده قرار نگرفته‌اند. علاوه بر استفاده بیش‌ازحد از «اشاره‌گرهای خام»، مشکلی بوده که بسیاری از توسعه‌دهندگان اصرار دارند از یک تکنیک واحد برای گزارش تمام خطاها استفاده کنند، یعنی یا همه با پرتاب یا همه با بازگرداندن کد خطا. این با نیازهای کدهای واقعی همخوانی ندارد.


۴. مدولاریتی

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

۴.۱. فایل‌های سرآیند (Header Files)

یک دستور #include متن منبع را از یک «فایل سرآیند» به واحد ترجمه فعلی کپی می‌کند. متأسفانه، این به این معناست که:

#include "a.h"
#include "b.h"

ممکن است معنای متفاوتی نسبت به:

 
#include "b.h"
#include "a.h"

داشته باشد. این منبع باگ‌های ظریفی است.

یک #include انتقالی است. یعنی اگر a.h شامل #include "c.h" باشد، متن c.h نیز بخشی از هر فایل منبعی که از #include "a.h" استفاده می‌کند، می‌شود. این نیز منبع باگ‌های ظریفی است. از آنجا که یک فایل سرآیند اغلب در ده‌ها یا صدها فایل منبع #include می‌شود، این به معنای تکرار زیاد در کامپایل است.

۴.۲. ماژول‌ها

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

import a;
import b;

همان معنای:

import b;
import a;

را دارد. استقلال متقابل ماژول‌ها به معنای بهبود بهداشت کد است و باگ‌های وابستگی ظریف را غیرممکن می‌کند.

اینجا یک مثال بسیار ساده از تعریف یک ماژول است:

چون وارد کردن ماژول انتقالی نیست، کاربران map_printer به جزئیات پیاده‌سازی موردنیاز برای print_map دسترسی ندارند.

یک ماژول فقط یک‌بار نیاز به کامپایل دارد، صرف‌نظر از اینکه چند بار وارد می‌شود. این به معنای بهبود بسیار قابل‌توجه در زمان کامپایل است. یک کاربر گزارش داده است:

#include <libgalil/DmcDevice.h> // 457440 خط پس از پیش‌پردازش
int main() { // 151268 خط غیرخالی
    Libgalil::DmcDevice("192.168.55.10"); // 1546 میلی‌ثانیه برای کامپایل
}

این یعنی ۱.۵ ثانیه برای کامپایل تقریباً نیم میلیون خط کد. این سریع است! اما کامپایلر کار بیش‌ازحد انجام می‌دهد.

 
import libgalil; // 5 خط پس از پیش‌پردازش
int main() { // 4 خط غیرخالی
    Libgalil::DmcDevice("192.168.55.10"); // 62 میلی‌ثانیه برای کامپایل
}

این یک سرعت ۲۵ برابری است. نمی‌توان انتظار داشت که در همه موارد این‌گونه باشد، اما برتری ۷ تا ۱۰ برابری وارد کردن نسبت به #include رایج است. اگر آن کتابخانه را در ۲۵ فایل منبع #include کنید، هزینه آن ۱.۵ ثانیه ۲۵ بار خواهد بود، در حالی که وارد کردن در مجموع ۱.۵ ثانیه طول می‌کشد.

کتابخانه استاندارد کامل به یک ماژول تبدیل شده است. به برنامه سنتی «سلام، دنیا!» نگاه کنید:

#include <iostream>
int main()
{
    std::cout << "Hello, World!\n";
}

روی لپ‌تاپ من، این در ۰.۸۷ ثانیه کامپایل شد. جایگزین کردن #include<iostream.h> با import std; زمان کامپایل را به ۰.۰۸ ثانیه کاهش داد، با وجود اینکه حداقل ۱۰ برابر اطلاعات بیشتری در دسترس قرار گرفت.

بازسازی مقدار قابل‌توجهی از کد آسان یا ارزان نیست، اما در مورد ماژول‌ها، مزایا از نظر کیفیت کد قابل‌توجه و از نظر زمان کامپایل عظیم است.

چرا در این مورد خاص – و فقط در این مورد – زحمت توضیح «روش قدیمی بد» را به خودم می‌دهم؟ چون #includeها همه‌گیر هستند، تقریباً از زمان تولد سی، و بسیاری از توسعه‌دهندگان تصور سی++ بدون آن را دشوار می‌دانند.


۵. برنامه‌نویسی جنریک

برنامه‌نویسی جنریک یکی از پایه‌های کلیدی سی++ امروزی است. این از پیش از تغییر نام «سی با کلاس‌ها» به «سی++» وجود داشته، اما تنها به‌تازگی (C++20) پشتیبانی زبان به آرمان‌ها نزدیک شده است.

برنامه‌نویسی جنریک، یعنی برنامه‌نویسی با انواع و توابعی که توسط انواع پارامتریزه شده‌اند، ارائه می‌دهد:

  • کد کوتاه‌تر و خواناتر
  • بیان مستقیم‌تر ایده‌ها
  • انتزاع با هزینه صفر
  • ایمنی نوع داده

قالب‌ها، پشتیبانی زبان سی++ برای برنامه‌نویسی جنریک، در کتابخانه استاندارد همه‌گیر هستند:

  • ظروف و الگوریتم‌ها
  • پشتیبانی از همزمانی: نخ‌ها، قفل‌ها، ...
  • مدیریت حافظه: تخصیص‌دهنده‌ها، دسته‌های منابع (مثل vector و list)، اشاره‌گرهای مدیریت منابع، ...
  • ورودی/خروجی
  • رشته‌ها و عبارات منظم
  • و خیلی چیزهای دیگر

می‌توانیم کدی بنویسیم که برای تمام انواع آرگومان مناسب کار کند. برای مثال، اینجا یک تابع مرتب‌سازی است که تمام انواعی که تعریف استاندارد ISO سی++ از یک محدوده قابل‌مرتب‌سازی را دارند، می‌پذیرد:

void sort(Sortable_range auto& r);
vector<string> vs;
// ... پر کردن vs ...
sort(vs);

array<int,128> ai;
// ... پر کردن ai ...
sort(ai);

کامپایلر اطلاعات کافی برای تأیید این دارد که انواع vs و ai آنچه Sortable_range نیاز دارد را دارند؛ یعنی یک محدوده با دسترسی تصادفی از مقادیر انواعی که می‌توانند برای مرتب‌سازی مقایسه و جابه‌جا شوند. اگر آرگومان‌ها مناسب نباشند، خطا توسط کامپایلر در نقطه استفاده شناسایی می‌شود. برای مثال:

list<int> lsti;
// ... پر کردن lsti ...
sort(lsti); // خطا: یک لیست دسترسی تصادفی ارائه نمی‌دهد

طبق استاندارد سی++، یک لیست محدوده قابل‌مرتب‌سازی نیست زیرا دسترسی تصادفی ارائه نمی‌دهد.

۵.۱. مفاهیم (Concepts)

یک مفهوم (concept) یک پیش‌نیاز در زمان کامپایل است. یعنی تابعی که توسط کامپایلر اجرا می‌شود و یک مقدار بولی تولید می‌کند. بیشتر برای بیان الزامات پارامترهای یک قالب استفاده می‌شود. یک مفهوم اغلب از مفاهیم دیگر ساخته می‌شود. برای مثال، اینجا Sortable_range موردنیاز تابع sort بالا آمده است:

template<typename R>
concept Sortable_range =
    random_access_range<R> // دارای begin()/end()، ++، []، +، ...
    && sortable<iterator_t<R>>; // می‌تواند عناصر را مقایسه و جابه‌جا کند
 
این می‌گوید که یک نوع R یک Sortable_range است اگر یک random_access_range باشد و دارای نوع تکرارساز قابل‌مرتب‌سازی باشد. random_access_range و sortable مفاهیمی هستند که در کتابخانه استاندارد تعریف شده‌اند. یک مفهوم می‌تواند یک یا چند آرگومان بگیرد و می‌تواند از ویژگی‌های اساسی زبان ساخته شود. برای مشخص کردن یک ویژگی نوع مستقیماً به زبان (به‌جای استفاده از مفاهیم دیگر)، از «الگوهای استفاده» استفاده می‌کنیم. برای مثال:
template<typename T, typename U = T>
concept equality_comparable = requires(T a, U b) {
    {a==b} -> Boolean;
    {a!=b} -> Boolean;
    {b==a} -> Boolean;
    {b!=a} -> Boolean;
};

سازه‌های داخل {...} باید معتبر باشند و چیزی را برگردانند که با مفهوم مشخص‌شده پس از -> مطابقت داشته باشد. بنابراین، اینجا الگوهای استفاده فهرست‌شده (مثل a==b) باید چیزی برگردانند که بتوان به‌عنوان یک bool استفاده کرد. معمولاً، همان‌طور که در مثال sort دیده شد، بررسی اینکه یک نوع با یک مفهوم مطابقت دارد به‌صورت ضمنی انجام می‌شود، اما می‌توانیم صراحتاً با static_assert این کار را انجام دهیم:

static_assert(equality_comparable<int,double>); // موفق
static_assert(equality_comparable<int>); // موفق (U به‌طور پیش‌فرض int است)
static_assert(equality_comparable<int,string>); // ناموفق

مفهوم equality_comparable در کتابخانه استاندارد تعریف شده است. نیازی به تعریف آن توسط خودمان نیست، اما مثال خوبی است.

ما می‌خواهیم کدی بنویسیم که برای تمام انواع آرگومان مناسب کار کند. با این حال، بسیاری (شاید بیشتر) الگوریتم‌ها بیش از یک نوع آرگومان قالب می‌گیرند. این به این معناست که باید روابط بین این آرگومان‌های قالب را بیان کنیم. برای مثال:

template<input_range R, indirect_unary_predicate<iterator_t<R> Pred>
Iterator_t<R> find_if(R&& r, Pred p);
این می‌گوید که find_if یک محدوده ورودی r و یک پیش‌نیاز p می‌گیرد که می‌تواند روی نتیجه یک غیرمستقیم‌سازی از طریق تکرارساز r اعمال شود. برای مثال:
vector<string> numbers; // رشته‌هایی که اعداد را نشان می‌دهند؛ مثلاً "13" و "123.45"
// ... پر کردن numbers ...
auto q = find_if(numbers, [](const string& s) { return stoi(s)<42; });

پارامتر دوم فراخوانی find_if یک عبارت لامبدا است. این یک شیء تابعی تولید می‌کند که هنگام فراخوانی در پیاده‌سازی find_if برای یک آرگومان s، عبارت stoi(s)<42 را اجرا می‌کند. عبارات لامبدا (که معمولاً فقط «لامبدا» نامیده می‌شوند) در سی++ امروزی بسیار مفید و محبوب شده‌اند.

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

  • انواع داخلی سی/سی++: حسابی و اعشاری
  • کتابخانه استاندارد سی++: تکرارسازها، دنباله‌ها، و ظروف
  • ریاضیات: موناد، گروه، حلقه، و میدان
  • گراف‌ها: یال‌ها و رأس‌ها، گراف، DAG، ...

استاندارد C++20 ایده مفاهیم را معرفی نکرد؛ فقط زبان مستقیمی برای مفاهیم اضافه کرد. یک مفهوم یک پیش‌نیاز در زمان کامپایل است. استفاده از مفاهیم آسان‌تر از عدم استفاده از آن‌هاست. با این حال، مانند هر سازه جدید، باید یاد بگیریم که چگونه از آن‌ها به‌طور مؤثر استفاده کنیم.

۵.۲. ارزیابی در زمان کامپایل

یک مفهوم نمونه‌ای از یک تابع در زمان کامپایل است. در سی++ امروزی، هر تابع به‌اندازه کافی ساده می‌تواند در زمان کامپایل ارزیابی شود:

  • constexpr: می‌تواند در زمان کامپایل ارزیابی شود
  • consteval: باید در زمان کامپایل ارزیابی شود
  • concept: در زمان کامپایل ارزیابی می‌شود، می‌تواند انواع را به‌عنوان آرگومان بگیرد

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

constexpr auto jul = weekday(December/24/2024); // سه‌شنبه

برای اینکه توابع consteval و constexpr و مفاهیم بتوانند در زمان کامپایل ارزیابی شوند، نمی‌توانند:

  • اثرات جانبی داشته باشند
  • به داده‌های غیرمحلی دسترسی داشته باشند
  • رفتار نامعین (UB) داشته باشند

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

بنابراین، چنین توابعی نسخه سی++ از ایده یک تابع خالص هستند و یک کامپایلر سی++ امروزی شامل یک مفسر تقریباً کامل سی++ است. ارزیابی در زمان کامپایل همچنین برای عملکرد یک مزیت بزرگ است.


۶. راهنماها و اجرا

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

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

برای کمک به توسعه‌دهندگان برای تمرکز بر استفاده مؤثر از سی++ امروزی و اجتناب از «گوشه‌های تاریک» قدیمی زبان، مجموعه‌هایی از راهنماها توسعه یافته‌اند. در اینجا من بر راهنماهای هسته سی++ تمرکز می‌کنم که به نظرم جاه‌طلبانه‌ترین هستند.

یک مجموعه راهنما باید فلسفه منسجمی از زبان نسبت به یک کاربرد خاص را نشان دهد. هدف اصلی من استفاده ایمن از نظر نوع داده و منابع از سی++ استاندارد ISO است. یعنی:

  • هر شیء فقط طبق تعریف خود استفاده می‌شود
  • هیچ منبعی نشت نمی‌کند

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

یک مجموعه راهنما نقاط قوت و ضعفی دارد:

  • اکنون در دسترس است (مثلاً راهنماهای هسته سی++)
  • قوانین فردی می‌توانند اجرا شوند یا نشوند
  • اجرا ناقص است

با تکیه بر راهنماها، به اجرا نیاز داریم:

  • یک پروفایل مجموعه‌ای منسجم از قوانین راهنما است که اجرا می‌شود
  • در WG21 و جاهای دیگر در حال کار است
  • هنوز در دسترس نیستند، جز نسخه‌های آزمایشی و جزئی

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

۶.۱. راهنماها

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

  • ابتدا: زبان را با چند انتزاع کتابخانه‌ای گسترش دهید: از بخش‌هایی از کتابخانه استاندارد استفاده کنید و یک کتابخانه کوچک (کتابخانه پشتیبانی راهنماها، GSL) اضافه کنید تا استفاده از راهنماها راحت و کارآمد باشد.
  • سپس: زیرمجموعه‌سازی: استفاده از ویژگی‌های سطح پایین، غیرکارآمد و مستعد خطا را ممنوع کنید.

آنچه به‌دست می‌آید «سی++ تقویت‌شده» است: چیزی ساده، ایمن، انعطاف‌پذیر و سریع؛ نه یک زیرمجموعه فقیر یا چیزی که به بررسی‌های گسترده در زمان اجرا وابسته باشد. همچنین زبانی با ویژگی‌های جدید و/یا ناسازگار ایجاد نمی‌کنیم. نتیجه ۱۰۰٪ سی++ استاندارد ISO است. ویژگی‌های پیچیده، خطرناک و سطح پایین همچنان می‌توانند در صورت نیاز فعال و استفاده شوند.

حوزه‌های کاربردی مختلف نیازهای متفاوتی دارند و بنابراین به مجموعه‌های راهنمای متفاوتی نیاز دارند، اما در ابتدا تمرکز روی «هسته یا راهنماهای هسته سی++» است. قوانینی که امیدواریم همه در نهایت از آن‌ها بهره‌مند شوند:

  • بدون متغیرهای مقداردهی‌نشده
  • بدون نقض محدوده یا nullptr
  • بدون نشت منابع
  • بدون اشاره‌گرهای آویزان
  • بدون نقض نوع
  • بدون نامعتبرسازی

دو کتاب سی++ را با پیروی از این راهنماها شرح می‌دهند، مگر در مواردی که خطاها را نشان می‌دهند: «تور سی++» برای برنامه‌نویسان با تجربه و «برنامه‌نویسی: اصول و تمرین با استفاده از سی++» برای مبتدیان. دو کتاب دیگر جنبه‌های راهنماهای هسته سی++ را بررسی می‌کنند.

۶.۲. قانون نمونه: از اندیس‌گذاری اشاره‌گرها استفاده نکنید

یک اشاره‌گر اطلاعات مرتبط موردنیاز برای بررسی محدوده را ندارد. با این حال، بررسی محدوده برای ایمنی حافظه و همچنین ایمنی نوع داده ضروری است، زیرا نمی‌توانیم به کد برنامه اجازه دهیم اشیائی را بخواند یا بازنویسی کند که فراتر از محدوده اشیاء اشاره‌شده هستند. در عوض، باید از انتزاعی استفاده کنیم که اطلاعات کافی برای بررسی محدوده داشته باشد، مانند یک آرایه، یک بردار، یا یک span.

سبک رایجی را در نظر بگیرید: یک اشاره‌گر به‌علاوه یک عدد صحیح که ظاهراً تعداد عناصر اشاره‌شده را نشان می‌دهد:

void f(int* p, int n)
{
    for (int i = 0; i<n; i++)
        do_something_with(p[n]);
}
int a[100];
// ...
f(a,100); // مشکلی ندارد؟ (بستگی به معنای n در تابع فراخوانی‌شده دارد)
f(a,1000); // احتمالاً فاجعه

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

راه‌حل این مشکل، اتصال محکم اندازه به اشاره‌گر است (مانند Vector؛ بخش ۳.۱). این همان کاری است که یک span انجام می‌دهد:

void f(span<int> a) // یک span شامل یک اشاره‌گر و تعداد عناصر اشاره‌شده است
{
    for (int& x: s) // حالا می‌توانیم از یک range-for استفاده کنیم
        do_something_with(x);
}
int a[100];
// ...
f(a); // نوع و تعداد عناصر استنباط می‌شود
f({a,1000}); // درخواست مشکل، اما به‌صورت نحوی مشخص‌شده و به‌راحتی قابل‌بررسی

استفاده از span نمونه خوبی از اصل «ساده کردن چیزهای ساده» است. کد با استفاده از آن ساده‌تر از «سبک قدیمی» است: کوتاه‌تر، ایمن‌تر، و اغلب سریع‌تر.

نوع span در کتابخانه پشتیبانی راهنماها به‌عنوان یک نوع بررسی‌شده محدوده معرفی شد. متأسفانه، وقتی به کتابخانه استاندارد اضافه شد، تضمین بررسی محدوده حذف شد. بدیهی است که یک پروفایل (بخش ۶.۴) که این قانون را اجرا می‌کند، باید بررسی محدوده را انجام دهد. هر پیاده‌سازی عمده سی++ راه‌هایی برای اطمینان از این دارد (مثلاً سخت‌سازی کتابخانه استاندارد GCC، ایمنی فضایی گوگل، و تحلیل‌گر استاتیک ویژوال استودیو مایکروسافت). متأسفانه، هنوز راه استاندارد و قابل‌حمل برای درخواست آن وجود ندارد.

۶.۳. قانون نمونه: از اشاره‌گر نامعتبر استفاده نکنید

برخی ظروف، به‌ویژه بردار، می‌توانند عناصر خود را جابه‌جا کنند. اگر کسی خارج از ظرف اشاره‌گری به یک عنصر به‌دست آورد و پس از جابه‌جایی از آن استفاده کند، فاجعه ممکن است رخ دهد. به مثال زیر توجه کنید:

void f(vector<int>& vi)
{
    vi.push_back(9); // ممکن است عناصر vi را جابه‌جا کند
}
void g()
{
    vector<int> vi { 1,2 };
    auto p = vi.begin(); // اشاره به اولین عنصر vi
    f(vi);
    *p = 7; // خطا: p نامعتبر است
}

با قوانین مناسب برای استفاده از سی++ (بخش ۶.۱)، تحلیل استاتیک محلی می‌تواند از نامعتبرسازی جلوگیری کند. در واقع، پیاده‌سازی‌های بررسی‌های طول عمر راهنماهای هسته از سال ۲۰۱۹ این کار را انجام داده‌اند. پیشگیری از نامعتبرسازی و استفاده از اشاره‌گرهای آویزان به‌طور کلی کاملاً استاتیک (در زمان کامپایل) است. هیچ بررسی در زمان اجرا درگیر نیست.

اینجا جای شرح مفصل چگونگی انجام این تحلیل نیست. با این حال، طرح کلی مدل این است:

  • قوانین برای هر موجودی که مستقیماً به یک شیء اشاره می‌کند، مانند اشاره‌گرها، اشاره‌گرهای مدیریت منابع، ارجاع‌ها، و ظروف اشاره‌گرها اعمال می‌شود. مثال‌ها شامل int*، int&، vector<int*>، unique_ptr<int>، jthread که یک int* را نگه می‌دارد، و یک لامبدا که یک int را با ارجاع گرفته است.
  • استفاده پس از delete (بدیهی است) ممنوع است و به RAII (بخش ۳) تکیه کنید.
  • اجازه ندهید یک اشاره‌گر از محدوده آنچه به آن اشاره می‌کند فرار کند. این به این معناست که یک اشاره‌گر فقط در صورتی می‌تواند از یک تابع برگردانده شود که به چیزی استاتیک اشاره کند، به چیزی در حافظه آزاد (هیپ یا حافظه پویا) اشاره کند، یا به‌عنوان آرگومان وارد شده باشد.
  • فرض کنید تابعی (مثل vector::push_back()) که آرگومان‌های غیرثابت می‌گیرد، نامعتبر می‌کند. اگر اشاره‌گری به یکی از عناصر آن گرفته شده باشد، فراخوانی آن را ممنوع می‌کنیم. توابعی که فقط آرگومان‌های ثابت می‌گیرند نمی‌توانند نامعتبر کنند، و برای جلوگیری از مثبت‌های کاذب گسترده و حفظ تحلیل محلی، می‌توانیم اظهارات تابع را با [[profiles::non_invalidating]] حاشیه‌نویسی کنیم. این حاشیه‌نویسی می‌تواند هنگام دیدن تعریف تابع اعتبارسنجی شود. بنابراین، این یک حاشیه‌نویسی ایمن است، نه یک حاشیه‌نویسی «به من اعتماد کن».

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

۶.۴. اجرا: پروفایل‌ها

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

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

ما مجموعه‌ای منسجم از قوانین راهنما که تضمینی را فراهم می‌کنند، یک «پروفایل» می‌نامیم. طبق برنامه‌ریزی فعلی برای استاندارد، مجموعه اولیه پروفایل‌ها (بر اساس پروفایل‌های راهنماهای هسته که سال‌هاست استفاده می‌شوند) عبارتند از:

  • نوع: هر شیء مقداردهی‌شده؛ بدون کست‌ها؛ بدون یونیون‌ها
  • طول عمر: بدون دسترسی از طریق اشاره‌گرهای آویزان؛ بررسی ارجاع‌زدایی اشاره‌گر برای nullptr؛ بدون new/delete صریح
  • محدوده‌ها: تمام اندیس‌گذاری‌ها بررسی محدوده می‌شوند؛ بدون حساب اشاره‌گر
  • حسابی: بدون سرریز یا زیرریز؛ بدون تبدیل‌های تغییر مقدار امضاشده/بدون امضا

این اساساً «هسته هسته» توصیف‌شده در بخش ۶.۱ است. با زمان و آزمایش، پروفایل‌های بیشتری دنبال خواهند شد. برای مثال:

  • الگوریتم‌ها: تمام محدوده‌ها، بدون ارجاع‌زدایی تکرارسازهای end()
  • همزمانی: حذف قفل‌های مرده و رقابت‌های داده (سخت برای انجام)
  • RAII: هر منبع متعلق به یک دسته (نه فقط منابع مدیریت‌شده با new/delete)

همه پروفایل‌ها استاندارد ISO نخواهند بود. انتظار دارم پروفایل‌هایی برای حوزه‌های کاربردی خاص تعریف شوند، مثلاً برای انیمیشن، نرم‌افزار پرواز، و محاسبات علمی.

اجرا عمدتاً استاتیک (در زمان کامپایل) است، اما چند بررسی مهم باید در زمان اجرا باشد (مثل اندیس‌گذاری و ارجاع‌زدایی اشاره‌گر).

یک پروفایل باید به‌صورت صریح برای یک واحد ترجمه درخواست شود. برای مثال:

[[profile::enforce(type)]] // بدون کست‌ها یا اشیاء مقداردهی‌نشده در این واحد ترجمه

در صورت لزوم، یک پروفایل می‌تواند برای یک دستور (از جمله دستورات مرکب) که لازم است، سرکوب شود. برای مثال:

 
[profile::suppress(lifetime))] this->succ = this->succ->succ;

نیاز به سرکوب تأیید تضمین‌ها عمدتاً برای پیاده‌سازی انتزاع‌های موردنیاز برای ارائه تضمین‌ها (مثل span، vector، و string_view)، تضمین بررسی محدوده، و دسترسی مستقیم به سخت‌افزار است. چون سی++ نیاز به دستکاری مستقیم سخت‌افزار دارد، نمی‌توانیم پیاده‌سازی انتزاع‌های اساسی را به زبان دیگری واگذار کنیم. همچنین – به دلیل گستردگی کاربردها و پیاده‌سازی‌های مستقل متعدد – نمی‌توانیم پیاده‌سازی تمام انتزاع‌های بنیادی (مثل تمام انتزاع‌های شامل ساختارهای پیوندی) را به کامپایلر واگذار کنیم.


۷. آینده

من تمایلی به پیش‌بینی درباره آینده ندارم، بخشی به این دلیل که این ذاتاً خطرناک است، و به‌ویژه چون تعریف سی++ توسط کمیته استاندارد ISO عظیم که بر اساس اجماع عمل می‌کند، کنترل می‌شود. آخرین باری که بررسی کردم، فهرست اعضا ۵۲۷ ورودی داشت. این نشان‌دهنده اشتیاق، علاقه گسترده، و ارائه تخصص گسترده است، اما برای طراحی زبان برنامه‌نویسی ایده‌آل نیست و قوانین ISO نمی‌توانند به‌طور چشمگیر تغییر کنند. در میان موضوعات دیگر، کارهایی در حال انجام است در مورد:

  • یک مدل عمومی برای محاسبات ناهمگام
  • بازتاب استاتیک
  • SIMD
  • یک سیستم قرارداد
  • تطبیق الگو به سبک برنامه‌نویسی تابعی
  • یک سیستم واحد عمومی (مثل سیستم SI)

نسخه‌های آزمایشی همه این‌ها در دسترس هستند.

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


۸. خلاصه

سی++ برای تکامل طراحی شده بود. وقتی شروع کردم، نه‌تنها منابع لازم برای طراحی و پیاده‌سازی زبان ایده‌آلم را نداشتم، بلکه درک کردم که به بازخورد از استفاده نیاز دارم تا آرمان‌هایم را به واقعیتی عملی تبدیل کنم. و تکامل یافت، در حالی که به اهداف اساسی خود وفادار ماند. سی++ امروزی (C++23) تقریب بسیار بهتری به آرمان‌ها نسبت به هر نسخه قبلی است، از جمله پشتیبانی از کیفیت کد بهتر، ایمنی نوع داده، قدرت بیان، عملکرد، و برای گستره بسیار وسیع‌تری از حوزه‌های کاربردی.

با این حال، رویکرد تکاملی مشکلاتی جدی ایجاد کرد. بسیاری از مردم با دیدگاهی قدیمی از چیستی سی++ گیر کرده‌اند. امروز، هنوز ارجاعات بی‌پایانی به زبان افسانه‌ای C/C++ می‌بینیم، که معمولاً به این معناست که سی++ به‌عنوان یک افزونه جزئی از سی دیده می‌شود که تمام بدترین جنبه‌های سی را همراه با سوءاستفاده‌های وحشتناک از ویژگی‌های پیچیده سی++ در بر می‌گیرد. منابع دیگر سی++ را به‌عنوان تلاشی ناموفق برای طراحی جاوا توصیف می‌کنند. همچنین، پشتیبانی ابزارها در زمینه‌هایی مانند مدیریت بسته‌ها و سیستم‌های ساخت به دلیل تمرکز جامعه بر سبک‌های قدیمی‌تر استفاده عقب مانده است.

مدل سی++ را می‌توان به‌صورت زیر خلاصه کرد:

  • سیستم نوع استاتیک
  • پشتیبانی برابر برای انواع داخلی و تعریف‌شده توسط کاربر
  • معناشناسی مقدار و ارجاع
  • مدیریت منابع سیستماتیک و عمومی (RAII)
  • برنامه‌نویسی شیءگرا کارآمد
  • برنامه‌نویسی جنریک انعطاف‌پذیر و کارآمد
  • برنامه‌نویسی در زمان کامپایل
  • استفاده مستقیم از منابع ماشین و سیستم‌عامل
  • پشتیبانی از همزمانی از طریق کتابخانه‌ها (پشتیبانی‌شده توسط ویژگی‌های داخلی)

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



0 دیدگاه


نظرهای پیشنهاد شده

هیچ دیدگاهی برای نمایش وجود دارد.

مهمان
افزودن دیدگاه

×   شما در حال چسباندن محتوایی با قالب بندی هستید.   حذف قالب بندی

  تنها استفاده از ۷۵ اموجی مجاز می باشد.

×   لینک شما به صورت اتوماتیک جای گذاری شد.   نمایش به عنوان یک لینک به جای

×   محتوای قبلی شما بازگردانی شد.   پاک کردن محتوای ویرایشگر

×   شما مستقیما نمی توانید تصویر خود را قرار دهید. یا آن را اینجا بارگذاری کنید یا از یک URL قرار دهید.

  • کاربران آنلاین در این صفحه   0 کاربر

    هیچ کاربر عضوی،در حال مشاهده این صفحه نیست.

×
×
  • جدید...