何时使用auto初始化
本文由《effective morden cpp》的第二章总结而来,讨论我们在编程过程中何时使用auto初始化,以及哪些场景不应该使用auto初始化。
优先考虑auto初始化情况
使用auto初始化可以自动帮助我们推导类型,减少了手动指定类型时可能出现的错误,特别是对于复杂类型,比如下面对于一个局部变量使用解引用迭代器的方式初始化:
template<typename It> //对从b到e的所有元素使用
void dwim(It b, It e) //dwim(“do what I mean”)算法
{
while (b != e) {
typename std::iterator_traits<It>::value_type
currValue = *b;
…
}
// 可以使用auto替代
while (b != e) {
auto currValue = *b;
…
}
}
同时,我们在使用auto初始化时可以避免有些变量忘记初始化导致的未定义行为,因为使用auto时必须指定该变量的值类型,不然在编译器就会报错。
int x1; //潜在的未初始化的变量
auto x2; //错误!必须要初始化
auto x3 = 0; //没问题,x已经定义了
// 不好的做法
std::vector<int>::iterator it; // 未初始化,使用它会导致未定义行为
// 使用 auto 的正确做法
std::vector<int> vec = {1, 2, 3};
auto it = vec.begin(); // 必须提供初始值,否则编译错误
还有些情况必须使用auto声明变量,比如说lambda表达式,因为lambda表达式在底层会感觉参数列表与捕获的参数生成一个匿名可调用类对象,所以作为用户来说,我们并不知道这个可调用类对象的类型,所以此时必须使用auto声明。但是有人可能想到,我们可以使用std::function声明一个可调用对象,std::function是一个C++11标准模板库中的一个模板,它泛化了函数指针的概念。与函数指针只能指向函数不同,std::function可以指向任何可调用对象,也就是那些像函数一样能进行调用的东西。所以可以使用std::function声明这个lambda表达式:
auto derefUPLess =
[](const std::unique_ptr<Widget> &p1, //用于std::unique_ptr
const std::unique_ptr<Widget> &p2) //指向的Widget类型的
{ return *p1 < *p2; }; //比较函数
auto derefLess = //C++14版本
[](const auto& p1, //被任何像指针一样的东西
const auto& p2) //指向的值的比较函数
{ return *p1 < *p2; };
std::function<bool(const std::unique_ptr<Widget> &,
const std::unique_ptr<Widget> &)>
derefUPLess = [](const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2)
{ return *p1 < *p2; }; // std::function声明lambda表达式
语法冗长不说,还需要重复写很多形参类型,使用std::function还不如使用auto。用auto声明的变量保存一个和闭包一样类型的(新)闭包,因此使用了与闭包相同大小存储空间。实例化std::function并声明一个对象这个对象将会有固定的大小。这个大小可能不足以存储一个闭包,这个时候std::function的构造函数将会在堆上面分配内存来存储,这就造成了使用std::function比auto声明变量会消耗更多的内存。
换句话说,std::function方法比auto方法要更耗空间且更慢,还可能有out-of-memory异常。并且正如上面的例子,比起写std::function实例化的类型来,使用auto要方便得多。所以我们应该使用auto声明lambda表达式。
使用auto还可以避免一种叫做类型快捷方式的问题,比如类型快捷方式问题主要出现在我们使用简写或隐式类型转换时,这可能导致意外的行为或错误。比如当我们使用 unsigned char c = 300 时,由于 unsigned char 的范围是 0-255,300 会被截断为 44,这种隐式转换可能导致难以发现的 bug。
同样,当我们使用 float f = 3.14159265359 时,由于 3.14159265359 是 double 类型,赋值给 float 会导致精度损失。在指针使用中,使用 int* p = 0 或 int* q = NULL 这样的快捷方式也不如使用 auto p = nullptr 更安全。在容器操作中,使用 size_t index = -1 然后访问容器元素会导致未定义行为,而使用 auto index = vec.size() 则能自动推导出正确的类型。在模板编程中,使用类型快捷方式可能导致意外的类型转换,而 auto 能保持原始类型,避免这些问题。auto 的主要优势在于它强制我们明确表示类型转换的意图,而不是依赖隐式转换,它会自动推导出最合适的类型,避免了数值截断和精度损失,使代码更安全、更可靠。当然,在使用 auto 时,我们仍然需要注意在确实需要类型转换时使用 static_cast 等显式转换,使用适当的字面量后缀(如 f、L、ULL 等)来确保得到正确的类型,并了解 auto 的类型推导规则。
考虑下面这段代码:
std::vector<int> v;
…
unsigned sz = v.size();
v.size()的标准返回类型是std::vector<int>::size_type,但是只有少数人意识到了这一点,为了避免隐式类型转化造成的BUG,使用auto可以确保你不需要浪费时间:auto sz =v.size();
再考虑下面这段代码:
std::unordered_map<std::string, int> m;
…
for(const std::pair<std::string, int>& p : m)
{
… //用p做一些事
}
这个表达看起来很正常,要想看到错误你就得知道std::unordered_map的key是const的,所以hash table,中的std::pair的类型不是std::pair<std::string, int>,而是std::pair<const std::string, int>。但那不是在循环中的变量p声明的类型。编译器会努力的找到一种方法把std::pair<const std::string, int>,由于const对象不可修改,但是const对象的拷贝与const对象无关了,可以修改,所以编译器找到的方法是通过拷贝m中的对象创建一个临时对象,这个临时对象的类型是p想绑定到的对象的类型,即m中元素的类型,然后把p的引用绑定到这个临时对象上。在每个循环迭代结束时,临时对象将会销毁,效率非常低下。
使用auto可以避免这些很难被意识到的类型不匹配的错误:for(const auto& p : m)。
在上述例子中,显式的指定类型可能会导致你不想看到的类型转换,但是使用auto的话就可以避免上述问题,而且更加方便。
auto推导若非己愿,使用显示类型初始化
这一部分主要介绍使用无脑使用auto类型推导可能造成的错误,举个例子,假如我有一个函数,参数为Widget,返回一个std::vector<bool>,这里的bool表示Widget是否提供一个独有的特性。std::vector<bool> features(const Widget& w);。更进一步假设第5个bit表示Widget是否具有高优先级,我们可以写这样的代码:
Widget w;
…
auto highPriority = features(w)[5]; //w高优先级吗?
…
processWidget(w, highPriority); //根据它的优先级处理w
我们在声明highPriority时按照上面说的优先考虑auto,但是在这里会出现BUG。如果我们在vscode中将光标放在这个变量上面,可以看到这个变量的类型是std::vector<bool>::reference而不是我们期待的bool类型。std::vector<bool>::reference之所以存在是因为std::vector<bool>规定了使用一个打包形式表示它的bool,每个bool占一个bit。那给std::vector的operator[]带来了问题,因为std::vector<T>的operator[]应当返回一个T&,但是C++禁止对bits的引用。无法返回一个bool&,std::vector<bool>的operator[]返回一个行为类似于bool&的对象。
要想成功扮演这个角色,bool&适用的上下文std::vector<bool>::reference也必须一样能适用。在std::vector<bool>::reference的特性中,使这个原则可行的特性是一个可以向bool的隐式转化。转化之后为bool而不是bool&。
如果使用bool声明的话bool highPriority = features(w)[5];,这里,features返回一个std::vector<bool>对象后再调用operator[],operator[]将会返回一个std::vector<bool>::reference对象,然后再通过隐式转换赋值给bool变量highPriority。highPriority因此表示的是features返回的std::vector<bool>中的第五个bit,这也正如我们所期待的那样。
但是使用auto的话auto highPriority = features(w)[5];,features返回一个std::vector<bool>对象,再调用operator[],operator[]将会返回一个std::vector<bool>::reference对象,但是现在这里有一点变化了,auto推导highPriority的类型为std::vector<bool>::reference,但是highPriority对象没有第五bit的值。
std::vector<bool>::reference是一个代理类(proxy class)的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类。很多情况下都会使用代理类,std::vector<bool>::reference展示了对std::vector<bool>使用operator[]来实现引用bit这样的行为。一些代理类被设计于用以对客户可见。比如std::shared_ptr和std::unique_ptr。其他的代理类则或多或少不可见,比如std::vector<bool>::reference就是不可见代理类的一个例子。
给出一个矩阵类Matrix和矩阵对象m1,m2,m3,m4,举个例子,这个表达式
Matrix sum = m1 + m2 + m3 + m4;
在这个代理类中重载了+号运算符,使其构造一个对象,而不是直接计算,达到了延迟计算的效果。可以参考这篇博客了结一下表达式模板与CRTP,也就是说,对两个Matrix对象使用operator+将会返回如Sum<Matrix, Matrix>这样的代理类作为结果而不是直接返回一个Matrix对象。在std::vector<bool>::reference和bool中存在一个隐式转换,同样对于Matrix来说也可以存在一个隐式转换允许Matrix的代理类转换为Matrix,这让表达式等号“=”右边能产生代理对象来初始化sum。
作为一个通则,不可见的代理类通常不适用于auto。但是你怎么能意识到你正在使用代理类?应用他们的软件不可能宣告它们的存在。它们被设计为不可见。虽然“不可见”代理类都在程序员日常使用的雷达下方飞行,但是很多库都证明它们可以上方飞行。当你越熟悉你使用的库的基本设计理念,你的思维就会越活跃,不至于思维僵化认为代理类只能在这些库中使用。很少会出现源代码全都用代理对象,它们通常用于一些函数的返回类型,所以通常能从函数签名中看出它们的存在。
namespace std{ //来自于C++标准库
template<class Allocator>
class vector<bool, Allocator>{
public:
…
class reference { … };
reference operator[](size_type n);
…
};
}
假设你知道对std::vector<T>使用operator[]通常会返回一个T&,在这里operator[]不寻常的返回类型提示你它使用了代理类。多关注你使用的接口可以暴露代理类的存在。
不管你怎么发现它们的,一旦看到auto推导了代理类的类型而不是被代理的类型,解决方案并不需要抛弃auto。auto本身没什么问题,问题是auto不会推导出你想要的类型。解决方案是强制使用一个不同的类型推导形式,这种方法我通常称之为显式类型初始器惯用法。
auto highPriority = static_cast<bool>(features(w)[5]);
这里,features(w)[5]还是返回一个std::vector<bool>::reference对象,就像之前那样,但是这个转型使得表达式类型为bool,然后auto才被用于推导highPriority。