Можливо, ви не знаєте, як правильно написати контейнер у C++.

Я розповім історію про те, над чим я працював, як я натрапив на цю проблему та як її вирішив; але якщо ви з тих, хто пропускає все і йде прямо до частини TL;DR, пропустіть до відповідної частини.

Отже, я працював над структурою даних ArrayList, що була завданням для мого курсу з ООП. Я повинен був розробити шаблонний клас, подібний до вектора, і потім використовувати його для зберігання типу std::shared_ptr.

Спочатку я придумав такий план:

template <typename T>  
class ArrayList {  
 private:  
 T* m_data;  
 std::size_t m_sz, m_cap;  

 public:  
 ArrayList(std::size_t initial_cap = 2)  
 : m_data(new T[std::max(2UL, initial_cap)]),  
 m_sz(0),  
 m_cap(std::max(2UL, initial_cap)) {}  

 void insert(const T& data) {  
 // _expand(); Одна з функцій, яку я реалізував.  

 m_data[m_sz++] =  
 data; // Виникає кілька проблем.  
 // 1. Можливо, копіювання присвоєння типу T не реалізовано.  
 // 2. Дані спочатку конструюються за замовчуванням,  
 // а потім їх конструюють знову.  
 }  

 ~ArrayList() {  
 if (m_data != nullptr) {  
 delete[] m_data;  
 }  

 m_sz = 0;  
 m_cap = 0;  
 m_data = nullptr;  
 }  
};

Це працювало добре для типів, таких як int, і проходило всі тести. Але проблема виникла, коли я використав тип, у якого був видалений конструктор за замовчуванням, або не реалізований, або явно:

class NoDefault {  
 private:  
 int m_dummy;  

 public:  
 NoDefault(int x) : m_dummy(x) {}  
};  

// ... Пізніше в main:  
ArrayList<NoDefault> some_data;

Пишучи цей фрагмент коду, ми отримуємо дивну помилку:

./main.cpp: In instantiation of ‘ArrayList::ArrayList(std::size_t) [with T = NoDefault; std::size_t = long unsigned int]’:  
./main.cpp:45:24: required from here  
 45 | ArrayList some_data;  
 | ^``````~~  
./main.cpp:11:16: error: no matching function for call to ‘NoDefault::NoDefault()’  
 11 | : m_data(new T[std::max(2UL, initial_cap)]),  
 | ^``````````````````````````````~~  
./main.cpp:41:3: note: candidate: ‘NoDefault::NoDefault(int)’  
 41 | NoDefault(int x) : m_dummy(x) {}  
 | ^``````~~  
