Небольшое предисловие
Сам я наткнулся на 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);
Таким же образом экспортируются еще несколько классов, а затем все соединяется вместе лямбда-функциями, объявленными прямо в коде инициализации. Результат работы скрипта вы можете увидеть на скриншоте:
консоль с chaiscript на ImGui: загрузка и установка объекта через команды
Учитывая в целом гибкость библиотеки, поменять подход к экспорту классов в скрипт не составит практически никакого труда. Безусловно, Lua обладает более обширной документацией и сообществом, и этот язык будет предпочтительней в случае, если вам нужно получить большую производительность от кода скрипта (JIT все же делает свое дело), но списывать со счетов ChaiScript все же не стоит. Если у вас есть небольшой проект, который нуждается в скриптинге, можете смело эксперементировать с доступными альтернативами.
На этой ноте я бы хотел завершить данную статью. Если вы уже имели опыт работы со скриптовыми языками внутри C++ (будь то Lua или другой язык), в комментариях буду рад услышать ваше мнение о ChaiScript и скриптингу в целом. Также я приветствую любые вопросы или замечания касательно публикации. Всем спасибо за прочтение.
Полезные ссылки
- Официальный сайт ChaiScript: chaiscript.com
- Быстрый справочник по синтаксису и библиотеке: ChaiScript cheatsheet
- Официальная документация: codedocs.xyz/ChaiScript/ChaiScript
- Мой проект с использованием ChaiScript: github.com/MomoDeve/MomoEngine
atri1
как я понял это интерпретируемый скриптовый язык… ну такое
чем это лучше интерпретатора джаваскрипта на сотню киллобайт на Си?
П.С. есть шикарный Haxe который и интерпретируется, и имеет свою VM, и может бинарно компилироваться во всех поддерживаемых яыках(включая C++), Haxe это типо Раста, но с нормальным синтаксисом.
WhiteBlackGoose
Js в прицнипе не очень благородное дело, а еще и для проекта на cpp...
MomoDev Автор
Тащить js ну такое… При этом мы теряем адекватную типизацию. Тогда уж питон взять проще
bentall
Опять же для прозрачного взаимодействия плюсов с питоном понадобится какой-нибудь Boost.Python (или есть что-то более стильное, модное молодёжное?). А ChaiScript и AngelScript, они нормально взаимодействуют только с плюсами, но зато взаимодействие с плюсами у них «из коробки»…
bentall
Ну, автор же всё расписал:
Для Lua нужен ещё какой-нибудь Sol или LuaBridge (когда-то ещё luabind был) ну или самому заботится об обращении из lua к объектам cpp. Интерпретатор JS на сотню килобайт вряд ли сравнится по эффективности с Lua, не говоря уж о LuaJit (а как с этим у Чая?) ну и про маппинг объектов cpp там, опять же вряд-ли кто позаботился — всё ручками.
Есть ещё похожий проект AngelScript, но там статически типизированный язык (не совсем C++, но в первом приближении похож).
MomoDev Автор
С производительностью все не так гладко. Так как компилятора в байткод нет, то все вычисления проводятся на AST, что конечно же в разы медленней как минимум из-за разрозненности данных в памяти. Сам Jason говорит по этому поводу тут: discourse.chaiscript.com/t/moving-to-a-bytecode-representation/186/3
bentall
Ага, уже сам это нагуглил. Вот AngelScript, похоже, быстрее даже «ванильного» Lua (не JIT), но сам язык больше похож на C++, чем на скриптовый.
bentall
Судя по подобным дискуссиям ChaiScript по производительности всё же ближе к простым интерпретаторам JS «на сотню килобайт» чем к lua. Во всяком случае был.