原创文章,未经许可,禁止转载!

Item 6: auto推导出非预期类型时请显示指定类型

Item 5 展示了一些使用auto定义变量的优势,但是auto在有些时候也会推导出一些我们不期望的类型。比如,一个函数通过传入一个Widget返回一个std::vector<bool>的类型,容器内的每一个bool值表示Widget的一个特征:

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

比如第5个bool值表示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>的[]运算符并没有返回容器类指定元素的引用(std::vector::operator[]返回的都是元素的引用,除了bool类型),而是返回了std::vector<bool>::reference(一个嵌在std::vector<bool>的对象)。

std::vector<bool>::reference 的存在是因为 std::vector<bool>指定了特殊的包装方式代替bool值,一个bit表示一个bool值,因此造成了std::vector<bool>::operator[] 返回的问题,因为在设计上std::vector<T> 的 operator[] 返回的应该是 T&,但是C++禁止引用bits,因此无法返回 bool&,所以 std::vector<bool>::operator[] 返回一个行为类似 bool& 的对象。

为了实现这个行为,std::vector<bool>::reference 对象必须能够在所有的上下文中表示 bool& 的行为。而 std::vector<bool>::reference 的特性可以隐式的转换成bool。(转成bool而不是bool&。这个行为的具体过程就不在这里解释了,举这个例子只是因为它很有针对性)。

有了这个概念,回头看下之前的那段代码:

bool highPriority = features(w)[5] // 显示的定义 highPriority 的类型

这里,feturates 返回的是一个 std::vector<bool> 对象,同时 operator[] 括号运算符被返回一个 std::vector<bool>::reference 对象,这个对象隐式的转换成了一个 bool 值并初始化 highPriority。highPriority 最终被赋值成了 features 的第5个元素,跟我们的初衷是相同的。

对比下用 auto 的情况:

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

同样,features 返回 std::vector<bool> 对象,并且 operator[] 括号运算符返回 std::vector<bool>::reference 对象,而 auto 将 highPriority 推导成了这个对象类型,因此 highPriority 并没有得到 std::vector<bool> 的第5个元素的值。

这个值取决于 std::vector<bool>::reference的实现。其中一种实现是这个对象保存了一个指针,这个指针指向一个机器字(machine word)的空间,这个空间的第5位就是我们要的bit位,通过位移操作获取这个bit位的数据。设想下,如果 std::vector<bool>::reference 支持这样的操作,那么用这个对象去初始化 highPriority 会有什么样的意义?

调用 features 函数返回一个临时的 std::vector<bool> 对象,这个对象没有名字,但是为了方便讲解,我们这里叫它 temp,通过移位操作到第5个bit。highPriority 是这个 std::vector<bool>::reference 对象的一个拷贝,所以 highPriority 也持有 temp 所持有的指针。在这行代码执行完以后,temp 被销毁,因为它是一个临时对象,因此,highPriority 里保存的指针就成了野指针,因此会导致在调用 processWidget 时产生不可预测的结果。

processWidget(w, highPriority);  // 未定义的行为,highPriority 是一个野指针。

std::vector<bool>::reference 是一个代理类(proxy class)的范例,这个代理类的目的就是为了模拟和扩展其他类型的行为。代理类有很多种功能。std::vector<bool>::reference 展示了 std::vector<bool> 返回一个比特位的引用。 例如,还有像标准库中的智能指针(详见第四章),同样也是代理类,它是帮助裸指针去管理资源释放的。代理类的实用性已经确立已久,事实上,代理是设计模式中最早的模式之一,历史非常久远。

一些代理类设计的非常显而易见,就像 std::shared_ptr 和 std::unique_ptr一样。其他的有一些就设计多少有些隐晦,比如 std::bitset 的 std::bitset::reference。

在代理类这个阵营里,有些C++库使用了一种叫模版表达式的技术,这样的库的初衷是为了增加编码效率。比如 Matrix 类和 Matrix 对象,m1,m2,m3 和 m4,比如如下表达式:

Matrix sum = m1 + m2 + m3 + m4;

可以通过 operator+ 返回一个代理而不是一个 Matrix 自己而使得计算更加高效。这里,对两个Matrix对象使用 operator+ 操作将返回一个代理对象类似 Sum<Matrix, Matrix> 而不是 Matrix 对象。这个和前面的 std::vector<bool>::reference 与 bool 的关系是一样的,这里会有一个从代理对象到 Matrix 的隐式转换,这个转换允许代理对象通过 = 表达式初始化 sum(这个类型的对象将作用整个初始化表达式,比如像 Sum<Sum<Sum<Matrix, Matrix>, Matrix>, Matrix>。这种类型在开发时应该要尽量规避。)

