Пространства, вставленные препроцессором C

Предположим, что нам дан этот код ввода C:

#define Y 20
#define A(x) (10+x+Y)

A(A(40))

gcc -E выводится как (10+(10+40 +20)+20).

gcc -E -traditional-cpp выводится как (10+(10+40+20)+20).

Почему по умолчанию cpp вставляет пробел после 40?

Где я могу найти наиболее подробную спецификацию cpp, которая покрывает эту логику?

Ответ 1

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

В фазе 3 программа "разлагается на токены предварительной обработки и последовательности символов пробела". Помимо результата оператора конкатенации, который игнорирует пробелы, и оператор стробирования, который сохраняет пробелы, токены затем фиксируются, а их разделение больше не требуется для их разделения. Тем не менее, пробелы необходимы для:

  • директивы препроцессора синтаксического анализа
  • правильно обработать оператор стробирования

Элементы пробелов в потоке не устраняются до фазы 7, хотя они больше не актуальны после завершения фазы 4.

Gcc способен создавать разнообразную информацию, полезную для программистов, но не соответствующую чему-либо в стандарте. Например, фаза препроцессора перевода может также создавать информацию о зависимости, полезную для вставки в Makefile, используя одну из опций -M. В качестве альтернативы, скомпилированный код, читаемый человеком, может быть выведен с использованием опции -S. И компилируемая версия предварительно обработанной программы, примерно соответствующая потоку токенов, созданная фазой 4, может быть выведена с использованием опции -E. Ни один из этих форматов вывода никоим образом не контролируется стандартом C, который касается только фактического выполнения программы.

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

Например, если вызов макроса в вашем примере был изменен на

A(A(0x40E))

то наивный вывод потока токенов приведет к

(10+(10+0x40E+20)+20)

который нельзя было скомпилировать, потому что 0x40E+20 - это один токен с номером pp-номера, который не может быть преобразован в числовой токен. Пробел перед + предотвращает это.

Если вы попытаетесь реализовать препроцессор как какое-то строковое преобразование, вы, несомненно, столкнетесь с серьезными проблемами в угловых случаях. Правильная стратегия реализации заключается в том, чтобы сначала маркировать, как указано в стандарте, а затем выполнять фазу 4 как функцию потока токенов и пробелов.

Stringification - особенно интересный случай, когда пробел влияет на семантику, и его можно использовать для просмотра того, как выглядит фактический поток токенов. Если вы растягиваете расширение A(A(40)), вы можете увидеть, что на самом деле не было вставлено никаких пробелов:

$ gcc -E -x c - <<<'
#define Y 20
#define A(x) (10+x+Y)
#define Q_(x) #x
#define Q(x) Q_(x)         
Q(A(A(40)))'

"(10+(10+40+20)+20)"

Обработка пробелов в строчении точно определяется стандартом: (& sect; 6.10.3.2, абзац 2, большое спасибо Джону Боллинджеру за то, что он нашел спецификацию.)

Каждое появление пробела между токенами предварительной обработки аргументов становится символом пробела в символьном строковом литерале. Перед первым токеном предварительной обработки и после последнего токена предварительной обработки, составляющего аргумент, удаляется пробел.

Вот более тонкий пример, когда в выводе gcc -E требуется дополнительное пространство пробелов, но на самом деле оно не вставлено в поток токенов (снова показано с помощью строкой для создания реального потока токенов.) I (идентифицировать ) используется макрос, чтобы разрешить вставлять два токена в поток токенов без промежуточных пробелов; что полезный трюк, если вы хотите использовать макросы для составления аргумента директивы #include (не рекомендуется, но это может быть сделано).

Возможно, это может быть полезным тестовым примером для вашего препроцессора:

#define Q_(x) #x
#define Q(x) Q_(x)
#define I(x) x
#define C(x,...) x(__VA_ARGS__)
// Uncomment the following line to run the program
//#include <stdio.h>

char*quoted=Q(C(I(int)I(main),void){I(return)I(C(puts,quoted));});
C(I(int)I(main),void){I(return)I(C(puts,quoted));}

