Pull to refresh

Обработка препроцессорных директив в Objective-C

Reading time 9 min
Views 4.8K

Язык программирования с препроцессорными директивами сложен для обработки, поскольку в этом случае необходимо вычислять значения директив, вырезать некомпилируемые фрагменты кода, а затем производить парсинг очищенного кода. Обработка директив может осуществляться во время парсинга обычного кода. Данная статья подробно описывает оба подхода применительно к языку Objective-C, а также раскрывает их достоинства и недостатки. Эти подходы существуют не только в теории, но уже реализованы и используются на практике в таких веб-сервисах, как Swiftify и Codebeat.



Swiftify — веб-сервис для преобразования исходников на Objective-C в Swift. На данный момент сервис поддерживает обработку как одиночных файлов, так и целых проектов. Таким образом, он может сэкономить время разработчикам, желающим освоить новый язык от Apple.



Codebeat — автоматизированная система для подсчета метрик кода и проведения анализа различных языков программирования, в том числе и Objective-C.





Содержание




Введение


Обработка директив препроцессора осуществляется во время парсинга кода. Базовые понятия парсинга мы описывать не будем, однако здесь будут использоваться термины из статьи по теории и парсингу исходного кода с помощью ANTLR и Roslyn. В качестве генератора парсера в обоих сервисах используется ANTLR, а сами грамматики Objective-C выложены в официальный репозиторий грамматик ANTLR (Objective-C grammar).


Нами было выделено два способа обработки препроцессорных директив:


  • одноэтапная обработка;
  • двухэтапная обработка.


Одноэтапная обработка


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


