Соз­давая объ­ект за объ­ектом, мы час­то не обра­щаем вни­мания на такую «мелочь», как динами­чес­кое выделе­ние памяти. Нарав­не с копиро­вани­ем и сери­али­заци­ей, выделе­ние памяти из кучи через new пос­тепен­но сво­дит на нет пре­иму­щес­тва C++ в ско­рос­ти. Чем интенсив­нее мы поль­зуем­ся завет­ным new, тем слож­нее ста­новит­ся при­ложе­нию, пос­коль­ку память кон­чает­ся, фраг­менти­рует­ся и вся­чес­ки стре­мит­ся уте­кать. Эта участь уже пос­тигла удоб­ные, но неяв­но опас­ные для про­изво­дитель­нос­ти кон­тей­неры STL: vector, string, deque, map. Осо­бен­но обид­но терять ско­рость на выделе­нии неболь­ших объ­ектов в боль­ших количес­твах. Но есть спо­соб обра­ботать раз­мещение памяти таких объ­ектов на сте­ке, при этом скры­вая детали реали­зации в спе­циаль­ный класс дан­ных. В этом нам поможет механизм раз­меща­юще­го new — неп­ревзой­ден­ный спо­соб опти­миза­ции при­ложе­ния, пол­ного час­тых и мел­ких выделе­ний памяти из кучи.

В прош­лом уро­ке мы делали порази­тель­ные вещи: работа­ли с объ­екта­ми C++ как с кон­тей­нерами, содер­жащими зна­чения типа, вычис­ленно­го на эта­пе выпол­нения и запол­ненно­го динами­чес­ки. Мы активно исполь­зовали надс­трой­ку Copy-on-Write над std::shared_ptr, которым ссы­лались на реаль­ный тип дан­ных, при запол­нении объ­екта. При этом под­разуме­валось, что память под любую ини­циали­зацию дан­ных мы будем выделять так­же динами­чес­ки, вызывая new каж­дый раз, как толь­ко нам понадо­бят­ся новые дан­ные про­изволь­ного типа.

Та­кой под­ход име­ет свои пре­иму­щес­тва. Дан­ные мож­но раз­делять меж­ду нес­коль­кими объ­екта­ми, откла­дывая копиро­вание. Мож­но, в прин­ципе, ничего не знать заранее о типе дан­ных. Одна­ко есть у это­го метода и ряд недос­татков, из‑за которо­го Copy-on-Write исполь­зует­ся, как пра­вило, для объ­ектов, потен­циаль­но доволь­но боль­ших.

Пер­вый недос­таток выяс­няет­ся сра­зу же. Мас­совое динами­чес­кое выделе­ние памяти серь­езно замед­ляет выпол­нение прог­раммы, осо­бен­но мас­совое неяв­ное выделе­ние памяти через new. Да, я в кур­се и про std::string, и про std::vector, которые зачас­тую, не спра­шивая прог­раммис­та, начина­ют перерас­пре­делять память, вызывая один new за дру­гим (при­чем про перераз­мещение дан­ных в std::vector мы еще погово­рим). Хороший спе­циалист в C++ раз­работ­ке всег­да зна­ет об этих забав­ных осо­бен­ностях стан­дар­тных кон­тей­неров и понима­ет, как избе­жать лиш­них зат­рат на выделе­ние новых сег­ментов памяти. Чем всег­да был хорош чис­тый си, так это имен­но тем, что любая работа с памятью выпол­нялась проз­рачно, в C++ всег­да нуж­но дер­жать в голове целый ряд слу­чаев неяв­ной работы с памятью.

Вто­рой недос­таток явля­ется следс­тви­ем пер­вого. Час­тое выделе­ние неболь­ших сег­ментов памяти в боль­ших количес­твах при­ведет к жут­кой фраг­мента­ции памяти и невоз­можнос­ти выделить даже доволь­но неболь­шой блок памяти еди­ным кус­ком, нап­ример для ини­циали­зации того же std::vector или std::string. В резуль­тате мы получа­ем bad_alloc безо вся­ких видимых при­чин. Памяти нам­ного боль­ше, чем нуж­но, а выделить неп­рерыв­ный блок даже неболь­шого раз­мера в усло­виях силь­но фраг­менти­рован­ной памяти не получит­ся.

