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

Мова програмування с препроцессорными директивами складний для обробки, оскільки в цьому випадку необхідно обчислювати значення директив, вирізати непотрібні фрагменти некомпилируемого коду, а потім проводити парсинг очищеного коду. Обробка директив може здійснюватися також під час парсинга звичайного коду. Дана стаття описує обидва підходи стосовно мові 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 UIApplicationDelegate
#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* пріоритет?;

Тому про такому підході можна відразу забути.
Виникає питання: як же все ж витягувати такі токени при обході дерева розбору?
Як виявилося, існує кілька варіантів вирішення такої задачі, при яких приховані токени зв'язуються з нетерминальными або ж термінальними (кінцевими) вузлами дерева розбору.

Зв'язування прихованих токенів з нетерминальными вузлами
В цьому випадку всі приховані токени розбиваються на безлічі наступних типів:
  • попередні токени (precending);
  • наступні токени (following);
  • токени-сироти (orphans).
Даний спосіб запозичений з відносно старої статті 2012 року по ANTLR 3.
Щоб краще зрозуміти, що означають ці типи, розглянемо просте правило, в якому фігурні дужки — термінальні символи, а як
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 разом з визитором, обробляють препроцессорные директиви: Objective-C two-step processing. Додатковим плюсом такого методу є подання граматик у більш структурованою формою.
Для двоетапної обробки використовуються наступні компоненти:
  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
;

Цей текст буде перетворений у наступний код Swift при заданому умовному символі
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.
У Swiftify використовується одноетапна обробка препроцессорных директив, так як наша задача — не виконувати роботу препроцесора, а транслювати препроцессорные директиви у відповідні мовні конструкції Swift, незважаючи на потенційно можливі помилки парсингу. Наприклад, директиви
#define
в Objective-C зазвичай використовуються для оголошення глобальних констант і макросів. В Swift для цих же цілей використовуються константи (let) та функції (func).
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.