Is `sizeof(T)` with an incomplete type a valid substitution-failure as per the C++ Standard?

Viewed 121

I've seen it come up a few times on StackOverflow and elsewhere that decltype(sizeof(T)) can be used with std::void_t to SFINAE off of whether T is complete or not. This process is even documented by Raymond Chen in Microsoft's blog titled Detecting in C++ whether a type is defined with the explicit comment stating:

I’m not sure if this is technically legal, but all the compilers I tried seemed to be okay with it.

Is this behavior reliable and well-defined as per the C++ standard?

The only indication I can find in the standard is from [expr.sizeof]/1 wherein it states:

... The sizeof operator shall not be applied to an expression that has function or incomplete type, to the parenthesized name of such types, or to a glvalue that designates a bit-field ...

However it is unclear to me whether the wording "shall not be applied" would imply that this is "invalid" for the purposes of substitution as per the rules in [temp], or whether this is ill-formed.

ℹ️ Note: This question is not directed at any particular version of the standard, but it would be interesting to compare if this has changed at any point.

1 Answers

"Shall not be applied" means that it would normally be ill-formed. In an SFINAE context, if something would normally be ill-formed due to resulting in "an invalid type or expression", this becomes a substitution failure, as long as it is in the "immediate context" (C++20 [temp.deduct]/8) and not otherwise excluded from SFINAE (e.g. see p9 regarding lambda expressions).

There is no difference between "invalid" and "ill-formed" in this context. p8 explicitly says: "An invalid type or expression is one that would be ill-formed, with a diagnostic required, if written using the substituted arguments." This wording has been present since C++11. However, in C++03, invalid expressions were not substitution failures. This is the famous "expression SFINAE" feature that was added in C++11, after compiler implementers were sufficiently convinced that they would be able to implement it.

There is no rule in the standard that says that sizeof expressions are an exception to the SFINAE rules, so as long as an invalid sizeof expression occurs in the immediate context, SFINAE applies.

The "immediate context" has still not been explicitly defined in the standard. An answer by Jonathan Wakely, a GCC dev, explains the intent. Eventually, someone might get around to formally defining it in the standard.

However, the case of incomplete types, the problem is that this technique is very dangerous. First, if the completeness check is performed twice in the same translation unit on the same type, the instantiation is only performed once; this implies that the second time it's checked, the result of the check will still be false, because the is_type_complete_v<T> will simply refer to the previous instantiation. Chen's post appears to simply be wrong about this: GCC, Clang, and MSVC all behave the same way. See godbolt. It's possible that the behaviour was different on an older version of MSVC.

Second, if there is cross-translation-unit variance: that is, is_type_complete_v<T> is instantiated in one translation unit and is false, and is instantiated in another translation unit and is true there, the program is ill-formed NDR. See C++20 [temp.point]/7.

For this reason, completeness checks are generally not done; instead, library implementers either say that you are allowed to pass incomplete types to their templates and they will work properly, or that you must pass a complete type but the behaviour is undefined if you violate this requirement, as it cannot be reliably checked at compile time.

One creative way around the template instantiation rules is to use a macro with __COUNTER__ to make sure that you have a fresh instantiation every time you use the type trait, and you have to define the is_type_complete_v template with internal linkage, to avoid the issue of cross-TU variance. I got this technique from this answer. Unfortunately, __COUNTER__ is not in standard C++, but this technique should work on compilers that support it.

(I looked into whether the C++20 source_location feature can replace the non-standard __COUNTER__ in this technique. I think it can't, because IS_COMPLETE may be referenced from the same line and column but within two different template instantiations that somehow both decide to check the same type, which is incomplete in one and complete in the other.)

Related