目录

cpp学习笔记

cpp学习笔记

学习资源

视频地址:C++教程-油管大佬 The Cherno C++教程

1. cpp语法

1.1 基本结构

下面有一个简单的cpp程序,我们来看看:

1
2
3
4
5
6
7
8
#include <iostream>
#include <string>
#include "b.h"

int main() {
    std::string a = "Hello World!";
    std::cout << a << std::endl;
}

由于cpp兼容着c,因此基本语法并没有很明显的区别,这里需要注意的是cpp的头文件和c语言的标准库文件确实是有区别的,c语言的头文件一般是#include <stdio.h>,而cpp的标准库文件一般没有.h作为结尾,这是为了和c语言有所区分。

这时候又会有人问了,那为什么cpp中又有<>又有""这两种include写法呢?其实就是为了让编译器知道,哪些头文件需要从用户的仓库目录里面找,哪些从gcc的库函数里面去找。

1.2 编译、链接、头文件

刚刚给的例子太简单了,这次我们给一个稍微复杂一点的,涉及到调用另一个文件中的方法的代码:

main.cc:

1
2
3
4
5
#include <iostream>

int main() {
    funcB();        // 调用b.cc中的方法funcB();
}

如果设计到一个项目中有两个文件,而且main函数需要调用另一个文件中的funcA方法,编译器到底怎么样才能将这个main函数成功运行呢?

那么我们就得了解编译器到底干了什么事情。简单来说主要就是两件事:编译链接

编译其实就是编译器(如 g++)把 源代码.cpp)转化为 目标文件.o/.obj),这个过程中包含几个子阶段:

  1. 预处理 (Preprocessing)
    • 处理 #include#define#if 等预处理指令。
    • 例如:#include "b.h" 会直接把 b.h 的内容拷贝进来。
    • 预处理后的文件其实是一个纯 C++ 源文件。
  2. 词法/语法分析
    • 检查语法是否正确,比如 int x = "abc"; 就会报错。
    • 把代码拆成语法树(AST)。
  3. 语义分析 + 生成中间表示
    • 确认类型是否匹配,作用域是否正确。
    • 例如:声明了函数 void f(); 但是在同一个 .cpp 文件里没有定义,不会报错,只会标记“这个符号需要外部解析”。
  4. 生成目标文件 (.o)
    • 每个 .cpp 编译后,都会生成对应的 .o
    • .o 文件里包含了 机器指令 + 符号表(符号表记录了函数和变量的名字、是否已经实现、还是等待外部提供)。

看了上面的介绍,你认为上面的代码使用g++ -c main.cpp -o a.o能成功编译吗?

答案当然是 No,根据上面编译过程的分析可以知道,想要使用一个当前文件没有实现的方法,我们需要声明这个函数,我们把代码更新为:

1
2
3
4
5
6
7
#include <iostream>

void funcB();

int main() {
    funcB();
}

现在我们重新编译:g++ -c main.cpp -o a.o

这次果然成功了,生成了a.o这个目标文件,but oh no, b.cc我甚至还没写啊,what crazy! 这样居然编译都没报错,因此你应该理解了下面这句话了:

👉 这个阶段 不会报“未定义引用(undefined reference)”错误,因为编译器只管当前 .cpp 文件,不管别的文件到底有没有funcB()的具体实现。

至于funcB()到底实现在哪了,那就是接下来链接阶段需要关心的。

链接阶段其实就是链接器(ld)把所有 .o 文件和库文件 .a / .so 组合在一起,生成一个可执行文件

  1. 符号解析
    • 每个 .o 文件都有符号表(symbol table)。
    • 如果某个 .o 里声明了 extern void foo();,但是没有实现,链接器会在别的 .o 或库里找 foo 的定义。
  2. 符号重定位
    • 不同目标文件中的函数、变量可能会分布在不同内存区域,链接器会把调用地址改成实际的地址。
    • 比如:在 main.o 里有 call foo,但是不知道 foo 在哪,链接器会把它改成 call 0x400123 这种真实地址。
  3. 生成可执行文件
    • 所有引用都能匹配 → 链接成功,得到 ELF(Linux)或 PE(Windows)格式的可执行文件。
    • 如果找不到 → 报错 undefined reference to 'foo'

注:what isextern?

👉 它告诉编译器: “这个变量/函数的定义在别的地方,我这里只是声明一下。”

换句话说,extern 只做 声明 (declaration),不做 定义 (definition)。 最终在链接阶段,链接器会去别的翻译单元(别的 .o 文件、库)里找到真正的实现。

(1)全局变量跨文件使用

1
2
3
4
5
6
7
8
// a.cpp
int g_value = 42;   // 定义(真正分配内存)

// b.cpp
extern int g_value; // 声明(不分配内存)
void print() {
 std::cout << g_value << std::endl;
}

编译时 b.cpp 看到 extern int g_value;,知道有个 int 变量存在,但不生成内存。 链接时,它去 a.o 找到 g_value 的定义,拼在一起 ✅。


(2)函数默认就是 extern

1
2
3
4
5
// a.cpp
void foo() {}  // 定义

// b.cpp
void foo();    // 声明(其实相当于 extern void foo();)

函数声明本质上都带 extern,所以你不写也行

这时候如果我们没有实现b.cpp直接去把g++ main.cc -o main

我们会发现报错:

