Pull to refresh

Clang API. Начало

Reading time11 min
Views34K
Сейчас с уверенностью можно утверждать, что времена самописных C++-парсеров постепенно отходят в прошлое. На сцену медленно, но неумолимо выходит clang — полноценный C++-фронренд и компилятор, предоставляющий своим пользователям богатое API. С помощью этого API можно распарсить исходный текст на C/C++/Objective C, и вытащить из него всю необходимую информацию — от простого лексического значения токенов, до таблицы символов, AST-деревьев и результатов статического анализа кода на предмет всяких разных проблем. В связке с llvm и при сильном на то желании C++ можно использовать в качестве скриптового языка, парся и исполняя C++-программы «на лету». В общем, возможности перед программистами открываются богатые, надо только понять — как ими правильно воспользоваться. А тут, как это не редко случается, и начинается самое интересное.

1. Clang или clang-c?


Надо начать с того, что разработчики clang'а предоставляют своим клиентам два вида API. Первое — полностью «плюсовое», но… потенциально нестабильное (в том смысле, что может меняться от версии к версии). Второе — гарантированно стабильное, но… чисто «сишное». Выбор в пользу того или другого надо делать по ситуации, и исходя из потребностей разрабатываемого на базе clang продукта.

1.1 clang-c API

В дереве исходников clang'а реализация этой библиотеки размещена в ветке tools (реализация самого ядра clang'а размещена в lib). Эта библиотека компилируется в динамически-подгружаемый модуль, и ее интерфейс предоставляет клиенту ряд гарантий:
  1. Стабильность и backward compatibility. Клиент может безопасно для себя (то есть для своего кода) переходить с одной версии clang'а на другую, не боясь что что-нибудь отвалится или, того хуже, перестанет собираться.
  2. Есть возможность в рантайме определить capabilities используемой реализации clang, и под них подстроиться.
  3. Высокая отказоустойчивость — фатальные ошибки в ядре clang'а не приведут к краху клиента.
  4. Собственные средства управления потоками для тяжеловесных активностей (как то парсинг).
  5. Нет необходимости компилировать сам фронтенд, поскольку весь функционал компилятора и API собран в виде одной динамически-подгружаемой библиотеки.

Но, за перечисленные преимущества надо платить. По этому clang-c API обладает следующим набором недостатков:
  1. Дизайн в соответствии с паттерном «корабль в бутылке». Сущности, с которыми взаимодействует клиент этого API — по сути своей, обертки над оригинальными классами, предоставляемыми clang API.
  2. (как следствие) Ручное управление ресурсами. Для удобного использования из C++-кода необходимо написать обертки, обеспечивающие RAII.
  3. Очень «узкий» интерфейс. Клиенту предоставляется небольшой набор C-методов и типов, посредством которых он взаимодействует с ядром.
  4. (как следствие) достаточно небогатый набор функционала. Многие средства, предоставляемые clang API, просто недоступны клиенту, или предоставляются в урезанном виде.

Использовать этот вариант API имеет смысл в тех случаях, когда имеющийся набор «плюсов» существенен для клиентского кода. Ну или «минусы» не столь принципиальны. Это API вполне подходит для извлечения семантической информации из исходного текста (как в виде AST, так и в виде семантической нагрузки каждого конкретного токена в исходном тексте), его нечастой индексации, верификации «на лету» со сбором всей диагностики, и т. п. задач. Соответственно, это подходит для различного рода standalone-трансляторов, генераторов метаинформации, статических анализаторов и верификаторов кода, и т. п.
И, в свою очередь, это API плохо подходит для задач, где требуется повышенная производительность, или более плотное взаимодействие с ядром компилятора.

1.2 clang API

Этот вариант API — по сути своей интерфейс самого ядра компилятора. Это API чисто C++-ное, и предоставляет широкий доступ ко всем возможностям ядра clang'а. К его достоинствам можно отнести:

  1. Как уже было сказано, прямой и удобный доступ ко всем возможностям компилятора.
  2. Удобный (как минимум по сравнению с clang-c) интерфейс.
  3. Большое количество всевозможных кастомизаций.
  4. Несколько более высокая производительность (по сравнению с clang-c).

И, отчасти как следствие, недостатки:
  1. Незащищенность клиента от возможных падений внутри ядра.
  2. Отсутствие гарантий backward compatibility по интерфейсу.
  3. Поставка в виде статических библиотек. Клиент вынужден линковаться непоредственно с ядром и, как следствие, собирать clang и llvm для своей конфигурации.
  4. «Многословность». В ряде сценариев исходного кода (по сравнению с clang-c API) получается больше.
  5. Далеко не все документировано.
  6. Высокая степень связности с llvm API. Без llvm использовать clang нельзя. С этим нужно просто смириться.

