C++ 3d.Комментарии

STL-контейнеры


Она явилась результатом целенаправленного поиска бескомпромиссно эффективных общих алгоритмов.

Вместе с тем, не стоит думать, что STL не содержит снижающих эффективность компромиссов. Очевидно, что специально написанный для решения конкретной проблемы код будет работать эффективнее, вопрос в том, насколько эффективнее? Например, если нам нужно просто сохранить в памяти заранее неизвестное количество элементов, а затем их последовательно использовать, то (односвязный) список будет наиболее адекватной структурой данных. Однако STL не содержит односвязных списков, как много мы на этом теряем?

Рассмотрим следующий пример: #include <stdio.h> #include <stdlib.h> #include <time.h> #include <list>

struct List { // односвязный список struct Data { int val; Data* next;

Data(int v, Data* n=0) : val(v), next(n) {} };

Data *head, *tail;

List() { head=tail=0; }

~List() { for (Data *ptr=head, *n; ptr; ptr=n) { // удаляем все элементы n=ptr->next; delete ptr; } }

void push_back(int v) // добавляем элемент { if (!head) head=tail=new Data(v); else tail=tail->next=new Data(v); } };

long Count, Var;

void f1() { List lst; for (int i=0; i<1000; i++) lst.push_back(i);

for (List::Data* ptr=lst.head; ptr; ptr=ptr->next) Var+=ptr->val; }

void f2() { typedef std::list<int> list_type;



list_type lst; for (int i=0; i<1000; i++) lst.push_back(i);

for (list_type::const_iterator ci=lst.begin(), cend=lst.end(); ci!=cend; ++ci) Var+=*ci; }

int main(int argc, char** argv) { if (argc>1) Count=atol(argv[1]);

clock_t c1,c2; { c1=clock();

for (long i=0; i<Count; i++) for (long j=0; j<1000; j++) f1();

c2=clock(); printf("f1(): %ld ths calls per %.1f sec\n", Count, double(c2-c1)/CLK_TCK); } { c1=clock();

for (long i=0; i<Count; i++) for (long j=0; j<1000; j++) f2();

c2=clock(); printf("f2(): %ld ths calls per %.1f sec\n", Count, double(c2-c1)/CLK_TCK); } }

В нем f1() использует определенный нами List: вставляет 1000 элементов, а затем проходит по списку.


Т.к. STL использует собственный распределитель памяти (вскоре вы увидите, что делает она это совсем не напрасно), то то же самое следует попробовать и нам: struct List { // односвязный список struct Data {

// ...

// для собственного распределения памяти static Data* free; static void allocate(); void* operator new(size_t); void operator delete(void*, size_t); };

// ...

};

List::Data* List::Data::free;

void List::Data::allocate() { const int sz=100; // выделяем блоки по sz элементов free=reinterpret_cast<Data*>(new char[sz*sizeof(Data)]);

// сцепляем свободные элементы for (int i=0; i<sz-1; i++) free[i].next=free+i+1; free[sz-1].next=0; }

inline void* List::Data::operator new(size_t) { if (!free) allocate();

Data* ptr=free; free=free->next;

return ptr; }

inline void List::Data::operator delete(void* dl, size_t) { // добавляем в начало списка свободных элементов Data* ptr=static_cast<Data*>(dl); ptr->next=free; free=ptr; }

Обратите внимание, что в данном примере наш распределитель памяти не возвращает полученную память системе. Но это не memory leak (утечка памяти) -- это memory pool, т.е. заранее выделенный запас памяти для быстрого последующего использования. На первый взгляд, разница между memory leak и memory pool может показаться слишком тонкой, но она есть: дело в том, что в первом случае потребление памяти не ограничено, вплоть до полного ее исчерпания, а во втором оно никогда не превысит реально затребованного программой объема плюс некоторая дельта, не превосходящая размер выделяемого блока.

И еще, наш распределитель содержит очень серьезную ошибку -- он неправильно обрабатывает удаление нуля (NULL-указателя). В нашем примере это не имеет значения, но в реальном коде вы обязаны это учесть, т.е.: inline void List::Data::operator delete(void* dl, size_t) { if (!dl) return; // игнорируем NULL

// добавляем в начало списка свободных элементов Data* ptr=static_cast<Data*>(dl); ptr->next=free; free=ptr; }

И, для чистоты эксперимента, в заключение попробуем двусвязный список -- его по праву можно назвать вручную написанной альтернативой std::list<int>: struct DList { // двусвязный список struct Data { int val; Data *prev, *next;



Data(int v, Data* p=0, Data* n=0) : val(v), prev(p), next(n) {}

// для собственного распределения памяти static Data* free; static void allocate(); void* operator new(size_t); void operator delete(void*, size_t); };

Data *head, *tail;

DList() { head=tail=0; }

~DList() { for (Data *ptr=head, *n; ptr; ptr=n) { // удаляем все элементы n=ptr->next; delete ptr; } }

void push_back(int v) // добавляем элемент { if (!head) head=tail=new Data(v); else tail=tail->next=new Data(v, tail); } };

Итак, все готово, и можно приступать к тестированию. Данные три теста я попробовал на двух разных компиляторах, вот результат:

односвязный односвязный с собственным
распределителем памяти
двусвязный с собственным
распределителем памяти
f1() f2() f1() f2() f1() f2()
реализация 1 9.6 12.1 1.1 12.1 1.3 12.1
реализация 2 20.2 2.5 1.8 2.5 1.9 2.5
И что же мы здесь видим?


    добавление собственного распределителя памяти существенно ускоряет программу (в нашем примере, от 9 до 11 раз). Также замечу, что это приводит и к существенной экономии памяти (как правило, для борьбы с внутренней фрагментацией стандартные распределители памяти выделяют отрезки памяти, кратные некоторому числу, например 16 байт; плюс память теряется на структурах, поддерживающих список выделенных блоков. Т.е. желательно выделять память большими блоками и как можно реже.

    в зависимости от качества компилятора/реализации STL, использование STL может быть как почти приемлемым (замедление 30% для двусвязного и 40% для односвязного списков), так и неприемлемым совершенно (замедление от 9 до 11 раз!)

    двусвязный список является вполне приемлемой альтернативой односвязному (замедление 5 - 20%)

    Итак, наши измерения показывают, что бескомпромиссная эффективность STL является мифом. Даже более того, если вы используете недостаточно хороший оптимизатор, то использование STL вызовет существенные накладные расходы.


    Содержание раздела