Tip on DIY closures

Posted 2021-03-01

LDC 1.25 was released last week which has another template optimization that can make a big difference in binary size and compile+link time, and a quick tip on how to do your own closures.

Core D Development Statistics

In the community

Community announcements

See more at the announce forum.

Tip of the Week

The compiler will automatically create closures for you if needed:

int delegate() makeClosure(int x) {
	return () { return x++; };
}

void main() {
	auto dg = makeClosure(5);
	assert(dg() == 5);
	assert(dg() == 6);
}

The makeClosure function, since it returns a function that references local variables, is automatically handled by the compiler to extend the lifetime of the referenced locals. (In some cases, it copies them, but the compiler, since it knows ahead of time, usually just creates the variables in the correct place up front. But regardless, it makes it work automatically.)

However, not all delegates are closures. They can also be pointers to objects when used with a member function:

class A { int foo() { return 5; } }
int delegate() notQuiteAClosure(A a) {
	return &a.foo;
}
void main() {
	A a = new A();
	auto dg = notQuiteAClosure(a);
	assert(dg() == 5);
	// and since the delegate points directly to the object instance ...
	assert(dg.ptr is cast(void*) a);
}

This gives us a way to do DIY closures: just make a struct that holds the values you want.

void* malloced;

int delegate() makeMallocClosure(int x) {
	static struct Captures {
		int x;

		int opCall() {
			return x++;
		}
	}

	import core.stdc.stdlib;
	Captures* captures = cast(Captures*) malloc(Captures.sizeof);
	captures.x = x;

	malloced = cast(void*) captures; // save just for comparison later

	return &captures.opCall;
}

void main() {
	auto dg = makeMallocClosure(5);
	assert(dg() == 5);
	assert(dg() == 6);

	import core.stdc.stdlib;

	// it now refers to the malloced pointer from before!
	assert(dg.ptr is malloced);

	// meaning we can (and in fact, must) free it manually
	free(dg.ptr);
}

You might notice that there's two problems here: one is that it is a bit verbose, and the second, more serious problem is that all three delegates there are identical. That's great for interop - you can pass any delegate from any source to any function that expects a delegate... but it also means you can't know how to free it from type information. You can't do refcounting or introspection either, since it is just a func pointer, no further interface is available at all.

If you just assume it is garbage collected, of course the big benefit is you don't need to know where it came from (well unless it was an address of a struct on the stack... but you should only pass those to functions accepting delegates with the scope attribute. Which btw will also hint to the compiler's automatic capture that it need not worry about lifetime, so it will leave locals on the stack too, it can be a nice little optimization if your function accepts a delegate it calls immediately and/or never stores.).

But if you want a more complex lifetime and accept DIY delegates, you've probably got to document what you do. Your receiving function might take a free delegate as well. Your providing function will have to explain that it was malloced (or whatever) in its documentation so the user isn't completely taken by surprise.

And there is a certain point where you'd actually want to skip the delegate and instead just return your Captures struct itself - which means you can capture by value on the stack or automatically refcounted among other thing, but the cost is any receiving function must be templated to accept the various types - or use an interface instead of a delegate so it encapsulates other functions you can call to release the memory, e.g. manual reference counting COM style.

Once nice thing about returning the struct is many templates already accept it - thanks to the opCall name it is considered a functor and passes Phobos' isCallable test, and you can always &it.opCall to extract the delegate again. I'd encourage the practice of returning a functor like this instead of the direct delegate if you are aiming to be compatible with varied allocation strategies.

As to the syntax, if you take a page from the compiler and just create the variable where you want it, you can simplify a little:

auto demo() {
	int notCaptured;
	static struct Captures {
		int captured;

		int opCall() { return captured; }
	}

	Captures captures; // or malloc if you like

	with(captures) {
		notCaptured = 12;

		captured = 34;
	}

	return captures;
}

Using that with statement gives the illusion that both captured and non-captured are equally local and saves you from having to declare the value twice and pass it to a constructor. It is still more verbose than letting the compiler capture it for you (or, granted, C++'s newish lambda syntax, which is sugar for this same pattern)... but not a whole lot more, so I do think you should consider it when appropriate.

Of course you can also do wrapper functions and other magic to change syntax but I think such isn't worth the hassle.