C++11 详解

C++11 已经出来很久了,一直没有时间好好学习一下,最近项目中用到了一些,花了点时间总结一些常用的语法和库,以备参考。

题外话

C++语言是一门非常繁复的语言,支持的编程范式很多:

  • 面向过程编程(c子集)
  • 面向对象OO
  • 泛型编程

而且C++本身并不限制使用者,方便使用者进行混用,再加上包罗万象的语法和各种隐晦的含义,也很方便使用者滥用和误用。

艺术 vs 工程

编程中常会有编程是门艺术还是一项工程的考虑,最完美的结果当然是艺术与工程的完美结合,但现实往往所有偏向,以我个人的理解,艺术是让最优秀的人做的最好,工程是让最平庸的人做的不能太差。像画家,只要一根笔就能绘制无穷的世界,但只有最优秀的艺术家才能做出珍品,平庸的人往往都是涂鸦,这就是艺术的最好体现。而像流水线的工人,只要培训几天就能上岗,机械的重复简单的劳动,集合起来便能做出令人惊讶的产品,这就是工程的力量。而编程,是一项脑力劳动,不能像流水线的工人那么机械,需要自身的创作,所以会有艺术成分。像很多年以前的手工业者,也往往蕴涵艺术创作,随着时代发展,越来越倾向于工程创作。编程同样也是如此,尤其是大型项目,往往更侧重工程性而非艺术性。

C++ 是一门艺术

如果认同上面的理论,那就很容易得出,C++是一门艺术,优秀的人能写出优雅的艺术作品,平庸的人几乎没办法避免滥用。所以在学习的时候,要非常审慎,避免误用造成涂鸦。

可以这么用 vs 为什么要这么用

在学习C++,如果仅仅是知道语法,并不算学会了C++,如果看到一个语法,心里想,原来C++可以这么用,往往会误入歧途,正确的想法应该是想,为什么要有这样一个语法,用其他的语法替代会有什么优点和缺点,刨根追底,才会更好的避免误用。同时,每个C++语法的引入,都是旷日持久的讨论权衡的结果,通过理解引入的原因,也能增加自身的编程技艺。

C++11 编译

C++11 大多数情况下兼容之前版本,从C99版本升级到C++11,C++11扩充了关键字,如果之前代码有用到,需要替换,增加编译参数c++11:

g++ -std=c++11 test.cpp

C++11 语法

auto类型推断

auto 语法为了简化,一个是简化写法,一个是简化程序员心智,有时候不需要关心到底是什么类型。auto 语法很简单,但需要知道 auto 是在编译期间推断的类型:

int main()
{
        vector<int> vec;
        vec.push_back(1);

        auto i = vec.begin();   // vector<int>::iterator
        return 0;
}

如果是编译期间推断,设想以下场景能否成立?

auto func() {
        int a = 10;
        return a;
}

其实以上写法不能编译通过,需要返回值后置语法。

返回值后置语法

auto func() -> int {
        int a = 10;
        return a;
}

其实对于一般函数,这么使用毫无意义,这种语法常用的使用场景有两个,其中一种用法如下:

class Map {
public:
    struct Cell {
		int color;
	};
	Cell * GetCell(int x, int y);
	Cell * ClearCell(int x, int y);
};

// 返回值类型必须加域
Map::Cell * Map::GetCell(int, int)
{
	return nullptr;
}

// 使用返回值后置,不用加域
auto Map::ClearCell(int, int) -> Cell*
{
	return nullptr;
}

另外一种用法是模板的返回值类型推断,需要结合decltype使用。

decltype

通过使用 decltype 语法,可以在编译期间获得auto或者其他变量的类型,进行定义同类型的新变量。

#include <vector>

using namespace std;

int main()
{
        vector<int> vec;
        vec.push_back(1);

        auto i = vec.begin();
        decltype(i) j = vec.end();

        return 0;
}

使用decltype的其中一个好处,正如auto的好处,不用关心具体的类型,另外一个也是用在模板,通过非成员begin来说明。

非成员begin & end

经常使用stl的人应该很熟悉成员begin和end,分别表示起始和结束的迭代器,这也是标准库的统一接口。在C++11中引入了非成员begin和end,用法如下:

int a[] = {2, 1, 3};
std::sort(std::begin(a), std::end(a));

std::vector<int> vec;
std::sort(std::begin(vec), std::end(vec));