一般情况下,不明显的代理类不要使用 auto。这些类型的设计初衷往往让它的生存期不超过一条语句,因此创建这些类型的对象常常违反基础库的设定。上面说的就是 std::vector<bool>::reference 的范例,可以清晰的看见违反规则而造成的不确定行为。

因此你应该避免这样的代码:

auto someVar = 隐式的代理类型

但是应该怎样去分辨是否是代理类型的对象呢?软件的作者不太会去宣传这些问题,他们的目的就是为了隐藏这些细节,至少在概念上去隐藏。一旦你发现了这个问题,你是不是只能放弃使用auto 并且放弃在 Item5 中提到的诸多优势呢?

先来看下如何发现这些问题。尽管这些看不见的代理类型无处不在,使用这个特性的库开发者常常会在文档中提及。事实上你对这些库的设计越熟悉,你就越不会犯这样的错误。

文档虽然都比较简单,但是可以从头文件里看出端倪。库的源代码不可能掩盖代理对象的使用。这些库的设计者都是希望用户点用函数得到返回值的,因此可以从函数签名看到它们的存在。

下面是 std::vector<bool>::operator[] 的头文件代码:

namespace std {              // C++ 标准库的头文件
// from C++ Standards
  template <class Allocator>
  class vector<bool, Allocator> {
  public:
    ...
    class reference { ... };
    reference operator[](size_type n);
    ... };
}

假设你知道 std::vector<T> 的 operator[] 一般返回的是一个 T&,但是 operator[] 异于常规的返回类型就是提示你代理类的使用。仔细观察函数接口常常可以发现代理类的使用。

在实际应用中,许多开发者只有在跟踪莫名其妙的编译错误或者调试不正确的 unit test 的时候才发现代理类的使用。不管你用什么方式发现这个问题,一旦 auto 被推导成了代理类型而不是原生类型时,解决方案并不是放弃使用 auto。auto 不是问题,问题是 auto 推导成了你不希望得到的类型。解决方案应该是强制类型推导,我称他为显示初始化。

这种显示的初始化包含定义 auto 变量,但是强制转换初始化表达式,将表达式的类型转换成你想要的类型。下面给出一个范例将 highPriority 推导成bool:

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

这里,虽然 features(w)[5] 仍然返回 std::vector<bool>::reference 对象,但是 static_cast 将表达式的类型转换成了 bool,因此 auto 也推导成了bool。在运行时,std::vector<bool>::reference 对象来自于 std::vector<bool>::operator[] 调用的返回值,执行这样的转换是合法的,并且在转换过程中,指针并没有被引用。这避免了前面提到的不可预测的行为。通过指针所以到了第5个 bit 上,并且用 bool 值初始化了 highPriority。

再来看下 Matrix 的例子,显示的初始化看上去是下面这个样子的:

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

常规操作不会限制代理对象的产生。使用强制转换初始化表达式也是非常有用的。设想一个函数用来计算公差值:

double calcEpsilon();  // 返回公差值

calcEpsilon 很明显返回一个 double 值,但是假设你知道你自己的程序用 float 的精度就足够了,并且你还关心 float 和 double 占用的内存大小,你大可以声明一个 float 类型来存放,

float ep = calcEpsilon();   // 隐式将 double 转换成 float,会丢失精度

但是这样并不表示要求函数降低返回值的精度,使用转换初始化表达式就可以做到这一点:

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

同样的情况也可以用在将 float 的返回值转换成 int。假如需要通过迭代器索引容器中的元素(比如 std::vector,std::deque 或者是 std::array),而且范围是 0.0~1.0 之间的 double 值,那么到底应该是哪个元素呢?(0.5 应该是容器中间的那个元素。)进一步假设你确定索引值可以转换成一个 int,但是看下面的代码,容器是 c,d 是 double 类型,你需要这样计算索引:

int index = d * c.size();

但是这样等号右侧的计算结果还是 double 类型,这导致了 double 向 int 的隐式转换。但显示的初始化表达式可以让这个过程变得透明:

auto index = static_cast<int>(d * c.size());

重点总结

  • 隐藏的代理类型会导致 auto 在初始化表达式中推导出错误的类型。
  • 显示的初始化强制 auto 推导出想要的类型。

发表评论