Implementing Properties

Published March 26, 2017
Advertisement

I seem to have become obsessed with Om, my scripting language, again and have done nothing whatsoever on my game since last entry, just been busy with Om. I justify this on the basis that a) I'll need a scripting language for my game and b) this is hobby stuff so I can do whatever the hell I like :)

I've been fairly disciplined about implememnting the boring stuff. Most of it is easy enough as I can copy the code from the previous version of Om, simplifiying it a bit since the new version is a lot cleaner, and I have the fun of working on the optimisation now in the new version as I add each feature, since optimisation wasn't included in the last one.

But I reached a good point to implement properties in this incarnation, which I've never done in a scripting language before. Has been enormous fun and not nearly as difficult as I thought.

By properties, I mean object members that call a function when read from or written to as values. This is complicated slightly by the fact that functions in Om can either be script functions or C++ functions wrapped up in an Om::Value.

I went through a number of different approaches before I settled on what I have now. I've added [font='courier new']Om::Type::Property[/font] as a new type to the system, which is another reference type that is implemented by a [font='courier new']PropertyEntity[/font]. The [font='courier new']PropertyEntity[/font] stores [font='courier new']TypedValues[/font] for the get and set methods, which can be either an [font='courier new']Om::Type::Function[/font], and external C++ function or [font='courier new']Om::Type::Null[/font] (making the property read or write only, or both).

First up, let's look at the script syntax for defining properties. There are two versions. The first simply allows you to assign values to the getter and setter, so you can use anything to initialise them:

var f = function(a){ this.n = a;};var o ={ n = "Foo"; name = property { get = function { return this.n; }; set = f; }; };out o.name; // prints "Foo";o.name = "Bar"; // calls f("Bar")out o.name; // prings "Bar";You can assign anything to the getter and setter, but you will get errors if you then reference the getter or setter in an expression if they are not set to the correct type i.e. a function taking no arguments for the getter and a function taking one argument for the setter.

As a bit of syntax sugar, you can also define the methods inline:

var o ={ n = "Foo"; name = property { get { return this.n; }; set(a) { this.n = a; } }};out o.name; // prints "Foo";o.name = "Bar"; // calls f("Bar")out o.name; // prings "Bar";Essentially the same as above, just expressed a bit more cleanly, and we can enforce the correct function types here at compile-time.

From the C++ side, [font='courier new']Om::Engine[/font] has a new [font='courier new']makeProperty()[/font] method that you can use to implement properties using external functions. You can use the same function for getting and setting if you wish, by checking the incoming argument count.