1
2
3
4
5
6
(TraeAI-4) ~/Downloads/cpp [126] $ g++ main.cc -o main
Undefined symbols for architecture arm64:
  "funcB()", referenced from:
      _main in main-331bbe.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

这说明虽然上面的编译阶段成功了,但是链接阶段却有问题,提示找不到funcB()的具体实现

这时候我们就需要实际去实现我们的b.cc文件了:

1
2
3
4
5
#include <iostream>

void funcB() {
    std::cout << "B" << std::endl;
}

现在我们再次运行g++ main.cc b.cc -o main,构建成功了!

聪明的同学肯定还有疑问啊,那c以及cpp文件中随处可见的.h文件,在项目构建时会发生什么呢?

包括还有人有疑问,每次写我们都得将函数声明void funcB();写在文件的上面吗,那不是非常的丑陋,如果调用的函数非常多的话?

其实头文件.h就是为了解决这个问题的,也就是我们上面提到的编译过程中的预处理阶段。在预处理阶段,编译器看到了#include等预处理指令,就会 copy 这个文件中的内容到这个位置,对就是这么朴实无华的复制粘贴,因此一个大胆的想法就是,我们为什么不吧函数声明专门放在一个.h文件中呢,那么我们只需要在文件前面使用#include "b.h",就可以实现函数声明的作用,是不是很天才!

另外要注意的是,.h文件并没有被实际编译,因此我们依旧运行g++ main.cc b.cc -o main便可以构建成功了。以及b.cc的头文件不一定是b.h,也可以命名为c.h,因为本质上b.h的内容只是被 copy 进了main.cc中了,他的使命在编译阶段便结束了,至于怎么找到funcB()的实际实现,就是靠链接阶段的符号表了。

以及头文件中可能会造成重复声明的问题,这里只需要在头文件的开头加上一行#pragma once就可以解决问题了。至于古老的项目中,你可能还会见到下面这种写法,只是不太推荐:

1
2
3
4
5
6
#ifndef C_H
#define C_H

void funcB();

#endif

1.3 pointer

首先我们来下一个定义:指针只是存储内存地址的整数

1
2
3
4
int main() {
  int var = 8;
  int* ptr = &var;
}

因此本质上来看,为什么变量要指定类型呢?答案是需要知道实际用几个字节来存储该变量的值。那么指针指定的变量难道也是为了告诉编译器需要用几个字节来存储吗?No,No,No,如果真这么简单我也不会在这里花一段话来讲了,本质上来看指针存的只是内存中某一个位置的地址值,难道地址大小还不一样吗计算机哈哈哈,所以比如如果是64位的机器,那么默认指针都需要用8B来存啊,这都不用用户去说编译器就能自动知道。但是拿到这个地址值之后,你知道要读多少位吗?是读8位的char,还是32位的int呢?这就需要事先告诉编译器了,因此指针的类型是告诉编译器如果*ptr,编译器需要去读多少字节。

1.4 reference

首先我们来下一个定义:引用只是指针的语法糖

可以简单理解为引用是一种别名,引用只能引用已经存在的变量,引用本身不是创建变量,因此它并不占用内存。他们并没有真正的存储空间。

要注意的是,c语言中常见的&a的是意思是变量a的地址,引用是跟在类型后面的,实际是类型的一部分,我们会看到int& ref = a,这里就是引用类型了。

而至于什么时候是引用,什么时候是地址,那就看上下文了,跟在类型后面就是引用,在变量前面就是地址。

这里的ref变量实际不存在,他只存在于我们的源代码中。如果你现在编译这段代码,不会得到两个变量aref,你只会得到a,我们只是把所有对ref的操作都加到a上。

因此之前我们如果想传入一个值,并且在函数内部修改这个值,需要用到指针,并且看着有点乱.现在我们就可以优雅的用引用传递了(Java中对对象默认自动支持引用传递):

1
2
3
4
5
6
7
int add(int* a) {
  return (*a) ++;
}

int add(int& a) {
  return a ++;
}

最后要说的一点是,引用只能绑定一次,不能中途更换引用的变量,要想更换只能使用功能更强大,但是也更复杂的指针了。

1.5 class

很多人看到c++中的class,经常会想到c语言中大名鼎鼎的struct,所以他们之间有什么区别呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

// 和下面的class写法等价
// struct Player {
//     int x, y;
//     int speed;
// };

class Player {
public:
    int x, y;
    int speed;
};

int main() {
    Player player;
    player.x = 10;
    player.y = 20;
    player.speed = 100;
    std::cout << player.x << std::endl;
    std::cout << player.y << std::endl;
    std::cout << player.speed << std::endl;
}

上面代码中的Player的结构体和类的作用是一摸一样的,我们就能看到一个最明显的区别:class默认内部都是private的,如果想要public需要声明;而struct则恰好相反。

所以说对于是用class还是struct,如果不是为了类的继承多态等高级特性,那这两个基本没有区别了。那就看个人的编程风格了,如果他只是一种只表示变量的简单组织结构,比如基本的数据结构,就可以考虑只使用简单的struct

下面未来介绍类的其他特性,我们先给出一个简单的log类,它将会随着我们新的语法特性的介绍而逐渐被优化,现在代码如下:

 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
class Log {
private:
    int log_level;
public:
    const int LogLevelError = 0;
    const int LogLevelWarning = 1;
    const int LogLevelInfo = 2;

    void SetLevel(int level) {
        log_level = level;
    }

