每周技巧 #232:变量声明中何时使用 `auto`

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #232: When to Use auto for Variable Declarations

原文最初作为 TotW #232 发布于 2024 年 6 月 20 日。

作者:Kenji Inoue 和 Google 工程师 Michael Diamond

更新于 2024 年 9 月 30 日。

快捷链接:abseil.io/tips/232

风格指南在类型推导(包括 auto)一节中说:

只有在能让不熟悉项目的读者觉得代码更清晰,或者让代码更安全时,才使用类型推导。不要仅仅为了避免书写显式类型的不便而使用它。

讽刺的是,过度使用 auto 往往会让代码变得更不清晰。不过随着时间推移,一些模式已经浮现出来,在这些模式中使用 auto 可以提升代码清晰性和安全性,例如:

  • 正确指定类型可能很困难,且指定错误类型会导致性能或正确性问题的情况,例如对 map 做基于范围的 for 循环。
  • 类型信息确实冗余,写出完整类型会分散注意力的情况,例如常用模板化工厂函数和某些迭代器用法。
  • 泛型代码中,只要语法正确,类型本身并不重要的情况。

下面会逐一讨论这些情况,重点澄清哪些情况下 auto 会让代码更安全或更清晰。

对 map 的基于范围的 for 循环

下面代码有个问题:map 中每个元素都会被无意拷贝:

1
2
3
4
5
6
absl::flat_hash_map<std::string, DogBreed> dog_breeds_by_name = ...;
// `name_and_breed` 会为 map 的每个元素拷贝构造。
for (const std::pair<std::string, DogBreed>& name_and_breed :
     dog_breeds_by_name) {
  ...
}

意外拷贝发生的原因是,关联容器的 value_typestd::pair<const Key, Value>,而如果底层类型可以隐式转换,std::pair 允许 pair 对象之间发生隐式转换。这里 std::pair::first_typestd::string,而 map 条目的 std::pair::first_typeconst std::string,两个 pair 不是同一类型,于是发生隐式转换,尽管 name_and_breed 声明为引用,pair 内容仍被拷贝。

使用 auto,可能再结合结构化绑定(技巧 #169),可以让代码更安全、性能更好:

1
2
3
4
5
6
absl::flat_hash_map<std::string, DogBreed> dog_breeds_by_name = ...;

// 如果元素类型能从局部上下文看清,可以使用带结构化绑定的 `auto`。
for (const auto& [name, breed] : dog_breeds_by_name) {
  ...
}

有时元素类型无法从局部上下文明显看出。在这种情况下,可以这样做:

1
2
3
4
5
6
// 不带结构化绑定的 `auto`,允许指定元素类型。
for (const auto& name_and_breed : dog_breeds_by_name) {
  const std::string& name = name_and_breed.first;
  const DogBreed& breed = name_and_breed.second;
  ...
}

迭代器

迭代器类型名称冗长,并且当容器类型在附近可见时,往往提供冗余类型信息。

下面代码片段把迭代器赋给一个局部变量:

1
2
3
4
5
std::vector<std::string> names = ...;
std::vector<std::string>::iterator name_it = names.begin();
while (name_it != names.end()) {
  ...
}

所有容器都会暴露 begin()end() 函数,它们返回迭代器,而这些迭代器类型是 ContainerType::iteratorContainerType::const_iterator

当容器类型在附近可见时,写出这些类型只有一个小好处:区分 iteratorconst_iterator。因为容器类型部分(例如 std::vector<std::string>)与容器本身相同。在这种情况下,我们可以使用 auto 移除冗余,而不隐藏有用信息:

1
2
3
4
5
std::vector<std::string> names = ...;
auto name_it = names.begin();
while (name_it != names.end()) {
  ...
}

当容器类型在局部不可见时,优先写出完整迭代器类型或元素类型:

1
2
3
4
std::vector<std::string>::iterator name_it = names_.begin();
while (name_it != names_.end()) {
  ...
}
1
2
3
4
5
auto name_it = names_.begin();
while (name_it != names_.end()) {
  const std::string& name = *name_it;
  ...
}

std::make_unique 和其他广泛使用的工厂函数

在下面代码片段中,std::make_uniqueproto2::MakeArenaSafeUnique 指定了要实例化的类型。

1
2
3
4
5
std::unique_ptr<MyFavoriteType> my_type =
    std::make_unique<MyFavoriteType>(...);

proto2::ArenaSafeUniquePtr<MyFavoriteProto> my_proto =
    proto2::MakeArenaSafeUnique<MyFavoriteProto>(arena);

大家普遍知道 std::make_unique<T> 返回 std::unique_ptr<T>proto2::MakeArenaSafeUnique<T> 返回 proto2::ArenaSafeUniquePtr<T>。特别是,结果类型中重要的部分 T 已经在右侧表达式中指定,而且这是广泛知识,不是项目特定知识。这里可以使用 auto 移除冗余,而不隐藏有用信息:

1
2
3
auto my_type = std::make_unique<MyFavoriteType>(...);

auto my_proto = proto2::MakeArenaSafeUnique<MyFavoriteProto>(arena);

泛型代码

编写泛型代码时,例如模板或 GoogleTest matcher,有些情况下类型可能无法指定或非常难指定(例如通过模板元编程或 decltype 写出的类型)。这些情况下 auto 也可能合适。不过,这些情况应该很少见。

其他情况:避免使用 auto

当类型很长且对来说似乎显而易见时,使用 auto 可能很诱人,但请记住,未来的代码读者可能不熟悉你的项目及其使用的类型。例如,考虑一个常见的嵌套 proto 访问模式。

1
2
// 当然 `breed` 的类型是 `const DetailedDomesticCatBreed&`!
const auto& breed = cat.pedigree().detailed_breed();

auto 还可能隐藏 const 性、类型是否为指针、是否发生拷贝等基本语义(技巧 #44)。

1
2
3
// 作者是不是想在这里拷贝?即使 `detailed_breed()` 返回引用,
// `breed` 不是引用这一点对所有读者也并不明显!
auto breed = cat.pedigree().detailed_breed();
1
2
// 类型和语义都清楚。
const DetailedDomesticCatBreed& breed = cat.pedigree().detailed_breed();

建议总结

  • 当手写更具体类型有较高正确性或性能风险时,使用 auto
  • 当有用的类型信息在局部可见时,使用 auto 移除冗余,而不隐藏有用信息。
  • 对于某些类型无法指定或很难指定的泛型代码,auto 可能合适;这些情况应该很少见。
  • 其他情况下避免使用 auto:虽然它可能让你更容易写代码,或者避免换行,但它很可能让不熟悉你项目的人更难理解代码。

相关阅读

每周技巧 #231:这里和那里之间:几个容易忽视的小算法

上一节

每周技巧 #234:按值、按指针,还是按引用传递?

下一节