12.14 — Type deduction with pointers, references, and const

In lesson 10.8 -- Type deduction for objects using the auto keyword, we discussed how the auto keyword can be used to have the compiler deduce the type of a variable from the initializer:

int getVal(); // some function that returns an int by value

int main()
{
    auto val { getVal() }; // val deduced as type int

    return 0;
}

We also noted that by default, type deduction will drop const (and constexpr) qualifiers:

const double foo()
{
    return 5.6;
}

int main()
{
    const double cd{ 7.8 };

    auto x{ cd };    // double (const dropped)
    auto y{ foo() }; // double (const dropped)

    return 0;
}

Const (or constexpr) can be (re)applied by adding the const (or constexpr) qualifier in the definition:

const double foo()
{
    return 5.6;
}

int main()
{
    constexpr double cd{ 7.8 };

    const auto x{ foo() };  // const double (const dropped, const reapplied)
    constexpr auto y{ cd }; // constexpr double (constexpr dropped, constexpr reapplied)
    const auto z { cd };    // const double (constexpr dropped, const applied)

    return 0;
}

Type deduction drops references

In addition to dropping const/constexpr qualifiers, type deduction will also drop references:

#include <string>

std::string& getRef(); // some function that returns a reference

int main()
{
    auto ref { getRef() }; // type deduced as std::string (not std::string&)

    return 0;
}

In the above example, variable ref is using type deduction. Although function getRef() returns a std::string&, the reference qualifier is dropped, so the type of ref is deduced as std::string.

Just like with the dropped const qualifier, if you want the deduced type to be a reference, you can reapply the reference at the point of definition:

#include <string>

std::string& getRef(); // some function that returns a reference

int main()
{
    auto ref1 { getRef() };  // std::string (reference dropped)
    auto& ref2 { getRef() }; // std::string& (reference reapplied)

    return 0;
}

Top-level const and low-level const

A top-level const is a const qualifier that applies to an object itself. For example:

const int x;    // this const applies to x, so it is top-level
int* const ptr; // this const applies to ptr, so it is top-level

In contrast, a low-level const is a const qualifier that applies to the object being referenced or pointed to:

const int& ref; // this const applies to the object being referenced, so it is low-level
const int* ptr; // this const applies to the object being pointed to, so it is low-level

A reference to a const value is always a low-level const. A pointer can have a top-level, low-level, or both kinds of const:

const int* const ptr; // the left const is low-level, the right const is top-level

When we say that type deduction drops const qualifiers, it only drops top-level consts. Low-level consts are not dropped. We’ll see examples of this in just a moment.

Type deduction and const references

If the initializer is a reference to const (or constexpr), the reference is dropped first (and then reapplied if applicable), and then any top-level const or constexpr is dropped from the result.

#include <string>

const std::string& getConstRef(); // some function that returns a reference to const

int main()
{
    auto ref1{ getConstRef() }; // std::string (reference dropped, then top-level const dropped from result)

    return 0;
}

In the above example, since getConstRef() returns a const std::string&, the reference is dropped first, leaving us with a const std::string. This const is now a top-level const, so it is also dropped, leaving the deduced type as std::string.

Key insight

Dropping a reference may change a low-level const to a top-level const: const std::string& is a low-level const, but dropping the reference yields const std::string, which is a top-level const.

We can reapply either or both of these:

#include <string>

const std::string& getConstRef(); // some function that returns a const reference

int main()
{
    auto ref1{ getConstRef() };        // std::string (reference and top-level const dropped)
    const auto ref2{ getConstRef() };  // const std::string (reference dropped, const reapplied)

    auto& ref3{ getConstRef() };       // const std::string& (reference reapplied, low-level const not dropped)
    const auto& ref4{ getConstRef() }; // const std::string& (reference reapplied, low-level const not dropped)

    return 0;
}

We covered the case for ref1 in the prior example. For ref2, this is similar to the ref1 case, except we’re reapplying the const qualifier, so the deduced type is const std::string.

Things get more interesting with ref3. Normally the reference would be dropped first, but since we’ve reapplied the reference, it is not dropped. That means the type is still const std::string&. And since this const is a low-level const, it is not dropped. Thus the deduced type is const std::string&.

The ref4 case works similarly to ref3, except we’ve reapplied the const qualifier as well. Since the type is already deduced as a reference to const, us reapplying const here is redundant. That said, using const here makes it explicitly clear that our result will be const (whereas in the ref3 case, the constness of the result is implicit and not obvious).

Best practice

If you want a const reference, reapply the const qualifier even when it’s not strictly necessary, as it makes your intent clear and helps prevent mistakes.

What about constexpr references?

Constexpr is not part of an expression’s type, so it is not deduced by auto.

A reminder

When defining a const reference (e.g. const int&), the const applies to the object being referenced, not the reference itself.

When defining a constexpr reference to a const variable (e.g. constexpr const int&), we need to apply both constexpr (which applies to the reference) and const (which applies to the type being referenced).

This is covered in lesson 12.4 -- Lvalue references to const.

#include <string_view>
#include <iostream>

constexpr std::string_view hello { "Hello" };   // implicitly const

constexpr const std::string_view& getConstRef() // function is constexpr, returns a const std::string_view&
{
    return hello;
}

int main()
{
    auto ref1{ getConstRef() };                  // std::string_view (reference dropped and top-level const dropped)
    constexpr auto ref2{ getConstRef() };        // constexpr const std::string_view (reference dropped and top-level const dropped, constexpr reapplied)

    auto& ref3{ getConstRef() };                 // const std::string_view& (reference reapplied, low-level const not dropped)
    constexpr const auto& ref4{ getConstRef() }; // constexpr const std::string_view& (reference reapplied, low-level const not dropped, constexpr reapplied)
    
    return 0;
}

