C++语言高级特性

C++是一门很高深的语言,曾经上高中学了“C with STL”就以为自己学会了C++,实际上只是管中窥豹。如今捧起《C++ primer plus》拜读,才知道自己的渺小。C++的语言特新甚至还不断随着新标准的发布而更新。这篇博客会概括一下C++11之前的高级语言特性。由于这些高级用法过于尖端,实际使用到的很少,所以各个版块之间也毫无关联,权当走马观花,简单了解一些,以致于在看到别人写的代码时不至于看不懂。

友元类

之前有提到过友元函数,这是一种允许外部函数访问私有成员的技术。友元类也十分类似,在类中声明另一个类为友元类之后,这个友元类就可以访问类的所有私有成员。友元类可以声明在类的私有、保护、公有部分,这是无关紧要的,因为友元类并不是类的一个成员。

1
2
3
4
5
6
7
8
9
10
class TV
{
public:
friend class Remote;
}

class Remote
{

}

友元成员函数

很多情况下友元类并不是所有方法都需要访问另一个类的私有部分,此时可以让特定的类成员成为另一个类的友元。但这样必须小心排序各种声明和定义,以便于编译器处理。

1
2
3
4
class TV
{
friend void Remote::set_chan(TV &t, int c);
}

这里需要让编译器知道Remote类的定义,否则编译器无法知道set_chan这个方法,而set_chan方法又使用了TV的引用,所以正确的排序应该如下所示:

1
2
3
class TV;
class Remote{};
class TV{};

总之,在声明和定义友元类时,应该注意为编译器提供足够的信息,可以考虑使用前向声明外部定义的方法来达成这个目的。

类型转换运算符

作为一种强类型语言,C++提供了比C更严格的类型转换运算符:

  • dynamic_cast
  • const_cast
  • static_cast
  • reinterpret_cast

接下来分别介绍:

dynamic_cast

这个运算符的出现是便于派生类和基类之间的相互转换。众所周知,派生类的引用和指针可以转换位基类的引用和指针,这是安全的,但是将基类转化为派生类是无意义的。这个运算符有点像C#中的as运算符,其特点都是尝试将基类引用/指针转化为派生类引用/指针,如果指针或引用不是指向派生类的,就会返回空指针,其用法如下:

1
2
High *ph = new High();
Low *pl = dynamic_cast<Low*> ph;

const_cast

这个运算符用于去除const变量的不可变性,通过这个运算符获得新指针后,就可以通过新指针来修改原来是const的变量。

1
2
3
High bar;
const High *pbar = &bar;
High *pb = const_cast<High *>(pbar);

这样就可以通过pb来修改bar的内容。为什么要提供这样的运算符呢?因为有可能需要这样的一个值,在大部分情况下是常量,而在特定的地方又需要修改,此时使用这个运算符更加安全。

static_cast

static_castdynamic_cast的作用非常相似,都是进行继承的上下游转换,但是它们是有区别的,这点从运算符的名字就可以看出来。实际上,static_cast是一种静态的转换,是在编译时就进行转换,所以它实际上是C语言中强制类型转换的一种替代,可能会引发危险。而dynamic_cast是一种动态的转化,在运行期进行检查并转化,它是一种“试探性”的转换,如果发现没有办法实现转换,会返回一个空指针,因此较为安全。

reinterpret_cast

这是一种“天生危险”的转换,会产生一些“令人生厌”的操作,非到万不得已不要使用。

智能指针模板类

C++最令人诟病的另一个问题是堆上内存的释放,特别是在分支流程较多,或者存在异常处理语句的程序中,释放内存这个过程很容易被程序员遗忘,造成内存泄露。于是C++就诞生了智能指针类,智能指针能够在变量的生存期到时自动释放指针所指向的内存,智能指针类分为三种:

  • auto_ptr
  • unique_ptr
  • shared_ptr

接下来分别阐述。

auto_ptr

