Язык программирования с препроцессорными директивами сложен для обработки, поскольку в этом случае необходимо вычислять значения директив, вырезать некомпилируемые фрагменты кода, а затем производить парсинг очищенного кода. Обработка директив может осуществляться во время парсинга обычного кода. Данная статья подробно описывает оба подхода применительно к языку 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);
Двухэтапная обработка
Алгоритм двухэтапной обработки может быть представлен в виде следующей последовательности шагов:
- Токенизация и разбор кода препроцессорных директив. Обычные фрагменты кода на этом шаге распознаются как простой текст.
- Вычисление условных директив (
#if
,#elif
,#else
) и определение компилируемых блоков кода. - Вычисление и подстановка значений
#define
директив в соответствующие места в компилируемых блоках кода. - Замена директив из исходника на символы пробела (для сохранения корректных позиций токенов в исходном коде).
- Токенизация и парсинг результирующего текста с удаленными директивами.
Третий шаг может быть пропущен, и макросы могут быть включены непосредственно в грамматику, по крайней мере частично. Однако данный метод все равно сложнее реализовать, чем одноэтапную обработку: в этом случае после первого шага необходимо заменять код препроцессорных директив на пробелы, если существует потребность в сохранении правильных позиций токенов обычного исходного кода. Тем не менее данный алгоритм обработки препроцессорных директив в свое время также был реализован и сейчас используется в Codebeat. Грамматики выложены на GitHub вместе с визитором, обрабатывающим препроцессорные директивы. Дополнительным плюсом такого метода является представление грамматик в более структурированной форме.
Для двухэтапной обработки используются следующие компоненты:
- препроцессорный лексер;
- препроцессорный парсер;
- препроцессор;
- лексер;
- парсер.
Напомним, что лексер группирует символы исходного кода в значимые последовательности, которые называются лексемами или токенами. А парсер строит из потока токенов связную древовидную структуру, которая называется деревом разбора. Визитор (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).
Sirikid
А у вас макро
DEGREES_TO_RADIANS
сломанCrulex
Спасибо! С трансляцией подобных макросов пока еще остается нерешенный вопрос, как методом дедукции получить type name (Double в примере в статье).
Планируем сделать примерно такой вариант:
но это еще в процессе :)
Sirikid
Я имел ввиду что сам макрос реализован неправильно, нету скобок вокруг аргумента.
Какой профит в данном случае будет от использования
Any
вместоDouble
если можно вывести тип аргумента? Собственно из выражения надо выводить тип аргумента, подставлять его и выводить тип выражения.Не рассматривался вариант использования родного препроцессора (
cpp
)?Преобразовывать макросы с параметрами в функции это смелое решение, гораздо проще было бы просто развернуть их. Как обрабатываются ситуации когда макрос нельзя преобразовать в функцию?
Crulex
По поводу скобок вокруг аргумента — согласен, спасибо!
Правда, не всегда ж на входе конвертора попадается "правильный" код.
Конкретно этот макрос судя по всему взят отсюда:
http://stackoverflow.com/questions/29179692/how-can-i-convert-from-degrees-to-radians
Если рассматривать конвертацию любого макроса с параметрами, а не конкретный случай, то выходит примерно следующее:
1) Типы параметра (degrees) и возвращаемого значения не указаны, по-этому объявляем их как Any;
2) На этапе разбора
M_PI * (degrees)
конвертор знает, что M_PI имеет тип Double, соответственно выражение (degrees) должно иметь тип Double;3) Ввиду того, что переменная degrees имеет тип Any, используется приведение типов.
(к сожалению, Swift очень строго относится к совместимости numeric data types).
4) Вывести тип аргумента и возвращаемого значения в приведенном примере можно,
а в общем случае — очень затруднительно. Ведь вместо (degrees) может идти вызов метода (известного нам, или нет), или же выражение любой сложности.
Рассматривался, но в контексте конвертации с Objective-C на Swift обычно это не совсем то, что нужно пользователю:
1) В Objective-C приложении нередко глобальные константы объявляются как #define.
Наиболее подходящая для этого конструкция в Swift — глобальная константа (let).
Если же заменить все использования макроса в коде на подставляемое значение, получится код в стиле copy&paste который мало кому понравится.
2) То же самое относится к #define с параметрами. В большинстве случаев, заменять такой макрос в коде на подставляемый результат — далеко не то, что ожидается в конечном результате.
Например, представьте себе развернутый в коде макрос UIColorToRGB() отсюда (далеко не самый сложный случай):
http://stackoverflow.com/questions/1243201/is-macro-better-than-uicolor-for-setting-rgb-color
Огромный плюс использования родного препроцессора в том, что он позволяет избежать ошибок парсинга в случае использования сложных макросов. Вероятно, в будующем мы добавим использование пепроцессора как опцию.
В реальных проектах которые я рассматривал, имеет больше смысла дать пользователю возможность заменить объявление макроса вручную, чем заменить все использования макроса.
Даже если это поможет избежать некоторых ошибко конвертации, спагетти-код полученный в результате все равно придется переделывать.
Sirikid
Спасибо ещё раз за пост. Ещё такой вопрос, стали бы использовать стандартный препроцессор если бы у него было больше опций? Например можно было бы выбрать какие директивы обрабатывать, а какие нет.
Crulex
Некоторые опции могли бы быть полезны.
К примеру, вряд ли нам пригодится препроцессорная обработка #if, #else, #endif, и включение в результат только участка кода, при котором условие вычисляется в true.
Вместо этого, мы включаем подобные директивы в Swift код, хоть их поддержка в Swift сильно ограничена.