返回文章索引

C++ 每日一题:auto、auto&、auto&& 的作用和区别

从模板类型推导、引用折叠和值类别出发,讲清 auto、auto&、auto&& 如何处理 const、引用、数组和左值/右值,并总结遍历与泛型转发中的正确选择。

GitHub 原文

问:autoauto&auto&& 的作用和区别是什么?

请判断下面变量的类型,并说明它们能否修改原对象:

int value = 10;
const int const_value = 20;

auto a = const_value;
auto& b = const_value;
auto&& c = value;
auto&& d = 42;

先记住结论:

写法 核心语义 能绑定什么 是否通常产生新对象
auto x = expr 按值推导 左值、右值
auto& x = expr 左值引用推导 左值
auto&& x = expr 转发引用推导 左值、右值

对于开头的代码:

// a 的类型是 int:复制 const_value,顶层 const 被忽略。
auto a = const_value;

// b 的类型是 const int&:引用原对象,并保留 const。
auto& b = const_value;

// c 的声明类型最终是 int&:左值触发引用折叠。
auto&& c = value;

// d 的声明类型是 int&&:右值绑定到右值引用。
auto&& d = 42;

因此:

  • 修改 a 不影响 const_value
  • b 引用 const_value,但不能通过 b 修改它。
  • 修改 c 就是在修改 value
  • d 绑定临时对象;在 d 的作用域内,该临时对象的生命周期被延长。

一、auto:按值推导

auto 的规则近似于函数模板的按值参数:

template <class T>
void function(T parameter);

auto 声明变量时,可以把 auto 想象成这里的 T。按值推导通常会:

  1. 去掉引用。
  2. 去掉顶层 const 和顶层 volatile
  3. 让数组和函数发生按值传参时的退化。

1. 去掉引用和顶层 const

int value = 10;
int& reference = value;
const int const_value = 20;

auto a = reference;    // int
auto b = const_value;  // int

ab 都是独立的 int 对象:

a = 100;  // 不影响 value
b = 200;  // 合法,不影响 const_value

注意,被指向对象的底层 const 不会被去掉:

const int value = 10;
const int* pointer = &value;

auto copied_pointer = pointer;  // const int*

这里 pointer 自身可以被复制,但它指向的是 const int,所以 copied_pointer 仍然不能用来修改 value

2. 数组会退化成指针

int numbers[3] = {1, 2, 3};

auto first = numbers;  // int*
auto& second = numbers; // int (&)[3]

first 只保存首元素地址,数组长度信息已经丢失;second 是整个数组的引用,仍然保留长度 3

3. auto 不等于“什么都不做”

std::string name = "C++";
auto copy = name;

copy 是新的 std::stringauto 只是让编译器推导类型,不会自动把值语义改成引用语义,也不会自动避免复制。

二、auto&:只绑定左值,并保留 cv 限定

auto& 表示推导一个左值引用:

int value = 10;
const int const_value = 20;

auto& first = value;        // int&
auto& second = const_value; // const int&

修改 first 会修改原对象:

first = 99;
std::cout << value; // 99

second 保留了原对象的 const,不能通过它写入:

// second = 99; // 编译错误

普通 auto& 不能绑定纯右值:

// auto& reference = 42; // 编译错误

如果只想只读访问,同时兼容左值和临时对象,常用 const auto&

const auto& first = value;       // const int&
const auto& second = 42;         // const int&,临时对象生命周期延长
const auto& third = makeObject(); // 避免一次不必要的复制

const auto& 是“只读借用”的常见写法;auto& 是“可写借用,但必须尊重原对象的 const 属性”。

三、auto&&:左值变左值引用,右值变右值引用

auto&& 中的 auto 需要根据初始化表达式推导时,它是转发引用,也常被称为万能引用。

int value = 10;
const int const_value = 20;

auto&& a = value;             // int&
auto&& b = const_value;       // const int&
auto&& c = 42;                // int&&
auto&& d = std::move(value);  // int&&

关键规则是:

  • 初始化表达式是左值,auto&& 最终折叠成左值引用。
  • 初始化表达式是右值,auto&& 最终是右值引用。

引用折叠规则

引用折叠一共四种:

T&  &   -> T&
T&  &&  -> T&
T&& &   -> T&
T&& &&  -> T&&

可以简化成一句话:只要组合中出现左值引用 &,结果就是左值引用;只有两个 && 相遇,结果才是右值引用。

例如:

int value = 10;
auto&& reference = value;

因为 value 是左值,auto 被推导为 int&。代回声明后相当于:

int& && reference = value;

经过引用折叠,最终类型是 int&

四、声明为 T&& 的变量,表达式仍然可能是左值

这是最容易答错的面试点:

auto&& value = 42; // value 的声明类型是 int&&

虽然 decltype(value)int&&,但是表达式 value 有名字,所以使用 value 时它是左值:

static_assert(std::is_same_v<decltype(value), int&&>);
static_assert(std::is_same_v<decltype((value)), int&>);

如果要恢复初始化时的值类别,需要显式转换:

consume(std::move(value));

在泛型代码里,应该使用 std::forward

auto wrapper = [](auto&& argument) {
    consume(std::forward<decltype(argument)>(argument));
};
  • 调用 wrapper(lvalue) 时,继续把参数作为左值传递。
  • 调用 wrapper(Temporary{}) 时,继续把参数作为右值传递。

这就是完美转发的基础。

五、auto&& 不总是“右值引用”

&& 是否为转发引用,要看是否发生了特殊的类型推导:

auto&& first = expression;        // 是转发引用

auto lambda = [](auto&& value) {  // 是转发引用
};

const auto&& second = expression; // 不是转发引用,带 const 后只接右值

