Интерактивная интерпретация C++ при помощи Cling

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

Создание экземпляра шаблона по запросу

Cling реализует механизм  LookupHelper, который берет код C++ и проверяет, существует ли уже объявление с именем, которое так квалифицировано. Например:

[cling] #include "cling/Interpreter/Interpreter.h"
[cling] #include "cling/Interpreter/LookupHelper.h"
[cling] #include "clang/AST/Decl.h"
[cling] struct S{};
[cling] cling::LookupHelper& LH = gCling->getLookupHelper()
(cling::LookupHelper &) @0x7fcba3c0bfc0
[cling] auto D = LH.findScope("std::vector<S>",
                 cling::LookupHelper::DiagSetting::NoDiagnostics)
(const clang::Decl *) 0x1216bdcd8
[cling] D->getDeclKindName()
(const char *) "ClassTemplateSpecialization"

В данном конкретном случае findScope создает экземпляр шаблона и возвращает его представление в виде абстрактного синтаксического дерева для clang. Инстанцирование шаблона по требованию решает распространенную библиотечную проблему, которая называется «комбинаторный взрыв шаблона» (template combinatorial explosion). Инстанцирование шаблона по требованию и преобразование текстовых квалифицированных имен C++ в метаинформацию об объекте показало себя как очень мощный механизм, способствующий сериализации данных и языковой интероперабельности.

Языковая интероперабельность по требованию

В качестве примера рассмотрим генератор cppyy, предоставляющий во время выполнения автоматические привязки кода Python к коду на C++, при помощи Cling. Python сам по себе – это динамический язык, выполняемый интерпретатором. Поэтому взаимодействие с кодом C++ получается более естественным, если в качестве промежуточного звена выступает Cling. Среди примеров – инстанцирование шаблонов во время выполнения, обратные вызовы функций (указателей), межъязыковое наследование, автоматическое понижающее приведение (downcasting) и сопоставление исключений (exception mapping). Многие продвинутые возможности C++, такие как размещающий оператор new (placement new), множественное виртуальное наследование, шаблоны с переменным количеством параметров (variadic templates), т.д., естественным образом разрешаются при помощи LookupHelper.

cppyy достигает высокой производительности путем «вселенивого» подхода к конструированию привязок времени выполнения и специализации типичных случаев путем отражения времени выполнения. В целом он характеризуется гораздо меньшими издержками при вызовах, чем, например, pybind11, и перебирать std::vector через cppyy гораздо быстрее, чем перебирать массив numpy того же типа. Если сделать еще шаг вперед, то можно обратить внимание на реализацию cppyy для PyPy, полностью совместимого интерпретатора Python, который может похвастаться трассирующим JIT-генератором. Во многих случаях он может предоставить нативный доступ к коду C++ при JIT в стиле PyPy, обеспечивая, в частности, разрешение перегрузок и предоставляя подсказки JIT, благодаря которым возможна агрессивная оптимизация.

Благодаря отражению времени выполнения, действующему в Cling, cppyy значительно упрощает поддержку большого программного стека; кроме собственной привязки к интерпретатору Python, имеющейся в cppyy, этот инструмент не содержит никакого скомпилированного кода, который зависел бы от Python. То есть, модули расширений, основанные на cppyy, не требуют никакой перекомпиляции при смене версий Python (ни даже, если, скажем, мы будем переключаться между интерпретаторами CPython и PyPy).

В следующем примере показана плотная интеграция между C++ и Python; здесь видна активная двунаправленная коммуникация между инстанцированием шаблонов и переопределениями, связанными с перекрестным наследованием; еще здесь видны поведения, характерные для времени выполнения (все происходит во время выполнения, скомпилированного кода здесь нет).

import cppyy

cppyy.cppdef(r"""\
template<typename T> class Producer {
private:
  T m_value;

protected:
  virtual T produce_imp() = 0;

public:
  Producer(const T& value) : m_value(value) {}
  virtual ~Producer() {}

  T produce_total() { return m_value + produce_imp(); }
};

class Consumer {
public:
  template<typename T>
  void consume(Producer<T>& p) {
    std::cout << "received: \"" << p.produce_total() << "\"\n";
  }
};""")