Здесь вывод gcc -E (только хороший материал в конце):

$ gcc -E squish.c | tail -n2
char*quoted="intmain(void){returnputs(quoted);}";
int main(void){return puts(quoted);}

В потоке токенов, который выдается из фазы 4, токены int и main не разделяются пробелами (и ни return, ни puts). Это ясно показано строкой, в которой никакие пробелы не разделяют токен. Однако программа компилирует и выполняет отлично, даже если она явно передается через gcc -E:

$ gcc -E squish.c | gcc -x c - && ./a.out 
intmain(void){returnputs(quoted);}

и скомпилировать вывод gcc -E.


Различные компиляторы и разные версии одного и того же компилятора могут создавать разные сериализации предварительно обработанной программы. Поэтому я не думаю, что вы найдете какой-либо алгоритм, который можно проверить с помощью сравнения по каждому символу с выходом -E данного компилятора.

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

Я думаю, что алгоритм минимального пространства будет состоять в том, чтобы записать состояние DFA в конце последнего символа в токене, чтобы впоследствии вывести пробел между двумя последовательными токенами, если существует переход от состояния в конце первый токен первого символа следующего токена. (Сохранение состояния DFA как части токена по сути не отличается от сохранения типа токена как части токена, поскольку вы можете получить тип токена из простого поиска из состояния DFA.) Этот алгоритм не будет вставлять пробел после 40 в вашем исходном тестовом примере, но он вставляет пробел после 0x40E. Так что это не алгоритм, используемый вашей версией gcc.

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

Если вы не хотите записывать состояния (хотя, как я уже сказал, практически нет затрат при этом), и вы не хотите восстанавливать состояние, повторное сканирование маркера при его выводе (что также быть довольно дешевым), вы можете предварительно скопировать двумерный булевский массив с ключом типа токена и последующим символом. Вычисление по существу будет таким же, как и выше: для каждого принимающего состояния DFA, которое возвращает определенный тип токена, введите истинное значение в массиве для этого типа токена и любой символ с переходом из состояния DFA. Затем вы можете найти маркерный токен и первый символ следующего токена, чтобы узнать, может ли быть необходимым пространство. Этот алгоритм не дает выход с минимальным интервалом: он, например, разместил бы пробел после 40 в вашем примере, так как 40 является pp-number, и возможно, что некоторые pp-number будут расширены с + (хотя вы не можете расширить 40 таким образом). Таким образом, возможно, что gcc использует некоторую версию этого алгоритма.

Ответ 2

Добавление некоторого исторического контекста к превосходному ответу rici.

Если вы можете получить рабочую копию gcc 2.7.2.3, поэкспериментируйте с ее препроцессором. В то время препроцессор был отдельной программой от компилятора, и он использовал очень наивный алгоритм для сериализации текста, который, как правило, вставлял гораздо больше пробелов, чем это было необходимо. Когда Нил Бут, Пер Ботнер и я реализовали интегрированный препроцессор (появляющийся в gcc 3.0 и с тех пор), мы решили сделать вывод -E более умным одновременно, но не слишком усложнив реализацию. Ядром этого алгоритма является библиотечная функция cpp_avoid_paste, определенная в https://gcc.gnu.org/git/?p=gcc.git;a=blob;f=libcpp/lex.c#l2990, и ее вызывающий объект здесь: https://gcc.gnu.org/git/?p=gcc.git;a=blob;f=gcc/c-family/c-ppoutput.c#l177 (ищите "Тонкая логика для вывода пробела..." ).

В случае вашего примера

#define Y 20
#define A(x) (10+x+Y)
A(A(40))

cpp_avoid_paste будет вызываться с помощью токена CPP_NUMBER (то, что rici называется "pp-number" ) слева, и маркера "+" справа. В этом случае он безоговорочно говорит "да, вам нужно вставить пространство, чтобы избежать вставки", а не проверять, является ли последний символ маркера числа одним из eEpP.

Конструкция компилятора часто сводится к компромиссу между точностью и простотой реализации.