Когда возникает потребность внедрить скриптовый язык в проект на C++, первым делом большинство людей вспоминает Lua. В этой статье его не будет, я расскажу о другом, не менее удобном и легком в освоении языке под названием ChaiScript.

image

Небольшое предисловие


Сам я наткнулся на ChaiScript случайно, когда смотрел один из докладов Jason'а Turner'a, одного из создателей языка. Меня это заинтересовало, и в тот момент, когда нужно было выбрать скриптовый язык в проект, я решил — почему бы не попробовать ChaiScript? Результат меня приятно удивил (о моем личном опыте будет написано ближе к концу статьи), однако, как бы странно это ни звучало, на хабре не оказалось ни одной статьи, в которой хоть как-то бы упоминался этот язык, и я решил, что было бы неплохо написать о нем. У языка конечно есть документация и официальный сайт, но из наблюдений далеко не каждый будет ее читать, да и формат статьи многим (включая меня) ближе.

Сначала мы поговорим о синтаксисе языка и всех его фичах, потом о том, как внедрить его в ваш проект C++, а в конце я расскажу немного о своем опыте. Если какая-то часть вас не интересует, или вы хотите прочитать статью в другом порядке, можете воспользоваться оглавлением:



Синтаксис языка


Язык ChaiScript очень похож на C++ и JS своим синтаксисом. Прежде всего, он, как и подавляющее большинство скриптовых языков, является динамически-типизированным, однако в отличие от JavaScript, имеет строгую типизацию (никаких 1 + "2"). Также есть встроенный сборщик мусора, язык является полностью интерпретируемым, позволяя исполнять код построчно, без компиляции в байткод. Имеет поддержку исключений (причем совместную, позволяя ловить их как внутри скрипта, так и в C++), лямбда-функции, перегрузку операторов. Не чувствителен к пробелам, позволяя писать как в одну строку через точку-с-запятой, так и в стиле python, разделяя выражения новой строкой.

Примитивные типы


ChaiScript по умолчанию хранит целочисленные переменные как int, вещественные как double, а строки с помощью std::string. Сделано это прежде всего для того, чтобы обеспечить совместимость с вызывающим кодом. В языке даже есть суффиксы у чисел, чтобы мы могли явно указать, какого типа является наша переменная:

/* 
переменные в chaiscript объявляются как в js
тип указывать не нужно, достаточно var / auto
`;` в конце строки по желанию 
*/
var myInt = 1 // int
var myLongLong = 1ll // long long int
var myFloating = 3.3 // double
var myBoolean = false // bool
var myString = "hello world!\n" // std::string

Менять тип переменных просто так не выйдет, скорее всего вам необходимо будет определить свой оператор `=` для этих типов, иначе вы рискуете либо вызвать исключение (об этом мы поговорим позже), либо стать жертвой округления, так:

var integer = 3
integer = 5.433
print(integer) // печатает 5 за счет округления double при присвоении в int!
integer = true // вызывает исключение - не оператора `=` для (int, bool)

Тем не менее, вы можете объявить переменную, не присвоив ей значение, в таком случае в ней будет лежать своеобразный undefined до того момента, пока ей не будет присвоено значение.

Встроенные контейнеры


В языке предусмотрено два контейнера — Vector и Map. Работают они очень схоже с аналогами в C++ (std::vector и std::map соответственно), однако не требуют указания типа, потому что могут хранить любой. Индексировать Vector можно как обычно с помощью int'ов, а вот Map требует ключом обязательно строку. Видимо вдохновившись python, авторы также добавили возможность быстро объявлять контейнеры в коде с помощью следующего синтаксиса:

var v = [ 1, 2, 3u, 4ll, "16", `+` ] // массив с элементами разных типов
var m = [ "key1" : 1, "key2":  "Bob" ]; // словарь с произвольными типами-значениями

var M = Map() // создает пустой словарь
var V = Vector() // создает пустой массив

// в массив можно добавлять элементы в стиле C++ вектора:
v.push_back(123)

// есть нужно добавить ссылку, можно воспользоваться отдельной функцией
v.push_back_ref(m); // m - произвольный объект

// в словарь добавлять также легко
m["key"] = 3

// по ссылке можно через ссылочное присваивание (reference assignment):
m["key"] := m // теперь словарь хранит ссылку на объект 

Оба этих класса почти в полной мере повторяют свои аналоги в C++, за исключением итераторов, ведь вместо них существует специальные классы Range и Const_Range. К слову, все контейнеры передаются по ссылке даже если вы пользуетесь присваиванием через =, что для меня весьма странно, так как для всех остальных типов происходит копирование по значению.

