Thread overview
templated toString and class inheritance
Dec 25, 2022
cc
Dec 25, 2022
cc
Dec 25, 2022
cc
December 25, 2022

toString is a vitally important (IMO) method for relaying text information about an object to the user, at least during the development and debugging stages of an application but quite often in production as well. The basic version usable by the standard library potentially allocates (apologies to this thread, which got me thinking about this again, for borrowing the class names):

class Animal {
	override string toString() {
		return "Animal()";
	}
}
class Dog : Animal {
	override string toString() {
		return "Dog()";
	}
}
class Bulldog : Dog {
	bool sunglasses;
	override string toString() {
		return format("Bulldog(cool: %s)", sunglasses);
	}
}

Now, even the most diehard GC enthusiast should agree that situations can arise where you might potentially be calling toString hundreds of times per frame in a high-intensity application loop, and allocating every single time is highly undesirable. I won't bother discussing issues of caching values as the specific use cases and complexity aren't relevant here. Fortunately, std.format provides a non-[necessarily-]allocating alternative:

import std.format;
import std.range.primitives; // Mandatory, see aside
class Animal {
	void toString(W)(ref W writer) if (isOutputRange!(W, char)) {
		writer.formattedWrite("Animal()");
	}
}
class Dog : Animal {
	void toString(W)(ref W writer) if (isOutputRange!(W, char)) {
		writer.formattedWrite("Dog()");
	}
}
class Bulldog : Dog {
	bool sunglasses;
	void toString(W)(ref W writer) if (isOutputRange!(W, char)) {
		writer.formattedWrite("Bulldog(cool: %s)", sunglasses);
	}
}

However, we have a problem:

void main() {
	auto animals = [new Animal, new Dog, new Bulldog];
	foreach (animal; animals) {
		animal.writeln;
	}
}
Animal()
Animal()
Animal()

As this function is templated, it cannot be virtual as I understand it, and thus we have this problem of determining the correct toString to call when the type is known only at runtime.

Current solutions feel somewhat clumsy, and involve for example manually registering all such relevant classes and proxying toString with a handler function that identifies an object by its runtime typeid and looking up the relevant correct function to call. Automatically determining which classes to register can be difficult, especially if classes inherit each other across multiple files or are added at a later date, and increase complexity and programmer responsibility to maintain.

Given how important toString is, it would be great if there were some kind of special case or path the compiler could use to facilitate this (though a more general-purpose solution would also be interesting). I briefly thought "Wouldn't it be nice if TypeInfo_Class could store a reference to the class's matching toString function that could be called at runtime?", but given that it's a template, that's a no go. Are there better ways to handle this? Have ideas/best practices for working around templates and virtual functions or things like that been discussed before? It feels like something that should be so simple, conceptually, but despite bordering on a number of D's great features there doesn't seem to a be a simple fire-and-forget solution.

I have come up with one solution, which I will attach in the next post. Hopefully I haven't missed something completely obvious with all this.

December 25, 2022

On Sunday, 25 December 2022 at 19:10:13 UTC, cc wrote:

>

I have come up with one solution, which I will attach in the next post.

Here is what I am playing with currently. I don't know if it's the best way to handle the situation. Redundant checks end up being made. Given that there are two levels of indirection that need to be considered (What OutputRange template is being passed around? and What is the runtime type of the object being written?), I had to get a little creative, I think. Since we can't cleanly anticipate which matches of toString(W)..isOutputRange will be used (and could be a voldemort type) I have incorporated the registering of classes directly into the toString handler, but this can still potentially fail if derived classes are spread into different files and not called first.

module util.tostringmaster;
import std.string;
import std.traits;
import std.format;
import std.range.primitives;

// TODO: Make sure toString template is actually compatible with the desired W
//       Proper error handling/fallback if matching toString doesn't exist

