Пример разбора C++ кода с помощью libclang на Python

0
386

На одном личном проекте на C++ мне потребовалось получать информацию о типах объектов во время выполнения приложения. В C++ есть встроенный механизм Run-Time Type Information (RTTI), и конечно же первая мысль была использовать именно его, но я решил написать свою реализацию, потому что не хотел тянуть весь встроенный механизм, ведь мне нужна была лишь малая часть его функционала. А еще хотелось попробовать на практике новые возможности C++ 17, с которыми я был не особо знаком.

В этой статье представлю пример работы с парсером libclang на языке Python.

Детали релизации своего RTTI я опущу. Важным для нас в данном случае являются только следующие моменты:

  • Каждый класс или структура, которые могут предоставить информацию о своём типе должны наследовать интерфейс IRttiTypeIdProvider;
  • В каждом таком классе (если он не является абстрактным) необходимо добавить макрос RTTI_HAS_TYPE_ID, который добавляет статическое поле типа указатель на объект RttiTypeId. Таким образом, чтобы получить идентификатор типа можно писать MyClass::__typeId или вызывать метод getTypeId у конкретного экземпляра класса во время выполнения приложения.

Пример:

#pragma once

#include <string>
#include "RTTI.h"

struct BaseNode : public IRttiTypeIdProvider {
    virtual ~BaseNode() = default;

    bool bypass = false;
};

struct SourceNode : public BaseNode {
    RTTI_HAS_TYPE_ID

    std::string inputFilePath;
};

struct DestinationNode : public BaseNode {
    RTTI_HAS_TYPE_ID

    bool includeDebugInfo = false;
    std::string outputFilePath;
};

struct MultiplierNode : public BaseNode {
    RTTI_HAS_TYPE_ID

    double multiplier;
};

struct InverterNode : public BaseNode {
    RTTI_HAS_TYPE_ID
};

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

RTTI_PROVIDER_BEGIN_TYPE(SourceNode)
(
    RTTI_DEFINE_FIELD(SourceNode, bypass)
    RTTI_DEFINE_FIELD(SourceNode, inputFilePath)
)
RTTI_PROVIDER_END_TYPE()

RTTI_PROVIDER_BEGIN_TYPE(DestinationNode)
(
    RTTI_DEFINE_FIELD(DestinationNode, bypass)
    RTTI_DEFINE_FIELD(DestinationNode, includeDebugInfo)
    RTTI_DEFINE_FIELD(DestinationNode, outputFilePath)
)
RTTI_PROVIDER_END_TYPE()

RTTI_PROVIDER_BEGIN_TYPE(MultiplierNode)
(
    RTTI_DEFINE_FIELD(MultiplierNode, bypass)
    RTTI_DEFINE_FIELD(MultiplierNode, multiplier)
)
RTTI_PROVIDER_END_TYPE()

RTTI_PROVIDER_BEGIN_TYPE(InverterNode)
(
    RTTI_DEFINE_FIELD(InverterNode, bypass)
)

И это всего для 4-х классов. Какие можно выявить проблемы?

 

  1. При копипасте блоков кода вручную можно упустить из виду имя класса при определении поля (скопистили блок с SourceNode для DestinationNode, но в одном из полей забыли поменять SourceNode на DestinationNode). Компилятор всё пропустит, приложение может даже не упасть, однако информация о поле будет некорректной. А если делать запись или чтение данных основываясь на информации из такого поля, то всё взорвётся (так говорят, но сам не хочу проверять).
  2. Если добавить поле в базовый класс, то нужно обновлять ВСЕ записи.
  3. Если поменяли имя или порядок полей в классе, то нужно не забыть обновить имя и порядок в этой портянке кода.

 

Но главное — это всё нужно писать вручную. Когда дело касается подобного монотонного кода я становлюсь очень ленивым и ищу способ сгенерировать его автоматически, даже если на это уйдёт больше времени и сил чем на ручное написание.