Условные конструкции


Почти все конструкции условий и циклов можно описать буквально в одном примере кода:

var a = 5
var b = -1
// классический if-else
if (a > b) {
    print("a > b")
} else if (a == b){
    print("a == b")
} else {
    print("a < b")
}

// switch - раскрывается как набор if-ов
// Можно проверять переменную любого типа
// Без break исполнение также проваливается вниз, как и в C++
var str = "hello"
switch(str)
{
    case("hi") { print("hi!"); break; }
    case("hello") { print("hello!" break; }
    case("bye") { print("bye-bye!") break; }
    default { print("what have you said?") }
}

var x = true
// фигурные скобки должны всегда присутствовать, даже если в теле одна строка
while (x) {
  print("x was true")
  x = false;
}

// цикл в стиле C. По желанию можно объявить одну переменную в начале, после идет булево выражение, в конце любое выражение, исполняющееся каждую итерацию
for (var i = 0; i < 10; ++i) // есть только пре-инкеремент, значение увеличивается сразу
{
  print(i); // печатаем 0 ... 9 в 10 строк
}

// ranged-for loop
for(element : [1, 2, 3, 4, 5])
{
    puts(element) // печатает подряд 12345
}

// для гурманов: прямиком из C++17 if-init statements:
if(var x = get_value(); x < 10) {
    print(x) // x является локальной переменной внутри if
}

Думаю, люди, знакомые с C++, нового ничего не нашли. Это не удивительно, ведь ChaiScript позиционируется, как легкий для «сишников» в освоении язык, и поэтому заимствует всем известные классические конструкции. Авторы решили выделить даже два ключевых слова для объявления переменных — var и auto, на случай, если вам очень уж сильно нравятся плюсы с auto.

Контекст выполнения


В ChaiScript есть локальный и глобальный контекст. Код исполняется сверху вниз построчно, однако его можно вынести в функции и вызвать позднее (но не раньше!). Переменные, объявленные внутри функций или условий/циклов по умолчанию не видны извне, но вы можете изменить это поведение, используя идентификатор global вместо var. Глобальные переменные отличаются от обычных тем, что, во-первых: видны за пределами локального контекста, а, во-вторых: могут быть заново объявлены (если при повторном объявлении не задается значение, то оно остается прежним)

// простая функция на языке chaiscript
def foo(x)
{
    global G = 2
    print(x)
}

foo(0) // вызов foo(x), G = 2
print(G)  // печатает 2
global G = 3 // теперь G = 3, повторное объявление global - не ошибка!

К слову, если у вас есть переменная, и нужно проверить, присвоено ли ей значение, воспользуйтесь встроенной функцией is_var_undef, возвращающей true, если переменная undefined.

Интерполяция строк


Базовые объекты или пользовательские, у которых определен метод to_string(), могут быть помещены в строку с помощью синтаксиса ${object}. Это позволяет избежать лишних конкатенаций строк и в целом выглядит намного опрятней:

var x = 3
var y = 4

// печатает sum of 3 + 4 = 7
print("sum of ${x} + ${y} = ${x + y}") 

Vector, Map, MapPair и все примитивы также поддерживают эту функцию. Vector выводится в формате [o1, o2, ...], Map как [<key1, val1>, <key2, val2>, ...], а MapPair: <key, val>.

Функции и их нюансы


Функции ChaiScript — такие же объекты, как и все остальное. Их можно захватывать, присваивать переменным, делать вложенными в другие функции и передавать как аргумент. Также для них вы можете указать тип входных значений (то чего так не хватало динамически-типизированным языкам!), для этого надо указать тип перед объявлением параметра функции. Если при вызове параметр можно преобразовать в указанный, то произойдет преобразование по правилам C++, иначе генерируется исключение:

def adder(int x, int y)
{
    return x + y
}

def adder(bool x, bool y)
{
   return x || y
}

adder(1, 2) // ок, результат 3
adder(1.22, -3.7) // ок, результат 1 + (-3) = 2
adder(true, true) // ок, результат true 
adder(true, 3) // ошибка, нет подходящей функции adder(bool, int)

Функциям в языке также можно задавать условия вызова (call guard). Если он не соблюдается, вызывается исключение, иначе выполняется вызов. Также отмечу, что если функция не имеет return-statement'а в конце, то вернется последнее выражение. Очень удобно для небольших подпрограмм:

def div(x, y) : y != 0 { x / y } // если `y` не равен нулю - вернуть результат деления `x` на `y`

print(div(2, 0.5)) // печатает 4.0
print(div(2, 0)) // ошибка, `y` равен 0!

Классы и Dynamic_Object


ChaiScript имеет зачатки ООП, что является несомненным плюсом в случае, если вам необходимо манипулировать сложными объектами. В языке присутствует особый тип — Dynamic_Object. По факту все экземпляры классов и пространства имен являются именно Dynamic_Object с заранее заданными свойствами. Динамический объект позволяет добавлять к нему поля по ходу выполнения скрипта, а после обращаться к ним:

var obj = Dynamic_Object();
obj.x = 3;
obj.f = fun(arg) { print(this.x + arg); } // теперь obj имеет метод f (внутри него можно обращаться к `x`
obj.f(-3); // печатает 0

Классы определяются достаточно просто. Им можно задать поля, методы, конструкторы. Из интересного — через специальную функцию set_explicit(object, value) можно «зафиксировать» поля объекта, запретив добавление новых методов или атрибутов после объявления класса (обычно это делается в конструкторе):

class Widget
{
    var id; // атрибут id
    def Widget() { this.id= 0 } // конструктор без параметров
    def Widget(id) { this.id = id } // конструктор с 1 параметров
    def get_id() { id } // метод класса
}
var w = Widget(10)
print(w.get_id()) // печатает 10 (w.id)
print(w.get_id) // также печатает 10, скобки могут быть опущены если нет параметров
set_explicit(w, true) // зафиксировать объект класса
w.x = 3 // вызовет ошибку так как у Widget нет поля x

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

def set_id(Widget w, id) { w.id = id }

w.set_id(9) // w.id = 9
set_id(w, 9) // тоже самое, w.id = 9

Кто знаком с C#, может заменить, что больно похоже это на метод расширения, и будет недалек от истины. Таким образом, в языке вы можете добавить новый функционал даже для встроенных классов, к примеру для строки или int'а. Также авторы предлагают хитрый способ перегрузки операторов: чтобы его сделать, необходимо окружить символ оператора тильдой (`) как в примере ниже:

// перегрузка оператора + для двух объектов типа Widget
def `+`(Widget w1, Widget w2)
{
    print("merging two widgets!")
}

var widget1 = Widget()
var widget2 = Widget()
widget1 + widget2 // без функции выше генерируется исключение

// оператор также можно захватить как функцию и вызвать:
var plus = `+`
print(plus(1, 7)) // печатает 8

Пространства имен


Говоря о пространстве имен в ChaiScript, стоит иметь в виду, что это по существу классы, которые всегда находятся в глобальном контексте. Вы можете создать их с помощью функции namespace(name), а после добавить необходимые функции и классы. По умолчанию в языке нет никаких библиотек, однако вы можете их установить с помощью расширений, о которых мы поговорим чуть позже. В целом, инициализация пространства имен может выглядеть так:

namespace("math") // создаем пространство имен math

// добавим функций
math.square = fun(x) { x * x }
math.hypot_squared= fun(x, y) { math.square(x) + math.square(y) }

print(math.square(4)) // печатает 16
print(math.hypot_squared(3, 4)) // печатает 25

Лямбда выражения и другие фичи


Лямбда выражения в ChaiScript подобны тем, что мы знаем из C++. Для них используется ключевое слово fun, и они также требуют явного указания захватываемых переменных, однако делают это всегда по ссылке. Также в языке есть функция bind, которая позволяет привязать значения к параметрам функции:

var func_object = fun(x) { x * x }
func_object(9) // печатает 81

var name = "John"
var greet = fun[name]() { "Hello, " + name } 
print(greet()) // печатает Hello, John
name = "Bob"
print(greet()) // печатает Hello, Bob

var message = bind(fun(msg, name) { msg + " from " + name }, _, "ChaiScript");
print(message("Hello")) // печатает Hello from ChaiScript

Исключения


Во время выполнения скрипта могут возникнуть исключения. Они могут быть как перехвачены в самом ChaiScript (что мы здесь и обсудим), так и в C++. Синтаксис абсолютно идентичен с плюсами, вы можете даже выкидывать число или строку:

try {
    eval(x + 1) // x не существует
} catch (e) {
    print("Error during evaluation"))
}

