Note: This website is archived. For up-to-date information about D projects and development, please visit wiki.dlang.org.

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.

Metamethods, broadly, are a set of methods that are called on objects when certain "special" operations are performed. This covers not only the familiar case of operator overloading, but also other operations such as foreach loop iteration, conversion to a string, and interception of method calls.

Types Which Can Have Metamethods

All types in MiniD can have metamethods. However, only a few can have metamethods defined for them from within MiniD code. For example, char, string, and array have some metamethods defined for them by the standard libraries, 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 have some metamethods defined for them by the standard libraries; 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 }
writeln(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() = format("Table x = ", :x, " y = ", :y)
}

writeln(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() = format("Table x = ", :x, " y = ", :y)

local t = { x = 5, y = 10 }
addToString(t)

writeln(t)

Instance 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 declaration, or by adding them after the fact. This is very similar to how it's done with tables.

class C
{
	x = 5
	y = 10

	function toString() = format("C x = ", :x, " y = ", :y)
}

local c = C()
writeln(c)

Now every instance of C will have the toString metamethod defined for it.

A Note on the "Assign" Metamethods

There are a number of metamethod pairs which have a "getter" version and a "setter" version, where the "setter" is denoted by ending with "Assign". For these metamethods, if the target object is the left-hand-side of an operation assignment, such as a[0] += 5, the following will happen:

  1. a.opIndex(0) is called to retrieve the value which is put into a temporary variable
  2. The operation assignment is performed on the temporary
  3. a.opIndexAssign(0, temp) is called to assign the new value back into a

Note how both opIndex and opIndexAssign are called as part of the same operation. This happens with all pairs of metamethods that look like this.

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]). opIndex is passed the index, 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 in two ways. For tables (and only tables), field access (like t.x) is functionally equivalent to indexing (like t["x"]). For all other types, field access is a separate operation and is overloaded with opField and opFieldAssign instead. The other way a table is special is what happens when the table is indexed. When it 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. Instances will always have their opIndex called when an index expression is used, as they have no built-in definition for the operation.

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. 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)
	writeln(fib[i])