    void Error(const std::string& message) {
        if (log_level >= LogLevelError)
            std::cout << "[ERROR]: " << message << std::endl;
    }

    void Warn(const std::string& message) {
        if (log_level >= LogLevelWarning)
            std::cout << "[WARNING]: " << message << std::endl;
    }

    void Info(const std::string& message) {
        if (log_level >= LogLevelInfo)
            std::cout << "[INFO]: " << message << std::endl;
    }

};

int main() {
    Log log;
    log.SetLevel(log.LogLevelInfo);
    log.Error("called object type 'int' is not a function or function pointer");
    log.Warn("default member initializer for non-static data member is a C++11 extension [-Wc++11-extensions]");
    log.Info("1 warnings and 1 errors generated.");   
}

1.6 static

和引用一样,static在不同的语境下的含义也是不一样的。

1.6.1 在类和结构体之外

对于在类和结构体之外使用static,它的作用就是将你声明的该符号不对外链接,这意味着它只在你定义他的这个文件中起作用。

这种情况的好处是对于cpp,如果你链接的两个文件中,有一个同名的变量,就会报错,在链接阶段,这时候只需要将一个变量声明为static就行。

因此一个编程建议是:除非你想要将一个文件跨文件链接,否则尽量将你的函数和变量声明为静态。

1.6.2 在类和结构体里

这种情况就分为静态变量和静态方法两种了。

类和结构体中的静态变量是所有实例共有的,每个实例有的是一个副本,访问静态变量需要通过类名::静态变量来访问。

静态方法的访问方式和上面一样,静态方法中不能访问类中的非静态变量。这个很好理解吧,因为静态方法同样不需要绑定实例,但是非静态变量是和实例有关的,显然是冲突的。

1.6.3 局部静态变量

这里的局部静态变量和我们在 1.6 static 中的有点不一样,这是指的是在函数内部定义的static变量。

我们可以看下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int& getCounter() {
    static int count = 0; // 局部静态变量
    count++;
    return count;
}

int main() {
    cout << getCounter() << endl;
    cout << getCounter() << endl;
    cout << getCounter() << endl;
}

最后输出的是123。

这说明局部静态变量只会被初始化一次,之后调用函数会直接使用已经存在的变量。并且和普通局部变量一样,只能在函数内部访问。

有时候这个是非常有用的,对于简化代码写法的角度,例如如果我们需要写一个单例模式,我们使用之前的类的静态成员的写法,代码就得写成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Singleton {
public:
    static Singleton& getInstance() {
        return *instance;
    }
private:
    static Singleton* instance; // 声明
};

Singleton* Singleton::instance = nullptr; // 类外初始化

但是如果我们使用局部静态变量,代码就可以直接简化为:

1
2
3
4
5
6
7
class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance; // 局部静态变量,自动销毁
        return instance;
    }
};

我们可以画一张两种方式的对比表:

特性 类的静态成员变量 局部静态变量
定义位置 类内声明,类外定义 函数内部
初始化时机 程序启动时 第一次执行到定义语句时
是否需要手动初始化 必须(类外) 不需要,自动初始化
作用域 类作用域(可全局访问) 函数内部
生命周期 整个程序运行期间 整个程序运行期间

1.7 enum

这里我们先讲普通的enum:

enum 的作用就是可以统一管理一些变量,将他们映射为整数(注意只能映射为整数),同时这个值都是如果未指定,默认是从0开始递增。

同时我们还可以通过:类型来指定值的实际存储类型,当然float不行。这样可以使用char来省空间有时候。就像下面写法这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
enum Status : unsigned char {
    Pending = 65,
    Success, Failed,   // Success=66,默认递增
};
int main() {
    Status e = Success;
    if (e == Success) {
        std::cout << e << std::endl;
    }
}

这样我们声明出来的Status e也不怕别人往里面写错误的值了,如果不用enum,直接通过int e,那么用户可以随意写不在enum范围内的值,比如1000,这样也不用写范围判断了。

有了这个工具,我们也就可以把之前写的简陋的log类来更新了:

 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
class Log {
public:
    enum Level {
        LevelError, LevelWarn, LevelInfo
    };

    void SetLevel(Level level) {
        log_level = level;
    }

    void Error(const std::string& message) {
        if (log_level >= LevelError)
            std::cout << "[ERROR]: " << message << std::endl;
    }

    void Warn(const std::string& message) {
        if (log_level >= LevelWarn)
            std::cout << "[WARNING]: " << message << std::endl;
    }

    void Info(const std::string& message) {
        if (log_level >= LevelInfo)
            std::cout << "[INFO]: " << message << std::endl;
    }

private:
    Level log_level = LevelInfo;
    
};

int main() {
    Log log;
    log.SetLevel(Log::LevelInfo);
    log.Error("called object type 'int' is not a function or function pointer");
    log.Warn("default member initializer for non-static data member is a C++11 extension [-Wc++11-extensions]");
    log.Info("1 warnings and 1 errors generated.");   
}

TODO:补充一下 enum class

1.8 constructor

构造函数就是在创建对象的时候默认执行的方法,如果没有指定构造函数,就会默认一个空的publib类型的构造函数。

1.9 destructor

构造函数的evil twin:析构函数

当销毁对象时,会自动运行析构函数。

析构函数和构造函数在声明和定义的唯一区别就是析构函数前面加上了~。一般用来在对象销毁时,主动释放一些堆上申请的空间。

