В случае распределителей STL бессмысленность определений типов для указателей и ссылок объясняется не техническими недостатками промежуточных объектов, а следующим фактом: Стандарт разрешает считать, что определение типа
pointer
любого распределителя является синонимом
T*
, а определение типа
reference
— синонимом
T&
. Да, все верно, разработчики библиотек могут игнорировать определения и использовать указатели и ссылки напрямую! Таким образом, даже если вам удастся написать распределитель с новыми определениями для указателей и ссылок, никакой пользы от этого не будет, поскольку используемая реализация STL запросто сможет эти определения проигнорировать. Интересно, не правда ли?
Пока вы не успели осмыслить этот пример странностей стандартизации, я приведу следующий. Распределители являются объектами,
из чего следует, что они могут обладать собственными функциями, вложенными типами и определениями типов (такими как
pointer
и
reference
). Однако в соответствии со Стандартом реализация STL может предполагать, что все однотипные объекты распределителей эквивалентны и почти всегда равны. Разумеется, это обстоятельство объяснялось вескими причинами. Рассмотрим следующий фрагмент:
Вспомните: при перемещении элементов из одного контейнера
list
в другой функцией
splice
данные не копируются. Стоит изменить значения нескольких указателей, и узлы, которые раньше находились в одном списке, оказываются в другом, поэтому операция врезки выполняется быстро и защищена от исключений. В приведенном примере узлы, ранее находившиеся в
L2
, после вызова
splice
перемещаются в
L1
.
Разумеется, при уничтожении контейнера
L1
должны быть уничтожены все его узлы (с освобождением занимаемой ими памяти). А поскольку контейнер теперь содержит узлы, ранее входившие в
L2
, распределитель памяти
L1
должен освободить память, ранее выделенную распределителем
L2
. Становится ясно, почему Стандарт разрешает программистам STL допускать эквивалентность однотипных распределителей. Это сделано для того, чтобы память, выделенная одним объектом-распределителем (таким как
L2
), могла безопасно освобождаться другим объектом-распределителем (таким как L1). Отсутствие подобного допущения привое бы к значительному усложнению реализации врезки и к снижению ее эффективности (кстати, операции врезки влияют и на другие компоненты STL, один из примеров приведен в совете 4).
Все это, конечно, хорошо, но чем больше размышляешь на эту тему, тем лучше понимаешь, какие жесткие ограничения накладывает предположение об эквивалентности однотипных распределителей. Из него следует, что переносимые объекты распределителей — то есть распределители памяти, правильно работающие в разных реализациях STL, — не могут обладать состоянием. Другими совами, это означает, что переносимые распределители не могут содержать нестатических переменных (по крайней мере таких, которые бы влияли на их работу). В частности, отсюда следует, что вы не сможете создать два распределителя
SpecialAllocator<int>
, выделяющих память из разных куч (heap). Такие распределители не были бы эквивалентными, и в некоторых реализациях STL попытки использования обоих распределителей привели бы к порче структур данных во время выполнения программы.
Обратите внимание: эта проблема возникает на стадии выполнения. Распределители, обладающие состоянием, компилируются вполне нормально — просто они не работают так, как предполагалось. За эквивалентностью всех однотипных распределителей вы должны следить сами. Не рассчитывайте на то, что компилятор предупредит о нарушении этого ограничения.
Справедливости ради стоит отметить, что сразу же за положением об эквивалентности однотипных распределителей памяти в Стандарт включен следующий текст: «…Авторам реализаций рекомендуется создавать библиотеки, которые… поддерживают неэквивалентные распределители. В таких реализациях… семантика контейнеров и алгоритмов для неэквивалентных экземпляров распределителей определяется самой реализацией».
Трогательное проявление заботы, однако пользователю STL, рассматривающему возможность создания нестандартного распределителя с состоянием, это не дает практически ничего. Этим положением можно воспользоваться только в том случае, если вы уверены в том,
что используемая реализация STL поддерживает неэквивалентные распределители, готовы потратить время на углубленное изучение документации, чтобы узнать, подходит ли вам «определяемое самой реализацией» поведение неэквивалентных распределителей, и вас не беспокоят проблемы с переносом кода в реализации STL, в которых эта возможность может отсутствовать. Короче говоря, это положение (для особо любознательных — абзац 5 раздела 20.1.5) лишь выражает некие благие намерения по поводу будущего распределителей. До тех пор пока эти благие намерения не воплотятся в жизнь, программисты, желающие обеспечить переносимость своих программ, должны ограничиваться распределителями без состояния.
Выше уже говорилось о том, что распределители обладают определенным сходством с оператором
new
— они тоже занимаются выделением физической памяти, но имеют другой интерфейс. Чтобы убедиться в этом, достаточно рассмотреть объявления стандартных форм
В обоих случаях передается параметр, определяющий объем выделяемой памяти, но в случае с оператором
new
указывается конкретный объем в байтах, а в случае с
allocator<T>::allocate
указывается количество объектов
T
, размещаемых в памяти. Например, на платформе, где
sizeof (int)==4
, при выделении памяти для одного числа
int
оператору
new
передается число 4, а
allocator<int>::allocate
— число 1. Для оператора
new
параметр относится к типу
size_t
, а для функции
allocate
— к типу
allocator<T>::size_type
, В обоих случаях это целочисленная величина без знака, причем
allocator<T>::size_type
обычно является простым определением типа для
size_t
. В этом несоответствии нет ничего страшного, однако разные правила передачи параметров оператору
new
и
allocator<T>::allocate
усложняют использование готовых пользовательских версий new в разработке нестандартных распределителей.
Оператор new отличается от
allocator<T>::allocate
и типом возвращаемого значения. Оператор
new
возвращает
void*
, традиционный способ представления указателя на неинициализированную память в C++. Функция
allocator<T>::allocate
возвращает
T*
(через определение типа
pointer
), что не только нетрадиционно, но и отдает мошенничеством. Указатель, возвращаемый
allocator<T>::allocate
, не может указывать на объект
T
, поскольку этот объект еще не был сконструирован! STL косвенно предполагает, что сторона, вызывающая
allocator<T>::allocate
, сконструирует в полученной памяти один или несколько объектов
T
(вероятно, посредством
allocator<T>::construct
,
uninitialized_fill
или
raw_storage_iterator
), хотя в случае
vector::reseve
или
string::reseve
этого может никогда не произойти (совет 13). Различия в типах возвращаемых значений оператора
new
и
allocator<T>::allocate
означают изменение концептуальной модели неинициализированной памяти, что также затрудняет применение опыта реализации оператора
new
к разработке нестандартных распределителей.
Мы подошли к последней странности распределителей памяти в STL: большинство стандартных контейнеров никогда не вызывает распределителей, с которыми они ассоциируются. Два примера:
list<int> L; // То же, что и list<int, allocator<int».
// Контейнер никогда не вызывает
// allocator<int> для выделения памяти!
set<Widget.SAW> s;// SAW представляет собой определение типа