abstract final class ToStringMaster {
	private:
	static abstract class ToStringBase {}
	static abstract class ToStringWriter(W) : ToStringBase {
		void writeWith(ref W writer, Object obj);
	}
	static final class ToStringHolder(W, T) : ToStringWriter!W {
		override void writeWith(ref W writer, Object obj) {
			T t = cast(T) obj;
			t.toString(writer);
		}
	}
	static ToStringBase[string][string] table;
	static void[0][string] writerTable;
	static void registerAll(W, string MOD)() {
		enum WNAME = fullyQualifiedName!W;
		enum MNAME = WNAME~":"~MOD;
		//pragma(msg, "REGISTERALL "~MNAME);
		if (MNAME in writerTable)
			return;
		writerTable.require(MNAME);
		{
			mixin(`import `~MOD~`;`);
			mixin(`alias ALL = __traits(allMembers, `~MOD~`);`);
			static foreach (sym; ALL) {{
				mixin(`alias SYM = __traits(getMember, `~MOD~`, "`~sym~`");`);
				static if (is(SYM == class)) {
					alias CLASS = SYM;
					alias TOSTRING = __traits(getMember, CLASS, "toString");
					static if (__traits(isTemplate, TOSTRING)) {
						register!(W, CLASS);
					}
				}
			}}
			// Explicit class registration
			//register!(W, Animal);
			//register!(W, Dog);
			//register!(W, Bulldog);
		}
	}
	static void register(W, T)() {
		enum WNAME = fullyQualifiedName!W;
		enum TNAME = fullyQualifiedName!T;
		table.require(WNAME);
		table[WNAME][TNAME] = new ToStringHolder!(W, T);
	}
	static void redirect(W, T)(ref W writer, T obj) {
		enum WNAME = fullyQualifiedName!W;
		static if (!(WNAME.indexOf("__lambda") >= 0)) { // Don't register hasToString, etc
			registerAll!(W, moduleName!(T));

			scope auto tname = typeid(obj).name;
			if (auto wp = WNAME in table) {
				if (auto p = tname in *wp) {
					auto tsh = cast(ToStringWriter!W) *p;
					assert(tsh, "Invalid ToStringWriter: "~WNAME);
					tsh.writeWith(writer, obj);
					return;
				}
			}
			writer.formattedWrite("<Unknown:%s>", tname);
		}
	}
}

// Mixin alternative instead of delegates
/* enum string ToStringReal = q{
	static if (!(fullyQualifiedName!W.indexOf("__lambda") >= 0)) { // hasToString
		static assert(__FUNCTION__.endsWith(".toString"), "Only include this mixin in toString");
		if (typeid(this) !is typeof(this).classinfo) {
			ToStringMaster.redirect(writer, this);
			return;
		}
	}
}; */

void realToStringOr(T,W)(T obj, ref W writer, scope void delegate() origDG = null) if (isOutputRange!(W, char)) {
	if (typeid(obj) !is T.classinfo) {
		ToStringMaster.redirect(writer, obj);
		return;
	}
	if (origDG !is null) origDG();
}
class Animal {
	void toString(W)(ref W writer) if (isOutputRange!(W, char)) {
		realToStringOr(this, writer, {
			writer.formattedWrite("Animal()");
		});
	}
}
class Dog : Animal {
	void toString(W)(ref W writer) if (isOutputRange!(W, char)) {
		realToStringOr(this, writer, {
			writer.formattedWrite("Dog()");
		});
	}
}
final class Bulldog : Dog {
	bool sunglasses;
	void toString(W)(ref W writer) if (isOutputRange!(W, char)) {
		// We don't need realToStringOr if we know for certain this class will never be derived, but...
		writer.formattedWrite("Bulldog(cool: %s)", sunglasses);
	}
}
void main() {
	auto animals = [new Animal, new Dog, new Bulldog];
	foreach (animal; animals) {
		animal.writeln;
	}
}
Animal()
Dog()
Bulldog(cool: false)
December 25, 2022

On Sunday, 25 December 2022 at 19:10:13 UTC, cc wrote:

>

Fortunately, std.format provides a non-[necessarily-]allocating alternative:

import std.format;
import std.range.primitives; // Mandatory, see aside

Aside: importing std.range.primitives is necessary to use the templated toString version, but the compiler error for failing to do so is highly unintuitive as to this simple requirement:

C:\D\dmd2\windows\bin\..\..\src\phobos\std\format\internal\write.d(2440): Error: template instance `std.format.internal.write.formatObject!(LockingTextWriter, Animal, char)` does not match template declaration `formatObject(Writer, T, Char)(ref Writer w, ref T val, ref scope const FormatSpec!Char f)`
  with `Writer = LockingTextWriter,

       T = test.Animal,

       Char = char`

  whose parameters have the following constraints:

  `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`

`  > hasToString!(T, Char)

`  `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`
  Tip: not satisfied constraints are marked with `>`
C:\D\dmd2\windows\bin\..\..\src\phobos\std\format\write.d(1239): Error: template instance `std.format.internal.write.formatValueImpl!(LockingTextWriter, Animal, char)` error instantiating
C:\D\dmd2\windows\bin\..\..\src\phobos\std\format\write.d(632):        instantiated from here: `formatValue!(LockingTextWriter, Animal, char)`
C:\D\dmd2\windows\bin\..\..\src\phobos\std\stdio.d(1710):        instantiated from here: `formattedWrite!(LockingTextWriter, char, Animal)`
C:\D\dmd2\windows\bin\..\..\src\phobos\std\stdio.d(4227):        instantiated from here: `write!(Animal, char)`