1.10 inheritance

可以近似理解为:子类 $ \subseteq $ 父类(子类拥有父类中包含的所有内容(private除外))。

可以通过这样写来让子类继承某个父类class Child : public Parent 。这意味着此时的Child不但具有类型Child,而且具有类型Parent

绝大多数情况下我们都使用的public继承

当然继承方式其实有两种,区别如下:

继承方式 父类 public 成员变成 父类 protected 成员变成 父类 private 成员 能否被孙类继承?
public 继承 子类的 public 成员 子类的 protected 成员 不可见 可以
private 继承 子类的 private 成员 子类的 private 成员 不可见 不可以

1.11 virtual function

虚函数是为了实现多态,来让子类重写父类方法。(对应于Java语言中的@override注解)

如果我们直接这么写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Parent {
public:
    void say() {
        std::cout << "I am a parent" << std::endl;
    }
};

class Child : public Parent {
public:
    void say() {
        std::cout << "I am a child" << std::endl;
    }
};
int main() {
    Parent* person = new Child();
    person->say();
}

// 运行结果:I am a parent

这说明虽然person对象有ChildParent两个类型,但是编译器默认直接去声明的Parent类型中去找同名的方法。那么如何才能让编译器意识到,我们传入的其实是个Child,我们想调用的是Child中的方法呢? 这就得靠虚函数了,虚函数引入了动态分配的概念,编译器通常通过一个VTABLE来实现它。VTABLE是一个包含我们父类中的虚函数的所有子类重写函数的映射的一张表,这样我们就能在运行时将他们映射到正确的重写函数。

使用虚函数很简单,我们只需要在父类的方法前面加上virtual,方法就会变成一个虚函数。而c++11中引入了override关键词,我们还可以在子类的重写方法后门加上override关键词(当然不是必须的,但是推荐加上,因为可以让编译器去帮我们检查子类的方法名有没有写错,是不是和父类中的某个方法一样等等)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>

class Parent {
public:
    virtual void say() {
        std::cout << "I am a parent" << std::endl;
    }
};

class Child : public Parent {
public:
    void say() override {
        std::cout << "I am a child" << std::endl;
    }
};
int main() {
    Parent* person = new Child();
    person->say();
}

// 运行结果:I am a child

当然我们需要承认的是,虚函数肯定不是free的,它有两种运行时成本:VTABLE的存储开消,以及查看VTABLE来确定具体映射到哪个重写函数的时间开销。

但是,绝大部分情况下,我们还没有到无法容忍虚函数开销的地步。

1.12 pure virtual function

c++中的接口是通过纯虚函数实现的。

纯虚函数的写法就像这样:virtual void say() = 0;,方法体不需要实现,留给子类去实现。并且父类由于并未实现这个方法,就无法直接创建父类对象。

1.13 visibility

c++中的可见性有三种:publicprotectedprivate

protected是指可以在父类和子类中访问,但是对象中不可见。

其实可见性最主要的作用其实是一种 code style。用户可以通过比如private立马知道这个不是暴露给用户的,这样看很多代码时可以忽略掉很多细节,主要看暴露给用户的API的public即可。

1.14 string

std::string name = "thisingl"

cpp中直接"xxx"默认是一个const char*类型,本身是在栈上分配空间,但是string又是一个引用类型,空间分配在堆上,于是默认会将const char*的内容复制到堆上分配的空间。

1.15 const

你可以简单理解为:cpp中的const,只是一种编译器级别的保护,对于最后产生生成的机器码完全没有影响。(编译优化除外)

大家肯定都知道const int a代表着变量a不可被修改,那const int* aint const* a以及int* const a呢?

分析 含义
const int* a 可以看作const (*a) 可以改变a指针,但是不能改变a指向的内容
int const* a const (*a) const int* a
int* const a 可以看作const (a) 可以改变a指向的内容,但是不能改变a指针
const int* const a 可以看做const (*a) && const (a) 既不能改变a指针,也不能改变a指向的内容
int const* const a const (*a) && const (a) const int* const a

上面我们讨论的都是在变量中的使用,那你肯定不经想问,在类中有什么使用场景吗?

有的兄弟,有的,例如下面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Person {
private:
    int age = 10;
public:    
    int GetAge() const {
        // age = 1;  // 编译错误
        return age;
    }
};

void printPerson(const Person& p) {
    std::cout << p.GetAge() << std::endl;
}

如果const放在方法后面,就代表我们的方法不会影响到对象本身。而const Person& p,意味着我们承诺我们不会修改对象p,那么此时我们只能访问p中被const修饰的方法,因为没有被const修饰的方法我们无法保证不会修改对象p

但是有些时候,我们有一些用于调试的变量比如int var,我们的const修饰的方法可能也想修改他,但是我们能确保他不会影响到程序,这时候就很不方便了,我们又不想放弃将方法定义为const,这时候我们就可以使用下一次要讲到的mutable来完成他了!

1.16 mutable

mutable修饰的变量意味着可以修改,const方法中可以修改这种变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Person {
private:
    mutable int age = 10;
public:    
    int GetAge() const {
        age = 1;
        return age;
    }
};

void printPerson(const Person& p) {
    std::cout << p.GetAge() << std::endl;
}

还有一种使用场景是在lambda表达式中修改按值传递的变量(非常非常少见,因为可以直接将这些变量设置为引用传递),所以我们就直接略过了。

