Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ
Шрифт:
Но прежде напомним, что встраивание – это совет, который компилятор может проигнорировать. Большинство компиляторов отвергают встраивание функций, которые представляются слишком сложными (например, содержат циклы или рекурсию), и за исключением наиболее тривиальных случаев, вызов виртуальной функции отменяет встраивание. В этом нет ничего удивительного: virtual означает «какую точно функцию вызвать, определяется в момент исполнения», а inline – «перед исполнением заменить вызов функции ее кодом». Если компилятор не знает, какую функцию вызывать, то трудно винить его в том, что он отказывается делать встраивание.
Все это в конечном счете сводится к следующему: от реализации используемого компилятора зависит, встраивается ли в действительность встроенная функция. К счастью, большинство компиляторов обладают достаточными диагностическими возможностями и выдают предупреждение (см. правило 53), если не
Иногда компилятор генерирует тела встроенной функции, даже если ничто не мешает ее встроить. Например, если ваша программа получает адрес встроенной функции, то компилятор, как правило, должен сгенерировать настоящее тело функции. Как иначе он может получить адрес функции, если ее не существует? В совокупности с тем фактом, что обычно компиляторы не выполняют встраивание, если функция вызывается по указателю, это значит, что вызовы встроенных функций могут встраиваться или не встраиваться в зависимости от того, как к ней производится обращение:
Призрак невстраиваемых inline-функций может преследовать вас, даже если вы никогда не используете указателей на функции, потому что указатели на функции может запрашивать не только программист. Иногда компилятор генерирует невстраиваемые копии конструкторов и деструкторов так, что они запрашивают указатели на функции во время конструирования и разрушения объектов в массивах.
Фактически конструкторы и деструкторы часто являются наихудшими кандидатами для встраивания. Например, рассмотрим конструктор класса Derived:
Этот конструктор выглядит как отличный кандидат на встраивание, поскольку он не содержит никакого кода. Но впечатление обманчиво.
C++ дает различные гарантии о том, что должно происходить при конструировании и разрушении объектов. Например, когда вы используете оператор new, динамически создаваемые объекты автоматически инициализируются своими конструкторами, а при обращении к delete вызываются соответствующие деструкторы. Когда вы создаете объект, то автоматически конструируются члены всех его базовых классов, а равно его собственные данные-члены, а во время удаления объекта автоматически происходит обратный процесс. Если во время конструирования объекта возбуждается исключение, то все части объекта, которые были к этому моменту сконструированы, автоматически разрушаются. Во всех этих случаях C++ говорит, что должно случиться, но не говорит – как. Это зависит от реализации компилятора, но должно быть понятно, что такие вещи не происходят сами по себе. В вашей программе должен быть какой-то код, который все это реализует, и этот код, который генерируется компилятором и вставляется в вашу программу, должен где-то находиться. Иногда он помещается в конструкторы и деструкторы, поэтому можем представить себе следующую реализацию сгенерированного кода в якобы пустом конструкторе класса Derived:
В действительности это не совсем тот код, который порождает компилятор, потому что реальные компиляторы обрабатывают исключения более сложным образом. И все же этот пример довольно точно отражает поведение «пустого» конструктора класса Derived. Независимо от того, насколько хитроумно обходится с исключениями компилятор, конструктор Derived должен, по крайней мере, вызывать конструкторы своих данных-членов и базового класса, и эти вызовы (которые сами по себе могут быть встроенными) могут свести преимущества встраивания на нет.
То же самое относится и к конструктору класса Base, поэтому если он встроенный, то весь вставленный в него код вставляется также и в конструктор Derived (поскольку конструктор Derived вызывает конструктор Base). И если конструктор класса string тоже окажется встроенным, то в конструктор Derived его код войдет пять раз – по одному для каждой из пяти имеющихся в классе Derived строк (две унаследованные и три, объявленные в нем самом). Наверное, теперь вам ясно, почему решений о встраивании конструктора Derived не стоит принимать с легким сердцем. Аналогично обстоят дела и с деструктором класса Derived, который каким-то образом должен гарантировать правильное уничтожение всех объектов, инициализированных конструктором.
Разработчики библиотек должны принимать во внимание, что произойдет при объявлении функций встроенными, потому что невозможно предоставить двоичное обновление видимых клиенту встроенных библиотечных функций. Другими словами, если f – встроенная библиотечная функция, то пользователи этой библиотеки встраивают ее тело в свои приложения. Если разработчик библиотеки позднее решит изменить f, то все программы, которые ее использовали, придется откомпилировать заново. Часто это нежелательно. С другой стороны, если f не будет встроенной функцией, то после ее модификации клиентские программы нужно будет лишь заново компоновать с библиотекой. Это ощутимо быстрее, чем перекомпиляция, а если библиотека, содержащая функцию, является динамической, то изменения в ней вообще будут прозрачны для пользователей.
При разработке программ важно иметь в виду все эти соображения, но с практической точки зрения наиболее существен следующий факт: у большинства отладчиков возникают проблемы со встроенными функциями. Это совсем не удивительно. Как установить точку остановки в функции, которой не существует? Хотя некоторые среды разработки ухитряются поддерживать отладку встроенных функций, во многих встраивание для отладочных версий просто отключается.
Это приводит нас к следующей стратегии выбора функций, подходящих для встраивания. Поначалу откажитесь от встроенных функций вовсе, или, по крайней мере, ограничьтесь теми, которые обязаны быть встроенными (см. правило 46) либо являются тривиальными (такие как Person::age выше). Применяя встроенные функции с должной аккуратностью, вы не только получаете возможность пользоваться отладчиком, но и определяете встраиванию подобающее место: тонкая оптимизация вручную. Не забывайте об эмпирическом правиле «80–20», которое утверждает, что типичная программа тратит 80 % времени на исполнение 20 % кода. Это важное правило, поскольку оно напоминает, что цель разработчика программного обеспечения – идентифицировать те 20 % кода, которые действительно способны повысить производительность программы. Можно до бесконечности оптимизировать и объявлять функции inline, но все это будет пустой тратой времени, если только вы не сосредоточите усилия на нужных функциях.