第 18 章 探讨 C++ 新标准
1. 复习前面学习过的 C++ 11 功能
1.1 新类型
新增了 long long 和 unsigned long long 类型,支持 64 位(或更宽)的整型;新增 char16_t 和 char32_t,支持 16 位 和 32 位字符。
1.2 统一的初始化
初始化可用于所有的内置类型和用户自定义的类型(类对象),使用初始化时,可添加等号(=),也可以不添加:
int x = {5};
double y{2.75};
short quar[5] {4, 5, 2, 76, 1};
初始化也可用于 new 表达式:
int * ar = new int [4] {2, 4, 6, 7};
创建类对象时,也可以使用大括号(不是圆括号),来调用构造函数初始化类对象:
class Stump
{
private:
int roots;
public:
Stump(int r) : roots(r) {};
}
Stump s1(32);
Stump s2{12};
Stump s3 = {3};
如果类定义中,有以 initialize_list 作为参数的构造函数,则只有该构造函数可以使用大括号形式。
使用大括号的初始化列表语法,可以防止数值窄缩,即使用列表初始化一个不能储存该数值的变量时,编译时会报错:
int c1 = 1.57e27 // int -> double 未定义行为
char c2 = 4589652359 // int -> char 超内存 未定义行为
char c3 {1.57e27} // 编译报错
char c4 = {589652335} // 编译报错
C++ 11 提供的 initializer_list 模板类,当存在使用这个类作为参数的构造函数时,初始化列表初始化类对象时,只能适配这个构造函数:
vector<int> a1(10); // 十个元素的容器
vector<int> a2{10}; // 使用 initializer_list 构造函数,一个元素,初始化成10
initializer_list 模板类还可以作为常规函数的参数,并以大括号的形式表示:
double sum(std::initializer_list<double> i1);
...
double total = sum({2.5, 3.14, 4});
double sum(std::initializer_list<double> i1)
{
double tot;
for(auto p = i1.begin(); p != i1.end(); p++)
tot += *p;
return tot;
}
1.3 声明
auto 可以实现自动类型推断,但是要配合初始化:
auto maton = 112; // int
auto pt = &maton; // int *
double fm(double, int);
auto pf = fm; // double(*)(double, int)
decltype 可以将变量类型声明为表达式形式,在定义模板时很有用:
double x;
int n;
decltype(x*n) q; // q 类型是 x*n 表达式的类型,这里是 double
decltype(&x) pd; // &x 的类型,这里是 int*
template<typename T, typename U>
void ef(T t, U u)
{
decltype(t*u) tu;
...
}
decltype 的原理要复杂一些,如表达式有小括号时,是指向引用:
int j = 3;
int &k = j
const int &n = j;
decltype(n) i1; // i1 type const int &
decltype(j) i2; // i2 type int
decltype((j)) i3; // i3 type int &
decltype(k+1) i4; // i4 type int
decltype 可以实现模板函数的返回值类型后置:
template<typename T, typename U>
auto eff(T t, U u) -> decltype(t*u)
{
...
}
使用 using 可以为模板起别名,方便对冗长复杂的标识符简化:
using itType = std::vector<std::string>::iterator;
对于模板部分具体化:
template<typename T>
using arr12 = std::array<T, 12>;
...
arr12<double> a1; // std::array<double, 12>
1.4 异常方面
关键字 noexcept 可以使函数不引发异常:
void f85(short) noexcept; // f85 不会throw 异常
1.5 作用域内枚举
枚举提供了一种创建名称常量的方式,它的作用域是定义所属的作用域,若同一作用域内定义多个枚举,所有的枚举成员不能同名。而且,枚举不同的实现可能使得枚举成员的底层类型不同。C++ 11 新增了使用 struct 或 class 定义的枚举:
enum old1 {yes, no};
enum class new1 {never, sometimes, often};
enum struct new2 {never, lever};
引用枚举成员时,使用 new1::never 或 new2::never。这两个不冲突。
1.6 对类的修改
explicit 关键字禁止单参数构造函数将符合参数的变量自动转换成类对象,但是可以显式调用:
class Pleb
{
public:
Pleb(int);
explicit Pleb(double);
...
};
...
Pleb a, b;
a = 5; // 隐式自动转换,可以
b = 0.5; // 隐式自动转换,被explicit禁止
b = Pleb(0.5); // 显示调用构造函数,允许
C++ 11 将 explicit 关键字扩展到了类转换函数上:
class Plob
{
...
operator int() const;
explicit operator double() const;
...
};
...
Plob a, b;
int n = a; // 允许
double x = b; // 不允许,explicit 禁用
x = double(b); // 显式调用,允许
C++ 11 允许类内初始化,降低在构造函数中写重复代码。
1.7 模板和 STL 方面的修改
STL 新增了 cbegin()、cend()、crbegin()、crend() 函数,分别是指向第一个、超尾的 const版本,以及反向的 const 版本。
1.8 右值引用
左值是一个表示数据的表达式(如变量名或解除引用的指针),程序可以获取左值的地址,修饰符 const 使得一个变量不能给它赋值,但是可以取地址。
C++ 11 新增右值引用,用 && 表示,右值引用关联到右值,右值可以出现在赋值表达式右边,但不能对右值取地址。右值包括字面常量(不包含字符串常量,它表示地址)、如x+y等表达式以及有返回值的函数(该函数返回值不是引用):
int x = 10;
int y = 23;
int &&r1 = 233;
int &&r2 = x + y;
double && r3 = std::sqrt(2.0);
r2 关联的是 x + y 的结果,即 33,即使修改了 x y 的值,也不影响 r2。右值引用将右值存储到特定位置,且可以通过对 r1 用 & 取值运算符,得到右值 233 的地址。
2. 移动语义和右值引用
右值引用主要的使用领域是移动语义,如有一个vector,有20000个string,对它进行复制或者按值作为参数传入函数时,可能会创建临时变量,并可能做大量无用功。移动语义可以直接转让所有权,省略掉创建临时变量的过程。而右值引用告诉编译器这时候使用移动语义。
一般的深复制构造函数:
// n 和 指针 pc 为类的私有成员
Useless:Useless(const Useless & f) : n(f.n)
{
pc = new char [n];
for (int i = 0; i < n; i++)
pc[i] = f.pc[i];
}
而一般的移动构造函数:
Useless:Useless(Useless && f) : n(f.n)
{
pc = f.pc;
f.pc = nullptr;
f.n = 0;
}
转让了内存,并将指针置为空,这样析构的时候不会产生问题,delete [ ] 可以对空指针使用。由于修改了作为参数的对象的内容,因此形参不能是 const。
使用移动构造函数时:
Useless one, two;
Useless thress(one + two); // 假定类定义中重载了 + ;使用移动构造函数
C++ 98 时,为引用右值引用这个概念,Useless thress(one + two) 时,会调用复制构造函数,但是 one + two 是右值,复制构造函数的形参是 const,当实参是右值时,const 引用会指向一个临时变量,该临时变量会复值成 one + two 的结果。
同样的,对于赋值运算符重载,也有移动语义的情况,一般的深度赋值运算符重载:
Useless & Useless::operator=(const Useless & f)
{
if (this == &f)
return *this;
delete [] pc;
pc = new char [n];
for (int i = 0; i < n; i++)
pc[i] = f.pc[i];
n = f.n;
return *this;
}
一般的移动赋值运算符重载:
Useless & Useless::operator=(Useless && f)
{
if (this == &f)
return *this;
delete [] pc;
pc = f.pc;
n = f.n;
f.pc = nullptr;
f.n = 0;
return *this;
}
移动赋值同样是转让参数的所有权,所以形参不能是 const。
可以将左值强制转换为右值,通过 static_cast<> 将对象转换为 && 的形式。C++ 11 提供了头文件 utility,其中声明的函数 std::move() 函数可以将左值转换为右值:
Useless one, two,three;
three = one + two; // 移动赋值
Useless four;
four = one; // 赋值
four = std::move(one); // 移动赋值
但是,如果一个类没有定义移动赋值运算符,而程序使用 std::move() 时,编译器将使用赋值运算符,如果也没有定义赋值运算符,则不允许这种赋值。
右值引用对大部分开发人员的作用并非是编写出右值引用的代码,而是使用定义了右值引用函数的库,如 STL。
3. 新的类功能
C++ 11 下,有6个特殊的类成员函数,默认构造函数、析构函数、复制构造函数、赋值运算符、移动构造函数和移动赋值运算符。特殊是因为,上述成员函数编译器会自动提供。
如果用户定义了析构函数、复制构造函数或赋值运算符,编译器将不会提供移动构造函数和移动赋值运算符;反之,如果用户定义了移动构造函数或移动运算符,编译器也不会提供复制构造函数和赋值运算符。
如果定义了移动构造函数,编译器不会自动生成默认构造函数、复制构造函数和赋值运算符,但是可以通过关键字 default,显式的让编译器生成默认的版本:
class Someclass
{
public:
Someclass(Someclass &&);
Someclass() = default; // 显式让编译器生成默认构造函数
Someclass(const Someclass &) = default;
Someclass & operator=(const Someclass &) = default;
...
};
也可以通过关键字 delete,禁止编译器使用特定方法:
class Someclass
{
public:
Someclass() = default;
Someclass (const Someclass &) = delete; // 禁用复制构造函数
...
};
关键字 default 只能用在 6 种特殊成员函数,而关键字 delete 可用于任何成员函数。delete 的一种用法是禁止特定的转换:
class Someclass
{
public:
void redo(double);
...
};
Someclass s1;
s1.redo(4); // int 值 4 被提升为 4.0,进而执行 redo()方法
class Someclass
{
public:
void redo(double);
void redo(int) = delete;
...
};
Someclass s2;
s2.redo(5); // 不允许
s2.redo(5.0_; // 允许
如果类内定义了多个构造函数,允许在构造函数定义中使用另一个构造函数,称为委托,通过初始化列表的形式实现:
class Note
{
int k;
double x;
std::string st;
public:
Note();
Note(int);
Note(int, double);
Note(int, double, std::string);
};
Note::Note(int kk, double xx, std::string stt) :
k(kk), x(xx), st(stt) { ... }
Note::Note() : Note(0, 0.1, "Oh") { ... }
Note::Note(int kk) : Note(kk, 0.01, "Ah") {...}
Note::Note(int kk, double xx) : Note(kk, xx, "Uh") {...}
类中的虚方法,通过让基类指针或引用通过指向的对象选择基类或者派生类的虚方法,但是当虚方法的特征标不同时,派生类的虚方法会隐藏基类的方法,并不是覆盖:
class Action
{
...
public:
virtual void f(char ch) { ... }
};
class Bin : public Action
{
...
public:
virtual void f(char * ch) { ... }
}
Bin b;
b.f('#'); // 不允许,f(char ch) 方法是 Action 类的,被 Bin 的虚方法隐藏掉了
说明符 override 指出派生类的虚方法是要覆盖掉基类的虚方法,如果声明的虚方法特征标与基类的不同,编译器会直接报错:
class Bin : public Action
{
...
public:
virtual void f(char * ch) override { ... } // 编译器会报错
}
归根结底,实现虚方法的多态,派生类与基类虚方法必须一样。
说明符 final 禁止派生类重新定义基类的虚方法:
class Action
{
...
public:
virtual void f(char ch) final { ... } // 派生类将不能覆盖 f 方法
};
class Bin : public Action
{
...
public:
virtual void f(char ch) { ... } // 编译器会报错
}
4. Lambda 函数
lambda 即匿名函数,用中括号代替函数名,没有返回值:
[](参数,如:int x){函数逻辑,例如:return x % 3;}
如果没有return,则会推断返回类型为 void。仅当lambda 函数的函数体由一条 return 表达式组成时,会自动推断返回类型,否则需要使用返回类型后置的语法:
[](double x)->double{int y = x; return x + y;}
lambda 函数可以访问它定义的作用域内的的所有变量,要捕获这些变量,将它们放在中括号内就可以了,并可以给 lambda 制定一个名称:
{
int c = 2;
int d = 14;
int b = 3;
auto sum = [b](int x){return x + b};
int num = sum(8); // 8+3
}
也可以在名称前加前缀 &,按引用访问该变量;[=] 表示可以按值访问作用域内的所有变量,即上面的 b、c、d;[&] 表示按引用访问作用域内所有变量,即 &c、&d、&b;[c, &d] 表示按值访问 c,并按引用访问 d;[&, c] 表示按引用访问 d、b,按值访问 c;[=, &d] 表示按值访问 b、c,按引用访问 d。
当想要复用 lambda 时,可以给它起名,并可以把这个名称当函数符使用:
auto mod3 = [](int x){return x % 3 == 0;}
bool result = mod3(3);
5. 包装器
C++ 提供了多个包装器(wrapper,也叫适配器 [adapter]),C++ 11 提供了模板 bind、men_fn、reference_wrapper 以及 function。bind 可以替换前面章节提到的 bind1st 和 bind2nd。men_fn
能够将成员函数作为常规函数。reference_wrapper 可以创建行为像引用但可以被赋值的对象。function 可以以统一的方式处理多种类似函数的形式。
5.1 包装器 function 及模板的低效性
模板的低效性在于,不同的函数对象实际实例化相同的函数时,模板可能会实例化多个:
template <typename T, typename F>
T use_f(T t, F f)
{
static int count = 0;
count++;
cout << "&count = " << &count << endl;
return f(t);
}
double dub(double x) (return 2.0 * x;}
double square(double x) {return x * x;}
class Fp
{
private:
double z_;
public:
Fp(double z = 1.0) : z_(z) {}
double operator()(double p) {return z_ * p;}
};
class Fq
{
private:
double z_;
public:
Fp(double z = 1.0) : z_(z) {}
double operator()(double p) {return z_ + p;}
};
double y = 1.21;
use_f(y, dub);
use_f(y, square);
use_f(y, Fp(5.0));
use_f(y, Fq(5.0));
上面的模板实例了 4 次,但其实实例的模板参数 F 实际都是 double(*)(double) 的形式,四次调用只需要实例化模板一次就够了。但是程序运行的时候会实例化三次,前两个 use_f 对应 double(*)(double) 的形式,调用的是函数指针,所以会实例化一次。后两个 use_f,因为参数变成了类对象,而且是不同的对象,Fp,Fq,因此又实例化了两次。
这时候使用 std::function 就可以提高效率,它只会实例化一次:
#include <functional>
std::function<double(double)> fd1 = dub;
std::function<double(double)> fd2 = square;
std::function<double(double)> fd3 = Fp(5.0);
std::function<double(double)> fd4 = Fq(5.0);
use_f(y, fd1);
use_f(y, fd2);
use_f(y, fd3);
use_f(y, fd4);
其实上面的例子中,无论是函数指针还是函数对象,甚至是 lambda,只要它们的参数和返回类型相同,即调用特征标相同,通过 function,都可以看作一种对象,特征标 double(double),由尖括号括起来,并起了名字 fd1、2、3、4,这样模板实例化时,会把同类的对象视为同一种模板参数 F,这样多次都用也只会实例化一次。
function 还可以这样用,只创建一个 function 对象,当作 use_f()的参数,相当于类初始化:
typedef std::function<double(double)> fd;
use_f(y, fd(dub));
use_f(y, fd(square));
use_f(y, fd(Fp(5.0)));
use_f(y, fd(Fq(5.0)));
或者模板的第二个参数 F 直接定义成 function 包装器对象:
template <typename T>
T use_f(T t, std::function<T(T)> f)
{
static int count = 0;
count++;
cout << "&count = " << &count << endl;
return f(t);
}
use_f<double>(y, dub);
use_f<double>(y, square);
use_f<double>(y, Fp(5.0));
use_f<double>(y, [](double u){return u * u;});
6. 可变参数模板
可变参数模板使用户可以创建可变参数数量的模板函数和模板类。可变参数模板主要由:模板参数包、函数参数包、展开参数包、递归,这几个部分组成。
template<typename... Args>
void show_list(Args... args)
{
...
}
Args 表示模板参数包,args 表示函数参数包,这两个参数包可以指定任何符合 C++ 标识符规则的名称。同 <typename T> 相比,T 只能指一种类型,Args 可以指多个类型(包括0个,即无参数)。
show_list(); // 无参数
show_list(99); // Args 为 int
show_list(85.2,"cat"); // Args 为 double 和 const char *
show_list(2, 2.7, "dog", string("HI")); // Args 为 int double const char * string
无法通过索引访问模板参数包的第几个类型:Args[2] 来访问第三个类型是不行的。
要实现可变参数模板,就需要递归的方法,从第一项开始处理,直到处理完全部 Args:
void show_list() {}
template<typename T>
void show_list(T &value)
{
cout << value << endl;
}
template<typename T, TypeName... Args>
void show_list(const T& value, const Args&... args)
{
cout << value << ",";
show_list(args...);
}
通过递归,每次都只输出一个类型对应的变量值,为了提高效率,采用了按引用传递的模板形参,对于函数参数包的情况:const Args&... args。模板每次只输出第一个参数,将剩下的交给递归调用。并通过定义一个只有一个参数的模板重载,将最后一个参数可能会输出 “,”的情况消除掉了。