// можно ловить C++ исключения внутри ChaiScript
// Так как Vector - обертка над std::vector, то он кидает std::exception при обращении за пределы массива
try {
    var vec = [1, 2]
    var val = vec[3] // обращение за пределы массива
} catch (e) {
    print("index out of range: " + e.what()); // e.what преобразуется в строку ChaiScript
}

// к сatch можно добавить guard так же как для функций, проверяется условие после `:`
try {
  throw(5.2)
} catch(e) : is_type(e, "int") {
  print("Int: ${e}"); // только если `e` это int
} catch(e) : is_type(e, "double") {
    print("Double: ${e}"); // если `e` это double
}

По-хорошему, вы должны определить свой класс исключений и кидать его. О том, как его перехватывать в C++, мы поговорим во втором разделе. Для исключений интерпретатора ChaiScript генерирует свои исключения, такие как eval_error, bad_boxed_cast и т.п.

Константы интерпретатора


К моему удивлению в языке оказалось и некое подобие макросов компилятора — их всего 4 и все они служат для выявления контекста и по большей части используются для обработки ошибок:
__LINE__ текущая строка, если код исполняется не из файла, то '1'
__FILE__ текущий файл, если код вызывается не из файла, то "__EVAL__"
__CLASS__ текущий класс или «NOT_IN_CLASS»
__FUNC__ текущая функция или «NOT_IN_FUNCTION»

