On fullyQualifiedName

Posted 2023-01-23

I've been saying for a few years now that fullyQualifiedName is a useless strawman. As it is being enshrined in the language as a new trait this week, it is apropos to go over this again. I also touch about alias in error messages.

Core D Development Statistics

In the community

Community announcements

See more at the announce forum.

fullyQualifiedName

std.traits.fullyQualifiedName is completely useless. Yet, it is often discussed and I call this a strawman, and examining this reveals problems in D's development methodology. Again.

So, what to people claim it is good for and why are they wrong?

The claims of usefulness

The few replies to the thread asking for current users all suggested a total of three uses:

Unique identifiers

One use is an arbitrary, repeatable, and unique identifier for types. fullyQualifiedName is technically underspecified and thus not repeatable, since it is liable to change without notice, but in practice it does do this job.

But there's several alternatives to this, most notably including the built-in .mangleof property, which is defined by the D ABI for exactly the purpose of uniquely identifying types in a repeatable way. Indeed, a reasonable implementation of fullyQualifiedName would be demangle(T.mangleof); they encode the same information, and mangleof does so more efficiently!

Mixins

The most common claim of fullyQualifiedName's usefulness is when generating code for mixin. I've been writing about better ways to do this for years:

  1. September 17, 2015: "always use local names in string mixins", Blog post and Stack Overflow post
  2. February 21, 2016: Blog post on minimizing string mixins
  3. May 28, 2017: Blog post showing mixing in string mixins immediately
  4. June 17, 2019: Forum post on using local names
  5. December 26, 2022 Blog post specifically on fullyQualifiedName

There's a few other posts that are arguably related too, but these specifically talk about the problem here. People often reach for T.stringof, find an error, and hack around it with fullyQualifiedName!T when they could and should have simply used the local name, plain T, instead! fullyQualifiedName is just a longer stringof so all the same problems (including that both are underspecified - neither are even specified to return valid or even well-formed D code!) - and same solutions (use local names) - apply to either.

Note that the addition of static foreach to the language, released in 2.076 on September 1, 2017, gave even more capability to use local names, but even before that, you could do it using the techniques I wrote about back then. mixin templates are great for helping with this, to learn more about them, see understanding mixin templates (January 2020) and mixins vs mixin templates (August 2019), as well as D Cookbook (published May 2015) chapters 6, 8, and 9.

One claim that holds a little bit of water is what if you're generating the string in other functions and want to mix it in elsewhere? My answer is really quite simple: don't do that. mixin templates provide a way to pass symbols around granting the ability to compose functions and you can always refactor code to keep the string mixin component local - some variant of mixin(q{ code right here }) - which produces more robust code and more readable error messages - but there's still some cases where it makes sense to make string functions. However, you still don't need fullyQualifiedName - it is a function, meaning it takes arguments from the caller. The caller can pass down the appropriate local name to the function! It'd look like mixin(generator_function("T"));.

Another general problem with fully qualified names is that you must import the containing module to use them regardless of qualification, and it is impossible to extract the name of the containing module out of a string without additional information. (Observe that module std; struct stdio { struct File {} } would be an equally valid interpretation of std.stdio.File as module std.stdio; struct File {}.) All full qualification does is disambiguate existing imports, it doesn't tell you how to import things in the first place. Local names do not suffer from this, since they are in the current scope by definition.

Moreover, template arguments may come from different modules that would also need to be imported. It would be possible for fullyQualifiedName to return imported!"module.name".member.name consistently, but it doesn't do this.

And again, local names avoid all of this and are always accessible - otherwise the code would be impossible to write anyway - even if it means you need to pass it down the call chain.

Error messages

The next most common claim for the usefulness of fullyQualifiedName is for error messages. This one certainly has some legitimacy, error messages are one place where .stringof can be legitimately justified, and fullyQualifiedName is, remember, just a longer .stringof (including the underspecified nature of it), so it gets a few points of usefulness here.

However, again, this needs to consider the cases where it doesn't work and be compared to the alternatives. If there's an alternative that always works better, it means fullyQualifiedName remains useless.

First off, some of the cases where it does more harm than good is long names. Consider the following:

struct FromSql(string sql, string tableName) {
	mixin(convert_sql_to_d(sql, tableName));
}

alias MyTable = FromSql!(import("schema.sql"), "my_table");

pragma(msg, fullyQualifiedName!MyTable);

You'll find the entire contents of the schema.sql file printed out, as it is part of the fully qualified name! While it is possible to imagine a case where this is somewhat useful... you'd be better off using is(MyTable == FromSql!(sql, tableName), string sql, string tableName) to extract it when you need it rather than seeing it dumped in its entirety in every error message, which generally just obscures the message with screens full of spam rather than enlightening the user as to what went wrong.

