I'm not done yet!
This page has not yet been updated to MiniD 2.
Metamethods
Often you will want to create your own objects which will behave like distinct types. MiniD allows you to customize the behavior of objects through what are called metamethods.
Lua users will find this term familiar, and MiniD's metamethods are similar in purpose to those in Lua. However, there is no concept of a metatable in MiniD as in Lua.
Types Which Can Have Metamethods
All types except null can have metamethods in MiniD. However, only a few can have metamethods defined for them from within MiniD code. For example, char, string, array, and namespace have some metamethods defined for them by the interpreter, but you can't add metamethods of your own to these types.
The two main types of objects which you can customize from within MiniD are tables and instances. Note that tables still have some metamethods defined for them by the interpreter; these methods are available to all table objects. But you can also define metamethods for individual instances of tables.
Table Metamethods
To define a metamethod for a table, simply put a function in the table. We'll use toString as an example; this metamethod is called when the object needs to be converted to a string.
By default, when you print out the string representation of a table, you get something that's not very useful to humans.
local t = { x = 5, y = 10 }; writefln(t); // prints something like "table 0x00AA0B20"
That is, it just prints out the word "table" followed by its memory address.
So you want to make this print out a little nicer. We can define a toString metamethod to do just that. We can do this in one of two ways: define the method in the table, or add it as a member after the fact. We'll define this one in the table.
local t = { x = 5, y = 10, function toString() { return format("Table x = ", x, " y = ", y); } }; writefln(t);
Now the table prints out as "Table x = 5 y = 10". Much better. Keep in mind that when a metamethod is called, the object on which it's being called is passed as the this parameter to the metamethod (see Functions for info on the this parameter). We then take advantage of that to be able to access the called table's x and y fields from within toString().
This table is the only one which has this toString metamethod. If you wanted many tables to have this method, you could write a function to add it to the table.
function addToString(t) { t.toString = function() { return format("Table x = ", x, " y = ", y); }; } local t = { x = 5, y = 10 }; addToString(t); writefln(t);
Class Metamethods
The other main type of object which you can give metamethods is the instance type. You do this by defining the metamethods in a class. This is very similar to how it's done with tables.
class C { x = 5; y = 10; function toString() { return format("C x = ", x, " y = ", y); } } local c = C(); writefln(c);
Now every instance of the class C will have the toString metamethod defined for it.
The Metamethods
This is a list of all the metamethods which can be defined, what they do, and some examples of their use.
string toString()
We've already met this method in the examples. This metamethod is called when an object needs to be turned into a string, such as when it's being written out with a formatted writing function (writefln and its kind), or when the toString baselib function is called on it.
toString takes no parameters and is expected to return a single string. The examples above show how to define and use toString.
value opIndex(index)
The opIndex metamethod is called when an object is indexed; that is, when you use the square brackets (a[5]) or a dot expression (t.x). opIndex is passed the index (which can be anything if you use the square brackets or a string if you use a dot expression), and is expected to return some value. Any value is fine, including null. The convention in MiniD is to throw an exception when given an invalid index, but you can do whatever you want.
Tables are sort of special; when a table is indexed, the key is looked up. If the key is found, that's the end of it. But if the key isn't found, it will try to call any opIndex metamethods defined in the table; that is, tables only have their opIndex metamethods called when a certain key doesn't exist in them.
An example on how to use it is given in the next section, on its sibling method opIndexAssign.
void opIndexAssign(index, value)
This metamethod accompanies opIndex and is called when an object is indexed on the left side of an assignment expression. It is given two parameters: the index and the value to be assigned to this container. (Note that the order of the parameters here is flipped from D, since in D you can have several indices in the brackets.) This metamethod is not expected to return anything, since nothing can be assigned the value of an assignment in MiniD anyway.
Again, tables have special behavior with this metamethod. This is only called when assigning a key-value pair into a table which doesn't already exist in the table.
Here is an example class showing how to use opIndex and opIndexAssign to emulate some container functionality.
class Array { mData; this(size) { mData = array.new(size); } function opIndex(index) { if(index < 0 || index >= #mData) throw format("Invalid Array index: ", index); return mData[index]; } function opIndexAssign(index, value) { if(index < 0 || index >= #mData) throw format("Invalid Array index: ", index); mData[index] = value; } } local a = Array(10); // These statements call opIndexAssign a[0] = 5; a[1] = 10; // These statements call opIndex writefln(a[0]); writefln(a[1]); // Try out a bad index try a[-5] = 10; catch(e) writefln("CAUGHT: ", e);
Here are a few examples of opIndex and opIndexAssign used with tables.
// This is a Fibonacci table which memoizes its values when it's accessed. local fib = { function opIndex(index) { if(index == 0 || index == 1) this[index] = 1; else this[index] = this[index - 1] + this[index - 2]; return this[index]; } }; // Prints out the first 10 Fibonacci numbers in reverse. // This is significant because the numbers are only calculated on fib[10]; // every subsequent access just gets the precalculated number in the table. for(i: 10 .. 0) writefln(fib[i]); local troll = { function opIndexAssign(index, value) { if(isInt(index)) writefln("I don't like int indices."); else // We use table.set, or else we'd get infinite recursion trying // to add the new key-value pair to this. table.set(this, index, value); } }; troll[3] = 5; // prints "I don't like int indices." troll["hi"] = 8; writefln(troll[3]); // prints null; 3 was never added writefln(troll["hi"]); // prints 8
value opSlice(lo, hi)
This metamethod is called when an object is sliced using the a[x .. y] syntax. It is expected to take two values, the low and high indices of the slice, and to return one value.
The low and high indices can be of any type, but null has a special meaning. When you omit indices in slice expressions (such as a[..]), they are passed as null. So a null slice index should be interpreted as "to the end" (if that even makes sense for your object). The returned value can be anything.
An example of use is shown in the next section, on opSliceAssign.
void opSliceAssign(lo, hi, value)
This metamethod is called when an object is sliced on the left-hand side of an assignment (such as a[x .. y] = z). This is the sibling function of opSlice. It takes three parameters: the low index, the high index, and the value to be assigned. Notice that the order of the parameters are flipped from D -- indices first, and then the value. This metamethod is not expected to return any value. The indices are like in opSlice, where null indicates an omitted index.
Here is a class which implements some slicing functionality. Notice that the keys are not even integers; slicing doesn't have to be done just on arrays.
class FloatArray { mData; mMin = null; mMax = null; this() { mData = {}; } function opIndex(index) { return mData[index]; } function opIndexAssign(index, value) { if(mMin is null || index < mMin) mMin = index; if(mMax is null || index > mMax) mMax = index; mData[index] = value; } function opSlice(lo, hi) { if(#mData == 0) return FloatArray(); local ret = FloatArray(); if(lo is null) lo = mMin; if(hi is null) hi = mMax + 1; foreach(k, v; mData) if(k >= lo && k < hi) ret[k] = v; return ret; } function opSliceAssign(lo, hi, value) { if(#mData == 0) return; if(lo is null) lo = mMin; if(hi is null) hi = mMax + 1; foreach(k, v; mData) if(k >= lo && k < hi) mData[k] = value; } function toString() { local s = StringBuffer("FloatArray ["); foreach(v; mData.keys().sort()) s.append("(", v, ": ", mData[v], ") "); s ~= "]"; return s.toString(); } } local a = FloatArray(); a[0.0] = 7; a[1.4] = 3; a[3.5] = 8; a[5.1] = 2; a[7.9] = 6; a[10.3] = 9; writefln(a); writefln(a[2.5 .. 10.0]); a[3.3 .. 9.1] = 5; writefln(a);
value opCat(vararg)
This metamethod is invoked when you use the concatenation operator, "~". Because of the way concatenation is implemented, this method takes a variadic number of arguments, and will receive at least one argument. This is because lists of concatenations, such as "a ~ b ~ c ~ d" are interpreted as "a.opCat(b, c, d)". This is to improve performance, rather than concatenating just two values at a time. This method is expected to return one value, which is the concatenation of this with all the arguments opCat was passed.
Here is a small example.
class Concatenator { function opCat(vararg) { local s = "Concatenator: "; foreach(v; [vararg]) s ~= toString(v) ~ " "; return s; } } local c = Concatenator(); writefln(c ~ 2 ~ 5 ~ "hi");
void opCatAssign(value)
This is the reflexive version of opCat, called when you use the "~=" operator on an object. This method also takes a variadic number of arguments. An expression like "a ~= b ~ c ~ d" is turned into "a.opCatAssign(b, c, d)". This only happens when concatenation is on the right-hand side; other expressions will cause only one argument to be passed.
Another small example.
class Set { mData; this() { mData = {}; } function opCatAssign(vararg) { foreach(value; [vararg]) mData[value] = true; } function contains(value) { if(mData[value] is null) return false; else return true; } } local s = Set(); // Add members to the set with opCatAssign. // This adds 3 and "hi" separately; it doesn't concatenate them. s ~= 3 ~ "hi"; writefln(s.contains(3)); // prints true writefln(s.contains(-4.5)); // prints false
bool opIn(index)
You can use the "in" operator in MiniD to see if a value is in a container (and the "!in" operator to see if it isn't). This is defined for arrays, strings, tables, and namespaces, but you can define it for your own types by defining an opIn metamethod.
opIn is passed one value, which is the value on the left side of the "in" operator. opIn is called on the value on the right side of the "in" operator. opIn should return a bool; true if the value is in the object, and false otherwise. Actually opIn can return any value, but they will all be converted to bools (using the MiniD definition of truth and fallacy -- i.e. 0, 0.0, null are false..). "!in" calls this metamethod, but it just inverts the result.
Extending the little Set class we made in the opCatAssign class, we can make the code a bit nicer-looking. All we have to do is change the name of contains to opIn.
class Set { mData; this() { mData = {}; } function opCatAssign(vararg) { foreach(value; [vararg]) mData[value] = true; } function opIn(value) { if(mData[value] is null) return false; else return true; } } local s = Set(); s ~= 3; s ~= "hi"; // Using opIn writefln(3 in s); writefln(-4.5 in s); writefln(-4.5 !in s); // prints true, since s.opIn(-4.5) returns false
int opCmp(other)
This metamethod allows you to compare objects to one another. This is called whenever you use a relational (<, <=, >, >=) or equality (==, !=) operator. It is passed an object to compare to, and should return an integer describing the comparison. If this is less than other, it should return a negative integer; if this is greater than other, it should return a positive integer; and if this is equal to other, it should return 0.
By default, comparing some objects such as tables and instances makes no sense. You can give meaning to these comparisons by defining an opCmp metamethod.
class Value { mVal; this(val) { mVal = val; } function opCmp(other) { if(isInstance(other) && (other as Value)) return mVal - other.mVal; else return mVal - other; } } local v1 = Value(5); local v2 = Value(10); if(v1 < v2) writefln("less"); if(v1 >= 3) writefln("greater or equal");
Notice that this opCmp implementation sees if the other value is an instance of Value so it can compare itself against instances of the same type.
function, value, value opApply([value])
opApply is the metamethod you define if you want to be able to use your object as the container expression of a foreach loop.
opApply takes an optional parameter. What this parameter does is completely up to you. When you write a foreach loop like:
foreach(k, v; object, value) // ...
opApply is called on object, and it is passed value as the parameter. In this way you can define many modes of iteration for an object by passing different values into opApply. If no value is given, it defaults to null.
opApply then should return three values in this order: the actual iterator function, the object to be iterated over (usually this but it can be other things), and then the first index to be passed into your iterator function. The returned iterator function will be called once per iteration of the foreach loop, in order to keep getting new values. The iterator function should have a signature like "newIndex, newValue iterator(oldIndex)". This bit of code should help clarify what happens when you run a foreach loop:
// This code: foreach(k, v; object, value) { writefln(k, ", ", v); } // Gets turned into something like this: { local iterator = object; local state = value; local index = null; if(!isFunction(iterator)) { // Notice we're passing in "value" to opApply iterator, state, index = iterator.opApply(state); } // The state gets passed as the context to the iterator. local k, v = iterator(with state, index); while(k !is null) { index = k; // Body of the loop writefln(k, ", ", v); k, v = iterator(with state, index); } }
It seems complex, but it's really not that bad. Basically, the iterator is called repeatedly, using the container as the context and the previous index of iteration, and it has to return a new index and a new value. If it returns null as the index, the loop stops.
Here are a few examples of opApply.
// Iterating over arrays is easy. class Array { mData; this(length) { mData = array.new(length); } function opIndex(index) { return mData[index]; } function opIndexAssign(index, value) { mData[index] = value; } function opApply(method) { // We'll use the method parameter to see if we should go forward or in reverse. if(isString(method) && method == "reverse") { local function iterator(index) { --index; if(index < 0) return null; return index, mData[index]; } // We'll start the iterator at the first invalid index after the end of the array // and then go back from there. return iterator, this, #mData; } else { local function iterator(index) { ++index; if(index >= #mData) return null; return index, mData[index]; } // To go forward, we'll start at -1, so that when we increment the index, it'll be // at 0. return iterator, this, -1; } } // This is really simple; see the section on Unary Metamethods. function opLength() { return #mData; } } local a = Array(5); for(local i = 0; i < #a; ++i) a[i] = i + 1; // Loop through normally. foreach(i, v; a) writefln("a[", i, "] = ", v); writefln(); // Loop through in reverse. foreach(i, v; a, "reverse") writefln("a[", i, "] = ", v); writefln(); // Iterating over a table is a little trickier, since you can't just go element-by-element. // One way to do it is to get the keys array, keep that in an upvalue, and iterate over that, // returning key-value pairs from the table. In this case, we ignore the index passed to the // iterator function by MiniD, since the index that it gives us will be a table index, and not // the index into the keys array. class Map { mData; this() { mData = {}; } function opIndex(index) { return mData[index]; } function opIndexAssign(index, value) { mData[index] = value; } function opApply(method) { // We're seeing if the method parameter is "intValues", and we're going to // only iterate through key-value pairs which have integer values if it is. if(isString(method) && method == "intValues") { local index = 0; local keys = mData.keys(); // Remember, the state is passed as the context to this iterator function. // We're going to ignore the index in this function. local function iterator(idx) { while(index < #keys) { local key = keys[index]; local value = mData[key]; ++index; if(isInt(value)) return key, value; } return null; } // We return the iterator function, the container to iterate (this), and the // first index. We're not using the index, so we can return anything. return iterator, this, 0; } else { // Regular iteration. local index = 0; local keys = mData.keys(); local function iterator(idx) { if(index < #keys) { local key = keys[index]; ++index; return key, mData[key]; } return null; } return iterator, this, 0; } } } local m = Map(); m[4] = 5; m[8] = "hi"; m["x"] = 3; // Print out all the key-value pairs in the map. foreach(k, v; m) writefln("m[", k, "] = ", v); writefln(); // Print out just those pairs which have integer values. foreach(k, v; m, "intValues") writefln("m[", k, "] = ", v);
anything opCall(anything)
Using the opCall metamethod, you can make any object behave as if it were a function. Because of this, opCall can take any number and type of parameters, and return any number and type of values.
Here is an example.
// A group of functions which can all be called at once using opCall. class FuncGroup { mFuncs; this() { mFuncs = {}; } function opCatAssign(vararg) { foreach(func; [vararg]) { if(!isFunction(func)) throw format("FuncGroup -- trying to add a '{}'", typeof(func), ": ", func); mFuncs[func] = func; } } function opCall(vararg) { foreach(func; mFuncs) func(vararg); } } // An imaginary GUI widget. class Widget { mOnClick; this() { mOnClick = FuncGroup(); } function addClickHandler(func) { mOnClick ~= func; } function click(x, y) { // Now we're using opCall to pretend like mOnClick is a function. mOnClick(x, y); } } local button = Widget(); // Set some handlers. button.addClickHandler(function(x, y) { writefln("Handler 1: ", x, ", ", y); }); button.addClickHandler(function(x, y) { writefln("Handler 2: ", x, ", ", y); }); // Simulate a click to call the onClick handlers. button.click(4, 5); writefln();
Overloading Binary Metamethods
The following metamethods are all overloaded in virtually the same way:
- opAdd (a + b)
- opSub (a - b)
- opMul (a * b)
- opDiv (a / b)
- opMod (a % b)
- opAnd (a & b)
- opOr (a | b)
- opXor (a ^ b)
- opShl (a << b)
- opShr (a >> b)
- opUShr (a >>> b)
For all these operators, the method is only ever called on the left-hand side. If no method exists in the left-hand side, an error will be thrown. This means it's not possible for you to write something like "4 - a", where a is a custom object which defines opSub. You can only write "a - 4".
All of these operators take one parameter, the right-hand side, and should return one value.
Here is a simple example.
class BoolArray { mData; this(size) { mData = array.new(size, false); } function opIndex(index) { return mData[index]; } function opIndexAssign(index, value) { if(!isBool(value)) throw format("BoolArray.opIndexAssign() - Cannot assign a '{}' to a BoolArray", typeof(value)); mData[index] = value; } function opApply(method) { // We'll use a shortcut here unlike in the opApply example -- we'll // just return the result of the array opApply! return mData.opApply(method); } function opAnd(other) { assert(#mData == #other.mData); local n = BoolArray(#mData); for(local i = 0; i < #mData; ++i) n[i] = mData[i] && other.mData[i]; return n; } function opOr(other) { assert(#mData == #other.mData); local n = BoolArray(#mData); for(local i = 0; i < #mData; ++i) n[i] = mData[i] || other.mData[i]; return n; } function toString() { return mData.toString(); } } local a1 = BoolArray(5); local a2 = BoolArray(5); a1[0] = true; a1[1] = true; a1[2] = true; a2[0] = true; a2[2] = true; a2[4] = true; writefln(" ", a1); writefln(" & ", a2); writefln("___________________________________"); writefln(" ", a1 & a2); writefln(); writefln(" ", a1); writefln(" | ", a2); writefln("___________________________________"); writefln(" ", a1 | a2);
Overloading Unary Metamethods
There are a few unary metamethods as well, which only take one parameter, the object on which they're operating. They are as follows:
- opNeg (-a)
- opCom (~a)
- opLength (#a)
You can get the length of objects in MiniD by using the Length operator, "#". This is implemented for arrays, strings, tables, and namespaces by default.
All of these methods take no parameters (besides this) and return any value.
Here is an example of the opLength metamethod. opNeg and opCom work exactly the same way.
class C { function opLength() { return 10; } } local c = C(); writefln(#c); // prints 10
Overloading Reflexive Metamethods
These are the operations which are the combination of a binary operator and an assignment. In these expressions, the left-hand side is taken to be both the source and the destination. These methods, like the binary metamethods, all work pretty much the same way: they take one value, the right-hand side, and return nothing. The possible methods are as follows:
- opAddAssign (a += b)
- opSubAssign (a -= b)
- opMulAssign (a *= b)
- opDivAssign (a /= b)
- opModAssign (a %= b)
- opAndAssign (a &= b)
- opOrAssign (a |= b)
- opXorAssign (a ^= b)
- opShlAssign (a <<= b)
- opShrAssign (a >>= b)
- opUShrAssign (a >>>= b)
Here is the BoolArray example, expanded a bit.
class BoolArray { mData; this(size) { mData = array.new(size, false); } function opIndex(index) { return mData[index]; } function opIndexAssign(index, value) { if(!isBool(value)) throw format("BoolArray.opIndexAssign() - Cannot assign a '{}' to a BoolArray", typeof(value)); mData[index] = value; } function opApply(method) { // We'll use a shortcut here unlike in the opApply example -- we'll // just return the result of the array opApply! return mData.opApply(method); } function opAnd(other) { assert(#mData == #other.mData); local n = BoolArray(#mData); for(local i = 0; i < #mData; ++i) n[i] = mData[i] && other.mData[i]; return n; } function opAndAssign(other) { assert(#mData == #other.mData); for(local i = 0; i < #mData; ++i) mData[i] = mData[i] && other.mData[i]; } function opOr(other) { assert(#mData == #other.mData); local n = BoolArray(#mData); for(local i = 0; i < #mData; ++i) n[i] = mData[i] || other.mData[i]; return n; } function opOrAssign(other) { assert(#mData == #other.mData); for(local i = 0; i < #mData; ++i) mData[i] = mData[i] || other.mData[i]; } function toString() { return mData.toString(); } } local a1 = BoolArray(5); local a2 = BoolArray(5); a1[0] = true; a1[1] = true; a1[2] = true; a2[0] = true; a2[2] = true; a2[4] = true; writefln(" ", a1); writefln(" & ", a2); writefln("___________________________________"); writefln(" ", a1 & a2); writefln(); writefln(" ", a1); writefln(" | ", a2); writefln("___________________________________"); writefln(" ", a1 | a2); writefln(); a1 &= a2; writefln("a1 &= a2 = ", a1);
