DConf in session, some more exception experimentation from me

Posted 2022-08-01

DConf is this week, so nothing to say about it until after it happens (though I'll be doing some comments on the chat rooms, likely after the fact given the timezone shift and other business - i still have to work!), but i'll show an experimental exception concept I've been playing with again.

Core D Development Statistics

In the community

Community announcements

See more at the announce forum.

Exception template concept

Background

I was on chat again (what a surprise lol) discussing my position that strings are bad, and you should put information in variables instead. A person countered that this requires making new types, and making new types is a hassle.

Well, creating new types is actually pretty easy in D: we often do it without even thinking about it, with the return value of implicitly instantiated templates, like with the std.algorithm pipelines.

I made an experimental module, arsd.exception, a while ago to demonstrate some of this. My big focus there was improving on enforce and the module isn't entirely usable, just a concept demo (yes, a bit silly to give it the full arsd treatment then, but meh). I'm now looking at just making new exceptions with data attached.

Usage

Let me show you the potential usage:

import exception2;

enum MyError;
enum OtherError;

void main() {
	int a;
	try {
		throw Exception2!MyError(a, "more info");
	} catch(Exception2!(OtherError) e) {
		// won't catch this, since it is another error
		import std.stdio;
		writeln("wrong");
	} catch(Exception2!(MyError, int) e) {
		// will catch this though!
		import std.stdio;
		writeln("CAUGHT!");
		writeln(e);
	}
}

You might notice that I threw both an int and a string, but caught only the int. The idea here is all the details you add are just further specializations, you can process them or not, depending how much of the data you're interested in. You can just catch(Exception2!MyError) if you don't care about the attached data too.

Of course, another option here would be to statically list the data in a struct MyError instead of enum MyError. On the other hand, you could throw away all the namespaced type differentiation and just use Exception2!("some string", data):

import exception2;
void main() {
        int a;

        try {
		// string error type instead of namespaced D type
		// can still attach information though
                throw Exception2!"foo bar"(a);
        } catch(Exception2!"foo bar") { // caught by string
                import std.stdio;
                writeln("caught");
        }
}

I don't like this much myself, since you might use the same string as a different library, and they would be no way to distinguish them. With an enum, you can differentiate through module import and namespace rules.

But the advantage here is that there's nothing declared ahead of time at all - the new exception class is declared right at the throw point, while still being able to be used again at the catch point to identify it.

While I don't love this style, it is at least as convenient (often more!) as the common use of throw new Exception("some string"), and still gives benefits of easy-to-attach data.

Now, you might argue that different data attachments should always be a different exception family anyway, which would lean toward just declaring a new class anyway, which can be quite easy to do with a mixin template giving you some of the boilerplate. So if you're gonna declare a struct, or even an enum, you might as well declare the associated data there and this whole scheme doesn't gain much.

However, I still think the enum strikes a good middle ground: you get to declare a static list of exception families, then add data as needed. Consider a InvalidValue family: the attached info might give int valueGiven, int maxValue or it might give int valueGiven, int minValue, int maxValue. Or maybe string valueGiven, string expectedPattern. So one family might reasonably have different values attached.

Of course, this example also gives a reason why you might want some kind of structured data: if you just got two int values, which one was valueGiven and which maxValue? Or were two values given (e.g. a matrix coordinate)? But, even using this scheme, you can still throw Exception2!InvalidValue(MyStruct(structured, information, here);, and people can either catch(Exception2!InvalidValue) or catch(Exception2!(InvalidValue, MyStruct)) and capture the family or the details. So it'd still work, just the argument that you need to declare struct MyStruct {} vs class InvalidValueExceptionWithMyStruct : InvalidValueException { MyStruct data; mixin ExceptionCtor;s } isn't quite as compelling.

...just quite frankly, the status quo of D is we rarely see any of this. We most often just see throw new Exception("invalid value"); - with no type to catch and often, no information about the value at all either. Any of my Exception2 schemes are better than the typical Exception we see in practice today.

The implementation

People in the chat when I was first showing it saw the throw Exception2... and assumed there was something to do with @nogc in here. While perhaps you could, that's a separate topic that I don't want to talk about again today. No, my implementation still uses new at some point, so why is it not at the usage site?

It is actually because I want to use that IFTI - implicit function template instantiation - to attach data, which means a new subclass needs to be created. You can't do that with a normal constructor call. But you can do it with an opCall!

Moreover, I also wanted to make sure the derived class could be named outside the declaration point, so people can easily catch the exceptions. This rules out anonymous classes (ok, not exactly ruled out, you could make it work, but it gets messier), but still worked with the opCall.

Let's dig into the code:

1 module exception2;
2 
3 /+
4 	It uses the long-form template writeup because I want to define the Parent separately
5 	and this just makes it a bit easier.
6 +/
7 template Exception2(alias Type, T...) {
8 	// Each piece of added data is a specialization of the more general
9 	// thing. This is realized by making the parent class just be the same
10 	// thing but with one piece chopped off.
11 	//
12 	// Or fallback to Exception as the generic parent of all Exception2s.
13 	static if(T.length)
14 		alias Parent = Exception2!(Type, T[0 .. $-1]);
15 	else
16 		alias Parent = Exception;
17 
18 	class Exception2 : Parent {
19 		// this should probably be named differently or at least const or something
20 		// but it holds the data passed at the throw point
21 		T t;
22 
23 		// This is the main entry point for throwing - notice how it takes the
24 		// arguments and forwards them to a new class - just like the factory pattern
25 		// often used in Phobos to construct, e.g., a MapResult from a map().
26 		//
27 		// If you tried to use `new Exception2` directly, you'd have to specify all those
28 		// types which you are about to pass, but with the opCall, the `R` is implicitly inferred.
29 		//
30 		// Note the inferred return value is the full static type passed down.
31 		static opCall(R...)(R r, string file = __FILE__, size_t line = __LINE__) {
32 			return new Exception2!(Type, T, R)(r, "", file, line); // that string could hold something i guess
33 		}
34 
35 		// you wouldn't call this directly! I might even be able to make it `protected`
36 		this(T t, string msg, string file = __FILE__, size_t line = __LINE__) {
37 			this.t = t;
38 			static if(is(Parent == Exception))
39 				super(msg, file, line);
40 			else
41 				super(t[0 .. $-1], msg, file, line);
42 		}
43 
44 		// this is basically the same as the old arsd.exception
45 		//nothing too special here
46 		override void toString(scope void delegate(in char[]) sink) const {
47 
48 			import std.conv;
49 
50 			sink(typeid(this).name); // FIXME: eponymous things so long of a name
51 
52 			sink("@");
53 
54 			sink(file);
55 			sink(":");
56 			sink(to!string(line));
57 
58 			sink("\n");
59 
60 			// because I love this phrasing lol
61 			// just a joke though
62 			sink("The program has performed an illegal operation and will be shut down.");
63 
64 			// this part is real though: when printing, loop through the attached data
65 			// and show them all
66 			foreach(idx, item; t) {
67 				sink("\n");
68 				sink(typeof((cast() this).t[idx]).stringof);
69 				sink(" = ");
70 				sink(to!string(item));
71 			}
72 
73 			if(info) {
74 				try {
75 					sink("\n----------------");
76 					foreach (t; info) {
77 						sink("\n"); sink(t);
78 					}
79 				}
80 				catch (Throwable) {
81 					// ignore more errors
82 				}
83 			}
84 		}
85 	}
86 }

This is the flexible class that lets you both define catch(Exception2!X) as well as throw Exception2!X(data) - the basic thing declares an opCall which returns a further derived object, constructed for you. Not every day you see static opCall on a class, but it is a pattern I've used before (see my old entry on declarative guis in D for another use) and it works quite well.

I'm pretty happy with this result. You get the convenience of throw new Exception("stuff") with far more structure, without giving you as much hassle in declaring it. I want to pursue this further; perhaps starting to use it in some way in the arsd libs.