通过使用方式来设计一下 std::begin 和 std::end 是如何实现的?

template <class Container>
auto end (Container& cont) -> decltype (cont.end());

template <class T, size_t N>
T* end (T(&arr)[N]);

这里也是 decltype 和 auto 的经典使用法,否则需要多一个模板参数才能实现,不方便调用。但仔细想下,非成员 begin 和 end 的作用其实只是统一了数组和容器,并没有太多可讲之处,不过这种统一方便了另外一个foreach语法。

for each

foreach 可以简化代码,让代码更清晰可读性更强,使用方法如下:

int arr[] = {2, 2, 3, 8, 3};

for (auto v: arr) {
	cout << v << " ";
}

for (auto & v : arr) {
	v ++;
}

之所以能做到foreach,得益于非成员begin&end,通过foreach的原理,自定义的容易,只要实现begin,end,operator[]操作符,调用方也可使用foreach语法。

lambda 表达式

lambda 表达式是C++11重点引入的语法之一,通过lambda表达式和闭包,可以使代码更为简洁,所谓闭包,是词法闭包(Lexical Closure)的简称,是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。

vector<int> vec;
std::for_each(vec.begin(), vec.end(), [](int v){ cout << v << endl;});

int out = 0;
std::for_each(vec.begin(), vec.end(), [&](int v){ cout << v << ":" << out++ << endl;});

方括号内的符号表示外部变量的引用情况,具体信息如下:

  • [] // 没有定义任何变量。使用未定义变量会导致错误。
  • [x, &y] // x 以传值方式传入(默认),y 以引用方式传入。
  • [&] // 任何被使用到的外部变量皆隐式地以引用方式加以使用。
  • [=] // 任何被使用到的外部变量皆隐式地以传值方式加以使用。
  • [&, x] // x 显示地以传值方式加以使用。其余变量以引用方式加以使用。
  • [=, &z] // z 显示地以引用方式加以使用。其余变量以传值方式加以使用。
  • [this] // 传递this指针,匿名函数可以像成员函数一样使用成员变量。

其中&引用传递需要注意外部变量的生命周期,如果是局部变量,一定要确保闭包的生命周期在局部变量之前完结。

现在回头再想一下,for_each是如何实现的呢?这就要用到 function。

function

闭包也是一个对象,可以使用function模板类型的变量接收:

int TestFunction(int param, std::function<int(int)> fun) {
	return fun(param);
}

int main() {
	int test = 100;
	int ret = TestFunction(100, [](int p) {cout << "add:" << p << endl;return p+100;});
	cout << ret << endl;
	return 0;
}

右值引用和move语义

在C++03及之前的标准,临时对象(称为右值R-values,位于赋值运算符之右)无法被改变。C++11增加一个新的非常数引用类型,称作右值引用(R-value reference),标记为T &&。右值引用所引用的临时对象可以在该临时对象被初始化之后做修改,这是为了允许move语义。

简单来说,在C++中经常需要创建一些临时对象,对于小内存变量还好,对于有大块内存的变量,如string等,拷贝一次将会浪费很大的空间,而临时变量创建一次就需要销毁,造成性能的浪费,move语意可以方便的把临时变量中的内存,转移到另一个对象中(当然每个类转移的代码需要自己编写)。先来看一个简单的例子:

string one = "TestOne String";
string two = std::move(one);

cout << "One:" << one << endl;
cout << "Two:" << two << endl;

可以看到很神奇的通过一次moe,one变量变成了空字串,one和two使用的是同一份内存空间,从one转移到了two。这一切是怎么实现的呢?接下来需要看下右值引用,先来看一下什么情况下会识别为右值:


class Elem {};

bool is_right_reference(Elem & elem)
{
        return false;
}
bool is_right_reference(Elem && elem)
{
        return true;
}

Elem elem;
cout << is_right_reference(Elem()) << endl;
cout << is_right_reference(elem) << endl;
cout << is_right_reference(std::move(elem)) << endl;

通过重载is_right_reference函数,来识别传入的参数是否是右值(T&&是右值参数),可以看到,第一个输出不是右值,第二个是右值,很容易理解,第三个不是右值,通过std::move却可以识别为右值。通过这种用法,可以写一个右值拷贝构造函数,实现当参数是右值时,把对方的内存空间转移到自身(通过右值引用可以修改右值),从而完成move操作:

class Elem {
public:
        Elem(const char * name) : name(strdup(name)) {
                cout << "Create One Elem: " << name << endl;
        }
        Elem(Elem && rf) {
                free(name);
                name = rf.name;
                rf.name = nullptr;
                cout << "Create Elem By R-Refer: " << name << endl;
        }
public:
        char * name;
};

Elem elem1 = elem;
Elem elem2 = std::move(elem);

cout << (long)elem.name << endl;

可以看到,通过std::move方式,修改了elem地址,完成了move操作。当然,如果原始值以后还有用处,就不要使用move语意,避免出现代码逻辑错误或者理解上的偏差。

元组

C++11标准库提供了std::tuple类,可以方便的把若干不同类型元素集合在一起:

std::tuple<int, int, int> fun() {
	return std::make_tuple(1, 2, 3);
}

int a, b, c;
std::tie(a, b, c) = fun();

auto t = fun();
int one = std::get<0>(t);

tuple只是一个类库,可以设想一下这个类库如何实现?这里就要用到新的语法,变长参数模板。

变长参数模板

C++11 支持可变长参数的模板,类似可变长参数:

template<typename ... Types>
struct Tuple {};

template<typename ... Types>
int Size() {
        return sizeof...(Types);
}

Tuple <int, char> t1;

cout << Size<int, int, char>() << endl;

这里只是简单展示一下语法,这里的Tuple是没有任何意义的,因为不管多少参数,最终定义都等同于空结构体。要想实现更丰富的语法,就需要用到模板的特化,以下是一个简单的例子:

void Print() {
}

template<typename T, typename ... Types>
void Print(T t, Types... other) {
        cout << t << endl;
        Print(other...);
}

int main()
{
        Print(1, 2, 3, 10, 'a', "is string");
        return 0;
}

nullptr

nullptr 的出现主要是为了替换NULL,为什么需要一个 nullptr 呢?那就得看下NULL在使用过程中会有什么问题。NULL在C/C++中只是一个宏:

#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif

在C++中之所以定义成0,因为C++是强类型语言,void *不能隐式复制给其他类型,要想实现int * p = NULL;这样的表达式,只能定义成0。在C++中建议使用0来表示空指针而不是NULL,因为C++的重载:

void func(int *p);
void func(int i);

调用方使用 func(NULL) 实际上调用的是void func(int i);,但NULL给人的信息是空指针,表意跟实际运行不同,会误导我们以为调用的是void func(int *p);,从而产生隐晦的错误,为了防止这种问题出现,于是 nullptr 被引入了,表示空指针,从而终结了0和NULL的各种说不清道不明的关系。

override & final

override 和 final 相对含义比较清楚,在之前的版本,对类继承重写等并没有太多限制,可能会由于编码错误等原因误用,C++11中加强了这方面的语法检查:

class Shape {
public:
	virtual void draw() {
		cout << "draw one shape" << endl;
	};
	virtual ~Shape() {}
};

class Circle : public Shape {
public:
	void draw() override {
		cout << "draw one circle" << endl;
	}
};

显式声明为override,如果因为参数等原因,不构成继承,在编译期间将会出错,建议重写继承的函数都添加override标识。

class Shape {
public:
	virtual void draw() final {
		cout << "draw one shape" << endl;
	};
	virtual ~Shape() {}
};

class Circle : public Shape {
public:
	// override will failed
	void draw() {
		cout << "draw one circle" << endl;
	}
};

试图去重写final函数,将会在编译期失败,另外final函数必须修饰virtual函数,否则会编译失败。下面是使用final标识禁止类被继承的例子:

class FinalClass final {
};

// complie failed
class SubClass : public FinalClass {
};

委托构造函数

在C++03以前,构造函数不能相互调用,要想重用代码,必须写一个类似Init的函数,不同的构造函数都去调用,在C++11中,构造函数可以相互调用,如下所示:

class Shape {
public:
	Shape() : Shape("Default") {}
	Shape(const string & name) : name(name) {}
private:
	string name;
};

虽然使用起来比较方便,不过到底是使用Init函数集中初始化好呢,还是构造函数相互调用更清晰?这就看具体的问题了。

使用默认或禁用函数

C++11允许显式地表明采用或拒用编译器提供的内置函数。例如要求类带有默认构造函数,可以用以下的语法:

struct SomeType
{
  SomeType() = default;
  SomeType(OtherType value);
};

也可以禁止编译器自动产生某些函数。如下面的例子,类不可复制:

struct NonCopyable
{
	NonCopyable & operator=(const NonCopyable&) = delete;
	NonCopyable(const NonCopyable&) = delete;
	NonCopyable() = default;
};

禁止成员函数以特定的形参调用:

struct NoDouble
{
	void f(int i);
    void f(double) = delete;
};

constexpr

类似const,但比const更加严格,声明为constexpr的变量,取值必须在编译器间获得,否则编译出错。

constexpr int add(const int a, const int b)
{
        return a + b;
}

int main()
{
        const int c1 = 100;
        int c2 = 200;

		// const 运行期间取值
        const int c3 = c2 * 2;
        
        // constexpr编译期间取值,编译出错
        // constexpr int c4 = c2 * 2;
        
        // 没有问题
        constexpr int c5 = c1 * 2;
        
        // constexpr 返回值函数,没有问题
        constexpr int c6 = add(10, 20);

        constexpr int c7 = add(c6, c5);

        // 试图通过非常量取值,编译出错
        // constexpr int c8 = add(c2, c2);

        int a, b;
        cin >> a >> b;
        
        // 声明为constexpr返回值的函数,并不限制必须返回常量
        cout << add(a, b) << endl;
        return 0;
}

容器初始化列表

C++11 支持通过数组初始化容器:

std::vector<int> vec = {1, 2, 3, 4};

这里的实现是通过模板template<class T> class initializer_list;,下面通过一个简单的例子展示如何让自己的类通过列表初始化:

class TestInit
{
public:
        TestInit(initializer_list<int> list) {
                for (auto k : list) {
                        cout << "TEST:" << k << endl;
                }
        }
};

TestInit a = {1, 3, 5, 7};

reference wrapper

看一个模板的例子:

#include <iostream>
#include <functional>

using namespace std;

void inc(int & value)
{
        value ++;
}


template<typename F, typename P>
void call(F func, P param)
{
        func(param);
}

int main()
{
        int i = 0;
        call(inc, i);
        cout << i << endl;

        call(inc, std::ref(i));
        cout << i << endl;
        return 0;
}

第一次call调用以后,i值没有变化,因为模板推导时,P自动推导成了int,call的第二个参数是int有一次值拷贝,通过使用std::ref,显示的指定这应该推导为引用,即能修改i的值。

强类型枚举

在C++中,enum枚举类型与define类似,定义了一个常量,可以与整型相互转换与比较,这样往往违背了enum的初衷,C++11中增加了强类型枚举:

enum class CType {
        One, Two, Three
};

enum EType {
        One, Two, Three
};

int main()
{
        CType ctype = CType::One;
        ctype = CType::Two;

        // can not print
        // cout << ctype << endl;
    
        EType etype = EType::Two;
        int a = etype;
        cout << etype << " " << a << endl;
        return 0;
}

CType 为类型更强的枚举,没法cout输出,也不能跟整数相互转换。

C++11 扩展库

智能指针

智能指针的作用应该都比较清楚,为了防止内存泄漏,或者野指针,C++11在这方面也做了很多工作,先来看最基本的shared_ptr,每当复制出来一份副本,引用计数+1,当所有副本都销毁时,内存也会被释放:

class Shape {
public:
	Shape() {cout << "Create" << endl;}
	~Shape() {cout << "Destroy" << endl;}
};

std::shared_ptr<Shape> outer;
{
	std::shared_ptr<Shape> share(new Shape);
    outer = share;
}
outer.reset();

这个比较好理解,接下来看下weak_ptr。有了shared_ptr似乎能解决大多数问题了,为什么还需要weak_ptr呢?这跟所有权有关系,设想一个对象A,有一个属性P对象,但B对象有时需要使用P,如果使用shared_ptr,A对象销毁后,A的属性P还存在于B对象,这从设计上不合理,这里就可以使用weak_ptr

void CheckWeakPtr(shared_ptr<Shape> share) {
	static weak_ptr<Shape> weak = share;
	if (auto ptr = weak.lock()) {
		cout << "Weak Locked" << endl;
	}
	else {
		cout << "Weak Expired" << endl;
	}
}
int main()
{
	{
		std::shared_ptr<Shape> share(new Shape);
		CheckWeakPtr(share);
	}
	CheckWeakPtr(nullptr);
    return 0;
}

另外还有一个unique_ptr,它持有对象的独有权,两个unique_ptr不能指向一个对象,不能进行复制操作只能进行移动操作。