Even cases that aren't this extreme can be questionable: while the name Array may be ambiguous in an error message, that doesn't mean you should always print out the full name of frontero.util.containers.reference_counted.array.Array.

There clearly are cases where you want template parameters listed in error output. Array!int vs Array!string is often a meaningful distinction. Similarly, frontero..Array and std..Array can be a meaningful distinction. You can produce these with existing D reflection tools... but fullyQualifiedName doesn't help.

If you want something for error messages and debug logs, you're currently better off doing it yourself so you can present only the meaningful parts to your user. That said, there is potential for the compiler to improve things:

  • All compiler error messages should use public aliases when they were used in the original source. MyTable instead of FromSql!args... because that's what the author wrote in the API and they probably did so because the alias was justified.

    Generally speaking, error messages should agree with how the user is actually writing their code, which tends to come from the public signatures and documentation. The compiler should not discard this information in its own error messages, and should make this information available to library reflection for their error messages.

  • The compiler will attempt to disambiguate automatically in some messages. When it sees two names in scope with the same string, it will expand them for you to clarify the difference. E.g. "cannot pass type arsd.color.Color to function x expecting std.experimental.color.Color. It can see the whole scope and thus more intelligently decide what needs to be disambiguated and what doesn't.

    It already uses this internally (though I'd prefer it elide part of the name like I did above - arsd..Color vs std..Color is all that's really needed unless there's further ambiguity in scope, the rest of the package hierarchy is just noise). It should also expose this for library errors in some way.

In today's D, I'd recommend you craft your own partially qualified names for error messages that show what matters and elides that doesn't.

In tomorrow's D, I'd fully support adding a __traits(getHumanReadableName, T) function to reach into its knowledge and machinery for generating error messages instead of reaching for __traits(fullyQualifiedName, T). I'd also be in favor of making alias introspectable again in general.

The problems of methodology

I've now shown that fullyQualifiedName is useless. Everything you can do with it is better done with other means. I've also shown that this is not new knowledge - I have linked to publicly-accessible posts, including one from over seven years ago, where I've explained this at length in the past.

Why, then, does it come up again and again? Why was it the main target of Stefan Koch's posts about newCTFE and his core.reflect ideas? Why was it enshrined in the language just last week?

The short answer is that very few people actually listen to me. If you're a regular reader of my blog, you're one of the elite few who care to learn how the D language actually works and how you can be productive in it. You're also almost certainly not part of the D leadership team.

The longer answer is that fullyQualifiedName appears useful to the new user. String mixins have a low barrier to entry, and one of the most obvious ways to use them is to write some helper functions that return strings, and it isn't hard to follow the path from return x ~ y ~ z; to return x ~ y.stringof ~ z; to return x ~ fullyQualifiedName!y ~ z;, and from there to return "import " ~ moduleName!y ~ ";" ~ x ~ fullyQualifiedName!y ~ z;. Each step of that is a solution to some bugs you might encounter along the way.

The realization that you could have also written mixin("x y " ~ z); with a bit of light refactoring takes rethinking the problem from a different angle instead of whacking the individual bugs as they come up and might require outside help to get there.

It is easy to imagine users falling into the local optimum of fullyQualifiedName, declaring "it is ugly but it works for me" and never escape to the much greater heights of the alternatives.

Then, since fullyQualifiedName is notoriously slow and bloated, it becomes easy prey for people trying to improve D metaprogramming. And when I tell them that you can simply delete the whole thing, they're incredulous, despite how much incontrovertible proof I offer - every single time, without exception, I've found alternatives to people's specific fullyQualifiedName uses that work better and compile faster. You can delete the whole thing and lose no functionality.

This is why I call it a strawman - you can create some new implementation of fullyQualifiedName that is much, much faster than the one in phobos without solving any real problem because, again fullyQualifiedName is useless! It can be replaced by alternatives that work better and faster in all cases, so benchmarking against it is futile. If you want to win this argument, you need to benchmark against the alternatives I've described so many times over these last 7+ years.

Now, if you did general improvements to the compiler that just happened to improve fullyQualifiedName's performance among other things, that might be valuable... but because it improved other things. However, the proposals rarely focus on any big picture issues. Indeed, the PR that was merged in dmd a few days ago just swaps fullyQualifiedName!T for __traits(fullyQualifiedName, T) without even specifying the behavior (though it does copy/paste the Phobos unittests into the dmd test suite, indicating that it is, indeed, meant to be a 100% drop-in replacement).

They're pounding a nail that doesn't fasten to anything. Sure, it can lesson the hazard of stepping on it. But you could have also just pulled it out of the floor.

And I'm worried that its point is going to drive into a live wire on the other side.