Та­ким обра­зом, для неболь­ших объ­ектов, срав­нимых с int64_t, которые мож­но спо­кой­но раз­мещать на сте­ке, мож­но и нуж­но исполь­зовать дру­гую тех­нику обра­бот­ки дан­ных. Такие объ­екты мож­но переда­вать по зна­чению, мож­но сколь­ко угод­но раз копиро­вать, не откла­дывая до пер­вого изме­нения, пос­коль­ку баналь­но копиру­ется один‑два регис­тра.

При этом мы не дол­жны отхо­дить от прак­тики объ­явле­ния деталей дан­ных в реали­зации. Но кое‑чем при­дет­ся пожер­тво­вать: нам нуж­но будет заранее знать точ­ный раз­мер дан­ных в бай­тах. Он пот­ребу­ется для того, что­бы вмес­те с обыч­ным ука­зате­лем на дан­ные дер­жать в клас­се буфер для раз­мещения дан­ных объ­екта. Теперь под­робнее.

 

Первый класс

Внеш­не поч­ти ничего не меня­ется. Все тот же класс, обес­печива­ющий API объ­ектов. Класс содер­жит ссыл­ку на дан­ные, класс которых объ­явлен через forward declaration и будет вынесен в детали реали­зации. Из‑за это­го поле клас­са нель­зя объ­явить объ­ектом дан­ного типа, одна­ко на тип дан­ных мож­но сос­лать­ся прос­тым ука­зате­лем и заранее завес­ти буфер для хра­нения дан­ных объ­екта в самом же объ­екте. Если объ­ект будет соз­дан, нап­ример, на сте­ке, то и все дан­ные будут хра­нить­ся на сте­ке как часть объ­екта. Теперь рас­смот­рим при­мер, что­бы все вста­ло на свои мес­та:

class object
{
public:
...
protected:
// Объявление класса данных
class data;
// Заранее известное количество байтов под данные
static const size_t max_data_size = N;
private:
// Указатель на данные
data* m_data;
// Буфер памяти, где будут храниться данные
char m_buffer[max_data_size];
};

В этом фраг­менте кода мы про­дол­жаем иде­оло­гию сок­рытия дан­ных в реали­зации, все, что мы зна­ем о дан­ных клас­са, — это имя клас­са и наличие ука­зате­ля на дан­ные. Одна­ко теперь у нас есть воз­можность не лезть за памятью в heap. Класс в тер­миноло­гии C++ все так же хра­нит дан­ные в виде сво­их полей. По сути, дан­ные раз­местят­ся в буфере m_buffer, память под который выделе­на уже при соз­дании клас­са. Оста­лось лишь объ­яснить детали, как раз­местить дан­ные в буфер байт.

 

Размещающий new

Как пра­вило, нем­ногие вспо­мина­ют про такое полез­ное свой­ство опе­рато­ра new, как воз­можность ука­зать готовую область памяти для раз­мещения соз­дава­емо­го объ­екта. Все, что нам пот­ребу­ется, — это написать new(m_buffer) для соз­дания любого типа объ­екта в выделен­ном буфере. Зву­чит прос­то, одна­ко нуж­но пом­нить, что пла­тим мы высокую цену: заранее ука­зывая мак­сималь­ный раз­мер буфера. Мало того, раз­мер буфера попада­ет в заголо­воч­ный файл и явно учас­тву­ет в объ­явле­нии API.