1.17 member initializer list

我们首先来看下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Entity {
public:
    Entity() {
        std::cout << "Entity default constructor" << std::endl;
    }
    
    Entity(int a) {
        std::cout << "Entity int constructor" << std::endl;
    }
};

class Person {
private:
    Entity entity;
public:    
    Person() {
        entity = Entity(100);
    }
};

int main() {
    Person p;
}

问题来了,你认为最后打印输出的是什么呢?是Entity int constructor吗?

答案是No,输出的是"Entity default constructor"Entity int constructor

是不是很疑惑,为什么我明明只调用了Entity(int a)这个构造函数,怎么还自动先调用了Entity()构造函数???

答案是:c++中的成员变量在进入构造函数体时,会先被复制进来执行,也就是可以理解为上面的构造函数其实长这样:

1
2
3
4
    Person() {
        Entity entity;        // 把private中内容移动到了这里(调用了构造器)
        entity = Entity(100); // 赋值操作
    }

所以其实Entity的两个构造器都被调用了,很明显这个是一种资源的浪费,那么有没有办法解决了?

聪明的c++创建者肯定考虑到了,所以提供了成员初始化列表这个语法。

当你在成员初始化列表中指定了如何构造成员变量,编译器就会使用你指定的方法,而不会默认执行一遍成员变量的默认构造函数了,这个的优先级最高。

使用方法如下:

1
2
3
4
5
6
7
8
9
class Person {
private:
    Entity entity;
public:    
    Person() 
        : entity(Entity(100)){    // 成员初始化列表
        
    }
};

此时运行就会发现,只会打印输出:Entity int constructor

当然这个写法还涉及到一种代码风格,如果构造函数中的代码逻辑很复杂的话,那么简单的初始化复制逻辑就可以直接放在成员初始化列表中,这样就分离开来了。

1.18 instantiate object

c++中由于有两个地址空间,所以实例化对象也有两种,对应的是一种在栈上申请空间,一种在堆上申请空间。

在栈上申请空间就是通过直接ClassName A(初始化参数)或者ClassName A = ClassName(初始化参数)。这时分配的空间在栈上,大家都知道进行方法调用时会伴随着压栈和出栈,当出栈时栈中的空间也就都释放了,所以很显然,栈上申请的空间只能存活在变量存在的生命周期中(这个变量前面的第一个{到后面的第一个}之间),一旦超过就会自动释放。所以适合空间小,而且不需要长期存在的变量,更推荐使用栈。

而堆上申请的空间上通过newdelete关键字进行管理,需要用户主动去释放空间,所以容易出现忘记释放空间导致内存泄漏的问题。通过直接ClassName* B = new ClassName(初始化参数)在堆上申请空间。这里指针上因为使用new申请空间会返回申请到的空间的初始地址。使用完毕后就需要使用delete(B)进行主动的释放。

对于Java中,由于有gg的存在,所以不需要主动释放,而且也不提供栈和堆两种空间申请方式,统一使用堆,所以和c++中有点不一样。而c++中后面提到的智能指针可以在一定程度上完成像Java中这种在堆上申请空间并且主动释放的功能。

1.19 implicit conversion

c++中有一个独特的特性:隐式转换。这是Java中没有的。

我们先看一段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Person {
private:
    std::string name;
    int age;
public:    
    Person(const std::string& name)
    : name(name), age(-1) {
    }
  
    Person(int age)
    : name("Unknown"), age(age) {
    }
};

我们知道Person p = Person(100)肯定是正确的,那如果偷个懒,直接写成Person p = 100,请问这个写法对吗?

如果从Java等其他语言的角度来看,这肯定错啊,100怎么可能能赋值给一个对象,但是先别急,我们刚刚提到的隐式转换说的就是这个,当给对象赋值的时候,如果直接传入一个值,就会自动尝试去调用他的构造函数,也就是Person(int age){}了,也就是说这两种写法其实是等价的。

那么理解到这里,我再问你一个问题,Person p = "thisingl"对吗?先别急着下结论噢,我们先来看看这个情况和我们刚刚Person p = 100的情况一样吗?聪明的你肯定发现了一个语法细节了,"thisingl"其实是const char[]类型的,这里就已经有一次默认的隐式抓换了,从const char[]->string,然后再进行一次隐式转换,从string->Person(const std::string& ),而c++中规定:最多只能进行一次隐式转换,所以这个写法是错误的,我们可以写成:Person p = std::string("thisingl"),就可以避免第一次隐式转换。

当然直接这么写其实很多时候很奇怪给人,就是会增加人的理解成本,所以其实用的还是比较少的,感觉可能在一些数学场景下用的比较多

1.20 explicit

explicit上显示关键词,他可以支持让用户不允许某些情况下的隐式调用,比如如果上面改成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Person {
private:
    std::string name;
    int age;
public:    
    explicit Person(const std::string& name)
    : name(name), age(-1) {
    }
    Person(int age)
    : name("Unknown"), age(age) {
    }
};

那么这时候这样写就会报错:Person p = std::string("thisingl")

1.21 operator overload

c++中提供了一个很强大的,其他语言基本上没有的功能:操作符重载

也就是说在c++中,我们可以赋予像+*或者>>等操作符另外的含义,这样有时候会简化很多特别繁琐的情况,特别是数学计算等领域。