./main.cpp:41:3: note: candidate expects 1 argument, 0 provided  
./main.cpp:36:7: note: candidate: ‘constexpr NoDefault::NoDefault(const NoDefault&)’  
 36 | class NoDefault {  
 | ^``````~~  
./main.cpp:36:7: note: candidate expects 1 argument, 0 provided  
./main.cpp:36:7: note: candidate: ‘constexpr NoDefault::NoDefault(NoDefault&&)’  
 ./main.cpp:36:7: note: candidate expects 1 argument, 0 provided

Тому я кілька годин шукав інформацію в інтернеті і дізнався про два чудових інструменти: оператор розміщеного new, і оператор ::new.

Оператор розміщеного new

Припустимо, ви хочете ініціалізувати пам'ять, не виділяючи її знову. Наприклад, припустимо, що ви хочете знову використати один з конструкторів, не використовуючи конструктор копії. Один з інструментів називається оператором “розміщеного new”. Він отримує пам'ять за вказаною адресою, ініціалізує її з певним конструктором, який ми визначаємо, і повертає вказівник на це місце зберігання. Ось приклад його використання:

class Random {  
 private:  
 int m_seed;  

 public:  
 Random(int seed) : m_seed(seed) {}  

 int gen() {  
 return m_seed = (m_seed * 1000 + 21) % static_cast<int>(1e9 + 7);  
 }  

 ~Random() { std::cout << "Destructor called" << std::endl; }  
};  

// ... Пізніше в main  
Random* rand =  
 new Random(0); // Поки що вважаємо, що ця пам'ять не ініціалізована.  

// Тепер ми хочемо ініціалізувати її. Використовуємо:  
rand = new (rand) Random(42);  
// І тепер її можна безпечно використовувати.  

delete rand;

Вивід:

Destructor called

Це означає, що оператор розміщеного new не викликає деструктор.

Як бачите, він дуже корисний, коли в нас є частина пам'яті, яка ще не ініціалізована.

Оператор ::operator new

Наступне, про що я хочу поговорити, — це оператор ::operator new. Його можна використовувати, як malloc, але для C++.
Припустимо, ми хочемо виділити шматок пам'яті як сирі байти і робити з ними що завгодно, не обмежуючи все класом, не пишучи конструкторів за замовчуванням тощо. Тоді ми можемо використовувати ::operator new для виділення цієї пам'яті і, можливо, привести її до якогось типу. ::operator new використовується для виділення пам'яті типу void*.

::operator new приймає один (або, як ви побачите, насправді два) аргументи — це кількість байтів, які потрібно виділити:

void* bytes_and_beats = ::operator new(16); // Виділяє 16 байтів.

Потім ми можемо використовувати ::operator delete, щоб звільнити цю пам'ять:

::operator delete(bytes_and_beats);

Наприклад:

int* x = static_cast<int*>(::operator new(sizeof(int)));  
*x = 3;  
std::cout << *x << std::endl;  
// delete x;  
// Або  
// ::operator delete(static_cast<int*>(x));

Перевага ::operator new в тому, що він нічого не ініціалізує. Таким чином, ми можемо виділити сире місце в пам'яті і відкласти ініціалізацію.

Тож як ці інструменти корисні в моєму класі ArrayList?

Як я казав раніше, я хотів мати можливість reserve пам'яті і ініціалізувати її пізніше для підвищення ефективності. Так само, як у std::vector. Або навіть реалізувати метод emplace_back, щоб ініціалізувати об'єкт безпосередньо на місці. Це те, що робить std::vector::emplace_back:

Додає новий елемент до кінця контейнера. Елемент конструюється через std::allocator_traits::construct, який зазвичай використовує розміщений new, щоб побудувати елемент безпосередньо в місці, наданому контейнером.

Отже, підказка полягає в тому, щоб спочатку використовувати ::operator new для виділення деякої сирої пам'яті, привести її до типу T*, а потім ініціалізувати за допомогою оператора розміщеного new; або принаймні, це те, що я вважав правильним.

Мій код став виглядати так:

template <typename T>  
class ArrayList {  
 private:  
 T* m_data;  
 std::size_t m_sz, m_cap;  

 public:  
 ArrayList(std::size_t initial_cap = 2)  
 : m_data(static_cast<T*>(  
 ::operator new(std::max(2UL, initial_cap) * sizeof(T)))),  
 m_sz(0),  
 m_cap(std::max(2UL, initial_cap)) {}  

 template <typename... Args>  
 void insert(  
 Args&&... args) { // Цього разу я використовую аргументи для конструктора  
 // _expand(); Одна з функцій, яку я реалізував.  

 new (m_data + m_sz)  
 T(std::forward<Args>(args)...); // Подумайте про це як про чорну магію поки що.  
 m_sz++;  
 }  

 ~ArrayList() {  
 if (m_data != nullptr) {  
 for (std::size_t i = 0; i < m_sz; i++) {  
 m_data[i].~T(); // Цього разу ми повинні вручну викликати деструктор.  
 }  

 ::operator delete(m_data);  
 }  

 m_sz = 0;  
 m_cap = 0;  
 m_data = nullptr;  
 }  
};

Це поступово стає складнішим, але не надмірно складним. Я вважаю, що все це було необхідно.

Тепер цей код працює без проблем:

ArrayList<int> x;  
x.insert(2);  
x.insert(3);

Я розслаблявся і думав, що все в порядку, мій код пройшов всі тести, які я написав. І ось одного дня, коли я повинен був представити свій код. Чому? Тому що я написав деякий простий код, і бац — тепер я отримую помилки, які я навіть не можу прочитати!

Це не так просто відтворити помилку, оскільки вони були справді випадковими. Але в загальному, помилки виглядали більш-менш ось так:

Access to misaligned memory alignas(64) …

Після цього я дізнався, що деякі типи мають вирівнювання, і щоб використовувати ці типи, я повинен був використати ::operator new і ::operator delete таким чином:

m_data(static_cast<T*>(  
 ::operator new(std::max(2UL, initial_cap) * sizeof(T),  
 alignof(T)))),

І звільняти пам'ять ось так:

::operator delete(m_data, alignof(T));

Без цього неможливо використовувати вирівняні типи, такі як std::shared_ptr.

TL;DR

Іноді нам потрібно виділити пам'ять на купі і не ініціалізувати її (не викликати конструктор). Подумайте про якусь структуру даних Container, яка отримує тип T і створює масив цього типу.
Тепер припустимо, що ми хочемо мати метод Container::reserve, який резервує деяку пам'ять для нас, щоб пізніше заповнити її даними.

Очевидним рішенням є використання malloc у поєднанні з оператором розміщеного new; це працює добре; ПОКИ ВИ НЕ ЗАЗНАЧАЄТЕ ПРО ПОПЕРЕДЖЕННЯ І САНІТАЙЗЕРИ. Це тому, що ви не можете використовувати delete для пам'яті, виділеної за допомогою malloc. Навіть з оператором розміщеного new.

Рішення

Один стандартний спосіб зробити це — це виділити блок пам'яті за допомогою оператора ::operator new, який називається ”Global new” оператор. Цей оператор виділяє сире місце в пам'яті без ініціалізації, що дуже схоже на malloc, але він зручний через свою пару — ::delete, ”Global delete” оператор. Цей оператор більше підходить для C++ і є більш схильним до помилок.

Але є один нюанс. Для типів, які потребують деякого вирівнювання (ми використовували alignas() для них), цей ::operator new не враховує вирівнювання.

Однак C++17 ввів новий оператор ::operator new, який також враховує вирівнювання.

Наприкінці хочу вибачитися за погане форматування, оскільки це мій перший раз на Medium, і я думав, що він підтримує формат MarkDown, але, схоже, це не так. Сподіваюся, вам сподобалося читати це.

Перекладено з: You probably don’t know how to write a proper C++ container…

Leave a Reply

Your email address will not be published. Required fields are marked *