Обычно токены директив начинаются со знака решетки (# или шарп) и заканчиваются символами разрыва строк (\r\n). Таким образом, для захвата подобных токенов целесообразно иметь другой режим распознавания лексем. ANTLR поддерживает такие режимы, они описываются так: mode DIRECTIVE_MODE;. Фрагмент лексера с секцией mode для препроцессорных директив выглядит следующим образом:


SHARP:  '#'                    -> channel(DIRECTIVE_CHANNEL), mode(DIRECTIVE_MODE);

mode DIRECTIVE_MODE;

DIRECTIVE_IMPORT:              'import' [ \t]+  -> channel(DIRECTIVE_CHANNEL), mode(DIRECTIVE_TEXT_MODE);
DIRECTIVE_INCLUDE:             'include' [ \t]+ -> channel(DIRECTIVE_CHANNEL), mode(DIRECTIVE_TEXT_MODE);
DIRECTIVE_PRAGMA:              'pragma'         -> channel(DIRECTIVE_CHANNEL), mode(DIRECTIVE_TEXT_MODE);

Часть препроцессорных директив Objective-C преобразуется в определенный код на языке Swift (например, с использованием синтаксиса let): какие-то остаются в неизмененном виде, а остальные преобразуются в комментарии. Таблица ниже содержит примеры:


Objective-C Swift
#define SERVICE_UUID @ "c381de0d-32bb-8224-c540-e8ba9a620152" let SERVICE_UUID = "c381de0d-32bb-8224-c540-e8ba9a620152"
#define ApplicationDelegate ((AppDelegate *)[UIApplication sharedApplication].delegate) let ApplicationDelegate = (UIApplication.shared.delegate as? AppDelegate)
#define DEGREES_TO_RADIANS(degrees) (M_PI * (degrees) / 180) func DEGREES_TO_RADIANS(degrees: Double) -> Double { return (.pi * degrees)/180; }
#if defined(__IPHONE_OS_VERSION_MIN_REQUIRED) #if __IPHONE_OS_VERSION_MIN_REQUIRED
#pragma mark - Directive between comments. // MARK: - Directive between comments.

Комментарии также нужно помещать в правильную позицию в результирующем Swift коде. Однако, как уже упоминалось, в дереве разбора отсутствуют сами скрытые токены.


Что если включать скрытые токены в дерево разбора?

Действительно, скрытые токены можно включать в грамматику, но из-за этого она станет слишком сложной и избыточной, т.к. токены COMMENT и DIRECTIVE будут содержаться в каждом правиле между значимыми токенами:


declaration: property COMMENT* COLON COMMENT* expr COMMENT* prio?;

Поэтому о таком подходе можно сразу забыть.


Возникает вопрос: как же все же извлекать такие токены при обходе дерева разбора?


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



Связывание скрытых токенов с нетерминальными узлами


Данный способ заимствован из относительно старой статьи 2012 года по ANTLR 3.


В этом случае все скрытые токены разбиваются на множества следующих типов:


  • предшествующие токены (precending);
  • последующие токены (following);
  • токены-сироты (orphans).

Чтобы лучше понять что означают эти типы рассмотрим простое правило, в котором фигурные скобки — терминальные символы, а в качестве statement может быть любое выражение, содержащее точку с запятой на конце, например присваивание a = b;.


root
    : '{' statement* '}'
    ;

В таком случае все комментарии из следующего фрагмента кода попадут в список precending, т.е. первый токен в файле или токены перед нетерминальными узлами дерева разбора.


/*First comment*/ '{' /*Precending1*/ a = b; /*Precending2*/ b = c; '}'

Если комментарий является последним в файле, или же комментарий вставлен после всех statement (после него идет терминальная скобка), то он попадает в список following.


'{' a = b; b = c; /*Following*/ '}' /*Last comment*/ 

Все остальные комментарии попадают в список orphans (все они по сути обособлены токенами, в данном случае фигурными скобками):


'{' /*Orphan*/ '}'

Благодаря такому разбиению, все скрытые токены можно обрабатывать в общем методе Visit. Данный способ и сейчас используется в Swiftify, однако он достаточно сложный и строить достоверное (fidelity) дерево разбора с помощью него проблематично. Достоверность дерева заключается в том, что оно может быть преобразовано обратно в код символ в символ, включая пробелы, комментарии и директивы препроцессора. В будущем мы планируем перейти на использование способа для обработки препроцессорных директив и других скрытых токенов, описание которого вы увидите ниже.



Связывание скрытых токенов с терминальными узлами


В данном случае скрытые токены связываются с определенным значимыми токенами. При этом скрытые токены могут быть лидирующими (LeadingTrivia) и замыкающими (TrailingTrivia). Этот способ сейчас используется в Roslyn парсере (для C# и Visual Basic), а скрытые токены в нем называются тривиями (Trivia).


Во множество замыкающих токенов попадают все тривии на той же самой строчке от значимого токена до следующего значимого токена. Все остальные скрытые токены попадают в множество лидирующих и связываются со следующим значимым токеном. Первый значимый токен содержит в себе начальные тривии файла. Скрытые токены, замыкающие файл, связываются с последним специальным end-of-file токеном нулевой длины. Более детально о типах дерева разбора и тривиях написано в официальной документации по Roslyn.


В ANTLR для токена с индексом i существует метод, который возвращает все токены из определенного канала слева или справа: getHiddenTokensToLeft(int tokenIndex, int channel), getHiddenTokensToRight(int tokenIndex, int channel). Таким образом, можно сделать так, чтобы парсер на основе ANTLR формировал достоверное дерево разбора, схожое с деревом разбора Roslyn.



Игнорируемые макросы


Так как при одноэтапной обработке макросы не заменяются на фрагменты кода Objective-C, их можно игнорировать или помещать в отдельный изолированный канал. Это позволяет избежать проблем при парсинге обычного кода Objective-C и необходимости включать макросы в узлы грамматики (по аналогии с комментариями). Это касается и макросов по умолчанию, таких как NS_ASSUME_NONNULL_BEGIN, NS_AVAILABLE_IOS(3_0) и других:


NS_ASSUME_NONNULL_BEGIN : 'NS_ASSUME_NONNULL_BEGIN' ~[\r\n]*  -> channel(IGNORED_MACROS);
IOS_SUFFIX              : [_A-Z]+ '_IOS(' ~')'+ ')'           -> channel(IGNORED_MACROS);


Двухэтапная обработка


Алгоритм двухэтапной обработки может быть представлен в виде следующей последовательности шагов:


  1. Токенизация и разбор кода препроцессорных директив. Обычные фрагменты кода на этом шаге распознаются как простой текст.
  2. Вычисление условных директив (#if, #elif, #else) и определение компилируемых блоков кода.
  3. Вычисление и подстановка значений #define директив в соответствующие места в компилируемых блоках кода.
  4. Замена директив из исходника на символы пробела (для сохранения корректных позиций токенов в исходном коде).
  5. Токенизация и парсинг результирующего текста с удаленными директивами.

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


Для двухэтапной обработки используются следующие компоненты:


  1. препроцессорный лексер;
  2. препроцессорный парсер;
  3. препроцессор;
  4. лексер;
  5. парсер.

Напомним, что лексер группирует символы исходного кода в значимые последовательности, которые называются лексемами или токенами. А парсер строит из потока токенов связную древовидную структуру, которая называется деревом разбора. Визитор (Visitor) — паттерн проектирования, позволяющий выносить логику обработки каждого узла дерева в отдельный метод.



Препроцессорный лексер


Лексер, отделяющий токены препроцессорных директив и обычного Objective-C кода. Для токенов обычного кода используется DEFAULT_MODE, а для кода директив — DIRECTIVE_MODE. Ниже приведены токены из DEFAULT_MODE.


SHARP:                    '#'                                        -> mode(DIRECTIVE_MODE);
COMMENT:                  '/*' .*? '*/'                              -> type(CODE);
LINE_COMMENT:             '//' ~[\r\n]*                              -> type(CODE);
SLASH:                    '/'                                        -> type(CODE);
CHARACTER_LITERAL:        '\'' (EscapeSequence | ~('\''|'\\')) '\''  -> type(CODE);
QUOTE_STRING:             '\'' (EscapeSequence | ~('\''|'\\'))* '\'' -> type(CODE);
STRING:                   StringFragment                             -> type(CODE);
CODE:                     ~[#'"/]+;

При взгляде на этот фрагмент кода может возникнуть вопрос о необходимости дополнительных токенов (COMMENT, QUOTE_STRING и прочих), тогда как для кода Objective-C используется всего один токен — CODE. Дело в том, что символ # может быть спрятан внутрь обычных строк и комментариев. Поэтому такие токены необходимо выделять отдельно. Но это не является проблемой, поскольку их тип все равно изменяется на CODE, а в препроцессорном парсере для отделения токенов существуют следующие правила:


text
    : code
    | SHARP directive (NEW_LINE | EOF)
    ;

code
    : CODE+
    ;


Препроцессорный парсер


Парсер, отделяющий токены кода Objective-C и обрабатывающий токены препроцессорных директив. Полученное дерево разбора затем передается препроцессору.



Препроцессор


Визитор, вычисляющий значения препроцессорных директив. Каждый метод обхода узла возвращает строку. Если вычисленное значение директивы принимает значение true, то возвращается последующий фрагмент кода Objective-C. В противном случае код Objective-C заменяется на пробелы. Как уже говорилось ранее, это необходимо для того, чтобы сохранить правильные позиции токенов основного кода. Для облегчения понимания приведем в качестве примера следующий фрагмент кода Objective-C:


BOOL trueFlag =
#if DEBUG
    YES
#else
    arc4random_uniform(100) > 95 ? YES : NO
#endif
;

Этот фрагмент будет преобразован в следующий код на Objective-C при заданном условном символе DEBUG при использовании двухэтапной обработки.


BOOL trueFlag =

    YES

;

Стоит обратить внимание, что все директивы и некомпилируемый код превратились в пробелы. Директивы также могут быть вложенными друг в друга:


#if __IPHONE_OS_VERSION_MIN_REQUIRED >= 60000
    #define MBLabelAlignmentCenter NSTextAlignmentCenter
#else
    #define MBLabelAlignmentCenter UITextAlignmentCenter
#endif


Лексер


Лексер обычного Objective-C без токенов, распознающих препроцессорные директивы. Если директив в исходном файле нет, то на вход поступает тот же самый оригинальный файл.



Парсер


Парсер обычного Objective-C кода. Грамматика данного парсера совпадает с грамматикой парсера из одноэтапной обработки.



Другие способы обработки


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


Так как ANTLR очень сильно завязан на процесс токенизации, то подобные решения не рассматривались. Хотя возможность создания безлексерых грамматик сейчас уже существует и будет дорабатываться в будущем (см. обсуждение).



Заключение


В настоящей статье были рассмотрены подходы по обработке препроцессорных директив, которые могут использоваться при парсинге C-подобных языков. Эти подходы уже реализованы для обработки кода Objective-C и используются в коммерческих сервисах, таких как Swiftify и Codebeat. Парсер с двухэтапной обработкой протестирован на 20 проектах, в которых количество безошибочно обработанных файлов составляет более 95% от общего числа. Кроме того, одноэтапная обработка также реализована для парсинга C# и выложена в Open Source: C# grammar.


В Swiftify используется одноэтапная обработка препроцессорных директив, так как наша задача — не выполнить работу препроцессора, а транслировать препроцессорные директивы в соответствующие языковые конструкции Swift, несмотря на потенциально возможные ошибки парсинга. Например, директивы #define в Objective-C обычно используются для объявления глобальных констант и макросов. В Swift для этих же целей используются константы (let) и функции (func).

Tags:
Hubs:
+8
Comments 6
Comments Comments 6

Articles