Заголовок: Багато маленьких моментів "електричок"
Аудиторія
Ця стаття трохи змішана, в ній зібрані різні аспекти, які не зовсім підходили для моїх попередніх матеріалів з C++ тут. Це зібрання корисних тем, які варто знати.
Деякі з концепцій (володіння, управління пам'яттю і т. д.) допоможуть вам структурувати ваші програми, в той час як інші (многопоточність, підказки компілятора) навчать вас новим технікам.
Ця стаття вимагає гарного розуміння C++, яке ви, на щастя, можете отримати з списку, що наведено вище. Також вам слід розуміти основи управління пам'яттю, зокрема стек та купу, а також багатопоточність.
Варто зазначити, що жодна з цих функцій не є унікальною для цієї мови (деякі з них зустрічаються в подібних мовах, таких як Rust). Це просто корисні речі для розуміння на шляху до освоєння C++.
Аргументи
Вибір компілятора
Як згадувалося в цій статті, C++ є компільованою мовою. Для компіляції потрібен компілятор. Що не завжди очевидно — це як вибрати правильний компілятор.
Зазвичай для кожної платформи є своя переважна опція. Наприклад, на MacOS це Clang (хоча Xcode має власний вбудований компілятор), на Linux це може бути або Clang, або GCC. Для локальної розробки досить вибрати компілятор для тієї платформи, на якій ви працюєте.
Компілятори зазвичай працюють найкраще на платформі, для якої вони призначені. Якщо ви хочете, щоб ваша програма працювала на MacOS, компілюйте її там! Деякі компілятори підтримують крос-компіляцію, що дозволяє генерувати код для іншої платформи (корисно, якщо неможливо чи важко працювати на певній платформі, наприклад, в системах вбудованих пристроїв).
Різні компілятори мають різні підтримки версій мови (наприклад, підтримка C++23), ліцензування (відкритий код проти власницького), оптимізацію (наприклад, спеціальна підтримка певних архітектур), виведення помилок та інші аспекти.
Якщо ви вже трохи працювали з C++, ви, ймовірно, вже використовували стандартну бібліотеку. Вона містить основні функції для роботи з контейнерами, ввід/вивід, управління пам'яттю тощо.
Варто зазначити, що кожен компілятор має свою власну реалізацію стандартної бібліотеки. Реалізації можуть бути схожими, але вони не обов'язково однакові.
Управління пам'яттю
Розуміння того, як виділяється пам'ять, важливе для всіх мов програмування, але чим нижчий рівень, тим важливіше це стає.
Щоб зрозуміти підхід C++ до управління пам'яттю, потрібно визначити час життя об'єкта. Це частина програми, після його створення і до знищення, і час, протягом якого його можна використовувати.
C++ моделює управління пам'яттю через тривалість зберігання. Це визначає мінімальний час життя об'єкта. Існують такі варіанти:
- Статичне: Час життя від початку програми до її завершення. Значення ініціалізується на початку і більше не змінюється. Наприклад, статичні змінні.
- Потокове: Час життя — це виконання потоку. Об'єкт створюється та ініціалізується, коли потік починається, і є унікальний об'єкт для кожного потоку.
- Автоматичне: Час життя в межах блоку, об'єкти створюються/знищуються кожного разу, коли блок вхід/вихід. Наприклад, змінні в визначенні функції.
4.
Динамічно/виділена пам'ять: Час життя повністю контролюється програмістом. Він повинен пам'ятати про видалення будь-яких об'єктів, які він створює, інакше є ризик витоків пам'яті.
Зверніть увагу, що немає прямого згадування стеків або куп. Це не є вимогою C++, а деталь реалізації. Однак давайте подивимося, як це може співвідноситися з вищезгаданими концепціями.
Як стек, так і купа зберігають дані в пам'яті. Для стека:
- Стек створюється на початку виконання потоку (включаючи основну програму) і існує на основі кожного потоку. Зазвичай має фіксований розмір.
- Коли ми входимо в метод, для даних функції виділяється новий «кадр стека». Коли ми виходимо з методу, кадр знищується, і пам'ять стає доступною для використання.
- Виділення пам'яті в стеці швидке завдяки принципу останній вхід, перший вихід (LIFO).
- Не потрібно турбуватися про очищення пам'яті — це відбувається автоматично, коли ви виходите з області видимості методу. Однак це означає, що ви не можете звертатися до змінних стека після завершення методу.
Купа:
- Виділяється на початку програми. Зазвичай не має фіксованого розміру і може розширюватися, поки операційна система дозволяє виділяти більше місця.
- Виділення простору в купі повільніше, оскільки це більш складний процес.
- Ми повинні вручну виділяти змінні в купі, і відповідати за їх звільнення. Якщо цього не зробити, це призведе до витоків пам'яті.
- Змінні купи спільні між потоками.
Ми бачимо, як можна помістити автоматично область видимості змінних у стек, а решту — у купу!
Цікава думка: ‘як тут підходять розумні вказівники’? Вони допомагають мостити розрив між автоматичними і динамічними тривалостями зберігання.
Уявімо, що ми хочемо виділити об'єкт з динамічною тривалістю зберігання. Тепер нам потрібно пам'ятати, щоб його видалити, що є незручним і схильним до помилок.
Чи не було б краще прив'язати його до об'єкта з автоматичною тривалістю зберігання, x
, так, щоб коли x
вийшов з області видимості, об'єкт з динамічною тривалістю зберігання був видалений...
Це, по суті, роль розумних вказівників unique
і shared
! Це також чудовий приклад принципу "Здобуття ресурсів є ініціалізацією" (RAII). Цей принцип говорить, що всі об'єкти з динамічною тривалістю зберігання повинні належати об'єктам з автоматичною тривалістю зберігання, так що коли останні виходять з області видимості, перші очищаються.
Хто володіє чим?
Ще однією важливою концепцією є "володіння".
Ми визначаємо ресурс як все, що потрібно отримати і потім звільнити. Наприклад, пам'ять, виділена в купі. "Володіння" — це концепція, що кожен ресурс повинен мати один або більше об'єктів, відповідальних за його життєвий цикл (якщо ви забули, перегляньте нашу попередню дефініцію). RAII — це підмножина принципу володіння, який також визначає тривалості зберігання.
Є два варіанти володіння: одиничне та спільне. В одиничному володінні один об'єкт відповідає за ресурс. Чудовим прикладом є unique_ptr
. Спільне володіння — коли кілька об'єктів відповідають за ресурс (наприклад, shared_ptr
).
Многопоточність та блокування
mutex
— це об'єкт, який запобігає одночасному доступу до спільного ресурсу кількома потоками. Наприклад, ми можемо захотіти відкрити файл, записати в нього деякі дані, а потім закрити його. Ми не хочемо, щоб кілька потоків одночасно працювали з файлом, тому використовуємо mutex.
У C++ ресурсом, який ми блокуємо, є сегмент коду, що працює з елементом, який вимагає синхронізації (наприклад, файл, база даних/мережеве з'єднання тощо).
Давайте продемонструємо це на прикладі.
Приклад базових mutex
Щось, що ви могли помітити, — це складність у необхідності розблокувати mutex наприкінці функції. Це можна полегшити за допомогою скопованих блокувань.
Це зручні обгортки RAII навколо mutex, що означає, що як тільки ми виходимо з області видимості, процес розблокування виконується автоматично.
void scopedLocksExample() {
// Вилучаємо незручність розблокування
// mutex
scoped_lock lock(example_mutex);
cout << "Це виклик номер " << i++ << ", інші потоки не можуть увійти!" << endl;
}
Ми не будемо занурюватися в точні деталі та моменти використання mutex (наприклад, чи слід блокувати на високому рівні, блокуючи більше коду з меншим числом mutex, чи навпаки), але цікаве введення можна знайти в статті Вікіпедії тут.
Підказки компілятора
Підказки компілятора — це спосіб, яким ви, як програміст, надаєте компілятору вказівки, як оптимально обробити ваш код.
Інлайн-функції — чудовий приклад цього. Вони пропонують компілятору замінити виклик функції на вміст функції. Це добре, оскільки зменшується накладні витрати (наприклад, нічого не додається в стек), але погано, оскільки це збільшує розмір програми (цей код копіюється в кількох місцях).
// Простий функція для подвоєння числа. Ми
// можемо розглядати це як зручну обгортку
// для спільного використання коду. Використовуючи інлайни, ми отримуємо
// багаторазове використання, але підвищену ефективність, оскільки кожен виклик
// не має накладних витрат.
inline int double(int x) {
return 2 * x;
}
Інлайн-функції чудово підходять, коли у вас є невеликі функції, такі як ті, що надають доступ до даних об'єкта.
Ключове слово — «пропозиція». Комп'ютер не завжди повинен робити те, що йому вказано в підказках! Однак, якщо ми впевнені, ми можемо примусити компілятор (майже завжди) зробити те, що ми хочемо, за допомогою __forceinline
.
Інша така підказка — constexpr
. Вона говорить компілятору, що значення змінної може бути обчислене на етапі компіляції. Це відрізняється від const
, яке може також бути обчислене під час виконання програми.
// Ми можемо мати змінну, обчислену на етапі компіляції
constexpr int j = 1;
// Або об'єкт (за умови, що аргументи також є constexpr)
constexpr MyObject my_object{const_expr_arguments};
// Або оголосити функцію, яка повертає constexpr (і також
// неявно є інлайновою)
constexpr int double(int x) {
return 2 * x;
}
Перевагою цього є менше навантаження на час виконання, але більш читабельний код.
Останні підказки компілятора, які ми розглянемо, є досить новими (C++20) і використовуються для вказівки компілятору, які кодові шляхи більш або менш імовірні/неімовірні.
Хорошим прикладом для цього може бути випадок помилки. Загалом, ми сподіваємося, що програма потрапить до випадку помилки лише за виняткових обставин, і ми можемо вказати це тут.
int get(int i) {
if(i < 0 || i > v.size()) {
[[unlikely]]
throw runtime_error("Усі панікують!");
} else {
[[likely]]
return v[i];
}
}
Висновок
Підсумовуючи, ми розглянули кілька цікавих аспектів, зокрема:
- Вибір компілятора
- Управління пам'яттю
- Володіння
- Многопоточність та блокування
- Підказки компілятора
Сподіваємося, що ця інформація стане корисним кроком на вашому шляху освоєння C++!
Перекладено з: A Crash Course in C++: Thinking in C++