В этом мне помогает Python, на нём я пишу скрипты для решения подобных проблем. Но мы имеем дело не просто с шаблонным текстом, а текстом, построенным на основе исходного кода на C++. Нужно средство для получения информации о C++ коде, и в этом нам поможет libclang.

libclang — это высокоуровневый C-интерфейс для Clang. Предоставляет API к средствам для синтаксического анализа исходного кода в абстрактном синтаксическом дереве (AST), загрузки уже проанализированных AST, обхода AST, сопоставления местоположений физического источника с элементами внутри AST и других средств из набора Clang.

Как следует из описания, libclang предоставляет C-интерфейс, а для работы с ним через Python нужна связывающая библиотека (binding). На момент написания этого поста официальной такой библиотеки для Python нет, но из неофициальных есть вот эта https://github.com/ethanhs/clang.

Устанавливаем её через менеджер пакетов:

pip install clang

Библиотека снабжена комментариями в исходом коде. Но для понимания устройства libclang нужно читать документацию libclang. Примеров использования библиотеки не много, и там нет комментариев поясняющих почему всё работает вот так, а не иначе. У тех, кто уже имел опыт с libclang вопросов будет меньше, а лично я такого опыта не имел, поэтому пришлось знатно покопаться в коде и потыкаться в отладчике.

Начнём с простого примера:

import clang.cindex

index = clang.cindex.Index.create()
translation_unit = index.parse('my_source.cpp', args=['-std=c++17'])

for i in translation_unit.get_tokens(extent=translation_unit.cursor.extent):
    print (i.kind)

Здесь создаётся объект типа Index, который умеет парсить файл с кодом на C++. Метод parse возвращает объект типа TranslationUnit, это единица трансляции кода. TranslationUnit является узлом (node) AST, а каждый узел AST хранит информацию о своём положении в исходном коде (extent). Циклом проходим по всем лексемам (token) в TranslationUnit и выводим тип этих лексем (свойство kind).

Для примера возьмём следующий C++ код:

class X {};

class Y {};

class Z : public X {};

Результат выполнения скрипта

TokenKind.KEYWORD
TokenKind.IDENTIFIER
TokenKind.PUNCTUATION
TokenKind.PUNCTUATION
TokenKind.PUNCTUATION
TokenKind.KEYWORD
TokenKind.IDENTIFIER
TokenKind.PUNCTUATION
TokenKind.PUNCTUATION
TokenKind.PUNCTUATION
TokenKind.KEYWORD
TokenKind.IDENTIFIER
TokenKind.PUNCTUATION
TokenKind.KEYWORD
TokenKind.IDENTIFIER
TokenKind.PUNCTUATION
TokenKind.PUNCTUATION
TokenKind.PUNCTUATION

Теперь займёмся обработкой AST. Прежде чем писать код на Python давайте посмотрим что вообще нам стоит ожидать от парсера clang. Запустим clang в режиме дампа AST:

clang++ -cc1 -ast-dump my_source.cpp

Результат выполнения команды

