C++

「C++」 C++基础知识

Posted by Dawn-K's Blog on March 4, 2021

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. 析构顺序: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
  • 静态成员函数也不能被声明为constvirtual. 会直接编译错误。对于静态成员函数,它没有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 对指针,得到指针本身所占空间大小。

内存泄漏

解决方案

  1. 智能指针
  2. 内存池
  3. 内存泄漏检查工具
  4. 被继承的父类的析构函数写为虚函数
  5. 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 参数)对程序进行内存泄漏的检测。 能够检测出内存泄漏,内存覆盖,访问非法内存,访问未初始化空间等情况。

指针

指针和引用的区别

  1. 指针有自己的一块空间,而引用只是一个别名;
  2. 使用 sizeof 看一个指针的大小,在 32 位系统中一个指针是 4 个字节,在 64 位系统中一个指针是 8 个字节。但是实际上也是未必的,CPU 可能有多种模式,严谨来讲应该是由实际上所使用的地址总线宽度决定。而引用则是被引用对象的大小;
  3. 指针可以被初始化为 NULL, 而引用必须被初始化且必须是一个已有对象的引用;
  4. 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用的修改都会改变引用所指向的对象;(这个基本无用)
  5. 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变;
  6. 指针可以有多级指针 (**p), 而引用只有一级;
  7. 如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露。(?)

数组名取地址

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 功能类似

  1. auto_ptr 通过拷贝构造或者 operator=赋值后,对象所有权转移到新的 auto_ptr 中去了,原来的 auto_ptr 对象就不再有效,这点不符合人的直觉。unique_ptr 则直接禁止了拷贝构造与赋值操作。
  2. 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 失效。

  1. 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 &&param) {
    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 &param)
{
	return static_cast<T&&>(param);
}

无论 param 传入的什么类型,因为 T& 是个左值引用,所以 param 也一定是左值引用。然后 T&&就是 param 原本的引用,这里就能将 param 转换成原本的类型。