智能指针类听起来高级,很实际上就是一种模板类,它有一个指针类型的成员变量,用于存储我们希望自动释放的指针。通常智能指针被声明为栈变量,如此一来,当其生存期到达时,就会调用析构函数释放成员变量所指向的内存,防止出现忘记释放内存的情况。

智能指针还实现了*->等运算符,所以使用上和一般指针没有特别大的区别,但是有一点需要注意,智能制造类只有一个explicit构造函数,所以必须显示声明智能指针,不能使用赋值语句来隐式地为其赋值。

1
2
3
auto_ptr<MyClass> p(new MyClass()); // valid
auto_ptr<MyClass> p;
p = new MyClass(); // not valid

智能指针的注意事项

智能指针虽然很好用,但是会出现一个问题,如果多个智能指针指向同一个地址,那么在代码块结束时,这些智能指针类的析构函数会被依次调用,那么同一个内存就会被释放很多次,这会带来问题。另一种情况是,程序在另一处已经使用delete释放了指针指向的内存,而智能指针再次试图释放。

基于这个原因,auto_ptr被很多程序员摒弃,转而使用更安全的shared_ptrunique_ptr

shared_ptr

不同与auto_ptr的无条件释放内存,shared_ptr会统计指向某一块内存的指针总数,当指向某块内存的指针数量为0时,其才会将内存释放,所以使用起来更加安全,这点与java、C#等语言的引用原理很相似。

unique_ptr

shared_ptr相反,unique_ptr不允许多个指针指向同一个内存,它禁止了赋值运算符和复制构造函数,因此没有办法令两个智能指针指向同一个内存。但是在一些特殊情况下,如右值是一个临时右值,编译器将允许这么做:

1
2
3
unique_ptr<string> ps = get_new_string(); // allowed
unique_ptr<string> ps2 = ps; // not allowed
unique_ptr<string> ps3 = unique_ptr<string>(new string("hhh"));

因为右边的指针是一个临时的变量,其所有权即将被转移,所以这么做没有问题。

实际上,在编程中会需要用到unique_ptr之间的赋值(实际上这里应该称为转移),但是这个智能指针类禁用了赋值运算符,所以需要使用标准库函数std::move()来实现:

1
2
3
unique_ptr<string> ps1, ps2;
ps1 = demo("aaa");
ps2 = std::move(ps1);

这样依赖,原来的ps1指针会变为nullptr

泛型编程

模板使得能够按泛型定义函数或者类,而泛型编程试图将算法运用到这些模板对象中,使得算法与数据的存在形式相对地独立。

迭代器

想要在容器上运用某种算法,就必须先尝试用通用的办法来遍历。C++提供了迭代器来完成这个操作,每种容器都定义了自己的迭代器类型,想要使用迭代器,需要先使用域解析运算符。根据迭代器的用法,C++提供了5种迭代器的类型:

  • 输入迭代器:(输入是相对程序而言)该迭代器只能从容器读取数据,而不能修改容器的数据,是单向迭代器,只能递增,不能倒退。
  • 输出迭代器:与输入迭代器相反,只能够修改容器的数据,而不能读取。
  • 正向迭代器:能够正向移动,也能输入输出,可以看成输入迭代器和输出迭代器的合并。
  • 双向迭代器:能够正向和反向移动,也能输入输出。
  • 随机访问迭代器:可以实现i += n操作,比双向迭代器更进一步。

设计不同种类的迭代器,目的是在编写算法时尽可能使用要求最低的迭代器,以保护数据。关于迭代器和STL的内容,需要探讨的太多了,这里只简单介绍,之后如果使用到更深入的内容会专门写一篇来记录一下。

函数对象

函数对象(函数符functor)是可以以函数方式与()结合使用的任意对象,可以是函数名、指向函数的指针或者是重载了()运算符的类对象。

这里给出一个以类对象而不是函数形式存在的函数符定义:

