monitor optional in objects, float and char init to 0

Posted 2025-09-29

Though most of my time has been swamped with other responsibilities, we made a few more long-desired strides in OpenD including making Object monitors opt-in and changing the default init of char and float types to zero. These are potentially risky breaking changes, with silent change to runtime behavior, so you'll want to check your code and run tests again after this update.

Optional Object monitor

The old situation

D borrowed most its class object model from Java, including its synchronized keyword. To support this, all Object instances had a reserved slot for their monitor, which manages that synchronization inside druntime. This slot is just a null pointer, lazily initialized when used, so it isn't a large cost - on 64 bit builds, it is only an addition 8 bytes in each instance - but it still feels bad when you don't need it at all, pushing some people to make extern(C++) classes to eliminate it, and a common refrain upstream has been to make some ProtoObject to remove this, a move I've staunchly opposed as being pointless, but nevertheless had support among significant stakeholders.

The monitor slot, since it was there, also would be used sometimes for other purposes, like weak ref tracing (inside phobos!), or other unsanctioned uses by third party libraries. This made changing it potentially serious breakage, and being just eight bytes, I've resisted until now.

The new situation

A significant contributor to OpenD caught me in a "yolo" mood and I decided to give the change a try and see how much work it really would be to make the monitor slot opt-in. The implementation wasn't too hard, mostly removing special cases from the compiler to make that slot, and then adding an alternative to the library. I went with two options: you can inherit from SynchronizableObject or mixin EnableSynchronization and get the same result. Compiler checks point you to these solutions when it can be statically detected. To support libraries that tried to do other things with the slot at minimum runtime cost, I made __monitor into a virtual ref method, which, by default, triggers an assert(0) runtime error, alerting you to the problem, and once you opt in to synchronization with either the other parent class or the mixin, it overrides this method to return a reference to the newly reserved, but still lazily-initialized slot, allowing the existing code to use (or abuse it) as desired.

I ran this against several applications and libraries and found most synchronized blocks already used either TypeInfo or explicit Mutexes, so I made those opt in to the monitor. I almost wish it wasn't in TypeInfo, and maybe can pare it back from some branches, but for now I put it in all of them and found things work well. TypeInfo has single instance, so the savings aren't as big there, but it is all static so still, not a big deal but can revisit later.

After this, I had to make about a dozen minor changes across about sixty libraries, half of which were in arsd! And then the basic functionality worked. This wasn't much of a bother in migration, and with a quick no-op stub polyfill for old compilers, this would be adequately compatible for me.

Two tricky cases I found, both of which used the monitor for something other than synchronization, were Phobos' std.signals and vibe.d's core internals.

std.signals uses it to attach finalizer hooks an object, as part of an internal weak reference. This is a use that should probably have an entirely different implementation, but rewriting things wasn't in the cards for an opt in migration. But, due to using the same runtime, once you did opt in, this still worked! Only downside is there is no static type safety to it; indeed, the documentation says it is undefined behavior at runtime if you get it wrong at all! Pass &thing.foo where thing is a struct instead of class? Runtime problem of the worst kind. I at least was able to make the runtime problem well-defined, and add the documentation with the note to use the synchronizable object. This library isn't that well used anyway, so this transition seemed acceptable to me.

vibe.d uses a custom monitor and calls into extern(C) private functions in druntime to hook its behavior... something surely inappropriate, but nevertheless I had to assess the breakage, even to libraries doing it inappropriately. The fix wasn't too hard, a few edits to opt into the monitor in its internal classes, and then user code never needs to know. While I'm a bit nervous that it broke something else due to one scare in an early debugging session, it has worked fine since then, so I think it ended up ok.

Bottom line is that while the benefit is fairly small, just one pointer in class objects that are often larger than that anyway, it is still something that can add up, and since the migration wasn't too bad in reality, I went ahead and committed the code.

Code changes:

https://github.com/opendlang/opend/commit/42403039bd21f2192678fdac873c327723c79e01

https://github.com/opendlang/opend/commit/32d96ae4e12c308c2ffe54cfbd4de3b15471c658

https://github.com/opendlang/opend/commit/8d42a3e58021f43f31d2f4b7ddf002e4ff88a799

https://github.com/opendlang/opend/commit/3d118fcf1d81d8c94012ed2b7dbcc144b4153ac8 (this one has other fixups for zero init too)

The other libraries I had to fix like vibe.d, podoonis, and ae, are not currently in the opend repo, but they were also fairly small changes adding sync support where needed, about ten line diff between them all.

Then, while in the "yolo" mood and encouraged by this success, I looked at another long time ask that had potential runtime breakage to batch them together: float and char init.

Float and Char init

The old situation

D has historically default initialized floating point types to nan and character types to \xff, analogizing these to null as invalid sentinel values that make their use stand out at runtime - nan math always results in more nan, making the result obviously wrong, and \xff is invalid utf, so it will fail validation when you try to use it too.

The big problem with this is that int (and similar) don't follow this same "invalid" pattern. Integral types init to zero... which is a perfectly reasonable initial number for using them. This gets people in the habit of assuming it will be zero, making the other values an unpleasant surprise. Moreover, not all missed initializations are obvious - using a struct initializer, for example, makes it easy to forget a value, and you don't get an error, you just get the default. Arguably, this is exactly what the invalid values are supposed to help you catch, but in practice, it is often a puzzler instead of a clear signal, when you just get disappearing images on screen due to NaN, or runaway C pointers due to a missing zero terminator.

Finally, a single non-zero initializer in a compound type also requires a static blob of data in the executable, which can get large quickly and be more expensive to allocate, whereas all zero gets special treatment from the operating system to minimize these costs. The non-zero cases cancel this opportunity too.

The hesitation

There's been demand to change this in both old D and OpenD going back some ways. Upstream, Walter resists it because he really believes the invalid values have significant value. Here, I have resisted because I'm fairly neutral on the value - the preceding section might make me sound strongly pro-zero, but really, I can go either way. I think Walter does make fair points about the invalid values, and the arguments for either side are perfectly reasonable.

What pushed me toward resisting the change is just that it is a change: OpenD focuses on common-sense, incremental improvements to D, avoiding breaking changes without both benefit and a clear transition path. I see the benefit here as moderate, but the transition path is iffy since the compiler offers little help in migrating. We looked at deprecating use of init, but this was unworkable - tons of code threw up messages, virtually none of which caught actual bugs. We looked at implementing a C# style uninitialized variable detector, forcing all float and char vars to be explicitly initialized to some value, but this similarly didn't work very well, triggering in templates all over the place, missing T.init uses that could be problematic, and where it did give messages, it felt like pointless busy work with no real benefit. It was broken old code, while enabling no benefit to new code.

So, for a long time, I just said no. But after the success of removing monitor, I decided to just try this change too and see what happened.

The new situation

The implementation in the compiler was again pretty easy: remove the special case for float/char from the has zero init methods, then change the actual values output. This was only slightly harder than the "utterly trivial" expectation I had, since the zero init checks and the actual initializer were in different places, but still no problem.

I found no difficulty in druntime at all. Phobos, however, had a surprise in store for me: calls to std.format failed to compile after this change. Why? Turns out a few years ago, someone hoping to "streamline" the isSomeChar template, decided to use !__traits(hasZeroInit) as a proxy for it. Like, it worked, in old D, if you were an integer type with non-zero init, that would mean char, but it just seemed so unprincipled and fragile. What if the language later added some new int that was non-zero? Or... if char was changed to be zero? It'd no longer think they were chars!

Reverting this to an is() check for chars specifically fixed the issue and Phobos worked again.

The bigger worry I had was my own arsd library. In several cases, I used dchar.init as a distinct value from 0, with different runtime behavior, and I sometimes used float.init to indicate a special value as well. I knew changing this would break these functions, but how hard is it to handle it and what is the consequence of missing a spot?

The float ones were relatively easy: I could change every reference to float.init (including the implicit ones when declaring the variables... which are the hardest to check since you can't grep that..) to float.nan, and ditto for double.init to double.nan. If I missed a spot and it used zero instead of nan, for these specific functions, it'd either seek the stream to the beginning (probably not noticed since that's where you want to start anyway), or turn the volume down... which would be noticed, it'd make the audio library feel broken as it would make no sound, but why would you call setVolume(float.init) explicitly anyway, since float.init is used here to mean "do no change it"? If you don't want to change it, you would just not call the function. So I deemed this an acceptable risk.

The char ones were a bit trickier. For one, there is no char.invalid to swap out for char.init. I made some library enums char_invalid (and ditto for wchar and dchar) to use here, and this is fine for me, but an extra step for outside users. But then, what if user code misses a spot? Most the uses were as function default parameters, but still, the user could explicitly pass dchar.init and this was a documented behavior, so what to do?

For this particular public function, Terminal.getline's password replacement parameter, there are three settings: the invalid char meant echo the user input normally, not obscuring the password. Zero meant echo nothing. Any other valid char would be used as the replacement char, typically '*'. The behavior of the typical swap would be the opposite of what the user intended: if you explicitly passed dchar.init, you were asking for normal echo, and instead you'd get no echo at all! The most likely reason for making this explicit call is something like a "show password" toggle, which is now broken. I can't detect this in the library and decided to simply accept it as possible breakage and documented the change.

Other changes from 0xff to 0 are likely to be less harmful, since the 0 is commonly used as a string terminator anyway, but I can't prove it, so there's certainly other possible problems like this, making this one of the riskier OpenD changes, but I decided to do it anyway since it seems ok enough. I believe that more people will benefit from the automatic zero than be hurt by the change in init.

Conclusion: AUDIT YOUR CODE. search for any explicit float.init, double.init, real.init, char.init, wchar.init, and dchar.init and see why you use it. This is the only way to be fairly sure.

If you are writing code that must be compatible with both old and new compilers, always explicitly initialize these things.

Code changes:

https://github.com/opendlang/opend/commit/3e97ea249965274b026b695d55b1e38f9cdb7d76

https://github.com/opendlang/opend/commit/3d118fcf1d81d8c94012ed2b7dbcc144b4153ac8

https://github.com/opendlang/opend/commit/485ab3b558c0bad98d078003dc0e93ea952c9960

And the arsd prep work for all this:

https://github.com/opendlang/opend/commit/4a913bb7fb26d6e448cb5ce6bc0e31f3bb1a36e8