假设我们有下面这样一个二维向量类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Vector {
public:
    float x, y;
    Vector(float x, float y)
    : x(x), y(y) {}

    Vector Add(const Vector& other) const {
        return Vector(x + other.x, y + other.y);
    }

    Vector Multiply(const Vector& other) const {
        return Vector(x * other.x, y * other.y);
    }
};

我们有下面这三个向量:

1
2
3
Vector position(4.0f, 4.0f);
Vector speed(0.5f, 1.5f);
Vector powerup(1.1f, 1.1f);

如果我们想要计算一个position + speed * powerup的结果,在Java等语言中只能写成这样:Vector result1 = position.Add(speed.Multiply(powerup)),这个写法看看就不知道到底在做啥数学运算了,很容易看晕如果更复杂的话,我们只想要直观的计算,而不想看到这样的方法调用。好消息是,C++中的操作符重载在这种场景下就非常方便了,我们改写一下二维向量类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Vector {
public:
    float x, y;
    Vector(float x, float y)
    : x(x), y(y) {}

    Vector Add(const Vector& other) const {
        return *this + other;
    }

    Vector operator +(const Vector& other) const {
        return Vector(x + other.x, y + other.y);
    }

    Vector Multiply(const Vector& other) const {
        return *this * other;
    }

    Vector operator *(const Vector& other) const {
        return Vector(x * other.x, y * other.y);
    }

};

这时候我们只需要这样调用即可:Vector result2 = position + speed * powerup

是不是简单太多了。当然我们还可以改写std::cout << 中的<<操作符来达到直接输出这个二维向量的功能,以及重写==操作符来达到比较两个二维向量的功能。

1.22 this

c++中的this的用法和Java中有点不同,c++中的this本质上是ClassName * const this类型的,所以它访问类中变量是用this->x,Java中一般是this.x

1.23 object lifetime

之前在1.18 instantiate object中我们就已经讨论过了两种申请空间方式的区别。

但是之前讲的对栈的使用太浅了,因为那只是将对象的空间申请到栈上,但是也提到了栈的空间比较小,那么有没有办法和堆结合起来呢?我们又想要堆的大空间,又想要栈这种超过使用范围就自动销毁的能力?

聪明的你一定想到了,我们可以用栈上的指针指向堆中的空间呀,一旦出栈就调用析构函数去释放掉堆上空间。

比如我们可以设计一个这样的作用域指针

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Entity {
public:
    Entity() {
        std::cout << "Entity created" << std::endl;
    }

    ~Entity() {
        std::cout << "Entity destroyed" << std::endl;
    }
};

class ScopedPtr {
public:
    ScopedPtr(Entity* entity)
    : e_ptr(entity) {}

    ~ScopedPtr() {
        delete e_ptr;
    }

private:
    Entity* e_ptr;
};

我们就可以直接创建这样一个作用域指针ScopedPtr e = new Entity(),自动在作用域结束后就会释放掉Entity变量所占的空间。

当然智能指针的功能更加强大,我们这里只是简单的演示它的一个小功能。

1.24 smart pointer

c++中的智能指针主要有:unique_ptrshared_ptrweak_ptr

我们首先来聊一聊unique_ptr,这个就是我们刚刚演示过的一个简单的作用域指针,自动会在作用域结束后就会释放掉堆上空间,看看下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Entity {
public:
    Entity() {
        std::cout << "Entity created" << std::endl;
    }

    ~Entity() {
        std::cout << "Entity destroyed" << std::endl;
    }
};

int main() {
    // std::unique_ptr<Entity> entity(new Entity()); // 不推荐写法
    std::unique_ptr<Entity> entity = std::make_unique<Entity>(); // 推荐写法   
}

这里申请unique_ptr指向的空间时推荐使用make_unique申请空间,因为会更加安全。一开始 C++11 只有 make_shared,没有 make_unique,是因为 make_unique 没有性能优势(不像 make_shared 可以少分配一次内存),但后来大家发现安全性问题太多,于是 C++14 才正式引入。

原因就是如果直接std::unique_ptr<Entity> entity(new Entity()),那么假设在堆上申请完new Entity()空间后,还没来得及构造std::unique_ptr<Entity> entity指向刚刚申请的空间,就出现异常,那么申请的空间就忘记自动释放了,造成了内存泄漏。而如果使用 std::make_unique<Entity>()来申请内存,那么就能保证不会出现刚刚的问题。

同时一个对象只能被一个unique_ptr拥有,那么你肯定好奇c++是怎么做到这么确保的呢?其实很简单,unique_ptr的实现中删除了=运算符,也就导致了不能被拷贝了,自然也就可以做到这个独占所有权,这也是名字中的unique的由来。

下面我们再看看shared_ptr,这种智能指针就能够让多个指针指向同一个对象,他的具体实现和编译器有关,但是大部分都是使用了所谓的引用计数,也就是当最后一个指向这个对象的shared_ptr被销毁时,才会释放内存。

用法如下:

1
2
3
4
5
6
7
8
int main() {
    std::shared_ptr<Entity> entity1;
    {
        std::shared_ptr<Entity> entity2 = std::make_shared<Entity>();
        entity1 = entity2;
    }
    // 执行到这里不会释放空间
} // 执行到这里才会释放空间

这里使用std::make_shared来申请空间不但是为了安全,同时还有性能差异:

  • std::make_shared<T>(...)一次性分配一块内存,同时存放对象和引用计数控制块。

  • std::shared_ptr<T>(new T(...)) 会分配两次:

    • new T(...) 分配对象

    • 内部再分配一块内存存放引用计数控制块

