What Is Type Deduction with Pointers, References, and Const?

auto deduces a variable's type from its initializer, but it does not copy that type wholesale. Some parts are deliberately discarded and others are kept, and which is which depends on references, pointers, and where exactly a const sits. This lesson gives you the rules and a way to check them yourself.

Two rules cover almost everything:

  1. A reference is dropped, unless you write it back with auto&.
  2. A top-level const is dropped, unless you write it back with const auto.

Everything else is a consequence of those two, plus knowing which const counts as top-level.

The Two Kinds of const

A top-level const applies to the object itself:

const int num;      // const applies to num
int* const pointer; // const applies to pointer

A low-level const applies to whatever is being pointed at or referred to:

const int& reference; // const applies to the referenced object
const int* pointer;   // const applies to the pointed-to object

A reference to const is always low-level. A pointer can carry either, or both at once:

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

References have no top-level const syntax, because a reference is already unable to be rebound and is therefore implicitly top-level const.

This distinction is the whole game: type deduction drops only top-level const. Low-level const always survives.

Starting Simple

With a plain value initializer, const is dropped and can be reapplied:

#include <iostream>

int main()
{
    constexpr double pi{ 3.14159 }; // constexpr implies const
    auto value{ pi };               // double, const dropped
    const auto locked{ pi };        // const double, const reapplied
    constexpr auto fixed{ pi };     // constexpr double

    std::cout << value << ' ' << locked << ' ' << fixed << '\n';

    return 0;
}

Output:

3.14159 3.14159 3.14159

constexpr is worth noting separately: it is not part of a type at all, so auto never deduces it. If you want a constexpr variable, you write constexpr yourself, every time.

References work the same way. Given a function returning std::string&, auto gives you a std::string copy, and auto& gives you the reference back.

Combining References and const

The interesting case is a function returning a reference to const, because dropping the reference changes what kind of const you are left with.

Key Concept
Dropping a reference can promote a low-level const into a top-level one. const std::string& has a low-level const. Remove the reference and you have const std::string, where that same const is now top-level, and therefore droppable.

That two-step explains the whole table below. The reference goes first, then any const that is now top-level:

Declaration Deduced type Why
auto message1 std::string Reference dropped, const became top-level and was dropped too
const auto message2 const std::string Same, then const reapplied
auto& message3 const std::string& Reference reapplied, so the const stays low-level and survives
const auto& message4 const std::string& Same as above, with a redundant but clarifying const

You do not have to take that on trust. static_assert with std::is_same_v checks each row at compile time, so if any claim were wrong the program would not build:

#include <iostream>
#include <string>
#include <type_traits>

const std::string& getTextConstRef()
{
    static const std::string text{ "beacon" };

    return text;
}

int main()
{
    auto message1{ getTextConstRef() };
    const auto message2{ getTextConstRef() };
    auto& message3{ getTextConstRef() };
    const auto& message4{ getTextConstRef() };

    static_assert(std::is_same_v<decltype(message1), std::string>);
    static_assert(std::is_same_v<decltype(message2), const std::string>);
    static_assert(std::is_same_v<decltype(message3), const std::string&>);
    static_assert(std::is_same_v<decltype(message4), const std::string&>);

    std::cout << "All four deductions match: " << message1 << '\n';

    return 0;
}

Output:

All four deductions match: beacon

The message4 row shows the habit worth forming. Its const is redundant, since message3 already produced a const reference, but writing it states the intent instead of leaving a reader to work out that the constness came along for the ride.

Best Practice
Reapply const, constexpr, and & whenever you want them, even when deduction would have supplied them anyway. Redundant qualifiers cost nothing and make the intent unmistakable.

Why Pointers Are Not Dropped

Unlike references, pointers survive deduction untouched. auto p{ getPointer() }; gives you a pointer.

Key Concept
The asymmetry follows from what the two things mean. Evaluating a reference gives you the referenced object, so deducing the object's type is the natural reading, and auto& is right there when you want the reference. Evaluating a pointer gives you the pointer itself, not its target, so deducing a pointer is the natural reading. Dereference it if the target is what you meant.

auto Versus auto*

Both forms usually produce the same type, but they get there differently. With auto, the deduced type includes the pointer. With auto*, the deduced type is the pointed-to type and the pointer is reattached afterwards.

Two consequences follow. First, auto* demands a pointer initializer, so auto* p{ *getPointer() }; is a compile error while auto p{ *getPointer() }; happily deduces std::string. That is a feature: it makes the compiler confirm you got a pointer.

Second, the two forms differ in how const attaches, which is the next section.

const With Pointers

With auto, a const on either side means the same thing, exactly as const int and int const do: make the pointer itself const. There is no way to express a pointer-to-const with plain auto.

With auto*, the position matters. A const on the left makes it a pointer to const; a const on the right makes it a const pointer. If that is hard to hold onto, compare against the non-deduced spelling: const int* is a pointer to const, so const auto* is too, and int* const is a const pointer, so auto* const is as well.

Starting from a const std::string* const, which carries both kinds:

Declaration Deduced type Top-level const Low-level const
auto pointer1 const std::string* Dropped Kept
auto* pointer2 const std::string* Dropped Kept
const auto pointer3 const std::string* const Reapplied Kept
auto* const pointer4 const std::string* const Reapplied Kept
const auto* pointer5 const std::string* Dropped Kept, and explicitly restated
const auto* const pointer6 const std::string* const Reapplied Kept, and explicitly restated

Verified the same way:

#include <iostream>
#include <string>
#include <type_traits>

int main()
{
    std::string text{ "beacon" };
    const std::string* const pointer{ &text };

    auto pointer1{ pointer };
    auto* pointer2{ pointer };
    const auto pointer3{ pointer };
    auto* const pointer4{ pointer };
    const auto* pointer5{ pointer };
    const auto* const pointer6{ pointer };

    static_assert(std::is_same_v<decltype(pointer1), const std::string*>);
    static_assert(std::is_same_v<decltype(pointer2), const std::string*>);
    static_assert(std::is_same_v<decltype(pointer3), const std::string* const>);
    static_assert(std::is_same_v<decltype(pointer4), const std::string* const>);
    static_assert(std::is_same_v<decltype(pointer5), const std::string*>);
    static_assert(std::is_same_v<decltype(pointer6), const std::string* const>);

    std::cout << "Pointer deductions verified: " << *pointer1 << '\n';

    return 0;
}

Output:

Pointer deductions verified: beacon

One combination is simply illegal: const auto const p{ pointer }; applies the qualifier twice and fails to compile. Use const auto* const when you want both.

Tip
Prefer auto* when you are deducing a pointer. It states that a pointer is expected, makes the compiler enforce it, and lets you place const on either side to say precisely which one you mean.

Summary

Two kinds of const: a top-level const applies to the object itself (const int, int* const), while a low-level const applies to what is pointed at or referred to (const int&, const int*). A pointer can have both.

What deduction drops: references first, then any const that is top-level once the reference is gone. Low-level const is never dropped. Pointers are never dropped.

Reference plus const: dropping a reference can turn a low-level const into a top-level one, which is why auto on a const std::string& yields a plain std::string, while auto& yields const std::string&.

constexpr: not part of the type, never deduced, always written explicitly.

auto versus auto*: auto absorbs the pointer into the deduced type, auto* deduces the pointed-to type and reattaches the pointer, so it fails unless the initializer really is a pointer.

const placement: with auto, either side means a const pointer. With auto*, left means pointer-to-const and right means const pointer.

Habit: reapply &, const, and constexpr whenever you want them, redundant or not, and reach for auto* over auto for pointers.