local troll =
{
	function opIndexAssign(index, value)
	{
		if(isInt(index))
			writeln("I don't like int indices.")
		else
			// We use hash.set, or else we'd get infinite recursion trying
			// to add the new key-value pair to this.
			hash.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[..] or 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 built-in arrays.

class FloatArray
{
	mData
	mMin = null
	mMax = null

	this()
		:mData = {}

	function opIndex(index) = :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 = :mMin, hi = :mMax + 1)
	{
		local ret = FloatArray()

		if(#:mData == 0)
			return ret

		foreach(k, v; :mData)
			if(k >= lo && k < hi)
				ret[k] = v

		return ret
	}

	function opSliceAssign(lo = :mMin, hi = :mMax + 1, value)
	{
		if(#:mData == 0)
			return

		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 opField(name: string)

This is a metamethod which is exclusive to instances. Tables, as mentioned before, see field access as the same as indexing, and cannot overload it separately. All other types, however, see field access as separate from indexing, and thus you can overload the two separately for instances.

When opField is called is very similar to how opIndex is called on tables. When you access a field out of an instance like o.x, it looks for a field named "x" in the instance, and if it exists anywhere in the instance or its base class chain, it returns the value of that field. If it doesn't exist, then opField is attempted.

opField takes the name of the field to retrieve as a string and is expected to return any value. An example of use is in the section after the next.

void opFieldAssign(name: string, value)

Just like indexing and slicing, field access has an assignment version as well. opFieldAssign is called on an instance when you assign a field into an instance and that field doesn't already exist. It takes the name of the field as a string, followed by the value that is being assigned into the field.

An example showing how to use opField and opFieldAssign, as well as opMethod is given in the following section.

anything opMethod(name: string, vararg)

This method is just cool. When you attempt to call a method on an object and that method either doesn't exist or isn't a function, you'll get an error. However, sometimes you'd like to intercept this behavior, and that's what opMethod is for. If an instance defines opMethod and you attempt to call a method which doesn't exist, opMethod will be called instead with the method's name as the first parameter and any other parameters following it. opMethod is allowed to return any number of values. In this way opMethod acts as a perfect replacement for the method that was supposed to have been called.

Using the opField, opFieldAssign, and opMethod metamethods, we can now make a mock class, which is a class which pretends like it's another class. However since we are intercepting all the field accesses and method calls, we can do interesting things, like perform logging or convert method calls and field accesses into more interesting operations such as database accesses or remote procedure calls. The mock class we create here is just a simple one which prints some debugging info upon each field access and method call.

class Mock
{
	mObj

	this(obj: instance)
		:mObj = obj

	function opField(name: string)
	{
		writefln("Getting field {}", name)
		return :mObj.(name)
	}

	function opFieldAssign(name: string, value)
	{
		writefln("Setting field {} to {}", name, value)
		:mObj.(name) = value
	}

	function opMethod(name: string, vararg)
	{
		writefln("Calling method {}", name)
		return :mObj.(name)(vararg)
	}
}

class Test
{
	x = 5
	y = 10

	function foo(a, b)
	{
		writefln("a = {}, b = {}", a, b)
		return :x, :y
	}
}

function testObject(o)
{
	writefln("x = {}, y = {}", o.x, o.y)
	o.x = 12

	local x, y = o.foo(3, 4)
	writefln("x = {}, y = {}", x, y)
}

testObject(Test())
writeln()
testObject(Mock(Test()))

This outputs:

x = 5, y = 10
a = 3, b = 4
x = 12, y = 10

Getting field x
Getting field y
x = 5, y = 10
Setting field x to 12
Calling method foo
a = 3, b = 4
x = 12, y = 10

value opCat(other), value opCat_r(other)

These metamethods are invoked when you use the concatenation operator, "~". There are actually two metamethods for this operation. If you do something like "a ~ b" where a has an opCat metamethod, it will call a.opCat(b); however, if a has no opCat metamethod, it will then attempt b.opCat_r(a). This allows you to preserve order when overloading concatenation, as well as allowing you to overload situations such as "hello" ~ x, where the first operand is a built-in type.

Both methods are expected to return one value, which is the concatenation of this with the value that was passed.

Here is a small example.

class MyString
{
	mData

	this(data: string)
		:mData = data

	function opCat(s: string | MyString)
	{
		if(s as MyString)
			return MyString(:mData ~ s.mData)
		else
			return MyString(:mData ~ s)
	}

	function opCat_r(s: string) = MyString(s ~ :mData)

	function toString() = format("<{}>", :mData)
}

local s1 = MyString("hello")
local s2 = s1 ~ " world"  // MyString ~ string
local s3 = "I said: " ~ s2  // string ~ MyString
local s4 = MyString("After that, ") ~ s3  // MyString ~ MyString

writeln(s1) // prints "<hello>"
writeln(s2) // prints "<hello world>"
writeln(s3) // prints "<I said: hello world>"
writeln(s4) // prints "<After that, I said: hello world>"

Notice how the opCat overload takes either a string or a MyString?, but the opCat_r overload only takes a string and no MyString?. This is because if the left-hand side of a concatenation is a MyString?, it'll have opCat called on it and opCat_r won't even be attempted.

void opCatAssign(vararg)

This is the reflexive version of opCat, called when you use the "~=" operator on an object. This method takes a variadic number of arguments instead of just one. 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, so something like "a ~= b + c + d" becomes "a.opCatAssign(b + c + d)". There is one exception to this rule; if you concatenate two string literals or characters, the compiler will always join them together, as the following example shows.

Another small example.

class Set
{
	mData

	this()
		:mData = {}

	function opCatAssign(vararg)
	{
		for(i: 0 .. #vararg)
			:mData[vararg[i]] = true
	}

	function contains(value) = :mData[value] !is null
}

local s = Set()

// Add members to the set with opCatAssign.
// "hi" and "bye" are concatenated by the compiler into "hibye", so this only adds
// two items.
s ~= 3 ~ "hi" ~ "bye"

writeln(s.contains(3))    // prints true
writeln(s.contains(-4.5)) // prints false
writeln(s.contains("hibye")) // prints true

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 strings, tables, arrays, 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 truth value of the result.

Extending the little Set class we made in the opCatAssign example, 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)
	{
		for(i: 0 .. #vararg)
			:mData[vararg[i]] = true
	}

	function opIn(value) = :mData[value] !is null
}

local s = Set()

// Add members to the set with opCatAssign.
// "hi" and "bye" are concatenated by the compiler into "hibye", so this only adds
// two items.
s ~= 3 ~ "hi" ~ "bye"

writeln(3 in s)    // prints true
writeln(-4.5 in s) // prints false
writeln(-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 (<, <=, >, >=) 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 types such as tables and instances makes no sense. You can give meaning to these comparisons by defining an opCmp metamethod.

When overloading opCmp, an operator that very often comes in handy is the three-way comparison operator (<=>). This operator takes two operands, compares them, and returns a comparison integer that is exactly what opCmp should return. The following example uses it.

class Value
{
	mVal

	this(val)
		:mVal = val

	function opCmp(other)
	{
		if(other as Value)
			return :mVal <=> other.mVal
		else
			return :mVal <=> other
	}
}

local v1 = Value(5)
local v2 = Value(10)

if(v1 < v2)
	writeln("less")

if(v1 >= 3)
	writeln("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.

bool opEquals(other)

This metamethod is called when you use the equality or inequality operators (== or !=). Equality is separated from comparison as for some types, ordering makes no sense while equality does (or vice versa); and often equality can be determined more efficiently than ordering. This method should return true if this is equal to other, and false otherwise. Let's just slightly modify the Value class:

class Value
{
	mVal

	this(val)
		:mVal = val

	function opCmp(other)
	{
		if(other as Value)
			return :mVal <=> other.mVal
		else
			return :mVal <=> other
	}

	function opEquals(other)
	{
		if(other as Value)
			return :mVal == other.mVal
		else
			return :mVal == other
	}
}

local v1 = Value(5)
local v2 = Value(10)

if(v1 < v2)
	writeln("less")

if(v1 >= 3)
	writeln("greater or equal")

if(v1 == 5)
	writeln("equal")

if(v1 != v2)
	writeln("not equal")

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)".

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. For a somewhat more exhaustive description of how foreach loops work, see the section on it here.

Here are a few examples of opApply.

// Iterating over arrays is easy.
class Array
{
	mData

	this(length)
		:mData = array.new(length)

	function opIndex(index) = :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.
		// Remember that strings that have the same contents are the same object, so 'is' is
		// sufficient to check for it.
		if(method is "reverse")
		{
			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
		{
			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 a really simple metamethod; you can check it out in a later section.
	function opLength() = #:mData
}

local a = Array(5)

for(i: 0 .. #a)
	a[i] = i + 1

// Loop through normally.
foreach(i, v; a)
	writefln("a[{}] = {}", i, v)

writeln()

// Loop through in reverse.
foreach(i, v; a, "reverse")
	writefln("a[{}] = {}", i, v)

writeln()

// 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) = :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(method is "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.
			function iterator()
			{
				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 and the container to iterate (this).
			// We're not using the index, so we don't have to return anything.
			return iterator, this
		}
		else
		{
			// Regular iteration.
			local index = 0
			local keys = :mData.keys()

			function iterator()
			{
				if(index < #keys)
				{
					local key = keys[index]
					++index
					return key, :mData[key]
				}

				return null
			}

			return iterator, this
		}
	}
}

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)

writeln()

// 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)
	{
		for(i: 0 .. #vararg)
		{
			local func = vararg[i]

			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.
		// We have to put parens around :mOnClick or else it'll be parsed
		// as a method call.
		(: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)

any opLength(), void opLengthAssign(any)

You can overload the length (#) operator with these two metamethods. opLength is called when the length is to be retrieved and should return one value; opLengthAssign is called when the length is to be set, takes one value, and returns nothing. They're very simple. A contrived example follows.

class O
{
	mLen = 0

	function opLength() = :mLen
	function opLengthAssign(l: int) :mLen = l
}

local o = O()
writeln(#o) // calls opLength and prints 0
#o = 5 // calls opLengthAssign
writeln(#o) // prints 5

Unary Metamethods

There are a few unary metamethods which only take one parameter, the object on which they're operating. They are as follows:

  • opNeg (-a)
  • opCom (~a)
  • opInc (++a or a++)
  • opDec (--a or a--)

All of these methods take no parameters (besides this). opNeg and opCom may return any value; opInc and opDec do not return a vlue.

A contrived example follows.

class Int
{
	mVal

	this(v: int)
		:mVal = v

	function opNeg() = Int(-:mVal)
	function opCom() = Int(~:mVal)

	function opInc() :mVal++
	function opDec() :mVal--

	function toString() = toString(:mVal)
}

local i = Int(5)
writeln(i)
writeln(-i)
writeln(~i)
i++
writeln(i)
i--
writeln(i)

Binary Metamethods

There are many binary operators which all behave in very similar ways. Here is a table documenting each operator and its corresponding metamethods.

OperatorCommutative?MetamethodReverse
+yesopAddopAdd_r
- opSubopSub_r
*yesopMulopMul_r
/ opDivopDiv_r
% opModopMod_r
&yesopAndopAnd_r
|yesopOropOr_r
^yesopXoropXor_r
<< opShlopShl_r
>> opShropShr_r
>>> opUShropUShr_r

Now, to interpret this table, we need to know how binary metamethod lookup happens when we encounter an expression that needs to call metamethods. There are two cases: commutative operators and noncommutative operators.

Suppose we have an expression such as "a + b". Addition is a commutative operation according to the above table. The metamethod lookup works as follows for addition as well as for all commutative binary operations:

  1. a.opXxx(b)
  2. b.opXxx_r(a)
  3. a.opXxx_r(b)
  4. b.opXxx(a)

So for addition, it would try a.opAdd(b), then b.opAdd_r(a), then a.opAdd_r(b), and finally b.opAdd(a). If it attempts all four and fails, it's an error.

What does commutativity have to do with the lookup? Let's contrast it with the metamethod lookup for something like subtraction, which isn't commutative:

  1. a.opXxx(b)
  2. b.opXxx_r(a)

Now you can see that for noncommutative operations, it doesn't attempt the last two steps; that is, it doesn't call metamethods in the "wrong" order.

All of these operations take one parameter which is the other operand of the operation, and are expected to return one value.

Here is a simple example.

class Vec2
{
	mX = 0.0
	mY = 0.0

	this(x: int|float = 0.0, y: int|float = 0.0)
	{
		:mX = toFloat(x)
		:mY = toFloat(y)
	}

	function opAdd(other: int|float|Vec2)
		if(other as Vec2)
			return Vec2(:mX + other.mX, :mY + other.mY)
		else
			return Vec2(:mX + other, :mY + other)

	function opSub(other: int|float|Vec2)
		if(other as Vec2)
			return Vec2(:mX - other.mX, :mY - other.mY)
		else
			return Vec2(:mX - other, :mY - other)

	function opSub_r(other: int|float|Vec2)
		if(other as Vec2)
			return Vec2(other.mX - :mX, other.mY - :mY)
		else
			return Vec2(other - :mX, other - :mY)

	function toString() = format("<{}, {}>", :mX, :mY)
}

local v1 = Vec2(1, 2)
local v2 = Vec2(3, 4)

writeln("     v1 = ", v1)
writeln("     v2 = ", v2)
writeln("v1 + v2 = ", v1 + v2) // v1.opAdd(v2)
writeln(" 3 + v1 = ", 3 + v1)  // v1.opAdd(3)
writeln(" v1 - 5 = ", v1 - 5)  // v1.opSub(5)
writeln(" 5 - v1 = ", 5 - v1)  // v1.opSub_r(5)

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 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 vector example, expanded a bit.

class Vec2
{
	mX = 0.0
	mY = 0.0

	this(x: int|float = 0.0, y: int|float = 0.0)
	{
		:mX = toFloat(x)
		:mY = toFloat(y)
	}

	function opAdd(other: int|float|Vec2)
		if(other as Vec2)
			return Vec2(:mX + other.mX, :mY + other.mY)
		else
			return Vec2(:mX + other, :mY + other)

	function opSub(other: int|float|Vec2)
		if(other as Vec2)
			return Vec2(:mX - other.mX, :mY - other.mY)
		else
			return Vec2(:mX - other, :mY - other)

	function opSub_r(other: int|float|Vec2)
		if(other as Vec2)
			return Vec2(other.mX - :mX, other.mY - :mY)
		else
			return Vec2(other - :mX, other - :mY)

	function opAddAssign(other: int|float|Vec2)
	{
		if(other as Vec2)
		{
			:mX += other.mX
			:mY += other.mY
		}
		else
		{
			:mX += other
			:mY += other
		}
	}

	function toString() = format("<{}, {}>", :mX, :mY)
}

local v1 = Vec2(1, 2)
local v2 = Vec2(3, 4)

writeln("     v1 = ", v1)
writeln("     v2 = ", v2)
writeln("v1 + v2 = ", v1 + v2)
writeln(" 3 + v1 = ", 3 + v1)
writeln(" v1 - 5 = ", v1 - 5)
writeln(" 5 - v1 = ", 5 - v1)

local v3 = Vec2(5, 10)
writeln("     v3 = ", v3)
v3 += 0.5 // v3.opAddAssign(0.5)
writeln("     v3 = ", v3)