Введение | |
43 | 1.3.1. Эффективность и структура |
73 | 2.5.5. Виртуальные функции |
79 | 2.7.2. Обобщенные алгоритмы |
128 | 5.1.1. Ноль |
192 | 7.4. Перегруженные имена функций |
199 | 7.6. Неуказанное количество аргументов |
202 | 7.7. Указатель на функцию |
296 | 10.4.6.2. Члены-константы |
297 | 10.4.7. Массивы |
316 | 11.3.1. Операторы-члены и не-члены |
328 | 11.5.1. Поиск друзей |
333 | 11.7.1. Явные конструкторы |
337 | 11.9. Вызов функции |
344 | 11.12. Класс String |
351 | 12.2. Производные классы |
361 | 12.2.6. Виртуальные функции |
382 | 13.2.3. Параметры шаблонов |
399 | 13.6.2. Члены-шаблоны |
419 | 14.4.1. Использование конструкторов и деструкторов |
421 | 14.4.2. auto_ptr |
422 | 14.4.4. Исключения и оператор new |
431 | 14.6.1. Проверка спецификаций исключений |
431 | 14.6.3. Отображение исключений |
460 | 15.3.2. Доступ к базовым классам |
461 | 15.3.2.1. Множественное наследование и управление доступом |
475 | 15.5. Указатели на члены |
477 | 15.6. Свободная память |
478 | 15.6. Свободная память |
479 | 15.6.1. Выделение памяти под массив |
480 | 15.6.2. "Виртуальные конструкторы" |
498 | 16.2.3. STL-контейнеры |
505 | 16.3.4. Конструкторы |
508 | 16.3.5. Операции со стеком |
526 | 17.1.4.1. Сравнения |
541 | 17.4.1.2. Итераторы и пары |
543 | 17.4.1.3. Индексация |
555 | 17.5.3.3. Другие операции |
556 | 17.6. Определение нового контейнера |
583 | 18.4.4.1. Связыватели |
584 | 18.4.4.2. Адаптеры функций-членов |
592 | 18.6. Алгоритмы, модифицирующие последовательность |
592 | 18.6.1. Копирование |
622 | 19.2.5. Обратные итераторы |
634 | 19.4.1. Стандартный распределитель памяти |
637 | 19.4.2. Распределители памяти, определяемые пользователем |
641 | 19.4.4. Неинициализированная память |
647 | 20.2.1. Особенности символов |
652 | 20.3.4. Конструкторы |
655 | 20.3.6. Присваивание |
676 | 21.2.2. Вывод встроенных типов |
687 | 21.3.4. Ввод символов |
701 | 21.4.6.3. Манипуляторы, определяемые пользователем |
711 | 21.6.2. Потоки ввода и буфера |
773 | 23.4.3.1. Этап 1: выявление классов |
879 | А.5. Выражения |
931 | B.13.2. Друзья |
935 | B.13.6. template как квалификатор |
Оптимизация | |
Макросы | |
Исходный код |
В процессе чтения (и многократного) перечитывания C++ 3rd у меня возникало множество вопросов, большая часть которых отпадала после изучения собственно стандарта и продолжительных раздумий, а за некоторыми приходилось обращаться непосредственно к автору. Хочется выразить безусловную благодарность д-ру Страуструпу за его ответы на все мои, заслуживающие внимания, вопросы и разрешение привести данные ответы здесь.
Как читать эту книгу. Прежде всего, нужно прочитать "Язык программирования C++" и только на этапе второго или третьего перечитывания обращаться к моему материалу, т.к. здесь кроме исправления ошибок русского перевода излагаются и весьма нетривиальные вещи, которые вряд ли будут интересны среднему программисту на C++. Моей целью было улучшить перевод C++ 3rd, насколько это возможно и пролить свет на множество интересных особенностей C++. Кроме того, оригинальное (английское) издание пережило довольно много тиражей, и каждый тираж содержал некоторые исправления, я постарался привести все существенные исправления здесь.
Если вы что-то не поняли в русском переводе, то первым делом стоит заглянуть в оригинал: Bjarne Stroustrup "The C++ Programming language", 3rd edition и/или в стандарт C++ (ISO/IEC 14882 Programming languages - C++, First edition, 1998-09-01). К слову сказать, как и любой другой труд сравнимого объема и сложности, стандарт C++ также содержит ошибки. Для того, чтобы быть в курсе последних изменений стандарта, будет полезным просматривать C++ Standard Core Issues List и C++ Standard Library Issues List на его оффициальной странице.
Также не помешает ознакомиться с классической STL, ведущей начало непосредственно от Алекса Степанова. И, главное, не забудьте заглянуть к самому Бьерну Страуструпу.
Кстати, если вы еще не читали "The C programming Language" by Brian W. Kernighan and Dennis M. Ritchie, 2е издание, то я вам советую непременно это сделать -- Классика!
С уважением, Сергей Деревяго.
new
, delete
, type_id
, dynamic_cast
, throw
и блока try
, отдельные выражения и инструкции C++ не требуют поддержки во время выполнения.
Хотелось бы отметить, что есть еще несколько очень важных мест, где мы имеем неожиданную и порой весьма существенную "поддержку времени выполнения". Это конструкторы/деструкторы (сложных) объектов, код создания/уничтожения массивов объектов, пролог/эпилог создающих объекты функций и, отчасти, вызовы виртуальных функций.
Для демонстрации данной печальной особенности рассмотрим следующую программу (замечу, что в исходном коде текст программы, как правило, разнесен по нескольким файлам для предотвращения агрессивного выбрасывания "мертвого кода" качественными оптимизаторами):
#include <stdio.h> #include <stdlib.h> #include <time.h> struct A { A(); ~A(); }; void ACon(); void ADes(); void f1() { A a; } void f2() { ACon(); ADes(); } long Var, Count; A::A() { Var++; } A::~A() { Var++; } void ACon() { Var++; } void ADes() { Var++; } 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<1000000; j++) f1(); c2=clock(); printf("f1(): %ld mlns 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<1000000; j++) f2(); c2=clock(); printf("f2(): %ld mlns calls per %.1f sec\n",Count,double(c2-c1)/CLK_TCK); } }В ней функции
f1()
и f2()
делают одно и то же, только первая неявно, с помощью конструктора и деструктора класса A
, а вторая с помощью явного вызова ACon()
и ADes()
.
Для работы программа требует одного параметра -- сколько миллионов раз вызывать тестовые функции. Выберите значение, позволяющее f1()
работать несколько секунд и посмотрите на результат для f2()
.
При использовании качественного оптимизатора никакой разницы быть не должно; тем не менее, на некоторых платформах она определенно есть и порой достигает 10 раз!
А что же inline
? Давайте внесем очевидные изменения:
struct A { A() { Var++; } ~A() { Var++; } }; void f1() { A a; } void f2() { Var++; Var++; }Теперь разницы во времени работы
f1()
и f2()
не быть должно. К несчастью, на большинстве компиляторов она все же присутствует.
Что же происходит? Наблюдаемый нами эффект называется abstraction penalty, т.е. обратная сторона абстракции или налагаемое на нас некачественными компиляторами наказание за использование (объектно-ориентированных) абстракций.
Давайте посмотрим как abstraction penalty проявляется в нашем случае.
Что же из себя представляет
void f1() { A a; }эквивалентное
void f1() // псевдокод { A::A(); A::~A(); }И чем оно отличается от простого вызова двух функций:
void f2() { ACon(); ADes(); }В данном случае -- ничем! Но, давайте рассмотрим похожий пример:
void f1() { A a; f(); } void f2() { ACon(); f(); ADes(); }Как вы думаете, эквивалентны ли данные функции? Правильный ответ -- нет, т.к.
f1()
представляет собой
void f1() // псевдокод { A::A(); try { f(); } catch (...) { A::~A(); throw; } A::~A(); }Т.е. если конструктор успешно завершил свою работу, то языком гарантируется, что обязательно будет вызван деструктор. Т.е. там, где создаются некоторые объекты, компилятор специально вставляет блоки обработки исключений для гарантии вызова соответствующих деструкторов. А накладные расходы в оригинальной
f1()
чаще всего будут вызваны присутствием ненужных в данном случае блоков обработки исключений (фактически, присутствием "утяжеленных" прологов/эпилогов):
void f1() // псевдокод { A::A(); try { // пусто } catch (...) { A::~A(); throw; } A::~A(); }Дело в том, что компилятор обязан корректно обрабатывать все возможные случаи, поэтому для упрощения компилятора его разработчики часто не принимают во внимание "частные случаи", в которых можно не генерировать ненужный код. Увы, подобного рода упрощения компилятора очень плохо сказываются на производительности интенсивно использующего средства абстракции и
inline
функции кода. Хорошим примером подобного рода кода является STL, чье использование, при наличии плохого оптимизатора, вызывает чрезмерные накладные расходы.
Поэкспериментируйте со своим компилятором для определения его abstraction penalty -- гарантированно пригодится при оптимизации "узких мест".
vtbl
для каждого такого класса.
На самом деле первое утверждение неверно, т.е. объект полученный в результате множественного наследования от полиморфных классов будет содержать несколько "унаследованных" указателей на vtbl
.
Рассмотрим следующий пример. Пусть у нас есть полиморфный (т.е. содержащий виртуальные функции) класс B1
:
struct B1 { // я написал struct чтобы не возиться с правами доступа int a1; int b1; virtual ~B1() { } };И пусть имеющаяся у нас реализация размещает
vptr
(указатель на таблицу виртуальных функций класса) перед объявленными нами членами. Тогда данные объекта класса B1
будут расположены в памяти следующим образом:
vptr_1 // указатель на vtbl класса B1 a1 // объявленные нами члены b1Если теперь объявить аналогичный класс
B2
и производный класс D
struct D: B1, B2 { virtual ~D() { } };то его данные будут расположены следующим образом:
vptr_d1 // указатель на vtbl класса D, для B1 здесь был vptr_1 a1 // унаследованные от B1 члены b1 vptr_d2 // указатель на vtbl класса D, для B2 здесь был vptr_2 a2 // унаследованные от B2 члены b2Почему здесь два
vptr
? Потому, что была проведена оптимизация, иначе их было бы три.
Я, конечно, понял, что вы имели ввиду: "Почему не один"? Не один, потому что мы имеем возможность преобразовывать указатель на производный класс в указатель на любой из базовых классов. При этом, полученный указатель должен указывать на корректный объект базового класса. Т.е. если я напишу:
D d; B2* ptr=&d;то в нашем примере
ptr
укажет в точности на vptr_d2
. А собственным vptr
класса D
будет являться vptr_d1
. Значения этих указателей, вообще говоря, различны. Почему? Потому что у B1
и B2
в vtbl
по одному и тому же индексу могут быть расположены разные виртуальные функции, а D
должен иметь возможность их правильно заместить. Т.о. vtbl
класса D
состоит из нескольких частей: часть для B1
, часть для B2
и часть для собственных нужд.
Подводя итог, можно сказать, что если мы используем множественное наследование от большого числа полиморфных классов, то накладные расходы по памяти могут быть достаточно существенными.
Судя по всему, от этих расходов можно отказаться, реализовав вызов виртуальной функции специальным образом, а именно: каждый раз вычисляя положение vptr
относительно this
и пересчитывая индекс вызываемой виртуальной функции в vtbl
. Однако это спровоцирует существенные расходы времени выполнения, что неприемлемо.
И раз уж так много слов было сказано про эффективность, давайте реально измерим относительную стоимость вызова виртуальной функции.
#include <stdio.h> #include <stdlib.h> #include <time.h> struct B { void f(); virtual void vf(); }; struct D : B { void vf(); // замещаем B::vf }; void f1(B* ptr) { ptr->f(); } void f2(B* ptr) { ptr->vf(); } long Var, Count; void B::f() { Var++; } void B::vf() { } void D::vf() { Var++; } int main(int argc,char** argv) { if (argc>1) Count=atol(argv[1]); clock_t c1,c2; D d; { c1=clock(); for (long i=0; i<Count; i++) for (long j=0; j<1000000; j++) f1(&d); c2=clock(); printf("f1(): %ld mlns 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<1000000; j++) f2(&d); c2=clock(); printf("f2(): %ld mlns calls per %.1f sec\n",Count,double(c2-c1)/CLK_TCK); } }В зависимости от компилятора и платформы, накладные расходы на вызов виртуальной функции составили от 10% до 2.5 раз. Т.о. можно утверждать, что "виртуальность" небольших функций может обойтись сравнительно дорого.
И слово "небольших" здесь не случайно, т.к. уже даже тест с функцией Аккермана (отлично подходящей для выявления относительной стоимости вызова)
#include <stdio.h> #include <stdlib.h> #include <time.h> struct B { int ackf(int x, int y); virtual int vackf(int x, int y); }; struct D : B { int vackf(int x, int y); // замещаем B::vackf }; void f1(B* ptr) { ptr->ackf(3, 5); // 42438 вызовов! } void f2(B* ptr) { ptr->vackf(3, 5); // 42438 вызовов! } int B::ackf(int x, int y) { if (x==0) return y+1; else if (y==0) return ackf(x-1, 1); else return ackf(x-1, ackf(x, y-1)); } int B::vackf(int x, int y) { return 0; } int D::vackf(int x, int y) { if (x==0) return y+1; else if (y==0) return vackf(x-1, 1); else return vackf(x-1, vackf(x, y-1)); } long Count; int main(int argc,char** argv) { if (argc>1) Count=atol(argv[1]); clock_t c1,c2; D d; { c1=clock(); for (long i=0; i<Count; i++) for (long j=0; j<1000; j++) f1(&d); 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(&d); c2=clock(); printf("f2(): %ld ths calls per %.1f sec\n", Count, double(c2-c1)/CLK_TCK); } }показывает заметно другие результаты, существенно уменьшая относительную разность времени выполнения.
char vc1[200]; char vc2[500]; void f() { copy(&vc1[0],&vc1[200],&vc2[0]); }
Ну, если к делу подойти формально, то записать мы так не можем. Вот что говорит об этом д-р Страуструп:
The issue is whether taking the address of one-past-the-last element of an array is conforming C and C++. I could make the example clearly conforming by a simple rewrite:copy(vc1,vc1+200,vc2);However, I don't want to introduce addition to pointers at this point of the book. It is a surprise to most experienced C and C++ programmers that&vc1[200]
isn't completely equivalent tovc1+200
. In fact, it was a surprise to the C committee also and I expect it to be fixed in the upcoming revision of the standard. (also resolved for C9x - bs 10/13/98).Суть вопроса в том, разрешено ли в C и C++ взятие адреса элемента, следующего за последним элементом массива. Я мог сделать пример очевидно корректным простой заменой:
copy(vc1,vc1+200,vc2);Однако, я не хотел вводить сложение с указателем в этой части книги. Даже для самых опытных программистов на C и C++ большим сюрпризом является тот факт, что&vc1[200]
не полностью эквивалентноvc1+200
. Фактически, это оказалось неожиданностью и для C комитета, и я ожидаю, что это недоразумение будет устранено в следующих редакциях стандарта.
Так в чем же нарушается эквивалентность? По стандарту C++ мы имеем следующие эквивалентные преобразования:
&vc1[200] -> &(*((vc1)+(200))) -> &*(vc1+200)Действительно ли равенство
&*(vc1+200) == vc1+200
неверно?
It is false in C89 and C++, but not in K&R C or C9x. The C89 standard simply said that&*(vc1+200)
means dereferencevc1+200
(which is an error) and then take the address of the result, and the C++ standard copiled the C89 wording. K&R C and C9x say that&*
cancels out so that&*(vc1+200) == vc2+200
.Это неверно в С89 и C++, но не в K&R C или С9х. Стандарт С89 говорит, что
&*(vc1+200)
означает разыменованиеvc1+200
(что является ошибкой) и затем взятие адреса результата. И стандарт C++ просто взял эту формулировку из С89. Однако K&R C и С9х устанавливают, что&*
взаимно уничтожаются, т.е.&*(vc1+200) == vc1+200
.
Спешу вас успокоить, что на практике в выражении &*(vc1+200)
некорректное разыменование *(vc1+200)
практически никогда не произойдет, т.к. результатом всего выражения является адрес и ни один серьезный компилятор не станет выбирать значение по некоторому адресу (операция разыменования) чтобы потом получить тот же самый адрес с помощью операции &
.
NULL
, воспользуйтесь
const int NULL=0;
Суть данного совета в том, что согласно определению языка не существует контекста, в котором (определенное в заголовочном файле) значение NULL
было бы корректным, в то время как просто 0
-- нет.
Исходя из того же определения, передача NULL
в функции с переменным количеством параметров вместо корректного выражения вида static_cast<SomeType*>(0)
запрещена.
Безусловно, все это правильно, но на практике NULL
в функции с переменным количеством параметров все же передают. Например, так:
#include <stdio.h> #include <stdarg.h> #include <stdlib.h> void error(int stat ...) { va_list ap; va_start(ap, stat); while (const char* sarg=va_arg(ap, const char *)) printf("%s", sarg); va_end(ap); exit(stat); } int main() { error(1, "Случилось ", "страшное", NULL); // внимание, ошибка! // вместо NULL нужно использовать // static_cast<const char *>(0) }Именно для поддержки подобного рода практики (некорректной, но широко распространенной) реализациям разрешено определять
NULL
как 0L
(а не просто 0
) на архитектурах, где sizeof(void*)==sizeof(long)>sizeof(int)
.
Приведенный в книге пункт [2] нужно заменить на:
bool
в int
, char
в int
, short
в int;
B.6.1), float
в double
.
struct A { private: void f(int); public: void f(...); }; void g() { A a; a.f(1); // ошибка: выбирается A::f(int), использование // которой в g() запрещено }Отсутствие данного правила породило бы тонкие ошибки, когда выбор подходящей функции зависел бы от места вызова: в функции-члене или в обычной функции.
va_start()
, необходимо осуществить вызов va_end()
. Причина состоит в том, что va_start()
может модифицировать стек таким образом, что станет невозможен нормальный выход из функции.
Ввиду чего возникают совершенно незаметные подводные камни.
Общеизвестно, что обработка исключения предполагает раскрутку стека. Следовательно, если в момент возбуждения исключения функция изменила стек, то у вас гарантированно будут неприятности.
Таким образом, до вызова va_end()
следует воздерживаться от потенциально вызывающих исключения операций. Специально добавлю, что ввод/вывод C++ может генерировать исключения, т.е. "наивная" техника вывода в std::cout
до вызова va_end()
чревата неприятностями.
cmp3
в качестве аргумента ssort()
нарушило бы гарантию того, что ssort()
вызовется с аргументами mytype*
.
Здесь имеет место досадная опечатка, совершенно искажающая смысл предложения. Следует читать так: Причина в том, что разрешение использования cmp3
в качестве аргумента ssort()
нарушило бы гарантию того, что cmp3()
вызовется с аргументами mytype*
.
Вроде бы все хорошо, но почему только интегрального типа? В чем причина подобной дискриминации? Д-р Страуструп пишет по этому поводу следующее:
The reason for "discriminating against" floating points in constant expressions is that the precision of floating point traditionally varied radically between processors. In principle, constant expressions should be evaluated on the target processor if you are cross compiling.Причина подобной "дискриминации" плавающей арифметики в константных выражениях в том, что обычно точность подобных операций на разных процессорах существенно отличается. В принципе, если вы осуществляете кросс-компиляцию, то такие константные выражения должны вычисляться на целевом процессоре.
Т.е. в процессе кросс-компиляции на процессоре другой архитектуры будет крайне проблематично абсолютно точно вычислить константное выражение, которое могло бы быть использовано в качестве литерала (а не адреса ячейки памяти) в машинных командах целевого процессора.
Судя по всему, за пределами задач кросс-компиляции (которые, к слову сказать, встречаются не так уж и часто) никаких проблем с определением нецелочисленных констант не возникает, т.к. некоторые компиляторы вполне допускают код вида
class Curious { static const float c5=7.0; };в качестве (непереносимого) расширения языка.
К счастью, это ограничение можно сравнительно легко обойти. Например, посредством введения локального класса:
#include <stdio.h> struct A { // исходный класс int a; A(int a_) : a(a_) { printf("%d\n",a); } }; void f() { static int vals[]={2, 0, 0, 4}; static int curr=0; struct A_local : public A { // вспомогательный локальный A_local() : A(vals[curr++]) { } }; A_local arr[4]; // и далее используем как A arr[4]; } int main() { f(); }Т.к. локальные классы и их использование остались за рамками книги, далее приводится соответствующий раздел стандарта:
9.8 Объявления локальных классов [class.local]
int x; void f() { static int s; int x; extern int g(); struct local { int g() { return x; } // ошибка, auto x int h() { return s; } // OK int k() { return ::x; } // OK int l() { return g(); } // OK }; // ... } local* p = 0; // ошибка: нет local в текущем контексте
Y
может быть объявлен внутри локального класса X
и определен внутри определения класса X
или же за его пределами, но в том же контексте (scope), что и класс X
. Вложенный класс локального класса сам является локальным.
complex r1=x+y+z; // r1=operator+(x,operator+(y,z))
На самом деле данное выражение будет проинтерпретировано так:
complex r1=x+y+z; // r1=operator+(operator+(x,y),z)Потому что операция сложения левоассоциативна:
(x+y)+z
.
// нет f() в данной области видимости class X { friend void f(); // бесполезно friend void h(const X&); // может быть найдена по аргументам }; void g(const X& x) { f(); // нет f() в данной области видимости h(x); // h() -- друг X }Он взят из списка авторских исправлений к 8-му тиражу и показывает, что если
f
не было в области видимости, то объявление функции-друга f()
внутри класса X
не вносит имя f
в область видимости, так что попытка вызова f()
из g()
является ошибкой.
String s1='a'; // ошибка: нет явного преобразования char в String String s2(10); // правильно: строка для хранения 10 символовможет показаться очень тонкой...
Но она несомненно есть. И дело тут вот в чем.
Запись
X a=b;всегда означает создание объекта
a
класса X
посредством копирования значения некоторого другого объекта класса X
. Здесь может быть два варианта:
b
уже является объектом класса X
. В этом случае мы получим непосредственный вызов конструктора копирования:
X a(b);
b
объектом класса X
не является. В этом случае должен быть создан временный объект класса X
, чье значение будет затем скопировано:
X a(X(b));Именно этот временный объект и не может быть создан в случае explicit-конструктора, что приводит к ошибке компиляции.
12.8 Копирование объектов классов [class.copy]
return
выражение является именем локального объекта, тип которого (игнорируя cv-квалификаторы) совпадает с типом возврата, реализации разрешено не создавать временный объект для хранения возвращаемого значения, даже если конструктор копирования или деструктор имеют побочные эффекты. В этих случаях объект будет уничтожен позднее, чем были бы уничтожены оригинальный объект и его копия, если бы данная оптимизация не использовалась.
#include <stdio.h> #include <string.h> struct A { static const int nsize=10; char n[nsize]; A(char cn) { n[0]=cn; n[1]=0; printf("%5s.A::A()\n", n); } A(const A& a) { if (strlen(a.n)<=nsize-2) { n[0]='?'; strcpy(n+1, a.n); } else strcpy(n, "беда"); printf("%5s.A::A(const A& %s)\n", n, a.n); } ~A() { printf("%5s.A::~A()\n", n); } A& operator=(const A& a) { if (strlen(a.n)<=nsize-2) { n[0]='='; strcpy(n+1, a.n); } else strcpy(n, "беда"); printf("%5s.A::operator=(const A& %s)\n", n, a.n); return *this; } }; A f1(A a) { printf("A f1(A %s)\n", a.n); return a; } A f2() { printf("A f2()\n"); A b('b'); return b; } A f3() { printf("A f3()\n"); return A('c'); } int main() { { A a('a'); A b='b'; A c(A('c')); A d=A('d'); } printf("----------\n"); { A a('a'); A b=f1(a); printf("b это %s\n", b.n); } printf("----------\n"); { A a=f2(); printf("a это %s\n", a.n); } printf("----------\n"); { A a=f3(); printf("a это %s\n", a.n); } }Прежде всего, в
main()
разными способами создаются объекты a
, b
, c
и d
. В нормальной реализации вы получите следующий вывод:
a.A::A() b.A::A() c.A::A() d.A::A() d.A::~A() c.A::~A() b.A::~A() a.A::~A()Там же, где разработчики компилятора схалтурили, появятся ненужные временные объекты, например:
... c.A::A() ?c.A::A(const A& c) c.A::~A() d.A::A() d.A::~A() ?c.A::~A() ...Т.е.
A c(A('c'))
превратилось в A tmp('c'), c(tmp)
. Далее, вызов f1()
демонстрирует неявные вызовы конструкторов копирования во всей красе:
a.A::A() ?a.A::A(const A& a) A f1(A ?a) ??a.A::A(const A& ?a) ?a.A::~A() b это ??a ??a.A::~A() a.A::~A()На основании
a
создается временный объект ?a
, и передается f1()
качестве аргумента. Далее, внутри f1()
на основании ?a
создается другой временный объект -- ??a
, он нужен для возврата значения. И вот тут-то и происходит исключение нового временного объекта: b это ??a
, т.е. локальная переменная main()
b
-- это та самая, созданная в f1()
переменная ??a
, а не ее копия (специально для сомневающихся: будь это не так, мы бы увидели b это ???a
).
Полностью согласен -- все это действительно очень запутано, но разобраться все же стоит. Для более явной демонстрации исключения временной переменной я написал f2()
и f3()
:
A f2() b.A::A() ?b.A::A(const A& b) b.A::~A() a это ?b ?b.A::~A() ---------- A f3() c.A::A() a это c c.A::~A()В
f3()
оно происходит, а в f2()
-- нет! Как говорится, все дело в волшебных пузырьках.
Другого объяснения нет, т.к. временная переменная могла была исключена в обоих случаях (ох уж мне эти писатели компиляторов!).
А сейчас рассмотрим более интересный случай -- перегрузку операторов. Внесем в наш класс соответствующие изменения:
#include <stdio.h> #include <string.h> struct A { static const int nsize=10; static int tmpcount; int val; char n[nsize]; A(int val_) : val(val_) // для создания временных объектов { sprintf(n, "_%d", ++tmpcount); printf("%5s.A::A(int %d)\n", n, val); } A(char cn, int val_) : val(val_) { n[0]=cn; n[1]=0; printf("%5s.A::A(char, int %d)\n", n, val); } A(const A& a) : val(a.val) { if (strlen(a.n)<=nsize-2) { n[0]='?'; strcpy(n+1, a.n); } else strcpy(n, "беда"); printf("%5s.A::A(const A& %s)\n", n, a.n); } ~A() { printf("%5s.A::~A()\n", n); } A& operator=(const A& a) { val=a.val; if (strlen(a.n)<=nsize-2) { n[0]='='; strcpy(n+1, a.n); } else strcpy(n, "беда"); printf("%5s.A::operator=(const A& %s)\n", n, a.n); return *this; } friend A operator+(const A& a1, const A& a2) { printf("operator+(const A& %s, const A& %s)\n", a1.n, a2.n); return A(a1.val+a2.val); } }; int A::tmpcount; int main() { A a('a', 1), b('b', 2), c('c', 3); A d=a+b+c; printf("d это %s\n", d.n); printf("d.val=%d\n", d.val); }После запуска вы должны получить следующие результаты:
a.A::A(char,int 1) b.A::A(char,int 2) c.A::A(char,int 3) operator+(const A& a,const A& b) _1.A::A(int 3) operator+(const A& _1,const A& c) _2.A::A(int 6) _1.A::~A() d это _2 d.val=6 _2.A::~A() c.A::~A() b.A::~A() a.A::~A()Все довольно наглядно, так что объяснения излишни. А для демонстрации работы оператора присваивания попробуйте
A d('d',0); d=a+b+c;В данном случае будет задействовано на одну временную переменную больше:
a.A::A(char,int 1) b.A::A(char,int 2) c.A::A(char,int 3) d.A::A(char,int 0) operator+(const A& a,const A& b) _1.A::A(int 3) operator+(const A& _1,const A& c) _2.A::A(int 6) =_2.A::operator=(const A& _2) _2.A::~A() _1.A::~A() d это =_2 d.val=6 =_2.A::~A() c.A::~A() b.A::~A() a.A::~A()
operator()()
объекта Add(z)
.
Использование шаблонов и смысл их параметров может стать для вас совершенно непонятным, если раз и навсегда не уяснить одну простую вещь: при вызове функции-шаблона вы передаете объекты, но критически важной для инстанциирования шаблонов информацией являются типы переданных объектов. Сейчас я проиллюстрирую данную идею на приведенном в книге примере.
Рассмотрим, например, определение функции-шаблона for_each()
template <class InputIter, class Function> Function for_each(InputIter first, InputIter last, Function f) { for ( ; first != last; ++first) f(*first); return f; }Данное определение я взял непосредственно из sgi STL (предварительно убрав символы подчеркивания для улучшения читаемости). Если сравнить его с приведенным в книге, то сразу бросается в глаза исправление типа возвращаемого значения (по стандарту должен быть аргумент-функция) и отказ от использования потенциально менее эффективного постинкремента итератора.
Когда мы вызываем for_each()
c аргументом Add(z)
,
for_each(ll.begin(), ll.end(), Add(z));то
Function
-- это Add
, т.е. тип, а не объект Add(z)
. И по определению for_each()
компилятором будет сгенерирован следующий код:
Add for_each(InputIter first, InputIter last, Add f) { for ( ; first != last; ++first) f.operator()(*first); return f; }Т.о. в момент вызова
for_each()
будет создан временный объект Add(z)
, который затем и будет передан в качестве аргумента. После чего, внутри for_each()
для копии этого объекта будет вызываться Add::operator()(complex&)
. Конечно, тип InputIter
также будет заменен типом соответствующего итератора, но в данный момент это нас не интересует.
На что же я хочу обратить ваше внимание? Я хочу отметить, что шаблон -- это не макрос в который передается что-то, к чему можно приписать скобки с соответствующими аргументами. Если бы шаблон был макросом, непосредственно принимающим переданный объект, то мы бы получили
Add for_each(...) { for (...) Add(z).operator()(*first); return f; }что, в принципе, тоже корректно, только крайне неэффективно: при каждом проходе цикла создается временный объект, к которому затем применяется операция вызова функции.
String
s.operator[](1)
означает Cref(s,1)
.
А вот здесь хотелось бы поподробнее. Почему в одном классе мы можем объявить const
и не const
функции-члены? Как осуществляется выбор перегруженной функции?
Рассмотрим следующее объявление:
struct X { void f(int); void f(int) const; }; void h() { const X cx; cx.f(1); X x; x.f(2); }Ввиду того, что функция-член всегда имеет скрытый параметр
this
, компилятор воспринимает данное объявление как
// псевдокод struct X { void f( X *const this); void f(const X *const this); }; void h() { const X cx; X::f(&cx,1); X x; X::f(&x,2); }и выбор перегруженной функции осуществляется по обычным правилам. В общем, никакой мистики.
Вместе с тем, данная терминология совершенно естественна в теоретико-множественном смысле. А именно: каждый объект производного класса является объектом базового класса, а обратное, вообще говоря, неверно. Т.о. базовый класс шире, поэтому он и суперкласс. Путаница возникает из-за того, что больше сам класс, а не его объекты, которые ввиду большей общности класса должны иметь меньше особенностей (членов).
Это, вообще говоря, неверно. При применении множественного наследования "просто косвенного вызова" оказывается недостаточно. Рассмотрим следующую программу:
#include <stdio.h> struct B1 { int b1; // непустая virtual ~B1() { } }; struct B2 { int b2; // непустая virtual void vfun() { } }; struct D : B1, B2 { // множественное наследование от непустых классов virtual void vfun() { printf("D::vfun(): this=%p\n", this); } }; int main() { D d; D* dptr=&d; printf("dptr\t%p\n", dptr); dptr->vfun(); B2* b2ptr=&d; printf("b2ptr\t%p\n", b2ptr); b2ptr->vfun(); }На своей машине я получил следующие результаты:
dptr 0x283fee8 D::vfun(): this=0x283fee8 b2ptr 0x283feec D::vfun(): this=0x283fee8Т.е. при вызове через указатель на производный класс
dptr
, внутри D::vfun()
мы получим this=0x283fee8
. Но несмотря на то, что после преобразования исходного указателя в указатель на (второй) базовый класс b2ptr
, его значение (очевидно) изменилось, внутри D::vfun()
мы все равно видим исходное значение, что полностью соответствует ожиданиям D::vfun()
относительно типа и значения своего this
.
Что же все это означает? А означает это то, что если бы вызов виртуальной функции
struct D : B1, B2 { virtual void vfun(D *const this) // псевдокод { // ... } };через указатель
ptr->vfun()
всегда сводился бы к вызову (*vtbl[index_of_vfun])(ptr)
, то в нашей программе мы бы получили b2ptr==0x283feec==this!=0x283fee8
.
Вопрос номер два: как они это делают? Суть проблемы в том, что одна и та же замещенная виртуальная функция (D::vfun()
в нашем случае) может быть вызвана как через указатель на производный класс (ptr==0x283fee8
) так и через указатель на один из базовых классов (ptr==0x283feec
), чьи значения не совпадают, в то время как переданное значение this
должно быть одним и тем же (this==0x283fee8
) в обоих случаях.
К счастью, vtbl
содержит разные записи для каждого из вариантов вызова, так что решение, очевидно, есть. На практике, чаще всего, используется один из следующих способов:
vtbl
добавляется дополнительная колонка -- vdelta
. Тогда в процессе вызова виртуальной функции кроме адреса функции из vtbl
извлекается и дельта, чье значение добавляется к ptr
:
addr=vtbl[index].vaddr; // извлекаем адрес функции vfun delta=vtbl[index].vdelta; // извлекаем дельту, зависящую от способа вызова vfun (*addr)(ptr+delta); // вызываем vfunСущественным недостатком данного способа является заметное увеличение размеров
vtbl
и значительные накладные расходы времени выполнения: дело в том, что абсолютное большинство вызовов виртуальных функций не требует коррекции значения ptr
, так что соответствующие им значения vdelta
будут нулевыми. Достоинством -- возможность вызова виртуальной функции из ANSI C кода, что важно для C++ -> C трансляторов.
ptr
(если это вообще нужно):
vfun_entry_0: // ... // собственно код vfun // ... return; vfun_entry_1: ptr+=delta_1; // корректируем значение ptr goto vfun_entry_0; // и переходим к телу vfunВ этом случае
vtbl
содержит только адреса соответствующих точек входа и никаких напрасных вычислений не требуется. Специфическим недостатком данного способа является невозможность его реализации средствами ANSI C.
Потому что строковый литерал -- это объект с внутренней компоновкой (internal linkage).
М-да... Определенно, не самое удачное место русского перевода. Тем более, что в оригинале все предельно просто и понятно:
Curiously enough, a template constructor is never used to generate a copy constructor, so without the explicitly declared copy constructor, a default copy constructor would have been generated.Как ни странно, конструктор-шаблон никогда не используется для генерации конструктора копирования, т.е. без явно определенного конструктора копирования будет сгенерирован конструктор копирования по умолчанию.
Далее хочу отметить, что постоянно встречающуюся в переводе фразу "конструктор шаблона" следует понимать как "конструктор-шаблон".
Если вы решили, что тем самым должна повыситься производительность, ввиду того, что в теле функции отсутствуют блоки try/catch
, то должен вас огорчить -- они будут автоматически сгенерированы компилятором для корректной обработки раскрутки стека. Но все-таки, какая версия выделения ресурсов обеспечивает большую производительность? Давайте протестируем следующий код:
#include <stdio.h> #include <stdlib.h> #include <time.h> void ResourceAcquire(); void ResourceRelease(); void Work(); struct RAII { RAII() { ResourceAcquire(); } ~RAII() { ResourceRelease(); } }; void f1() { ResourceAcquire(); try { Work(); } catch (...) { ResourceRelease(); throw; } ResourceRelease(); } void f2() { RAII raii; Work(); } long Var, Count; void ResourceAcquire() { Var++; } void ResourceRelease() { Var--; } void Work() { Var+=2; } 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<1000000; j++) f1(); c2=clock(); printf("f1(): %ld mln 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<1000000; j++) f2(); c2=clock(); printf("f2(): %ld mln calls per %.1f sec\n", Count, double(c2-c1)/CLK_TCK); } }Как выдумаете, какая функция работает быстрее? А вот и нет! В зависимости от компилятора быстрее работает то
f1()
, то f2()
, а иногда они работают совершенно одинаково из-за полной идентичности сгенерированного компилятором кода. Все зависит от используемых принципов обработки исключений и качества оптимизатора.
Как же работают исключения? Если вкратце, то в разных реализациях исключения работают по-разному. И всегда чрезвычайно нетривиально! Особенно много сложностей возникает с ОС, использующими так называемый Structured Exception Handling и/или поддерживающими многопоточность (multithreading). Фактически, с привычными нам современными ОС...
На текущий момент в Internet можно найти достаточное количество материала по реализации exception handling (EH) в C++ и не только, приводить здесь который не имеет особого смысла. Тем не менее, влияние EH на производительность C++ программ заслуживает отдельного обсуждения.
Увы, но стараниями недобросовестных "преувеличителей достоинств" в массы пошел миф о том, что обработку исключений можно реализовать вообще без накладных расходов. На самом деле это не так, т.к. даже самый совершенный метод реализации EH, отслеживающий созданные (и, следовательно, подлежащие уничтожению) на данный момент (под)объекты по значению счетчика команд (например, регистр (E)IP процессоров Intel-архитектуры) не срабатывает в случае создания массивов.
Но более надежным (и, кстати, не зависящим от способа реализации EH) опровержением исходной посылки является тот факт, что EH добавляет дополнительные дуги в Control Flow Graph, т.е. в граф потоков управления, что не может не сказаться на возможностях оптимизаци.
Тем не менее, накладные расходы на EH в лучших реализациях не превышают 5%, что с практической точки зрения почти эквивалентно полному отсутствию расходов.
Но это в лучших реализациях! О том, что происходит в реализациях "обычных" лучше не упоминать -- как говорит герой известного анекдота: "Гадкое зрелище"...
auto_ptr
<memory>
auto_ptr
объявлен следующим образом...
Ввиду того, что после выхода первых (английских) тиражей стандарт претерпел некоторые изменения в части auto_ptr
, концовку данного раздела следует заменить следующим текстом (он взят из списка авторских исправлений к 4 тиражу).
Для достижения данной семантики владения (также называемой семантикой разрушающего копирования (destructive copy semantics)), семантика копирования шаблона auto_ptr
радикально отличается от семантики копирования обычных указателей: когда один auto_ptr
копируется или присваивается другому, исходный auto_ptr
очищается (эквивалентно присваиванию 0
указателю). Т.к. копирование auto_ptr
приводит к его изменению, то const auto_ptr
не может быть скопирован.
Шаблон auto_ptr
определен в <memory>
следующим образом:
template<class X> class std::auto_ptr { // вспомогательный класс template <class Y> struct auto_ptr_ref { /* ... */ }; X* ptr; public: typedef X element_type; explicit auto_ptr(X* p =0) throw() { ptr=p; } ~auto_ptr() throw() { delete ptr; } // обратите внимание: конструкторы копирования и операторы // присваивания имеют неконстантные аргументы // скопировать, потом a.ptr=0 auto_ptr(auto_ptr& a) throw(); // скопировать, потом a.ptr=0 template<class Y> auto_ptr(auto_ptr<Y>& a) throw(); // скопировать, потом a.ptr=0 auto_ptr& operator=(auto_ptr& a) throw(); // скопировать, потом a.ptr=0 template<class Y> auto_ptr& operator=(auto_ptr<Y>& a) throw(); X& operator*() const throw() { return *ptr; } X* operator->() const throw() { return ptr; } // вернуть указатель X* get() const throw() { return ptr; } // передать владение X* release() throw() { X* t = ptr; ptr=0; return t; } void reset(X* p =0) throw() { if (p!=ptr) { delete ptr; ptr=p; } } // скопировать из auto_ptr_ref auto_ptr(auto_ptr_ref<X>) throw(); // скопировать в auto_ptr_ref template<class Y> operator auto_ptr_ref<Y>() throw(); // разрушающее копирование из auto_ptr template<class Y> operator auto_ptr<Y>() throw(); };Назначение
auto_ptr_ref
-- обеспечить семантику разрушающего копирования, ввиду чего копирование константного auto_ptr
становится невозможным. Конструктор-шаблон и оператор присваивания-шаблон обеспечивают возможность неявного пребразования auto_ptr<D>
в auto_ptr<B>
если D*
может быть преобразован в B*
, например:
void g(Circle* pc) { auto_ptr<Circle> p2 = pc; // сейчас p2 отвечает за удаление auto_ptr<Circle> p3 = p2; // сейчас p3 отвечает за удаление, // а p2 уже нет p2->m = 7; // ошибка программиста: p2.get()==0 Shape* ps = p3.get(); // извлечение указателя auto_ptr<Shape> aps = p3; // передача прав собственности и // преобразование типа auto_ptr<Circle> p4 = pc; // ошибка: теперь p4 также отвечает за удаление }Эффект от использования нескольких
auto_ptr
для одного и того же объекта неопределен; в большинстве случаев объект будет уничтожен дважды, что приведет к разрушительным результатам.
Следует отметить, что семантика разрушающего копирования не удовлетворяет требованиям к элементам стандартных контейнеров или стандартных алгоритмов, таких как sort()
. Например:
// опасно: использование auto_ptr в контейнере void h(vector<auto_ptr<Shape> >& v) { sort(v.begin(),v.end()); // не делайте так: элементы не будут отсортированы }Понятно, что
auto_ptr
не является обычным "умным" указателем, однако он прекрасно справляется с предоставленной ему ролью -- обеспечивать безопасную относительно исключений работу с автоматическими указателями, и делать это без существенных накладных расходов.
new
Т.к. приведенные в книге объяснения немного туманны, вот соответствующая часть стандарта:
5.3.4. New [expr.new]
typedef
не может ее содержать.
Сразу же возникает вопрос: в чем причина этого неудобного ограничения? Д-р Страуструп пишет по этому поводу следующее:
The reason is the exception spacification is not part of the type; it is a constraint that is checked on assignment and exforced at run time (rather than at compile time). Some people would like it to be part of the type, but it isn't. The reason is to avoid difficulties when updating large systems with parts from different sources. See "The Design and Evolution of C++" for details.Причина в том, что спецификации исключений не являются частью типа; данное ограничение проверяется при присваивании и принудительно обеспечивается во время выполнения (а не во время компиляции). Некоторым людям хотелось бы, чтобы спецификации исключений были частью типа, но это не так. Причина в том, что мы хотим избежать трудностей, возникающих при внесении изменений в большие системы, состоящие из отдельных частей полученных из разных источников. Обратитесь к книге "Дизайн и эволюция C++" за деталями.
По моему мнению, спецификации возбуждаемых исключений -- это одна из самых неудачных частей определения C++. Исторически, неадекватность существующего механизма спецификации исключений обусловлена отсутствием реального опыта систематического применения исключений в C++ (и возникающих при этом вопросов exception safety) на момент их введения в определение языка. К слову сказать, о сложности проблемы говорит и тот факт, что в Java, появившемся заметно позже C++, спецификации возбуждаемых исключений так же реализованы неудачно.
Имеющийся на текущий момент опыт свидетельствует о том, что критически важной для написания exception safe кода информацией является ответ на вопрос: Может ли функция вообще возбуждать исключения? Эта информация известна уже на этапе компиляции и может быть проверена без особого труда.
Так, например, можно ввести ключевое слово nothrow
:
// ключевое слово nothrow отсутствует: // f() разрешено возбуждать любые исключения прямо или косвенно void f() { // ... }
// f() запрещено возбуждать любые исключения прямо или косвенно, // проверяется на этапе компиляции void f() nothrow { // ... }
void f() { // здесь можно возбуждать исключения прямо или косвенно nothrow { // nothrow-блок // код, находящийся в данном блоке никаких исключений возбуждать // не должен, проверяется на этапе компиляции } // здесь снова можно возбуждать исключения }
std::bad_exception
описанным в данном разделе образом. Вот что об этом пишет д-р Страуструп:
The standard doesn't support the mapping of exceptions as I describe it in 14.6.3. It specifies mapping tostd::bad_exception
for exceptions thrown explicitly within anunexpected()
function. This makesstd::bad_exception
an ordinary and rather pointless exception. The current wording does not agree with the intent of the proposer of the mechanism (Dmitry Lenkov of HP) and what he thought was voted in. I have raised the issue in the standards committee.Стандарт не поддерживает отображение исключений в том виде, как это было мной описано в разделе 14.6.3. Он специфицирует отображение в
std::bad_exception
только для исключений, явно возбужденных в функцииunexpected()
. Это лишаетstd::bad_exception
первоначального смысла, делая его обычным и сравнительно бессмысленным исключением. Текущая формулировка (стандарта) не совпадает с первоначально предложенной Дмитрием Ленковым из HP. Я возбудил соответствующее issue в комитете по стандартизации.
Ну и раз уж столько слов было сказано про формулировку из стандарта, думаю, что стоит ее привести:
15.5.2 Функция unexpected()
[except.unexpected]
void unexpected();сразу же после завершения раскрутки стека (stack unwinding).
unexpected()
не может вернуть управление, но может (пере)возбудить исключение. Если она возбуждает новое исключение, которое разрешено нарушенной до этого спецификацией исключений, то поиск подходящего обработчика будет продолжен с точки вызова сгенерировавшей неожиданное исключение функции. Если же она возбудит недозволенное исключение, то: Если спецификация исключений не содержит класс std::bad_exception
(18.6.2.1), то будет вызвана terminate()
, иначе (пере)возбужденное исключение будет заменено на определяемый реализацией объект типа std::bad_exception
и поиск соответствующего обработчика будет продолжен описанным выше способом.
std::bad_exception
, то любое неописанное исключение может быть заменено на std::bad_exception
внутри unexpected()
.
class XX : B { /* ... */ }; // B -- закрытый базовый класс class YY : B { /* ... */ }; // B -- открытая базовая структура
На самом деле, в оригинале было так:
class XX : B { /* ... */ }; // B -- закрытая база struct YY : B { /* ... */ }; // B -- открытая базаТ.е. вне зависимости от того, является ли база
B
классом или структурой, права доступа к унаследованным членам определяются типом наследника: по умолчанию, класс закрывает доступ к своим унаследованным базам, а структура -- открывает.
В принципе, в этом нет ничего неожиданного -- доступ по умолчанию к обычным, не унаследованным, членам задается теми же правилами.
Тут, конечно, имеет место досадная опечатка, что, кстати сказать, сразу видно из приведенного примера. Т.е. читать следует так: ... если он разрешен по некоторому из возможных путей.
Это утверждение, вообще говоря, неверно и я вам советую никогда так не поступать. Сейчас покажу почему.
Прежде всего, стоит отметить, что в C++ вы не сможете прямо вывести значение указателя на член:
struct S { int i; void f(); }; void g() { cout<<&S::i; // ошибка: operator<< не реализован для типа int S::* cout<<&S::f; // ошибка: operator<< не реализован для типа void (S::*)() }Это довольно странно. Andrew Koenig пишет по этому поводу, что дело не в недосмотре разработчиков библиотеки ввода/вывода, а в том, что не существует переносимого способа для вывода чего-либо содержательного (кстати, я оказался первым, кто вообще об этом спросил, так что проблему определенно нельзя назвать злободневной). Мое же мнение состоит в том, что каждая из реализаций вполне способна найти способ для вывода более-менее содержательной информации, т.к. в данном случае даже неидеальное решение -- это гораздо лучше, чем вообще ничего.
Поэтому для иллюстрации внутреннего представления указателей на члены я написал следующий пример:
#include <string.h> #include <stdio.h> struct S { int i1; int i2; void f1(); void f2(); virtual void vf1(); virtual void vf2(); }; const int SZ=sizeof(&S::f1); union { unsigned char c[SZ]; int i[SZ/sizeof(int)]; int S::* iptr; void (S::*fptr)(); } hack; void printVal(int s) { if (s%sizeof(int)) for (int i=0; i<s; i++) printf(" %02x", hack.c[i]); else for (int i=0; i<s/sizeof(int); i++) printf(" %0*x", sizeof(int)*2, hack.i[i]); printf("\n"); memset(&hack, 0, sizeof(hack)); } int main() { printf("sizeof(int)=%d sizeof(void*)=%d\n", sizeof(int), sizeof(void*)); hack.iptr=&S::i1; printf("sizeof(&S::i1 )=%2d value=", sizeof(&S::i1)); printVal(sizeof(&S::i1)); hack.iptr=&S::i2; printf("sizeof(&S::i2 )=%2d value=", sizeof(&S::i2)); printVal(sizeof(&S::i2)); hack.fptr=&S::f1; printf("sizeof(&S::f1 )=%2d value=", sizeof(&S::f1)); printVal(sizeof(&S::f1)); hack.fptr=&S::f2; printf("sizeof(&S::f2 )=%2d value=", sizeof(&S::f2)); printVal(sizeof(&S::f2)); hack.fptr=&S::vf1; printf("sizeof(&S::vf1)=%2d value=", sizeof(&S::vf1)); printVal(sizeof(&S::vf1)); hack.fptr=&S::vf2; printf("sizeof(&S::vf2)=%2d value=", sizeof(&S::vf2)); printVal(sizeof(&S::vf2)); } void S::f1() {} void S::f2() {} void S::vf1() {} void S::vf2() {}Существенными для понимания местами здесь являются объединение
hack
, используемое для преобразования значения указателей на члены в последовательность байт (или целых), и функция printVal()
, печатающая данные значения.
Я запускал вышеприведенный пример на трех компиляторах, вот результаты:
sizeof(int)=4 sizeof(void*)=4 sizeof(&S::i1 )= 8 value= 00000005 00000000 sizeof(&S::i2 )= 8 value= 00000009 00000000 sizeof(&S::f1 )=12 value= 004012e4 00000000 00000000 sizeof(&S::f2 )=12 value= 004012ec 00000000 00000000 sizeof(&S::vf1)=12 value= 004012d0 00000000 00000000 sizeof(&S::vf2)=12 value= 004012d8 00000000 00000000 sizeof(int)=4 sizeof(void*)=4 sizeof(&S::i1 )= 4 value= 00000001 sizeof(&S::i2 )= 4 value= 00000005 sizeof(&S::f1 )= 8 value= ffff0000 004014e4 sizeof(&S::f2 )= 8 value= ffff0000 004014f4 sizeof(&S::vf1)= 8 value= 00020000 00000008 sizeof(&S::vf2)= 8 value= 00030000 00000008 sizeof(int)=4 sizeof(void*)=4 sizeof(&S::i1 )= 4 value= 00000004 sizeof(&S::i2 )= 4 value= 00000008 sizeof(&S::f1 )= 4 value= 00401140 sizeof(&S::f2 )= 4 value= 00401140 sizeof(&S::vf1)= 4 value= 00401150 sizeof(&S::vf2)= 4 value= 00401160Прежде всего в глаза бросается то, что несмотря на одинаковый размер
int
и void*
, каждая из реализаций постаралась отличиться в выборе представления указателей на члены, особенно первая. Что же мы можем сказать еще?
Указатели на функции-члены во втором компиляторе реализованы неоптимально, т.к. иногда они содержат указатель на "обычную" функцию (ffff0000 004014e4
), а иногда -- индекс виртуальной функции (00020000 00000008
). В результате чего, вместо того, чтобы сразу произвести косвенный вызов функции, компилятор проверяет старшую часть первого int
, и если там стоит -1
(ffff
), то он имеет дело с обычной функцией членом, иначе -- с виртуальной. Подобного рода проверки при каждом вызове функции-члена через указатель вызывают ненужные накладные расходы.
Внимательный читатель должен спросить: "Хорошо, пусть они всегда содержат обычный указатель на функцию, но как тогда быть с указателями на виртуальные функции? Ведь мы не можем использовать один конкретный адрес, так как виртуальные функции принято замещать в производных классах." Правильно, дорогой читатель! Но выход есть, и он очевиден: в этом случае компилятор автоматически генерирует промежуточную функцию-заглушку.
Например, следующий код:
struct S { virtual void vf() { /* 1 */ } void f () { /* 2 */ } }; void g(void (S::*fptr)(), S* sptr) { (sptr->*fptr)(); } int main() { S s; g(S::vf, &s); g(S::f , &s); }превращается в псевдокод:
void S_vf(S *const this) { /* 1 */ } void S_f (S *const this) { /* 2 */ } void S_vf_stub(S *const this) { // виртуальный вызов функции S::vf() (this->vptr[index_of_vf])(this); } void g(void (*fptr)(S *const), S* sptr) { fptr(sptr); } int main() { S s; g(S_vf_stub, &s); // обратите внимание: не S_vf !!! g(S_f , &s); }А если бы в C++ присутствовал отдельный тип "указатель на виртуальную функцию-член", он был бы представлен простым индексом виртуальной функции, т.е. фактически простым
size_t
, и генерации функций-заглушек (со всеми вытекающими потерями производительности) было бы можно избежать. Более того, его, как и указатель на данные-член, всегда можно было бы передавать в другое адресное пространство.
p
указывает на s
байтов памяти, выделенной Employee::operator new()
Данное предположение не вполне корректно: p
также может являться нулевым указателем, и в этом случае определяемый пользователем operator delete()
должен корретно себя вести, т.е. ничего не делать.
Запомните: определяя operator delete()
, вы обязаны правильно обрабатывать удаление нулевого указателя! Т.о. код должен выглядеть следующим образом:
void Employee::operator delete(void* p, size_t s) { if (!p) return; // игнорируем нулевой указатель // полагаем, что p указывает на s байтов памяти, выделенной // Employee::operator new() и освобождаем эту память // для дальнейшего использования }Интересно отметить, что стандартом специально оговорено, что аргумент
p
функции
template <class T> void std::allocator::deallocate(pointer p, size_type n);не может быть нулевым. Без этого замечания использование функции
Pool::free
в разделе 19.4.2. "Распределители памяти, определяемые пользователем" было бы некорректным.
Именно так. Т.е. если вы объявили деструктор некоторого класса
A::~A() { // тело деструктора }то компилятором (чаще всего) будет сгенерирован следующий код
// псевдокод A::~A(A *const this, bool flag) { if (this) { // тело деструктора if (flag) delete(this, sizeof(A)); } }Ввиду чего функция
void f(Employee* ptr) { delete ptr; }превратится в
// псевдокод void f(Employee* ptr) { Employee::~Employee(ptr, true); }и т.к. класс
Employee
имеет виртуальный деструктор, это в конечном итоге приведет к вызову соответствующего метода.
new[]
связаны не вполне очевидные вещи. Не мудрствуя лукаво, привожу перевод раздела 10.3 "Array Allocation" из книги "The Design and Evolution of C++" одного известного автора:
Определенный для класса X
оператор X::operator new()
используется исключительно для размещения одиночных объектов класса X
(включая объекты производных от X
классов, не имеющих собственного распределителя памяти). Следовательно
X* p = new X[10];не вызывает
X::operator new()
, т.к. X[10]
является массивом, а не объектом класса X
.
Это вызывало много жалоб, т.к. я не разрешил пользователям контролировать размещение массивов типа X
. Однако я был непреклонен, т.к. массив элементов типа X
-- это не объект типа X
, и, следовательно, распределитель памяти для X
не может быть использован. Если бы он использовался и для распределения массивов, то автор X::operator new()
должен был бы иметь дело как с распределением памяти под объект, так и под массив, что сильно усложнило бы более распространенный случай. А если распределение памяти под массив не очень критично, то стоит ли вообще о нем беспокоиться? Тем более, что возможность управления размещением одномерных массивов, таких как X[d]
не является достаточной: что, если мы захотим разместить массив X[d][d2]
?
Однако, отсутствие механизма, позволяющего контролировать размещение массивов вызывало определенные сложности в реальных программах, и, в конце концов, комитет по стандартизации предложил решение данной проблемы. Наиболее критичным было то, что не было возможности запретить пользователям размещать массивы в свободной памяти, и даже способа контролировать подобное размещение. В системах, основанных на логически разных схемах управления размещением объектов это вызывало серьезные проблемы, т.к. пользователи наивно размещали большие динамические массивы в обычной памяти. Я недооценил значение данного факта.
Принятое решение заключается в простом предоставлении пары функций, специально для размещения/освобождения массивов:
class X { // ... void* operator new(size_t sz); // распределение объектов void operator delete(void* p); void* operator new[](size_t sz); // распределение массивов void operator delete[](void* p); };Распределитель памяти для массивов используется для массивов любой размерности. Как и в случае других распределителей, работа
operator new[]
состоит в предоставлении запрошенного количества байт; ему не нужно самому беспокоиться о размере используемой памяти. В частности, он не должен знать о размерности массива или количестве его элементов. Laura Yaker из Mentor Graphics была первой, кто предложил операторы для размещения и освобождения массивов.
Следует отметить, что эти "некоторые ослабления" не являются простой формальностью. Рассмотрим следующий пример:
#include <stdio.h> struct B1 { int b1; // непустая virtual ~B1() { } }; struct B2 { int b2; // непустая virtual B2* vfun() { printf("B2::vfun()\n"); // этого мы не должны увидеть return this; } }; struct D : B1, B2 { // множественное наследование от непустых классов virtual D* vfun() { printf("D::vfun(): this=%p\n", this); return this; } }; int main() { D d; D* dptr=&d; printf("dptr\t%p\n", dptr); void* ptr1=dptr->vfun(); printf("ptr1\t%p\n", ptr1); B2* b2ptr=&d; printf("b2ptr\t%p\n", b2ptr); void* ptr2=b2ptr->vfun(); printf("ptr2\t%p\n", ptr2); }Обратите внимание: в данном примере я воспользовался "некоторыми ослаблениями" для типа возвращаемого значения
D::vfun()
, и вот к чему это привело:
dptr 0012FF6C D::vfun(): this=0012FF6C ptr1 0012FF6C b2ptr 0012FF70 D::vfun(): this=0012FF6C ptr2 0012FF70Т.о. оба раза была вызвана
D::vfun()
, но возвращаемое ей значение зависит от способа вызова (ptr1!=ptr2
), как это, собственно говоря, и должно быть.
Делается это точно так же, как уже было описано в разделе 361 "12.2.6. Виртуальные функции", только помимо корректировки принимаемого значения this
необходимо дополнительно произвести корректировку this
возвращаемого. Понятно, что виртуальные функции с ковариантным типом возврата встречаются настолько редко, что реализация их вызова посредством расширения vtbl
вряд ли может быть признана адекватной. На практике обычно создаются специальные функции-заглушки, чьи адреса помещаются в соответствующие элементы vtbl
:
// псевдокод // оригинальная D::vfun, написанная программистом D* D::vfun(D *const this) { // ... } // сгенерированная компилятором функция-заглушка для вызова D::vfun() через // указатель на базовый класс B2 B2* D::vfun_stub(B2 *const this) { return D::vfun(this+delta_1)+delta_2; }где возвращаемый функцией указатель корректируется посредством константы
delta_2
, вообще говоря, не равной delta_1
.
Подводя итог, хочется отметить, что в общем случае вызов виртуальной функции становится все меньше похож на "просто косвенный вызов функции". Ну, и раз уж речь зашла о виртуальных функциях с ковариантным типом возврата, стоит привести соответствующую часть стандарта:
10.3. Виртуальные функции [class.virtual]
D::f
замещает функцию B::f
, типы возвращаемых ими значений будут ковариантными, если они удовлетворяют следующим условиям:
B::f
идентичен классу в возвращаемом значении D::f
или он является однозначно определенным открытым прямым или косвенным базовым классом возвращаемого D::f
класса и при этом доступен в D
D::f
имеет те же или меньшие cv-квалификаторы, что и класс в возвращаемом значении B::f
.
D::f
отличается от типа возвращаемого значения B::f
, то тип класса в возвращаемом значении D::f
должен быть завершен в точке определения D::f
или он должен быть типом D
. Когда замещающая функция будет вызывана (как последняя заместившая функция), тип ее возвращаемого значения будет (статически) преобразован в тип возвращаемого значения замещаемой функции (5.2.2). Например:
class B {}; class D : private B { friend class Derived; }; struct Base { virtual void vf1(); virtual void vf2(); virtual void vf3(); virtual B* vf4(); virtual B* vf5(); void f(); }; struct No_good : public Base { D* vf4(); // ошибка: B (базовый класс D) недоступен }; class A; struct Derived : public Base { void vf1(); // виртуальная и замещает Base::vf1() void vf2(int); // не виртуальная, скрывает Base::vf2() char vf3(); // ошибка: неправильный тип возвращаемого значения D* vf4(); // OK: возвращает указатель на производный класс A* vf5(); // ошибка: возвращает указатель на незавершенный класс void f(); }; void g() { Derived d; Base* bp=&d; // стандартное преобразование: Derived* в Base* bp->vf1(); // вызов Derived::vf1() bp->vf2(); // вызов Base::vf2() bp->f(); // вызов Base::f() (не виртуальная) B* p=bp->vf4(); // вызов Derived::pf() и преобразование // возврата в B* Derived* dp=&d; D* q=dp->vf4(); // вызов Derived::pf(), преобразование // результата в B* не осуществляется dp->vf2(); // ошибка: отсутствует аргумент }
3.9.3. CV-квалификаторы [basic.type.qualifier]
нет cv-квалификатора | < | const |
нет cv-квалификатора | < | volatile |
нет cv-квалификатора | < | const volatile |
const |
< | const volatile |
volatile |
< | const volatile |
Вместе с тем, не стоит думать, что 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 |
И что же мы здесь видим?
vr
инициализируется конструктором Record()
, а каждый из s1
элементов контейнера vi
инициализируется int()
.
Инициализация 10 000 элементов конструктором по умолчанию не может не впечатлять -- только в очень редком случае нужно именно это. Если вы выделяете эти 10 000 элементов про запас, для последующей перезаписи, то стоит подумать о следующей альтернативе:
vector<X> vx; // объявляем пустой вектор vx.reserve(10000); // резервируем место воизбежание "дорогих" // перераспределений в push_back() // ... vx.push_back(x_work); // добавляем элементы по мере надобностиО ней тем более стоит подумать, т.к. даже в отличной реализации STL 3.2 от sgi конструктор
vector<int> vi(s1);подразумевает явный цикл заполнения нулями:
for (int i=0; i<s1; i++) vi.elements[i]=0;и требуется достаточно интеллектуальный оптимизатор для превращения этого цикла в вызов
memset()
:
memset(vi.elements, 0, sizeof(int)*s1);что значительно улучшит производительность (конечно не программы вообще, а только данного отрезка кода). Matt Austern поставлен в известность, и в будущих версиях sgi STL можно ожидать повышения производительности данного конструктора.
Очень жаль, что дорогая редакция сочла возможным поместить в книгу такую глупость. Для приведения количества "дорогих" перераспределений к приемлемому уровню O(log(N)), в STL используется увеличение объема зарезервированной памяти в полтора-два раза, а при простом добавлении некоторого количества (10, например) мы, очевидно, получим O(N), что есть плохо. Также отмечу, что для уменьшения количества перераспределений стоит воспользоваться reserve()
, особенно, если вы заранее можете оценить предполагаемую глубину стека.
И дело не только в определении операции "меньше", а еще и в том, что char*
не стоит использовать в качестве элементов STL контейнеров вообще: контейнер будет содержать значение указателя -- не содержимое строки, как кто-то по наивности мог полагать. Например, следующая функция содержит серьезную ошибку:
void f(set<char*>& cset) { for (;;) { char word[100]; // считываем слово в word ... cset.insert(word); // ошибка: вставляем один и тот же указатель // на локальную переменную } }Для получения ожидаемого результата следует использовать
string
:
void f(set<string>& cset) { for (;;) { char word[100]; // считываем слово в word ... cset.insert(word); // OK: вставляем string } }Использование
char*
в STL контейнерах приводит к чрезвычайно коварным ошибкам, т.к. иногда все работает правильно. Например документация к sgi STL широко использует char*
в своих учебных примерах:
struct ltstr { bool operator()(const char* s1, const char* s2) const { return strcmp(s1, s2) < 0; } }; int main() { const int N = 6; const char* a[N] = {"isomer", "ephemeral", "prosaic", "nugatory", "artichoke", "serif"}; set<const char*, ltstr> A(a, a + N); // и т.д. }Данный пример вполне корректен, но стоит только вместо статически размещенных строковых литералов использовать локально формируемые C-строки, как неприятности не заставят себя ждать.
Относитесь скептически к учебным примерам!
pair
.
Честно говоря, при первом знакомстве с шаблонами от всех этих многословных объявлений начинает рябить в глазах, и не всегда понятно, что именно удобно в такой вот функции:
template <class T1,class T2> pair<T1,T2> std::make_pair(const T1& t1, const T2& t2) { return pair<T1,T2>(t1,t2); }А удобно следующее: Если нам нужен экземпляр класса-шаблона, то мы обязаны предоставить все необходимые для инстанциирования класса параметры, т.к. на основании аргументов конструктора они не выводятся. С функциями-шаблонами дела обстоят получше:
char c=1; int i=2; // пробуем создать "пару" pair(c,i); // неправильно -- pair<char,int> не выводится pair<char,int>(c,i); // правильно make_pair(c,i); // правильно
operator[]()
.
Вообще говоря, существует, т.к. она объявлена в классе, но, ввиду ее неконстантности, применена быть не может -- при попытке инстанциирования вы получите ошибку компиляции.
К счастью, это не так: в данном случае этот "довольно сложный и редкий синтаксис" не требуется.
В самом деле, если разрешено
f<int>(); // f -- функция-шаблонто почему вдруг компилятор не может правильно разобраться с
obj.f<int>(); // f -- функция-шаблон, член классаМожет, и разбирается!
Исторически, непонимание возникло из-за того, что:
template
был изобретен комитетом по стандартизации, а не д-ром Страуструпом;
template
как квалификатор.
hash_map
.
А вот еще один "ляп", и нет ему оправдания! Дело в том, что в стандарте понятия "поддерживаемый hash_map
" не существует. Еще больше пикантности данной ситуации придает тот факт, что в самой STL, которая является основной частью стандартной библиотеки C++, hash_map
есть (и есть уже давно). Д-р Страуструп пишет по этому поводу, что hash_map
просто проглядели, а когда хватились, то было уже поздно -- никакие существенные изменения внести в стандарт было уже нельзя. Ну что ж, бывает...
Что же нам советуют признать читаемым и эффективным (впрочем, к эффективности, теоретически, претензий действительно нет)?
list<int>::const_iterator p=find_if(c.begin(),c.end(),bind2nd(less<int>(),7));Осмелюсь предложить другой вариант:
list<int>::const_iterator p; for (p=c.begin(); p!=c.end(); ++p) if (*p<7) break;Трудно ли это написать? По-видимому, нет. Является ли этот явный цикл менее читаемым? По моему мнению, он даже превосходит читаемость примера с использованием
bind2nd()
. А если нужно написать условие вида *p>=5 && *p<100
, что, в принципе, встречается не так уж и редко, то вариант с использованием связывателей и find_if()
проигрывает однозначно. Стоит добавить и чисто психологический эффект: вызов красивой функции часто подсознательно воспринимается атомарной операцией и не лишне подчеркнуть, что за красивым фасадом порой скрывается крайне неэффективный последовательный поиск.
В целом, я агитирую против потери здравого смысла при использовании предоставленного нам пестрого набора свистулек и колокольчиков. Увы, следует признать, что для сколь-нибудь сложного применения они не предназначены, да и на простом примере польза практически не видна.
Теперь немного про вызовы функций-членов для элементов контейнера с помощью механизма mem_fun()
. Действительно, вариант
for_each(lsp.begin(),lsp.end(),mem_fun(&Shape::draw)); // рисуем все фигурыподкупает своим изяществом. И даже более того, предоставляемые
mem_fun()
возможности действительно могут быть востребованы, например, при реализации некоторого абстрактного шаблона разработки (design pattern). Но за красивым фасадом скрывается вызов функции через указатель на член -- операция отнюдь не дешевая и далеко не все компиляторы умеют встраивать вызов функции через такой указатель. Будем рисковать?
А что, если нам нужно повернуть все фигуры на заданный угол? bind2nd()
, говорите? А если на разные углы да причем не все элементы контейнера, и эти углы рассчитываются по сложному алгоритму? По-моему, такой вариант в реальных программах встречается гораздо чаще.
Выходит, что и механизм mem_fun()
не очень-то предназначен для серьезного использования. Изучить его, конечно, стоит, а вот использовать или нет -- решать вам.
Вот это да! Т.е. если я попытаюсь удалить элемент из списка с помощью такого remove()
, то вместо удаления элемента я получу просто переприсваивание (в среднем) половины его элементов?!
Поймите меня правильно, среди приведенных в этом разделе алгоритмов будут и практически полезные, но держать в стандартной библиотеке не только неэффективные, но даже не соответствующие своему названию алгоритмы -- это уже слишком!
Но в таком виде они будут совершенно неэффективны в приложении ко встроенным типам, ведь общеизвестно, что для копирования больших объемов информации (если без него действительно никак нельзя обойтись) следует использовать функции стандартной библиотеки C memcpy()
и memmove()
. Вы нечасто используете векторы встроенных типов? Осмелюсь заметить, что вектор указателей встречается не так уж и редко и как раз подходит под это определение. К счастью, у меня есть хорошая новость: в качественной реализации STL (например от sgi) вызов операции копирования для vector<int>
как раз и приведет к эффективному memmove()
.
Выбор подходящего алгоритма производится на этапе компиляции с помощью специально определенного шаблона __type_traits<>
-- свойства типа. Который (по умолчанию) имеет безопасные настройки для сложных типов с нетривиальными конструкторами/деструкторами и оптимизированные специализации для POD типов, которые можно копировать простым перемещением блоков памяти.
В C++ вы часто будете встречать аббревиатуру POD (Plain Old Data). Что же она обозначает? POD тип -- это тип, объекты которого можно безопасно перемещать в памяти (с помощью memmove()
, например). Данному условию очевидно удовлетворяют встроенные типы (в том числе и указатели) и классы без определяемой пользователем операции присваивания и деструктора.
Почему я об этом говорю? Потому что, например, очевидное определение класса Date
является POD типом:
class Date { int day, mon, year; // или даже long val; // yyyymmdd public: // ... };Поэтому стоит разрешить оптимизацию предоставив соответствующую специализацию
__type_traits<>
:
template<> struct __type_traits<Date> { // ... };Только имейте ввиду:
__type_traits<>
-- не часть стандартной библиотеки, разные реализации могут использовать различные имена или даже не производить оптимизацию вообще. Изучите то, что есть у вас.
*
возвращает значение *(current-1)
...
Да, по смыслу именно так:
24.4.1.3.3 operator*
[lib.reverse.iter.op.star]
reference operator*() const;
Iterator tmp = current; return *--tmp;
I don't think anyone would use a reverse iterator if an iterator was an alternative, but then you never know what people might know. When you actually need to go through a sequence in reverse order a reverse iterator is often quite efficient compared to alternatives. Finally, there may not be any overhead because where the iterator is a vector the temporary isn't hard to optimize into a register use. One should measure before worrying too much about overhead.Я не думаю, что бы кто-то использовал обратный итератор там, где можно использовать обычный, но мы никогда не можем знать, что думают другие люди. Когда вам действительно нужно пройти последовательность в обратном порядке, обратный итератор является вполне приемлемой альтернативой. В принципе, иногда можно вообще избежать накладных расходов, например в случае обратного прохода по вектору, когда временная переменная-итератор без труда размещается в регистре. В любом случае, не стоит чрезмерно беспокоиться о производительности не проведя реальных измерений.
Вместе с тем, обратный итератор все-таки несет в себе ненужные накладные расходы, и для обратного прохода по последовательности лучше использовать обычный итератор с явным (пре)декрементом.
И раз уж речь зашла о реальных измерениях, давайте их произведем.
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <list> long Count, Var; typedef std::list<int> list_type; list_type lst; void f1() { for (list_type::reverse_iterator ri=lst.rbegin(), rend=lst.rend(); ri!=rend; ++ri) Var+=*ri; } void f2() { list_type::iterator i=lst.end(), beg=lst.begin(); if (i!=beg) { do { --i; Var+=*i; } while (i!=beg); } } int main(int argc, char** argv) { if (argc>1) Count=atol(argv[1]); for (int i=0; i<10000; i++) lst.push_back(i); 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); } }В данном примере список из 10 000 элементов проходится несколько тысяч раз (задается параметром) с использованием обратного (в
f1()
) и обычного (в f2()
) итераторов. При использовании качественного оптимизатора разницы времени выполнения замечено не было, а для "обычных" реализаций она составила от 45% до 2.4 раза.
И еще одна проблема: приводит ли постинкремент итератора к существенным накладным расходам по сравнению с преинкрементом? Давайте внесем соответствующие изменения:
void f1() { for (list_type::iterator i=lst.begin(), end=lst.end(); i!=end; ++i) Var+=*i; } void f2() { for (list_type::iterator i=lst.begin(), end=lst.end(); i!=end; i++) Var+=*i; }И опять все тот же результат: разницы может не быть, а там, где она проявлялась, ее величина находилась в пределах 5 - 30 процентов.
В целом, не стоит использовать потенциально более дорогие обратные итераторы и постинкременты, если вы не убедились в интеллектуальности используемого оптимизатора.
Вполне резонным будет вопрос: что же здесь имелось ввиду? Недостаток каких свойств мешает ссылкам C++ быть "совершенными"? Д-р. Страуструп ответил следующее:
Something that would allow a copy constructor to be defined using a user-defined reference object.Что-то, что позволило бы определить конструктор копирования с использованием предоставленного пользователем ссылочного типа.
template<class T> T* Pool_alloc<T>::allocate(size_type n, void* =0) { if (n==1) return static_cast<T*>(mem_alloc()); // ... }
Как всегда, самое интересное скрывается за многоточием. Как же нам реализовать часть allocate<>()
для n!=1
? Простым вызовом в цикле mem_alloc()
? Увы, в данном случае очевидное решение не подходит совершенно. Почему? Давайте рассмотрим поведение Pool_alloc<char>
. Глядя на конструктор оригинального Pool
:
Pool::Pool(unsigned int sz) : esize(sz<sizeof(Link*) ? sizeof(Link*) : sz) { // ... }можно заметить, что для
sz==sizeof(char)
для каждого char
мы будем выделять sizeof(Link*)
байт памяти. Для "обычной" реализации это означает четырехкратный перерасход памяти! Т.о. выделение памяти для массивов объектов типа X
, где sizeof(X)<sizeof(Link*)
становится нетривиальной задачей, равно как и последующее их освобождение в deallocate<>()
, фактически, придется принципиально изменить алгоритм работы аллокатора.
template<class T, class A> T* temporary_dup(vector<T,A>& v) { T* p=get_temporary_buffer<T>(v.size()).first; if (p==0) return 0; copy(v.begin(),v.end(),raw_storage_iterator<T*,T>(p)); return p; }
Вообще говоря, приведенная функция написана некорректно, т.к. не проверяется второй элемент возвращаемой get_temporary_buffer<>()
пары. Т.к. get_temporary_buffer<>()
может вернуть меньше памяти, чем мы запросили, то необходима другая проверка:
template<class T, class A> T* temporary_dup(vector<T,A>& v) { pair<T*,ptrdiff_t> p(get_temporary_buffer<T>(v.size())); if (p.second<v.size()) { if (p.first) return_temporary_buffer(p.first); return 0; } copy(v.begin(),v.end(),raw_storage_iterator<T*,T>(p)); return p.first; }
assign(s,n,x)
при помощи assign(s[i],x)
присваивает n
копий x
строке s
.compare()
использует для сравнения символов lt()
и eq()
.
К счастью, для обычных символов char_traits<char>
это не так, в том смысле, что не происходит вызов в цикле lt()
, eq()
, assign(s[i],x)
, а используются специально для этого предназначенные memcmp()
и memset()
, что, впрочем, не влияет на конечный результат. Т.е. используя strcmp()
мы ничего не выигрываем, даже более того, в специально проведенных мной измерениях производительности, сравнения string
оказались на 30% быстрее, чем принятое в C сравнение char*
с помощью strcmp()
. Что и не удивительно: для string
размеры сравниваемых массивов char
известны заранее.
basic_string
хранит длину строки, не полагаясь на завершающий символ (ноль).
Вместе с тем, хорошо оптимизированные реализации хранят строку вместе с завершающим нулем, дабы максимально ускорить функцию basic_string::c_str()
. Не секрет, что большинство используемых функций (традиционно) принимают строку в виде [const
] char*
вместо эквивалентного по смыслу [const
] string&
, исходя из того простого факта, что мы не можем ускорить "безопасную" реализацию, но можем скрыть эффективную за безопасным интерфейсом.
К слову сказать, мой личный опыт свидетельствует о том, что слухи об опасности манипулирования простыми char*
в стиле C оказываются сильно преувеличенными. Да, вы должны следить за всеми мелочами, но, например, ни у кого не возникает протеста по поводу того, что если в формуле корней квадратного уравнения мы вместо '-
' напишем '+
', то результат будет неверен.
Резюмируя данный абзац, хочу сказать, что string
использовать можно и нужно, но если логика работы вашей программы интенсивно использует манипуляции со строками, стоит подумать о разработке собственных средств, основанных на функциях типа memcpy()
, а в "узких" местах без этого просто не обойтись.
Я бы попросил вас серьезно отнестись к данному совету (т.е. к проверке имеющейся реализации). Например, sgi STL 3.2 всегда копирует символы строки, не полагаясь на основанную на подсчете ссылок версию. Авторы библиотеки объясняют это тем, что использующие модель подсчета ссылок строки не подходят для многопоточных приложений.
Ими утверждается, что использующие данную реализацию строк многопоточные приложения аварийно завершают свою работу один раз в несколько месяцев и именно из-за строк. В принципе, модель подсчета ссылок действительно плохо подходит для многопоточных приложений, т.к. ее использование приводит к существенным накладным расходам (более подробно об этом можно почитать у Herb Sutter Reference Counting - Part III), но вот собственно аварийное завершение работы может быть вызвано только ошибками в реализации -- чудес не бывает.
Как бы то ни было, но факт остается фактом: существуют отлично оптимизированные реализации стандартной библиотеки, которые, по тем или иным причинам, отказались от использования основанных на подсчете ссылок строк.
Резюмируя данный материал хочу отметить, что я всегда, где это возможно, стараюсь избегать копирования строк, например путем передачи const string&
.
(cerr.operator<<("x=")).operator<<(x);
Конечно же на самом деле все не так: в новых потоках ввода-вывода оператор вывода строки больше не является функцией-членом, следовательно оно будет интерпретировано так:
operator<<(cerr,"x=").operator<<(x);Товарищи программисты! Еще раз повторю: никогда не копируйте блоками старый текст, а если это все-таки необходимо, -- обязательно проверяйте каждую загогулину!
Вот гражданин Страуструп забыл проверить, и, в результате, новый релиз его монографии содержит очевидную ошибку.
Вынужден вас огорчить: определенные стандартом потоки C++ заявленным свойством не обладают. Они всегда работают медленнее C, а в некоторых реализациях -- медленно до смешного (правда, объективности ради стоит отметить, что мне попадались и совершенно отвратительно реализованные FILE*
потоки C, в результате чего C++ код вырывался вперед; но это просто недоразумение, если не сказать крепче!). Рассмотрим следующую программу:
#include <stdio.h> #include <time.h> #include <io.h> // для open() #include <fcntl.h> #include <iostream> #include <fstream> using namespace std; void workc(char*); void workcpp(char*); void work3(char*); int main(int argc, char **argv) { if (argc==3) switch (*argv[2]-'0') { case 1: { workc(argv[1]); break; } case 2: { workcpp(argv[1]); break; } case 3: { work3(argv[1]); break; } } } void workc(char* fn) { FILE* fil=fopen(fn, "rb"); if (!fil) return; time_t t1; time(&t1); long count=0; while (getc(fil)!=EOF) count++; time_t t2; time(&t2); fclose(fil); cout<<count<<" bytes per "<<t2-t1<<" sec.\n" ; } void workcpp(char* fn) { ifstream fil(fn, ios_base::in|ios_base::binary); if (!fil) return; time_t t1; time(&t1); long count=0; while (fil.get()!=EOF) count++; time_t t2; time(&t2); cout<<count<<" bytes per "<<t2-t1<<" sec.\n" ; } class File { int fd; // дескриптор файла unsigned char buf[BUFSIZ]; // буфер стандартного размера unsigned char* gptr; // следующий читаемый символ unsigned char* bend; // конец данных int uflow(); public: File(char* fn) : gptr(0), bend(0) { fd=open(fn, O_RDONLY|O_BINARY); } ~File() { if (Ok()) close(fd); } int Ok() { return fd!=-1; } int gchar() { return (gptr<bend) ? *gptr++ : uflow(); } }; int File::uflow() { if (!Ok()) return EOF; int rd=read(fd, buf, BUFSIZ); if (rd<=0) { // ошибка или EOF close(fd); fd=-1; return EOF; } gptr=buf; bend=buf+rd; return *gptr++; } void work3(char* fn) { File fil(fn); if (!fil.Ok()) return; time_t t1; time(&t1); long count=0; while (fil.gchar()!=EOF) count++; time_t t2; time(&t2); cout<<count<<" bytes per "<<t2-t1<<" sec.\n" ; }Ее нужно запускать с двумя параметрами. Первый параметр -- это имя (большого) файла для чтения, а второй -- цифра 1, 2 или 3, выбирающая функцию
workc()
, workcpp()
или work3()
соответственно. Только не забудьте про дисковый кэш, т.е. для получения объективных результатов программу нужно запустить несколько раз для каждого из вариантов.
Необычным местом здесь является функция work3()
и соответствующий ей класс File
. Они написаны специально для проверки "честности" реализации стандартных средств ввода-вывода C -- FILE*
. Если вдруг окажется, что workc()
работает существенно медленнее work3()
, то вы имеете полное право назвать создателей такой библиотеки, как минимум, полными неучами.
А сейчас попробуем получить информацию к размышлению: проведем серию контрольных запусков и посмотрим на результат.
И что же нам говорят безжалостные цифры? Разница в разы! А для одного широко распространенного коммерческого пакета (не будем показывать пальцем) она порой достигала 11 раз!!!
Стоит только взглянуть на определения вызываемых функций, как ответ сразу станет очевидным.
Для C с его getc()
в типичной реализации мы имеем:
#define getc(f) ((--((f)->level) >= 0) ? (unsigned char)(*(f)->curp++) : _fgetc (f))Т.е. коротенький макрос вместо функции. Как говорится -- всего-ничего. А вот для C++ стандарт требует столько, что очередной раз задаешься вопросом: думали ли господа-комитетчики о том, что горькие плоды их творчества кому-то реально придется применять?!
Ну и ладно: предупрежден -- вооружен! А что, если задать буфер побольше?
void workc(char* fn) { // ... if (setvbuf(fil, 0, _IOFBF, LARGE_BUFSIZ)) return; // ... } void workcpp(char* fn) { // ... char* buf=new char[LARGE_BUFSIZ]; fil.rdbuf()->pubsetbuf(buf, LARGE_BUFSIZ); // ... delete [] buf; }Как ни странно, по сути ничего не изменится! Дело в том, что современные ОС при работе с диском используют очень качественные алгоритмы кэширования, так что еще один уровень буферизации внутри приложения оказывается излишним (в том смысле, что используемые по умолчанию буферы потоков вполне адекватны).
Кстати, одним из хороших примеров необходимости использования многопоточных программ является возможность ускорения работы программ копирования файлов, когда исходный файл и копия расположены на разных устройствах. В этом случае программа запускает несколько потоков, осуществляющих асинхронные чтение и запись. Но в современных ОС в этом нет никакого смысла, т.к. предоставляемое системой кэширование кроме всего прочего обеспечивает и прозрачное для прикладных программ асинхронное чтение и запись.
Подводя итог, хочется отметить, что если ввод-вывод является узким местом вашего приложения, то следует воздержаться от использования стандартных потоков C++ и использовать проверенные десятилетиями методы.
printf()
программиста. Не верите? Давайте попробуем вывести обыкновенную дату в формате dd.mm.yyyy
:
int day= 31, mon= 1, year=1974; printf("%02d.%02d.%d\n", day, mon, year); // 31.01.1974 cout<<setfill('0')<<setw(2)<<day<<'.'<<setw(2)<<mon<<setfill(' ')<<'.' <<year<<"\n"; // тоже 31.01.1974Думаю, что комментарии излишни.
За что же не любят потоки C и чем потоки C++ могут быть удобнее? У потоков C++ есть только одно существенное достоинство -- типобезопасность. Т.к. потоки C++ все же нужно использовать, я написал специальный манипулятор, который, оставаясь типобезопасным, позволяет использовать формат ...printf()
. Он не вызывает существенных накладных расходов и с его помощью приведенный выше пример будет выглядеть следующим образом:
cout<<c_form(day,"02")<<'.'<<c_form(mon,"02")<<'.'<<year<<'\n';Вот исходный код заголовочного файла:
#include <ostream> /** личное пространство имен функции c_form, содержащее детали реализации */ namespace c_form_private { typedef std::ios_base::fmtflags fmtflags; typedef std::ostream ostream; typedef std::ios_base ios; /** * Вспомогательный класс для осуществления форматирования. */ class Formatter { /** флаги для установки */ fmtflags newFlags; /** ширина */ int width; /** точность */ int prec; /** символ-заполнитель */ char fill; /** сохраняемые флаги */ fmtflags oldFlags; public: /** * Создает объект, использующий переданное форматирование. */ Formatter(const char* form, int arg1, int arg2); /** * Устанавливает новое форматирование для переданного потока, сохраняя * старое. */ void setFormatting(ostream& os); /** * Восстанавливает первоначальное форматирование, сохраненное в функции * setFormatting(). */ void restoreFormatting(ostream& os); }; /** * Вспомогательный класс. */ template <class T> class Helper { /** выводимое значение */ const T& val; /** объект для форматирования */ mutable Formatter fmtr; public: /** * Создает объект по переданным параметрам. */ Helper(const T& val_, const char* form, int arg1, int arg2) : val(val_), fmtr(form, arg1, arg2) {} /** * Функция для вывода в поток сохраненного значения в заданном формате. */ void putTo(ostream& os) const; }; template <class T> void Helper<T>::putTo(ostream& os) const { fmtr.setFormatting(os); os<<val; fmtr.restoreFormatting(os); } /** * Оператор для вывода объектов Helper в поток. */ template <class T> inline ostream& operator<<(ostream& os, const Helper<T>& h) { h.putTo(os); return os; } } /** * Функция-манипулятор, возвращающая объект вспомогательного класса, для * которого переопределен оператор вывода в ostream. Переопределенный оператор * вывода осуществляет форматирование при выводе значения. * @param val значение для вывода * @param form формат вывода: [-|0] [число|*] [.(число|*)] [e|f|g|o|x] * @param arg1 необязательный аргумент, задающий ширину или точность. * @param arg2 необязательный аргумент, задающий точность. * @throws std::invalid_argument если передан аргумент form некорректного * формата. */ template <class T> inline c_form_private::Helper<T> c_form(const T& val, const char* form, int arg1=0, int arg2=0) { return c_form_private::Helper<T>(val, form, arg1, arg2); }и файла-реализации:
#include "c_form.hpp" #include <stdexcept> #include <cctype> namespace { /** * Вспомогательная функция для чтения десятичного числа. */ int getval(const char*& iptr) { int ret=0; do ret=ret*10 + *iptr-'0'; while (std::isdigit(*++iptr)); return ret; } } c_form_private::Formatter::Formatter(const char* form, int arg1, int arg2) : newFlags(fmtflags()), width(0), prec(0), fill(0) { const char* iptr=form; // текущий символ строки формата if (*iptr=='-') { // выравнивание влево newFlags|=ios::left; iptr++; } else if (*iptr=='0') { // добавляем '0'ли только если !left fill='0'; iptr++; } if (*iptr=='*') { // читаем ширину, если есть width=arg1; iptr++; arg1=arg2; // сдвигаем агрументы влево } else if (std::isdigit(*iptr)) width=getval(iptr); if (*iptr=='.') { // есть точность if (*++iptr=='*') { prec=arg1; iptr++; } else if (std::isdigit(*iptr)) prec=getval(iptr); else throw std::invalid_argument("c_form"); } switch (*iptr++) { case 0: return; // конец строки формата case 'e': newFlags|=ios::scientific; break; case 'f': newFlags|=ios::fixed; break; case 'g': break; case 'o': newFlags|=ios::oct; break; case 'x': newFlags|=ios::hex; break; default: throw std::invalid_argument("c_form"); } if (*iptr) throw std::invalid_argument("c_form"); } void c_form_private::Formatter::setFormatting(ostream& os) { oldFlags=os.flags(); // очищаем floatfield и устанавливаем свои флаги os.flags((oldFlags & ~ios::floatfield) | newFlags); if (width) os.width(width); if (fill) fill=os.fill(fill); if (prec) prec=os.precision(prec); } void c_form_private::Formatter::restoreFormatting(ostream& os) { os.flags(oldFlags); if (fill) os.fill(fill); if (prec) os.precision(prec); }Принцип его работы основан на следующей идее: функция
c_form<>()
возвращает объект класса c_form_private::Helper<>
, для которого определена операция вывода в ostream
.
Для удобства использования, c_form<>()
является функцией, т.к. если бы мы сразу использовали конструктор некоторого класса-шаблона c_form<>
, то нам пришлось бы явно задавать его параметры:
cout<<c_form<int>(day,"02");что, мягко говоря, неудобно. Далее. Мы, в принципе, могли бы не использовать нешаблонный класс
Formatter
, а поместить весь код прямо в Helper<>
, но это привело бы к совершенно ненужной повторной генерации общего (не зависящего от параметров шаблона) кода.
Как можно видеть, реализацию манипулятора c_form
вряд ли можно назвать тривиальной. Тем не менее, изучить ее стоит хотя бы из тех соображений, что в процессе разработки было использовано (неожиданно) большое количество полезных приемов программирования.
readsome()
является операцией нижнего уровня, которая позволяет...
Т.к. приведенное в книге описание readsome()
туманно, далее следует перевод соответствующей части стандарта:
27.6.1.3 Функции неформатированного ввода [lib.istream.unformatted]
streamsize readsome(char_type* s, streamsize n);
!good()
вызывает setstate(failbit)
, которая может возбудить исключение. Иначе извлекает символы и помещает их в массив, на первый элемент которого указывает s
. Если rdbuf()->in_avail() == -1
, вызывает setstate(eofbit)
(которая может возбудить исключение ios_base::failure
(27.4.4.3)) и не извлекает символы;
rdbuf()->in_avail() == 0
, не извлекает символы
rdbuf()->in_avail() > 0
, извлекает min(rdbuf()->in_avail(),n))
символов
Думаю, что стоит поподробнее рассмотреть данный конкретный случай, т.к. он иллюстрирует довольно распространенную ошибку проектирования. На первый взгляд может показаться, что идея сделать класс Circle
производным от класса Ellipse
является вполне приемлемой, ведь они связаны отношением is-a: каждая окружность является эллипсом. Некорректность данной идеи станет очевидной, как только мы приступим к написанию кода.
У эллипса, кроме прочих атрибутов, есть два параметра: полуоси a
и b
. И производная окружность их унаследует. Более того, нам нужен один единственный радиус для окружности и мы не можем для этих целей использовать один из унаследованных атрибутов, т.к. это изменит его смысл и полученный от эллипса код перестанет работать. Следовательно мы вынуждены добавить новый атрибут -- радиус и, при этом, поддерживать в корректном состоянии унаследованные атрибуты. Очевидно, что подобного рода наследование лишено смысла, т.к. не упрощает, а усложняет разработку.
В чем же дело? А дело в том, что понятие окружность в математическом смысле является ограничением понятия эллипс, т.е. его частным случаем. А наследование будет полезно, если конструируемый нами объект содержит подобъект базового класса и все унаследованные операции для него имеют смысл (рассмотрите, например, операцию изменения значения полуоси b
-- она ничего не знает об инварианте окружности и легко его разрушит). Другими словами, объект производного класса должен быть расширением объекта базового класса, но не его частным случаем (изменением), т.к. мы не можем повлиять на поведение базового класса, если он нам не предоставил соответствующих возможностей, например в виде подходящего набора виртуальных функций.
Т.к. сложные объявления C++ могут быть непонятны даже неновичку, стоит прокомментировать приведенные в книге объявления. Неочевидность всех приведенных примеров основана на добавлении лишних скобок:
T(*e)(int(3)); |
эквивалентно | T* e(int(3)); |
То, что инициализация указателя с помощью int запрещена, синтаксичестим анализатором не принимается во внимание: будет распознано объявление указателя и выдана ошибка. |
T(f)[4]; |
эквивалентно | T f[4]; |
|
T(a); |
эквивалентно | T a; |
|
T(a)=m; |
эквивалентно | T a=m; |
|
T(*b)(); |
объявление указателя на функцию. | ||
T(x),y,z=7; |
эквивалентно | T x,y,z=7; |
template<class C> class Basic_ops { // базовые операции с контейнерами friend bool operator==<>(const C&, const C&); // сравнение элементов friend bool operator!=<>(const C&, const C&); // ... };Уголки (
<>
) после имен функций означают, что друзьями являются функции-шаблоны (поздние изменения стандарта).
Этот текст взят из списка авторских исправлений к 10 тиражу.
Почему в данном случае необходимы <>
? Потому что иначе мы объявляем другом operator==()
не шаблон, т.к. до объявления класса в окружающем контексте не было объявления operator==()
-шаблона. Вот формулировка стандарта:
14.5.3. Друзья [temp.friend]
template<class T> class task; template<class T> task<T>* preempt(task<T>*); template<class T> class task { // ... friend void next_time(); friend void process(task<T>*); friend task<T>* preempt<T>(task<T>*); template<class C> friend int func(C); friend class task<int>; template<class P> friend class frd; // ... };здесь функция
next_time
является другом каждой специализации класса-шаблона task
; т.к. process
не имеет явных template-arguments, каждая специализация класса-шаблона task
имеет функцию-друга process
соответствующего типа и этот друг не является специализацией функции-шаблона; т.к. друг preempt
имеет явный template-argument <T>
, каждая специализация класса-шаблона task
имеет другом соответствующую специализацию функции-шаблона preempt
; и, наконец, каждая специализация класса-шаблона task
имеет другом все специализации функции-шаблона func
. Аналогично, каждая специализация класса-шаблона task
имеет другом класс-специализацию task<int>
, и все специализации класса-шаблона frd
.
template
как квалификаторВ данном разделе д-р Страуструп привел пример его использования с функцией-членом шаблоном. А что, если нам нужно вызвать статическую функцию-член или функцию-друга? Полный пример будет выглядеть следующим образом:
template <class T> void get_new3(); // (1) template <class Allocator> void f(Allocator& m) { int* p1= m.template get_new1<int>( ); int* p2=Allocator::template get_new2<int>(m); int* p3= get_new3<int>(m); } struct Alloc { template <class T> T* get_new1() { return 0; } template <class T> static T* get_new2(Alloc&) { return 0; } template <class T> friend T* get_new3(Alloc&) { return 0; } }; int main() { Alloc a; f(a); }Итак:
get_new1
--- это функция-член, для вызова которой в данном случае обязательно должен быть использован квалификатор template
. Дело в том, что в точке определения f
класс Allocator
является всего лишь именем параметра шаблона и компилятору нужно подсказать, что данный вызов -- это не (ошибочное) выражение (m.get_new1) < int...
get_new2
-- это статическая функция-член, при вызове из f
, ее имя должно быть предварено все тем же квалификатором template
по тем же причинам.
get_new3
-- друг класса Alloc
, привносит в наш пример некоторые проблемы. Дело в том, что он используется в f
до его определения в классе Alloc
(точно так же, как я использую до их определения функции get_new1
и get_new2
). Чтобы определение f
было корректным, мы должны гарантировать, что имя get_new3
известно в точке определения f
как имя функции-шаблона. Дабы не ограничивать общность f
, я не использовал в точке (1) прототип конкретной get_new3
-- друга класса Alloc
, а просто описал (даже не определил!) некоторую функцию-шаблон get_new3
. Очевидно, что она не может быть использована в f
-- она просто делает вызов
p3=get_new3<int>(m);легальным, внося в область видимости нужное имя-шаблон. Обратите внимание, что описанная в точке (1) функция
get_new3
не имеет параметров и не возвращает никакого значения. Это сделано для того, чтобы она не принималась во внимание при выборе подходящей (возможно перегруженной) get_new3
, в точке ее вызова в функции f
.
p3=template get_new3<int>(m);К сожалению, приходится констатировать, что использование квалификатора
template
не было в достаточной мере продумано комитетом по стандартизации C++.
Что нужно оптимизировать? Когда? И нужно ли вообще? В этих вопросах легко заблудиться, если с самого начала не выбрать правильную точку зрения. Взгляд со стороны пользователя, все сразу ставит на свои места:
Итак, анализ проведен, решение принято -- ускоряемся! Что может ускорить нашу программу? Да все, что угодно; вопрос поставлен некорректно. Что может существенно ускорить нашу программу? А вот над этим уже стоит подумать.
Прежде всего, стоит подумать о "внешнем" ускорении, т.е. о не приводящих к изменению исходного кода действиях. Самый широкораспространенный метод -- использование более мощного "железа". Увы, зачастую это не самый эффективный способ. Как правило, гораздо большего можно добиться путем правильного конфигурирования того, что есть. Например, работа с БД -- практически всегда самое узкое место. Должно быть очевидно, что правильная настройка сервера БД -- это одно из самых важных действий и за него всегда должен отвечать компетентный специалист. Вы будете смеяться, но грубые оплошности админов происходят слишком часто, чтобы не обращать на них внимание (из моей практики: неоднократно время работы приложения уменьшалось с нескольких часов до нескольких минут (!) из-за очевидной команды UPDATE STATISTICS
; фактически, перед анализом плана испонения тяжелых SQL-запросов всегда полезно невзначай поинтересоваться актуальностью статистики. Не менее частым происшествием является "случайная потеря" индекса важной таблицы в результате реорганизации или резервного копирования БД).
Коль скоро среда исполнения правильно сконфигурирована, стоит обратить внимание непосредственно на код. Очевидно, что максимальная скорость эскадры определяется скоростью самого медленного корабля. Он-то нам и нужен. Если "эскадрой" является набор SQL-запросов работающего с БД приложения, то, как правило, никаких трудностей с определением узких мест не возникает. Трудности возникают с определением узких мест "обычных" приложений.
Узкие места нужно искать только с помощью объективных измерений, т.к. интуиция в данной области чаще всего не срабатывает (не стоит утверждать, что не работает вообще). Причем измерять относительную производительность имеет смысл только при "релиз"-настройках компилятора (при отключенной оптимизации узкие места могут быть найдены там, где их нет. Увы, данного рода ошибки допускают даже опытные программисты) и на реальных "входных данных" (так, например, отличные сравнительные характеристики в сортировке равномерно распределенных int
, отнють не гарантируют отличную работу на реальных ключах реальных данных). Действительно серьезным подспорьем в поиске узких мест являются профайлеры -- неотъемлемая часть любой профессиональной среды разработки.
Когда критический участок кода локализован, можно приступать к непосредственному анализу. С чего начать? Начинать нужно с самых ресурсоемких операций. Как правило, по требуемому для исполнения времени, операции легко разделяются на слои, отличающиеся друг от друга на несколько порядков:
Как правило, только в исключительных случаях заметного ускорения работы можно достичь путем локальных улучшений (которыми пестрят древние наставления: a+a
вместо 2*a
, register int i;
и т.д.), современные компиляторы прекрасно справляются с ними без нас (вместе с тем, генерация компилятором недостаточно оптимального кода "в данном конкретном месте" все еще не является редкостью). Серьезные улучшения обычно приносит только изменение алгоритма работы.
Первым делом стоит обратить внимание на сам алгоритм (классическим примером является сортировка с алгоритмами O(N*N), O(N*log(N)) и O(N*M) стоимости или выбор подходящего контейнера). Но не попадите в ловушку! Память современных компьютеров уже не является устройством произвольного доступа, в том смысле, что промах мимо кэша при невинном обращении по указателю может обойтись гораздо дороже вызова тривиальной функции, чей код уже попал в кэш. Известны случаи, когда изменение прохода большой двумерной матрицы с последовательного построчного на "обманывающий" кэш постолбцовый замедляло работу алгоритма в несколько раз!
Если же принципиальный алгоритм изначально оптимален, можно попробовать использовать замену уровней ресурсоемкости. Классическим примером является все то же кэширование. Например вместо дорогостоящего считывания данных с диска, происходит обращение к заранее подготовленной копии в памяти, тем самым мы переходим с первого уровня на второй-третий. Стоит отметить, что техника кэширования находит свое применение не только в работе с внешними устройствами. Если, например, в игровой программе узким местом становится вычисление sin(x)
, то стоит подумать об использовании заранее рассчитанной таблицы синусов (обычно достаточно 360 значений типа int
вместо потенциально более дорогой плаваючей арифметики). Более "прикладной" пример -- это длинный switch
по типам сообщений в их обработчике. Если он стал узким местом, подумайте об использовании таблицы переходов или хэширования (стоимость O(1)) или же специальной древовидной структуры (стоимость O(log(N))) -- существенно лучше O(N), обычно обеспечиваемого switch
. Ну а про возможность использования виртуальной функции вместо switch
я даже не стану напоминать.
Все эти замечания применимы в равной степени к любому языку. Давайте посмотрим на что стоит обратить внимание программистам на C++.
Прежде всего, стоит отметить, что все более-менее существенные маленькие хитрости собственно C++ уже были рассмотрены в предыдущих примерах, так же как и скрытые накладные расходы. Быть может, за кадром осталась только возможность "облегченного вызова функции", т.к. она является не частью (стандартного) C++, а особенностью конкретных реализаций.
C++ как и C при вызове функции размещает параметры в стеке. Т.е. имея параметр в регистре, компилятор заносит его в стек, вызывает функцию, а в теле функции опять переносит параметр в регистр. Всего этого можно избежать использовав соответствующее соглашение вызова (в некоторых реализациях используется зарезервированное слово _fastcall
), когда параметры перед вызовом размещаются непосредственно в регистрах, исключая тем самым ненужные стековые операции. Например в простом тесте:
void f1(int arg) { Var+=arg; } void _fastcall f2(int arg) { Var+=arg; }функция
f1()
работала на 50% медленнее. Конечно, реальную выгоду из этого факта можно получить только при массовом использовании функций облегченного вызова во всем проекте. И эта совершенно бесплатная разница может быть достаточно существенной.
Еще один немаловажный фактор -- размер программ. Откуда взялись все эти современные мегабайты? Увы, большая их часть -- мертвый код, реально, более 90% загруженного кода никогда не будет вызвано! Не беда, если эти мегабайты просто лежат на диске, реальные трудности появляются, когда вы загружаете на выполнение несколько таких монстров. Падение производительности системы во время выделения дополнительной виртуальной памяти может стать просто катастрофическим.
Если при разработке большого проекта изначально не придерживаться политики строгого определения зависимостей между исходными файлами (и не принимать серьезных мер для их минимизации), то в итоге, для успешной линковки будет необходимо подключить слишком много мусора из стандартного инструментария данного проекта. В несколько раз больше, чем полезного кода. Из-за чего это происходит? Если функция f()
из file1.cpp
вызывает g()
из file2.cpp
, то, очевидно, мы обязаны подключить file2.cpp
к нашему проекту. При этом, если не было принято специальных мер, то в file2.cpp
почти всегда найдется какая-нибудь g2()
, как правило не нужная для работы g()
и вызывающая функции еще какого-либо файла; и пошло-поехало... А когда каждое приложение содержит свыше полусотни исходных файлов, а в проекте несколько сотен приложений, то навести порядок постфактум уже не представляется возможным.
Отличное обсуждение локальных приемов оптимизации можно найти у Paul Hsieh "Programming Optimization". Не очень глубокий, а местами и откровенно "слабый", но, тем не менее, практически полезный обзор более высокоуровневых техник представлен в книге Steve Heller "Optimizing C++".
Я полностью согласен с тем, что чрезмерное и необдуманное использование макросов может вызвать большие неприятности, особенно при повторном использовании кода. Вместе с тем, я не знаю ни одного средства C++, которое могло бы принести пользу при чрезмерном и необдуманном его использовании.
Итак, когда макросы могут принести пользу?
_VAL_
, выводящий имя и значение переменной:
#define _VAL_(var) #var "=" << var << " "Надъязыковой частью здесь является работа с переменной как с текстом, путем перевода имени переменной (оно существует только в исходном коде программы) в строковый литерал, реально существующий в коде бинарном. Данную возможность могут предоставить только макросы.
_ADD_
. Например:
cout<<_ADD_("Ошибка чтения");выведет что-то вроде
Ошибка чтения <file.cpp:34>А если нужен перевод строки, то стоит попробовать
cout<<"Ошибка чтения" _ADD_("") "\n";Такой метод работает, потому что макрос
_ADD_
возвращает строковый литерал. Вроде бы эквивалентная функция
char* _ADD_(char*);вполне подошла бы для первого примера, но не для второго. Конечно, для вывода в
cout
это не имеет никакого значения, но в следующем пункте я покажу принципиальную важность подобного поведения.
Рассмотрим устройство _ADD_
:
#define _ADD_tmp_tmp_(str,arg) str " <" __FILE__ ":" #arg ">" #define _ADD_tmp_(str,arg) _ADD_tmp_tmp_(str,arg) #define _ADD_(str) _ADD_tmp_(str,__LINE__)Почему все так сложно? Дело в том, что
__LINE__
в отличие от __FILE__
является числовым, а не строковым литералом и чтобы привести его к нужному типу придется проявить некоторую смекалку. Мы, конечно, не можем написать:
#define _ADD_(str) str " <" __FILE__ ":" #__LINE__ ">"т.к.
#
может быть применен только к аргументу макроса. Решением является передача __LINE__
в виде параметра некоторому вспомогательному макросу, но очевидное
#define _ADD_tmp_(str,arg) str " <" __FILE__ ":" #arg ">" #define _ADD_(str) _ADD_tmp_(str,__LINE__)не работает: результатом
_ADD_("Ошибка чтения")
будет
"Ошибка чтения <file.cpp:__LINE__>"что нетрудно было предвидеть. В итоге мы приходим к приведенному выше варианту, который обрабатывается препроцессором следующим образом:
_ADD_("Ошибка чтения")
последовательно подставляется в
_ADD_tmp_("Ошибка чтения",__LINE__) _ADD_tmp_tmp_("Ошибка чтения",34) "Ошибка чтения" " <" "file.cpp" ":" "34" ">" "Ошибка чтения <file.cpp:34>"
DB::Query
с соответствующей функцией
void DB::Query::Statement(const char *);и мы хотим выбрать все строки некоторой таблицы, имеющие равное некому "магическому числу" поле
somefield
:
#define FieldOK 7 // ... DB::Int tmp(FieldOK); q.Statement(" SELECT * " " FROM sometable " " WHERE somefield=? " ); q.SetParam(), tmp;Излишне многословно. Как бы это нам использовать
FieldOK
напрямую? Недостаточно знакомые с возможностями макросов программисты делают это так:
#define FieldOK 7 // ... #define FieldOK_CHAR "7" // ... q.Statement(" SELECT * " " FROM sometable " " WHERE somefield=" FieldOK_CHAR );В результате чего вы получаете все прелести синхронизации изменений взаимосвязанных наборов макросов со всеми вытекающими из этого ошибками. Правильным решением будет
#define FieldOK 7 // ... q.Statement(" SELECT * " " FROM sometable " " WHERE somefield=" _GETSTR_(FieldOK) );где
_GETSTR_
определен следующим образом:
#define _GETSTR_(arg) #argКстати, приведенный пример наглядно демонстрирует невозможность полностью эквивалентной замены всех числовых макросов на принятые в C++
const int FieldOK=7; enum { FieldOK=7 };макрос
_GETSTR_
не сможет с ними работать.
struct Table1 { // представление данных таблицы DB::Date Field1; DB::Int Field2; DB::Short Field3; }; void f() { Table1 tbl; DB::Query q; q.Statement(" SELECT Field1, Field2, Field3 " " FROM Table1 " ); q.BindCol(), tbl.Field1, tbl.Field2, tbl.Field3; // ... }И этот метод действительно работает. Но что, если представление таблицы изменилось? Теперь нам придется искать и исправлять все подобные места -- чрезвычайно утомительный процесс! Об этом стоило позаботиться заранее:
#define TABLE1_FLD Field1, Field2, Field3 #define TABLE1_FLD_CHAR "Field1, Field2, Field3" struct Table1 { // представление данных таблицы DB::Date Field1; DB::Int Field2; DB::Short Field3; // вспомогательная функция void BindCol(DB::Query& q) { q.BindCol(), TABLE1_FLD; } }; void f() { Table1 tbl; DB::Query q; q.Statement(" SELECT " TABLE1_FLD_CHAR " FROM Table1 " ); tbl.BindCol(q); // ... }Теперь изменение структуры таблицы обойдется без зубовного скрежета. Стоит отметить, что в определении
TABLE1_FLD_CHAR
я не мог использовать очевидное _GETSTR_(TABLE1_FLD)
, т.к. TABLE1_FLD
содержит запятые. К сожалению, данное печальное ограничение в примитивном препроцессоре C++ никак нельзя обойти.
q.Statement(" SELECT Field1, AccA_bal, AccA_cur, AccA_key, AccA_brn, " " AccA_per, Field2 " " FROM Table1 " ); q.BindCol(), tbl.Field1, tbl.AccA.bal, tbl.AccA.cur, tbl.AccA.key, tbl.AccA.brn, tbl.AccA.per, tbl.Field2; // ...Можете себе представить, сколько писанины требуется для выбора четырех счетов (
tbl.AccA
, tbl.AccB
, tbl.KorA
, tbl.KorB
). И снова на помощь приходят макросы:
#define _SACC_(arg) #arg"_bal, "#arg"_cur, "#arg"_key, "#arg"_brn, " \ #arg"_per " #define _BACC_(arg) arg.bal, arg.cur, arg.key, arg.brn, arg.per // ... q.Statement(" SELECT Field1, " _SACC_(AccA) " , Field2 " " FROM Table1 " ); q.BindCol(), tbl.Field1, _BACC_(tbl.AccA), tbl.Field2; // ...Думаю, что комментарии излишни.
struct A { MyDate Date; int Field2; short Field3; };Мы не можем использовать идентификатор
Date
для имени столбца таблицы, т.к. DATE
является зарезервированным словом SQL. Эта проблема легко обходится с помощью приписывания некоторого префикса:
struct TableA { DB::Date xDate; DB::Int xField2; DB::Short xField3; TableA& operator=(A&); void Clear(); };А теперь определим функции-члены:
TableA& TableA::operator=(A& a) { xDate=ToDB(a.Date); xField2=ToDB(a.Field2); xField3=ToDB(a.Field3); return *this; } void TableA::Clear() { xDate=""; xField2=""; xField3=""; }Гарантирую, что если
TableA
содержит хотя бы пару-тройку десятков полей, то написание подобного кода вам очень быстро наскучит, мягко говоря! Нельзя ли это сделать один раз, а потом использовать результаты? Оказывается можно:
TableA& TableA::operator=(A& a) { // используем склейку лексем: ## #define ASS(arg) x##arg=ToDB(a.arg); ASS(Date); ASS(Field2); ASS(Field3); #undef ASS return *this; } void TableA::Clear() { #define CLR(arg) x##arg="" CLR(Date); CLR(Field2); CLR(Field3); #undef CLR }Теперь определение
TableA::Clear()
по TableA::operator=()
не несет никакой нудной работы, если, конечно, ваш текстовый редактор поддерживает команды поиска и замены. Так же просто можно определить и обратное присваивание: A& A::operator=(TableA&)
.
Никакая часть данного материала не может быть использована в коммерческих целях без письменного разрешения автора.
Популярность: 23, Last-modified: Thu, 07 Dec 2006 05:46:12 GmT