条款6:当auto推导出非预期类型时应当使用显式的类型初始化

优质
小牛编辑
131浏览
2023-12-01

条款5解释了使用auto关键字去声明变量,这样就比直接显示声明类型提供了一系列的技术优势,但是有时候auto的类型推导会和你想的南辕北辙。举一个例子,假设我有一个函数接受一个Widget返回一个std::vector<bool>,其中每个bool表征Widget是否接受一个特定的特性:

std::vector<bool> features(const Widget& w);

进一步的,假设第五个bit表示Widget是否有高优先级。我们可以这样写代码:

Widget w;
…
bool highPriority = features(w)[5];         // w是不是个高优先级的?
…
processWidget(w, highPriority);             // 配合优先级处理w

这份代码没有任何问题。它工作正常。但是如果我们做一个看起来无伤大雅的修改,把highPriority的显式的类型换成auto

auto highPriority = features(w)[5];         // w是不是个高优先级的?

情况变了。所有的代码还是可以编译,但是他的行为变得不可预测:

processWidget(w, highPriority);             // 未定义行为

正如注释中所提到的,调用processWidget现在会导致未定义的行为。但是为什么呢?答案是非常的令人惊讶的。在使用auto的代码中,highPriority的类型已经不是bool了。尽管std::vector<bool>从概念上说是bool的容器,对std::vector<bool>operator[]运算符并不一定是返回容器中的元素的引用(std::vector::operator[]对所有的类型都返回引用,就是除了bool)。事实上,他返回的是一个std::vector<bool>::reference对象(是一个在std::vector<bool>中内嵌的class)。

std::vector<bool>::reference存在是因为std::vector<bool>是对bool数据封装的模板特化,一个bit对应一个bool。这就给std::vector::operator[]带来了问题,因为std::vector<T>operator[]应该返回一个T&,但是C++禁止bits的引用。没办法返回一个bool&std::vector<T>operator[]于是就返回了一个行为上和bool&相似的对象。想要这种行为成功,std::vector<bool>::reference对象必须能在bool&的能处的语境中使用。在std::vector<bool>::reference对象的特性中,是他隐式的转换成bool才使得这种操作得以成功。(不是转换成bool&,而是bool。去解释详细的std::vector<bool>::reference对象如何模拟一个bool&的行为有有些偏离主题,所以我们就只是简单的提一下这种隐式转换只是这种技术中的一部。)

在大脑中带上这种信息,再次阅读原先的代码:

bool highPriority = features(w)[5];         // 直接显示highPriority的类型

这里,features返回了一个std::vector<bool>对象,在这里operator[]被调用。operator[]返回一个std::vector<bool>::reference对象,这个然后隐式的转换成highPriority需要用来初始化的bool类型。于是就以features返回的std::vector<bool>的第五个bit的数值来结束highPriority的数值,这也是我们所预期的。

和使用autohighPriority声明进行对比:

auto highPriority = features(w)[5];         // 推导highPriority的类型

这次,features返回一个std::vector<bool>对象,而且,operator[]再次被调用。operator[]继续返回一个std::vector<bool>::reference对象,但是现在有一个变化,因为auto推导highPriority的类型。highPriority根本并没有features返回的std::vector<bool>的第五个bit的数值。

数值和std::vector<bool>::reference是如何实现的是有关系的。一种实现是这样的对象包含一个指向包含bit引用的机器word的指针,在word上面加上偏移。考虑这个对highPriority的初始化的意义,假设std::vector<bool>::reference的实现是恰当的。

调用features会返回一个临时的std::vector<bool>对象。这个对象是没有名字的,但是对于这个讨论的目的,我会把它叫做tempoperator[]是在temp上调用的,std::vector<bool>::reference返回一个由temp管理的包含一个指向一个包含bits的数据结构的指针,在word上面加上偏移定位到第五个bit。highPriority也是一个std::vector<bool>::reference对象的一份拷贝,所以highPriority也在temp中包含一个指向word的指针,加上偏移定位到第五个bit。在这个声明的结尾,temp被销毁,因为它是个临时对象。因此,highPriority包含一个野指针,这也就是调用processWidget会造成未定义的行为的原因:

processWidget(w, highPriority);         // 未定义的行为,highPriority包含野指针

std::vector<bool>::reference是代理类的一个例子:一个类的存在是为了模拟和对外行为和另外一个类保持一致。代理类在各种各样的目的上被使用。std::vector<bool>::reference的存在是为了提供一个对std::vector<bool>operator[]的错觉,让它返回一个对bit的引用,而且标准库的智能指针类型(参考第4章)也是一些对托管的资源的代理类,使得他们的资源管理类似于原始指针。代理类的功能是良好确定的。事实上,“代理”模式是软件设计模式中的最坚挺的成员之一。

