C++开发者都应该使用的10个C++11特性
在 C++11 新标准中,语言本身和标准库都增加了很多新内容,本文只涉及了一些皮毛。不过我相信这些新特性当中有一些,应该成为所有 C++ 开发者的常规装备。你也许看到过许多类似介绍各种 C++11 特性的文章。下面是我总结的, C++ 开发者都需要学习和使用的 C++11 新特性。
auto
在 C++11 之前, auto 关键字用来指定存储期。在新标准中,它的功能变为类型推断。 auto 现在成了一个类型的占位符,通知编译器去根据初始化代码推断所声明变量的真实类型。各种作用域内声明变量都可以用到它。例如,名空间中,程序块中,或是 for 循环的初始化语句中。
auto i = 42; // i is an int
auto l = 42LL; // l is an long long
auto p = new foo(); // p is a foo*
使用 auto 通常意味着更短的代码(除非你所用类型是 int ,它会比 auto 少一个字母)。试想一下当你遍历 STL 容器时需要声明的那些迭代器( iterator )。现在不需要去声明那些 typedef 就可以得到简洁的代码了。
std::map<std::string, std::vector<int>> map;
for(auto it = begin(map); it != end(map); ++it)
{
}
需要注意的是, auto 不能用来声明函数的返回值。但如果函数有一个尾随的返回类型时, auto 是可以出现在函数声明中返回值位置。这种情况下, auto 并不是告诉编译器去推断返回类型,而是指引编译器去函数的末端寻找返回值类型。在下面这个例子中,函数的返回值类型就是 operator+ 操作符作用在 T1 、 T2 类型变量上的返回值类型。
template <typename T1, typename T2>
auto compose(T1 t1, T2 t2) -> decltype(t1 + t2)
{
return t1+t2;
}
auto v = compose(2, 3.14); // v's type is double
nullptr
以前都是用 0 来表示空指针的,但由于 0 可以被隐式类型转换为整形,这就会存在一些问题。关键字 nullptr 是 std::nullptr_t 类型的值,用来指代空指针。 nullptr 和任何指针类型以及类成员指针类型的空值之间可以发生隐式类型转换,同样也可以隐式转换为 bool 型(取值为 false )。但是不存在到整形的隐式类型转换。
void foo(int* p) {}
void bar(std::shared_ptr<int> p) {}
int* p1 = NULL;
int* p2 = nullptr;
if(p1 == p2)
{
}
foo(nullptr);
bar(nullptr);
bool f = nullptr;
int i = nullptr; // error: A native nullptr can only be converted to bool or, using reinterpret_cast, to an integral type
为了向前兼容, 0 仍然是个合法的空指针值。
Range-based for loops (基于范围的 for 循环)
为了在遍历容器时支持 ”foreach” 用法, C++11 扩展了 for 语句的语法。用这个新的写法,可以遍历 C 类型的数组、初始化列表以及任何重载了非成员的 begin() 和 end() 函数的类型。
如果你只是想对集合或数组的每个元素做一些操作,而不关心下标、迭代器位置或者元素个数,那么这种 foreach 的 for 循环将会非常有用。
std::map<std::string, std::vector<int>> map;
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
map = v;
for(const auto& kvp : map)
{
std::cout << kvp.first << std::endl;
for(auto v : kvp.second)
{
std::cout << v << std::endl;
}
}
int arr = {1,2,3,4,5};
for(int& e : arr)
{
e = e*e;
}
Override 和 final
我总觉得 C++ 中虚函数的设计很差劲,因为时至今日仍然没有一个强制的机制来标识虚函数会在派生类里被改写。 vitual 关键字是可选的,这使得阅读代码变得很费劲。因为可能需要追溯到继承体系的源头才能确定某个方法是否是虚函数。为了增加可读性,我总是在派生类里也写上 virtual 关键字,并且也鼓励大家都这么做。即使这样,仍然会产生一些微妙的错误。看下面这个例子:
class B
{
public:
virtual void f(short) {std::cout << "B::f" << std::endl;}
};
class D : public B
{
public:
virtual void f(int) {std::cout << "D::f" << std::endl;}
};
D::f 按理应当重写 B::f 。然而二者的声明是不同的,一个参数是 short ,另一个是 int 。因此 D::f (原文为 B::f ,可能是作者笔误 —— 译者注)只是拥有同样名字的另一个函数(重载)而不是重写。当你通过 B 类型的指针调用 f() 可能会期望打印出 D::f ,但实际上则会打出 B::f 。
另一个很微妙的错误情况:参数相同,但是基类的函数是 const 的,派生类的函数却不是。
class B
{
public:
virtual void f(int) const {std::cout << "B::f " << std::endl;}
};
class D : public B
{
public:
virtual void f(int) {std::cout << "D::f" << std::endl;}
};
同样,这两个函数是重载而不是重写,所以你通过 B 类型指针调用 f() 将打印 B::f ,而不是 D::f 。
幸运的是,现在有一种方式能描述你的意图。新标准加入了两个新的标识符(不是关键字) : :
- override ,表示函数应当重写基类中的虚函数。
- final ,表示派生类不应当重写这个虚函数。
第一个的例子如下:
class B
{
public:
virtual void f(short) {std::cout << "B::f" << std::endl;}
};
class D : public B
{
public:
virtual void f(int) override {std::cout << "D::f" << std::endl;}
};
现在这将触发一个编译错误(后面那个例子,如果也写上 override 标识,会得到相同的错误提示):
'D::f' : method with override specifier 'override' did not override any base class methods
另一方面,如果你希望函数不要再被派生类进一步重写,你可以把它标识为 final 。可以在基类或任何派生类中使用 final 。在派生类中,可以同时使用 override 和 final 标识。
class B
{
public:
virtual void f(int) {std::cout << "B::f" << std::endl;}
};
class D : public B
{
public:
virtual void f(int) override final {std::cout << "D::f" << std::endl;}
};
class F : public D
{
public:
virtual void f(int) override {std::cout << "F::f" << std::endl;}
};
被标记成 final 的函数将不能再被 F::f 重写。
Strongly-typed enums 强类型枚举
传统的 C++ 枚举类型存在一些缺陷:它们会将枚举常量暴露在外层作用域中(这可能导致名字冲突,如果同一个作用域中存在两个不同的枚举类型,但是具有相同的枚举常量就会冲突),而且它们会被隐式转换为整形,无法拥有特定的用户定义类型。
在 C++11 中通过引入了一个称为强类型枚举的新类型,修正了这种情况。强类型枚举由关键字 enum class 标识。它不会将枚举常量暴露到外层作用域中,也不会隐式转换为整形,并且拥有用户指定的特定类型(传统枚举也增加了这个性质)。
enum class Options {None, One, All};
Options o = Options::All;
Smart Pointers 智能指针
已经有成千上万的文章讨论这个问题了,所以我只想说:现在能使用的,带引用计数,并且能自动释放内存的智能指针包括以下几种:
- unique_ptr: 如果内存资源的所有权不需要共享,就应当使用这个(它没有拷贝构造函数),但是它可以转让给另一个 unique_ptr (存在 move 构造函数)。
- shared_ptr: 如果内存资源需要共享,那么使用这个(所以叫这个名字)。
- weak_ptr: 持有被 shared_ptr 所管理对象的引用,但是不会改变引用计数值。它被用来打破依赖循环(想象在一个 tree 结构中,父节点通过一个共享所有权的引用 (chared_ptr) 引用子节点,同时子节点又必须持有父节点的引用。如果这第二个引用也共享所有权,就会导致一个循环,最终两个节点内存都无法释放)。
另一方面, auto_ptr 已经被废弃,不会再使用了。
什么时候使用 unique_ptr ,什么时候使用 shared_ptr 取决于对所有权的需求,我建议阅读以下的讨论: http://stackoverflow.com/questions/15648844/using-smart-pointers-for-class-members
以下第一个例子使用了 unique_ptr 。如果你想把对象所有权转移给另一个 unique_ptr ,需要使用 std::move (我会在最后几段讨论这个函数)。在所有权转移后,交出所有权的智能指针将为空, get() 函数将返回 nullptr 。
void foo(int* p)
{
std::cout << *p << std::endl;
}
std::unique_ptr<int> p1(new int(42));
std::unique_ptr<int> p2 = std::move(p1); // transfer ownership
if(p1)
foo(p1.get());
(*p2)++;
if(p2)
foo(p2.get());
第二个例子展示了 shared_ptr 。用法相似,但语义不同,此时所有权是共享的。
void foo(int* p)
{
}
void bar(std::shared_ptr<int> p)
{
++(*p);
}
std::shared_ptr<int> p1(new int(42));
std::shared_ptr<int> p2 = p1;
bar(p1);
foo(p2.get());
第一个声明和以下这行是等价的:
auto p3 = std::make_shared<int>(42);
make_shared<T> 是一个非成员函数,使用它的好处是可以一次性分配共享对象和智能指针自身的内存。而显示地使用 shared_ptr 构造函数来构造则至少需要两次内存分配。除了会产生额外的开销,还可能会导致内存泄漏。在下面这个例子中,如果 seed() 抛出一个错误就会产生内存泄漏。
void foo(std::shared_ptr<int> p, int init)
{
*p = init;
}
foo(std::shared_ptr<int>(new int(42)), seed());
如果使用 make_shared 就不会有这个问题了。第三个例子展示了 weak_ptr 。注意,你必须调用 lock() 来获得被引用对象的 shared_ptr ,通过它才能访问这个对象。
auto p = std::make_shared<int>(42);
std::weak_ptr<int> wp = p;
{
auto sp = wp.lock();
std::cout << *sp << std::endl;
}
p.reset();
if(wp.expired())
std::cout << "expired" << std::endl;
如果你试图锁定 (lock) 一个过期(指被弱引用对象已经被释放)的 weak_ptr ,那你将获得一个空的 shared_ptr.
Lambdas
匿名函数(也叫 lambda )已经加入到 C++ 中,并很快异军突起。这个从函数式编程中借来的强大特性,使很多其他特性以及类库得以实现。你可以在任何使用函数对象或者函子 (functor) 或 std::function 的地方使用 lambda 。你可以从这里( http://msdn.microsoft.com/en-us/library/dd293603.aspx )找到语法说明。
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
std::for_each(std::begin(v), std::end(v), (int n) {std::cout << n << std::endl;});
auto is_odd = (int n) {return n%2==1;};
auto pos = std::find_if(std::begin(v), std::end(v), is_odd);
if(pos != std::end(v))
std::cout << *pos << std::endl;
更复杂的是递归 lambda 。考虑一个实现 Fibonacci 函数的 lambda 。如果你试图用 auto 来声明,就会得到一个编译错误。
auto fib = (int n) {return n < 2 ? 1 : fib(n-1) + fib(n-2);};
error C3533: 'auto &': a parameter cannot have a type that contains 'auto'
error C3531: 'fib': a symbol whose type contains 'auto' must have an initializer
error C3536: 'fib': cannot be used before it is initialized
error C2064: term does not evaluate to a function taking 1 arguments
问题出在 auto 意味着对象类型由初始表达式决定,然而初始表达式又包含了对其自身的引用,因此要求先知道它的类型,这就导致了无穷递归。解决问题的关键就是打破这种循环依赖,用 std::function 显式的指定函数类型:
std::function<int(int)> lfib = (int n) {return n < 2 ? 1 : lfib(n-1) + lfib(n-2);};
非成员 begin() 和 end()
也许你注意到了,我在前面的例子中已经用到了非成员 begin() 和 end() 函数。他们是新加入标准库的,除了能提高了代码一致性,还有助于更多地使用泛型编程。它们和所有的 STL 容器兼容。更重要的是,他们是可重载的。所以它们可以被扩展到支持任何类型。对 C 类型数组的重载已经包含在标准库中了。
我们还用上一个例子中的代码来说明,在这个例子中我打印了一个数组然后查找它的第一个偶数元素。如果 std::vector 被替换成 C 类型数组。代码可能看起来是这样的:
int arr = {1,2,3};
std::for_each(&arr, &arr+sizeof(arr)/sizeof(arr), (int n) {std::cout << n << std::endl;});
auto is_odd = (int n) {return n%2==1;};
auto begin = &arr;
auto end = &arr+sizeof(arr)/sizeof(arr);
auto pos = std::find_if(begin, end, is_odd);
if(pos != end)
std::cout << *pos << std::endl;
如果使用非成员的 begin() 和 end() 来实现,就会是以下这样的:
int arr = {1,2,3};
std::for_each(std::begin(arr), std::end(arr), (int n) {std::cout << n << std::endl;});
auto is_odd = (int n) {return n%2==1;};
auto pos = std::find_if(std::begin(arr), std::end(arr), is_odd);
if(pos != std::end(arr))
std::cout << *pos << std::endl;
这基本上和使用 std::vecto 的代码是完全一样的。这就意味着我们可以写一个泛型函数处理所有支持 begin() 和 end() 的类型。
template <typename Iterator>
void bar(Iterator begin, Iterator end)
{
std::for_each(begin, end, (int n) {std::cout << n << std::endl;});
auto is_odd = (int n) {return n%2==1;};
auto pos = std::find_if(begin, end, is_odd);
if(pos != end)
std::cout << *pos << std::endl;
}
template <typename C>
void foo(C c)
{
bar(std::begin(c), std::end(c));
}
template <typename T, size_t N>
void foo(T(&arr))
{
bar(std::begin(arr), std::end(arr));
}
int arr = {1,2,3};
foo(arr);
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
foo(v);
static_assert 和 type traits
static_assert 提供一个编译时的断言检查。如果断言为真,什么也不会发生。如果断言为假,编译器会打印一个特殊的错误信息。
template <typename T, size_t Size>
class Vector
{
static_assert(Size < 3, "Size is too small");
T _points;
};
int main()
{
Vector<int, 16> a1;
Vector<double, 2> a2;
return 0;
}
error C2338: Size is too small
see reference to class template instantiation 'Vector<T,Size>' being compiled
with
static_assert 和 type traits 一起使用能发挥更大的威力。 type traits 是一些 class ,在编译时提供关于类型的信息。在头文件 <type_traits> 中可以找到它们。这个头文件中有好几种 class: helper class ,用来产生编译时常量。 type traits class ,用来在编译时获取类型信息,还有就是 type transformation class ,他们可以将已存在的类型变换为新的类型。
下面这段代码原本期望只做用于整数类型。
template <typename T1, typename T2>
auto add(T1 t1, T2 t2) -> decltype(t1 + t2)
{
return t1 + t2;
}
但是如果有人写出如下代码,编译器并不会报错
std::cout << add(1, 3.14) << std::endl;
std::cout << add("one", 2) << std::endl;
程序会打印出 4.14 和 ”e” 。但是如果我们加上编译时断言,那么以上两行将产生编译错误。
template <typename T1, typename T2>
auto add(T1 t1, T2 t2) -> decltype(t1 + t2)
{
static_assert(std::is_integral<T1>::value, "Type T1 must be integral");
static_assert(std::is_integral<T2>::value, "Type T2 must be integral");
return t1 + t2;
}
error C2338: Type T2 must be integral
see reference to function template instantiation 'T2 add<int,double>(T1,T2)' being compiled
with
error C2338: Type T1 must be integral
see reference to function template instantiation 'T1 add<const char*,int>(T1,T2)' being compiled
with
Move semantics ( Move 语义)
这是 C++11 中所涵盖的另一个重要话题。就这个话题可以写出一系列文章,仅用一个段落来说明显然是不够的。因此在这里我不会过多的深入细节,如果你还不是很熟悉这个话题,我鼓励你去阅读更多地资料。
C++11 加入了右值引用 (rvalue reference) 的概念(用 && 标识),用来区分对左值和右值的引用。左值就是一个有名字的对象,而右值则是一个无名对象(临时对象)。 move 语义允许修改右值(以前右值被看作是不可修改的,等同于 const T& 类型)。
C++ 的 class 或者 struct 以前都有一些隐含的成员函数:默认构造函数(仅当没有显示定义任何其他构造函数时才存在),拷贝构造函数,析构函数还有拷贝赋值操作符。拷贝构造函数和拷贝赋值操作符提供 bit-wise 的拷贝(浅拷贝),也就是逐个 bit 拷贝对象。也就是说,如果你有一个类包含指向其他对象的指针,拷贝时只会拷贝指针的值而不会管指向的对象。在某些情况下这种做法是没问题的,但在很多情况下,实际上你需要的是深拷贝,也就是说你希望拷贝指针所指向的对象。而不是拷贝指针的值。这种情况下,你需要显示地提供拷贝构造函数与拷贝赋值操作符来进行深拷贝。
如果你用来初始化或拷贝的源对象是个右值(临时对象)会怎么样呢?你仍然需要拷贝它的值,但随后很快右值就会被释放。这意味着产生了额外的操作开销,包括原本并不需要的空间分配以及内存拷贝。
现在说说 move constructor 和 move assignment operator 。这两个函数接收 T&& 类型的参数,也就是一个右值。在这种情况下,它们可以修改右值对象,例如 “ 偷走 ” 它们内部指针所指向的对象。举个例子,一个容器的实现(例如 vector 或者 queue )可能包含一个指向元素数组的指针。当用一个临时对象初始化一个对象时,我们不需要分配另一个数组,从临时对象中把值复制过来,然后在临时对象析构时释放它的内存。我们只需要将指向数组内存的指针值复制过来,由此节约了一次内存分配,一次元数组的复制以及后来的内存释放。
以下代码实现了一个简易的 buffer 。这个 buffer 有一个成员记录 buffer 名称(为了便于以下的说明),一个指针(封装在 unique_ptr 中)指向元素为 T 类型的数组,还有一个记录数组长度的变量。
template <typename T>
class Buffer
{
std::string _name;
size_t _size;
std::unique_ptr<T> _buffer;
public:
// default constructor
Buffer():
_size(16),
_buffer(new T)
{}
// constructor
Buffer(const std::string& name, size_t size):
_name(name),
_size(size),
_buffer(new T)
{}
// copy constructor
Buffer(const Buffer& copy):
_name(copy._name),
_size(copy._size),
_buffer(new T)
{
T* source = copy._buffer.get();
T* dest = _buffer.get();
std::copy(source, source + copy._size, dest);
}
// copy assignment operator
Buffer& operator=(const Buffer& copy)
{
if(this != ©)
{
_name = copy._name;
if(_size != copy._size)
{
_buffer = nullptr;
_size = copy._size;
_buffer = _size > 0 > new T : nullptr;
}
T* source = copy._buffer.get();
T* dest = _buffer.get();
std::copy(source, source + copy._size, dest);
}
return *this;
}
// move constructor
Buffer(Buffer&& temp):
_name(std::move(temp._name)),
_size(temp._size),
_buffer(std::move(temp._buffer))
{
temp._buffer = nullptr;
temp._size = 0;
}
// move assignment operator
Buffer& operator=(Buffer&& temp)
{
assert(this != &temp); // assert if this is not a temporary
_buffer = nullptr;
_size = temp._size;
_buffer = std::move(temp._buffer);
_name = std::move(temp._name);
temp._buffer = nullptr;
temp._size = 0;
return *this;
}
};
template <typename T>
Buffer<T> getBuffer(const std::string& name)
{
Buffer<T> b(name, 128);
return b;
}
int main()
{
Buffer<int> b1;
Buffer<int> b2("buf2", 64);
Buffer<int> b3 = b2;
Buffer<int> b4 = getBuffer<int>("buf4");
b1 = getBuffer<int>("buf5");
return 0;
}
默认的 copy constructor 以及 copy assignment operator 大家应该很熟悉了。 C++11 中新增的是 move constructor 以及 move assignment operator ,这两个函数根据上文所描述的 move 语义实现。如果你运行这段代码,你就会发现 b4 构造时, move constructor 会被调用。同样,对 b1 赋值时, move assignment operator 会被调用。原因就在于 getBuffer() 的返回值是一个临时对象 —— 也就是右值。
你也许注意到了, move constuctor 中当我们初始化变量 name 和指向 buffer 的指针时,我们使用了 std::move 。 name 实际上是一个 string , std::string 实现了 move 语义。 std::unique_ptr 也一样。但是如果我们写 _name(temp._name) ,那么 copy constructor 将会被调用。不过对于 _buffer 来说不能这么写,因为 std::unique_ptr 没有 copy constructor 。但为什么 std::string 的 move constructor 此时没有被调到呢?这是因为虽然我们使用一个右值调用了 Buffer 的 move constructor ,但在这个构造函数内,它实际上是个左值。为什么?因为它是有名字的 ——“temp” 。一个有名字的对象就是左值。为了再把它变为右值(以便调用 move constructor) 必须使用 std::move 。这个函数仅仅是把一个左值引用变为一个右值引用。
更新:虽然这个例子是为了说明如何实现 move constructor 以及 move assignment operator ,但具体的实现方式并不是唯一的。在本文的回复中 Member 7805758 同学提供了另一种可能的实现。为了方便查看,我把它也列在下面:
template <typename T>
class Buffer
{
std::string _name;
size_t _size;
std::unique_ptr<T> _buffer;
public:
// constructor
Buffer(const std::string& name = "", size_t size = 16):
_name(name),
_size(size),
_buffer(size? new T : nullptr)
{}
// copy constructor
Buffer(const Buffer& copy):
_name(copy._name),
_size(copy._size),
_buffer(copy._size? new T : nullptr)
{
T* source = copy._buffer.get();
T* dest = _buffer.get();
std::copy(source, source + copy._size, dest);
}
// copy assignment operator
Buffer& operator=(Buffer copy)
{
swap(*this, copy);
return *this;
}
// move constructor
Buffer(Buffer&& temp):Buffer()
{
swap(*this, temp);
}
friend void swap(Buffer& first, Buffer& second) noexcept
{
using std::swap;
swap(first._name , second._name);
swap(first._size , second._size);
swap(first._buffer, second._buffer);
}
};
结论
关于 C++11 还有很多要说的。本文只是各种入门介绍中的一个。本文展示了一系列 C++ 开发者应当使用的核心语言特性与标准库函数。然而我建议你能更加深入地学习,至少也要再看看本文所介绍的特性中的部分。