int main()
{
        std::unique_ptr<int> p1(new int);
        // std::unique_ptr<int> p2 = p1;
        std::unique_ptr<int> p2 = std::move(p1);
        return 0;
}

散列表

stl 的 set,和map都是红黑树变种实现,在C++11版本中引入四种了hash实现,分别为:unordered_set, unordered_map, unordered_multimap, unordered_multiset,如果之前用过set和map,这四种分别表示什么也很容易理解,只不过换成了hash表实现,set和map是根据大小创建红黑树,所以只要重载operator <即可,在unordered_set中,使用hash表,当然需要提供一个自定义函数了,参照前面的hash实现,另外,通过hash不能判断两个元素是否相同,还需要重载operator==运算符,unordered_set的声明如下:

template < class Key,
           class Hash = hash<Key>,
           class Pred = equal_to<Key>,
           class Alloc = allocator<Key>
           > class unordered_set;

用法跟set、map相同。需要了解的是,Hash表实现必然会遇到冲突,unorder系列容器创建一个buckets数组存放hash后的元素,落在同一个buckets中的元素使用链表方式存储。随着元素的增多,冲突的概率也会变大,标准库使用一个因子load_factor表示当前的冲突概率,算法如下:

// 因子 = 元素数量 / bucket数量
load_factor = size / bucket_count

load_factor大于等于max_load_factor时,调用rehash进行重新hash,这时需要重新拷贝数据,对性能影响很大。

unordered_set<int> st;
// 默认最大load_factor是1
cout << st.max_load_factor() << endl;
cout << st.load_factor() << endl;
// 默认bucket数量是11
cout << st.bucket_count() << endl;
// 获取最大bucket数量,由系统或者算法决定
cout << st.max_bucket_count() << endl;


st.insert(2);
st.insert({1, 2, 3, 5, 6});

// 获取第0bucket元素数量
cout << st.bucket_size(0) << endl;

// 重新分配hash,大小向上取素数,默认情况下扩大一倍向上取素数
// 复杂度,最好情况下st.size(),最坏情况下 O(n^2)
st.rehash(100); // real 103

max_load_factor 有重载的带参数函数,可进行设置,由调用者决定最大因子。rehash后迭代器失效,但对于取出来的独立的元素地址或者引用,是可以继续使用的。

hash

hash 的实现使用了模板,用法如下:

struct Shape {
	string name;
	bool operator==(const Shape & other) const {
		return name == other.name;
	}
};
namespace std
{
	template<>
	struct hash<Shape>
	{
		size_t operator() (Shape const & s) const{
			return std::hash<std::string>()(s.name);
		}
	};
}
int main()
{
	unordered_set<Shape> set;
	Shape s;
	s.name = "s1";
	set.insert(s);
	s.name = "s2";
	set.insert(s);
	return 0;
}

多线程

C++11标准库提供了多线程支持,可以用很简单的方法创建线程:

void thread_task() {
        cout << "one task begin." << endl;
        std::chrono::milliseconds dura(3000);
        std::this_thread::sleep_for(dura);
        cout << "one task end." << endl;
}

int main() {
        std::thread t(thread_task);
        t.join();
        return 0;
}

另外还有若干锁的支持,具体可参阅手册:http://en.cppreference.com/w/cpp/thread

升级老代码到C++11

C++11 兼容大部分语法,升级的时候只需要增加-std=c++11即可,不过多数情况下升级并没有这么简单,需要几点注意:

  • 关键字扩充,如果之前的老代码有使用C++11的关键字作为变量,需要替换变量名
  • C++11语法更为严格,如没有引入头文件,某些隐式转换,会变成编译错误,需要加入头文件或者强制转换

总结

最后总结一下,C++11主要是在代码的简化上做了优化,如auto,lambda,尤其是加强了模板的功能,通过模板和库来提升语言的表达能力,之前的C++版本,语法即语法,库即库,C++11中可以看到更多的是语法和库密不可分,融为一体。而且在尽量消除一些隐晦的错误,如nullptr,final等。

另外,C++的设计者始终秉承一个原则,就是你不知道的语法对你不会有任何影响,不论是性能上还是代码编写上,所以,不论是否精通C++的各种语法,只要知道一个子集,便能写出很好的代码,贪多不一定是好事。

参考

Built with Hugo
主题 StackJimmy 设计