weak_ptr一般和shared_ptr一块使用,他的特点是不会增加引用计数,同时通过使用lock()方法可以知道对象是否还存活,不存活就会返回空指针。

1.25 copy constructor

要想搞清楚什么是拷贝构造函数,我们就得先看看为什么需要它。

我们先来看下面一个类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class String {
private:
    char* buffer;
    unsigned int b_Size;
public:
    String(const char* string) {    // 构造函数
        b_Size = strlen(string);
        buffer = new char[b_Size + 1];
        memcpy(buffer, string, b_Size + 1);
    }
    
    ~String() {                     // 析构函数:释放空间
        delete[] buffer;
    }

    void getBuffer() {              // 打印 buffer 内容
        std::cout << buffer << std::endl;
    }
};

如果我们这样调用,程序会发生什么:

1
2
3
4
5
6
int main() {
    String first = "thising";
    String second = first;
    first.getBuffer();
    second.getBuffer();
}

答案是:在打印完两次"thising"之后,程序出现了未知的错误,程序崩溃了!

是不是非常的Interesting?

要想知道这里为什么报错了,我们就得先了解程序中两个非常重要的概念:浅拷贝深拷贝

  1. 浅拷贝(Shallow Copy)

​ 浅拷贝就是只复制对象的成员变量的值,而不复制指针所指向的实际数据。也就是说,如果对象中有指针成员,浅拷贝只会复制指针的值(地址),而不会新建一块内存来存放数据。

  1. 深拷贝(Deep Copy)

​ 深拷贝不仅复制成员变量的值,还会为指针成员分配新的内存空间,并将原指针指向的数据复制到新的内存中。

而我们刚刚进行的String second = first,其实就是一次浅拷贝,这是因为在所有的类中,有一个特殊的构造函数,叫做拷贝构造函数的存在,函数具体就长这样:

1
2
3
4
5
6
class A {
public:
    A(const A& other) {
        // 这里写如何从 other 复制成员
    }
};

这里如果说我们没有显示去重写这个拷贝构造函数,编译器就会默认帮我们生成一个默认的浅拷贝构造函数

那么根据浅拷贝的定义,就很容易知道first.buffersecond.buffer其实指向同一片内存区域,因此在函数运行结束后,会调用2次析构函数,对同一块内存区域释放两次!因此出现了未定义行为,导致了程序崩溃。

那么这里我们的本意肯定是希望使用的是深拷贝,因此我们就得重写拷贝构造函数了,代码如下:

 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
class String {
private:
    char* buffer;
    unsigned int b_Size;
public:
    String(const char* string) { 
        b_Size = strlen(string);
        buffer = new char[b_Size + 1];
        memcpy(buffer, string, b_Size + 1);
    }
    
    String(const String& other)      	// 拷贝构造函数
        :b_Size(other.b_Size) {
        buffer = new char[b_Size + 1];  // 申请新的空间
        memcpy(buffer, other.buffer, b_Size + 1);
    }

    ~String() { 
        delete[] buffer;
    }

    void getBuffer() { 
        std::cout << buffer << std::endl;
    }
};

此时再次运行之前的main函数,便不会报错了,由此大功告成

1.26 vector

std::vector是cpp中的标准库中的动态数组,但是却叫向量这个名字属实有点奇怪啊哈哈哈

基本用法我们就不说了,废话不多说,我们先看一段新手的经常写法:

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

    }

    Vertex(const Vertex& other)         // 改造拷贝构造函数来看看复制了几次
    : x(other.x), y(other.y), z(other.z) {
        std::cout << "do a copy" << std::endl;
    }
};

int main() {
    std::vector<Vertex> vertices;
    vertices.push_back(Vertex(1, 2, 3));
    vertices.push_back(Vertex(4, 5, 6));
    vertices.push_back(Vertex(7, 8, 9));
}

Q. 请问do a copy会被打印多少次呢?

A. 6次!是不是很神奇,卧槽为啥会被复制了6次呢?这不得非常的废时间,因为对象的复制操作其实挺慢的。

首先我们得先明白std::vector扩容策略,当然这和编译器有关,我这台win电脑上是默认一开始是没有空间的,每次扩容是按照2的倍速递增。当执行第一次push_back时,由于一开始没有空间,会分配 capacity=1 的空间,当然这次扩容并没有用到复制操作。当执行第二次push_back时,由于空间只有1,所以又会扩容,分配 capacity=2 的空间,这时候会将之前的Vertex(1, 2, 3)复制到新的空间里,复制操作**+1**。当执行第三次push_back时,由于空间只有2,所以又会扩容,分配 capacity=4 的空间,这时候会将之前的Vertex(1, 2, 3)Vertex(4, 5, 6)复制到新的空间里,复制操作**+2**。

所以上诉扩容策略导致了3次复制操作,有没有办法能优化一下这个性能呢?

有的兄弟有的,那就是试用reserve(3)去预分配用户想要的空间啊,注意不是使用的resize(),因为会影响到size,我们只想要空间,不想改变目前的节点数。

因此当我们使用下面的写法:

1
2
3
4
5
6
7
int main() {
    std::vector<Vertex> vertices;
    vertices.reserve(3);
    vertices.push_back(Vertex(1, 2, 3));
    vertices.push_back(Vertex(4, 5, 6));
    vertices.push_back(Vertex(7, 8, 9));
}