一些代理类被设计用来隔离用户。这就是std::shared_ptrstd::unique_ptr的情况。另外一些代理类是为了一些或多或少的不可见性。std::vector<bool>::reference就是这样一个“不可见”的代理,和他类似的是std::bitset,对应的是std::bitset::reference

同时在一些C++库里面的类存在一种被称作表达式模板的技术。这些库最开始是为了提高数值运算的效率。提供一个Matrix类和Matrix对象m1, m2, m3 and m4,举一个例子,下面的表达式:

Matrix sum = m1 + m2 + m3 + m4;

可以计算的更快如果Matrixoperator+返回一个结果的代理而不是结果本身。这是因为,对于两个Matrixoperator+可能返回一个类似于Sum<Matrix, Matrix>的代理类而不是一个Matrix对象。和std::vector<bool>::reference一样,这里会有一个隐式的从代理类到Matrix的转换,这个可能允许sum从由=右边的表达式产生的代理对象进行初始化。(其中的对象可能会编码整个初始化表达式,也就是,变成一种类似于Sum<Sum<Sum<Matrix, Matrix>, Matrix>, Matrix>的类型。这是一个客户端需要屏蔽的类型。)

作为一个通用的法则,“不可见”的代理类不能和auto愉快的玩耍。这种类常常它的生命周期不会被设计成超过一个单个的语句,所以创造这样的类型的变量是会违反库的设计假定。这就是std::vector<bool>::reference的情况,而且我们可以看到这种违背约定的做法会导致未定义的行为。

因此你要避免使用下面的代码的形式:

auto someVar = expression of "invisible" proxy class type;

但是你怎么能知道代理类被使用呢?软件使用它们的时候并不可能会告知它们的存在。它们是不可见的,至少在概念上!一旦你发现了他们,难道你就必须放弃使用auto加之条款5所声明的auto的各种好处吗?

我们先看看怎么解决如何发现它们的问题。尽管“不可见”的代理类被设计用来fly beneath programmer radar in day-to-day use,库使用它们的时候常常会撰写关于它们的文档来解释为什么这样做。你对你所使用的库的基础设计理念越熟悉,你就越不可能在这些库中被代理的使用搞得狼狈不堪。

当文档不够用的时候,头文件可以弥补空缺。很少有源码封装一个完全的代理类。它们常常从一些客户调用者期望调用的函数返回,所有函数签名常常可以表征它们的存在。这里是std::vector<bool>::operator[]的例子:

namespace std {                     // from C++ Standards
    template <class Allocator>
    class vector<bool, Allocator> {
        public:
        …
        class reference { … };
        reference operator[](size_type n);
        …
    };
}

假设你知道对std::vector<T>operator[]常常返回一个T&,在这个例子中的这种非常规的operator[]的返回类型一般就表征了代理类的使用。在你正在使用的这些接口之上加以关注常常可以发现代理类的存在。

在实践上,很多的开发者只会在尝试修复一些奇怪的编译问题或者是调试一些错误的单元测试结果中发现代理类的使用。不管你是如何发现它们,一旦auto被决定作为推导代理类的类型而不是它被代理的类型,它就不需要涉及到关于autoauto自己本身没有问题。问题在于auto推导的类型不是所想让它推导出来的类型。解决方案就是强制一个不同的类型推导。我把这种方法叫做显式的类型初始化原则。

显式的类型初始化原则涉及到使用auto声明一个变量,但是转换初始化表达式到auto想要的类型。下面就是一个强制highPriority类型是bool的例子:

auto highPriority = static_cast<bool>(features(w)[5]);

这里,features(w)[5]还是返回一个std::vector<bool>::reference的对象,就和它经常的表现一样,但是强制类型转换改变了表达式的类型成为bool,然后auto才推导其作为highPriority的类型。在运行的时候,从std::vector<bool>::operator[]返回的std::vector<bool>::reference对象支持执行转换到bool的行为,作为转换的一部分,从features返回的任然存活的指向std::vector<bool>的指针被间接引用。这样就在运行的开始避免了未定义行为。索引5然后放置在bits指针的偏移上,然后暴露的bool就作为highPriority的初始化数值。

针对于Matrix的例子,显示的类型初始化原则可能会看起来是这样的:

auto sum = static_cast<Matrix>(m1 + m2 + m3 + m4);

关于这个原则下面的程序并不禁止初始化但是要排除代理类类型。强调你要谨慎地创建一个类型的变量,它和从初始化表达式生成的类型是不同的也是有帮助意义的。举一个例子,假设你有一个函数去计算一些方差:

double calcEpsilon();               // 返回方差

calcEpsilon明确的返回一个double,但是假设你知道你的程序,float的精度就够了的时候,而且你要关注doublefloat的长度的区别。你可以声明一个float变量去存储calcEpsilon的结果:

float ep = calcEpsilon();           // 隐式转换double到float

但是这个会很难表明“我故意减小函数返回值的精度”,一个使用显式的类型初始化原则是这样做的:

auto ep = static_cast<float>(calcEpsilon());