c++ — Почему inline-функции, определённые в заголовочных файлах не дублируются при линковке?
Если спецификация языка говорит, что ошибки быть не должно, значит ошибки быть не должно. А дальше уже начинаются детали реализации. Почему вы решили, что они не дублируются?
В классической реализации инлайновые функции с внешним связыванием для которых компилятор при компиляции нескольких единиц трансляции решил сгенерировать тела, разумеется, дублируются. В процессе компиляции каждая единица трансляции получает свою копию такой функции в своем объектном файле с одним и тем же именем.
Однако такие функции в объектном файле помечены особым образом — так, чтобы при обнаружении множества копий одного и тот же внешнего символа при линковке линкер не выдавал ошибки, а наоборот молча удалял все копии, оставляя только одну. То есть компилятор C++ генерирует «свалку» одинаково именованных функций, раскиданных по разным объектным файлам, а линкер потом собирает всё вместе и занимается чисткой этой «свалки».
В компиляторах семейства *nix эта пометка — это обозначение экспортируемого символа, как т.наз. «слабого» (weak) символа. В компиляторе MSVC++ существует аналогичная пометка
. Линкеры выдают ошибку множественного определения только если встретят два или более одинаковых «сильных» символа в процессе линковки. Если же «сильный» символ только один (а остальные «слабые»), то побеждает «сильный» символ, а «слабые» символы отбрасываются. Если «сильного» символа нет вообще, а есть только «слабые», то побеждает один (какой-то) из «слабых». Никакой ошибки при этом не рапортуется.
Когда компилятор решает сгенерировать тело для inline-функции с внешним связыванием, он просто помечает соответствующий символ для линкера как «слабый» — и все.
(На этом же механизме построена трансляция шаблонных функций, которые, как известно, тоже определяются в заголовочных файлах и тоже порождают свои копии во всех объектных файлах, которые потребовали их инстанцирования.)
Например, скомпилировав вот такой простой исходник в объектный файл
inline void bar() {} void (*foo())() { return bar; }
и просмотрев содержимое этого объектного файла при помощи nm
мы увидим
0000000000000000 W _Z3barv 0000000000000000 T _Z3foov
Буковка W
помечает «слабый» символ, а буковка T
— «сильный» символ. Во всех объектных файлах, в которых сгенерировалось тело для такой inline
функции, она будет фигурировать под одним и тем же именем _Z3barv
с пометкой W
.
Обратите внимание, что ни о каком решении этой проблемы через генерацию множества функций с разными именами не может быть и речи: в всех остальных отношениях инлайновая функция с внешним связыванием должна вести себя так же как и любая другая функция с внешним связыванием, т.е., например, она обязана иметь один и тот же адрес во всех единицах трансляции.
Побочным эффектом такого подхода является то, что «классический» подход к формированию объектного файла, в котором у функции нет начала и конца, а есть только точка входа, становится неприемлем. Для того, чтобы иметь возможность исключать функции из объектного файла, С++ компиляторы вынуждены формировать тела функций в объектном файле в компактном виде.
Существуют исторические примеры альтернативных реализаций, которые пытались действовать по-другому. «Многопроходные» реализации вообще не порождали тел для инлайновых и шаблонных функций на первом проходе компиляции. Они выполняли предварительную линковку, на которой собирали информацию о том, каким функциям действительно нужны тела, затем снова вызывали компилятор и компилировали уникальные тела для таких функций, и затем уже выполняли финальную линковку. Но среди популярных компиляторов (GCC/Clang/MSVC) такой подход не прижился.
c++ — Почему функции которые содержат циклы, рекурсию или вызываются по указателю, плохо подвержены встраиванию (inline)
Вопрос построен на терминологической путанице. Встраивание вызовов функции — это не свойство самой функции, а всегда именно свойство индивидуального вызова функции. Свойства самой функции, разумеется, играют роль в принятии решения о том, будет ли конкретный вызов встроен, но все равно в общем случае это решение принимается для каждого отдельного вызова индивидуально. Одни вызовы функции могут оказаться встроены, в то время как другие вызовы той же функции могут оказаться не встроены. Даже аргументы функции, указанные в конкретном вызове, могут влиять на решение о том, встраивать ли этот конкретный вызов.
Утверждение о том, что вызовы функций с циклами не встраиваются, относится к категории «это было давно и неправда».
Каждый компилятор реализует какой-то набор эвристических критериев, который дает ему возможность принимать общие решения о том, заслуживают ли вызовы данной функции встраивания. В каком-то из старинных компиляторов этот эвристический критерий также включал проверку тела функции на наличие циклов (Borland или что-то в этом роде).
На самом деле циклы не представляют из себя никаких преград для встраивания. Мне не известны никакие современные реализации, которые бы отказывались встраивать вызовы функций с циклами внутри.
Утверждение о том, что функции, которые вызываются через указатель, не встраиваются — это как раз яркий пример того терминологической путаницы, о которой я писал выше. Это вызовы, выполненные через указатель, в общем случае не встраиваются. В то же время вызовы той же самой функции, выполненные напрямую, могут прекрасно встраиваться.
Почему в общем случае невозможно встроить вызов, сделанный через указатель, очевидно: потому что на стадии компиляции не ясно, какая функция будет вызываться. Однако если компилятор в состоянии сообразить на стадии компиляции, какая функция будет вызвана через указатель, он без проблем встроит и такой вызов.
Классический часто задаваемый вопрос, основанный на данной терминологической путанице — вопрос о встраивании вызовов виртуальных методов классов. Они ведь вызываются «через указатель» и значит не могут быть встроены, правда? Не правда. Во-первых, сам пользователь может выполнить вызов виртуального метода напрямую, без использования виртуального механизма. Во-вторых, в огромном количестве контекстов сам компилятор в состоянии сообразить, какой конкретный метод вызывается в данном вызове и, соответственно, встроить этот вызов.
Вызовы рекурсивных функций тоже могут встраиваться. Если компилятор на стадии компиляции в состоянии оценить максимальную глубину рекурсии, то он может встроить эти вызовы («развернуть рекурсию») на всю глубину. Если компилятор не в состоянии выполнить такой оценки, то он может встроить рекурсивные вызовы до определенной фиксированной глубины, после чего выполнить обычный рекурсивный вызов. Встраивание вызовов рекурсивных функции в этом отношении во многом аналогично развертке циклов.
Одной из простейших и наиболее очевидных ситуаций целесообразности встраивания вызова функции является, например, ситуация, когда заведомо известно, что некоторая функция вызывается в программе ровно один раз (в пространственном смысле, т.е. исходный код программы содержит ровно одно место с ее вызовом). Такой функции нет никакого смысла существовать в виде отдельной функции, независимо от того, как «тяжела» эта функция. (С этим идет ряд побочных оговорок, но к данной теме они не относятся.) Например, если функция имеет внутреннее связывание и вызывается в своей единице трансляции ровно один раз, то современные компиляторы встроят этот вызов независимо от каких-либо иных критериев.
В компиляторе GCC за это отвечает опция-finline-functions-called-once
, которая включается уже в режиме -O1
.В качестве другого примера, в компиляторе MSVC++ есть #pragma
-параметры inline_recursion
и inline_depth
, которые управляют встраиванием рекурсивных функций и глубиной развертки рекурсии.
Когда «встроенный» неэффективен? (в C)
встроенный
делает две вещи:
- дает вам исключение из «правила одного определения» (см. ниже). Этот всегда применяется .
- Дает компилятору подсказку, чтобы избежать вызова функции. Компилятор может игнорировать это.
#1 Может быть очень полезным (например, поместить определение в заголовок, если оно короткое), даже если #2 отключен.
На практике компиляторы часто лучше справляются с тем, что нужно встроить (особенно если доступна оптимизация на основе профилей).
[РЕДАКТИРОВАТЬ: полные ссылки и соответствующий текст]
Два приведенных выше пункта следуют из стандарта ISO/ANSI (ISO/IEC 9899:1999(E), широко известного как «C99»).
В §6.9 «Внешнее определение», параграф 5:
Внешнее определение — это внешнее объявление, которое также является определением функции (кроме встроенного определения) или объекта. Если идентификатор, объявленный с внешней связью, используется в выражении (кроме как часть операнда оператора sizeof, результатом которого является целочисленная константа), где-то во всей программе должно быть ровно одно внешнее определение идентификатора; в противном случае их должно быть не более одного.
Хотя эквивалентное определение в C++ явно называется правилом одного определения (ODR), оно служит той же цели. Внешние (т. е. не «статические» и, следовательно, локальные для одной единицы перевода — обычно один исходный файл) могут быть определены только один раз только , если только не является встроенной функцией и .
В §6.7.4, «Спецификаторы функций», определено встроенное ключевое слово:
Создание встроенной функции предполагает, что вызовы функции как можно быстрее. [118] Эффективность таких предложений определяется определяется реализацией.
И сноска (ненормативная), но содержит разъяснение:
Используя, например, альтернативу обычному механизму вызова функций, такую как «встроенная подстановка». Встроенная замена не является текстовой заменой и не создает новую функцию. Поэтому, например, расширение макроса, используемого в теле функции, использует определение, которое оно имело в точке появления тела функции, а не в месте вызова функции; а идентификаторы относятся к объявлениям в области видимости, в которой находится тело. Точно так же функция имеет один адрес, независимо от количества встроенных определений, которые встречаются в дополнение к внешнему определению.
Резюме: большинство пользователей C и C++ ожидают от встроенного кода совсем не то, что получают. Его очевидная основная цель — избежать накладных расходов на функциональные вызовы — совершенно необязательна. Но чтобы разрешить раздельную компиляцию, требуется ослабление единого определения.
(Все ударения в кавычках из стандарта.)
РЕДАКТИРОВАТЬ 2: Несколько замечаний:
- Существуют различные ограничения на внешние встроенные функции. У вас не может быть статической переменной в функции, и вы не можете ссылаться на статические объекты/функции области TU.
- Только что видел это в «оптимизации всей программы» VC++, что является примером того, как компилятор делает свои собственные встроенные действия, а не автор.
Встроенные функции в языке C: почему и когда они используются в программном обеспечении ADAS | Блог
11 июля 2017 г.
Я люблю смотреть «закулисные» клипы фильмов. Удивительно, как актеры, операторы и вся остальная съемочная группа работают вместе над созданием фильма. Приятно видеть и веселые кадры. Видя маленькие ошибки и негламурную сторону фильмов, я лучше оцениваю их на большом экране. Хотя компиляторы не так интересны, как фильмы, у них также много чего происходит за кулисами. Легко использовать оптимизацию компилятора, не имея представления о том, что компилятор на самом деле делает с вашим кодом. Однако понимание того, как работают эти инструменты, может помочь вам использовать их более эффективно. Такие инструменты, как встраивание функций, могут помочь ускорить вашу программу, но могут занять дополнительное место. Вот почему вам нужно тщательно выбирать, какие разделы кода встраивать. Если вы этого не сделаете, вы можете использовать дополнительное пространство без увеличения производительности.
Что такое встроенные функции?
Если фильм слишком длинный после съемок, сцены могут быть обрезаны или заменены на этапе постобработки. Точно так же встраивание функций позволяет сократить время, необходимое для вызова и возврата функций после того, как вы уже написали код. Встраивающие функции могут занимать больше места, чем вызывающие, но иногда разница в размерах минимальна.
Так что же такое встраивание функций? Обычно вы вызываете функции. Затем программа переходит к этой функции, выполняет свои операции и возвращает значение или значения. Когда функции встроены, компилятор заменяет вызов функции самой функцией. Это может ускорить ваше программное обеспечение, потому что вы не тратите время на вызов функции и последующий возврат ее значений. Вы просто запускаете функцию в соответствии с вашим кодом. Способ, которым компиляторы это делают, немного сложен, так как ваше программное обеспечение не должно быть в состоянии определить, была ли функция встроена или нет. Еще одна важная вещь, которую нужно знать, это то, что пометка чего-то «встроенным» не обязательно означает, что компилятор на самом деле встроит это. Вам просто нужно сделать все возможное, чтобы «подсказать» компилятору, что нужно встроить.
Встраивание функций может помочь вам оптимизировать ваше программное обеспечение.
Точно так же, как развертывание цикла, встраивание функций поднимает классическую головоломку времени и пространства. При программировании передовых систем помощи водителю (ADAS) вам необходимо найти тонкий баланс между ними. Вам нужны такие вещи, как автоматическое торможение, чтобы работать быстро, но вам также нужно, чтобы ваша программа была достаточно маленькой, чтобы поместиться на вашем оборудовании. Когда ваш компилятор встраивает функцию, он фактически заменяет ваши строки вызова строками функции. Если сама функция длиннее вызова, она займет больше места. Иногда функции имеют примерно тот же размер, что и их вызов, что означает, что они не занимают много места при встраивании. В других случаях функции намного больше и значительно увеличивают размер вашего кода. Если функция вызывается несколько раз, ваш компилятор может встроить несколько ее копий, теряя место. Вот почему вам всегда нужно тщательно выбирать, какие разделы кода вы хотите оптимизировать, чтобы не тратить слишком много места на скорость.
Какие функции следует встраивать
Есть два основных типа функций, которые следует попытаться встроить, если это возможно: короткие функции и статические функции. В противоположном направлении вам, вероятно, не следует встраивать длинные функции или функции, которые часто повторяются.
Короткие функции — это низко висящий плод встраивания. Если функция состоит примерно из того же количества строк, что и ее вызов, вы обязательно должны ее встроить. Компилятор заменит вызов функцией такого же размера, но с более высокой скоростью. Статические функции также отлично подходят для встраивания. Они особенно хороши для встраивания, когда у них есть только один вызов. Есть несколько других более сложных преимуществ встраивания функций, но они выходят за рамки этой статьи. Встраивание коротких и статических функций поможет вашей программе выполнять операции за меньшее время, не увеличивая размер вашего кода.
Основным недостатком встроенных функций является то, что они занимают больше места. Особенно, если функция, которую вы встраиваете, довольно длинная. Помните, что вы помещаете функцию непосредственно в свой код. Если функция очень длинная, вы можете добавить больше строк, чем нужно.