Насколько существенны недостатки, и перевешивают ли они достоинства — решать надо по ситуации. На мой взгляд, этот вариант использования clang'а надо выбирать везде, ге требуется хорошая производительность или доступ к специфическим возможностям, недоступным посредством clang-c. В частности, при использовании clang'а в качестве on-the-fly-парсера для IDE имеет смысл использовать именно этот вариант API.

2. Getting started, или парсинг исходного текста


А зачем тогда, спрашивается, вообще этот clang нужен? Действительно, распарсить исходный текст с целью извлечения из него интересующей разработчика информации или для превращения его в байт-код — это основная задача clang frontend. И для решения этой задачи clang предоставляет богатые возможности. Наверное даже слишком богатые. Признаться, я слегка опешил, когда в первый раз открыл один из примеров к clang'у — производимые в нем манипуляции показались мне из области черной магии, поскольку не совпадали с интуитивными представлениями о том, как этот самый парсинг должен бы выглядеть. В итоге, все оказалось вполне логичным, хотя установка опций парсинга путем передачи массива строк, описывающего аргументы командной строки, меня до сих пор несколько обескураживает.

2.1 Парсинг с помощью clang-c

Если бы мое знакомство с clang началось бы с примера, построенного на базе этого API, удивлений было бы меньше. Фактически, парсинг файла производится двумя вызовами. Первый создает экземпляр объекта CXIndex, второй — инициирует собственно разбор исходного текста и построение AST. Вот как это выглядит в исходном тексте:

#include <iostream>
#include <clang-c/Index.h>
int main (int argc, char** argv)
{
 CXIndex index = clang_createIndex (
         false, // excludeDeclarationFromPCH
         true   // displayDiagnostics
 );
 CXTranslationUnit unit = clang_parseTranslationUnit (
         index,                           // CIdx
         "main.cpp",                      // source_filename
         argv + 1 ,                        // command_line_args
         argc - 1 ,                        // num_command_line_args
         0,                                // unsave_files
         0,                                // num_unsaved_files
         CXTranslationUnit_None           // options
 );
 if (unit != 0 )
         std::cout << "Translation unit successfully created" << std::endl;
 else
         std::cout << "Translation unit was not created" << std::endl;
 clang_disposeTranslationUnit(unit);
 clang_disposeIndex(index);
}


Первый метод (clang_createIndex) создает контекст, в рамках которого будут создаваться и парситься экземпляры единиц трансляции (CXTranslationUnit). Он принимает два параметра. Первый (excludeDeclarationsFromPCH) управляет видимостью объявлений, прочитанных из precompiled-заголовка в процессе обхода полученного AST. Значение 1 означает, что такие объявления будут исключены из итоговой AST. Второй параметр (displayDiagnostics) управляет выводом диагностики, полученной в процессе трансляции, на консоль.
Второй метод (clang_parseTranslationUnit) выполняет собственно парсинг файла с исходным текстом. Этот метод имеет следующие параметры:
  • CIdx — указатель на контекст, созданный с помощью вызова clang_createIndex.
  • source_filename — путь к файлу, который необходимо распарсить.
  • command_line_args — аргументы командной строки, которые будут преобразованны в опции компилятора.
  • num_command_line_args — количество аргументов в командной строке, переданной в качестве предыдущего параметра.
  • unsaved_files — коллекция файлов, актуальное содержимое которых находится в памяти, а не на диске.
  • num_unsaved_files — количество элементов в коллекции незаписанных файлов.
  • options — дополнительные параметры парсинга.

Как можно видеть, вся настройка парсера выполняется посредством передачи парсеру аргументов командной строки в текстовом виде. Параметр unsaved_files полезен в сценариях использования clang'а из редакторов или IDE. Посредством него можно передать парсеру те файлы, которые были модифицированны пользователем, но еще не сохранены на диск. Это коллекция структур типа CXUnsavedFile, содержащих имя файла, его содержимое и размер содержимого в байтах. Имя и содержимое задается в виде C-строк, а размер — в виде беззнакового целого.
Последний параметр (options) — это набор следующих флагов:
  • CXTranslationUnit_None — тут всё очевидно. Никаких специальных опций парсинга не устанавливается.
  • CXTranslationUnit_DetailedPreprocessingRecord — установка этой опции указывает на то, что парсер должен будет генерировать детальную информацию о том, как и где в исходном тексте применяется препроцессор. Как явствует из документации, опция редкоиспользуемая, приводит к расходу большого количества памяти, и устанавливать её стоит только в тех случаях, когда такая информация действительно требуется.
  • CXTranslationUnit_Incomplete — установка этой опции указывает на то, что обрабатывается не полная (не законченная) единица трансляции. Например, заголовочный файл. В этом случае транслятор не будет пытаться инстанцировать шаблоны, которые должны были бы быть инстанцированы перед завершением трансляции.
  • CXTranslationUnit_PrecompiledPreamble — установка этой опции указывает на то, что парсер должен автоматически создавать precompiled header для всехзаголовочныхфайлов, которые включаются в начале единицы трансляции. Опция полезная в случае, если файл будет часто репарсится (посредством метода clang_reparseTranslationUnit), но со своими особенностями, которые будут описаны в следующем разделе.
  • CXTranslationUnit_CacheCompletionResults — установка этой опции приводит к тому, что после каждого последующего репарсинга часть результатов code completion будет сохранятся.
  • CXTranslationUnit_SkipFunctionBodies — установка этой опции приводит к тому, что в процессе трансляции не будут обрабатываться тела функций и методов. Полезно для быстрого поиска объявлений и определений тех или иных символов.