def factory(base_v, *derived_v):
  class _F(cppyy.gbl.Producer[type(base_v)]):
    def __init__(self, base_v, *derived_v):
      super().__init__(base_v)
      self._values = derived_v

    def produce_imp(self):
      return type(base_v)(sum(self._values))

    return _F(base_v, *derived_v)

consumer = cppyy.gbl.Consumer()
for producer in [factory(*x) for x in \
                  (("hello ", 42), (3., 0.14, 0.0015))]:
  consumer.consume(producer)

Вывод:

python3 cppyy_demo.py
received: "hello 42"
received: "3.1415"

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

  • Python создает классы во время выполнения, что может и Cling, пусть даже они и объявляются в модуле (здесь релевантные классы создаются в фабричном методе);

  • Шаблонные классы C++ можно инстанцировать на лету прямо из Python, взяв тип аргумента (т.e., воспользовавшись интроспекцией во время выполнения в Python), чтобы создать базовый класс C++ для класса Python.

  • Межъязыковое произведение происходит во время выполнения и не требует от класса С++ никакой поддержки сверх виртуального деструктора и виртуальных методов;

  • «Защищенные» методы C++ в Python могут быть переопределены, пусть даже в Python такой концепции нет, и вы, в сущности, не можете вызывать в Python защищенные методы из связанных объектов C++;

  • Все это работает прямо «из коробки».

