Here comes a more important topic, how to inject (bind) Native classes and methods into scripts so that they can be used normally.
Let’s start with a simple one. When you need to inject a native method, you can use the script::Function::newFunction
method. The accepted parameter is a script::FunctionCallback
type, which is std::function. <void(const Arguments& args)>
, that is, a callback function. When js calls the corresponding method, ScriptX will call this callback function, in this function:
- There is already EngineScope in the function.
- In the function, you can throw an exception to the script directly by throwing
script::Exception
. - The parameters of the function are obtained by
Local<Value> Arguments::operator[](size_t)
. - The return value of the function is passed through
return Local<Value>{xxx}
.
See the exception section above for related examples.
The basics of a Class is the same as Method, but the form is different.
There is a template definition in Native.h:
template<typename T>
struct ClassDefine {
const std::string className{};
const std::string nameSpace{};
const StaticDefine staticDefine{};
const InstanceDefine<T> instanceDefine{};
NativeRegister getNativeRegister() const;
}
Use ClassDefine<T>
to describe all the information of a class.
- Template parameter T: the corresponding class of the script class in C++. The instance of the script class corresponds to the instance of T in C++. Each time a script class instance is created, a new instance of T will be created in C++. When this class has only static methods (class methods) that we cannot create an instance, use void instead of T.
- className: the name of the class
- nameSpace: In which namespace this class is located. In JS, such as
nameSpace = "game.ui"; className="ImageView";
then the class will exist ingame.ui.ImageView
in the script, and usenew game.ui.ImageView()
. To cover different languages, the namespace separator char is always a single dot --.
. - staticDefine: class static method + properties definition
- instanceDefine: instance method + properties definition of the class
- getNativeRegister: Since ClassDefine is a template class, it is quite difficult to be stored in a container.
Therefore, ClassDefine directly implements a type erasure helper class.
The
NativeRegister
class is an ordinary class that supports copy and move, and can be used as a pointer.
Register a binding class by calling ScriptEngine::registerNativeDefine
. It should be noted that the destruction of this ClassDefine is later than ScriptEngine, otherwise there will be a wild pointer problem. The usual practice is to use ClassDefine as a global static variable.
StaticDefine
contains methods (method name + method callback), properties (property name + property getter + property setter) and binding methods are similar, so no need to repeat.
InstanceDefine
template<typename T>
struct InstanceDefine {
struct PropertyDefine {... };
struct FunctionDefine {
using FunctionCallback = std::function<void(T*, const Arguments& args)>;
...
};
const Constructor constructor{};
const std::vector<PropertyDefine> properties{};
const std::vector<FunctionDefine> functions{};
const size_t instanceSize = sizeof_helper_v<T>;
};
The template parameter T must be a type that inherits sctipt::ScriptClass
.
InstanceDefine<T>
has some differences compared to StaticDefine
. First of all, there is an
additional required param -- constructor
, which is the callback of the constructor.
ScriptX will call back to this function when the script side creates a new instance of the class.
This function needs to be created and returns a T*
pointer. When the corresponding instance is GCed,
ScriptX will automatically release and destroy T*
. In particular, when the constructor
parameters are illegal and the corresponding C++ instance cannot be constructed, you can
consider throwing an exception, or return nullptr
(ScriptX will throw an exception for you at this time).
Another difference is that the method callback has an additional parameter T*
which is the C++ instance pointer.
The pointer won't be null and always be the pointer returned by the constructor.
The above using FunctionCallback = std::function<void(T*, const Arguments& args)>;
With the help of std::function
, it is possible to bind a function with two parameters void (*) (T*, const Arguments&)
, can also bind a member function void (T::*)(const Arguments&)
of class T.
Finally, the above struct is not a public API, but only used to introduce the internal logic. All ClassDefine<T>
should be constructed by ClassDeinfeBuilder<T>::build
.
Examples are as follows:
class TestClass: public ScriptClass {
public:
explicit TestClass(const Local<Object>& scriptObj): ScriptClass(scriptObj) {}
~TestClass() override = default;
virtual Local<Value> greet(const Arguments& args);
Local<Value> age(const Arguments& args);
Local<Value> getSrc();
void setSrc(const Local<Value>& value);
static Local<Value> add(const Arguments& args);
static Local<Value> getVersion();
static void setVersion(const Local<Value>& value);
static TestClass* create(const Arguments& args);
};
const ClassDefine<TestClass> TestClassDefAll =
defineClass<TestClass>("TestClass")
.nameSpace("script.engine.test")
.constructor(&TestClass::create)
.function("add", &TestClass::add)
.property("version", &TestClass::getVersion, &TestClass::setVersion)
.instanceFunction("greet", &TestClass::greet)
.instanceFunction("age", &TestClass::age)
.instanceProperty("src", &TestClass::getSrc, &TestClass::setSrc)
.build();
Create a ClassDefineBuilder by using script::defineClass<ClasType>(className)
namespace(std::string)
see aboveproperty(std::string,std::function<Local<Value>()> getter, std::function<void(const Local<Value>& value)> setter)
is used to add a static propertyfunction(std::string, std::function<Local<Value>(const Arguments& args)>)
is used to add a static methodconstructor(std::function<T*(const Arguments& args)>)
is used to specify a constructor, see above for detailsinstanceFunction(std::string, std::function<Local<Value>(T*, const Arguments& args)>)
is used to add an instance methodinstanceProperty(std::string, std::function<Local<Value>(T*)> getter, std::function<void(T*, const Local<Value>& value)> setter)
is used Add an instance attributebuild
is used to constructClassDefine
, internally checks the validity of the data, and an exception will be thrown if the check fails.
Note that only when the template parameter T
is not void, that is, when the ClassDeinfe
is not a static class, methods 4, 5, and 6 can be called.
As shown in the example code above, there are three APIs in ScriptEngine
and ClassDefine<T>
.
- newNativeClass: create a classDefine object directly in C++
- isInstanceOf: Determine whether a
Local<Value>
is an instance of a certain classDefine - getNativeInstance: Get the pointer of its C++ object from the instance of classDefine.
Others include multiple overloaded methods of newNativeClass
to facilitate the use of API.
const ClassDefine<TestClass>& def();
EngineScope engineScope(engine);
try {
engine->registerNativeClass<TestClass>(def());
// 1. static property
auto version = engine->eval("script.engine.test.TestClass.version");
ASSERT_TRUE(addRet.isString());
// 2. new instance
Local<Value> ins = engine->newNativeClass<TestClass>();
Local<Value> ret = engine->eval("new script.engine.test.TestClass()");
// 3. Determine if a Script Value is an instance of ClassDefine
auto instanceof = engine->isInstanceOf<TestClass>(ret);
ASSERT_TRUE(instanceof);
// 4. Obtain C++ class pointer through Script value
TestClass* ptr = engine->getNativeInstance<TestClass>(ret);
} catch (const Exception& e) {
...
}
If you want to bind an instance class, you must inherit ScriptClass
. ScriptClass provides some basic operations of a C++ object bound to a script object.
Several APIs that need to be explained include
Get the bound script object.
The usage scenario is that sometimes the binding object needs to hold some other script objects, such as a callback function.
InternalStore is an array referenced by ScriptObject. With this array, we can set any object that needs to be stored. At the same time, there is no need to worry about GC related issues.
Destructor, ScriptX manages memory and GC and delete the memory (also calls destructor) as need.
ScriptX guarantees that this method must be called on the main thread.
ScriptX does not guarantee that this instance must be destroyed at the same time as ScriptObject GC (may be delayed). In fact, for V8 and JSCore, because both engines require that no engine-related APIs can be called in finalize, the destruction of their ScriptClass is all posted to MessageQueue.
So when you hold a ScriptClass pointer in C++, you may find that ScriptClass::getScriptObject
returns null in this time gap.
This is another constructor of ScriptClass. The usage scenario is that a certain binding class requires a lot of C++ dependencies when constructing it. In this way, going through another ScriptX will cause a lot of type conversion and troublesome. So provide this constructor, directly use C++new as an instance, and then get the corresponding ScriptObject through getScriptObejct
and return it to ScriptX.
Please use this competence with extra CAUTION, read the doc in header file with care, otherwise you may face strange crashes (memory issue).
YOU HAVE BEEN WARNED.
class MyImage: public script::ScriptClass {
public:
MyImage(void* canvas): script::ScriptClass(script::ScriptClass::ConstructFromCpp<MyImage>{});
void drawTo(void* canvas, int x, int y);
};
script::ClassDefine<MyImage> myClassDefine =
script::defineClass<MyImage>("MyImage")
.constructor(nullptr)
.function("newImage", []() -> Local<Value> {
auto img = new MyImage(Render::getInstance()->canvas());
return img.getScriptObject();
})
.function("newImage2", []() -> MyImge* {
// With the help of Converter, you can also return the pointer directly, and ScriptX will be converted to the equivalent code above
return MyImage(Render::getInstance()->canvas());
})
.build();
Because the binding class needs to inherit from ScriptClass
, it seems unfriendly to the existing C++ classes?
Fortunately, C++ has multiple inheritance. You only need to write a Wrapper class to inherit both the existing C++ class and ScriptClass
.
class BaseClass {
public:
int age = 0;
int num = 1;
const int length = 180;
std::string name() {return "Base";}
int getNum() {return num;}
void setNum(int n) {num = n;}
};
class BaseClassScriptWrapper: public BaseClass, public ScriptClass {
public:
explicit BaseClassScriptWrapper(const Local<Object>& thiz): BaseClass(), ScriptClass(thiz) {}
};
const auto baseWrapperDefine =
defineClass<BaseClassScriptWrapper>("BaseWrapper")
.nameSpace("BindBaseClass")
.constructor()
.instanceProperty("age", &BaseClass::age)
.instanceProperty("length", &BaseClass::length) // const property has getter, no setter
.instanceFunction("name", &BaseClass::name)
.instanceProperty("num", &BaseClass::getNum, &BaseClass::setNum)
.build();
See NativeTest for details
Although the above-mentioned binding API has been relatively easy to use, ScriptX is one step closer, using modern C++ template meta-programming capabilities, allowing ScriptX to directly bind ordinary C++ functions.
Such as:
class MyImage: public script::ScriptClass {
public:
using script::ScriptClass::ScriptClass;
static const int age = 0;
static const char* name() {return "hello world";}
std::string src;
int add(int a, int b) {return a+ b;}
};
script::ClassDefine<MyImage> myClassDefine =
script::defineClass<MyImage>("MyImage")
.constructor()
.property("age", &MyImage::age)
.function("name", &MyImage::name)
.instanceFunction("add", &MyImage::add)
.instanceProperty("src", &MyImage::src)
.build();
Note the above, you can even bind member variables directly.
The specific usage principle is that the method parameter and return value are types that support conversion.
List of supported conversion types:
- void
- bool
- any float type: float double
- any int type: int8_t uint8_t int16_t uint16_t int32_t uint32_t int64_t uint64_t size_t
- any string type: string string_view char* char8_t* u8string u8string_view
- all kind of Local reference
- any pointer of subclass of ScriptClass
Note 7, in fact, it supports the conversion of all binding classes and class pointers.
For example, Local<Value>
refers to the binding object of TestClass
, then it can be directly converted to TestClass*
You can customize the new type converter, you only need to specialize the template:
namespace script::converter {
template <>
struct Converter <T, void> {
// convert custom type T to Local<Value>
static Local<Value> toScript(T&& value) {...;}
// convert Local<Value> to custom type T
static T toCpp(const Local<Value>& value) {...;}
};
}
See NativeConverter for details
See CustomConverterTest for details
ScriptX also adds type conversion capabilities to other commonly used interfaces. Such as:
Local<Function> func;
func.call({}, "string", 1, classPtr);
Local<Object> obj;
obj.set("hello", 4.13);
Object::newObject(type, "name", false);
When directly binding C++ functions, we often face the situation of C++ function overloading.
Here is a util function to select the appropriate overloaded version.
script::selectOverloadedFunc
int overload(int) {return 0;}
int overload(double) {return 1;}
class X {
public:
int overload(int) {return 0;}
int overload(int&) {return 1;}
int overload(double) {return 2;}
int overload(double) const {return 3;}
};
TEST_F(NativeTest, SelectOverloadedFunction) {
auto o1 = script::selectOverloadedFunc<int(int)>(overload);
auto o2 = script::selectOverloadedFunc<int(double)>(overload);
EXPECT_EQ(0, o1(0));
EXPECT_EQ(1, o2(0.0));
auto x0 = script::selectOverloadedFunc<int (X::*)(int)>(&X::overload);
auto x1 = script::selectOverloadedFunc<int (X::*)(int&)>(&X::overload);
auto x2 = script::selectOverloadedFunc<int (X::*)(double)>(&X::overload);
auto x3 = script::selectOverloadedFunc<int (X::*)(double) const>(&X::overload);
X x;
X* p = &x;
int i = 0;
EXPECT_EQ(0, (p->*x0)(0));
EXPECT_EQ(1, (p->*x1)(i));
EXPECT_EQ(2, (p->*x2)(0.0));
EXPECT_EQ(3, (p->*x3)(0.0));
}
See NativeTest for details
Different scripting languages have different capabilities. For example, JS does not support overloaded operators, but languages such as Lua and Python allow.
ScriptX's design philosophy is: only provide common and necessary interfaces.
For example, ClassDefine
only provides the necessary 5 interfaces: constructor, static properties, static methods, instance properties, and instance methods.
With these five interfaces, theoretically the capabilities are complete.
In response to the problems raised above, ScriptX's solution is:
Each language provides a set of language-specific basic libraries.
graph code
flowchart BT;
C[C++ core competence] -- binding --> ScriptX;
ScriptX -- extension+wrapping --> J[JS basic library];
ScriptX -- extension+wrapping --> L[Lua basic library];
subgraph JS
J <-- API --> JB[JS business script];
end
subgraph Lua
L <-- API --> LB[Lua business script];
end
From bottom to top:
- ScriptX provides basic binding capabilities to expose C++ core capabilities;
- The user writes a set of language-related basic libraries to expand capabilities on top of ScriptX (such as the above operator overloading);
- Finally, the business script will call the basic library and the bound API to achieve its functional requirements.
In this way, the language-related logic is put into the basic library, and the code of the binding part is still universal and unified.