TranslationUnitDecl 0xaaaa9b9fa8 <<invalid sloc>> <invalid sloc>
|-TypedefDecl 0xaaaa9ba880 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128'
| `-BuiltinType 0xaaaa9ba540 '__int128'
|-TypedefDecl 0xaaaa9ba8e8 <<invalid sloc>> <invalid sloc> implicit __uint128_t 'unsigned __int128'
| `-BuiltinType 0xaaaa9ba560 'unsigned __int128'
|-TypedefDecl 0xaaaa9bac48 <<invalid sloc>> <invalid sloc> implicit __NSConstantString '__NSConstantString_tag'
| `-RecordType 0xaaaa9ba9d0 '__NSConstantString_tag'
|   `-CXXRecord 0xaaaa9ba938 '__NSConstantString_tag'
|-TypedefDecl 0xaaaa9e6570 <<invalid sloc>> <invalid sloc> implicit __builtin_ms_va_list 'char *'
| `-PointerType 0xaaaa9e6530 'char *'
|   `-BuiltinType 0xaaaa9ba040 'char'
|-TypedefDecl 0xaaaa9e65d8 <<invalid sloc>> <invalid sloc> implicit __builtin_va_list 'char *'
| `-PointerType 0xaaaa9e6530 'char *'
|   `-BuiltinType 0xaaaa9ba040 'char'
|-CXXRecordDecl 0xaaaa9e6628 <my_source.cpp:1:1, col:10> col:7 referenced class X definition
| |-DefinitionData pass_in_registers empty aggregate standard_layout trivially_copyable pod trivial literal has_constexpr_non_copy_move_ctor can_const_default_init
| | |-DefaultConstructor exists trivial constexpr needs_implicit defaulted_is_constexpr
| | |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
| | |-MoveConstructor exists simple trivial needs_implicit
| | |-CopyAssignment trivial has_const_param needs_implicit implicit_has_const_param
| | |-MoveAssignment exists simple trivial needs_implicit
| | `-Destructor simple irrelevant trivial needs_implicit
| `-CXXRecordDecl 0xaaaa9e6748 <col:1, col:7> col:7 implicit class X
|-CXXRecordDecl 0xaaaa9e6800 <line:3:1, col:10> col:7 class Y definition
| |-DefinitionData pass_in_registers empty aggregate standard_layout trivially_copyable pod trivial literal has_constexpr_non_copy_move_ctor can_const_default_init
| | |-DefaultConstructor exists trivial constexpr needs_implicit defaulted_is_constexpr
| | |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
| | |-MoveConstructor exists simple trivial needs_implicit
| | |-CopyAssignment trivial has_const_param needs_implicit implicit_has_const_param
| | |-MoveAssignment exists simple trivial needs_implicit
| | `-Destructor simple irrelevant trivial needs_implicit
| `-CXXRecordDecl 0xaaaa9e6928 <col:1, col:7> col:7 implicit class Y
`-CXXRecordDecl 0xaaaa9e69e0 <line:5:1, col:21> col:7 class Z definition
  |-DefinitionData pass_in_registers empty standard_layout trivially_copyable trivial literal has_constexpr_non_copy_move_ctor can_const_default_init
  | |-DefaultConstructor exists trivial constexpr needs_implicit defaulted_is_constexpr
  | |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
  | |-MoveConstructor exists simple trivial needs_implicit
  | |-CopyAssignment trivial has_const_param needs_implicit implicit_has_const_param
  | |-MoveAssignment exists simple trivial needs_implicit
  | `-Destructor simple irrelevant trivial needs_implicit
  |-public 'X'
  `-CXXRecordDecl 0xaaaa9e6b48 <col:1, col:7> col:7 implicit class Z

Здесь CXXRecordDecl это тип узла, представляющего декларацию класса. Можно заметить, что таких узлов здесь больше чем классов в исходном файле. Это потому, что таким же типом представлены референсные узлы, т.е. узлы, которые являются ссылками на другие узлы. В нашем случае указание базового класса это и есть референс. При разборке этого дерева определить референсный узел можно при помощи специального флага.

Теперь напишем скрипт, выводящий список классов в исходном файле:

import clang.cindex
import typing

index = clang.cindex.Index.create()
translation_unit = index.parse('my_source.cpp', args=['-std=c++17'])

def filter_node_list_by_node_kind(
    nodes: typing.Iterable[clang.cindex.Cursor],
    kinds: list
) -> typing.Iterable[clang.cindex.Cursor]:
    result = []

    for i in nodes:
        if i.kind in kinds:
            result.append(i)

    return result

all_classes = filter_node_list_by_node_kind(translation_unit.cursor.get_children(), [clang.cindex.CursorKind.CLASS_DECL, clang.cindex.CursorKind.STRUCT_DECL])

for i in all_classes:
    print (i.spelling)

Имя класса хранится в свойстве spelling. Для разного типа узлов значение spelling может содержать какие-нибудь модификаторы типа, но для декларации класса или структуры оно содержит имя без модификаторов.

Результат выполнения:

X
Y
Z

При парсинге AST clang также парсит файлы, подключенные через #include. Попробуйте добавить #include <string> в исходник, и в дампе получите 84 тысячи строк, что явно многовато для решения нашей задачки.

Для просмотра дампа AST таких файлов через командную строку лучше удалить все #include. Вернёте их обратно когда изучите AST и получите представление об иерархии и типах в интересующем файле.

В скрипте для того чтобы фильтровать только AST принадлежащие исходному файлу, а не подключенные через #include, можно добавить такую функцию фильтрации по файлу:

def filter_node_list_by_file(
    nodes: typing.Iterable[clang.cindex.Cursor],
    file_name: str
) -> typing.Iterable[clang.cindex.Cursor]:
    result = []

    for i in nodes:
        if i.location.file.name == file_name:
            result.append(i)

    return result
...

filtered_ast = filter_by_file(translation_unit.cursor, translation_unit.spelling)

Теперь можно заняться извлечением полей. Далее приведу полный код, который формирует список полей с учётом наследования и генерирует текст по шаблону. Ничего специфического для clang здесь нет, поэтому без комментариев.

Полный код скрипта
import clang.cindex
import typing

index = clang.cindex.Index.create()
translation_unit = index.parse('Input.h', args=['-std=c++17'])

def filter_node_list_by_file(
    nodes: typing.Iterable[clang.cindex.Cursor],
    file_name: str
) -> typing.Iterable[clang.cindex.Cursor]:
    result = []

    for i in nodes:
        if i.location.file.name == file_name:
            result.append(i)

    return result

def filter_node_list_by_node_kind(
    nodes: typing.Iterable[clang.cindex.Cursor],
    kinds: list
) -> typing.Iterable[clang.cindex.Cursor]:
    result = []

    for i in nodes:
        if i.kind in kinds:
            result.append(i)

    return result

def is_exposed_field(node):
    return node.access_specifier == clang.cindex.AccessSpecifier.PUBLIC

def find_all_exposed_fields(
    cursor: clang.cindex.Cursor
):
    result = []

    field_declarations = filter_node_list_by_node_kind(cursor.get_children(), [clang.cindex.CursorKind.FIELD_DECL])

    for i in field_declarations:
        if not is_exposed_field(i):
            continue

        result.append(i.displayname)

    return result

source_nodes = filter_node_list_by_file(translation_unit.cursor.get_children(), translation_unit.spelling)
all_classes = filter_node_list_by_node_kind(source_nodes, [clang.cindex.CursorKind.CLASS_DECL, clang.cindex.CursorKind.STRUCT_DECL])

class_inheritance_map = {}
class_field_map = {}

for i in all_classes:
    bases = []

    for node in i.get_children():
        if node.kind == clang.cindex.CursorKind.CXX_BASE_SPECIFIER:
            referenceNode = node.referenced

            bases.append(node.referenced)

    class_inheritance_map[i.spelling] = bases

for i in all_classes:
    fields = find_all_exposed_fields(i)

    class_field_map[i.spelling] = fields

def populate_field_list_recursively(class_name: str):
    field_list = class_field_map.get(class_name)

    if field_list is None:
        return []

    baseClasses = class_inheritance_map[class_name]

    for i in baseClasses:
        field_list = populate_field_list_recursively(i.spelling) + field_list

    return field_list

rtti_map = {}

for class_name, class_list in class_inheritance_map.items():
    rtti_map[class_name] = populate_field_list_recursively(class_name)

for class_name, field_list in rtti_map.items():
    wrapper_template = """\
RTTI_PROVIDER_BEGIN_TYPE(%s)
(
%s
)
RTTI_PROVIDER_END_TYPE()
"""

    rendered_fields = []

    for f in field_list:
        rendered_fields.append("    RTTI_DEFINE_FIELD(%s, %s)" % (class_name, f))

    print (wrapper_template % (class_name, ",\n".join(rendered_fields)))

Этот скрипт не учитывает имеет ли класс RTTI. Поэтому после получения результата придётся вручную удалить блоки, описывающие классы без RTTI. Но это мелочь.

Надеюсь кому-нибудь это будет полезно и сэкономит время. Весь код выложен на GitHub.

источник