За­то мы выиг­рыва­ем в ско­рос­ти. Если, выделяя дан­ные в куче на каж­дую ини­циали­зацию, мы рис­куем отстать от Java, то, раз­мещая все дан­ные в сте­ке, мы име­ем ско­рость чис­того си, недос­тижимую ско­рость для поч­ти любого язы­ка высоко­го уров­ня, кро­ме C++. При этом уро­вень абс­трак­ции край­не высок, мы выс­тра­иваем API на обыч­ных объ­ектах C++, скры­вая детали реали­зации. Единс­твен­ное огра­ниче­ние — раз­мер, который мы зада­ем; мы уже не можем зап­росто менять в реали­зации набор полей у клас­са дан­ных, всег­да нуж­но пом­нить о раз­мере.

Ма­ло того, нам необ­ходимо про­верять раз­мер дан­ных, опи­сан­ных в реали­зации, на соот­ветс­твие с ука­зан­ным в заголо­воч­ном фай­ле. Прос­то потому, что сбор­ка биб­лиоте­ки может рас­ходить­ся с вер­сией заголо­воч­ных фай­лов, нап­ример при получе­нии из раз­личных источни­ков. Рас­смот­рим при­мер, как дол­жна выг­лядеть подоб­ная про­вер­ка, как и соз­дание объ­екта в под­готов­ленной памяти раз­меща­ющим new.

object::object()
: m_data(new(m_buffer) object::data)
{
static_assert(sizeof(object::data) <= max_data_size, "...");
}

Здесь static_assert фак­тичес­ки выпол­нится на эта­пе ком­пиляции, поэто­му ини­циали­зация m_data будет выпол­нена, толь­ко если для object::data дос­таточ­но памяти в буфере m_buffer. Ана­логич­но у клас­са‑нас­ледни­ка, нап­ример flower, клас­са object дан­ные так­же не дол­жны пре­вышать задан­ную план­ку, пос­коль­ку дан­ные мы хра­ним в реали­зации базово­го клас­са.

flower::flower(std::string const& name)
: object(new(get_buffer()) flower::data(name))
{
static_assert(sizeof(flower::data) < max_data_size, "..." );
}

Оче­вид­но, что для это­го нужен protected-метод get_buffer() для получе­ния адре­са m_buffer в базовом клас­се, а так­же protected-конс­трук­тор object от object::data*. Так же, как и в прош­лом выпус­ке, мы нас­леду­ем дан­ные нас­ледни­ков от дан­ных базово­го клас­са, поэто­му flower::data* сов­местим с object::data. Для безопас­ности сто­ит в базовый конс­трук­тор от object::data добавить про­вер­ку на то, что передан адрес имен­но заранее выделен­ного буфера:

object::object(object::data* data_ptr)
{
if (static_cast<void*>(data_ptr) != static_cast<void*>(m_buffer))
throw some_exception(...);
m_data = data_ptr;
}

В резуль­тате, как и рань­ше, мы име­ем воз­можность эму­лиро­вать динами­чес­кую типиза­цию, работая с обыч­ными объ­екта­ми клас­сов. Нап­ример, так:

object rose = flower("rose");
 

Объекты с данными большого размера

Ос­талось выяс­нить, что делать с объ­екта­ми, чей раз­мер дан­ных выходит за рам­ки обоз­начен­ного мак­симума. На самом деле и здесь все доволь­но прос­то. Дос­таточ­но, что­бы в лимит впи­сывал­ся раз­мер copy_on_write<data::impl>, который по сути явля­ется надс­трой­кой над std::shared_ptr<data::impl>, где impl — реали­зация клас­са дан­ных про­изволь­ного раз­мера. Пос­коль­ку раз­мер std::shared_ptr<data::impl> не зависит от раз­мера самих объ­ектов клас­са data::impl, мы получа­ем уни­вер­саль­ный спо­соб хра­нения дан­ных с перехо­дом от хра­нения по зна­чению к хра­нению по ссыл­ке.

class huge
{
public:
...
protected:
class data;
};
class huge::data
{
public:
...
protected:
class impl;
private:
copy_on_write<impl> m_impl;
};

Од­нако отвле­чем­ся от решения проб­лемы еди­ного API для объ­ектов с динами­чес­кой типиза­цией и рас­смот­рим дру­гой при­мер опти­миза­ции через раз­меща­ющий new.