Author’s note

The intersection of constexpr, references, and type deduction is complex. I’m not 100% sure the above is correct. Please leave a comment below if you have additional insight.

Type deduction and pointers

Unlike references, type deduction does not drop pointers:

#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
    auto ptr1{ getPtr() }; // std::string*

    return 0;
}

We can also use an asterisk in conjunction with pointer type deduction:

#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
    auto ptr1{ getPtr() };  // std::string*
    auto* ptr2{ getPtr() }; // std::string*

    return 0;
}

Key insight

The reason that references are dropped during type deduction but pointers are not dropped is because references and pointers have different semantics.

When we evaluate a reference, we’re really evaluating the object being referenced. Therefore, when deducing a type, it makes sense that we should deduce the type of the thing being referenced, not the reference itself. Also, since we deduce a non-reference, it’s really easy to make it a reference by using auto&. If type deduction were to deduce a reference instead, the syntax for removing a reference if we didn’t want it is much more complicated.

On the other hand, pointers hold the address of an object. When we evaluate a pointer, we are evaluating the pointer, not the object being pointed to (if we want that, we can dereference the pointer). Therefore, it makes sense that we should deduce the type of the pointer, not the thing being pointed to.

The difference between auto and auto* Optional

When we use auto with a pointer type initializer, the type deduced for auto includes the pointer. So for ptr1 above, the type substituted for auto is std::string*.

When we use auto* with a pointer type initializer, the type deduced for auto does not include the pointer -- the pointer is reapplied afterward after the type is deduced. So for ptr2 above, the type substituted for auto is std::string, and then the pointer is reapplied.

In most cases, the practical effect is the same (ptr1 and ptr2 both deduce to std::string* in the above example).

However, there are a couple of difference between auto and auto* in practice. First, auto* must resolve to a pointer initializer, otherwise a compile error will result:

#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
    auto ptr3{ *getPtr() };      // std::string (because we dereferenced getPtr())
    auto* ptr4{ *getPtr() };     // does not compile (initializer not a pointer)

    return 0;
}

This makes sense: in the ptr4 case, auto deduces to std::string, then the pointer is reapplied. Thus ptr4 has type std::string*, and we can’t initialize a std::string* with an initializer that is not a pointer.

Second, there are differences in how auto and auto* behave when we introduce const into the equation. We’ll cover this below.

Type deduction and const pointers Optional

Since pointers aren’t dropped, we don’t have to worry about that. But with pointers, we have both the const pointer and the pointer to const cases to think about, and we also have auto vs auto*. Just like with references, only top-level const is dropped during pointer type deduction.

Let’s start with a simple case:

#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
    const auto ptr1{ getPtr() };  // std::string* const
    auto const ptr2 { getPtr() }; // std::string* const

    const auto* ptr3{ getPtr() }; // const std::string*
    auto* const ptr4{ getPtr() }; // std::string* const

    return 0;
}

When we use either auto const or const auto, we’re saying, “make whatever the deduced type is const”. So in the case of ptr1 and ptr2, the deduced type is std::string*, and then const is applied, making the final type std::string* const. This is similar to how const int and int const mean the same thing.

However, when we use auto*, the order of the const qualifier matters. A const on the left means “make the deduced pointer type a pointer to const”, whereas a const on the right means “make the deduced pointer type a const pointer”. Thus ptr3 ends up as a pointer to const, and ptr4 ends up as a const pointer.

Now let’s look at an example where the initializer is a const pointer to const.

#include <string>

int main()
{
    std::string s{};
    const std::string* const ptr { &s };

    auto ptr1{ ptr };  // const std::string*
    auto* ptr2{ ptr }; // const std::string*

    auto const ptr3{ ptr };  // const std::string* const
    const auto ptr4{ ptr };  // const std::string* const

    auto* const ptr5{ ptr }; // const std::string* const
    const auto* ptr6{ ptr }; // const std::string*

    const auto const ptr7{ ptr };  // error: const qualifer can not be applied twice
    const auto* const ptr8{ ptr }; // const std::string* const

    return 0;
}

The ptr1 and ptr2 cases are straightforward. The top-level const (the const on the pointer itself) is dropped. The low-level const on the object being pointed to is not dropped. So in both cases, the final type is const std::string*.

The ptr3 and ptr4 cases are also straightforward. The top-level const is dropped, but we’re reapplying it. The low-level const on the object being pointed to is not dropped. So in both cases, the final type is const std::string* const.

The ptr5 and ptr6 cases are analogous to the cases we showed in the prior example. In both cases, the top-level const is dropped. For ptr5, the auto* const reapplies the top-level const, so the final type is const std::string* const. For ptr6, the const auto* applies const to the type being pointed to (which in this case was already const), so the final type is const std::string*.

In the ptr7 case, we’re applying the const qualifier twice, which is disallowed, and will cause a compile error.

And finally, in the ptr8 case, we’re applying const on both sides of the pointer (which is allowed since auto* must be a pointer type), so the resulting types is const std::string* const.

Best practice

If you want a const pointer, pointer to const, or const pointer to const, reapply the const qualifier(s) even when it’s not strictly necessary, as it makes your intent clear and helps prevent mistakes.

guest
Your email address will not be displayed
Find a mistake? Leave a comment above!
Correction-related comments will be deleted after processing to help reduce clutter. Thanks for helping to make the site better for everyone!
Avatars from https://gravatar.com/ are connected to your provided email address.
Notify me about replies:  
163 Comments
Newest
Oldest Most Voted
Inline Feedbacks
View all comments