Я розповім історію про те, над чим я працював, як я натрапив на цю проблему та як її вирішив; але якщо ви з тих, хто пропускає все і йде прямо до частини 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…