Флаги могут комбинироваться посредством операции '|'.

Два последних метода (clang_disposeTranslationUnit и clang_disposeIndex) удаляют ранее созданные хэндлы, описывающие единицу трансляции и контекст.
Для успешной сборки этого примера кода достаточно подключить библиотеку libclang.

2.1 Парсинг с помощью clang API

Аналогичный (по функционалу) код с использованием clang API выглядит следующим образом:

#include <vector>
#include <iostream>
#include <clang/Basic/Diagnostic.h>
#include <clang/Frontend/DiagnosticOptions.h>
#include <clang/Frontend/CompilerInstance.h>
#include <clang/Frontend/CompilerInvocation.h>
#include <clang/Frontend/Utils.h>
#include <clang/Frontend/ASTUnit.h>
int main(int argc, char ** argv)
{
 using namespace clang ;
 using namespace llvm ;
 // Initialize compiler options list
 std::vector< const char *> args;
 for (int n = 1; n < argc; ++ n)
         args.push_back(argv[n]);

 args.push_back("main_clang.cpp" );
 const char** opts = &args.front();
 int opts_num = args.size();

 // Create and setup diagnostic consumer
 DiagnosticOptions diagOpts;
 IntrusiveRefCntPtr< DiagnosticsEngine> diags(CompilerInstance::createDiagnostics(
         diagOpts, // Opts
         opts_num, // Argc
         opts,     // Argv
         0,         // Client
         true,     // ShouldOwnClient
         false     // ShouldCloneClient
 ));

 // Create compiler invocation
 IntrusiveRefCntPtr< CompilerInvocation> compInvoke = clang::createInvocationFromCommandLine(
         makeArrayRef(opts, opts + opts_num), // Args
         diags                                // Diags
 );
 if (!compInvoke) {
         std::cout << "Can't create compiler invocation for given args" ;
         return -1;
 }
 // Parse file
 clang::ASTUnit *tu = ASTUnit ::LoadFromCompilerInvocation(
         compInvoke.getPtr(), // CI
         diags,               // Diags
         false,               // OnlyLocalDecls
         true,                // CaptureDiagnostics
         false,               // PrecompilePreamble
         TU_Complete,         // TUKind
         false                // CacheCodeCompletionResults
 );
 if (tu == 0 )
         std::cout << "Translation unit was not created" ;
 else
         std::cout << "Translation unit successfully created" ;
 return 0;
}


Букв в нём гораздо больше, а для сборки требуется ликовка со следующим набором библиотек: clangLex, clangBasic, clangAST, clangSerialization, clangEdit, clangAnalysis, clangFrontend, clangSema, clangDriver, clangParse, LLVMCore, LLVMMC, и LLVMSupport. При сборке под Windows также требуется добавить advapi32 и shell32. Зато на выходе получится исполняемый модуль без лишних внешних зависимостей.
Представленный выше код можно разделить на четыре части:
  1. Формирование коллекции параметров командной строки компилятора. В этом варианте API путь к файлу, который требуется разобрать, также передается в качестве одного из элементов коллекции, по этому в данном случае передавать напрямую argv и argc нельзя.
  2. Создание экземпляра Diagnostic Engine. Объект этого класса отвечает за сбор и хранение всех сообщений об ошибках, предупреждений и прочей диагностики, которые могут быть сформированы парсером в процессе разбора исходного текста.
  3. Создание экземпляра Compiler Invocation.
  4. Собственно парсинг исходного текста.


Формирование коллекции аргументов командной строки

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

Создание Diagnostic Engine

