ChaiScript — скриптовый язык для C++

    Когда возникает потребность внедрить скриптовый язык в проект на 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 и скриптингу в целом. Также я приветствую любые вопросы или замечания касательно публикации. Всем спасибо за прочтение.

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


    Средняя зарплата в IT

    113 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 5 572 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 33

      +3
      ChaiScript

      как я понял это интерпретируемый скриптовый язык… ну такое


      Если у вас есть небольшой проект, который нуждается в скриптинге, можете смело эксперементировать с доступными альтернативами.

      чем это лучше интерпретатора джаваскрипта на сотню киллобайт на Си?


      П.С. есть шикарный Haxe который и интерпретируется, и имеет свою VM, и может бинарно компилироваться во всех поддерживаемых яыках(включая C++), Haxe это типо Раста, но с нормальным синтаксисом.

        +7

        Js в прицнипе не очень благородное дело, а еще и для проекта на cpp...

          +5

          Тащить js ну такое… При этом мы теряем адекватную типизацию. Тогда уж питон взять проще

            +3
            Опять же для прозрачного взаимодействия плюсов с питоном понадобится какой-нибудь Boost.Python (или есть что-то более стильное, модное молодёжное?). А ChaiScript и AngelScript, они нормально взаимодействуют только с плюсами, но зато взаимодействие с плюсами у них «из коробки»…
            +3
            Ну, автор же всё расписал:
            легкость внедрения в уже существующий код и поддержка C++ вместо C, чтобы не городить забор из ООП-оберток над C-style функциями.

            Для Lua нужен ещё какой-нибудь Sol или LuaBridge (когда-то ещё luabind был) ну или самому заботится об обращении из lua к объектам cpp. Интерпретатор JS на сотню килобайт вряд ли сравнится по эффективности с Lua, не говоря уж о LuaJit (а как с этим у Чая?) ну и про маппинг объектов cpp там, опять же вряд-ли кто позаботился — всё ручками.

            Есть ещё похожий проект AngelScript, но там статически типизированный язык (не совсем C++, но в первом приближении похож).
              0
              С производительностью все не так гладко. Так как компилятора в байткод нет, то все вычисления проводятся на AST, что конечно же в разы медленней как минимум из-за разрозненности данных в памяти. Сам Jason говорит по этому поводу тут: discourse.chaiscript.com/t/moving-to-a-bytecode-representation/186/3
                0
                Ага, уже сам это нагуглил. Вот AngelScript, похоже, быстрее даже «ванильного» Lua (не JIT), но сам язык больше похож на C++, чем на скриптовый.
                0
                Интерпретатор JS на сотню килобайт вряд ли сравнится по эффективности с Lua, не говоря уж о LuaJit (а как с этим у Чая?)

                Судя по подобным дискуссиям ChaiScript по производительности всё же ближе к простым интерпретаторам JS «на сотню килобайт» чем к lua. Во всяком случае был.
              +1
              Прежде всего, он, как и подавляющее большинство скриптовых языков, является динамически-типизированным

              Менять тип переменных просто так не выйдет

              Все-таки какой-то не совсем динамически типизированный
                0
                Да, в принципе я с вамм согласен. Видимо это опять же сделано для совместимости с C++, так как объявленные классы в скрипте фиксированным типом не обладают и соответственно прямого доступа из вызывающего кода к ним нет. Поведение объектов похоже на dynamic в шарпе
                0
                C++ обертки для Lua
                github.com/vapourismo/luwra

                Обертка класса выглядит даже проще чем обертки для ChaiScript
                  0
                  там просто используется макрос. Я тоже так сделал для своего проекта. А вот пушить руками переменные на стек — архаизм
                    +2
                    Так и CHAI_IMPORT тоже макрос ))
                  +4
                  Не понимаю, почему авторы новых языков не делают их «expression oriented», то есть чтобы все эти if, for, {} возвращали значение, и можно было помечать «переменные» как неизменяемые. Без этого при условной инициализации приходится сначала объявить неинициализированную неконстантную переменную, а потом присвоить ей соответствующее значение в каждой ветке условного оператора. Что при дальнейшем рефакторинге обязательно выльется в ее использование до инициализации. А вот если бы он (if) возвращал значение, можно было бы написать
                  const foo = if (a > 10) { 10 } else { 20 }

                  В частности для этого подозреваю был придуман тернарный оператор. Особенно это удобно когда для инициализации нужно вычислить ряд промежуточных значений, можно ввести скоуп с помощью {} и вернуть из него одно значение, не замусоривая функцию лишними переменными и четко обозначая, что после определенной точки они не нужны. Ну и из цикла бывает удобно на нужной итерации выйти со значением.
                    0
                    Кстати такое вроде для GCC есть в качестве расширения компилятора Си (см. Управляющие операторы и блоки кода как выражения) habr.com/ru/post/315676
                      +2

                      В C++ для этого используют тернарный оператор в простых случаях или лямбды в более сложных:
                      int const val = [&] { if (a) return 0; else return 1; }();

                        +2
                        Вот и получается, что нужно городить IIFE (immediately invoked function expression), потому что это единственный способ инкапсулировать вычисление и вернуть результат, что, согласитесь, совсем не удобно и не бесплатно (хотя конечно умный компилятор это оптимизирует, но не все языки компилируемые)
                          +2
                          Rust же, doc.rust-lang.org/beta/reference/expressions/if-expr.html

                          let y = if 12 * 15 > 150 {
                              "Bigger"
                          } else {
                              "Smaller"
                          };
                          assert_eq!(y, "Bigger");
                            +1
                            И Ruby:

                            a = if true then 'Yes, I can!' else 'No :(' end

                            => «Yes, I can!»
                              0
                              Ну вот когда я написал про возможность возвращать значение из цикла, это и был намек на Rust
                          +1
                          +2
                          var myLongLong = 1ll // long long int


                          Ох не зря гугл просит буквы Л в суффиксах только большими писать
                          В шрифте статьи читается как 111. Пришлось осторожно вглядываться
                            0

                            У кого-то косяк с настройками
                            image

                              0

                              image


                              я настройки вообще не трогал

                              0
                              не надо вглядываться надо настроить шрифт в браузере =)
                              image
                                0

                                в коментах шрифт другой, элки вообще в палки превратились


                                image

                                  0
                                  В chromium поставил плагин font changer, в firefox через дефолтные настройки сняв «Разрешить веб-сайтам использовать свои шрифты вместо установленных выше».
                                  На всех сайтах, что статьи, что комментарии выглядят одинаково (PT Mono).
                              –4
                              Было бы интересно если был бы свой интерпретатор, а так не особо.
                                0

                                Странно, что автор сразу зарядил про lua, лично я подумал, а почему это не python или js. Оба два встраиваются довольно просто. А помимо этого имеют богатые библиотеки. Почти уверен, что тот же Js окажется по итогам и более производительным.

                                  +1

                                  Не знаю как с JS, а питон встраивается довольно нетривиально. Причём основная лично для меня проблема — стандартный механизм импортов крайне тяжело взять под контроль. Последнее, что я находил — требуется руками редактировать исходники питона, иначе нет возможности к примеру ограничить доступ к стандартной библиотеке. Даже если вы её выпилите из вашего дистрибутива, клиентский код вполне сможет подгрузить свой модуль, к примеру неизменённую стандартную библиотеку. В отличие от него, Lua и встраивается, и изолируется не в пример проще.

                                    0

                                    Хм с помощью boost::python например, я делал в обе стороны интеграции (плюсы в python и python в плюсы), не помню, что бы встретил какие-то трудности на этом пути, разницы с lua я практически не увидел, а бустовый сахар сделал процесс даже проще. Но у меня требования об ограничении доступа в стандартную библиотеку не стояли, вполне допускаю что это может быть нетривиально, т.к. обширная стандартная библиотека это один из основных критериев выбора обычно.


                                    Что касается JS — там API чуть сложнее(ну и реализаций минимум 2), но зато стандартной библиотеки — практически просто нет, а потоки хотя бы упоминаются в документации (в том плане что есть re-entrant, что есть thread-safe и какой ценой). По эффективности итогового решения, с включенным jit оно сравнимо или лучше lua (python тут в уверенном хвосте плетётся). Т.е. для конкурентных высоконагруженных приложений я бы несколько раз тщательно взвесил сравнения lua/js и произвёл бы бенчмарки для типовых случаев. Думаю цена интеграции реализаций рассчитанных на встраивание была бы тут ничтожно малой.


                                    Lua — конечно чемпион в плане простоты интеграции и быстрого старта, но это практически единственное его достоинство. Очень узкие комьюнити владеющих этим языком профессионально, поэтому вынести значительную часть бизнесс логики туда — часто выглядит сомнительным решением, даже на фоне чуть более высокой технической сложности для более распостранёныйх технологий. Я не говорю что это совсем не кейс, кейс и ещё какой, т.к. комьюнити имеют место быть, но подумать надо n, а лучше n+1 раз :)


                                    Итого, для себя я сформировал такой чеклист:


                                    • Есть комьюнити, клиент ожидает увидеть lua — берём lua, при условии, что нет необходимости иметь более широкие возможности быстрого прототипирования
                                    • Основная цель быстрое прототипирование и лёгкий вход небольшой и обученной группы, целевая аудитория не связанна с web-frontend, нет высоких нефункциональных требований — берём python
                                    • Есть web-oriented комьюнити, есть высокие нефункциональные требования, требуется широкая аудитория — берём ecma script

                                    Когда я говорю про высоконагруженные решения, я не имею ввиду i/o-bound решения. Там лучше выбирать то, на чём удобней будет писать скрипты конечному пользователю, остальное, в основном, это единоразовые затраты, хотя кейс с запретом импорта стандартной библиотеки таковым не кажется, и может потребовать поддержки.

                                  +1
                                  «днако, как бы странно это ни звучало, на хабре не оказалось ни одной статьи, в которой хоть как-то бы упоминался этот язык»

                                  Наверное это из-за того что в плюсах много нужно писать своими руками. Вот и выработалась привычка писать своё собственное решение вместо использования уже доступного. Скорее всего у Джейсона так и было.
                                    0
                                    Очень интересный язык, интереснее чем AngelScript и lua. Но есть вопросы, как с производительностью? Я в своё время выбрал AngelScript потому что он быстрее lua (даже с jit) при активном использовании проброшенных плюсовых классов и методов, что являлось подавляющим юзкейсом. И также не освещена тема многопоточности, имеются ли тут какие нибудь особенности типа того же GIL у питона?

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

                                    Самое читаемое