同样,在普通函数中已经写死的类型不是转发引用:

void function(std::string&& value); // 只接 std::string 右值

只有类似 T&&auto&&,并且 T / auto 正在按对应规则推导时,才可能根据实参在 &&& 之间变化。

六、范围 for 应该选哪个?

假设有:

std::vector<std::string> names = {"C", "C++", "Rust"};

1. auto:逐个复制

for (auto name : names) {
    name += "!"; // 修改副本,不影响 names
}

适合确实需要副本,或者元素很小、复制成本可忽略的场景。

2. auto&:直接修改元素

for (auto& name : names) {
    name += "!"; // 修改 names 中的元素
}

3. const auto&:只读且避免复制

for (const auto& name : names) {
    std::cout << name << '\n';
}

这是遍历大型对象并只读访问时最常见的选择。

4. auto&&:最通用的元素绑定

for (auto&& name : names) {
    // 对普通 vector<string>,这里的 name 是 string&。
}

auto&& 可以同时适配返回真正引用或代理对象的迭代器,例如某些视图和 std::vector<bool>。编写泛型范围算法时它更通用;在普通业务代码里,如果明确只处理左值容器,auto&const auto& 往往更直观。

七、花括号初始化的特殊规则

auto 遇到花括号时还有一组常考规则:

auto a = {1, 2, 3}; // std::initializer_list<int>
auto b{1};          // int

// auto c{1, 2};    // 编译错误:直接列表初始化只能有一个元素
// auto d = {1, 2.0}; // 编译错误:元素无法推导成同一种类型

所以不要仅凭右侧“看起来像整数”就断定 auto 一定是 int

八、autodecltype(auto) 的区别

auto 使用模板式推导,经常去掉引用和顶层 constdecltype(auto) 则严格遵循 decltype 规则,能够保留表达式的精确类型和值类别。

const int value = 10;

auto a = (value);           // int
decltype(auto) b = (value); // const int&

函数需要原样返回某个表达式时,decltype(auto) 很有用:

decltype(auto) getElement(Container& container, std::size_t index)
{
    return container[index];
}

但它对括号非常敏感:

int value = 10;

decltype(auto) a = value;   // int
decltype(auto) b = (value); // int&

九、完整可运行示例

#include <iostream>
#include <type_traits>
#include <utility>

int main()
{
    int value = 10;
    const int const_value = 20;

    auto copied = const_value;
    auto& const_reference = const_value;
    auto&& lvalue_reference = value;
    auto&& rvalue_reference = 42;

    static_assert(std::is_same_v<decltype(copied), int>);
    static_assert(
        std::is_same_v<decltype(const_reference), const int&>);
    static_assert(
        std::is_same_v<decltype(lvalue_reference), int&>);
    static_assert(
        std::is_same_v<decltype(rvalue_reference), int&&>);

    // 有名字的变量表达式是左值,即使声明类型为 int&&。
    static_assert(
        std::is_same_v<decltype((rvalue_reference)), int&>);

    copied = 100;
    lvalue_reference = 30;
    rvalue_reference = 50;

    int numbers[3] = {1, 2, 3};
    auto pointer = numbers;
    auto& array_reference = numbers;

    static_assert(std::is_same_v<decltype(pointer), int*>);
    static_assert(
        std::is_same_v<decltype(array_reference), int (&)[3]>);

    std::cout << "copied: " << copied << '\n'
              << "const_value: " << const_value << '\n'
              << "value: " << value << '\n'
              << "temporary through reference: "
              << rvalue_reference << '\n'
              << "array size: "
              << std::extent_v<std::remove_reference_t<
                     decltype(array_reference)>>
              << '\n';
}

编译运行:

g++ -std=c++17 -Wall -Wextra -pedantic auto_reference.cpp \
    -o auto_reference
./auto_reference

输出:

copied: 100
const_value: 20
value: 30
temporary through reference: 50
array size: 3

十、常见错误

错误 1:认为 auto 会保留引用

int value = 10;
int& reference = value;
auto copy = reference; // int,不是 int&

需要引用时必须显式写 auto& 或根据场景写 auto&&

错误 2:认为 auto&& 永远是右值引用

int value = 10;
auto&& reference = value; // int&

初始化表达式是左值时,结果是左值引用。

错误 3:把有名字的右值引用变量继续当成右值

auto&& object = makeObject();
consume(object);            // object 表达式是左值
consume(std::move(object)); // 显式转成右值

泛型代码不要无条件 std::move,应该用 std::forward 保留调用者传入的值类别。

错误 4:为了避免复制,到处写 auto&&

auto&& 很通用,但也更容易隐藏对象生命周期和值类别。普通代码应按意图选择:

  • 需要独立副本:auto
  • 需要修改已有左值:auto&
  • 只读且避免复制:const auto&
  • 需要兼容左值、右值并保留值类别:auto&& 配合 std::forward

面试总结

面试时可以这样回答:

auto 是按值推导,会去掉引用和顶层 const,并可能让数组、函数退化;变量通常是一个新对象。auto& 推导左值引用,只能绑定左值,保留原对象的 const 属性,修改它会修改原对象。auto&& 在发生 auto 类型推导时是转发引用:绑定左值会通过引用折叠得到左值引用,绑定右值才得到右值引用。还要注意,即使变量的声明类型是右值引用,只要变量有名字,这个变量表达式就是左值;泛型转发时要使用 std::forward。

最后记住四句话:

  1. auto:我要一个值。
  2. auto&:我要借用并可能修改一个左值。
  3. const auto&:我要只读借用,兼容临时对象。
  4. auto&&:我要根据初始化表达式保留左值或右值属性。