|
||||
|
Ассоциативные контейнеры Ассоциативные контейнеры по некоторым характеристикам схожи с последовательными контейнерами, однако между этими категориями существует ряд принципиальных различий. Так, содержимое ассоциативных контейнеров автоматически сортируется; анализ содержимого производится по критерию эквивалентности, а не равенства; контейнеры setи mapне могут содержать дубликатов, а контейнеры mapи multimapобычно игнорируют половину данных в каждом из содержащихся в них объектов. Да, ассоциативные контейнеры являются контейнерами, но они определенно выделяются в самостоятельную категорию. В этой главе мы рассмотрим основное понятие эквивалентности; проанализируем важное ограничение, установленное для функций сравнения; познакомимся с пользовательскими функциями сравнения для ассоциативных контейнеров указателей; обсудим официальные и практические аспекты неизменности ключа, а также пути повышения эффективности ассоциативных контейнеров. В STL отсутствуют контейнеры на базе хэш-таблиц, поэтому глава завершается примерами двух распространенных (хотя и нестандартных) реализаций. Несмотря на отсутствие хэш-таблиц в STL, вам не придется реализовывать их самостоятельно или обходиться другими контейнерами. Существует немало готовых качественных реализаций. Задача сравнения объектов возникает в STL очень часто. Например, если функция findищет в интервале первый объект с заданным значением, она должна уметь сравнивать два объекта и узнавать, совпадают ли их значения. При попытке включения нового элемента в множество функция set::insertдолжна проверить, не существует ли данное значение в контейнере. Совет 19. Помните о различиях между равенством и эквивалентностью Алгоритм findи функция set::insertявляются типичными представителями семейства функций, проверяющих совпадение двух величин, однако делают это они по-разному. Для findсовпадением считается равенство двух величин, проверяемое оператором ==. Функция set::insertпроверяет отношение эквивалентности, обычно основанное на операторе <. Таким образом, по одному определению два объекта могут иметь одинаковые значения, тогда как по другому определению они будут различаться. Отсюда следует, что для эффективного использования STL необходимо понимать различия между равенством и эквивалентностью. Формальное определение равенства основано на использовании оператора ==. Если результат выражения x==yравен true, значит, xи yимеют одинаковые значения, а если false— разные. В целом определение весьма прямолинейное, хотя следует помнить о том, что из равенства значений не следует равенство всех полей данных. Предположим, класс Widgetхранит внутренние данные о времени последнего обращения: class Widget { public: … private: TimeStamp lastAccessed; }; Для класса Widgetможно определить оператор ==, игнорирующий значение этого поля: bool operator==(const Widgets lhs, const Widgets rhs) { // Поле lastAccessed игнорируется } В этом случае два объекта Widgetбудут считаться равными даже в том случае, если их поля lastAccessedсодержат разные значения. Эквивалентность основана на относительном порядке значений объектов в отсортированном интервале. Проще всего рассматривать ее в контексте порядка сортировки, являющегося частью любого стандартного ассоциативного контейнера (то есть set, multiset, mapи multimap). Два объекта xи yсчитаются эквивалентными по отношению к порядку сортировки, используемому ассоциативным контейнером c, если ни один из них не предшествует другому в порядке сортировки c. На первый взгляд такая формулировка кажется запутанной, но на практике все просто. Возьмем контейнер set<Widget> s. Два объекта Widget, w1и w2, имеют эквивалентные значения по отношению к s, если ни один из них не предшествует другому в порядке сортировки s. Стандартная функция сравнения для set<Widget>— less<Widget>— по умолчанию просто вызывает operator<для объектов Widget, поэтому w1и w2будут иметь значения, эквивалентные по отношению к operator<если истинно следующее выражение: !(w1<w2) // Условие w1 < w2 ложно && // и !(w2<w1)// Условие w2 < w1 ложно Все вполне логично: два значения эквивалентны (по отношению к некоторому критерию упорядочения), если ни одно из них не предшествует другому в соответствии с данным критерием. В общем случае функцией сравнения для ассоциативного контейнера является не оператор <или даже less, а пользовательский предикат (см. совет 39). Каждый стандартный ассоциативный контейнер предоставляет свой предикат сортировки через функцию key_comp, поэтому два объекта xи yимеют эквивалентные значения по отношению к критерию сортировки ассоциативного контейнера c, если выполняется следующее условие: !c.key_comp()(x, y) && !c.key_comp()(y, x) // х не предшествует у // в порядке сортировки с, // а у не предшествует х Выражение !c.key_comp()(x,y)выглядит устрашающе, но стоит понять, что c.key_comp()возвращает функцию (или объект функции), как все затруднения исчезают. Перед нами простой вызов функции (или объекта функции), возвращаемой key_comp, которой передаются аргументы xи y. Затем вычисляется логическое отрицание результата. Функция с.keycomp()(х, у)возвращает trueлишь в том случае, если xпредшествует yв порядке сортировки, поэтому выражение !с.key_comp()(х, у)истинно только в том случае, если xне предшествует yв порядке сортировки c. Чтобы вы лучше осознали принципиальный характер различий между равенством и эквивалентностью, рассмотрим пример — контейнер set<string>без учета регистра символов, то есть контейнер set<string>, в котором функция сравнения игнорирует регистр символов в строках. С точки зрения такой функции строки «STL» и «stL» эквивалентны. Пример реализации функции ciStringCompare, игнорирующей регистр символов, приведен в совете 35, однако setтребуется типфункции сравнения, а не сама функция. Чтобы заполнить этот пробел, мы пишем класс функтора с оператором (), вызывающим ciStringCompare: struct CiStringCompare: // Класс сравнения строк public // без учета регистра символов; binary_function<string, string, bool>{ // описание базового класса // приведено в совете 40 bool operator() (const string& lhs, const string& rhs) const { return ciStringCompare(lhs, rhs); // Реализация ciStringCompare // приведена в совете 35 } }; При наличии CiStringCompareконтейнер set<string>, игнорирующий регистр символов, создается очень просто: set<string, CIStringCompare> ciss; Теперь при вставке строк «Persephone» и «persephone» в множество будет включена только первая строка, поскольку вторая считается эквивалентной: ciss.insert("Persephone"); // В множество включается новый элемент ciss.insert("persephone"); // Эквивалентный элемент не включается Если теперь провести поиск строки «persephone» функцией set::find, результат будет успешным: if(ciss.find("persephone")!=ciss.end())... // Элемент найден Но если воспользоваться внешним алгоритмом find, поиск завершается неудачей: if (find(ciss.begin(), ciss.end(), "persephone")!=ciss.end())… // Элемент отсутствует Дело в том, что строка «persephone» эквивалентна«Persephone» (по отношению к функтору сравнения CIStringCompare), но не равна ей (поскольку string("persephone") !=string("Persephone")). Приведенный пример поясняет одну из причин, по которой в соответствии с советом 44 рекомендуется использовать функции контейнеров ( set::find) вместо их внешних аналогов ( find). Возникает вопрос — почему же в работе стандартных ассоциативных контейнеров используется понятие эквивалентности, а не равенства? Ведь большинству программистов равенство кажется интуитивно более понятным, чем эквивалентность (в противном случае данный совет был бы лишним). На первый взгляд ответ кажется простым, но чем дольше размышляешь над этим вопросом, тем менее очевидным он становится. Стандартные ассоциативные контейнеры сортируются, поэтому каждый контейнер должен иметь функцию сравнения (по умолчанию less), определяющую порядок сортировки. Эквивалентность определяется в контексте функции сравнения, поэтому клиентам стандартных ассоциативных контейнеров остается лишь задать единственную функцию сравнения. Если бы ассоциативные контейнеры при сравнении объектов использовали критерий равенства, то каждому ассоциативному контейнеру, помимо используемой при сортировке функции сравнения, пришлось бы определять вторую функцию для проверки равенства. Вероятно, по умолчанию функция сравнения вызывала бы equal_to, но интересно заметить, что функция equal_toв STL не используется в качестве стандартной функции сравнения. Когда в STL возникает необходимость проверки равенства, по умолчанию просто вызывается оператор ==. В частности, именно так поступает внешний алгоритм find. Допустим, у нас имеется контейнер set2CF, построенный по образцуset — «set с двумя функциями сравнения». Первая функция сравнения определяет порядок сортировки элементов множества, а вторая используется для проверки равенства. А теперь рассмотрим следующее объявление: set2CF<string, CIStringCompare, equal_to<string> > s; Контейнер sпроизводит внутреннюю сортировку строк без учета регистра, но с использованием интуитивного критерия равенства: две строки считаются равными при совпадении их содержимого. Предположим, в sвставляются два варианта написания строки «Persephone»: s.insert("Persephone"); s.insert("persephone"); Как поступить в этом случае? Если контейнер поймет, что "Persephone" != "persephone", и вставит обе строки в s, в каком порядке они должны следовать? Напомню, что функция сортировки эти строки не различает. Следует ли вставить строки в произвольном порядке, добровольно отказавшись от детерминированного порядка перебора содержимого контейнера? Недетерминированный порядок перебора уже присущ ассоциативным контейнерам multisetи multimap, поскольку Стандарт не устанавливает никаких ограничений на относительный порядок следования эквивалентных значений ( multiset) или ключей ( multimap). Или нам следует настоять на детерминированном порядке содержимого sи проигнорировать вторую попытку вставки (для строки «persephone»)? А если будет выбран этот вариант, что произойдет при выполнении следующей команды: if (s.find("persephone") != s.end())… // Каким будет результат проверки? Функция findиспользует проверку равенства, но если проигнорировать второй вызов insertдля сохранения детерминированного порядка элементов s, проверка даст отрицательный результат — хотя строка «persephone» была отвергнута как дубликат! Мораль: используя одну функцию сравнения и принимая решение о «совпадении» двух значений на основании их эквивалентности, мы избегаем многочисленных затруднений, возникающих при использовании двух функций сравнения. Поначалу такой подход выглядит несколько странно (особенно когда вы видите, что внутренняя и внешняя версии findвозвращают разные результаты), но в перспективе он избавляет от всевозможных затруднений, возникающих при смешанном использовании равенства и эквивалентности в стандартных ассоциативных контейнерах. Но стоит отойти от сортированных ассоциативных контейнеров, как ситуация изменяется, и проблему равенства и эквивалентности приходится решать заново. Существуют две общепринятые реализации для нестандартных (но широко распространенных) ассоциативных контейнеров на базе хэш-таблиц. Одна реализация основана на равенстве, а другая — на эквивалентности. В совете 25 приводится дополнительная информация об этих контейнерах и тех принципак, на которых они основаны. Совет 20. Определите тип сравнения для ассоциативного контейнера, содержащего указатели Предположим, у нас имеется контейнер set, содержащий указатели string*, и мы пытаемся включить в него несколько новых элементов: set<string*> ssp; // ssp = "set of string ptrs" ssp.insert(new string("Anteater")); ssp.insert(new string("Wombat")); ssp.insert(new string("Lemur")); ssp.insert(new string("Penguin")); Следующий фрагмент выводит содержимое set. Предполагается, что строки будут выведены в алфавитном порядке — ведь содержимое контейнеров setавтоматически сортируется! for (set<string*>::const_iterator i = ssp.begin(); // Предполагаемый i!=ssp.end(); // порядок вывода: ++i) // "Anteater", "Lemur" cout << *i << endl; // "Penguin", "Wombat" Однако на практике ничего похожего не происходит. Вместо строк выводятся четыре шестнадцатеричных числа — значения указателей. Поскольку в контейнере setхранятся указатели, *iявляется не строкой, а указателем на строку. Пусть этот урок напоминает, чтобы вы следовали рекомендациям совета 43 и избегали написания собственных циклов. Использование алгоритма copy: copy(ssp.begin(), ssp.end(), // Скопировать строки. ostream_iterator<string>(cout,"\n")); //содержащиеся в ssp, в cout //(не компилируется!) не только делает программу более компактной, но и помогает быстрее обнаружить ошибку, поскольку вызов copyне компилируется. Итератор ostream_iteratorдолжен знать тип выводимого объекта, поэтому когда компилятор обнаруживает расхождение между заданным в параметре шаблона типом stringи типом объекта, хранящегося в ssp(string*), он выдает ошибку. Еще один довод в пользу сильной типизации… Если заменить *iв цикле на **i, возможно, вы получите нужный результат — но скорее всего, этого не произойдет. Да, строки будут выведены, но вероятность их следования в алфавитном порядке равна всего 1/24. Контейнер sspхранит свои элементы в отсортированном виде, однако он содержит указатели, поэтому сортироваться будут значения указателей, а не строки. Существует 24 возможных перестановки для четырех указателей, то есть 24 разных последовательности, из которых лишь одна отсортирована в алфавитном порядке[2]. Подходя к решению этой проблемы, нелишне вспомнить, что объявление set<string*> ssp; представляет собой сокращенную запись для объявления set<string*, less<string*> > ssp; Строго говоря, это сокращенная запись для объявления set<string*, less<string*>, allocator<string*> > ssp; но в контексте данного совета распределители памяти несущественны. Если вы хотите сохранить указатели string*в контейнере setтак, чтобы их порядок определялся значениями строк, стандартный функтор сравнения less<string*>вам не подойдет. Вместо этого необходимо написать собственный функтор сравнения, который получает указатели string*и упорядочивает их по содержимому строк, на которые они ссылаются. Пример: struct StringPtrLess: public binary_function<const string*, // Базовый класс const string*, // описан в совете 40 bool> { bool operator() (const string *ps1, const string *ps2) const { return *ps1 < *ps2: } }; После этого StringPtrLessиспользуется в качестве типа критерия сравнения ssp: typedef set<string*, StringPtrLess> StringPtrSet; StringPtrSet ssp; // Создать множество с объектами string // и порядком сортировки, определяемым // критерием StringPtrLess // Вставить те же четыре строки Теперь приведенный выше цикл будет работать именно так, как предполагалось (при условии, что ошибка была исправлена и вместо *iиспользуется **i). for (StringPtrSet::const_iterator i = ssp.begin(); i != ssp.end(); // Порядок вывода: ++i) // "Anteater", "Lemur", cout << **i << endl; // "Penguin", "Wombat" Если вы предпочитаете использовать алгоритм, напишите функцию, которая разыменовывает указатели string*перед выводом, а затем используйте ее в сочетании с for_each: void print(const string *ps) // Вывести в cout объект, { // на который ссылается ps cout << *ps << endl; } for_each(ssp.begin(), ssp.end(), print); // Вызвать print для каждого // элемента ssp Существует более изощренное решение — обобщенный функтор разыменования, используемый с transformи ostream_iterator: // Функтор получает T* и возвращает const T& struct Dereference { template<typename T> const T& operator()(const T* ptr) const { return *ptr; } }; transform(ssp.begin(), ssp.end(), // "Преобразовать" каждый ostream.iterator<string>(cout, "\n"), // элемент ssp посредством Dereference()); // разыменования и записать // результаты в cout Впрочем, замена циклов алгоритмами будет подробно рассматриваться позднее, в совете 43. А сейчас речь идет о том, что при создании стандартного ассоциативного контейнера указателей следует помнить: содержимое контейнера будет сортироваться по значениям указателей. Вряд ли такой порядок сортировки вас устроит, поэтому почти всегда определяются классы-функторы, используемые в качестве типов сравнения. Обратите внимание на термин «тип сравнения». Возможно, вас интересует, зачем возиться с созданием функтора вместо того, чтобы просто написать функцию сравнения для контейнера set? Например, так: bool stringPtrLess(const string* ps1, // Предполагаемая функция сравнения const string* ps2) // для указателей string*, { // сортируемых по содержимому строки return *ps1 < *ps2; } set<string, stringPtrLess> ssp; // Попытка использования stringPtrLess // в качестве функции сравнения ssp. // Не компилируется!!! Проблема заключается в том, что каждый из трех параметров шаблона setдолжен быть типом. К сожалению, stringPtrLess— не тип, а функция, поэтому попытка задать stringPtrLessв качестве функции сравнения setне компилируется. Контейнеру setне нужна функция; ему нужен тип, на основании которого можно создатьфункцию. Каждый раз, когда вы создаете ассоциативный контейнер указателей, помните о том, что вам, возможно, придется задать тип сравнения контейнера. В большинстве случаев тип сравнения сводится к разыменованию указателя и сравнению объектов, как это сделано в приведенном выше примере StringPtrLess. Шаблон для таких функторов сравнения стоит держать под рукой. Пример: struct DereferenceLess { template <typename PtrType> bool operator()(PtrType pT1, // Параметры передаются по значению. PtrType рТ2) const // поскольку они должны быть { // указателями (или по крайней мере return *рТ1 < *рТ2; // вести себя, как указатели) } }; Данный шаблон снимает необходимость в написании таких классов, как StringPtrLess, поскольку вместо них можно использовать DereferenceLess: set<string*, DereferenceLess> ssp; // Ведет себя так же, как // set<string*, stringPtrLess> И последнее замечание. Данный совет посвящен ассоциативным контейнерам указателей, но он в равной степени относится и к контейнерам объектов, которые ведут себя как указатели (например, умные указатели и итераторы). Если у вас имеется ассоциативный контейнер умных указателей или итераторов, подумайте, не стоит ли задать тип сравнения и для него. К счастью, решение, приведенное для указателей, работает и для объектов-аналогов. Если определение DereferenceLessподходит в качестве типа сравнения для ассоциативного контейнера T*, оно с большой вероятностью подойдет и для контейнеров итераторов и умных указателей на объекты T. Совет 21. Следите за тем, чтобы функции сравнения возвращали false в случае равенства Сейчас я покажу вам нечто любопытное. Создайте контейнер setс типом сравнения less_equalи вставьте в него число 10: set<int, less_equal<int> > s; // Контейнер s сортируется по критерию "<=" s.insert(10); // Вставка числа 10 Теперь попробуйте вставить число 10 повторно: s.insert(10); При этом вызове insertконтейнер должен выяснить, присутствует ли в нем число 10. Мы знаем, что такое число уже есть, но контейнер глуп как пробка и все проверяет лично. Чтобы вам было проще понять, что при этом происходит, назовем первоначально вставленный экземпляр 10A, а новый экземпляр — 10B. Контейнер перебирает свои внутренние структуры данных и ищет место для вставки 10B. В итоге ему придется проверить 10A и сравнить его с 10B. Для ассоциативного контейнера «сравнение» сводится к проверке эквивалентности (см. совет 19), поэтому контейнер проверяет эквивалентность объектов 10A и 10B. Естественно, при этой проверке используется функция сравнения контейнера set; в нашем примере это функция operator<=, поскольку мы задали функцию сравнения less_equal, a less_equalозначает operator<=. Затем контейнер проверяет истинность следующего выражения: !(10a<=10b)&&!(10b<=10a) // Проверка эквивалентности 10A и 10B Оба значения, 10A и 10B, равны 10, поэтому условие 10A<=10B заведомо истинно. Аналогично истинно и условие 10B<=10A. Приведенное выше выражение упрощается до !(true)&&!(true), то есть false&&false— результат равен false. Другими словами, контейнер приходит к выводу, что 10A и 10B не эквивалентны, и вставляет 10B в контейнер наряду с 10A. С технической точки зрения эта попытка приводит к непредсказуемым последствиям, но на практике в контейнере setпоявляются два экземпляра значения 10, а это означает утрату одного из важнейших свойств set. Передача типа сравнения less_equalпривела к порче контейнера! Более того, любаяфункция сравнения, которая возвращает trueдля равных значений, приведет к тем же последствиям. Равные значения по определению неэквивалентны! Здорово, не правда ли? Мораль: всегда следите за тем, чтобы функции сравнения для ассоциативных контейнеров возвращали falseдля равных значений. Будьте внимательны, поскольку это ограничение очень легко упустить из виду. Например, в совете 20 рассказано о том, как написать функцию сравнения для контейнеров указателей string*обеспечивающую автоматическую сортировку содержимого контейнера по значениям строк, а не указателей. Приведенная функция сравнения сортирует строки по возрастанию, но давайте предположим, что вам понадобилась функция для сортировки по убыванию. Естественно, вы возьмете существующий код и отредактируете его. Но если не проявить достаточной осторожности, у вас может получиться следующий результат (изменения выделены жирным шрифтом): struct StringPtrGreater: public binary_function<const string*, // Жирным шрифтом выделены const string*, // изменения, внесенные в код bool> { // из совета 20. // Внимание - приведенное решение // не работает! bool operator()(const string *ps1, const string *ps2) const { return !(*ps1 < *ps2); // Простое логическое отрицание } // старого условия не работает! }; Идея заключается в том, чтобы изменить порядок сортировки логическим отрицанием условия в функции сравнения. К сожалению, отрицанием операции « <» является не « >», а « >=», а мы выяснили, что операция « >=», возвращающая trueдля равных значений, не подходит для функции сравнения в ассоциативных контейнерах. Правильный тип сравнения должен выглядеть так: struct StringPtrGreater: // Правильный тип сравнения public binary_function<const string*, // для ассоциативных контейнеров const string*, bool> { bool operator() (const string *ps1, const string *ps2) const { return *ps2<*ps1;// Поменять местами операнды } }; Чтобы не попасть в ловушку, достаточно запомнить, что возвращаемое значение функции сравнения указывает, должно ли одно значение предшествовать другому в порядке сортировки, определяемом этой функцией. Равные значения никогда не предшествуют друг другу, поэтому функция сравнения всегда должна возвращать для них false. Я знаю, о чем вы думаете. «Конечно, это имеет смысл для setи map, поскольку эти контейнеры не могут содержать дубликатов. А как насчет multisetи multimap? Раз эти контейнеры могут содержать дубликаты, так ли важно, что два объекта с одинаковыми значениями окажутся не эквивалентными? Сохраним оба, для этого и нужны mult-контейнеры. Верно?» Нет, неверно. Давайте вернемся к исходному примеру, но на этот раз воспользуемся контейнером multiset: multiset<int, less_equal<int> > s; // s сортируется по критерию "<=" s.insert(10);// Вставка числа 10A s.insert(10);// Вставка числа 10B Теперь sсодержит два экземпляра числа 10, и было бы логично предположить, что при вызове equal_rangeмы получим пару итераторов, описывающих интервал с обеими копиями. Однако это невозможно. Функция equal_range, несмотря на свое название, определяет интервал не равных, а эквивалентныхзначений. В нашем примере функция сравнения sговорит, что 10A и 10B не эквивалентны, поэтому они не могут оказаться в интервале, определяемом функцией equal_range. Ну что, убедились? Функция сравнения всегда должна возвращать false для равных величин, в противном случае нарушается работа всех стандартных ассоциативных контейнеров (независимо от того, могут они содержать дубликаты или нет). Строго говоря, функции сравнения, используемые для сортировки ассоциативных контейнеров, должны определять для сравниваемых объектов порядок строгой квазиупорядоченности (strict weak ordering); аналогичное ограничение действует и для функций сравнения, передаваемых алгоритмам, — таким, как sort(см. совет 31). Если вас заинтересуют подробности того, что понимается под строгой квазиупорядоченностью, информацию можно найти во многих серьезных руководствах по STL, в том числе «The C++ Standard Library» [3], «Generic Programming аnd the STL» [4] и на web-сайте SGI, посвященном STL [21]. Особых откровений не ждите, но одно из требований строгой квазиупорядоченности относится непосредственно к данному совету. Требование заключается в следующем: функция, определяющая строгую квазиупорядоченность, должна возвращать falseпри получении двух копий одного значения. Совет 22. Избегайте изменения ключа «на месте» в контейнерах set и multiset Понять смысл этого совета нетрудно. Контейнеры set/multiset, как и все стандартные ассоциативные контейнеры, хранят свои элементы в отсортированном порядке, и правильное поведение этих контейнеров зависит от сохранения этого порядка. Если изменить значение элемента в ассоциативном контейнере (например заменить 10 на 1000), новое значение окажется в неправильной позиции, что нарушит порядок сортировки элементов в контейнере. Сказанное прежде всего касается контейнеров mapи multimap, поскольку программы, пытающиеся изменить значение ключа в этих контейнерах, не будут компилироваться: map<int, string> m; … m.begin()->first = 10; // Ошибка! Изменение ключей // в контейнере map запрещено multimap<int, string> mm; mm.begin()->first = 20; // Ошибка! Изменение ключей // в контейнере multimap запрещено Дело в том, что элементы объекта типа map<K, V>или multimap<K, V>относятся к типу pair<const K, V>. Ключ относится к типу const Kи поэтому не может изменяться. Впрочем, его все же можно изменить с применением const_cast, как показано ниже. Хотите — верьте, хотите — нет, но иногда это даже нужно. Обратите внимание: в заголовке этого совета ничего не сказано о контейнерах mapи multimap. Для этого есть веские причины. Как показывает предыдущий пример, модификация ключа «на месте» невозможна для mapи multimap(без применения преобразования const_cast), но может быть допустима для setи multiset. Для объектов типа set<T>и multiset<T>в контейнере хранятся элементы типа T, а не const T. Следовательно, элементы контейнеров setи mu ltiset можно изменять в любое время, и преобразование const_castдля этого не требуется (вообще говоря, дело обстоит не так просто, но не будем забегать вперед). Сначала выясним, почему элементы setи multisetне имеют атрибута const. Допустим, у нас имеется класс Employee: class Employee { public: … const string& name() const; // Возвращает имя работника void setName(const string& name); // Задает имя работника const string& title() const; // Возвращает должность void setTitle(const string& title); // Задает должность int idNumber() const; // Возвращает код работника … } Объект содержит разнообразные сведения о работнике. Каждому работнику назначается уникальный код, возвращаемый функцией idNumber. При создании контейнера setс объектами Employeeбыло бы вполне разумно упорядочить его по кодам работников: struct IDNumberLess: public binary_function<Employee, Employee, bool> { // См. совет 40 bool operator() (const Employees lhs, const Employees rhs) const { return lhs.idNumber() < rhs. IdNumber(); } } typedef set<Employee, IDNumberLess> EmplIDSet; EmplIDSet se; // Контейнер set объектов // Employee, упорядоченных // по коду С практической точки зрения код работника является ключомдля элементов данного множества, а остальные данные вторичны. Учитывая это обстоятельство, ничто не мешает перевести работника на более интересную должность. Пример: Employee selectedID; // Объект работника с заданным кодом … EmpIDSet::iterator = se.find(selectedID); if (i!=se.end()) { i->setTitle("Corporate Deity"); // Изменить должность } Поскольку мы всего лишь изменяем вторичный атрибут данных, не влияющий на порядок сортировки набора, этот фрагмент не приведет к порче данных, и он вполне допустим. Спрашивается, почему нельзя применить ту же логику к ключам контейнеров mapи multimap? Почему бы не создать контейнер map, ассоциирующий работников со страной, в которой они живут; контейнер с функцией сравнения IDNumberLess, как в предыдущем примере? И почему бы в таком контейнере не изменить должность без изменения кода работника, как в предыдущем примере? Откровенно говоря, мне это кажется вполне логичным, однако мое личное мнение в данном случае несущественно. Важно то, что Комитет по стандартизации решил, что ключи map/multimapдолжны быть неизменными ( const), а значения set/multiset— не должны. Значения в контейнерах set/multisetне являются неизменными, поэтому попытки их изменения обычно нормально компилируются. Данный совет лишь напоминает вам о том, что при модификации элементов set/multisetне следует изменять ключевую часть (то есть ту часть элемента, которая влияет на порядок сортировки в контейнере). В противном случае целостность данных контейнера будет нарушена, операции с контейнером начнут приводить к непредсказуемым результатам, и все это произойдет по вашей вине. С другой стороны, это ограничение относится только к ключевым атрибутам объектов, содержащихся в контейнере. Остальные атрибуты объектов находятся в вашем полном распоряжении — изменяйте на здоровье! Впрочем, не все так просто. Хотя элементы set/multisetи не являются неизменными, реализации могут предотвратить их возможную модификацию. Например, оператор *для set<T>::iteratorможет возвращать const T&, то есть результат разыменования итератора setможет быть ссылкой на const-элемент контейнера! При такой реализации изменение элементов setи multisetневозможно, поскольку при любом обращении к элементу автоматически добавляется объявление const. Законны ли такие реализации? Может, да, а может — нет. По этому вопросу Стандарт высказывается недостаточно четко, и в соответствии с законом Мерфи разные авторы интерпретируют его по-разному. В результате достаточно часто встречаются реализации STL, в которых следующий фрагмент компилироваться не будет (хотя ранее говорилось о том, что он успешно компилируется): EmplIDSet se; // Контейнер set объектов // Employee, упорядоченных // по коду Employee selectedID; // Объект работника с заданным кодом … EmpIDSet::iterator = se.find(selectedID); if (i!=se.end()) { i->setTitle("Corporate Deity"); // Некоторые реализации STL }; // выдают ошибку в этой строке Вследствие неоднозначности стандарта и обусловленных ею различий в реализациях программы, пытающиеся модифицировать элементы контейнеров setи multiset, не переносимы. Что из этого следует? К счастью, ничего особенно сложного: • если переносимость вас не интересует, если вы хотите изменить значение элемента в контейнере set/multisetи ваша реализация STL это разрешает — действуйте. Помните о том, что ключевая часть элемента (то есть часть элемента, определяющая порядок сортировки элементов в контейнере) должна сохраниться без изменений; • если программа должна быть переносимой, элементы контейнеров set/multisetмодифицироваться не могут (по крайней мере, без преобразования const_cast). Кстати, о преобразованиях. Вы убедились в том, что изменение вторичных данных элемента set/multisetможет быть вполне оправданно, поэтому я склонен показать, как это делается — а точнее, делается правильно и переносимо. Сделать это нетрудно, но при этом приходится учитывать тонкость, о которой забывают многие программисты — преобразование должно приводить к ссылке. В качестве примера рассмотрим вызов setTitle, который, как было показано, не компилируется в некоторых реализациях: EmpIDSet::iterator i = se.find(selectedID); if (i != se.end()) { i->setTitle("Corporate Deity"); // Некоторые реализации STL } // выдают ошибку в этой строке, // поскольку *i имеет атрибут const Чтобы этот фрагмент нормально компилировался и работал, необходимо устранить константность *i. Правильный способ выглядит так: if (i != se.end()){ // Устранить const_cast<Empioyee&>(*i).setTitle("Corporate Deity"); // константность *i } Мы берем объект, на который ссылается i, и сообщаем компилятору, что результат должен интерпретироваться как ссылка на (неконстантный) объект Employee, после чего вызываем setTitleдля полученной ссылки. Я не буду тратить время на долгие объяснения и лучше покажу, почему альтернативное решение работает совсем не так, как можно было бы ожидать. Многие программисты пытаются воспользоваться следующим кодом: if (i != se.end()){ // Преобразовать *i static_cast<Employee>(*i).setTitle("Corporate Deity"); // к Employee } Приведенный фрагмент эквивалентен следующему: if (i != se.end()){ // То же самое, ((Employee)(*i)).setTitle("Corporate Deity"); // но с использованием } // синтаксиса С Оба фрагмента компилируются, но вследствие эквивалентности работают неправильно. На стадии выполнения объект *iне модифицируется, поскольку в обоих случаях результатом преобразования является временный анонимный объект — копия *i, и setTitleвызывается для анонимного объекта, а не для *i! Обе синтаксические формы эквивалентны следующему фрагменту: if (i != se.end()) { Employee tempCopy(*i); // Скопировать *i в tempCopy tempCopy.setTitle("Corporate Deity"); // Изменить tempCopy } Становится понятно, почему преобразование должно приводить именно к ссылке — тем самым мы избегаем создания нового объекта. Вместо этого результат преобразования представляет собой ссылку на существующийобъект, на который указывает i. При вызове setTitleдля объекта, обозначенного ссылкой, функция вызывается для *i, чего мы и добивались. Все сказанное хорошо подходит для контейнеров set и multiset, но при переходе к map/multimap ситуация усложняется. Вспомните, что map<K, V>и multimap<K, V>содержат элементы типа pair<const K, V>. Объявление constозначает, что первый компонент пары определяетсякак константа, а из этого следует, что любые попытки устранить его константность приводят к непредсказуемому результату. Теоретически реализация STL может записывать такие данные в область памяти, доступную только для чтения (например, в страницу виртуальной памяти, которая после исходной записи защищается вызовом системной функции), и попытки устранить их константность в лучшем случае ни к чему не приведут. Я никогда не слышал о реализациях, которые бы поступали подобным образом, но если вы стремитесь придерживаться правил, установленных в Стандарте, — никогдане пытайтесь устранять константность ключей в контейнерах mapи multimap. Несомненно, вы слышали, что подобные преобразования рискованны. Надеюсь, вы будете избегать их по мере возможности. Выполняя преобразование, вы временно отказываетесь от страховки, обеспечиваемой системой типов, а описанные проблемы дают представление о том, что может произойти при ее отсутствии. Многие преобразования (включая только что рассмотренные) не являются абсолютно необходимыми. Самый безопасный и универсальный способ модификации элементов контейнера set, multiset, mapили multimapсостоит из пяти простых шагов. 1. Найдите элемент, который требуется изменить. Если вы не уверены в том, как сделать это оптимальным образом, обратитесь к рекомендациям по поводу поиска в совете 45. 2. Создайте копию изменяемого элемента. Помните, что для контейнеров map/multimapпервый компонент копии не должен объявляться константным — ведь именно его мы и собираемся изменить! 3. Удалите элемент из контейнера. Обычно для этого используется функция erase(см. совет 9). 4. Измените копию и присвойте значение, которое должно находиться в контейнере. 5. Вставьте новое значение в контейнер. Если новый элемент в порадке сортировки контейнера находится в позиции удаленного элемента или в соседней позиции, воспользуйтесь «рекомендательной» формой insert, повышающей эффективность вставки от логарифмической до постоянной сложности. В качестве рекомендации обычно используется итератор, полученный на шаге 1. EmpIDSet se; // Контейнер set объектов Employee, упорядоченных по коду Employee SelectedID; // Объект работника с заданным кодом … EmpIDSet::iterator i = // Этап 1: поиск изменяемого элемента se.find(selectedID); if (i != se.end()) { Employee e(*i); //Этап 2: копирование элемента se.erase(i++); // Этап 3: удаление элемента. // Увеличение итератора // сохраняет его // действительным (см. совет 9) e.setTitle("Corporate Deity"); // Этап 4: модификация копии se.insert(i, е); // Этап 5: вставка нового значения. // Рекомендуемая позиция совпадает // с позицией исходного элемента } Итак, при изменении «на месте» элементов контейнеров setи multisetследует помнить, что за сохранение порядка сортировки отвечает программист. Совет 23. Рассмотрите возможность замены ассоциативных контейнеров сортированными векторами Многие программисты STL, столкнувшись с необходимостью структуры данных с быстрым поиском, немедленно выбирают стандартные ассоциативные контейнеры set , multiset , map и multimap. В этом выборе нет ничего плохого, но он не исчерпывает всех возможных вариантов. Если скорость поиска действительно важна, подумайте об использовании нестандартных хэшированных контейнеров (см. совет 25). При правильном выборе хэш-функций хэшированные контейнеры могут обеспечить поиск с постоянным временем (а при неправильном выборе хэш-функций или недостаточном размере таблиц быстродействие заметно снижается, но на практике это встречается относительно редко). Во многих случаях предполагаемое постоянное время поиска превосходит гарантированное логарифмическое время, характерное для контейнеров set, mapи их multi-аналогов. Даже если гарантированное логарифмическое время поиска вас устраивает, стандартные ассоциативные контейнеры не всегда являются лучшим выбором. Как ни странно, стандартные ассоциативные контейнеры по быстродействию нередко уступают банальному контейнеру vector. Чтобы эффективно использовать STL, необходимо понимать, в каких случаях vectorпревосходит стандартные ассоциативные контейнеры по скорости поиска. Стандартные ассоциативные контейнеры обычно реализуются в виде сбалансированных бинарных деревьев. Сбалансированное бинарное дерево представляет собой структуру данных, оптимизированную для комбинированных операций вставки, удаления и поиска. Другими словами, оно предназначено для приложений, которые вставляют в контейнер несколько элементов, затем производят поиск, потом вставляют еще несколько элементов, затем что-то удаляют, снова возвращаются к удалению или вставке и т. д. Главной особенностью этой последовательности событий является чередование операций вставки, удаления и поиска. В общем случае невозможно предсказать следующую операцию, выполняемую с деревом. Во многих приложениях структуры данных используются не столь непредсказуемо. Операции со структурами данных делятся на три раздельные фазы. 1. Подготовка. Создание структуры данных и вставка большого количества элементов. В этой фазе со структурой данных выполняются только операции вставки и удаления. Поиск выполняется редко или полностью отсутствует. 2. Поиск. Выборка нужных данных из структуры. В этой фазе выполняются только операции поиска. Вставка и удаление выполняются редко или полностью отсутствуют. 3. Реорганизация. Модификация содержимого структуры данных (возможно, со стиранием всего текущего содержимого и вставкой новых элементов). По составу выполняемых операций данная фаза эквивалентна фазе 1. После ее завершения приложение возвращается к фазе 2. В приложениях, использующих эту схему работы со структурами данных, контейнер vectorможет обеспечить лучшие показатели (как по времени, так и по затратам памяти), чем ассоциативный контейнер. С другой стороны, выбор vectorне совсем произволен — подходят только сортированныеконтейнеры vector, поскольку лишь они правильно работают с алгоритмами binary_search, lower_bound, equal_rangeи т. д. (совет 34). Но почему бинарный поиск через вектор (может быть, отсортированный) обеспечивает лучшее быстродействие, чем бинарный поиск через двоичное дерево? Прежде всего из-за банального принципа «размер имеет значение». Существуют и другие причины, не столь банальные, но не менее истинные, и одна из них — локализованность ссылок. Начнем с размера. Допустим, нам нужен контейнер для хранения объектов Widget. Скорость поиска является важным фактором, поэтому рассматриваются два основных кандидата: ассоциативный контейнер объектов Widgetи сортированный vector<Widget>. В первом случае почти наверняка будет использоваться сбалансированное бинарное дерево, каждый узел которого содержит не только Widget, но и указатели на левого и правого потомков и (обычно) указатель на родительский узел. Следовательно, при хранении одного объекта Widgetв ассоциативном контейнере должны храниться минимум три указателя. С другой стороны, при сохранении Widgetв контейнере vectorнепроизводительные затраты отсутствуют. Конечно, контейнер vectorсам по себе требует определенных затрат памяти, а в конце вектора может находиться зарезервированная память (см. совет 14), но затраты первой категории как правило невелики (обычно это три машинных слова — три указателя или два указателя с одним числом int), а пустое место при необходимости отсекается при помощи «фокуса с перестановкой» (см. совет 17). Но даже если зарезервированная память и не будет освобождена, для нашего анализа ее наличие несущественно, поскольку в процессе поиска ссылки на эту память не используются. Большие структуры данных разбиваются на несколько страниц памяти, однако для хранения vectorтребуется меньше страниц, чем для ассоциативного контейнера. Это объясняется тем, что в vectorобъект Widgetхранится без дополнительных затрат памяти, тогда как в ассоциативном контейнере к каждому объекту Widgetприлагаются три указателя. Предположим, вы работаете в системе, где объект Widgetзанимает 12 байт, указатели — 4 байт, а страница памяти содержит 4096 байт. Если не обращать внимания на служебную память контейнера, vectorпозволяет разместить на одной странице 341 объект Widget, но в ассоциативном контейнере это количество уменьшается до 170. Следовательно, по эффективности расходования памяти vectorвдвое превосходит ассоциативный контейнер. В средах с виртуальной памятью это увеличивает количество подгрузок страниц, что значительно замедляет работу с большими объемами данных. В действительности я несколько оптимистично подошел к ассоциативным контейнерам — приведенное описание предполагает, что узлы бинарных деревьев сгруппированы в относительно малом наборе страниц памяти. В большинстве реализаций STL подобная группировка достигается при помощи нестандартных диспетчеров памяти, работающих поверх распределителей памяти контейнеров (см. советы 10 и И), но если реализация не следит за локализованностью ссылок, узлы могут оказаться разбросанными по всему адресному пространству. Это приведет к росту числа подгрузок страниц. Даже при использовании группирующих диспетчеров памяти в ассоциативных контейнерах обычно чаще возникают проблемы с подгрузкой страниц, поскольку узловым контейнерам, в отличие от блоковых (таких как vector), труднее обеспечить близкое расположение соседних элементов контейнера в физической памяти. Однако именно эта организация памяти сводит к минимуму подгрузку страниц при выполнении бинарного поиска. Мораль: данные, хранящиеся в сортированном векторе, обычно занимают меньше памяти, чем те же данные в стандартном ассоциативном контейнере; бинарный поиск в сортированном векторе обычно происходит быстрее, чем поиск в стандартном ассоциативном контейнере (с учетом подгрузки страниц). Конечно, сортированный vectorобладает серьезным недостатком — он должен постоянно сохранять порядок сортировки! При вставке нового элемента все последующие элементы сдвигаются на одну позицию. Операция сдвига обходится довольно дорого и становится еще дороже при перераспределении памяти (см. совет 14), поскольку после этого обычно приходится копировать всеэлементы вектора. С другой стороны, при удалении элемента из вектора все последующие элементы сдвигаются на одну позицию к началу. Операции вставки-удаления дорого обходятся для контейнеров vector, но относительно дешевы для ассоциативных контейнеров. По этой причине сортированные контейнеры vectorиспользуются вместо ассоциативных контейнеров лишь в том случае, если вы знаете, что при использовании структуры данных операции поиска почти не смешиваются со вставкой и удалением. В этом совете было много текста, но катастрофически не хватало примеров. Давайте рассмотрим базовый код использования сортированного vectorвместо set: vector<Widget> vw; // Альтернатива для set<Widget> … // Подготовительная фаза: много вставок, // мало операций поиска sort(vw.begin(), vw.end()); // Конец подготовительной фазы (при эмуляции // multiset можно воспользоваться // алгоритмом stable_sort - см. совет 31). Widget w;// Объект с искомым значением … // Начало фазы поиска if (binary_search(vw.begin(), vw.end(), w))… // Поиск с применением // binary_search vector<Widget>::iterator i = lower_bound(vw.begin(), vw.end(), w); // Поиск с применением if (i != vw.end() && !(*i < w))… // lower_bound: конструкция // !(*i<w)) описана в совете 45 pair<vector<Widget>::iterator, vector<Widget>::iterator> range = equal_range(vw.begin(), vw.end(), w); // Поиск с применением if (range.first != range.second)… // equal_range … // Конец фазы поиска, // начало фазы реорганизации sort(vw.begin(), vw.end()); // Начало новой фазы поиска… Как видите, все реализуется достаточно прямолинейно. Основные затруднения связаны с выбором алгоритма поиска ( binary_search, lower_boundи т. д.), но в этом вам поможет совет 45. При переходе от map/multimapк контейнеру vectorситуация становится более интересной, поскольку vectorдолжен содержать объекты pair, входящие в map/multimap. Но при объявлении объекта типа map<K, V>(или его multimap-аналога) элементы, хранящиеся в контейнере, в действительности относятся к типу pair<const K, V>. Чтобы эмулировать mapили multimapна базе vector, признак константности необходимо устранить, поскольку в процессе сортировки элементы вектора перемещаются посредством присваивания, а это означает, что оба компонента пары должны допускать присваивание. Следовательно, при эмуляции map<K, V>на базе vector данные, хранящиеся в векторе, должны относиться к типу pair<K, V>, а не pair<const K, V>. Содержимое map/multimapхранится в отсортированном виде, но при сортировке учитывается только ключевая составляющая элемента (первый компонент пары), поэтому при сортировке vectorдолжно происходить то же самое. Нам придется написать собственную функцию сравнения для пар, поскольку оператор <типа pairсравнивает обесоставляющие пары. Интересно заметить, что для выполнения поиска требуется вторая функция сравнения. Функция сравнения, используемая при сортировке, получает два объекта pair, но поиск выполняется только по значению ключа. С другой стороны, функция сравнения, используемая при поиске, должна получать два разнотипных объекта — объект с типом ключа (искомое значение) и pair(одна из пар, хранящихся в векторе). Но это еще не все: мы не знаем, что передается в первом аргументе — ключ или pair, поэтому в действительности для поиска необходимы две функции: одна получает ключ, а другая — объект pair. В следующем примере объединено все сказанное ранее: typedef pair<string, int> Data; // Тип, хранимый в "map" в данном примере class DataCompare{ // Класс для функций сравнения public: bool operator()(constData& lhs, // Функция сравнения constData& rhs) const // для сортировки { return keyLess(lhs.first, rhs.first); // Определение keyLess } // приведено ниже bool operator()(const Data& lhs, // Функция сравнения const Data::first_type& k) const // для поиска (форма 1) { return keyLess(lhs.first, rhs.first); } bool operator()(const Data::first_type& k, // Функция сравнения const Data& rhs) const; // для поиска (форма 2) { return keyLess(k.rhs.first); } private: // "Настоящая" функция bool keyLess(const Data::first_type& k1, // сравнения const Data::first_type& k2) const { return k1 < k2; } } В данном примере предполагается, что сортированный вектор эмулирует map<string, int>. Перед нами практически буквальное переложение комментариев, приведенных ранее, если не считать присутствия функции keyLess, предназначенной для согласования функций operator(). Каждая функция просто сравнивает два ключа, поэтому, чтобы не программировать одни и те же действия дважды, мы производим проверку в keyLess, а функция operator()возвращает полученный результат. Конечно, этот прием упрощает сопровождение DataCompare, однако у него есть один недостаток: наличие функций operator()с разными типами параметров исключает адаптацию объектов функций (см. совет 40). С этим ничего не поделаешь. Контейнер mapэмулируется на базе сортированного вектора практически так же, как и контейнер set. Единственное принципиальное отличие заключается в том, что в качестве функций сравнения используются объекты DataCompare: vector<Widget> vd; // Альтернатива для map<string, int> … // Подготовительная фаза: много вставок, // мало операций поиска sort(vd.begin(), vd.end(), DataCompare()); // Конец подготовительной фазы // (при эмуляции multiset можно // воспользоваться алгоритмом // stable_sort - см. совет 31) string s; // Объект с искомым значением … // Начало фазы поиска if (binary_search(vd.begin(), vd.end(), s, DataCompare()))… // Поиск // с применением binary_search vector<Data>::iterator i = lower_bound(vd.begin(), vd.end().s, DataCompare()); // Поиск с применением if (i != vd.end() && !(i->first < s))… // lower_bound: конструкция //!(i->first<s)) описана //в совете 45 pair<vector<Data>::iterator, vector<Data>::iterator> range = equal_range(vd.begin(), vd.end(), s, DataCompare()); // Поиск с применением if (range.first != range.second)… // equal_range … // Конец фазы поиска, // начало фазы реорганизации sort(vd.begin(), vd.end(), DataCompare()); //Начало новой фазы поиска… Как видите, после написания DataCompareвсе более или менее становится на свои места. Показанное решение часто быстрее работает и расходует меньше памяти, чем аналогичная архитектура с настоящим контейнером map— при условии, что операции со структурой данных в вашей программе делятся на фазы, описанные на с. 99. Если подобное деление на фазы не соблюдается, использование сортированного вектора вместо стандартных ассоциативных контейнеров почти всегда оборачивается напрасной тратой времени. Совет 24. Тщательно выбирайте между map::operator[] и map::insert Допустим, у нас имеется класс Widgetс конструктором по умолчанию, а также конструктором и оператором присваивания с операндом типа double: class Widget { public: Widget(); Widget(double weight); Widget& operator=(double weight); … }; Предположим, мы хотим создать контейнер map, ассоциирующий intс Widget, и инициализировать созданное множество конкретными значениями. Все выглядит очень просто: map<int, Widget> m; m[1]=1.50; m[2]=3.67; m[3]=10.5; m[4]=45.8; m[5]=0.0003; Настолько просто, что легко упустить, что же здесь, собственно, происходит. А это очень плохо, потому что в действительности происходящее может заметно ухудшить быстродействие программы. Функция operator[]контейнеров mapникак не связана с функциями operator[]контейнеров vector, dequeи string, а также со встроенным оператором [], работающим с массивами. Функция map::operator[]упрощает операции «обновления с возможным созданием». Иначе говоря, при наличии объявления map<K, V> mкоманда m[k]=v;проверяет, присутствует ли ключ kв контейнере. Если ключ отсутствует, он добавляется вместе с ассоциированным значением v. Если ключ уже присутствует, ассоциированное с ним значение заменяется на v. Для этого operator[]возвращает ссылку на объект значения, ассоциированного с ключом k, после чего v присваивается объекту, к которому относится эта ссылка. При обновлении значения, ассоциированного с существующим ключом, никаких затруднений не возникает — в контейнере уже имеется объект, ссылка на который возвращается функцией operator[]. Но при отсутствии ключа kготового объекта, на который можно было бы вернуть ссылку, не существует. В этом случае объект создается конструктором по умолчанию, после чего operator[]возвращает ссылку на созданный объект. Вернемся к началу исходного примера: map<int, Widget> m; m[1]=1.50; Выражение m[1]представляет собой сокращенную запись для m.operator[](1), поэтому во второй строке присутствует вызов map::operator[]. Функция должна вернуть ссылку на ассоциированный объект Widget. В данном примере mеще не содержит ни одного элемента, поэтому элемент с ключом 1 не существует. Конструктор по умолчанию создает объект Widget, ассоциируемый с ключом 1, и возвращает ссылку на этот объект. Наконец, созданному объекту Widgetприсваивается значение 1.50. Иначе говоря, команда m[1]=1.50: функционально эквивалентна следующему фрагменту: typedef map<int, Widget> intWidgetMap; // Вспомогательное определение типа pair<intWidgetMap::iterator, bool> result = // Создание нового m.insert(intWidgetMap::value_type(1, Widget())); // элемента с ключом 1 // и ассоциированным объектом, созданным // конструктором по умолчанию; комментарии // по поводу value_type // приведены далее result.first->second = 1.50; // Присваивание значения // созданному объекту Теперь понятно, почему такой подход ухудшает быстродействие программы. Сначала мы конструируем объект Widget, а затем немедленно присваиваем ему новое значение. Конечно, правильнее было бы сразу сконструировать Widgetс нужными данными вместо того, чтобы конструировать Widgetпо умолчанию и затем выполнять присваивание. Следовательно, вызов operator[]было бы правильнее заменить прямолинейным вызовом insert: m.insert(intWidgetMap::value_type(1, 1.50)); С функциональной точки зрения эта конструкция эквивалентна фрагменту, приведенному выше, но она позволяет сэкономить три вызова функций: создание временного объекта Widgetконструктором по умолчанию, уничтожение этого временного объекта и оператор присваивания Widget. Чем дороже обходятся эти вызовы, тем большую экономию обеспечивает применение map::insertвместо map::operator[]. В приведенном выше фрагменте используется определение типа value_type, предоставляемое всеми стандартными контейнерами. Помните, что для mapи multimap(а также для нестандартных контейнеров hash_mapи hash_multimap— совет 25) тип элемента всегда представляет собой некую разновидность pair. Я уже упоминал о том, что operator[]упрощает операции «обновления с возможным созданием». Теперь мы знаем, что при создании insert работает эффективнее, чем operator[]. При обновлении, то есть при наличии эквивалентного ключа (см. совет 19) в контейнере map, ситуация полностью меняется. Чтобы понять, почему это происходит, рассмотрим потенциальные варианты обновления: m[k] = v; // Значение, ассоциируемое // с ключом k, заменяется на v при помощи оператора [] m.insert(intWidgetMap::value_type(k, v)).first->second = v; // Значение, ассоциируемое // с ключом k, заменяется // на v при помощи insert Вероятно, один внешний вид этих команд заставит вас выбрать operator[], но в данном случае речь идет об эффективности, поэтому фактор наглядности не учитывается. При вызове insertпередается аргумент типа inWidgetMap::value_type(то есть pair<int, Widget>), потому при вызове insertнеобходимо сконструировать и уничтожить объект данного типа. Следовательно, при вызове insertбудут вызваны конструктор и деструктор pair, что в свою очередь приведет к вызову конструктора и деструктора Widget, поскольку pair<int, Widget>содержит объект Widget. При вызове operator[]объект pairне используется, что позволяет избежать затрат на конструирование и уничтожение pairи Widget. Следовательно, при вставке элемента в mapпо соображениям эффективности желательно использовать insertвместо operator[], а при обновлении существующих элементов предпочтение отдается operator[], что объясняется как эффективностью, так и эстетическими соображениями. Конечно, нам хотелось бы видеть в STL функцию, которая бы автоматически выбирала оптимальное решение в синтаксически привлекательном виде. Интерфейс вызова мог бы выглядеть следующим образом: iterator affectedPair = // Если ключ к отсутствует в контейнере m, efficentAddOrUpdate(m, k, v); // выполнить эффективное добавление // pair(k, v) в m; в противном случае // выполнить эффективное обновление // значения, ассоциированного с ключом k. // Функция возвращает итератор // для добавленной или измененной пары В STL такая функция отсутствует, но как видно из следующего фрагмента, ее нетрудно написать самостоятельно. В комментариях даются краткие пояснения, а дополнительная информация приведена после листинга. template<typename МарТуре, // Тип контейнера typename KeyArgType, // Причины для передачи параметров-типов typename ValueArgType> // KeyArgType и ValueArgType // приведены ниже typename МарТуре::iterator efficientAddOrUpdate(MapType& m, const KeyArgType& k, const ValueArgType& v) { typename МарТуре:iterator lb = // Определить, где находится // или должен находиться ключ k. m.lower_bound(k); // Ключевое слово typename // рассматривается на с. 20 if (lb != m.end())&& !(m.key_comp()(k.lb->first))){ // Если lb ссылается на пару, // ключ которой эквивалентен k, lb->second = v; // …обновить ассоциируемое значение return lb; // и вернуть итератор для найденной пары } else { typedef typename МарТуре::value_type MVT; return m.insert(lb.MVT(k, v)); // Включить pair(k, v) в m и вернуть // итератор для нового элемента } } Для эффективного выполнения операций создания и обновления необходимо узнать, присутствует ли ключ к в контейнере; если присутствует — где он находится, а если нет — где он должен находиться. Задача идеально подходит для функции lower_bound(совет 45). Чтобы определить, обнаружила ли функция lower_boundэлемент с нужным ключом, мы проверяем вторую половину условия эквивалентности (см. совет 19). При этом сравнение должно производиться функцией, полученной при вызове map::keycomp. В результате проверки эквивалентности мы узнаем, какая операция выполняется — создание или обновление. Обновление реализовано весьма прямолинейно. С созданием дело обстоит поинтереснее, поскольку в нем используется «рекомендательная» форма insert. Конструкция m.insert(lb.MVT(k, v))«рекомендует» lbкак правильную точку вставки для нового элемента с ключом, эквивалентным k, а Стандарт гарантирует, что в случае правильности рекомендации вставка будет выполнена за постоянное время (вместо логарифмического). В efficentAddOrUpdateмы знаем, что lbопределяет правильную позицию вставки, поэтому insertвсегда выполняется с постоянным временем. У данной реализации есть одна интересная особенность — KeyArgTypeи ValueArgTypeне обязаны быть типами, хранящимися в контейнере, а всего лишь должны приводитьсяк этим типам. Существует и другое возможное решение — удалить параметры-типы KeyArgType/ValueArgTypeи заменить их на МарТуре::key_typeи МарТуре::mapped_type. Но в этом случае вызов может сопровождаться лишними преобразованиями типов. Возьмем определение контейнера map, встречавшееся в примерах: map<int, Widget> m; // См. ранее Также вспомним, что Widget допускает присваивание значений типа double: class Widget { // См. ранее public: Widget& operator=(double weight); }; Теперь рассмотрим следующий вызов efficientAddOrUpdate: effcientAddOrUpdate(m, 10, 15); Допустим, выполняется операция обновления, то есть mуже содержит элемент с ключом 10. В этом случае приведенный ранее шаблон заключает, что ValueArgTypeявляется double, и в теле функции число 1.5 в формате doubleнапрямую присваивается объекту Widget, ассоциированному с ключом 10. Присваивание осуществляется вызовом Widget::operator=(double). Если бы третий параметр efficientAddOrUpdateотносился к типу МарТуре::mapped_type, то число 1.5 в момент вызова было бы преобразовано в Widget, что привело бы к лишним затратам на конструирование (и последующее уничтожение) объекта Widget. Сколь бы интересными не были тонкости реализации efficientAddOrUpdate, не будем отклоняться от главной темы этого совета — от необходимости тщательного выбора между map::operator[]и map::insertв тех случаях, когда важна эффективность выполняемых операций. При обновлении существующего элемента mapрекомендуется использовать оператор [], но при создании нового элемента предпочтение отдается insert. Совет 25. Изучите нестандартные хэшированные контейнеры После первого знакомства с STL у большинства программистов неизбежно возникает вопрос: «Векторы, списки, множества… хорошо, но где же хэш-таблицы?» Действительно, хэш-таблицы не входят в стандартную библиотеку C++. Все сходятся на том, что это досадное упущение, но Комитет по стандартизации C++ решил, что усилия, затраченные на их поддержку, привели бы к чрезмерной задержке в работе над стандартом. По всей вероятности, хэш-таблицы появятся в следующей версии Стандарта, но в настоящий момент хеширование не поддерживается в STL. Программисты, не печальтесь! Вам не придется обходиться без хэш-таблиц или создавать собственные реализации. Существует немало готовых STL-совместимых хэшированных ассоциативных контейнеров с вполне стандартными именами: hash_set, hash_multiset, hash_mapи hash_multimap. Реализации, скрытые за похожими именами… мягко говоря, не похожи друг на друга. Различается все: интерфейсы, возможности, структуры данных и относительная эффективность поддерживаемых операций, Можно написать более или менее переносимый код, использующий хэш-таблицы, но стандартизация хэшированных контейнеров значительно упростила бы эту задачу (теперь понятно, почему стандарты так важны), Из всех существующих реализаций хэшированных контейнеров наибольшее распространение получили две: от SGI (совет 50) и от Dinkumware (приложение Б), поэтому дальнейшее описание ограничивается устройством хешированных контейнеров от этих разработчиков. STLport (совет 50) также содержит хэшированные контейнеры, но они базируются на реализации SGI. В контексте настоящего примера все сказанное о хэшированных контейнерах SGI относится и к хэшированным контейнерам STLport. Хэшированные контейнеры относятся к категории ассоциативных, поэтому им, как и всем остальным ассоциативным контейнерам, при объявлении следует задать тип объектов, хранящихся в контейнере, функцию сравнения для этих объектов и распределитель памяти. Кроме того, для работы хэшированному контейнеру необходима хэш-функция. Естественно предположить, что объявление хэшированного контейнера должно выглядеть примерно так: template<typename T, typename HashFunction, typename CompareFunction, typename Allocator = allocator<T> > class hash_контейнер; Полученное объявление весьма близко к объявлению хэшированных контейнеров в реализации SGI. Главное различие между ними заключается в том, что в реализации SGI для типов HashFunctionи CompareFunctionпредусмотрены значения по умолчанию. Объявление hash_setв реализации SGI выглядит следующим образом (слегка исправлено для удобства чтения): template<typename T, typename HashFunction = hash<T>, typename CompareFunction = equal_to<T>, typename Allocator = allocator<T> > class hash_set; В реализации SGI следует обратить внимание на использование equal_toв качестве функции сравнения по умолчанию. В этом она отличается от стандартных ассоциативных контейнеров, где по умолчанию используется функция сравнения less. Смысл этого изменения не сводится к простой замене функции. Хэшированные контейнеры SGI сравнивают два объекта, проверяя их равенство, а неэквивалентность (см. совет 19), Для хэшированных контейнеров такое решение вполне разумно, поскольку в хэшированных ассоциативных контейнерах, в отличие от их стандартных аналогов (обычно построенных на базе деревьев), элементы не хранятся в отсортированном порядке. В реализации Dinkumware принят несколько иной подход. Она также позволяет задать тип объектов, хэш-функцию, функцию сравнения и распределитель, но хэш-функция и функция сравнения по умолчанию перемещены в отдельный класс hash_compare, который передается по умолчанию в параметре HashingInfoшаблона контейнера. Например, объявление hash_set(также отформатированное для наглядности) в реализации Dinkumware выглядит следующим образом: template<typename T, typename CompareFunction> class hash_compare; template<typename T, typename Hashinglnfo = hash_compare<T, less<T>>, typename Allocator = allocator<T>> class hash_set; В этом интерфейсе внимание стоит обратить на использование параметра HashingInfo, содержащего функции хэширования и сравнения, а также перечисляемые типы, управляющие минимальным количеством гнезд в таблице и максимальным допустимым отношением числа элементов контейнера к числу гнезд. В случае превышения пороговой величины количество гнезд в таблице увеличивается, а некоторые элементы в таблице хэшируются заново (в реализации SGI предусмотрены функции, обеспечивающие аналогичные возможности управления количеством гнезд в таблице). После небольшого форматирования объявление hash_compare(значение по умолчанию для HashingInfo) выглядит примерно так: template<typename T ,typename CompareFunction=less<T> > class hash_compare { public: enum { bucket_size = 4, // Максимальное отношение числа элементов к числу гнезд min_buckets = 8 // Минимальное количество гнезд } size_t operator()(const T&) const; // Хэш-функция bool operator() (const T&, const T&) const; … // Некоторые подробности опущены, // включая использование CompareFunction }; Перегрузка operator()(в данном случае для реализации функций хэширования и сравнения) используется гораздо чаще, чем можно представить. Другое применение этой концепции продемонстрировано в совете 23. Реализация Dinkumware позволяет программисту написать собственный касс-аналог hash_compare(возможно, объявленный производным от hash_compare). Если этот класс будет определять bucket_size, min_buckets, две функции operator()(с одним и с двумя аргументами) и еще несколько мелочей, не упомянутых выше, он может использоваться для управления конфигурацией и поведением контейнеров Dinkumware hash_setи hash_multiset. Управление конфигурацией hash_mapи hash_multimapосуществляется аналогичным образом. Учтите, что в обоих вариантах все принятие решений можно поручить реализации и ограничиться объявлением следующего вида: hash_set<int> intTable; // Создать хешированное множество int Чтобы это объявление нормально компилировалось, хэш-таблица должна содержать данные целочисленных типов (например, int), поскольку стандартные хэш-функции обычно ограничиваются целочисленными типами (в реализации SGI стандартные хэш-функции обладают более широкими возможностями; о том, где найти дополнительную информацию, рассказано в совете 50). Принципы внутреннего устройства реализаций SGI и Dinkumware очень сильно различаются. В реализации SGI использована традиционная схема открытого хэширования с массивом указателей на односвязные списки элементов. В реализации Dinkumware используется двусвязный список. Различие достаточно принципиальное, поскольку оно влияет на категории итераторов, поддерживаемых этими реализациями. Хэшированные контейнеры SGI поддерживают прямые итераторы, что исключает возможность обратного перебора; в них отсутствуют такие функции, как rbeginили rend. Реализация Dinkumware поддерживает двусторонние итераторы, что позволяет осуществлять перебор как в прямом, так и в обратном направлении. С другой стороны, реализация SGI чуть экономнее расходует память. Какая из этих реализаций лучше подходит для ваших целей? Понятия не имею. Только вы можете ответить на этот вопрос, однако в этом совете я даже не пытался изложить все необходимое для принятия обоснованного решения. Речь идет о другом — вы должны знать, что несмотря на отсутствие хэшированных контейнеров непосредственно в STL, при необходимости можно легко найти STL-совместимые хэшированные контейнеры (с разными интерфейсами, возможностями и особенностями работы). Более того, в свободно распространяемых реализациях SGI и STLport вам за них даже не придется платить. Примечания:2 Строго говоря, не все 24 перестановки равновероятны, так что вероятность 1/24 не совсем точна. Тем не менее, остается бесспорный факт: существуют 24 разные перестановки, и вы можете получить любую из них. |
|
||
Главная | Контакты | Нашёл ошибку | Прислать материал | Добавить в избранное |
||||
|