copy_on_write::flashback

Ес­ли кто‑то про­пус­тил прош­лый выпуск, то класс copy_on_write — это шаб­лонный класс для хра­нения дан­ных с опти­миза­цией копиро­вания. Эму­лируя ука­затель, этот класс име­ет хит­рую перег­рузку operator -> для const и non-const слу­чаев. При копиро­вании объ­ектов мы ссы­лаем­ся на одни и те же дан­ные, не вызывая дорогос­тояще­го копиро­вания. Одна­ко, как толь­ко мы вызыва­ем некон­стантный метод клас­са дан­ных, потен­циаль­но изме­няющий дан­ные, мы отцепля­ем для текуще­го объ­екта свою копию дан­ных. Упро­щен­но реали­зация выг­лядит при­мер­но так:

template <typename impl_type>
class copy_on_write
{
public:
copy_on_write(impl_type* pimpl)
: m_pimpl(pimpl) {
}
impl_type* operator -> () {
if (!m_pimpl.unique())
m_pimpl.reset(new impl_type(*m_pimpl));
return m_pimpl.get();
}
impl_type const* operator -> () const {
return m_pimpl.get();
}
private:
std::shared_ptr<impl_type> m_pimpl;
};

Та­ким обра­зом, при выборе мак­сималь­ного раз­мера дан­ных для встро­енно­го буфера сто­ит учесть раз­мер клас­са, содер­жащего copy_on_write в качес­тве поля.

 

Поля выборки данных

Са­мый мощ­ный спо­соб опти­миза­ции через раз­меща­ющий new — это поля записей выбор­ки в резуль­тате SQL-зап­роса. Выбор­ка зап­рашива­ет набор дан­ных самых раз­нооб­разных типов, от целочис­ленных и вещес­твен­ных до строк и мас­сивов. Хотя сами дан­ные получа­ются динами­чес­ки и типы полей, получен­ные со сто­роны базы дан­ных, при­ходит­ся ини­циали­зиро­вать с эму­ляци­ей динами­чес­кой типиза­ции, но зато все записи содер­жат один и тот же набор типов полей, по которо­му мож­но опре­делить общий раз­мер дан­ных для каж­дой записи. Это поз­воля­ет нам выделить память под поля записи лишь однажды, вычис­лив раз­мер по типам полей, вхо­дящим в каж­дую запись выбор­ки. Мож­но так­же выделить память однажды для всех записей еди­ным бло­ком, одна­ко, как пра­вило, пос­ле выбор­ки над запися­ми про­изво­дят все­воз­можные опе­рации, в том чис­ле филь­труя и сор­тируя их, поэто­му сами записи име­ет смысл опи­сать в виде Copy-on-Write объ­ектов для удобс­тва пос­леду­ющих опе­раций. Выделять же для каж­дого поля память из кучи неоп­равдан­но дорого.

Так будет выг­лядеть наш класс запись, если упростить объ­явле­ние и исполь­зовать copy_on_write нап­рямую от клас­са дан­ных:

class record
{
public:
record(std::vector<field::type> const& types);
...
protected:
class data;
private:
copy_on_write<data> m_data;
};
class record::data
{
public:
data(std::vector<field::type> const& types);
...
private:
std::vector<char> m_buffer;
std::vector<field*> m_fields;
};

Здесь для упро­щения пояс­нения вве­ден век­тор типов полей std::vector<field::type>, мас­сив enum-зна­чений. На самом деле этот мас­сив сле­дует наб­рать из аргу­мен­тов через boost::fusion либо, исполь­зуя Boost.Preprocessor, наб­рать мас­сив из обоб­щенных объ­ектов типа object от любого типа аргу­мен­тов. Нам сей­час важен сам механизм однократ­ного выделе­ния памяти из кучи для каж­дой записи.

