C++知识补充
类型转换
static_cast
对应于 c 中的隐式类型转换。只能在基础类型(但是基础类型和指针类型不能转换), 和有派生关系的类对象之间转换,也不能去除原有的类型修饰符。
1
2
3
//相当于创建一个 static_cast<double>类型的匿名对象赋值给 d2
double d2 = static_cast<double>(i);
int* p2 = static_cast<int*>(i);//无法转换,会报错
对于有父子关系的类对象之间之所以可以转换是因为 static_cast 把它们当做同一类型看待了。
reinterpret_cast
对应于 C 中的强制类型转换,是对指针/引用的转换,其中必须至少有一个是指针或引用,否则它会报错。 可以将不对类型的指针进行转换,也可以将指针转换为 long 类型,反之亦可。
const_cast
作用是去掉指针/引用中的 const 限制。这里要注意的是被转换的一定是指针/引用的 const, 而常数的 const 是不能去掉的。但是只能将同一类型的 const 指针/引用 转成非 const 指针/引用。
dynamic_cast
只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用。
C++的重载,重写和隐藏
重载 (overload)
同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。需要在同一个类中
重写(覆盖)(override)
重写的基类中被重写的函数必须有 virtual 修饰。 派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致. 只有函数体不同(花括号内), 派生类调用时会调用派生类的重写函数,不会调用被重写函数
个人感觉重写就是多态的一个基础,详见本文的多态部分,如果不加 virtual, 就没有多态
隐藏 (overwrite)
隐藏是指派生类的函数屏蔽了与其同名的基类函数。注意只要同名函数,不管参数列表是否相同,基类函数都会被隐藏. 无论是从外部,还是类内部,都无法调用基类的同名函数了,同样也包括基类的这个函数的重载不可使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <bits/stdc++.h>
using namespace std;
class Base {
public:
void f(int a) { cout << "base:" << a << endl; }
};
class Child : public Base {
public:
void f() { cout << "extend" << endl; }
};
int main() {
Base b;
b.f(1);
Child c;
c.f();
c.f(1); // 编译错误
return 0;
}
面向对象
析构函数
析构函数的声明方式
- 析构函数不需要返回值和参数,也不能重载。
- 析构顺序:1)派生类本身的析构函数;2)对象成员析构函数;3)基类析构函数。
模板
类模板和模板类
类模板是声明时候含有模板类型参数的类。 模板类是被编译器填入参数产生后生成的类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <bits/stdc++.h>
using namespace std;
template <class T>
T add(T p, T q) {
return p + q;
}
class Point {
public:
int x, y;
Point(int x, int y) : x(x), y(y) {}
Point operator+(Point b) { return Point(x + b.x, y + b.y); }
};
int main() {
Point a(1, 2);
Point b(2, 1);
Point c(1, 0);
c = add(a, b);
cout << c.x << " " << c.y << endl;
cout << add(1, 3) << endl;
return 0;
}
可变模板参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <bits/stdc++.h>
using namespace std;
void print() { cout << "empty" << endl; }
template <class T, class... Args>
void print(T header, Args... rest) {
cout << "header " << header << endl;
print(rest...);
}
int main() {
// print();
// print(1);
print(1, 2, 3);
return 0;
}
解释一下上文的代码。 首先 print(void) 是为了让函数支持 0 参数而设置的,是必须的, 如果不写这个的话,函数至少一个参数,但是这就会让下面的函数递归出错.
然后模板类写两个模板类型,一个是普通的模板 (class T), 一个是 (class… Args), 这个是参数包,会在递归中层层解析。 然后函数的参数有两个,声明方式和上面的同样。但是第二个 rest 参数包是可空的,在每次递归中,第一个参数会被赋值给 header, 其余的参数(可空)赋值给 rest.
多态
编译期多态
编译期多态主要是指函数重载和模板。 函数重载就是在编译的时候,编译器会根据参数类型和数量自动从同名函数中选择匹配的函数。 模板就是指在编译时,编译器也会根据你指定的类型而替换掉模板中的类型参数,达到多态的目的。
运行时多态
c++的运行时多态主要建立在虚函数上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
using namespace std;
class Animal {
public:
// 如果下面这行没有 virtual, 则最终只会输出两遍'animal'
virtual void bark() { cout << "animal" << endl; };
};
class Cat : public Animal {
public:
void bark() { cout << "miao~" << endl; }
};
class Dog : public Animal {
public:
void bark() { cout << "wang!" << endl; }
};
// 父类指针
void getSound(Animal& animal) { animal.bark(); }
int main() {
Dog dog;
Cat cat;
getSound(dog); // wang!
getSound(cat); // miao~
return 0;
}
虚函数
析构函数与虚函数
如果一个类要作为父类,那么他的析构函数要写成虚函数,否则根据”隐藏”, 子类无法调用父类的析构函数,所以就无法完全释放内存。 而 c++默认的析构函数不是虚函数,因为对于不会作为父类的类,其实用虚函数会有内存上的损耗。
静态函数和虚函数
静态函数的调用已经在编译期决定,虚函数是运行时绑定。而且虚函数需要调用虚函数表,开销大。
虚函数表
C++中,通过基类的引用或指针调用虚函数时,发生动态绑定
有了虚函数以后,对象所占用的存储空间比没有虚函数时多了 4 个字节。实际上,任何有虚函数的类及其派生类的对象都包含这多出来的 4 个字节,这 4 个字节位于对象存储空间的最前端,存放的是虚函数表的地址。虚函数表是编译器生成的,一个类有一个虚函数表,程序运行时被载入内存。一个类的虚函数表中列出了该类的全部虚函数地址。
如果不是虚函数的话就直接拷贝即可。详见 深入理解 c++内存对象。虚函数的调用关系: this -> vptr -> vtable -> virtual function
-
虚函数依靠 vptr 和 vtable 来处理。vptr 是一个指针,在类的构造函数中创建生成,并且只能用 this 指针来访问它,因为它是类的一个成员,并且 vptr 指向保存虚函数地址的 vtable. 所以静态函数不能用虚函数.
- 内联函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数.
- 构造函数用来创建一个新的对象,而虚函数的运行是建立在对象的基础上,在构造函数执行时,对象尚未形成,所以不能将构造函数定义为虚函数.
异常
异常不可忽略。如果程序出现异常,但是没有被捕获,程序就会终止 异常作为一个类,可以拥有自己的成员,可以传递充足的信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try
{
// 保护代码,中间含有 throw, 用以抛出异常
}catch( ExceptionName e1 )
{
// catch 块
}catch( ExceptionName e2 )
{
// catch 块
}catch( ExceptionName eN )
{
// catch 块
}catch(...){
// 捕获任意异常
}
- throw 语句的操作数可以是任意的表达式,表达式的结果的类型决定了抛出的异常的类型。
-
catch 关键字后的括号内的异常声明指定想要捕捉的异常类型。匹配过程是找最先匹配的,不是最佳匹配
- catch (…) 可用以接受任何异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 实例
#include <iostream>
using namespace std;
int division(int a, int b) {
if (b == 0) {
throw "Division by zero condition!";
}
return a / b;
}
int main() {
int x = 100;
int y = 0;
try {
cout << division(100, 2) << endl;
division(x, y);
cout << division(100, 2) << endl;
} catch (const char *s) {
std::cerr << s << '\n';
}
return 0;
}
函数的异常声明列表
为了增强程序的可读性和可维护性,使程序员在使用一个函数时就能看出这个函数可能会拋出哪些异常,C++ 允许在函数声明和定义时,加上它所能拋出的异常的列表,具体写法如下:
1
2
3
// func 可能拋出 int 型、double 型以及 A、B、C 三种类型的异常。
void func() throw (int, double, A, B, C);
void func() throw (int, double, A, B, C){...}
上面的写法异常声明列表可以在函数声明时写,也可以在函数定义时写。如果两处都写,则两处应一致。
1
2
3
4
// func 函数不会拋出任何异常。
void func() throw ();
// c++11 中使用 noexpection 表示不抛出任何异常
void func() noexcept;
一个函数如果不交待能拋出哪些类型的异常,就可以拋出任何类型的异常。 函数如果拋出了其异常声明列表中没有的异常,在编译时不会引发错误,编译出来的程序可能会出错,异常声明列表不起实际作用。
部分关键字
switch
- switch 的参数类型只能是基本类型中的整型
- case 标签后的只能是常量表达式
- case 标签后的表达式必须唯一
- default 是可选的
enum
1
2
3
4
5
6
7
8
enum <类型> {<枚举常量表>}
// 定义枚举类型 week, 默认是 0 1 2 3 ... 6
enum week {Sun, Mon, Tue, Wed, Thu, Fri, Sat};
// 若指定一个元素的值,则后续(如果没有指定的话)的值会根据其值+1
// 如下代码就得到 7 1 2 3 4 5 6
enum week {Sun=7, Mon=1, Tue, Wed, Thu, Fri, Sat};
枚举变量的常量表里只能用标识符,不能用常量或者字面量。
- 枚举变量可直接输出,不能直接输入。
- 枚举变量占用内存大小和整数相同。
- 枚举变量可以有比较操作(关系运算).
- 其成员值在所属枚举类型的声明作用域内是不可重复的, 也就是说如果在同一个作用域内有两个枚举类型,那么他们枚举常量表里面的标识符名称不能相同。
- 枚举类型可以直接复制,如上文可以有
week w = Tue
, 这个 Tue 可以直接用,原因即上一条。
goto
语句标号是按标识符规定书写的符号,放在某一语句行的前面,标号后加冒号 ( : ). 语句标号起标识语句的作用,与 goto 语句配合使用。
volatile
作用 1: volatile 的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
void main()
{
// 在 release 模式下,编译器会自动优化,对于没有 volatile 修饰的 i, 读取完 i 之后,会把 i 的值存在寄存器中,然后从寄存器赋值给 b
int i = 10;
// 如果换成下面这一句,那么 最后答案就是 10,32, 否则就是 10,10
// volatile int i = 10;
int a = i;
printf("i = %d", a);
// 下面汇编语句的作用就是改变内存中 i 的值为 32
// 但是又不让编译器知道
__asm {
mov dword ptr [ebp-4], 20h
}
int b = i;
printf("i = %d", b);
}
作用 2: 阻止编译器调整操作 volatile 变量的指令顺序 这个主要是在多线程中。
1
2
3
4
5
6
7
8
9
x = y = 0;
// 以下在线程 1 进行
x = 1;
r1 = y;
// 以下在线程 2 进行
y = 1;
r2 = x;
可能会交换成
1
2
3
4
5
6
7
8
9
x = y = 0;
// 以下在线程 1 进行
r1 = y;
x = 1;
// 以下在线程 2 进行
y = 1;
r2 = x;
但是这个仅仅靠 volatile 已经无法阻止了,因为是 cpu 的动态调度也会交换这两个指令的执行顺序。
static
- 被 static 修饰的局部变量在函数第一次执行时会初始化,之后就不再初始化。且生命周期延长到整个程序结束。
- static 修饰的全局变量只能在声明的文件里访问,无法被其他文件访问,即使是 extern 也不行。一般情况下声明的全局变量的作用域是整个工程。
- static 修饰的函数只能在声明的文件里访问,无法被其他文件访问,即使是 extern 也不行。
- static 修饰的全局变量和全局函数由默认的 external 变为 internal
- 静态成员函数也不能被声明为
const
和virtual
. 会直接编译错误。对于静态成员函数,它没有this
指针,所以无法访问 vptr. 所以 static 函数不能为 virtual. - 当声明一个非静态成员函数为
const
时,对this
指针会有影响。对于一个 Test 类中的 const 修饰的成员函数,this 指针相当于Test const *
, 而对于非 const 成员函数,this 指针相当于Test *
.static 成员函数没有 this 指针,所以 static 函数不能为 const. - volatile 也不能修饰静态成员函数。
1
2
3
4
5
6
7
8
9
10
11
12
// static 成员必须得在类外进行初始化,否则初次使用时会报链接错误 (undefined reference)
#include <bits/stdc++.h>
using namespace std;
class Student {
public:
static int count;
};
int Student::count = 0;
int main() {
cout << Student::count << endl;
return 0;
}
- static 修饰的函数只能用静态的变量。且不能用 this
文件读写
文件流
文件流有三种
- ifstream
- ofstream
- fstream
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <bits/stdc++.h>
using namespace std;
const char* inFile = "./FileForIOTest/in.txt";
const char* outFile = "./FileForIOTest/out.txt";
void TestFstream() {
/* 写文件流 */
// 也可写成 ifstream is(infile);
ifstream is;
is.open(inFile, ios::in);
if (!is) {
cout << "打开失败" << endl;
} else {
string buff;
getline(is, buff);
cout << buff << endl;
char a[100];
is.getline(a, sizeof a);
cout << a << endl;
// is.read()
char ch[100];
is.read(ch, sizeof ch);
cout << ch << endl;
// 这种方式会被空格和回车分开
char b[100];
is >> b;
cout << b << endl;
// 记得关闭文件流
is.close();
}
/*读文件流*/
ofstream os;
os.open(outFile, ios::out);
if (!os) {
cout << "打开失败" << endl;
} else {
char a[100] = "some words in line";
os.write(a, strlen(a));
os << "Write one line" << endl;
os.close();
}
}
void TestFopen() {
// 文件指针,使用 fopen fscanf fprintf
FILE* fpr = fopen(inFile, "r");
char s[100];
fscanf(fpr, "%s", s);
cout << s << endl;
fclose(fpr);
FILE* fpw = fopen(outFile, "a");
fprintf(fpw, "writen by fopen\n");
fclose(fpw);
};
void TestFreopen() {
// 注:此重定向不可逆
// 发生了一个迷惑行为,如果注释掉 TestFstream, 则一切正常,如果不注释
freopen(inFile, "r", stdin);
if (freopen(outFile, "a", stdout) == NULL) {
fprintf(stderr, "error redirecting stdout\n");
}
// printf 可以,但是 cout 不行,即使不用 endl, 改成、n 也不行,原因尚未查明
cout << "From Freopen\n";
// printf("From Freopen\n");
fclose(stdin);
fclose(stdout);
}
int main() {
TestFstream();
TestFopen();
TestFreopen();
return 0;
}
编译后文件的结构
程序的内存布局
注意,这是说的进程,要和可重定向文件和可执行文件的结构分开。
- bss 段 (bss segment) 通常是指用来存放程序中未初始化的全局变量的一块内存区域。
- 数据段 (data segment) 通常是指用来存放程序中已初始化的全局变量的一块内存区域。
- 代码区:存放函数体二进制代码,由操作系统进行管理的。这部分是共享的而且是只读的。
- 栈区:由编译器自动分配释放,函数的参数值,局部变量。栈帧
- 堆区:程序员分配和释放,若不释放,程序结束时由操作系统回收
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
High Addresses ---> .----------------------.
| | Kernel space, 1GB for Linux,
| Environment | 2GB for Windows by default.
|----------------------|
| | Functions and variable are declared
| STACK | on the stack.
base pointer -> | - - - - - - - - - - -|
| | |
| v |
: :
. . The stack grows down into unused space
. Empty . while the heap grows up.
. .
. . (other memory maps do occur here, such
. . as dynamic libraries, and different memory
: : allocate)
| ^ |
| | |
brk point -> | - - - - - - - - - - -| Dynamic memory is declared on the heap
| HEAP |
| |
|----------------------|
| BSS | Uninitialized data (BSS)
|----------------------|
| Data | Initialized data (DS)
|----------------------|
| Text | Binary code
Low Addresses ----> '----------------------'
动态存储区
其中栈区和堆区是动态存储区。
栈区
- 栈区是连续的内存区域,大小是固定的,生长方向从高到低,能从栈上获得的空间较小,操作系统底层对栈这种数据结构提供了支持,因此速度很快,且不会有碎片。Linux 默认是 8M 左右,可以调整。
- 栈是线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是 thread safe 的。每个 C++对象的数据成员也存在在栈中,每个函数都有自己的栈,栈被用来在函数之间传递参数。操作系统在切换线程的时候会自动的切换栈,就是切换 SS/ESP 寄存器。
- SP(栈指针)寄存器跟踪栈的顶部。
堆区
- 堆区是不连续的,系统采用空闲链表的方式来管理空闲内存的地址,堆的大小受限于操作系统的大小。频繁的 new/delete 会造成大量碎片,使程序效率降低。
- 堆是属于进程的。 TODO: brk, sbrk
静态存储区
内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。它主要存放静态数据、全局数据和常量。
BSS 段
历史原因,在汇编中曾有 block started by symbol
. 程序开始执行前,内核将此段中的数据初始化为0或者空指针。
Data 段
.data
已初始化的全局和静态变量。
代码区
.text
已经编译好的机器代码。这个段是只读的,并且是可共享的,也就是多个进程可以共享一个代码副本以节省内存。
sizeof
sizeof 是操作符而非函数,在编译期决定。 sizeof 对数组,得到整个数组所占空间大小。 sizeof 对指针,得到指针本身所占空间大小。
内存泄漏
解决方案
- 智能指针
- 内存池
- 内存泄漏检查工具
- 被继承的父类的析构函数写为虚函数
- RAII 机制(Resource Acquisition Is Initialization),也就是我们申请资源的时候,同时创建一个栈上的对象,让这个对象的生存周期和资源一致,然后当栈上对象过期的时候,就会调用构造函数自动释放资源,比如智能指针
unique_ptr 是一种智能指针,用以帮助避免资源泄漏。unique_ptr 不支持拷贝,仅能转移。也就是只能转移对某个裸指针的控制权,并且裸指针同时仅能有一个智能指针管理。 它在置空或者析构或者 reset 的时候,会自动释放管理的资源。开销很小,不用维护引用计数。 unique_ptr 可以管理数组(析构调用 delete[] ); 支持如下操作
1
2
3
4
5
6
7
8
9
10
11
12
13
up = nullptr;//置为空,释放 up 指向的对象
up.reset();//释放 up 指向的对象
up.release();//放弃控制权,返回裸指针,并将 up 置为空
// 转移控制权
std::unique_ptr<int> up1(new int(42));
std::unique_ptr<int> up2(up1.release());
// 使用 move 也行
// std::unique_ptr<int> up2(std::move(up1));
// 或者分两步执行也可以
// std::unique_ptr<int> up2;
// up2.reset(up1.release());
当 unique_ptr 想作为参数传入函数的时候,有两个选择:传引用,或者是传入裸指针(使用 get 方法)
检测工具
Valgrind
, 也就是在无需重新编译源代码的情况下(但最好是加入-g 和 -O0 参数)对程序进行内存泄漏的检测。
能够检测出内存泄漏,内存覆盖,访问非法内存,访问未初始化空间等情况。
指针
指针和引用的区别
- 指针有自己的一块空间,而引用只是一个别名;
- 使用 sizeof 看一个指针的大小,在 32 位系统中一个指针是 4 个字节,在 64 位系统中一个指针是 8 个字节。但是实际上也是未必的,CPU 可能有多种模式,严谨来讲应该是由实际上所使用的地址总线宽度决定。而引用则是被引用对象的大小;
- 指针可以被初始化为 NULL, 而引用必须被初始化且必须是一个已有对象的引用;
- 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用的修改都会改变引用所指向的对象;(这个基本无用)
- 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变;
- 指针可以有多级指针 (**p), 而引用只有一级;
- 如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露。(?)
数组名取地址
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int a[2] = {1,2};
int main(){
printf("a = %p\n", a); // I
printf("&a = %p\n", &a); // II
printf("a + 1 = %p\n", a + 1);// III
printf("&a + 1 = %p\n", &a + 1);// IV
return 0;
}
得到地址表如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 省略了一些与本主题无关的变量
0804a01c A _edata
0804a024 A _end
080484ec T _fini
08048508 R _fp_hw
080482bc T _init
08048330 T _start
0804a014 D a // a 变量保存在虚拟地址 0x0804a014 中
0804a01c b completed.7021
0804a00c W data_start
0804a020 b dtor_idx.7023
080483c0 t frame_dummy
080483e4 T main // main 函数的地址
U printf@@GLIBC_2.0
得到的汇编代码如下(已精简,由于汇编不能高亮,故采用了 cpp 的高亮)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
.file "name_of_array.c"
.globl a
.data
.align 4
.type a, @object
.size a, 8 // 从这里我们便知道 sizeof(a) 等于 8
a:
.long 1 // 从这里可以看出,编译器直接把 .c 文件中的 int 转化为 long 型
.long 2
.section .rodata
.LC0:
.string "a = %p\n"
.LC1:
.string "&a = %p\n"
.LC2:
.string "a + 1 = %p\n"
.LC3:
.string "&a + 1 = %p\n"
.text
.globl main
.type main, @function
main:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
subl $16, %esp
movl $.LC0, %eax // I 所对应的汇编代码,movl 是前者赋值给后者
movl $a, 4(%esp)
movl %eax, (%esp)
call printf
movl $.LC1, %eax // II 所对应的汇编代码
movl $a, 4(%esp)
movl %eax, (%esp)
call printf
movl $.LC2, %eax // III 所对应的汇编代码
movl $a+4, 4(%esp)
movl %eax, (%esp)
call printf
movl $a+8, %edx // IV 所对应的汇编代码
movl $.LC3, %eax
movl %edx, 4(%esp)
movl %eax, (%esp)
call printf
movl $0, %eax
leave
ret
.size main, .-main
.ident "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
.section .note.GNU-stack,"",@progbits
不难发现,对于 I II, 其编译后的结果都是 $a
, 也就是单纯的就值而言, a
与 &a
没有区别,就是 a 的首元素的地址。
通过对 III, IV 的观察,我们发现对于加法,结果并不相同。 a+1
是根据 a 的元素的类型来修改偏移的字节数 (a 是 int 型,根据 align 来定的). 但是 &a+1
却是根据 sizeof 来定的。
同样这个问题也能用来解释为什么 sizeof(a) 是 8 了,因为汇编代码中指出了 a 的大小就是 8.
参考资料-数组名和数组名取地址的区别
Fork
成功调用 fork() 会创建一个新的进程,它几乎与调用 fork() 的进程一模一样,这两个进程都会继续运行。
- 在子进程中,成功的 fork() 调用会返回 0.
- 在父进程中 fork() 返回子进程的 pid. 如果出现错误,fork() 返回一个负值。
STL
STL 删除元素问题
删除元素有两种方法
1
2
3
4
5
6
7
8
9
10
11
12
for (auto it = l.begin(); it != l.end();) {
cout << "cur: " << *it << endl;
if (*it == 3) {
// it = l.erase(it);
auto nxt = next(it);
l.erase(it);
it = nxt;
} else {
++it;
}
}
第一就是直接利用 erase
的返回值,第二就是先找到下一个元素的地址,然后进行删除,然后再赋值。
经过试验,vector 仅能用第一种方法,因为第二种方法会导致下个元素替代当前的位置,其而在下次迭代中被跳过。(好比上例中的 l 如果是 vector, 然后 cur 显示的迭代的路径中 3 后面是 5)
list, map, set, 两种都可以用
STL 内存分配
实现
C++ 默认的内存配置器有两级。第一级用于分配大于 128B 的内存,第二级用于分配小于 128B 的内存。 第一级主要是用 malloc() 和 free() 的方法,不再赘述。 第二级用以分配小内存。首先其内置了 16 个链表,每个链表分别管理其对应的 8 的倍数字节的内存块 (8, 16, …, 128). 使用时先将要求的内存提升至其最接近的 8 的倍数字节,然后尝试从链表中取出。如果链表为空,那么就尝试从内存池中取。 取的时候一般取 20 块,多余的放到链表中以备后用。如果内存池中不够取的,但是能分配一块,就分配。如果内存池中一块也分配不出来,那么就向操作系统申请,如果申请失败,那么就尝试将链表回收到内存池中分配,如果也不够,就调用一级分配器的 OOM 机制。
意义
这种内存分配方式减少了操作系统的碎片,并且让小内存能够快速分配。
C++11 新特性
Lambda 表达式
Lambda 又称匿名函数,可以理解为轻量级的函数。可以定义在函数中,并且可以读取函数的局部变量。 Lambda 主要由如下部分组成:捕获列表,参数,函数体等。
捕获列表
捕获函数的局部变量。可以分为值传递和引用传递。值传递要注意,它捕获的值是在声明时捕获而不是使用时捕获.
1
2
3
4
5
6
7
8
int a = 1, b = 2;
auto l = [a, b]() { return a + b; };
auto r = [&a, &b]() { return a + b; };
cout << l() << endl; // 3
b = 3;
cout << l() << endl; // 仍然是 3
cout << r() << endl; // 4, 因为是传引
当然也有简写方法,如 []
表示空, [=]
表示用值的方式传递 lambda 所有可见的(在 lambda 声明之前出现的变量), [&]
表示用引用的方式传递所有可见的变量。当然也可以组合成 =,&a,&b
. 也就是除 a 和 B 按引用进行传递外,其他的参数都按照值传递。
返回值
Lambda 表达式可以写 return, 如果不写,那么最后一个表达式的值就是返回值。
使用方法
Lambda 表达式可以有效简化代码,并且将一些变量封锁在更小的作用域中。 比如如果想用 STL 的 find_if 函数实现传入一个 x, 返回第一个大于 x 的值。 常规写法如下:
1
2
3
4
5
6
7
8
9
10
11
int x;
bool biggerThanX(int& p) { return p > x; }
int main(){
vector<int> v;
v.push_back(3);
v.push_back(7);
x = 5;
auto ans = find_if(v.begin(), v.end(), biggerThanX);
cout << *ans << endl;
return 0;
}
Lambda 表达式写法如下:
1
2
3
4
5
6
7
8
9
10
int main() {
vector<int> v;
v.push_back(3);
v.push_back(7);
int x;
x = 5;
auto ans = find_if(v.begin(), v.end(), [x](int& p) { return p > x; });
cout << *ans << endl;
return 0;
}
可见 Lambda 表达式减少了一个全局变量和一个全局函数的使用。
nullptr
在之前的 C/C++中,NULL 常常被定义为 0, 或者是 (void*)0
, 但是 C++不允许将 void *直接隐式转换,而如果非要定义 char * s= NULL
, 则会把 NULL 定义成 0. 进而导致重载混乱(无法区分 char*
和 int
)
智能指针
智能指针包括四个:
unique_ptr
unique_ptr 在内存泄漏那里讲过了。
auto_ptr
auto_ptr 是早期推出的智能指针,在 c++11 中已经废弃。其和 unique_ptr 功能类似
- auto_ptr 通过拷贝构造或者 operator=赋值后,对象所有权转移到新的 auto_ptr 中去了,原来的 auto_ptr 对象就不再有效,这点不符合人的直觉。unique_ptr 则直接禁止了拷贝构造与赋值操作。
- auto_ptr 不可做为容器元素,会导致编译错误。虽然 unique_ptr 同样不能直接做为容器元素,但可以通过 move 语意实现。
1
2
3
4
5
unique_ptr<int> sp(new int(88) );
vector<unique_ptr<int> > vec;
vec.push_back(std::move(sp));
// vec.push_back( sp ); error:
// cout << *sp << endl;
std::move 让调用者明确知道拷贝构造、赋值后会导致之前的 unique_ptr 失效。
- unique_ptr 可以用在函数返回值中。作为返回值的智能指针实际上是使用了 Move 来将所有权转移到了外面的指针上。
share_ptr
多个 share_ptr 可以指向同一个裸指针,当所有的 share_ptr 都解除了对此指针的引用的时候,资源就会自动释放。 但是一定要注意,对于一个对象的第一次托管直接声明即可,但是后续的托管一定要通过已有的指针
1
2
3
4
shared_ptr<A> sp1(new A(2)); //A(2) 由 sp1 托管,
shared_ptr<A> sp2(sp1); //A(2) 同时交由 sp2 托管
shared_ptr<A> sp3;
sp3 = sp2; //A(2) 同时交由 sp3 托管
如果每个 share_ptr 都直接对裸指针进行接管,那么释放的时候就会出现多次释放的情况。 share_ptr 在某些情况下还会引起循环引用问题。 比如我们将双向链表中的指针改为 share_ptr, 那么我们构建一个循环列表,则智能指针生命周期到了的时候,并不能将节点的引用计数清零,所以就导致了内存泄漏。
weak_ptr
weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个 shared_ptr 管理的对象。它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造,它的构造和析构不会引起引用记数的增加或减少。 weak_ptr 在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存
右值引用
C++中,有左值右值之分。左值可以理解为有名的变量,是可以取地址的,右值则往往是表达式的值,是临时的数据,无法取地址。 比如下面的代码
1
2
3
4
5
6
class A {};
// 一定要注意这里是返回一个对象,而不是对象的地址
// new A(); 才是返回地址
A f() {
return A();
}
那么在主函数中是没法获取这个对象的。即使是 A a = f();
, 也是调用的构造函数。(当然要实验这个问题还需要关闭编译器的返回值优化 -fno-elide-constructors
)
所以我们引入了右值引用。我们可以通过 A && a = f()
的方式来延长临时对象的生命周期,直到引用的生命周期结束。
右值引用就是以上的内容,但是还有个特例叫常量左值引用。虽然名字叫左值引用,但是其可以被赋值成常量左值,非常量左值,右值。但是其对引用的对象只能读,不能改。
const A & a = f();
移动构造
移动构造是利用右值引用来减少开销。比如临时对象加入一个 vector.
1
2
3
4
5
vector<MyString> vecStr;
vecStr.reserve(1000); //先分配好 1000 个空间
for(int i=0;i<1000;i++){
vecStr.push_back(MyString("hello"));
}
这样会调用很多次构造函数和复制构造函数。而且临时对象刚开辟,转眼又没用了。 所以就想能不能直接充分利用这个临时对象。 在 MyString 类中写入移动构造函数和移动赋值函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class MyString
{
public:
static size_t CCtor; //统计调用拷贝构造函数的次数
static size_t MCtor; //统计调用移动构造函数的次数
static size_t CAsgn; //统计调用拷贝赋值函数的次数
static size_t MAsgn; //统计调用移动赋值函数的次数
char * m_data;
public:
// 构造函数
MyString(const char* cstr=0){
if (cstr) {
m_data = new char[strlen(cstr)+1];
strcpy(m_data, cstr);
}
else {
m_data = new char[1];
*m_data = '\0';
}
}
// 拷贝构造函数
MyString(const MyString& str) {
CCtor ++;
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
}
// 移动构造函数
MyString(MyString&& str) noexcept
:m_data(str.m_data) {
MCtor ++;
str.m_data = nullptr; //不再指向之前的资源了
}
// 拷贝赋值函数 =号重载
// 这个不能对str进行修改
MyString& operator=(const MyString& str){
CAsgn ++;
if (this == &str) // 避免自我赋值!!
return *this;
delete[] m_data;
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
return *this;
}
// 移动赋值函数 =号重载 当且仅当等号右侧是右值的时候
// 比如 s = MyString("new string") 时候就会调用这个函数
MyString& operator=(MyString&& str) noexcept{
MAsgn ++;
if (this == &str) // 避免自我赋值!!
return *this;
delete[] m_data;
m_data = str.m_data;
str.m_data = nullptr; //不再指向之前的资源了
return *this;
}
~MyString() {
delete[] m_data;
}
char* get_c_str() const { return m_data; }
private:
char* m_data;
};
size_t MyString::CCtor = 0;
size_t MyString::MCtor = 0;
size_t MyString::CAsgn = 0;
size_t MyString::MAsgn = 0;
主函数不用变,可以发现移动构造函数被调用了 100 次,拷贝构造一次都没有调用。
上文新增的移动构造函数和移动赋值函数的特点是,接受一个右值引用,不仅仅将参数的内容赋值过来,更让参数无法释放掉挪取的空间。 对于左值,如果在这种赋值之后不再使用的话,也可以使用 move 方法转成右值,然后进行同样的处理,不过在这之后就不能使用了。
1
2
3
4
5
vecStr2.reserve(1000); //先分配好 1000 个空间
for(int i=0;i<1000;i++){
MyString tmp("hello");
vecStr2.push_back(std::move(tmp)); //调用的是移动构造函数
}
完美转发
知乎参考资料
完美转发稍微复杂一些。首先要介绍 universal references
.
当 auto&& b
这种形式,或者是参数模板 T &&b
这种形式的时候,这种引用既可以被左值初始化,也可以被右值初始化。
如果被左值初始化,参数就是左值引用,T 是左值引用类型
如果被右值初始化,参数就是右值引用,T 是右值的类型
这里具体解释的话涉及到 引用折叠的问题。就是多级引用(当然不可以直接这么写,在模板类中可能出现这种情况)时,只有全部都是右值引用,最终的结果才会是右值引用,否则就是左值引用。
param 的类型就是 T&&
, 如果 T 类型是 int &
, 那么 param 就是 int & &&
, 会被简化为左值引用。
如果 T 类型是 int
, 那么 param 就是 int &&
, 也就是右值引用。
这样一看似乎很合理,而且很智能。但是会有一个问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <bits/stdc++.h>
using namespace std;
// 接收左值的函数 f()
template <typename T>
void f(T &) {
cout << "f(T &)" << endl;
}
// 接收右值的函数 f()
template <typename T>
void f(T &&) {
cout << "f(T &&)" << endl;
}
// 万能引用,转发接收到的参数 param
template <typename T>
void PrintType(T &¶m) {
f(param); // 将参数 param 转发给函数 void f()
}
int main(int argc, char *argv[]) {
int a = 0;
PrintType(a); //传入左值
PrintType(int(0)); //传入右值
}
理论上来讲,这里应该第一个输出 f(T &)
, 第二个输出 f(T &&)
. 但是事实上最后输出的都是前者。
问题的核心在于,虽然 param 本身是保持着完美的类型属性,但是其在使用过程中会自动转成左值。(具体原因不深究), 为了解决这个问题,c++11 发明了 完美转发。
其实也很简单,把 PrintType() 的函数体写为 f(std::forward<T>(param));
就可以了。编译器会自动推断出 param 原有的类型,然后传递过去。
完美转发实现的原理大概如下(并非完全真实,只是相近的意思):
1
2
3
4
5
template<typename T>
T&& forward(T ¶m)
{
return static_cast<T&&>(param);
}
无论 param 传入的什么类型,因为 T&
是个左值引用,所以 param 也一定是左值引用。然后 T&&就是 param 原本的引用,这里就能将 param 转换成原本的类型。