Создание DE необходимо для того, чтобы получать от парсера clang различную диагностическую информацию, которую он генерирует в процессе разбора исходного текста. Такие параметры, как максимальное количество отображаемых ошибок, какие именно ошибки/предупреждения отображать и т. п. DE берет из командной строки, которые передается вторым и третьим параметрами. Последние три параметра описывают «диагностического клиента». Это специальный класс, которому DE будет передавать сообщения парсера (по мере их возникновения) для дальнейшей обработки специфичрым для пользователя clang образом. DE может взять контроль за временем жизни клиента на себя, либо работать с клоном переданного объекта. Это позволяет использовать разные сценарии реализации клиента — в виде статического/автоматического объекта, в виде объекта в куче, как часть класса, в методах которого производится работа с clang API, и т. п.

Создание Compiler Invocation

На этом шаге, фактически, создается контекст, в рамках которого будет производиться парсинг. Анализируются все параметры переданной командной строки, переменные окружения, создается вся внутренняя инфраструктура (в соответствии с этими параметрами), подключается Diagnostic Engine. После этого clang полностью готов к парсингу того файла, который был передан в качестве последнего параметра.

Парсинг исходного текста

Он осуществляется посредством вызова одного из статических методов класса clang:ASTUnit. Таких методов несколько, они заточены под разные сценарии. В примере приведен один из возможных вариантов. В данном случае парсеру передается экземпляр compiler invocation (парсер его потом сам удалит!), экземпляр Diagnostic Engine (его парсер автоматически удалять не будет), и несколько параметров, контролирующих поведение парсера:
  • OnlyLocalDecls — в итоговую AST будут включены только декларации из той единицы трансляции, которая парсилась. Декларации из PCH и подключенных заголовочных файлов будут исключены.
  • CaptureDiagnostic — управляет способом сбора диагностики. Если в качестве этого параметра передано false, то вся собранная диагностика будет передаваться diagnostic client'у, указанному при создании Diagnostic Engine. В противном случае диагностика будет сохранятся во внутренних структурах ASTUnit'а.
  • PrecompilePreamble — как уже говорилось выше, при включенной этой опции парсер будет автоматически создавать PCH для всех включенных в исходный текст заголовков. Да, действительно. Полезно при повтороном парсинге. Но, как оказалось, тут есть некоторые не совсем приятные моменты. Во-первых, фактически PCH создается при первом вызове метода ASTUnit::Reparse для полученного экземпляра ASTUnit'а. Во-вторых, в случае, если производится разбор заголовочного файла с #ifdef-guard'ами, то, увы, ничего не будет создано.
  • TUKind — тип единицы трансляции. Тут возможны следующие варианты:
    • TU_Complete — парсится полностью завершенная единица трансляции. В этом случае в итоговую AST будут помещены также все инстансы шаблонов, используемых в исходном тексте.
    • TU_Prefix — парсится «префикс для единицы трансляции», в этом случае исходный текст не считается завершенным.
    • TU_Module — парсится некий «модуль». Что это такое — документация умалчивает.
  • CacheCodeCompletionResults — в процессе разбора будут закешированы результаты code completion. Действительно помогает при последующих запросах code completion.


3. Маленькие хитрости в наборе опций


В своих первых экспериментах (это был парсинг заголовочных файлов на предмет извлечения деклараций) я долго не понимал причину, по которой парсинг завершался с большим количеством ошибок. В итоге все оказалось довольно просто. Итак, опции, которые могут оказаться полезными:
  • -x language — указывает конкретный тип файла, который парсится. Совместима с аналогичной опцией компилятора gcc.
  • -std=standard — указывает стандарт, которому соответствует исходный текст. По значениям совместима с аналогичной опцией компилятора gcc.
  • -ferror-limit=N — устанавливает в N максимальное количество ошибок, после которого парсинг будет завершен. Если требуется распарсить файл полностью игнорируя любые ошибки, то N должно быть равно 0.
  • -include <prefix-file> — указывает файл (обычно заголовочный) который должен быть распарсен до начала парсинга основного файла. Вообще, эта опция изначально предназначена для подключения PCH-заголовка, но при разборе файлов может быть полезна для, например, определения различных макросов.


На этом первое знакомство с clang API можно считать законченным. Подробнее про clang-c API можно почитать на официальном сайте clang: clang.llvm.org/doxygen/group__CINDEX.html
Там же можно ознакомится со всей иерархией классов clang API. К некоторому сожалению, документация генерируется автоматически из апстрима clang, по этому сигнатуры функций, их набор и т. п., описанные на сайте с документацией, могут отличаться от тех, которые представлены в том или ином релизе.

В следующей заметке я расскажу о том, как из созданной с помощью clang AST можно получить дерево деклараций.
Tags:
Hubs:
+52
Comments40

Articles