Metamethods

So now you've seen how to create and use classes, which are actually pretty simple. But the interesting thing you can do with your user-defined types is to make them behave like other types. You do so by using a concept known as metamethods. Metamethods are special methods that can be called directly, but which are meant to be called during built-in operations, such as arithmetic operations or function calls. Metamethods encompass what is typically referred to as "operator overloading" in static languages such as C++, D, and C#, but are more general in that there are metamethods for operations that don't necessarily have an associated "operator."

This is also where classes and instances differ greatly: classes do not participate in metamethods, while instances do. This way, instances can be made to behave just like built-in types, but you don't have to worry about these special behaviors affecting the way classes work.

The other type that can participate in metamethods is the table type. If you're familiar with Lua, MiniD's table metamethod support is a bit different. There is no metatable; instead, you just put the metamethods directly into the table. This makes tables less flexible, but they don't really need to be, since there are other types which pick up where they leave off. For the most part, this section will concentrate on using metamethods with instances, but know that almost everything that you can do with instance metamethods, you can also do with tables.

This section is not meant to be an exhaustive listing of all metamethods and what you can use them for. Rather, it's supposed to explain some of the concepts behind metamethods. For an exhaustive listing, see the metamethods section of the language spec.

A Simple Metamethod

A good way to start using metamethods is by defining a really simple (and common) one.

When you print out MiniD values, a lot of the time the output is kind of ugly. For example, the following:

writeln(Object)

prints out something like "class Object (0x00D4E6F4)". This tells you the class name and its memory address (useful for comparing identity), but a lot of the time, you'll want to be able to print out a readable representation of your objects.

Consider an interval bounded by two numbers.

class Interval
{
	start
	end

	this(start: float, end: float)
	{
		:start = start
		:end = end
	}
}

local i = Interval(2.0, 3.5)
writeln(i) // prints something like "instance of Interval (0x00DB8818)"

Ick. That output isn't very helpful. It'd be nice if the interval looked more like "[2.0 .. 3.5]" when we printed it out. This is easy to do - we just define a "toString" method in the class.

class Interval
{
	start
	end

	this(start: float, end: float)
	{
		:start = start
		:end = end
	}
	
	function toString() = format("[{} .. {}]", :start, :end)
}

local i = Interval(2.0, 3.5)
writeln(i) // prints "[2.0 .. 3.5]"

So what's going on? It's actually really simple. 'toString' is a metamethod that is called when an object is converted to a string. This happens when you print out an object or when you use the 'toString' function defined in the base library. Nothing special has to be done to "bless" a metamethod or anything like that. You just define it in the class, and whenever the metamethod is needed, it'll be looked up and used.

Let's move on to a slightly more challenging operation - the length operator.

Overloading the Length Operator

