深入理解 C++ 11
前言
说到 C++11,应该想到什么?
-
什么是 lambda,及怎么样使用它是最好的?
-
decltype 和 auto 类型推导有什么关系?
-
什么是移动语义,以及(右值引用)是如何解决转发问题的?
-
default/deleted 函数以及 override 是怎么回事?
-
异常描述符被什么替代了? noexcept 是如何工作的?
-
什么是原子类型以及新的内存模型?
-
如何在 C++11 中做并行编程?
语言的哪些关键字和 C++11 有关?
-
alignas
-
alignof decltype
-
auto(重新定义)
-
static_assert
-
using(重新定义)
-
noexcept
-
export(弃用,不过未来可能留作他用)
-
nullptr
-
constexpr
-
thread_local
保证稳定性和兼容性
将 C99 标准纳入 C++11
确定编译环境的预定义宏
-
STDC_HOSTED:编译器的目标系统环境中是否包含完整的 C 库
-
STDC:编译器对于标准 C 库的实现是否和 C 标准一致(是否定义、如何定义由编译器决定)
-
STDC_VERSION:编译器支持的 C 标准的版本(是否定义、如何定义由编译器决定)
-
STDC_ISO_10646:yyyymmL 格式的整数常量,表示 C++编译环境符合某个版本的 ISO/IEC 10646 标准
func 预定义标识符
函数中可以使用func标识符函数名称,编译器会在函数定义开始时隐式地插入func标识符定义:
static const char* func = “<函数名称>”;
_Pragma 操作符
在之前的 C/C++标准中已经规定了可以使用#pragma 预处理指令向编译器传递信息,比如可以通过在头文件首行放置#pragma once 来告诉编译器这个头文件只应该被 include 一次。
C++11 中,规定了一个新的操作符_Pragma,它的作用与#pragma 完全相同,_Pragma(“once”)就相当于#pragma once。但是,_Pragma 由于是一个操作符而不是预处理指令,它的使用更为灵活,可以在宏中展开。
不定参数宏定义以及 VA_ARGS
C99 规定宏定义中,在参数列表的最后,可以使用…省略参数定义,而VA_ARGS可以用来替换被省略号代表的字符串。
宽窄字符串连接
long long 整形
扩展的整形
宏__cplusplus
一般会和 extern “C"配合使用,来让一个头文件即可以同时被 include 到 C 和 C++中编译,使用 extern “C"避免 C++对符号名称进行重整,确保 C++编译器编译出的目标文件中的符号名维持原样。
#ifdef __cplusplus
extern "C" {
#endif
// 一些代码
#ifdef __cplusplus
}
#endif
静态断言
标准 C 库 assert.h 中,提供了 assert 函数来进行运行时的断言,表明某些情况一定不会发生。(在 C++ 中,程序员也可以定义宏 NDEBUG 来禁用 assert 宏。这对发布程序来说还是必 要的。因为程序用户对程序退出总是敏感的,而且部分的程序错误也未必会导致程序全部功 能失效。那么通过定义 NDEBUG 宏发布程序就可以尽量避免程序退出的状况。而当程序有 问题时,通过没有定义宏 NDEBUG 的版本,程序员则可以比较容易地找到出问题的位置。)
如果希望在预处理时确定某些情况一定不会发生,也可以使用#if 进行判断,使用#error 终止编译流程并给出错误提示。
C++11 引入 static_assert 来补充编译时断言。之前 Boost 库也实现了类似的功能,它是利用编译器在编译时会检查除以 0 这个特性,如果条件为假就构建一个除以 0 的语句迫使编译器报错来实现的,虽然可以达成编译时检查的目的,但报错信息比较不明确。使用 C++11 的 static_assert,在断言失败时,可以得到明确的错误提示。
noexcept 修饰符与 noexcept 操作符
作为修饰符
noexcept 作为修饰符可以用来表明函数是否会抛出异常,noexcept 修饰符后面可以跟随一个常量表达式,当常量表达式可以转为 true 时表示不会抛出异常,false 则表示会抛出异常。被 noexcept 修饰符标记为不会抛出异常的函数如果运行时抛出异常,会直接导致程序调用 std::terminate 终止执行,异常不会继续沿栈传播。
作为操作符
noexcept 作为操作符可以用来判断某个表达式是否会抛出异常。该操作符帮助我们在进行泛型编程时通过 noexcept 操作符判断一个依赖模板参数的表达式是否会抛出异常。
template <class T>
void fun() noexcept(noexcept(T())) {}
替代废弃的 throw
C++98 曾经定义了 throw 修饰符,用来表明表达式是否会抛出异常,一个空的 throw()就和 noexcept(true)表达的意思一样,但 throw 要求明确指出会抛出哪些类型的异常,实际编程中大家很少需要了解异常类型,只希望了解是否会抛出异常,因此新的 noexcept(false)用于替代 throw(<异常类型>)。
快速初始化成员变量
C++98 中,允许直接在类的静态常量整型成员声明时使用“=”对其初始化,这种声明+初始化的做法被叫做“就地”声明。这种声明方式很便捷,但仅能对【静态】【常量】【整型】成员进行这样的声明,使用场景很少,语法也不一致。
C++11 中允许对非静态成员进行就地的初始化,不再必须在构造函数中通过 initializer-list 进行初始化了。可以使用等号或者一对花括号{}进行就地初始化,其他形式无法通过编译。
在成员同时使用就地初始化,并且在构造函数的初始化列表中进行初始化时,最终仅会以初始化列表为准对其进行初始化。
非静态成员的 sizeof
C++98 中,sizeof 可以作用于类的静态成员,但对于非静态成员,必须通过一个类实例来引用,因此之前为了获取一个非静态成员的大小,同时避免创建无用的类实例,通常会使用下面的技巧:
sizeof(((People*)0)->hand);
C++11 后,sizeof 可以直接作用于类成员表达式了,上面的技巧可以简化成:
sizeof(People::hand)
扩展的 friend 语法
在 C++98,如果要指定另一个类是当前类的友元,必须要使用 friend class/struct <友元类>这样的写法,并且无法使用模板参数作为友元类,C++11 允许省略 class,而且可以使用模板参数作为友元类。
https://zh.cppreference.com/w/cpp/language/friend
final/override
final 用于在继承关系的中间终止一个虚函数被子类重载的可能性。override 用于显式声明某个函数是父类虚函数的重载,提升了代码可读性和健壮性(因为 virtual 修饰符只有在顶级父类声明虚函数时才是必须的,子类为同名虚函数添加 virtual 修饰符会被忽略,而 override 修饰符会被编=‘译器检查以确保函数的确重载了虚函数)。它们都在函数声明的参数列表之后。
为了尽可能兼容已有程序,C++11 没有将 final 和 override 规定为关键字,因此可以在代码中使用 final,override 变量,但最好别这么做。
模板函数的默认模板参数
C++98 允许为类模板参数指定默认参数,但却禁止为函数模板参数指定默认参数,语法上不一致,逻辑上不合理。
C++11 放宽了这一限制,可以为函数模板指定默认模板参数了。
外部模板
C++中,如果多个源文件都使用了同一个模板,会对该模板进行多次实例化,但最后编译时,编译器会仅保留一份模板实例化产生的代码。在一些比较大的项目中,冗余的模板实例化会明显拖慢编译速度,可以使用“外部模板”技术告诉编译器不需要对模板进行实例化,在某一个源文件中仅进行一次显式实例化。
template void fun
extern template void fun
局部和匿名类型作为模板实参
C++98 禁止局部或者匿名的类型作为模板参数,这个限制没什么道理,因此 C++11 放宽了该限制。
// C++98中,只有A或者a可以作为模板参数
struct A {int a;} a;
// 匿名类型
typedef struct {int a;} B;
// 匿名类型变量
struct {int a;} b;
void test() {
// 局部类型
struct C {int a;} c;
}
通用为本,专用为末
继承构造函数
继承关系中,子类可以自动或得父类的成员和接口,但构造函数无法自动地被子类继承。因为通常子类也有自己的成员,我们要定义子类自己的构造函数,在子类构造函数中去调用父类构造函数以及初始化自己的成员。
但是,如果子类中没有任何成员,或者其成员都用 C++11 的新特性“快速初始化成员变量”进行了初始化乃至于没有必要再用构造函数初始化了,那这时候我们很可能希望直接将父类的构造函数继承到子类,毕竟这时候只需要初始化父类成员。C++11 允许我们使用 using <父类名>::<父类名>来将所有父类构造函数引入子类,被 using 引入的父类构造函数是隐式声明的(也就是说,只有用到的函数才会被生成,以节省生成的代码)。
(书中这一节很多描述都和 XCode 实验现象对不上,很可能是因为成书时还无实验环境,导致描述有误)
委派构造函数
可以在构造函数的 initializer-list 中调用另一个构造函数来完成构造,这种将构造委托给另一个构造函数的行为 就叫委派构造函数。
一旦在 initializer-list 中进行了委派构造,就不能再用正常的 initializer-list 初始化成员变量了。因此,通常被委派的构造函数会负责初始化类的所有成员变量。
右值引用
可以使用两个引用符号 && 来声明并定义一个右值引用。和左值引用一样,右值引用的声明和定义必须在一块。
在 C++98 中,已经对右值有了一些描述,C++11 对右值进行了更进一步的定义:右值就是将亡值。
C++98 中,可以使用常量左值引用来引用右值,比如:
const MyCls &myRef = getTemp();
这样的常量左值引用的确引用了 getTemp 返回的临时变量,延长了它的声明周期,但由于 C++98 对于右值的定义是“不可被改变的常量”,因此之前只能使用对 const 的引用来引用右值。
C++11 改变了对右值的定义,因此使用 C++11 的右值引用方式引用的右值,其内容可以被修改。
移动语意
在 C++中,如果自定义类中含有指针,通常需要自定义拷贝构造函数和赋值运算符,在对指针进行赋值时,要为指针开辟新的堆内存,并将原指针内容拷贝过来,不要使用编译器生成的默认函数。因为默认函数在赋值时通通采用浅拷贝,会导致两个对象的指针指向同一地址,几乎一定会导致野指针问题。
但是,有时候我们拷贝或赋值时,比如 a = b,其中 b 如果是一个右值,那么直接将 b 的指针赋值给 a,并且阻止 b 对象在析构函数被调用时释放指针内存,是更合适的做法。因此如果 b 是一个右值,这意味着 b 马上就要被析构了,与其为 a 的指针开辟一片内存,不如直接利用 b 的指针现在使用的内存。这种做法就被称作“移动”b 的资源到 a,也就是“移动语意”。
C++11 中可以通过声明移动构造函数/赋值函数实现移动语意,这样的函数和普通函数的区别在于它们接受的参数类型是右值引用,因此当这样的函数被调用时,可以确保被引用的值马上就要被销毁,可以直接移动其资源。
移动构造函数应该是不会抛出异常的,因为如果移动到一半被终止了,会导致对象的一部分指针成员变成悬挂指针。标准库提供了 move_if_noexcept 函数,它会判断对象是否实现了 noexcept 的移动构造函数,如果实现了才返回右值引用,不然就返回左值引用,避免使用移动语意,退化为使用普通的拷贝。
完美转发
右值引用的问题
有了右值引用,看起来我们可以完美地实现移动语意了,但是,需要留意的是,我们在将右值赋给一个右值引用后,这个右值引用其实会被当成一个左值引用(毕竟移动语意本身就要求对右值引用进行修改)!类似的,右值引用的成员也是一个左值。
因此,在访问右值引用,或者在访问右值引用的成员时,必须将其转换成右值引用,否则就会被当成普通的左值引用。
// 像这样的声明赋值没有意义,实际上,a依然会成为一个左值引用
// A &&a = getTemp();
A &a = getTemp();
acceptRValueRef(std::move(a)); // OK,这里使用move把一个被当作左值引用的右值引用转成右值引用
accestRValueRef(std::forward<A>(a)); // OK,forward也能起到转为右值引用的作用
这个现象要求我们在创建移动构造函数时,必须要使用标准库
在将右值引用传入参数为右值引用的函数时,编译器会报错,因为右值引用实际上一旦被赋给引用变量,就会被当成左值引用。要让编译器重新将其重新当成一个右值引用,必须使用 std::move,std::forward 将其转成右值引用。
引用折叠
为了在模板编程时,让模板能够同时处理左值和右值引用,C++11 引入了引用折叠的规则:
using MyClsLRef = MyCls&;
using MyClsRRef = MyCls&&;
// C++11中被引用折叠规则理解为左值引用
MyClsLRef&& lRef = getMyCls();
// 下面两行是一样的,其中第一行在C++11中被引用折叠规则理解为右值引用
MyClsRRef&& rRef = getMyCls();
MyClsRRef rRef = getMyCls();
// 利用引用折叠规则,可以在模板编写中将参数声明为左值引用类型,这样的模板函数实际上可以同时接收
// 左值引用和右值引用
template <typename T>
void test(T&& t) { ... }
// 当T是一个右值引用时,T&&&&被折叠成右值引用
// 当T是一个左值引用时,T&&&被折叠成左值引用
// 不用考虑T不是一个引用,会有这样的考虑说明对C++不够熟悉,函数参数被声明为引用,传进来的肯定是引用
除了 std::move,标准库还提供了 std::forward,它的作用其实和 std::move 有重叠,都可以用来将变量转换为右值引用的。只不过它被规定应该专门用于“转发”场景,并且在调用时必须指定模板参数,从而可以利用引用折叠规则,将参数的左右值引用保留下来:
A getTemp() {
return A();
}
// 转发函数
void forwardToTest(A&& a) {
// do something
// test(a); 无法通过编译,因为一旦右值引用被赋给变量,这个变量就表现成了左值引用
test(std::forward<A&&>(a));
}
// 转发函数
template<typename T>
void forwardToTestTemplate(T&& a) {
// test(a); 同样无法通过编译
test(std::forward<T>(a));
}
template<typename T>
void test(T&& a) {
}
int main() {
forwardToTest(getTemp());
forwardToTestTemplate(getTemp());
A a;
// forwardToTest(a); 无法通过编译,因为a不是右值引用
forwardToTestTemplate(a); // 可以通过编译,因为模板方法有引用折叠规则
}
move 和 forward 的区别
- move 调用时不需要提供模板参数,它仅被用于将参数强制转为右值引用;
- forward 调用时必须要提供模板参数,通常会提供这样的模板参数:forward<T&&>,这样的好处是 T 如果被声明为左值,转换后还是左值,T 如果被声明为右值,转换后还是右值。
explicit 显示转换操作符
默认情况下,C++编译器会在函数调用的参数和函数声明不匹配时,想方设法地将参数转为匹配的类型,让函数调用能够通过,这中间会检查:
- 实参的类型转换运算符,如果有转换为目标类型的转换运算符就调用;
- 目标类型的构造函数,看是否有接收实参类型的构造函数,如果有就调用;
有时这很方便,但更多场景下这样的行为只会导致语意上的混乱。为了避免编译器的隐式转换,可以使用 explicit 修饰类型转换运算符或构造函数,这样编译器就不会尝试使用对应函数进行转换。
initializer_list 初始化列表
如何使用初始化列表
C++98 中,仅允许使用 initializer-list 初始化数组,C++11 扩展了 initializer-list 的概念,使得普通类型也可以使用 initializer-list 初始化(不要把它和类的成员初始化搞混,它们的确都叫 initializer-list,要区分时,可以将类的成员初始化叫做 member initializer list):
int a[]={1,3,5};//C++98通过,C++11通过
int b[]{2,4,6};//C++98失败,C++11通过
vector<int> c{1,3,5};//C++98失败,C++11通过
map<int,float> d = {{1,1.0f},{2,2.0f},{5,3.2f}};//C++98失败,C++11通过
如果要让自定义的类支持这种初始化方式,只要声明一个接收在<initializer_list>中定义的 initializer_list 类型的构造函数就可以了。该类型是一个 Iterable 类型,可以使用 begin, end 等标准遍历方法。
防止类型收窄
使用初始化列表还可以促使编译器检查类型收窄的情况。初始化列表是目前唯一一种检查类型收窄的方式(不过事实上现在的大多数编译器在没有使用初始化列表时也会检查类型收窄并给出警告,但使用初始化列表编译器会直接给出错误)。
POD 类型
POD,也就是 Plain Ordinary Data,纯数据类型。
C++11 对 POD 的定义是:平凡的,且是标准布局的。定义上,比 C++98 更宽容了,C++98 只规定了 C 风格的 struct 是 POD,但从 POD 的定义上,只要类对象布局是标准的,这样的类应该都是 POD 类。
(但是,对 POD 定义得更宽容似乎并没有什么意义?C++11 更多的是对于哪些情况会导致对象布局变化进行了更进一步的明确,只有不导致对象布局变化的类定义才是 POD 类。)
union 非受限联合体
C++11 将 C++98 对 union 的一些限制移除了。
-
在 C++98,union 中只能包含基础类型和 POD 类型,并且不能包含静态方法,但在 C++11 中,union 中可以包含任意非引用类型。
-
C++11 中,如果 union 任何一个成员拥有非平凡的构造函数,那么编译器就不会为 union 生成默认构造函数。
-
C++11 中,允许在类定义使用 union 声明成员变量,用这种方式声明的 union 不需要有类型名称,被称为匿名的非受限联合体,此时联合体内的所有成员都会自动的成为类的“变长成员”,即实际上它们共享同一处内存,应该只使用它们中的某一个。
用户定义字面量
字面量操作符
-
接收字符串:<用户类型> operator "” _<后缀字符>(const char* col, size_t size)
-
接收整数:<用户类型> operator "” _<后缀字符>(unsigned long long val)
-
接收整数,但整数越界,此时会传入’\0’结尾的 char*:<用户类型> operator "" _<后缀字符>(const char*)
-
接收浮点:<用户类型> operator "" _<后缀字符>(long double val)
-
接收字符:<用户类型> operator "" _<后缀字符>(char val)
内联名字空间
C++11 规定可以在 namespace 前加上 inline,将名字空间默认导出到声明名字空间的作用域。
这样的行为和 C++98 就有的匿名名字空间非常类似,除了内联名字空间有自己的名字以外。不过,它们被创建出来的目的是不同的:
- 匿名名字空间在 C++98 中用于替代 static 修饰符,因为 C++为类引入了 static 成员后,static 的语意变得非常模糊且矛盾,因此在原本使用 static 声明文件作用域的变量的地方,可以改成使用匿名名字空间来包围这些变量起到同样的效果;
- 内联名字空间则是被标准库用于和宏配合使用,根据当前编译环境决定默认导出同一个功能的哪一个版本的实现,这样做的好处是不关心具体实现的用户可以直接使用默认导出的功能,而了解更全面的细节的用户也可以使用名字空间来指定使用的是哪一个版本的功能。
使用 using 声明模板别名
在 C++11 中,已经可以使用 using 完全替代 typedef 了。
using 不仅有更清晰的语意,还可以部分声明模板参数:
template <typename T> using StringObjectMap = std::map<string, T>;
StringObjectMap<MyCls> myMap;
SFINAE 规则
SFINAE,就是 Substitution Failure Is Not An Error。
指的是,编译器在尝试模板参数匹配时,只要能够最终找到合适的匹配,中间尝试过的任何匹配失败都不会报错。
只不过,C++98 对于模板参数中使用表达式的情况支持的不友好,C++11 明确了任何在编译期合法的表达式都能够作为模板参数,比如下面的这个例子就在 C++98 中无法通过编译,而在 C++11 中可以:
template<int I> struct A{};char xxx(int);char xxx(float);template <class T> A<sizeof(xxx((T)0))> f(T){}
新手易学,老兵易用
右尖括号的改进
C++98 曾经规定应该把 » 优先判定为右移操作符,但这个规定在 C++11 被取消了,C++11 规定编译器可以自行智能地判断»是否是右移操作符。
auto 类型推导
在 C++98 中,auto 其实是用于声明一个变量具有“自动存储期”,但实际上除了 static 以外的变量都是默认具有自动存储期的,因此过去的 auto 几乎没有人使用。
C++11 中,auto 被赋予了新的含义,以前的 auto 含义被取消了。auto 成为了新的类型指示符,auto 声明的变量的类型必须在编译期由编译器推导出来。
decltype
decltype 是在编译时对表达式进行类型推导,推导出的类型可用于定义新的变量。decltype 主要是为了解决泛型编程中由于泛型参数类型不确定,导致和泛型参数相关的表达式类型无法确定的问题的。比如:
t1 是泛型 Type1 类型,t2 是泛型 Type2 类型,由于开发泛型代码时无法确定 Type1 和 Type2 的类型,自然无法确定 t1 + t2 的类型,但该类型其实是可以由编译器推导出来的,decltype 如今即可用在此处:
auto add(t1, t2) -> decltype(t1 + t2) {return t1 + t2;}
或者可以声明原本无法声明的变量:decltype(t1 + t2) addResult = t1 + t2;(当然,auto 也可以完成此工作)。
有时会将 decltype 和 auto 搭配使用:decltype(auto)。这是因为一方面,我们希望依赖 C++11 的类型推导能力(auto 的作用),但另一方面,又想保留 cv 限定符和引用(decltype 的作用)(auto 的类型推导规则和模板类似,如果不把 auto 声明为 auto&或者 auto*,auto 就会被视为不具备 cv 限定符的值类型,如果 auto 被声明为 auto&或者 auto*,auto 推导出的类型才会保留 cv 限定符)。但是使用这样的写法时要小心:decltype 在推导类型时,如果表达式是一个简单的名字,它会推导出名字的类型,但如果表达式不只是一个名字,比如 decltype((x)),那么即使 x 只是一个 int,该 decltype 也会推导出引用类型:int&。
追踪返回类型
auto func(char* a,int b) -> int;
基于范围的 for 循环
自定义集合类型要想支持这样的 for 循环,需要实现 begin, end, ++, ==四个函数。
提高类型安全
强类型枚举
C++98: enum X {…};
C++11: enum class X {…};
堆内存管理:智能指针与垃圾回收
提高性能及操作硬件的能力
常量表达式
C++11 规定使用 constexpr 修饰符来修饰常量表达式。常量表达式可以是函数或者值。常量表达式函数中不可以出现非常量表达式。常量表达式可以在编译期使用,但如果常量表达式并没有一定要在编译期被计算出来,标准规定编译器在这种情况下可以将常量表达式编译成普通的表达式,表达式会在运行时被计算。
变长模板
过去,C++可以使用 C 风格的方法来实现可变参函数,但这种实现方式是类型不安全的。
现在,C++11 为可变参函数提出了更合理(类型安全)的解决方案:变长模板。可以使用变长模板来声明变长模板函数或者变长模板类。
模板参数包与递归
使用 template <typename… Elements>这种方式可以声明一个变长模板参数,使用 Elements…这种方式可以将变长模板参数展开成实际的多个参数类型;
不定长的变长模板类可以通过模板类的递归来解包:
template <typename... Elements> class tuple; // 变长模板声明
// 以下是两个模板偏特化定义,利用模板偏特化会被优先匹配的规则,让变长模板参数递归地被解包
// 对于类型,可以使用递归的继承
template <typename Head, typename... Tail>
class tuple <Head, Tail...> : private tuple<Tail...> {
Head head;
}
template <> class tuple {};
// 对于函数,可以使用递归的函数调用
// 下面实现一个更强大的Printf,不论%后面跟的是什么符号,这个Printf总是会打印正确的类型
void Printf(const char* s) {
while (*s) {
if (*s == '%' && ++s != '%') {
throw runtime_error("invalide format");
}
cout *s++;
}
}
template <typename T, typename... Args>
void Printf(char* s, T value, Args... args) {
while(*s) {
if (*s == '%' && *s++ != '%') {
cout << value;
return Printf(++s, args...);
}
cout << *s++;
}
// 若百分号的数量和参数数量对不上,就抛异常
throw runtime_error("extra arguments provided");
}
进阶
引用类型
定义了模板参数包后,还可以在展开模板参数包时使用引用标记:Args&&…,这样的写法是合法的;
特殊展开
解包时,有些非常特殊的规则,需要特别说明一下:
template <typename... Args> class MyCls: private A<Args>... {};
// 上面的表达式在解包时会解包成多继承:
T<Parent1, Parent2> t; // t的类型是:class MyCls: private A<Parent1>, A<Parent2>
template <typename Args...> class MyCls: private A<Args...> {};
// 而这个表达式在解包时,会在泛型参数表达式中直接展开
T<Parent1, Parent2> t; // t的类型是:class MyCls: private A<Parent1, Parent2>
template <typename Args...> void test(Args... args) {
// 下面这个会被展开成call(a(arg1), a(arg2), ...)
call(a(args)...);
// 而下面这个会被展开成call(a(arg1, arg2, ...))
call(a(args...));
}
获取变长参数包长度
可以使用 sizeof…获取模板参数包的长度;
模板的模板(的模板的模板…)
变长参数的模板类型本身也可以是一个模板,这一点和以前的非变长模板参数一样。
原子类型和原子操作
C++11 以前,已经有很多使用多线程能力的 C++程序了,但之前语言本身并没有定义任何同多线程有关的内容,这些多线程能力来自于多线程接口 pthread。pthread 是一套 C 的接口。
通常情况下,如果我们不需要太精细的互斥控制,可以直接使用 posix 提供的 mutex 互斥锁 API,而如果想达到更优化的性能,可能会考虑为不同处理器编写内敛汇编代码。
C++11 标准为多线程程序在标准库中添加了原子类型,并允许指定原子类型的内存访问顺序一致性,让开发者可以不必操心操作系统和处理器的底层细节,也可以获得最优化的性能。
线程局部存储
C++11 定义了 thread_local 关键字来定义线程局部存储变量,这样的变量生命期是线程启动到线程结束,除了本线程外,没有其他线程可以访问到这样的变量。
C++11 仅规定了线程局部存储的行为,而没有规定其具体实现,不同的编译器在不同的环境中可能会有不同的实现方式。
快速退出:quick_exit, at_quick_exit
在过去,大体上有三种退出程序的方式:terminate(), abort(), exit()。
-
terminate 是有未处理的异常时会被调用的方法,可以使用 set_terminate 方法更改默认行为,terminate 默认调用 abort;
-
abort 是进程不得不终止时,被调用的函数,它会向本进程发送一个 SIGABRT 信号,该信号默认会导致操作系统直接释放该进程的所有资源并终止进程;
-
exit 是进程自发调用的退出函数,它代表着进程运行到了某个节点,该退出了,它会导致每一个自动变量的析构函数被调用(是的,仅自动变量,也就是栈变量会被调用析构函数,至于单例什么的需要自己处理),并调用 at_exit 注册的函数,然后才会回收进程;
C++11 新增了 quick_exit 标准方法,该方法语意上和 exit 一样,都是程序自发终止,但和 exit 不同的是,它不会进行本进程的清理工作,在多线程环境下也不会先等待线程结束,而是直接让操作系统终止进程并回收资源。
为改变思想方式而改变
指针空值-nullptr
C++11 定义了 nullptr_t 和 nullptr,前者是类型,后者是该类型的值。
nullptr 可以隐式转换为任何指针,但无法被隐式转换为 bool 类型,无法使用 if (nullptr)这样的表达式;此外它所属的 nullptr_t 是一个基础类型,nullptr 无法被推导为 T*这样的模板参数。
默认函数的控制
C++11 规定可以使用 = default 来使编译器生成默认版本的成员函数,可以由编译器生成的函数包括:
-
空构造函数
-
拷贝构造函数
-
拷贝赋值函数
-
移动构造函数
-
移动拷贝函数
-
析构函数
此外,编译器还为所有自定义类型提供以下全局默认操作符函数:
-
operator,
-
operator&
-
operator&&
-
operator*
-
operator->
-
operator->*
-
operator new
-
operator delete
lambda 函数
lambda 语法:
[<捕获外部变量>]<(可选)mutable>(<(可选)参数列表>) -> <(可选)返回值> {<函数体>}
-
捕获外部变量:变量需要用&或者=开头来引用,直接写&或者=后面不跟变量表示捕获所有外部变量,=表示按值捕获,&表示引用捕获;
-
mutable:labmda 默认是内联的 const 函数,不可以修改任何捕获的外部的按值捕获的变量(因为目前 lambda 的语意其实和仿函数完全一致,在仿函数中,所有捕获的外部变量都是仿函数类的成员,因此 const 函数不可以修改类成员,到 lambda 这里变成了 lambda 不可以修改捕获变量),但是引用捕获的变量则可以修改(这个行为也是和仿函数一致的,const 函数内可以调用任意成员引用的方法,修改其属性,因为将引用声明成 const,和将指针声明成 const 类似,都仅仅是禁止修改引用本身,但并没有限制对引用或者指针指向的变量进行修改)。如果希望 lambda 不是一个 const 函数,就要添加 mutable 声明;
-
参数列表:
-
返回值:当能够从函数体中推测出明确的返回值类型时,可以忽略;
-
函数体:
融入实际应用
对齐支持
C 和 C++都是具备直接操作底层硬件能力的语言,因此某些开发者会对数据结构的对齐方式特别关注。
C++11 规定了 alignof 和 alignas 关键字,前者可以查询某个类型的对齐方式(一般来说都是 32 位-4 字节或者 64 位-8 字节对齐),而后者可以规定某个自定义类的对齐方式。比如如果我们想要使用内联汇编的向量指令(可以同时处理 4 组处理器位数的数据)来优化处理速度,就可能想要将数据对齐到 4*处理器位数的位置处。
需要注意的是,之前很多编译器也规定了指定数据对齐方式的方式,比如 GNU 就规定可以使用如下方式规定对齐字节:attribute((aligned(8)));
需要注意的是,虽然标准规定了指定对齐的方式,但每个平台具体支持对齐到多少是不确定的。如果使用 alignas(2^64),那显然是不合法的。不幸的是,目前标准似乎没办法查询每个平台支持的最大对齐字节。不过一般来说我们也用不到太大的对齐字节。
通用属性
有时 C/C++提供的语言能力无法完全满足开发者的需求,编译器厂商为了解决这些问题,提供了一系列的语言扩展来扩展 C/C++的语法。这其中最常见的就是“属性”,可以告诉编译器一个符号的额外信息,让编译器做一些语言规范之外的处理。
- GNU 使用attribute((<属性列表>))来声明属性;
- Windows 使用__declspec(<属性列表>)来声明属性
C++11 也规定了类似的属性,之所以在语言中添加属性,是为了避免再给 C++增加更多的关键字。C++11 的属性和之前各个平台的编译器实现的属性的目的是一致的,它们提供的能力都是一般用不到,可以忽略的能力,语言规范不会考虑使用关键字来实现这些能力,因此将它们定义到通用属性里。C++规定通用属性的写法是:[[<属性列表>]],这样的通用属性可以用来修饰任何语言元素。不过目前 C++11 只定义了两个通用属性:[[noreturn]]和[[carries_dependency]]。
Unicode 支持
字符集和编码
ASCII 码是最早的编码,使用 7 位二进制位来表示所有英文字母和在英文排版印刷中用到的字符,后来 ISO 和 Unicode 组织共同制定了一套能够唯一的表示世界上所有字符的标准字符集,称为 ISO/Unicode 字符集或者 Unicode 字符集。Unicode 规定了每个字符在整个字符集中的具体值(范围 0x0-0x10FFFFF),但并没有规定计算机中如何存储这样的值,UTF-8 UTF-16 UTF-32 是 Unicode 字符集事实上的编码标准。
UTF-8 使用 1 ~ 6 字节的变长编码方式来编码 Unicode,由于 UTF-8 较为节约存储空间,因此使用的比较广泛。
GB2312 早于 Unicode 被定义,是和 Unicode 不同的一种编码(不过 Unicode 汉字部分编码其实就是 GB2312 的变种),采用 2 字节表示一个中文字符,和 Unicode 不一样的是,GB2312 既是字符集,又是字符编码。
C++中的 Unicode 支持
C++98 已经规定了 wchar_t 类型,但是 C++98 对 wchar_t 的定义不够明确,不同的编译器中 wchar_t 的位数不一致,导致移植性问题。
C++11 重新规定了 char16_t char32_t,用于存储 UTF-16 UTF-32 编码的 Unicode 数据,UTF-8 的数据则直接使用 char 来存储。C++中可以在字符串常量前加前缀来让编译器产生不同编码的数据:
-
u8 - UTF8
-
u - UTF-16
-
U - UTF-32
-
L - wchar_t
之所以没有为 UTF-8 规定类型,是因为 UTF-16 和 UTF-32 都是定长编码,而 UTF-8 是变长编码(有误,过去某段时间 Unicode 还比较少,当时 UTF16 编码 Unicode 的确是事实上的定长编码,但现在 Unicode 字符集已经收录了更多字符,早已超出了 UTF-16 的表示范围,UTF-16 已经成为了事实上的变长编码,一些历史程序如果还假定 UTF-16 是定长编码的话,遇到超出 UTF-16 表示范围的字符时就会出问题。),变长编码会导致很多算法变得极其复杂(比如无法确定一个 utf_8[]中的第 N 个字符究竟被存储在数组中的哪个位置)。对于语言来说,定长编码处理起来更自然,且增加的内存占用和减少的程序设计复杂度也大体可以认为相互抵消,可以使用定长编码进行处理,需要保存时再存成变长编码以节省存储空间。
C++中,影响 Unicode 字符能够正确保存和输出的因素有以下三点:
-
文件编码
-
编译器编码设置
-
输出设备
为了确保得到正确的输出,需要确保源文件的编码同系统编码一致、并且用于输出的设备支持被输出的编码(比如不少 shell 就只支持 UTF-8 编码,非 UTF-8 编码的会直接输出十六进制的编码值)。
标准库支持
C++11 新增了几个字符类型,也同步地在标准库中新增了字符类型的转换函数。