1
2
3
4
5
6
7
8
9
template<class T>
class TooBig
{
private:
T cutoff;
public:
TooBig(const T &t):cutoff(t){}
bool operator() (const T & v){return v > cutoff;}
}

这样就可以把TooBig对象当做函数一样的使用。

预定义的函数符

STL定义了多个函数符(其实就是函数模板,只不过定义的运算大多是与数学相关的),为了更好地解释预定义函数符,这里先介绍transform函数。该函数有两个原型,一个对应一元操作,一个对应二元操作。

1
2
3
4
5
6
7
8
9
template <class InputIterator, class OutputIterator, class UnaryOperation>
OutputIterator transform (InputIterator first1, InputIterator last1,
OutputIterator result, UnaryOperation op);

template <class InputIterator1, class InputIterator2,
class OutputIterator, class BinaryOperation>
OutputIterator transform (InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, OutputIterator result,
BinaryOperation binary_op);

前者将first1last1中的元素经过op运算后写入到result中,后者将first1first2中对应元素进行binary_op后写入到result中。这两个元素的最后一个参数就是我们需要使用的函数符。举个例子:

1
transform(v1.begin(),v1.end(),v2.begin(),out,plus<int>());

将两个向量中的元素相加后输出到out迭代器所指向的位置。

STL提供的函数符有以下几种:

运算符函数符运算符函数符+plus==equal_tominus!=not_euqal_tomultiplies>greater/divides<lessnegate>=greater_equal<=less_equal&&logicalandlogical_or!logical_not\begin{array}{|c|c||c|c|} \hline 运算符 &函数符 & 运算符 & 函数符\\ \hline + & plus & == & equal\_to\\ \hline - & minus & != & not\_euqal\_to\\ \hline * & multiplies & > & greater\\ \hline / & divides & < & less\\ \hline - & negate & >= & greater\_equal\\ \hline <= & less\_equal & \&\& & logical_and\\ \hline || & logical\_or & ! & logical\_not\\ \hline \end{array}

自适应函数符

有时我们想要对一个容器的数据进行二元运算,例如将一个向量的所有元素翻倍,如果使用上面的运算符,那么可能需要额外定义一个同样大小的向量,这很麻烦。更好的办法是使用自适应运算符,将二元函数符的一个参数固定住,这就需要使用STL中的函数binder1stbinder2st。接下来看看用法:

1
binder1st(multiplies<double>(), 2);

这个函数返回一个multiplies<double>对象,但是这个对象在作为函数符进行运算时,第一个参数会固定为2,这样就实现了上述的功能。

binder2nd同理,只是绑定的参数位置不同。

C++11新标准

声明

auto关键字

auto关键字让编译器自行推断值的类型,可以在编程时提供很多便利。

decltype和返回类型后置

这些在上一篇博客中提到过,这里不再赘述

模板别名using

类似于typedef,区别在于typedef不适用=号而使用空格。另一个很大的区别是using可以支持具体化模板,如:

1
2
3
4
template<typename T>
using ar12 = std::array<T,12>;

arr12<double> a1; // std::array<double,12>;

右值引用和移动语义

左值引用声明了一个对变量的别名,当然,前提是这个变量是拥有地址的,在内存中有确定的存储空间。而C++11新增了右值引用,使得我们可以对右值(函数的返回值、字面量)等声明别名。在函数的调用等方面,右值引用和左值引用在性能上并没有提升,但是当涉及到对象的深拷贝等方面时,移动语义(运用了右值引用)就可以大大提高性能。

通常来说,将右值赋给对象时,会调用复制构造函数(如果有定义的话),当对象占用空间很大时,这个操作的时间代价将很高:

1
string s2("long string" + "long long string")