The length operator (#) lets you get or set the length of an object. Normally it's only defined for strings, arrays, tables, and namespaces, but you can define it for your own types as well.

Getting and setting the length are handled by two separate methods. "opLength" gets the length, while "opLengthAssign" sets it.

A couple notes: one, most metamethods begin with "op". "toString" is the only exception. Two, there are a few "getter" and "setter" pairs of metamethods, and the setter's name is always the getter's with "Assign" at the end. That being said, let's move on.

"opLength" is a simple metamethod. All it's expected to do is return some value. The value doesn't even have to be an int; it can be any type. Consider the following:

class Interval
{
	start
	end
	
	this(start: float, end: float)
	{
		:start = start
		:end = end
	}

	function toString() = format("[{} .. {}]", :start, :end)
	
	function opLength() = :end - :start
}

local i = Interval(2.0, 3.5)
writeln(#i) // prints 1.5

The length of the interval is just the distance between the start and end. opLength is easy enough to overload.

The other method, "opLengthAssign", is called when the length of an object is set. For example, something like "#i = 3.4" would attempt to call opLengthAssign, but would currently fail since there is none defined for Interval. So let's define it.

class Interval
{
	start
	end

	this(start: float, end: float)
	{
		:start = start
		:end = end
	}

	function toString() = format("[{} .. {}]", :start, :end)

	function opLength() = :end - :start

	function opLengthAssign(newLength: float)
		:end = :start + newLength
}

local i = Interval(2.0, 3.5)
writeln(#i) // prints 1.5
#i = 3.4
writeln(#i) // prints 3.4

Easy enough! The "opLengthAssign" metamethod just takes the new length as its only parameter, and isn't expected to return anything.

Okay, there's one more case to consider. What if you do something like "#i -= 1.2"? That is, you want to reduce the length by 1.2. Actually, you don't have to do anything special here. Observe:

#i -= 1.2
writeln(#i) // prints 2.2

How'd it know how to do that? Simple. The opLength and opLengthAssign metamethods work as a pair. When you do "#i -= 1.2", it's the same as doing "#i = #i - 1.2". So it calls opLength, subtracts 1.2 from the return value, and then passes the result as the parameter to opLengthAssign.

"opLength" and "opLengthAssign" aren't unique in this regard. All of the "paired" metamethods work this way. These also include "opField"/"opFieldAssign", "opIndex"/"opIndexAssign", and "opSlice"/"opSliceAssign". If you want more info on those metamethods, see the metamethods section of the language spec.

A Binary Operation

The length operator is a unary operator; that is, it only operates on a single value. But there are also a lot of binary operations, such as arithmetic and bitwise operators. Continuing with our little Interval class, we might want to be able to add a scalar to an interval, which would translate the interval. So let's overload addition.

Addition is overloaded with the "opAdd" metamethod. When you do something like "a + b", where 'a' and 'b' don't have any meaning for addition, what happens is that it will attempt to call "a.opAdd(b)". That is, the opAdd metamethod expects one parameter - the other addend - and is expected to return a value. Most binary metamethods are the same way.

So let's add an add metamethod:

class Interval
{
	start
	end

	this(start: float, end: float)
	{
		:start = start
		:end = end
	}

	function toString() = format("[{} .. {}]", :start, :end)

	function opLength() = :end - :start

	function opLengthAssign(newLength: float)
		:end = :start + newLength

	function opAdd(v: float) = Interval(:start + v, :end + v)
}

local i = Interval(2.0, 3.5)
writeln(i) // prints "[3.0 .. 3.5]"
writeln(i + 4.0) // prints "[6.0 .. 7.5]"

Notice the last line. When we write "i + 4.0", it will actually call "i.opAdd(4.0)", and the result is a new interval that is shifted up by 4.0.

Actually, something like "4.0 + i" would work as well. It would still call "i.opAdd(4.0)". This is because addition is commutative, and metamethods respect commutativity. Then what about a non-commutative operation such as subtraction? Simple:

class Interval
{
	start
	end

	this(start: float, end: float)
	{
		:start = start
		:end = end
	}

	function toString() = format("[{} .. {}]", :start, :end)

	function opLength() = :end - :start

	function opLengthAssign(newLength: float)
		:end = :start + newLength

	function opAdd(v: float) = Interval(:start + v, :end + v)
	function opSub(v: float) = Interval(:start - v, :end - v)
}

local i = Interval(2.0, 3.5)
writeln(i) // prints "[3.0 .. 3.5]"
writeln(i + 4.0) // prints "[6.0 .. 7.5]"
writeln(4.0 + i) // prints "[6.0 .. 7.5]"
writeln(i - 2.0) // prints "[0.0 .. 1.5]"
writeln(2.0 - i) // error!

The very last line fails. Well what now? What if we want to define such an operation - a scalar minus an Interval? Thankfully, MiniD provides a means to do so - through the "reverse" metamethods. For each binary metamethod "opSomething", there is a "reverse" version named "opSomething_r". We could define opAdd_r, for example, in which case "4.0 + i" would call "i.opAdd_r(4.0)", if we wanted to treat addition in one direction differently from addition in the other. And in the case of subtraction, we can define "opSub_r" so that "2.0 - i" becomes "i.opSub_r(2.0)".

// in Interval
function opSub_r(v: float) = Interval(v - :start, v - :end)
...

writeln(2.0 - i) // prints "[0.0 .. -1.5]"

Now it works like we expect.

A Reflexive Metamethod

The last thing I'll show here is a reflexive metamethod. Say we want to be able to shift our Interval in-place, that is, without creating a new Interval object. Simple - we'll just overload "+=". This is overloaded with "opAddAssign". For that matter, for any binary metamethod "opSomething", there is an "augmentation" version named "opSomethingAssign".

// in Interval
function opAddAssign(v: float)
{
	:start += v
	:end += v
}
...

local i = Interval(2.0, 3.5)
writeln(i) // prints "[2.0 .. 3.5]"
i += 2.3
writeln(i) // prints "[4.3 .. 5.8]"

For More Information

This is by no means an exhaustive guide to metamethods. This is just meant to show some of the basic capabilities and ideas behind them. For a much more complete guide, see the language spec section on metamethods.

Up next is how to throw and handle errors in MiniD, through exceptions.