record::data::data(std::vector<field::type> const& types)
: m_buffer(field::calc_size(types)),
m_fields(types.size())
{
size_t offset = 0;
std::transform(types.begin(), types.end(), m_fields.begin(),
[&offset](field::type type, field*& field_ptr) {
field_ptr = new(m_buffer + offset) field(type);
offset += field::size(type);
}
);
}

где field::size вычис­ляет раз­мер дан­ных по передан­ному ield::type, а field::calc_size вычис­ляет уже сум­марный раз­мер, необ­ходимый под весь набор типов записи, передан­ный как std::vector<field::type>.

По­ле field реали­зует­ся ана­логич­но типу object, по сути кон­тей­нер динами­чес­кого содер­жимого. Боль­шая часть типов: int64_t, bool, double — ска­ляры и хра­нят­ся по зна­чению. Тип std::string так­же может хра­нить­ся по зна­чению, одна­ко сто­ит учи­тывать то, что поч­ти навер­няка дан­ные стро­ки будут хра­нить­ся в куче и выделять­ся динами­чес­ки. Если хочет­ся под­держать некий varchar опре­делен­ной дли­ны, то здесь, ско­рее все­го, нужен будет свой тип copy_on_write с мас­сивом сим­волов фик­сирован­ной дли­ны.

Раз­личные типы полей ана­логич­ны раз­личным типам объ­ектов, унас­ледован­ных от клас­са object. Мож­но даже не исполь­зовать enum, а завязать­ся нап­рямую на типы, но, как пра­вило, раз­бор резуль­тата SQL-зап­роса вле­чет за собой десери­али­зацию пакета бай­тов с дан­ными, где все типы полей заранее извес­тны, поэто­му enum для удобс­тва здесь никаких огра­ниче­ний не вле­чет. Тем более что метап­рограм­мирова­ние — сте­зя не для сла­бонер­вных, и MPL и Boost.Fusion мы здесь рас­смат­ривать не будем.

Ос­талось зат­ронуть пос­ледний важ­ный аспект исполь­зования раз­меща­юще­го new — пул одно­тип­ных объ­ектов в C++.

 

Пул однотипных объектов

