C оптимизация хвостового вызова

Я часто слышу, как люди говорят, что C не выполняет устранение хвостового вызова. Даже если это не гарантируется стандартом,разве это не выполняется на практике любой достойной реализацией? Предполагая, что вы нацелены только на зрелые, хорошо реализованные компиляторы и не заботитесь об абсолютной максимальной переносимости примитивных компиляторов, написанных для неясных платформ, разумно ли полагаться на устранение хвостового вызова в C?

кроме того, каково было обоснование для оставления хвостового вызова оптимизация вне стандарта?

8 ответов


утверждения типа "C не выполняет устранение хвостового вызова" не имеют смысла. Как вы правильно заметили, такие вещи полностью зависят от реализации. И да, любая приличная реализация может легко превратить хвостовую рекурсию в [эквивалент] цикла. Конечно, компиляторы C обычно не дают никаких гарантий относительно того, какие оптимизации будут и какие оптимизации не будут происходить в каждом конкретном фрагменте кода. Вы должны скомпилировать его и посмотреть сами.


чтобы ответить на последний вопрос: стандарт определенно не должен делать никаких заявлений об оптимизации. Могут быть среды, в которых это более или менее сложно реализовать.


хотя современные компиляторы могут выполнять оптимизацию хвостового вызова, если вы включите оптимизацию, ваши сборки отладки, вероятно, будут работать без нее, чтобы вы могли получить трассировки стека и шаг в/из кода и замечательные вещи, подобные этому. В этом случае оптимизация хвостового вызова не требуется.

поскольку оптимизация хвостового вызова не всегда желательна, нет смысла поручать ее составителям компиляторов.


Я думаю, что оптимизация хвостового вызова должна быть гарантирована только там, где большое рекурсии ожидается или требуется, то есть, в языках, что поощрять или применять функциональный стиль программирования. (С такими языками вы можете обнаружить, что for или while циклы либо сильно обескуражены, воспринимаются как неэлегантные, либо, вероятно, даже полностью отсутствуют в языке, поэтому вы прибегнете к рекурсии по всем этим причинам и, вероятно, больше.)

язык программирования C (IMHO) явно был не разработанный с функциональным программированием в виду. Есть все виды конструкций цикла, которые обычно используются в пользу рекурсии:for, do .. while, while. На таком языке не имеет смысла предписывать оптимизацию хвостового вызова в стандарте, потому что это не обязательно для гарантии рабочих программ.

сравните это снова с функциональным языком программирования, который нет while петли: это означает, что вы нужно рекурсия; что в свою очередь означает, что язык должен убедиться, что со многими итерациями переполнения стека не станут проблемой; таким образом, официальный стандарт для такого языка может выберите, чтобы назначить оптимизацию хвостового вызова.


П. С.: обратите внимание на небольшой недостаток в моем аргументе на оптимизация хвостового вызова. В конце я упоминаю переполнение стека. Но кто говорит, что вызовы функций всегда требуют стека? На некоторых платформах вызовы функций могут быть реализованы совершенно по-другому, и переполнение стека никогда не будет проблемой. Это был бы еще один аргумент против предписания оптимизации хвостового вызова в стандарте. (Но не поймите меня неправильно, я вижу преимущества таких оптимизаций, даже без стека!)


стандарт языка определяет, как ведет себя язык, а не как компиляторы должны быть реализованы. Оптимизация не является обязательной, потому что она не всегда нужна. Компиляторы предоставляют параметры, позволяющие пользователю включить оптимизацию, если он этого пожелает, и также отключить ее. Оптимизация компилятора может повлиять на возможность отладки кода (становится сложнее сопоставлять C сборке по строкам), поэтому имеет смысл выполнять оптимизацию только у пользователя запрос.


существуют ситуации, когда оптимизация хвостового вызова потенциально нарушит ABI или, по крайней мере, будет очень трудно реализовать семантически сохраняющим способом. Подумайте о независимом коде позиции в общих библиотеках, например: некоторые платформы позволяют программам динамически связываться с библиотеками, чтобы сохранить основную память, когда различные приложения зависят от одной и той же функциональности. В таких случаях библиотека загружается один раз и сопоставляется с каждой виртуальной программой память, как будто это единственное приложение в системе. В UNIX, а также в некоторых других системах это достигается с помощью независимого от позиции кода для библиотек, так что адресация является относительной к смещению, а не абсолютной к фиксированному адресному пространству. Однако на многих платформах независимый от позиции код не должен быть оптимизирован для хвостового вызова. Проблема заключается в том, что смещения для навигации по программе должны храниться в регистрах; на Intel 32-бит,%ebx используется вызываемой сохраненный регистр; другие платформы следуют этому понятию. В отличие от функций, использующих обычные вызовы, те, кто развертывает хвостовые вызовы, должны восстанавливать сохраненные регистры вызываемого перед ответвлением на подпрограмму, а не когда они возвращаются сами. Обычно это не проблема, потому что на данный момент самая верхняя вызывающая функция не заботится о значении, хранящемся в %ebx, но независимый от позиции код зависит от этого значения при каждом прыжке, вызове или команде ветви.

другой проблемы могут быть в ожидании очистки в объектно-ориентированных языках (C++), что означает, что последний вызов функции на самом деле не является последним вызовом - очистки. Следовательно, компилятор обычно не оптимизирует, когда это так.

и setjmp и longjmp являются проблематичными, конечно, поскольку это эффективно означает, что функция может завершить выполнение более одного раза, прежде чем она фактически завершится. Трудно или невозможно оптимизировать во время компиляции!

вероятно, можно придумать и другие технические причины. Это лишь некоторые соображения.


для тех, кто любит доказательство по конструкции, вот godbolt делает хорошую оптимизацию хвостового вызова и встроенную: https://godbolt.org/z/DMleUN

однако, если вы провернете оптимизацию на-O3 (или, без сомнения, если вы подождете несколько лет или используете другой компилятор), оптимизация полностью удалит цикл/рекурсию.

вот пример, который оптимизирует до одной инструкции даже с -O2:https://godbolt.org/z/CNzWex


обычно компиляторы распознают ситуации, когда функции не нужно ничего делать после вызова другого, и заменяют этот вызов прыжком. Многие случаи, когда это можно сделать безопасно, легко распознать, и такие случаи квалифицируются как "безопасные низко висящие фрукты". Однако даже на компиляторах, которые могут выполнять такую оптимизацию, не всегда может быть очевидно, когда она должна или будет выполнена. Различные факторы могут сделать стоимость хвостового вызова больше, чем стоимость обычного вызова, и эти факторы не всегда предсказуемы. Например, если функция заканчивается return foo(1,2,3,a,b,c,4,5,6);, может быть практично скопировать A, b и c в регистры, очистить стек, а затем подготовить аргументы для передачи, но может быть недостаточно регистров для обработки foo(a,b,c,d,e,f,g,h,i); дополнительно.

Если язык имел специальный синтаксис" хвостового вызова", который требовал, чтобы компиляторы, которые делают хвостовой вызов, если это вообще возможно, и отказываются от компиляции в противном случае, код мог бы безопасно предположить такое функции могут быть вложены произвольно глубоко. Однако при использовании обычного синтаксиса вызова нет общего способа узнать, сможет ли компилятор выполнить хвостовой вызов дешевле, чем "обычный".