Effective Modern C++之旅——剖析auto变量

auto大法好

使用auto不仅可以少打些字,还能阻止那些由于手动指定型别带来的潜在错误和性能问题。另外,某些auto型别推导的结果在编程者的视角看起来是错误的,因此,有必要知道如何去引导auto推导出正确的结果。

我们从一段“天真无邪”的代码引入

int x;

x忘记初始化了,所以它的值是不确定的,不过也不一定,它有可能被初始化为0。一切全看具体语境。

再来看一段不那么“天真”的代码

template<typename It>
void dwim(It b, It e)
{
    while(b != e)
    {
        typename std::iterator_traits<It>::value_type currrValue = *b;
        ....
    }
}

绕晕了吧,用std::iterator_traits<It>::value_type来表达迭代器所指涉到的值的型别!除非有特殊癖好,这种声明型别的方式没有丝毫乐趣可言。

当然你也可以用闭包的型别来声明局部变量,不过闭包的型别只有编译器知道,程序员是写不出来的,GG。

auto尚未出现的旧标准下,编程就是这么水深火热,但是在C++11我们迎来了auto,某种意义上写C++变得跟写弱类型语言(如python)一样流畅了——不过是在变量前加个auto而已。

强制初始化与简化代码

auto的好处之一是强制初始化——用auto声明的变量,其型别都推导自初始化物,因此在用C++造轮子时,可以完美避免由未初始化变量带来的问题了。

int x1;//x1未初始化
auto x1;//错误,必须要有初始化物
auto x3 = 0;

对于提领迭代器,auto也能游刃有余

template<typename It>
void dwim(It b, It e)
{
    while(b != e)
    {
        auto currrValue = *b;
        ....
    }
}

而且,由于auto使用了型别推导, 就可以用它来表示只有编译器才能掌握的复杂型别

auto derefUPLess = 
    [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2)
    {return *p1 < *p2}

我们虽然不知道Widget对象比对返回的型别,但交给auto就行了——是不是很酷。在C++14中,连lambda表达式的形参中都可以使用auto

auto derefUPLess = 
    [](const auto& p1, const auto& p2)
    {return *p1 < *p2}

酷毙了!

auto VS std::function

有些人认为不需要声明变量来持有闭包,因为可以用std::function来完成这件事。等等,std::function是什么?

std::functionC++11标准库中的一个模板,将函数指针的思想加以推广,它可以指向任何可以被调用的对象。创建一个std::function对象,需要指定欲涉的函数的型别,举个例子,声明一个指向任何以bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)签名调用的对象

std::function<bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)> func;

因为lambda表达式产生可调物对象,std::function对象中就可以存储闭包

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::functionauto在实现闭包时所占用的内存空间也不相同。使用auto声明的,存储着一个闭包的变量和该闭包是同一个型别,从而它要求的内存量也和该闭包一样。而使用std::function声明的,存储着一个闭包的变量是一个std::function实例,所以不管给定的签名是什么,它都占有固定的尺寸,而这个尺寸对于其存储的闭包而言不一定够用。在这种情况下,std::function的构造函数会在堆上分配内存来存储闭包。从结果上看,std::function对象一般会比使用auto声明的变量使用更多的内存。再者,编译器的实现细节一般会限制内联,并产生间接的函数调用,造成额外的开销。将这些因素考虑在内,毫无疑问,auto获胜。

为变量推导安全且合适的类型

使用auto可以避免某些类型安全的问题。比如下面的代码,你肯定见过,甚至还曾写过

std::vector<int> v;
unsigned sz = v.size();

标准规定,v.size()的返回型别是std::vector<int>::size_type,但是很少人会注意到,std::vector<int>::size_type仅仅是一个无符号整型,使用unsigned来指定型别可能会导致一些意想不到的后果:在32位Windows上,unsignedstd::vector<int>::size_type的长度一致,但在64位下,unsigned是32位,std::vector<int>::size_type却是64位——在应用移植的时候,你可能为一些由于整型溢出导致的奇怪数值浪费时间。但atuo绝不会让你在这个方面浪费时间

auto sz = v.size();//sz的型别推导为std::vector<int>::size_type

auto的大智慧还是一知半解?接着看下面的代码,找出其隐患

std::unordered_map<std::string, int> m;
....
for(const std::pair<std::string, int>& p : m)
{
    ....//在p上实施某些操作
}

这段代码看起来合情合理,实际上会造成极大的额外性能开销。别忘了,std::unordered_map的健值部分是const,所以其元素的型别并不是std::pair<std::string, int>,而是std::pair<cosnt std::string int>。可是在循环中,p的类型却不是这个。结果编译器使用了一种低效的方法将std::pair<cosnt std::string int>转换为std::pair<std::string, int>——对m中的每个对象执行一次复制操作,形成一个p想要绑定型别的临时对象。在循环每次迭代结束时,该临时对象都会被析构一次——虽然你想要的效果只是想把引用p依次绑定到m中的每个元素而已。这种无心之错可以利用auto轻松化解

for(const auto& p: m)
{
    ....
}

这样做不仅能提升运行效率,更可以简化代码。更有甚者,在这段代码中,如果对p取址,肯定能得到指向m中某个元素的指针,而没有auto,取到的只能是一个临时对象的指针。

这两个例子说明了,显式指定型别可能导致你既不想要,也没想到的隐式型别转换。如果你使用auto,就完全没必要担心这个问题。

不止于auto

虽然有若干种理由让auto相比于显式指定型别更加优秀,但是auto本身并不完美——每个auto变量的型别都是推导出来的,结果可能既不符合期望也不符合要求。这种情况何时会出现,又该如何应对,需要结合大量的知识储备和实践才能掌握。同时,auto可能会导致源代码可读性问题——需要自己推导型别而非一目了然。那么,auto到底应该如何使用,在何时使用呢?

首先明确一点:auto是一个可选项而不是必选项。如果你的代码在显式型别声明的前提下更清晰、可维护性更高、或者其他的好处,当然选择显式声明。C++引入auto只是借鉴了其他语言中称为性别推导的思想,是一种新的编程方式,而且不会与大型的、工业强度的基础代码产生冲突。有些人可能觉得使用auto之后无法一眼看出变量类型,就失去了对代码的掌控。但是IDE的对象型别的推导及显示能缓解这个问题,并且在很多情况下,对型别的抽象理解比了解完整类型更加有用。比如知道对象是个计数器,还是容器,亦或是指针,已经足以应付大多数情况,没必要知道它具体是何种型别。简单来说,stringbasic_string<char>更容易理解——虽然后者是前者的精确表示。事实上,使用auto还是显式型别声明,对代码的影响是十分微妙不可捉摸的,有些关乎正确性,有些关系到执行效率。

auto的另一个目的是简化重构。由于auto的型别随着其初始化表达式的型别动态改变,则意味着通过auto可以省略一些重构的代码。假如一个函数原本的声明返回值是int,后来想修改成double,如果使用函数的调用结果存储在auto中,直接修改返回类型就行了。相反,使用显式类型声明,你还得一个个改函数的调用点,修改接受返回值变量的型别

int f()
{
    return 1;
}

int x = f();
auto y = f();

//此时对f进行修改,返回值类型变更
double f()
{
    reutrn 1.1
}

auto y = f();//代码依然可以编译通过,不需进行任何修改
double x = f();//使用显式型别声明则需要修改变量类型

总结

  • auto变量必须初始化,基本免疫型别不匹配导致的效率和兼容性问题,还可以简化代码
  • auto变量有其局限性,不是万能,按需使用