Как и преж­де, мы опти­мизи­руем динами­чес­кое выделе­ние памяти. Что такое пул объ­ектов? Это заранее выделя­емый боль­шим ско­пом мас­сив загото­вок для ини­циали­зации опре­делен­ного типа. В некото­ром смыс­ле record выше был пулом для объ­ектов field. Так­же ты навер­няка встре­чал пул объ­ектов, если работал с высоко­уров­невыми язы­ками (C#, Python, Java), ведь для выделе­ния новых объ­ектов они исполь­зуют заготов­ленные сег­менты памяти, в которых раз­меща­ют объ­екты, по сути тип object. Пос­ле того как один из объ­ектов пула ста­новит­ся не нужен, ины­ми сло­вами на него перес­тали ссы­лать­ся, он либо сра­зу деини­циали­зиру­ется, либо ждет сво­ей печаль­ной учас­ти в виде оче­ред­ного обхо­да Garbage Collector’а — сбор­щика мусора — спе­циаль­ного механиз­ма уда­ления бес­хозно­го доб­ра. Вооб­ще говоря, деини­циали­зация объ­ектов в пуле — его сла­бое мес­то. Зато мы получа­ем ско­рос­тное выделе­ние объ­ектов, как пра­вило либо уже ини­циали­зиро­ван­ных, либо под­готов­ленных для ини­циали­зации. Если делать на осно­ве нашего типа objectпол­ноцен­ный пул объ­ектов с деини­циали­заци­ей по счет­чику ссы­лок и с Garbage Collector’ом, то мы получим Java или Python. Если тебе пот­ребова­лось что‑то подоб­ное, может, не сто­ит городить ого­род и взять готовый язык со сбор­кой мусора? Одна­ко если для опти­миза­ции одно­тип­ных объ­ектов пот­ребова­лось выделить заранее боль­шой сег­мент памяти и задача дей­стви­тель­но тре­бует мас­совой ини­циали­зации боль­шого чис­ла объ­ектов с неким базовым клас­сом, то пул объ­ектов поз­волит избе­жать мас­сы динами­чес­ких выделе­ний памяти.

Что­бы разоб­рать­ся, нам пот­ребу­ется понят­ное прик­ладное объ­ясне­ние. Как нас­чет собс­твен­но выбор­ки в резуль­тате SQL-зап­роса с пулом для записей? Это поз­волит опти­мизи­ровать мас­су выделе­ний памяти для пос­тро­ения объ­ектов записей выбор­ки.

class selection
{
public:
selection(std::vector<field::type> const& types,
size_t row_count);
...
protected:
class data;
private:
copy_on_write<data> m_data;
};
class selection::data
{
public:
data(std::vector<field::type> const& types,
size_t row_count);
...
private:
std::vector<field::type> m_types;
std::vector<char> m_buffer;
std::vector<record> m_rows;
};
selection::data::data(std::vector<field::type> const& types,
size_t row_count)
: m_types(types)
{
if (!row_count) return;
m_rows.reserve(row_count);
size_t row_data_size = field::calc_size(types);
m_buffer.resize(row_count * row_data_size);
char* offset = m_buffer
for (size_t i = 0; i < row_count; ++i)
{
m_rows.push_back(record::inplace(offset, types));
offset += row_data_size;
}
}

Где record::inplace по сути соз­дает дан­ные записи не в куче, а по задан­ному адре­су.

record record::inplace(void* address,
std::vector<field::type> const& types)
{
return record(new(address) record::data(types));
}

Нам пот­ребу­ется конс­трук­тор record с ини­циали­заци­ей и спе­циаль­ный дес­трук­тор, об этом далее. Дан­ный вари­ант ини­циали­зации record дела­ет невоз­можным исполь­зование его в пре­дыду­щем вари­анте, то есть в виде клас­са, содер­жащего лишь поле copy_on_write. Мы не смо­жем, спо­кой­но понаде­явшись на динами­чес­кое выделе­ние дан­ных в куче, ворочать запися­ми как хотим. С дру­гой сто­роны, мы получа­ем сумас­шедший при­рост про­изво­дитель­нос­ти при боль­шом наборе дан­ных. Одна­ко есть в раз­меща­ющем new под­вох, о котором сле­дует знать.

 

Явный вызов деструктора

warning

Ес­ли кто‑то име­ет при­выч­ку не дочиты­вать до кон­ца либо читать по диаго­нали — очень зря. Про­пус­тив этот важ­ный раз­дел, мож­но нап­лодить memory leak’ов — уте­чек памяти, при­чем в боль­ших количес­твах.

Есть еще одно «но» при исполь­зовании раз­меща­юще­го new — при­дет­ся вызывать дес­трук­тор самим, вруч­ную, пос­коль­ку delete не сде­лает ров­ным сче­том ничего. Поэто­му класс, содер­жащий дан­ные, выделя­ющиеся в заранее под­готов­ленную память, дол­жен в дес­трук­торе явно выз­вать дес­трук­тор соз­данно­го в памяти клас­са. Так, дес­трук­тор клас­са object::~object дол­жен явно выз­вать дес­трук­тор object::data::~data, а дес­трук­тор record::data::~data дол­жен будет поз­вать целый ряд дес­трук­торов field::~field — по одно­му на каж­дое поле. Для того что­бы наг­лядно показать, как это дол­жно про­исхо­дить, я более деталь­но рас­пишу класс object.

class object
{
public:
object();
virtual ~object();
...
protected:
class data;
char* get_buffer();
object(data* derived_data);
static const size_t max_data_size = N;
private:
data* m_data;
char m_buffer[max_data_size];
};
object::object()
: m_data(new(m_buffer) data)
{
static_assert(sizeof(data) <= max_data_size, "...");
}
object::~object()
{
m_data->~data();
}

Пос­коль­ку дес­трук­тор у клас­са дан­ных дол­жен быть опи­сан как virtual, то и деини­циали­зация дан­ных прой­дет успешно, какой бы нас­ледник object::data ни исполь­зовал­ся.

Так­же нуж­но пере­опре­делить конс­трук­тор и опе­ратор копиро­вания, как и переме­щения, пос­коль­ку в отли­чие от слу­чая с copy_on_write, где нас устра­ивал авто­гене­риру­емый конс­трук­тор, здесь каж­дый объ­ект смот­рит на свою область дан­ных прос­тым ука­зате­лем. Поэто­му поп­равим поведе­ние по умол­чанию:

object::object(object const& another)
: m_buffer(max_data_size),
m_data(another.clone_data_at(m_buffer))
{
}
object& object::operator = (object const& another)
{
destruct_data(); // здесь нужно вызвать деструктор
m_data = another.clone_data_at(m_buffer);
return *this;
}
object::data* object::clone_data_at(void* address)
{
return m_data->clone_at(address);
}
// Этот метод должен быть перегружен
// для каждого наследуемого типа данных
object::data* object::data::clone_at(void* address)
{
return new(address) data(*this);
}
void object::destruct_data()
{
m_data->~data();
}

Здесь наш новый метод desctuct_data() так и про­сит­ся в дес­трук­тор object::~object. Раз про­сит­ся, зна­чит, там ему самое мес­то. Для конс­трук­тора и опе­рато­ра переме­щения поведе­ние похожее:

object::object(object&& another)
: m_data(another.move_data_to(m_buffer))
{
}
object& object::operator = (object const& another)
{
destruct_data(); // здесь нужно вызвать деструктор
m_data = another.move_data_to(m_buffer);
return *this;
}
object::data* object::move_data_to(void* address)
{
return m_data->move_to(address);
}
// Этот метод должен быть перегружен
// для каждого наследуемого типа данных
object::data* object::data::move_to(void* address)
{
return new(address) data(std::move(*this));
}
object::~object()
{
destruct_data();
}

Итак, опас­ность memory leak’ов лик­видиро­вана. Поль­зовате­ли тво­его API могут раз­рабаты­вать спо­кой­но.

 

Размещающий new против new в куче

Как ты уже успел заметить, клас­сы, исполь­зующие раз­меща­ющий new, нам­ного слож­нее в реали­зации. Каж­дый аспект исполь­зования клас­са, реали­зован­ного на тех­нике раз­мещения объ­екта в под­готов­ленную память, дол­жен всес­торон­не тес­тировать­ся. Слож­ность же обыч­ного new любого клас­са, как пра­вило, сво­дит­ся к обер­тке умно­го ука­зате­ля. В чем же тог­да выгода, если даже эму­ляция динами­чес­кой типиза­ции усложня­ется явным ука­зани­ем мак­сималь­ного раз­мера типа дан­ных?

Вы­года в ско­рос­ти. Сила C++ по срав­нению с более удоб­ными C#, Java и Python — в ско­рос­ти выпол­нения. Здесь мы дос­тига­ем наивыс­ших ско­рос­тей, пос­коль­ку не идем в кучу за новыми объ­екта­ми. И не замед­ляем при­ложе­ние в даль­нейшей пер­спек­тиве, избе­гая фраг­мента­ции памяти. Фраг­менти­рован­ная память как сыр: пол­на дырок, и в сум­ме раз­мер этих дырок поз­воля­ет запихать туда апель­син, но на самом деле апель­син не помес­тится ни в одну из дыр, каж­дая из них слиш­ком мала. Так и std::vector, как и std::string, тре­бующие сег­мент неп­рерыв­ной памяти, могут в один прек­расный момент получить std::bad_alloc при перерас­пре­деле­нии эле­мен­тов.

 

Размещающий new в стандартной библиотеке

Пом­нишь, я обе­щал тебе рас­ска­зать про раз­меща­ющий new в std::vector в начале статьи? Так вот, все конс­трук­торы эле­мен­тов в std::vector вызыва­ются в под­готов­ленной памяти. И так же активно для эле­мен­тов вызыва­ются дес­трук­торы. Это не прин­ципи­аль­но для век­торов от прос­тых POD-типов вро­де int или char, но если мы хотим выделить std::vector<custom>, при­чем custom обла­дает нет­риви­аль­ным и тяжелым конс­трук­тором по умол­чанию и не менее тяжелым конс­трук­тором копиро­вания, то мы получим мас­су неп­рият­ностей, если не будем знать, как работа­ет std::vector со сво­ими дан­ными.

Итак, что же про­исхо­дит, ког­да мы про­сим век­тор изме­нить раз­мер? Для начала век­тор смот­рит, что еще не зарезер­вировал нуж­ное чис­ло бай­тов (буфер век­тор всег­да выделя­ет с запасом), пос­ле чего выделя­ет новый буфер. Все сущес­тву­ющие эле­мен­ты перено­сят­ся в новый буфер конс­трук­тором переме­щения через раз­меща­ющий new по соот­ветс­тву­юще­му адре­су. В резуль­тате все эле­мен­ты сто­ят на сво­их мес­тах. Пос­ле чего век­тор добира­ет нуж­ное чис­ло эле­мен­тов в конец мас­сива, соз­давая каж­дый раз­меща­ющим new и конс­трук­тором по умол­чанию. Так же и в обратную сто­рону — умень­шение количес­тва эле­мен­тов вызовет дес­трук­торы «вруч­ную» при уда­лении эле­мен­тов.

В отли­чие от std::vector, кон­тей­нер std::string не занима­ется placement new прос­то потому, что хра­нит всег­да char, не нуж­дающий­ся в конс­трук­торах или дес­трук­торах. Зато целый ряд кон­тей­неров стан­дар­тной биб­лиоте­ки: deque, list, map и дру­гие шаб­лоны клас­сов для хра­нения про­изволь­ных дан­ных — активно исполь­зуют раз­меща­ющий new в сво­ей реали­зации.

Не нуж­но думать о раз­меща­ющем new как о чем‑то срод­ни хаку, это пол­ноцен­ная фун­кция язы­ка, поз­воля­ющая ини­циали­зиро­вать объ­ект конс­трук­тором по ука­зан­ной памяти. Эта опе­рация ана­логич­на ста­рому трю­ку язы­ка си, ког­да выделен­ный блок бай­тов объ­являлся ука­зате­лем на некий тип (обыч­но струк­туру) и далее работа с этим бло­ком памяти велась через API это­го типа.

 

Подводя черту

Ко­неч­но, уме­ние поль­зовать­ся раз­меща­ющим new там, где надо, и толь­ко тог­да, ког­да это дей­стви­тель­но нуж­но, эффектив­но и оправдан­но, при­ходит не сра­зу. Одни до пос­ледне­го отби­вают­ся вре­дом пред­варитель­ной опти­миза­ции, дру­гие, наобо­рот, толь­ко про­читав статью, бро­сят­ся встра­ивать new(m_buffer), где надо и где не надо. Со вре­менем и те и дру­гие при­ходят к золотой середи­не.

Суть метода прос­та — если есть воз­можность и необ­ходимость раз­местить объ­ект клас­са в заранее при­готов­ленную память, сде­лать это отно­ситель­но прос­то, если пом­нить пару нес­ложных пра­вил:

  • па­мять дол­жна жить все вре­мя, пока в ней живет объ­ект, если память пот­рут, то объ­ект нач­нет ссы­лать­ся на битый сег­мент памяти;
  • дес­трук­тор клас­са для объ­екта, выделен­ного раз­меща­ющим new, дол­жен быть выз­ван вруч­ную, это печаль­но, но delete не дела­ет с памятью по ука­зате­лю ров­ным сче­том ничего.

Все осталь­ное огра­ничи­вает­ся лишь акку­рат­ностью и без­гра­нич­ной фан­тази­ей раз­работ­чика. То есть тебя.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии