Without form and void
In C++, void
is a pretty strange thing. We can cast expressions to void
, throw-expressions have void
type, void*
can point to anything, and void
functions can actually return back to the caller. But we can’t have objects of type void
or even write a type like void&
. A function declaration like void f(void)
is actually a nullary function. It’s a bit weird — but it’s not something a lot of people lose sleep over.
Until it starts wreaking havoc on your generic code — because it’s like the vector<bool>
of the type system.
A really common first run-in with the kinds of problems that void
can cause is when you try to write that first benchmarking function (personally, my first non-trivial case was a result of fulfilling promises). All you want to do is time some arbitrary function and log how long it takes. No problem:
This basically does what we want. We take two timestamps around invoking our function (using std::invoke
because of course pointers to members aren’t callable…), log the delta in units of seconds, and just return the result
back to the caller. And this works for all functions (that return a moveable type) except those that return void
— which give us a compile error pointing to the result
declaration with:
error: 'void result' has incomplete type
auto result = std::invoke(/* ... */)
^~~~~~
So, what do we do now? One solution that I’m immediately rejecting is to provide an overload of timeit()
for those functions that return void
, duplicating the entire body. This is only mildly frustrating in this case (it’s only a 10 line function after all), but it’s a complete non-starter in general — I am not going to duplicate every function template I write that accepts arbitrary callables.
One clever solution to this particular problem (and I am using “clever” here in its typical connotation as being complimentary) is to reorganize the function such that all the work after the call gets thrown into a scope guard:
This works. And if you think about it, it’s kind of strange that it does work. Well, first, there’s the putting all of this work in a scope guard to take advantage of the fact that local variables’ (such as the one implicitly created by this macro) destructors are sequenced after the return statement, and so this does in fact time the function. That’s… okay, maybe that’s a little strange. But the real strange part here is the fact that this very much looks like we’re returning an object of type void
, which is what caused us problems in our first version. The language doesn’t really work in that way, there is no void
object, we just get a special rule that says that this works for void
.
Cool.
But it’s only a matter of time in which this doesn’t quite work. We can’t quite reorganize each and every function to just return expressions of type void
. What do we do then?
My personal preferred approach is to introduce a type as a stand-in for void
that can be freely substituted back and forth, and provide helpers that make it easy to switch between void
and this new type as necessary. For lack of a better name, I call this type… Void
. It’s a pretty simple type:
Empty type, explicitly constructible from anything. Pretty basic. On top of this, we add two type aliases to convert between void
and Void
in the type system, if so desired:
Now, how do we use this thing? The main pain point of void
is dealing with unknown callables that might return it. The solution is to provide a function template that does the same thing as std::invoke()
, but just returns Void
instead of void
— the advantage here being that Void
is an actual object type.
Without Concepts, that’s two overloads that are mutually SFINAE-d out, one for the void
case and one for the non-void
case:
The existence of these function templates allows us to just swap in invoke_void()
wherever we had previously used std::invoke()
. Our initial example, for instance, rewritten to handle void
return types would be:
This just works, and we don’t need to know about the scope guard trick nor have to worry about writing duplicate templates going forward.
But there’s one more little thing that I’ve found very useful that’s probably more controversial.
My implementation of optional<T>
, in addition to supporting reference types, also supports void
(which internally is treated as Void
, I do not allow optional<Void>
). You might wonder that optional<T&>
is just an odd spelling of T*
, but come on — optional<void>
is a particularly ridiculous spelling of bool
. And… well, kind of. But it makes it easy in generic contexts if there’s just no holes in what optional
supports. This implementation also has all the member function that Simon Brand proposes in [P0798].
The question is, how should optional<void>::map()
behave? What kind of callable should it take? There are two options here:
- Since internally it’s a
Void
,map()
should take a unary function that takes aVoid
argument, with the appropriate cv-ref qualifiers. - Since externally it’s
void
, and you cannot have objects of typevoid
,map()
should take a nullary function. - No seriously, why do you have
optional<void>
?!
I think option 2 is the best choice here. It just seems like the most useful choice, that’s the least surprising for callers. And so, to avoid fragmenting the templates again, I actually have three overloads of invoke_void()
, not two:
In other words, invoke_void(f, Void())
is equivalent to invoke_void(f)
for all f
(that are actually nullary). This is true even if you try to call invoke_void
with a function that can actually take a Void
as an argument. It just won’t happen. I haven’t run into a case where I’ve actually wanted that though…
The only reason this 3rd overload exists is to make my life easier with all the monadic functions. For all I’m aware, it has no sound backing in programming language theory. But I can tell you with certainty that it’s been super useful and I would write it again.
Of course, this comes back to the question of — why is void
so weird anyway? The answer to all of those questions is usually something to the effect of “Because, C.” Although on occasion, it’s some earlier language’s fault. I don’t know the history here, but would be curious to find out.
In any case, wouldn’t all of this just be simpler if we didn’t have to work around void
to begin with? What if void
were, you know, just a regular type?
Void
only exists because void
isn’t an object type. But the one little quirk remains that invoking a function with an instance of void
, if it were a real type, would still have to behave as if it were a real argument. That is, consider:
If void
were a real type, this shouldn’t compile. In my world, currently, it does. I don’t really know what to make of this situation, but I’m sure I know a person or two who has a strong opinion on the subject.