script.txt:
return function(x){ var o = { n = "Foo"; name = x; }; out o.name; // prints "Foo"; o.name = "Bar"; // calls f("Bar") out o.name; // prings "Bar";};C++:
Om::Value test(Om::Engine &engine, Om::Context &ctx){ if(ctx.argumentCount()) { ctx.thisObject().setProperty("n", ctx.argument(0)); } return thisObject().property("n");}void f(Om::Engine &engine){ Om::Value f = engine.evaluate("script.txt", Om::Engine::EvaluateType::File); Om::Value p = engine.makeProperty(engine.makeFunction(test)); engine.call(f, Om::Value(), { p });}Similarly you can construct an [font='courier new']Om::Type::Object[/font] externally by calling [font='courier new']Om::Engine::makeObject()[/font] then using [font='courier new']Om::Value::setProperty()[/font] to set a property up as an [font='courier new']Om::Type::Property[/font].

So now we'll take a look at compilation and execution of this stuff. We'll define a slightly simpler script to make this a bit less verbose.

var o ={ n = property { get { out "getter"; }; set(a) { out "setter ", a; } };};out o.n;o.n = 10;First this is compiled by the system into the following abstract syntax tree:

function() block var o object member n property function() block trace string "getter" trace eol return function(a) block trace string "setter " trace symbol a trace eol return trace dot n symbol o trace eol expr assign dot n symbol o int 10 returnThe optimisation pass is run next, but there is nothing in this particular script that gets optimised.

Next, the bytecode for the virtual machine is generated from the tree. Here is the standard dump that we use constantly to check how things are looking internally. Each function is generated as a separate [font='courier new']FunctionEntity[/font] with its own program memory.

I'll annotate each line of the bytecode with some explanation. Comments are added here, not part of the dump.

Entity 0 flags[0] refs[0] vrefs[0] (function) params: 0 // function takes no parameters 0: MkEnt object // create an ObjectEntity, push a reference to it onto the stack, increment its reference count 6: MkEnt property // create a PropertyEntity, push a reference to it onto the stack, increment its reference count 12: Push function 1 // push a reference to the function with ID 1 (the getter) onto the stack, increment its reference count 18: AddPm 0 // pop the value from the top of the stack and set it as the stack-top PropertyEntity's get value 23: Push function 2 // push a reference to the function with ID 1 (the getter) onto the stack, increment its reference count 29: AddPm 1 // pop the value from the top of the stack and set it as the stack-top PropertyEntity's set value 34: AddCh 3 // pop the value from the top of the stack (the reference to PropertyEntity) and set it as a member of the top-of-stack (ObjectEntity) 39: GetLc 2 // grab a value from further down the stack and push it on top (ObjectEntity) 44: GetMb 3 // treat top of stack as ObjectEntity and get its member that has the name that resolves to 3 in the TextCache ("n") 49: Out // pop the top of stack, print it and decrement its reference count, destroying it if zero 50: OutNl // print a newline 51: Push int 10 // push an integer with the value of 10 on the stack 57: GetLc 2 // grab a value from further down the stack and push it on top (ObjectEntity) 62: PutMb 3 // treat top of stack as ObjectEntity and set its member that has the name that resolves to 3 in the TextCache ("n") 67: Pop // pop the top of the stack and decrement its reference count, destroying it if zero 68: Push null null // push a null onto the stack 74: PutLc 0 // put the value further down the stack (the return value slot in this case) 79: PopN 3 // pop three items from the stack, decrement each reference count (if applicable) and destroy if zero for each 84: Ret // returnEntity 1 flags[0] refs[1] vrefs[0] (function) params: 0 0: MkEnt string "getter" 6: Out 7: OutNl 8: Push null null 14: PutLc 0 19: PopN 2 24: RetEntity 2 flags[0] refs[1] vrefs[0] (function) params: 1 0: MkEnt string "setter " 6: Out 7: GetLc 2 12: Out 13: OutNl 14: Push null null 20: PutLc 0 25: PopN 3 30: RetTextCache:0: "prototype" (refs:1) [DS]1: "getter" (refs:1)2: "setter " (refs:1)3: "n" (refs:3)All the magic for properties is essentially handled inside the [font='courier new']OpCode::Type::GetMb[/font] and [font='courier new']OpCode::Type::SetMb[/font] instructions. These call [font='courier new']Machine::mb()[/font], passing the [font='courier new']TextCache[/font] id of the member (resolved at compile-time) and a flag to indicate if we are reading ([font='courier new']GetMb[/font]) or writing ([font='courier new']PutMb[/font]).

Here's [font='courier new']mb()[/font] in all its glory, although I appreciate it probably doesn't mean a lot to the casual reader.

void Machine::mb(uint id, AccessType type){ TypedValue o = vs.pop_back(); TypedValueGuard guard(state, { o }); if(o.type() == Om::Type::Object) { if(type == AccessType::Read) { uint object = o.toUint(); while(object != invalid_uint) { auto &m = state.entity(object).properties; auto i = m.find(id); if(i != m.end()) { vs.push_back(i->value); inc(state, vs.back()); if(vs.back().type() == Om::Type::Property) { TypedValue p = vs.pop_back(); TypedValue get = state.entity(p.toUint()).get; if(!get.valid()) { throw Exception("cannot read property", 0); } vs.push_back(TypedValue()); setupStack(state, vs, o, { }); vs.push_back(get); inc(state, vs.back()); call(0); dec(state, p); } return; } object = invalid_uint; i = m.find(DefinedStrings::Prototype); if(i != m.end() && i->value.type() == Om::Type::Object) { object = i->value.toUint(); } } vs.push_back(TypedValue()); return; } else { auto &e = state.entity(o.toUint()); if(e.properties.find(id) == e.properties.end()) { state.tc.inc(id); e.trefs.push_back(id); } TypedValue &m = e.properties[id]; if(m.type() == Om::Type::Property) { TypedValue set = state.entity(m.toUint()).set; if(!set.valid()) { throw Exception("cannot write to property", 0); } execute(set, o, { vs.back() }); } else { dec(state, m); m = vs.back(); inc(state, m); } } return; } throw Exception("dot applied to invalid type", 0);}Of relevance to the new property system are the checks for [font='courier new']type() == Om::Type::Property[/font] in the two blocks there.

If we are reading and it turns out we have read a property, we check it has a get method and, if so, set up the stack then call the [font='courier new']Call()[/font] method, passing in a zero parameter count. This saves us having to make a recursive call here and uses the same method that [font='courier new']OpCode::Type::Call[/font] does when calling a normal function.

Unfortunately, we can't do this when writing because we have to do the write "immediately", so when writing, we have to make a recusive call to [font='courier new']Machine::execute()[/font]. This is the entry point to the virtual machine so we are already running inside a call to this method, but everything is stack-based so a recursive call is fine.

We pass a reference to the object as the "this" value, and the value on the top of the stack as the parameter, which will be the value we assigned to the property in the script.

[font='courier new']Machine::execute()[/font]'s first parameter is a [font='courier new']TypedValue[/font] that can either point to an [font='courier new']Om::FunctionEntity[/font] or to a [font='courier new']TypedValue[/font] with a type of [font='courier new']ExternalFunctionType[/font], which is a special type not exposed by the API but just used internally. In this case, the data of the [font='courier new']TypedValue[/font] is a pointer to an [font='courier new']Om::Function[/font], which is defined as:

namespace Om{typedef Value(*Function)(Engine&, Context&);}This is what the [font='courier new']test()[/font] method in the C++ back up a ways implements.

[font='courier new']Om::Context[/font] is new to this version of Om as well. Previously I was passing an [font='courier new']Om::ValueList[/font] as the parameters to an external function, but the nice thing about [font='courier new']Om::Context[/font] is it internally stores a reference to the virtual machine's stack, and only converts the [font='courier new']TypedValue[/font] to an [font='courier new']Om::Value[/font] when you call the [font='courier new']Om::Context::argument(int index)[/font] method, so it is a bit more efficient.

So next up I need to figure out how to make [font='courier new']Om::Value::property()[/font] and [font='courier new']Om::Value::setProperty()[/font] call the property methods if the value is already initialised with an [font='courier new']Om::Type::Property[/font]. The two uses of the term "property" are a bit confused here. Need to think all this through carefully before I do anything.

Anyway, that's that. Thanks as always for stopping by.

1 likes 0 comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Profile
Author
Advertisement
Advertisement