C++23 explicit object parameters are really explicit
In programming languages with class methods, some take the instance of the class to operate on as an implicit parameter, often called this
as a keyword.
You don't see it in the list of parameters to the function, hence why it's referred to as implicit.
Other programing languages make you explicitly name that parameter, often as the first parameter, and you may even be able to give it any name you want, since it is an explicit parameter for the object to operate on.
C++ for most of its life has fallen mostly in the first camp by only offering implicit object parameters, though generic code can use std::invoke
or std::invoke_r
to not worry about the difference between free functions and member functions. As of the new C++23 language standard however, you can now use both implicit and explicit object parameters for class member functions without affecting the syntax used by callers in most cases.
There is one case where it is a breaking change, and that's when you take the address of the member function: an implicit object parameter member function's address is a pointer-to-member-function type, which is uniquely different from ordinary function types, whereas an explicit object parameter member function's address is an ordinary function type.
If you're using std::invoke
or std::invoke_r
, there's no difference, but if you've got your own generic code that works with functions and invoking them, you might need to tweak things a bit.
Function declaration | Client code | Pointer declaration | Reference declaration |
---|---|---|---|
void setSize(int); | obj.setSize(1); | void (ClassName::*funcPtr)(int); | Not allowed |
friend void setSize(ClassName&, int); | setSize(obj, 1); | void (*funcPtr)(ClassName&, int); | void (&funcPtr)(ClassName&, int); |
void setSize(this ClassName&, int); | obj.setSize(1); | void (*funcPtr)(ClassName&, int); | void (&funcPtr)(ClassName&, int); |
Note that in the explicit object parameter declaration, the this
keyword is for the compiler only, you still have to provide a name for the parameter if you want to refer to it since you can't use this
anymore.
In all cases, noexcept
specifiers work as normal - that is, they participate in the type of the function signature but don't affect the client code other than for compiler code generation purposes.
Things get interesting with other qualifiers though, specifically const
, volatile
, &
, and &&
qualifiers.
Most developers are familiar with the const
qualifier, so it shouldn't be surprising to learn that int getSize() const noexcept;
could alternatively be written as int getSize(this ClassName const&) noexcept;
with explicit member object syntax.
This removes a bit of syntactic weirdness from the implicit member object syntax version and makes it more obvious what that const
is really applying to.
The weirdness doesn't completely vanish though as can be seen when we build out a table of the combinations for the &
and &&
qualifiers:
Call by... | Implicit member object syntax | Explicit member object syntax |
---|---|---|
LValue reference only | std::string& getName() &; | std::string& getName(this ClassName&); |
RValue reference only | std::string&& getName() &&; | std::string&& getName(this ClassName&&); |
Any reference (and find out which) | Requires two declarations (both above) | decltype(auto) getName(this auto&&); and use std::forward_like |
Any reference (don't care) | std::string getName(); | std::string getName(this auto&&); |
Value (copy or move) | Not possible | std::string getName(this ClassName); |
Decide based on class template param | Requires two declarations and SFINAE/requires | The entire rest of this blog post |
The first four rows go mostly as expected, with a direct mapping between the implicit and explicit object parameter syntaxes, since the latter works much like ordinary free functions.
The bottom two rows are more interesting, and highlight a pre-existing consequence of C++'s implicit object parameter syntax: by not specifying either &
or &&
qualifiers, a member function can be called from any object expression, other than filtering by const
/volatile
.
By contrast, ordinary functions have to pick a reference category, or they can take parameters by value, which is something the implicit object parameter member functions can't do for the this
parameter.
For example, with an explicit object parameter passed by value, std::move(obj).convertToOther();
would actually move from obj
automatically!
As cool as that is, the other side of the coin is that ordinary functions and explicit object parameter member functions can't take the this
parameter by the same convention as the unqualified implicit object parameter member function, they instead have to split into two or more versions (or let a template do that for them) to cover the same surface area.
The template point is the main benefit of explicit object parameter syntax since having it dramatically simplifies a lot of generic code: the template can be instantiated with the necessary type information automatically to produce correct code for all the different ways the member function could be called, instead of having to create separate overloads for each one, and since it's instantiated at point of call it can become a derived type and simplify CRTP code. However, one use case it hasn't fully simplified is deciding the allowed value category based on external considerations, such as a class template parameter; both implicit and explicit object parameter declarations need at least two versions for that, even if only one ends up participating in overload resolution.
An example might clarify this point: imagine you're making a class type similar to std::future
and std::shared_future
, but using a template parameter to decide whether the state is "shared" or not (really, whether to return by move or by const reference).
With implicit object parameter syntax, it's pretty obvious you'll need two versions of the get()
member function (auto get();
and auto const& get() const;
), since you can't do any template or type system magic to decide whether to const
-qualify the member function.
You can, however, decide "dynamically" (at compile time) how to qualify the explicit object parameter though.
For example: decltype(auto) get(this std::conditional_t<Condition, ClassName&, ClassName const&>);
You might already notice the problem though: our only options for the value category are to pass by value, by LValue reference, by RValue reference, or to turn the function into a template and fight against the compiler's inctincts to deduce the value category automatically by banning the ones we don't want.
There's nothing we can write in an explicit object parameter that would be equivalent to decltype(auto) get();
because that implicit object parameter syntax is magic and automatically handles the value category in special ways not available to free functions or explicit object parameter functions.
If you pick any option that isn't the template approach, you'll cut off at least one valid way of calling the member function or introduce unnecessary copies/moves.
You can try it yourself in Compiler Explorer and report back if you have any success, but this table is easier on the eyes:
auto result = | getClassName().get() | obj.get() | std::move(obj).get() | std::as_const(obj).get() |
---|---|---|---|---|
R get(); | ✅ | ✅ | ✅ | ❌ |
R get() const; | ✅ | ✅ | ✅ | ✅ |
R get(this ClassName&); | ❌ | ✅ | ❌ | ❌ |
R get(this ClassName const&); | ✅ | ✅ | ❌ | ✅ |
R get(this ClassName&&); | ✅ | ❌ | ✅ | ❌ |
R get(this ClassName const&&); | ✅ | ❌ | ✅ | ❌ |
R get(this ClassName); | Moves | Copies | Moves | Copies |
R get(this auto); | Moves | Copies | Moves | Copies |
R get(this auto&); | ❌ | ✅ClassName& | ❌ | ✅ClassName const& |
R get(this auto const&); | ✅ | ✅ | ✅ | ✅ |
R get(this auto&&); | ✅ClassName&& | ✅ClassName& | ✅ClassName&& | ✅ClassName const& |
R get(this auto const&&); | ✅ClassName const&& | ✅ClassName const& | ✅ClassName const&& | ✅ClassName const& |
Each checkmark indicates the code would compile, whether we want that or not.
The first two rows of the table are the two implicit object parameter versions we'd need traditionally, and we'd have to disable one or the other with SFINAE/requires
(or do weird stuff with conditionally selecting CRTP base classes).
The rest of the table shows all the possible explicit object parameter versions, and you can clearly see that no pair can be passed to our std::conditional_t
usage to match the behavior of the first two rows.
The last five rows are actually templates, and whether we use auto
or explicit template parameters, we can't use std::conditional_t
in these cases, instead we have to constrain the template, which is a lot of unnecessary work for the compiler (and ourselves) to do.
So, we gained a bit more freedom and control over this, but not full control, there are some things we still have to explicitly do the old fashioned way, because explicit object parameters are really explicit: the value category can no longer be implicit.
If you're able to use C++20 or C++23 anyway, you can declare the first two rows and just use requires
to suppress the unwanted version, which tends to be simpler than SFINAE since you don't have to make the functions a template or worry about arcane rules like dependent types.
Avoiding code duplication is a still a challenge though, sometimes you can have a third private function declared as R& getImpl() const;
, so it can be used by both versions of the public-facing function.
Sometimes that won't work though because the return value might not be able to be non-const
if this
is const
-qualified, in which case your only option for avoiding code duplication is the messy constrained template version, so hopefully you can afford the added compile time.
[[nodiscard]] constexpr result_type get(this auto&& self) requires (!(TakeOnce && std::is_const_v<std::remove_reference_t<decltype(self)>>)) { //... }
Or, I suppose you could let it be free, wild, and unconstrained, and let your clients deal with the confusing error messages when the body of the function doesn't compile due their misusage, since that would also work... I think being more explicit for better error messages is a worthy tradeoff, but to each their own.
Comments
Post a Comment
Remember the universal code of conduct