(假设还没有进行任何优化)这将创建一个临时变量,然后调用s2的赋值构造函数将这个临时变量的数据一个一个拷贝,这将消耗大量的时间。我们可以思考,既然临时变量马上就要被销毁,那么为什么不可以直接将临时变量的数据占为己有。移动构造函数正是实现了这个效果,可以通过下面的方法来定义:

1
2
3
4
5
6
7
8
9
class string
{
// ...
string(string && s)
{
this->str = s.str;
s.str = nullptr;
}
}

第二步将s.str=nullptr是为了防止临时变量在调用析构函数时释放内存(释放空指针的内存是允许的)。通过定义移动构造函数,就省去了右值被拷贝的过程。同理,赋值运算符的移动语义也可以类似地实现:

1
2
3
4
string string::operator=(string && s)
{
//...
}

新的类功能

默认和禁用的方法

如果我们为一个类提供了移动构造函数,那么编译器将不会提供默认构造函数和默认复制构造函数,此时可以显示地声明:

1
2
3
4
5
6
class A
{
public:
A() = default;
A(const A &) = default;
}

反之,如果想要禁用某个类的方法(包括构造函数等),也可以显式地声明:

1
2
3
4
5
6
7
class A
{
public:
A(int a) = delete;
void func(int t) = delete;
void func(double t) = delete;
}

于是被声明为delete的方法都不能被调用,而试图通过func(5)来调用函数,也会被编译器所发现,并发生编译错误。

之前提到,也可以将方法放到private中以禁止调用,但是使用delete来调用显然更容易理解,且意图更明显。

委托构造函数

和Java、C#的语法很相似,其实就是构造函数通过调用自身的其他构造函数来完成数据的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
class A
{
int a,b;
public:
A():A(1,2);
A(int _a):A(_a,5);
A(int _b):A(9,_b);
A(int _a,_b)
{
a = _a;
b = _b;
}
}

继承构造函数

C++98中提供了using关键字,使得派生类中可以使用基类的方法(当方法签名相同时,优先使用派生类的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A
{
public:
void func(int);
void func(double);
}

class B:public A
{
public:
using A::func;
void func(double);
}
// ...

B b;
b.func(5.0); // calls B::func(double)
b.func(3); // calls A::func(int)

C++11将这种方法用于构造函数,可以将基类中的构造函数继承来使用,但是同样的,当签名相同时,会优先使用派生类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A
{
public:
A(int);
A(int, double);
A(double);
}

class B: public A
{
public:
using A::A;
B(int);
B(double);
}

// ...
B b(10); // calls B(int)
B b(9.9); // calss B(double)
B b(10,9.9); // calls A(int,double)

当然,基类的构造方法只能完成基类成员的初始化,当派生类有新的成员变量时,应该注意使用派生类的构造函数来初始化。可以将委托构造函数和继承构造函数配合使用,来减少派生类构造的工作量。

Lambda表达式

匿名函数,实际的意义可以参考Python中的Lambda表达式,通常用于临时(只使用一次)函数对象的场景,如排序、判断并删除等情况。其语法如下:

1
[](int x)->double{ /* ....*/ return x};

其中,圆括号内的值是匿名函数的参数,箭头后的double指代匿名函数的返回值类型,当Lambda表达式只由一个返回值语句组成时,这个返回值类型可以不写,由编译器自信推断。

最开头的方括号中可以写希望Lambda表达式访问的动态变量,可以访问值变量,也可以访问引用变量。

1
2
3
4
5
[z] // 按值访问z变量
[=] // 按值访问所有变量
[&a] // 按引用访问a变量
[&] // 按引用访问所有变量
[=,&a] // 按值访问所有变量,按引用访问a变量

包装器

可变参数模板

上面这个也是C++11新增的内容,但是内容过于曲径通幽,使用到的场景比较少,我就不写了。其实就是懒得写了,以后哪天想起来再写吧。


C++语言高级特性
http://zhf-xdu.top/2023/11/16/C-语言高级特性/
作者
周洪锋
发布于
2023年11月16日
许可协议