cppyy используется в нескольких больших базах кода по физике, химии, математике и биологии. Его легко установить через pip из PyPI (https://pypi.org/project/cppyy/), а также при помощи conda.

Другой пример – интеграционный язык Symmetry (SIL). Это предметно-ориентированный язык функционального толка на основе языка D. Он был разработан для внутрикорпоративного применения в компании Symmetry Investments, и там и используется. Одна из основных целей SIL – обеспечить легкую интероперабельность со всевозможными языками и системами. Это достигается при помощи разнообразных плагинов. Для вызова кода C++ язык SIL использует плагин под названием sil-cling, работающим как переходник между SIL и Cling. Однако, sil-cling взаимодействует с Cling не напрямую, а через cppyy-бекенд. Это обертка cppyy на C/C++, охватывающая Cling, который предоставляет стабильный API для рефлексии C/C++.

Существует два коренных типа, которые предоставляются из sil-cling в SIL. Один из них - CPPNamespace, предоставляющий пространство имен C++ и обеспечивающий свободный вызов функций, доступ к переменным пространства имен и создание экземпляров объектов из тех классов, что определены в этом пространстве имен. Другой - ClingObj, это посредник для объекта C++, обеспечивающий конструирование, вызов метода и манипуляции с членами данных объекта. Притом, что cppyy представляет классы, структуры и пространства имен C++ как «области видимости», и информация о рефлексии любых этих сущностей C++ получается через ассоциированные с ними объекты «области видимости», оба оберточных типа, предоставляемых для SIL, хранят ссылки на ассоциированные с ними объекты области видимости. Такой объект области видимости запрашивается всякий раз, когда оберточные типы используются для вызова кода C++.

Все вызовы выполняются из SIL через два оберточных типа, у каждого из которых по 3 аргумента: используемый объект-обертка, имя функции C++, которую нужно вызвать и (если нужно) последовательность аргументов для этой функции. Как только будут выполнены разрешение перегрузок и преобразование аргументов, sil-cling вызовет подходящую функцию cppyy, которая обернет вызов и диспетчеризует его в Cling для динамической компиляции. На момент написания статьи sil-cling может применяться для вызова таких библиотек C++ как Boost.Asiodlib или Xapian.

В следующем примере создается клиент-серверное приложение на основе Boost Asio, написанное на SIL с использованием плагина sil-cling. Server.sil содержит код SIL для сервера. Он начинает работу с включения нужных заголовочных файлов, это делается при помощи cppCompile. На следующем шаге создаются оберточные объекты для тех пространств имен, что нам нужны, и это делается путем вызова cppNamespace с именами тех пространств, к которым нам нужно получить доступ. Эти обертки CPPNamespace применяются для создания экземпляров классов, определяемых внутри тех пространств имен C++, которые в этих обертках заключены. При помощи таких оберток создаются конечная точка, принимающий сокет (который слушает входящие соединения) и активный сокет (обрабатывающий коммуникацию с клиентом). Затем сервер дожидается соединения и, как только клиент подключится, сервер считывает это сообщение и отправляет отклик.

// Server.sil
import * from silcling
import format from format

cppCompile ("#include <boost/asio.hpp>")
cppCompile ("#include \"helper.hpp\"")

// Обертки CPPNamespace 
asio = cppNamespace("boost::asio")
tcp = cppNamespace("boost::asio::ip::tcp")
helpers = cppNamespace("helpme")

// Использование оберток пространств имен для создания экземпляров классов - создается ClingObj(s)
ioService = asio.obj("io_service")
endpoint = tcp.obj("endpoint", tcp.v4(), 9999)

// Принимающий сокет – входящие соединения
acceptorSocket = tcp.obj("acceptor", ioService, endpoint)
// Активный сокет – коммуникация с клиентом
activeSocket = tcp.obj("socket", ioService)

// Ожидание соединенияи использование activeSocket для соединения с клиентом
helpers.accept(acceptorSocket, activeSocket)

// Ожидание сообщения
message = helpers.getData(activeSocket);
print(format("[Server]: Received \"%s\" from client.", message.getString()))

// Отправить отклик
reply = "Hello \'" ~ message.getString() ~ "\'!"
helpers.sendData(activeSocket, reply)
print(format("[Server]: Sent \"%s\" to client.", reply))

Client.sil содержит код SIL для клиента. Поскольку это сервер, он включает релевантные заголовки, создает обертки для требуемых пространств имен и далее для создания конечной точки и сокета. Затем клиент подключается к серверу, отправляет сообщение и ждет, пока сервер ответит. Как только ответ придет, клиент выведет его на экран.

// Client.sil
import * from silcling
import format from format

cppCompile ("#include <boost/asio.hpp>")
cppCompile ("#include \"helper.hpp\"")

asio = cppNamespace("boost::asio")
tcp = cppNamespace("boost::asio::ip::tcp")
helpers = cppNamespace("helpme")

// Оператор разрешения области видимости <-> address::static_method() или address::static_member
address = classScope("boost::asio::ip::address")

ioService = asio.obj("io_service")
endpoint = tcp.obj("endpoint", address.from_string("127.0.0.1"), 9999)

// Создание сокета
client_socket = tcp.obj("socket", ioService)
// Соединение
client_socket.connect(endpoint)

message = "demo"
helpers.sendData(client_socket, message)
print(format("[Client]: Sent \"%s\" to server.", message))

message = helpers.getData(client_socket);

Вывод:

[Client]: Sent "demo" to server.
[Server]: Received "demo" from client.
[Server]: Sent "Hello demo" to client.
[Client]: Received "Hello demo" from server.

Интерпретатор/компилятор как услуга

Cling, точно как и Clang, устроен так, что им можно пользоваться как библиотекой. В следующем примере будет показано, как инкорпорировать libCling в программу на C++. Cling можно использовать по требованию, как услугу – чтобы компилировать, изменять или описывать код на C++. В программе, приведенной в качестве примера, показано несколько способов, которыми могут взаимодействовать скомпилированный и интерпретируемый C++:

  • callCompiledFn – cling-demo.cpp определяет глобальную переменную aGlobal; статическую переменную для чисел с плавающей точкой, anotherGlobal; а также соответствующие методы доступа. Аргумент interp – это созданный ранее экземпляр интерпретатора Cling. Точно как и в стандартном C++, достаточно заранее объявлять скомпилированные сущности интерпретатору, чтобы потом их можно было использовать. Затем информация о выполнении от различных вызовов, которые нужно обработать, хранится в обобщенном объекте Cling Value, который используется для обмена информацией между скомпилированным и интерпретируемым кодом.

  • callInterpretedFn – дополнительно к callCompiledFn, скомпилированный код может вызвать интерпретируемую функцию, запросив Cling сформировать указатель функции на основе заданного искаженного имени. Далее в вызове используется стандартный синтаксис C++.

  • modifyCompiledValue – Cling полностью понимает C++, что позволяет нам поддерживать сложные низкоуровневые операции над памятью, выделяемой в стеке. В качестве примера запросим у компилятора адрес локальной переменной loc в памяти, а затем прикажем интерпретатору прямо во время выполнения возвести ее значение в квадрат.

// cling-demo.cpp
// g++ ... cling-demo.cpp; ./cling-demo
#include <cling/Interpreter/Interpreter.h>
#include <cling/Interpreter/Value.h>
#include <cling/Utils/Casting.h>
#include <iostream>
#include <string>
#include <sstream>

/// Определения объявлений, также внедряемых в cling.
/// ПРИМЕЧАНИЕ: это также можно оставить и в заголовке #included, и в cling, но
/// ради простоты мы просто переобъявим их здесь.
int aGlobal = 42;
static float anotherGlobal = 3.141;
float getAnotherGlobal() { return anotherGlobal; }
void setAnotherGlobal(float val) { anotherGlobal = val; }

///\brief Call compiled functions from the interpreter.
void callCompiledFn(cling::Interpreter& interp) {
  // We could use a header, too...
  interp.declare("int aGlobal;\n"
                 "float getAnotherGlobal();\n"
                 "void setAnotherGlobal(float val);\n");

  cling::Value res; // Будет содержать результат вычисления выражения.
  interp.process("aGlobal;", &res);
  std::cout << "aGlobal is " << res.getAs<long long>() << '\n';
  interp.process("getAnotherGlobal();", &res);
  std::cout << "getAnotherGlobal() returned " << res.getAs<float>() << '\n';

  setAnotherGlobal(1.); // мы изменяем скомпилированное значение,
  interp.process("getAnotherGlobal();", &res); // видит ли это интерпретатор?
  std::cout << "getAnotherGlobal() returned " << res.getAs<float>() << '\n';

  // Изменяем значение при помощи интерпретатора, теперь двоичный код видит новое значение.
  interp.process("setAnotherGlobal(7.777); getAnotherGlobal();");
  std::cout << "getAnotherGlobal() returned " << getAnotherGlobal() << '\n';
}

/// Вызываем интерпретируемую функцию по ее символьному адресу.
void callInterpretedFn(cling::Interpreter& interp) {
  // Объявляем функцию интерпретатору. Делаем ее внешней для "C", чтобы избавиться от
  // искажения имен.
  interp.declare("extern \"C\" int plutification(int siss, int sat) "
                 "{ return siss * sat; }");
  void* addr = interp.getAddressOfGlobal("plutification");
  using func_t = int(int, int);
  func_t* pFunc = cling::utils::VoidToFunctionPtr<func_t*>(addr);
  std::cout << "7 * 8 = " << pFunc(7, 8) << '\n';
}

/// Передаем в cling указатель как строку.
void modifyCompiledValue(cling::Interpreter& interp) {
  int loc = 17; // Значение, которое будет изменено

  // Обновляем значение loc, передавая его интерпретатору.
  std::ostringstream sstr;
  // в Windows, чтобы поставить перед шестнадцатеричным значением указателя префикс '0x',
  // нужно написать: std::hex << std::showbase << (size_t)pointer
  sstr << "int& ref = *(int*)" << std::hex << std::showbase << (size_t)&loc << ';';
  sstr << "ref = ref * ref;";
  interp.process(sstr.str());
  std::cout << "The square of 17 is " << loc << '\n';
}

int main(int argc, const char* const* argv) {
  // Создаем интерпретатор. LLVMDIR предоставляется как -D во время компиляции.
  cling::Interpreter interp(argc, argv, LLVMDIR);

  callCompiledFn(interp);
  callInterpretedFn(interp);
  modifyCompiledValue(interp);

  return 0;
}

Вывод:

./cling-demo
aGlobal is 42
getAnotherGlobal() returned 3.141
getAnotherGlobal() returned 1
getAnotherGlobal() returned 7.777
7 * 8 = 56
The square of 17 is 289

Пересекая границу между компилируемым и интерпретируемым кодом, мы полагаемся на предусмотренную в Clang реализацию двоичного интерфейса (ABI) хост-приложения. С годами эта реализация приобрела большую надежность как в Unix, так и в Windows, хотя, Cling активно используется для взаимодействия с базами кода, скомпилированными при помощи GCC и чувствителен к несовместимостям ABI между GCC и Cling в контексте спецификации Itanium ABI.

Расширения

Cling, точно как и Clang, поддается расширению плагинами. В следующем примере показан вариант встраиваемого использования расширения Cling для автоматического дифференцирования, этот вариант называется Clad. Clad преобразует абстрактное синтаксическое дерево Clang так, чтобы получить производные и градиенты математических функций. При создании экземпляра Cling мы указываем -fplugin и путь к самому плагину. Затем определяем целевую функцию pow2 и запрашиваем ее производную относительно ее первого аргумента.

#include <cling/Interpreter/Interpreter.h>
#include <cling/Interpreter/Value.h>

// Производные как услуга.

void gimme_pow2dx(cling::Interpreter &interp) {
  // Definitions of declarations injected also into cling.
  interp.declare("double pow2(double x) { return x*x; }");
  interp.declare("#include <clad/Differentiator/Differentiator.h>");
  interp.declare("auto dfdx = clad::differentiate(pow2, 0);");

  cling::Value res; // Здесь будет содержаться результат вычисления 
  interp.process("dfdx.getFunctionPtr();", &res);

  using func_t = double(double);
  func_t* pFunc = res.getAs<func_t*>();
  printf("dfdx at 1 = %f\n", pFunc(1));
}

int main(int argc, const char* const* argv) {
 std::vector<const char*> argvExt(argv, argv+argc);
  argvExt.push_back("-fplugin=etc/cling/plugins/lib/clad.dylib");
  // Создает cling. LLVMDIR предоставляется как -D во время компиляции.
  cling::Interpreter interp(argvExt.size(), &argvExt[0], LLVMDIR);
  gimme_pow2dx(interp);
  return 0;
}

Вывод:

./clad-demo
dfdx at 1 = 2.000000

Заключение

Мы продемонстрировали возможности Cling, позволяющие инстанцировать экземпляры шаблонов по требованию, инкорпорировать интерпретатор в стороннем коде, а также способствовать расширению интерпретатора. Ленивое инстанцирование шаблонов во встраиваемом провайдере может сильно пригодиться в случае, когда нужно обеспечить интероперабельность с C++. Расширяя такой сервис предметно-ориентированными возможностями, можно найти такой нужный выход для решения многих научных задач и более широких вариантов применения.

Благодарности

Автор выражает благодарности Сильвейну Корлею (Sylvain Corlay), Симеону Эригу (Simeon Ehrig), Дэвиду Ланжу (David Lange), Крису Латтнеру (Chris Lattner), Хавьеру Лопесу Гомесу (Javier Lopez Gomez), Виму Лаврийсену (Wim Lavrijsen), Акселю Науманну (Axel Naumann), Александру Пеневу (Alexander Penev), Хавьеру Валльсу Пла (Xavier Valls Pla), Ричарду Смиту (Richard Smith), Мартину Васильеву (Martin Vassilev), Иоане Ифрим (Ioana Ifrim), участвовавшим в подготовке этого поста.

Комментарии (1)


  1. DaneSoul
    08.04.2022 11:30
    +2

    Можно позанудствовать?
    То что изображено на КПДВ — это sling
    А cling это:
    image