运行后果然,只打印了3次了!

那么这3次又是哪里来的呢?这是由于当我们使用push_back(Vertex(1, 2, 3))时,Vertex(1, 2, 3)分配的空间是在main中的,而std::vector的空间是独有的,那么就会出现一次复制,将Vertex(1, 2, 3)从外面拷贝到std::vector的空间中。push_back总共执行了3次,所以这里也就打印了3次啊!

那么这里的复制是不是也是没必要的,聪明的你一定想要了,为什么不直接在std::vector的空间中创建对象呢?所以emplace_back()方法就应运而生了,你不需要传递一个对象进来,因为std::vector在声明的时候就已经绑定了类型了,因此知道类型只需要传入构造函数的参数就可以依靠emplace_back(1, 2, 3)在内部空间中直接创建了,也就少掉了1次没有必要的复制操作。

因此优化的代码如下:

1
2
3
4
5
6
7
8
int main() {
    std::vector<Vertex> vertices;
    vertices.reserve(3);
    vertices.emplace_back(1, 2, 3);
    vertices.emplace_back(4, 5, 6);
    vertices.emplace_back(7, 8, 9);
    
}

非常完美!现在什么都不打印了,说明一次都没有复制了。

1.27 pair && tuple

std::pair<int, std::string> p = (0, "thising")可以用来保存两个不同类型的值

std::tuple<int, std::string, double> t(1, "pi", 3.1415)可以用来包含任意数量和类型的值

1.28 template

模版是一个高级主题,非常的复杂,这里我们只是简单的使用。

我们首先考虑下面这种实际编程的场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void print(int value) {
    std::cout << value << std::endl;
}

void print(std::string value) {
    std::cout << value << std::endl;
}

int main() {
    print(100);
    print("thising");
} 

如果还需要支持更多的类型,就需要一直去重载,但是此时我们观察这些重载的方法,其实有区别的地方只有变量类型呀,这时候你不经好奇,能不能有这样一种功能:我们写好一个类似于模版的东西,大家只需要根据自己的实际使用,去微调一下这个模版中的个别关键的地方,比如把名字换掉,不就成自己的方法了?

恭喜你!你发明了模版template

上面的代码如果我们使用模版来写,就会很简洁:

1
2
3
4
5
6
7
8
9
template<typename T>
void print(T value) {
    std::cout << value << std::endl;
}

int main() {
    print<int>(100);
    print<std::string>("thising");
} 

模版只有在被实际调用的时候,才会生成相应的方法代码。当然现在的编译器检查变多了,也会检查模版的语法问题了,之前如果你写一个有语法错误的模版代码,只要不调用,编译运行就不会报错。

当然模版不仅只能生成我们想要的方法,还可以生成我们想要的,比如下面这样:

1
2
3
4
5
template<typename T, int N>
class Array {
private:
    T m_Array[N];
};

同时我们要明白:模版不应该被滥用,当发现错误的时候,模版调试时是比较困难的,同时可读性也不如重载,但是它足够简洁

1.29 function pointer

c++中有函数指针的概念,也就是我们的指针不仅可以指向一个变量,也可以指向一个函数,如下所示:

1
2
3
4
5
6
7
8
void PrintValue(int value) {
    std::cout << value << std::endl;
}

int main() {
    auto func = PrintValue; // PrintValue代表函数本身,PrintValue()代表函数调用
    func(10);
} 

这里的auto其实是void(*function)类型的,因此其实本质上是:

1
2
3
4
5
6
7
8
void PrintValue(int value) {
    std::cout << value << std::endl;
}

int main() {
    void(*func)(int) = PrintValue;
    func(10);
} 

知道了上面这个语法,我们就可以更进一步了,让函数变得更加的灵活!将函数的参数设置为另一个函数,这样用户在使用的时候就可以对一套大的函数逻辑进行个性化修改了使用了,不得不佩服c++的灵活性。

下面是一段简单的代码示范:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void PrintValue(int value) {
    std::cout << value << std::endl;
}

void ForEach(const std::vector<int>& values, void(*func)(int)) {
    for (int value : values) {
        func(value);
    }
}

int main() {
    std::vector<int> values = {1, 2, 3, 4, 5};
    ForEach(values, PrintValue);
} 

1.30 lambda

想要了解lambda表达式,首先得了解1.29 函数指针,知道一个函数的参数还可以是另一个函数。

lambda表达式就是为了简化之前的语法,当我们将函数的参数设置为另一个函数时,这个工具函数的名字似乎并没有什么意义,因此可以在用户使用时进行简化。

C++中的lambda表达式语法是这样的:

1
2
3
[capture](parameter_list) -> return_type {
    function_body
};

这里的捕获列表(capture)有下面几种类型:

写法 含义
[] 不捕获,不访问外部变量
[x] 按值捕获,只捕获变量x
[&x] 按引用捕获,只捕获变量x
[=] 按值捕获所有外部变量
[&] 按引用捕获所有外部变量

那么上面我们在 1.29 函数指针 中的代码就可以简化为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void ForEach(const std::vector<int>& values, void(*func)(int)) {
    for (int value : values) {
        func(value);
    }
}

int main() {
    std::vector<int> values = {1, 2, 3, 4, 5};
    auto lambda = [](int value) {
        std::cout << value << std::endl;
    };
    ForEach(values, lambda);
}