Перехват ошибок


Если функция, которую вы вызываете, не была объявлена, вызывается исключение. Если для вас это неприемлемо, вы можете определить специальную функцию — method_missing(object, func_name, params), которая будет вызвана с соответствующими аргументами в случае ошибки:

def method_missing(Widget w, string name, Vector v) 
{
    print("widget method ${name} with params {v} was not found")
}

w = Widget()
w.invoke_error(1, 2, 3) // печатает widget method invoke_error with params [1, 2, 3] was not found

Built-in функции


В ChaiScript определяется множество встроенных функций, и в статье хотелось бы рассказать об особо полезных. Среди них: eval(str), eval_file(filename), to_json(object), from_json(str):

var x = 3
var y = 5
var res = eval("x * y") // res = 15, в eval используется контекст вызывающего кода

// можем запустить скрипт из файла:
// через eval_file
eval_file("source.chai")
// или через use, последний гарантирует, что повторный вызов с тем же файлом игнорируется
use("source.chai")

// to_json превращает объект в Map и возвращает строку
var w = Widget(0)
var j = to_json(w) // j = "{ "id" : 0 }"
// from_json преобразует строку в Map (к сожалению, не в объект)
var m = from_json("
{
    "x": 0,
    "y": 3,
    "z": 2
}")
print(m) // печатает Map как [<x, 0>, <y, 3>, <z, 2>]


Внедрение в C++


Установка


ChaiScript является header-only библиотекой C++, построенной на шаблонах. Соответственно для установки вам всего лишь нужно сделать clone репозитория или просто поместить все файлы из этой папки в ваш проект. Так как в зависимости от IDE все это делается по-разному и уже давно детально расписано на форумах, далее будем предполагать, что у вас получилось подключить библиотеку, и код с include-ом: #include <chaiscript/chaiscript.hpp> компилируется.

Вызов кода C++ и загрузка скрипта


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

#include <string>
#include <chaiscript/chaiscript.hpp>

std::string greet_name(const std::string& name) 
{
    return "hello, " + name;
}

int main() 
{
    chaiscript::ChaiScript chai; // объект chaiscript
    chai.add(chaiscript::fun(&greet_name), "greet"); // добавляем функцию как greet

    // мгновенный eval  с выводом результата в консоль
    chai.eval(R"(
        print(greet("John"));
    )");
}

Надеюсь у вас получилось, и вы увидели результат выполнения функции. Сразу хочу отметить один нюанс — если вы объявите объект ChaiScript как статический, то получите неприятную ошибку времени выполнения. Связано это с тем, что язык по умолчанию поддерживает многопоточность и хранит локальные переменные потока, к которым обращается в своем деструкторе. Однако, уничтожаются они раньше, чем вызывается деструктор статического экземпляра, и в итоге мы имеем ошибку access violation или segmentation fault. Исходя из issue на github, самым простым решением будет просто поставить #define CHAISCRIPT_NO_THREADS в настройках компилятора или перед включением файла библиотеки, тем самым отключив многопоточность. Как я понял, пофиксить эту ошибку так и не удалось.

Теперь разберем детально, как же происходит взаимодействие С++ и ChaiScript. В библиотеке определена специальная шаблонная функция fun, которая может принимать указатель на функцию, функтор или указатель на переменную класса, а затем возвращать специальный объект, хранящий состояние. В качестве примера определим в C++ коде класс Widget и попробуем по-разному связать его с ChaiScript:

class Widget
{
    int Id;
public:
    Widget(int id) : Id(id) { }
    int GetId() const { return this->Id; }
};

std::string ToString(const Widget& w)
{
    return "widget #" + std::to_string(w.GetId());
}

int main()
{
    chaiscript::ChaiScript chai;
    Widget w(2); // создадим Widget в C++ коде

    chai.add(chaiscript::fun([&w] { return w; }), "get_widget"); // захватим его в лямбду и передадим в скрипт
    chai.add(chaiscript::fun(ToString), "to_string"); // внешняя функция
    chai.add(chaiscript::fun(&Widget::GetId), "get_id"); // метод класса

    // для примера вызовем код, который получает Widget и вызывает сначала GetId, а затем неявно to_string, который мы перегрузили
    chai.eval(R"(
        var w = get_widget()
        print(w.get_id) // печатает 2
        print(w) // печатает widget #2
        )");
}

Как видите, ChaiScript абсолютно спокойно работает с неизвестными ему классами C++ и умеет вызывать их методы. Если же вы где-то в коде ошибетесь, скорее всего скрипт выкинет исключение рода error in function dispatch, что совсем не критично. Однако не только функции можно импортировать, давайте посмотрим, как добавить переменную в скрипт средствами библиотеки. Для этого выберем задачу чуть потруднее — импортировать std::vector<Widget>. В этом нам помогут функция chaiscript::var и метод add_global. Также добавим public-поле Data в наш Widget, чтобы посмотреть, как импортировать поле класса:

class Widget
{
    int Id;
public:
    int Data = 0;
    Widget(int id) noexcept : Id(id) { }
    int GetId() const { return this->Id; }
};

std::string ToString(const Widget& w)
{
    return "widget #" + std::to_string(w.GetId()) +
        " with data: " + std::to_string(w.Data);

int main()
{
    chaiscript::ChaiScript chai;

    std::vector<Widget> W; // зададим массив из Widget
    W.emplace_back(1);
    W.emplace_back(2);
    W.emplace_back(3);

    chai.add(chaiscript::fund(ToString), "to_string");

    chai.add(chaiscript::fun(&Widget::Data), "data"); // указатель на поле класса 
    
    // добавим глобальный объект в ChaiScript
    chai.add_global(chaiscript::var(std::ref(W)), "widgets"); // избегаем копирования с помощью std::ref
    chai.add(chaiscript::fun(&std::vector<Widget>::size), "size"); // размер массива
   
    // индексация. Пояснение к этим двум строкам дано ниже
    using IndexFuncType = Widget& (std::vector<Widget>::*)(const size_t);
    chai.add(chaiscript::fun(IndexFuncType(&std::vector<Widget>::operator[])), "[]");

    chai.eval(R"(
        for(var i = 0; i < vec.size; ++i)
        {
            vec[i].data = i * 2;
            print(vec[i])
        }
    )");
}

Код выше выводит на экран: widget #1 with data: 0, widget #2 with data: 2, widget #3 with data: 4. Мы добавили в ChaiScript указатель на поле класса, и так как поле оказалось примитивным типом, мы изменить его значение. Также для работы с std::vector было добавлено несколько его методов, среди которых — operator[]. Те, кто хорошо знакомы с STL, знают, что метода индексации у std::vector два — один возвращает константную ссылку, другой — простую ссылку. Именно поэтому для перегруженных функций нужно явно указывать их тип — иначе возникает неоднозначность, и компилятор выдаст ошибку.

Библиотека предоставляет еще несколько методов для добавления объектов, но все они практически идентичны, поэтому рассматривать их подробно не вижу смысла. В качестве небольшой подсказки приведу код ниже:

chai.add(chaiscript::var(x), "x"); // x копируется в ChaiScript

chai.add(chaiscript::var(std::ref(x), "x"); // по ссылке, можно изменять из C++ и ChaiScript

auto shared_x = std::make_shared<int>(5);
chai.add(chaiscript::var(shared_x), "x"); // shared_ptr существует пока есть указатели в C++ или ChaiScript

chai.add(chaiscript::const_var(x), "x"); //  копируется в ChaiScript и становится константой

chai.add_global_const(chaiscript::const_var(x), "x"); // global const переменная. Исключение, если x уже существует

chai.add_global(chaiscript::var(x), "x"); // global переменная, Исключение. если x уже существует

chai.set_global(chaiscript::var(x), "x"); // устанавливает значение global переменной, если та не const

Использование STL контейнеров


Если вы хотите передать STL-контейнеры, содержащие примитивные типы в ChaiScript, вы можете добавить инстанцирование шаблонного контейнера в ваш скрипт, чтобы не импортировать для каждого типа его методы.

using MyVector = std::vector<std::pair<int, std::string>>;

MyVector V;
V.emplace_back(1, "John");
V.emplace_back(3, "Bob");

// добавим наши типы - vector и pair
chai.add(chaiscript::bootstrap::standard_library::vector_type<MyVector>("MyVec"));
chai.add(chaiscript::bootstrap::standard_library::pair_type<MyVector::value_type>("MyVecData"));

chai.add(chaiscript::var(std::ref(V)), "vec");
chai.eval(R"(
    for(var i = 0; i < vec.size; ++i)
    {
      print(to_string(vec[i].first) + " " + vec[i].second)
    }
  )");

Под капотом идет вызов нескольких функций ChaiScript, которые сами добавляют необходимые методы. В целом, если ваш класс поддерживает схожие операции с STL-контейнерами, вы также можете добавить его таким способом. В случае c std::vector<Widget> это, к сожалению, невозможно, так как ChaiScript требует наличие конструктора без параметров для элемента vector_type, коего у нашего Widget не было.

С++ классы внутри ChaiScript


Возможно в рамках вашей задачи необходимо не только изменять объекты в ChaiScript, но и создавать их в скрипте. Что же, это вполне возможно. Возьмем снова для примера класс Widget и унаследуем от него класс WindowWidget, а затем добавим в скрипт возможность создавать их оба, а также конвертировать унаследованный класс в базовый:

class Widget
{
    int Id;
public:
    Widget(int id) : Id(id) { }
    int GetId() const { return this->Id; }
};

class WindowWidget : public Widget
{
    std::pair<int, int> Size;
public:
    WindowWidget(int id, int width, int height)
        : Widget(id), Size(width, height) { }

    int GetWidth()  const { return this->Size.first; }
    int GetHeight() const { return this->Size.second; }
};

int main()
{
    chaiscript::ChaiScript chai;

    // добавим тип Widget и его конструктор
    chai.add(chaiscript::user_type<Widget>(), "Widget");
    chai.add(chaiscript::constructor<Widget(int)>(), "Widget");

    // добавим тип WindowWidget и его конструктор
    chai.add(chaiscript::user_type<WindowWidget>(), "WindowWidget");
    chai.add(chaiscript::constructor<WindowWidget(int, int, int)>(), "WindowWidget");

    // скажем, что Widget - базовый класс для WindowWidget
    chai.add(chaiscript::base_class<Widget, WindowWidget>());

    // добавим методы Widget и WindowWidget
    chai.add(chaiscript::fun(&Widget::GetId), "get_id");

    chai.add(chaiscript::fun(&WindowWidget::GetWidth), "width");
    chai.add(chaiscript::fun(&WindowWidget::GetHeight), "height");

    // создадим WindowWidget и вызовем его методы
    chai.eval(R"(
        var window = WindowWidget(1, 800, 600)
        print("${window.width} * ${window.height}")
        print("widget.id is ${window.get_id}")
    )");
}

Полиморфизм работает в ChaiScript абсолютно также, как и в C++ для типов, информацию о которых вы предоставили. Если по каким-то причинам возникает неоднозначность при добавлении указателя на унаследованный метод (возможно, класс наследуется сразу от нескольких базовых), приведите его к нужному классу явно, как это сделано в примере выше с оператором индексации std::vector<Widget>.

Привязка экземпляра к методу и конвертирование типа


Для синглтон объектов удобно использовать захват ссылки на них вместе с методом или полем. В таком случае в ChaiScript мы получим либо функцию, либо глобальную переменную, к которой можно обратиться без упоминания этого объекта:

Widget w(3);
w.Data = 4444;

// привязка Widget w
chai.add(chaiscript::fun(&Widget::GetId, &w), "widget_id");
chai.add(chaiscript::fun(&Widget::Data, &w), "widget_data");

chai.eval(R"(
    print(widget_id)
    print(widget_data)
)");

Также при экспорте более «библиотечных» классов из C++ в ChaiScript (к примеру, vec3, complex, matrix) часто требуется возможность неявного преобразования из одного типа в другой. В ChaiScript эта задача решается путем добавления type_conversion в объект скрипта. Для примера рассмотрим класс Complex и реализацию преобразования int и double в него при сложении:

class Complex
{
public:
    float Re, Im;
    Complex(float re, float im = 0.0f) : Re(re), Im(im) { }
};

int main()
{
    chaiscript::ChaiScript chai;

    // добавим Complex, определив поля re, im, конструктор и оператор `=`
    chai.add(chaiscript::user_type<Complex>(), "Complex");
    chai.add(chaiscript::bootstrap::standard_library::assignable_type<Complex>("Complex"));
    chai.add(chaiscript::constructor<Complex(float, float)>(), "Complex");
    chai.add(chaiscript::fun(&Complex::Re), "re");
    chai.add(chaiscript::fun(&Complex::Im), "im");

    // добавим неявное преобразование из double и int в Complex
    chai.add(chaiscript::type_conversion<int, Complex>());
    chai.add(chaiscript::type_conversion<double, Complex>());

    // в скрипте определим оператор `+` для произвольного типа
    chai.eval(R"(
        def `+`(Complex c, x)
        {
            var res = Complex(0, 0)
            res.re = c.re + x.re
            res.im = c.im + x.im
            return res
        }

        var c = Complex(1, 2)
        c = c + 3
        print("${c.re} + ${c.im}i")
    )"); // результат: `4 + 2i`
}

Таким образом, не обязательно писать функцию преобразования в самом C++, а лишь затем экспортировать ее в ChaiScript. Можно добавить преобразования, и уже в самом коде скрипта описать новый функционал. Если же конвертация для двух типов нетривиальна, вы можете передать лямбду аргументом в функцию type_conversion. Она будет вызываться при приведении типов.

Схожий принцип используется для преобразования Vector или Map ChaiScript'а в ваш пользовательский тип. Для этого в библиотеке определены vector_conversion и map_conversion.

Распаковка возвращаемых значений ChaiScript


Методы eval и eval_file возвращают значение последнего выполненного выражения в виде объекта Boxed_Value. Чтобы распаковать его и использовать результат в коде C++, вы можете как явно указать тип возвращаемого значения, так и использовать функцию boxed_cast<T>. Если преобразование между типами существует, оно будет выполнено, иначе возникнет исключение bad_boxed_cast:

// сразу привести результат к нужному типу
double d = chai.eval<double>("5.3 + 2.1");

// сохранить результат в виде Boxed_Value, затем привести к типу
auto v = chai.eval("5.3 + 2.1");
double d = chai.boxed_cast<double>(v);

Так как все объекты внутри ChaiScript хранятся с помощью shared_ptr, вы можете получить объект в виде указателя для последующей работы с ним. Для этого явно укажите тип shared_ptr при конвертации возвращаемого значения:

auto x = chai.eval<std::shared_ptr<double>>("var x = 3.2");

Главное, не стоит хранить ссылку на значение разыменнованного shared_ptr, иначе вы рискуете получить access violation после того, как переменная будет удалена в ходе автоматической сборки мусора в скрипте.

Так же как и переменные, вы можете достать из ChaiScript функции в виде запакованных функторов, захватывающих состояние объекта ChaiScript. Для примера, воспользуемся уже реализованным функционалом класса Complex и попробуем с помощью него вызвать функцию на этапе выполнения программы:

auto printComplex = chai.eval<std::function<void(Complex)>>(R"(
    fun(Complex c) { print("${c.re} + ${c.im}i"); }
)"); // создаем лямбду, печатающую объект класса, а затем возвращаем ее в C++
printComplex(Complex(2, 3)); // вызов chaiscript, печатает `2 + 3i`

Перехват исключений из ChaiScript


Авторы рекомендуют ловить три типа исключений помимо тех, которые вы генерируется самостоятельно. Это eval_error для ошибок времени выполнения, bad_boxed_cast, вызывающийся при неправильной распаковке возвращаемых значений и std::exception для всего остального. Если же вы планируете выкидывать свои собственные исключения, вы можете настроить автоматическое преобразование в типы С++:

class MyException : public std::exception
{
public:
    int Data;
    MyException(int data) : std::exception("MyException"), Data(data) { }
};

int main()
{
    chaiscript::ChaiScript chai;
    
    // добавим исключение как тип в chaiscript
    chai.add(chaiscript::user_type<MyException>(), "MyException");
    chai.add(chaiscript::constructor<MyException(int)>(), "MyException");

    try
    {
        // укажем к каким типам пытаться приводить исключения внутри скрипта
        chai.eval("throw(MyException(11111))",
            chaiscript::exception_specification<MyException, std::exception>());
    }
    catch (MyException& e)
    {
        std::cerr << e.Data; // здесь выведется `11111`
    }
    catch (chaiscript::exception::eval_error& e)
    {
        std::cerr << e.pretty_print();
    }
    catch(std::exception& e)
    {
        std::cerr << e.what();
    }
}

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

Библиотеки ChaiScript


К сожалению, по умолчанию ChaiScript не предоставляет дополнительного функционала в плане библиотек. К примеру, в нем отсутствуют математические функции, хеш-таблицы, большинство алгоритмов. Часть из них вы можете скачать в виде библиотек-модулей из официального репозитория ChaiScript Extras, а затем импортировать в ваш скрипт. Для примера возьмем библиотеку math и функцию acos(x):

#include <chaiscript/chaiscript.hpp>
#include <chaiscript/extras/math.hpp>

int main()
{
    chaiscript::ChaiScript chai;
    // добавим библиотеку
    auto mathlib = chaiscript::extras::math::bootstrap();
    chai.add(mathlib);

    std::cout << chai.eval<double>("acos(0.5)"); // ~1.047
}

Вы также можете написать свою библиотеку для языка и потом импортировать. Делается это достаточно просто, поэтому советую ознакомиться с открытым кодом math или другой либы в репозиторие. В принципе, в рамках интеграции с C++ мы рассмотрели почти все, так что считаю, что раздел на этом можно закончить.

Личный опыт


В данный момент я пишу 3D-движок под OpenGL в качестве персонального проекта, и у меня возникла вполне закономерная идея реализовать консоль отладки, чтобы в реальном времени управлять состоянием приложения посредством команд. Можно было бы конечно заняться велосипедированием, но, как говорится, «игра бы не стоила свеч», поэтому я решил взять готовую библиотеку.

Как я упомянул в начале статьи, о ChaiScript к тому моменту я уже знал, поэтому передо мной стоял выбор между ним и Lua. До того момента ни с тем, ни с другим языком я знаком не был, поэтому больше всего влияли такие факторы как: понятность синтаксиса, легкость внедрения в уже существующий код и поддержка C++ вместо C, чтобы не городить забор из ООП-оберток над C-style функциями. Думаю, по ходу чтения данной статьи вы уже догадались, на что пал мой выбор.

На данный момент язык меня более чем устраивает, и писать обвязки над классами не составляет большого труда. В коде движка к запускаемому приложению прикреплен один экземпляр консоли на ImGui, в которой инициализируется объект chaiscript. Парой макросов задача о внедрении нового класса в скрипт сводится к простому описанию всех методов, которые необходимо экспортировать:

// фрагмент кода с инициализацией методов 3D-объекта:

// rotation
CHAI_IMPORT(&GLInstance::RotateX, rotate_x);
CHAI_IMPORT(&GLInstance::RotateY, rotate_y);
CHAI_IMPORT(&GLInstance::RotateZ, rotate_z);

// scale
CHAI_IMPORT((GLInstance&(GLInstance::*)(float))&GLInstance::Scale, scale);
CHAI_IMPORT((GLInstance&(GLInstance::*)(float, float, float))&GLInstance::Scale, scale);

// translation
CHAI_IMPORT(&GLInstance::Translate, translate);
CHAI_IMPORT(&GLInstance::TranslateX, translate_x);
CHAI_IMPORT(&GLInstance::TranslateY, translate_y);
CHAI_IMPORT(&GLInstance::TranslateZ, translate_z);
		
// hide / show
CHAI_IMPORT(&GLInstance::Hide, hide);
CHAI_IMPORT(&GLInstance::Show, show);

// getters
CHAI_IMPORT(&GLInstance::GetTranslation, translation);
CHAI_IMPORT(&GLInstance::GetRotation, rotation);
CHAI_IMPORT(&GLInstance::GetScale, scale);

Таким же образом экспортируются еще несколько классов, а затем все соединяется вместе лямбда-функциями, объявленными прямо в коде инициализации. Результат работы скрипта вы можете увидеть на скриншоте:

image
консоль с chaiscript на ImGui: загрузка и установка объекта через команды

Учитывая в целом гибкость библиотеки, поменять подход к экспорту классов в скрипт не составит практически никакого труда. Безусловно, Lua обладает более обширной документацией и сообществом, и этот язык будет предпочтительней в случае, если вам нужно получить большую производительность от кода скрипта (JIT все же делает свое дело), но списывать со счетов ChaiScript все же не стоит. Если у вас есть небольшой проект, который нуждается в скриптинге, можете смело эксперементировать с доступными альтернативами.

На этой ноте я бы хотел завершить данную статью. Если вы уже имели опыт работы со скриптовыми языками внутри C++ (будь то Lua или другой язык), в комментариях буду рад услышать ваше мнение о ChaiScript и скриптингу в целом. Также я приветствую любые вопросы или замечания касательно публикации. Всем спасибо за прочтение.

Полезные ссылки