C++的高频20点

静态库和动态库

  1. 为什么需要使用库?有的时候我们并不想让别人看见函数的内部实现,而是看中他们的功能;有一些重复性的工作我们不用自己写,但是拷贝别人的源代码的时候是可以直接修改源代码的,所以库就可以很好的很好的开放接口,隐藏实现。
  2. 函数地址的绑定是在编译期间,如果一个main.c用到了imo.c文件下的foo函数,那么函数地址,所以编译器会暂时把这些调用foo的指令的目标地址搁置,等待最后链接时由链接器将这些指令的目标地址修正。这就是静态链接最基本的过程和作用。

静态库和动态库的生成

比如我要将src目录的c程序打包成静态库和动态库:

  1. 静态库:
  • 执行gcc -c *.c将add.c, div.c, mult.c sub.c全部转换为.o文件;
  • 执行ar rcs libcalc.a *.o将所有的.o打包成一个名为libcalc静态库;
  • 链接的时候需要用到这个库时候:gcc main.c -o main -I ./include -L ./lib -lcalc
  1. 动态库:
  • 执行gcc -c -fpic *.c -o,得到一个与位置无关的.o文件;(与位置无关的代码:动态库的代码会通过mmap()系统调用来映射到进程的虚拟地址空间,不同的进程中,同一个动态库映射的虚拟地址是不确定的。如果动态库的实现上使用位置相关的代码,则无法达到其任意地址运行的目的)
  • 执行gcc -shared *.o -o libcalc.so,生成动态库;库名为calc
  • 链接的时候使用gcc main.c -o main -I ./include -L ./lib -lcalc即可。

链接的过程

链接本质上就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确衔接。

  1. 符号解析:链接器会遍历所有的目标文件和库文件,尝试解析未定义的符号。对于每一个未定义的符号,链接器会在可执行文件的所有目标文件和库文件中查找其定义。
  2. 符号重定位:一旦所有的符号都已经解析,链接器接下来要做的是符号的重定位。这个过程是将每个符号引用替换为其在内存中的实际地址或偏移量的过程。
  3. 地址解析:确保每个目标文件和库文件中的符号引用都被正确地映射到最终的物理内存地址或虚拟内存地址上。

静态库底层

假设现在编译的过程到了编译、汇编、链接的链接阶段,那么文件的后缀由.c->.o,需要链接库文件之后才能到.exe可执行文件。(注意对于库文件中函数,地址的绑定是在链接阶段实现的)

  1. 静态库的链接过程
    生成的libcalc.a就是一个静态库,它处在lib目录下,在链接的过程中,将libcalc.a拷贝进程序中,对应的就是内存空间的.text段中。(注意我查询过,是整个文件的拷贝,而不是对应函数的实现拷贝)
  2. 静态库的优点
  • 运行效率高,因为是直接全部拷贝到目标文件中,少了动态链接的过程;
  • 发布的时候不用使用另外的库文件。
  1. 静态库的缺点
  • 占用空间大,因为只要用到某个库文件的函数需要复制整个库文件;
  • 更新维护困难,如果需要修改某个函数的实现,需要重新编译。

动态库

  1. 动态库的链接过程
    生成的libcalc.so就是一个动态库,当函数调用的时候是根据函数的映射表去调用函数,有一个动态链接的过程。
  2. 动态库的优点
  • 节省空间,多个应用程序可以共享同一个动态库,用的某个函数的时候去动态链接即可。
  • 易于更新,动态库更新后,所有依赖它的程序都可以自动获得更新,无需重新编译目标程序。
  1. 动态库的缺点
  • 如果一同发布的动态库丢失,那么程序就不能运行;
  • 动态加载和链接可能会引入一定的性能开销,尤其是在加载大型库时;

C++内存模型

C++内存模型以及变量存储位置


生命周期、作用域与内存分布:

生命周期 作用域 内存分布 引用方法
普通全局变量 程序运行期 全局作用域(包括所有的源文件) 全局数据区 其他文件需要使用需要用extern关键字
普通局部变量、形参 函数销毁即销毁 函数的局部作用域 栈区 在函数内通过变量名
静态全局变量 程序运行期 本文件作用域(只在本文件) 全局数据区 在本文件中通过变量名
静态局部变量 程序运行期 函数的局部作用域 全局数据区 在函数中通过变量名

堆区和栈区的区别

  1. 申请方式不同:栈区是由系统自动分配的;堆区需要程序员申请;
  2. 申请大小不同: 栈的大小一般是预设好的,可以通过ulimit -a查看,ulimit -s修改;
  3. 申请的效率不同:栈的分配速度快,不会有零碎的空间;堆的申请速度慢,且会有碎片;
管理方式 由程序员自己管理 由编译器自动管理
内存内部机制 系统内部有一个空闲内存链表专门管理空闲内存,所以空间其实是不连续的 由系统内定好的连续空间
空间大小 一般是1-4G 一般是1-4M
碎片化问题 频繁调用new/delete会造成大量碎片 就像栈一样,先进后出,不会产生碎片
分配方式 动态分配 既有动态分配和静态分配(重载new操作符是可以实现在栈区和静态区分配内存的,且不用自己释放)

new/delete和malloc/free(new申请的空间就叫自由存储区)

new malloc delete free
分类区别 C++的关键字 C/C++标准库函数 C++的关键字 C/C++标准库函数
实现过程 先调用operator new的标准库函数申请内存(底层通常是malloc),然后调用构造函数初始化,返回对象类型的指针 动态的申请内存,不会调用构造函数 先调用析构函数,之后调用operator delete标准库函数进行内存释放 直接释放所申请的内存
能否重载 因为是C++的关键字,所以可以重载。(通过重载甚至能够在栈区和静态区申请内存) 不能重载 和new一样支持重载 不能重载
异常返回 内存分配失败返回bad_alloc错误 分类内存失败返回NULL
返回值 返回对应的类型指针 返回的是void*类型的指针
内存中的值 对应构造函数初始化的值 内存中的值是随机的

free会立即返回操作系统吗?

不会,会把内存由一个双链表保存起来,然后下一次申请内存的时候优先在这些内存中寻找合适的返回,减少系统调用。

指针和引用的区别

指针 引用
本质 变量的地址值 变量的别名
初始化 可以不初始化,不具体指向 必须有具体的对象
函数传参 本质上还是值的传递,不过就是地址值 不会有形参的传参过程
sizeof结果 返回的是指针的大小,32位一般为4 返回的是对应变量的类型的大小
绑定其他变量 指针是可以改变指向的 一旦初始化之后不能改变
应用场景 动态内存管理(new)、实现复杂数据结构(树、链表等)、结构复杂但是不想传递整体过去 函数目标是对变量直接进行操作

一些关键字的作用

const

  1. 类外的行为
    1)const变量定义的时候必须初始化;
    2)const变量是在编译器告诉编译器这个对象是不能改变的,并且会有类型检查;
    3)可以用来定义常量,修饰函数参数,修饰函数返回值,且被const修饰的东西,都受到强制保护,可以预防其它代码无意识的进行修改,从而提高了程序的健壮性;
    4)const修饰的全局变量会限定作用域为本文件,不能通过extern改变;
  2. 类内的行为
    1)const成员变量:只能在初始化列表中进行初始化,即必须有构造函数;
    2)const成员函数:只有const对象只能调用const成员函数;
    3)mutable允许在const成员变量中去修改值;
 class person{
 public:
    void add() const {} //注意const函数的意思不是返回值,而是const修饰的函数本身。
 };

define

  1. define会在预编译期间进行宏替换,但是要注意宏替换的边际效应:
#define k 3 + 3
int a = k/2;//我们想要的结果是2.5,但是结果会变成4,即为边际效应
  1. define不会进行类型检查,而是直接做的文本替换;
  2. define的作用一般有:
1). 定义常量
#define MAX 1000;
2). 代替模板函数
#define SWAP(a,b) do\
{\
decltype(a) temp = a;\
a = b;\
b = temp;\
}while(0)
3). 条件编译
#define DEBUG
#ifdef DEBUG
    cout << "DEBUG" << endl;
#endif
}

static

初始化的时间:局部静态变量在第一次被使用时初始化,其他的包括类的static成员都是main函数执行之前就初始化了。

  1. 类外的定义
    1)在全局定义一个static变量,主要的作用是限定该变量的作用域为本文件;
    2)默认的初始值为0,存储在全局数据区
  2. 类内的定义
    1)类内的成员变量必须类外初始化;
    2)类的静态成员变量只属于类,而不属于对象,static函数不存在this指针,因为它不属于该类;

override:

用于显式地标记函数覆盖基类中的虚函数,比如你本来是要重写的foo函数,但是子类里面却写成了foq那么使用override会报错。

class A
{
    virtual void foo();
}
class B : public A
{
    void foo(); //OK
    virtual void foo(); // OK
    void foo() override; //OK
    void fo1() override; //false
}

final:

用于显式地标记类或虚函数不能被继承或覆盖。

class B final : A // 指明B是不可以被继承的
{
    void foo() override; // Error: 在A中已经被final了
};
 
class C : B // Error: B is final
{
};

volatile

volatile 关键字告诉编译器,这个变量可能会在程序之外被修改(表示它的值可能会被另一个线程或外部事件改变),所以每次访问这个变量的时候都必须重新读取它的值,而不能依赖于寄存器缓存。

mutable

在const函数里面需要修改变量的值的时候那么这个变量应该被mutable修饰。

class person
{
    int m_A;
    mutable int m_B;//特殊变量 在常函数里值也可以被修改
public:
    void add() const//在函数里不可修改this指针指向的值 常量指针
    {
        m_A = 10;//错误  不可修改值,this已经被修饰为常量指针
        m_B = 20;//正确
    }
};

explicit

只能用来修饰构造函数,表示不能隐式转换。

class person
{
public:
    int m_A;
    explicit person(int a) {
        m_A = a;
    }
};
int main(){
    vector<person> ve;
    ve.push_back(2);//如果没有explicit则成功,但是现在不能转换所以失败了。
    
    
    return 0;
}

auto

使得编译器根据变量的初始值去推导变量的类型,所以该变量一定要有初始值。

decltype

想用某个表达式(函数)的类型,但是不想去执行他的内容,这个时候auto会显得很吃力。就可以使用decltype。

decltype(auto)

先将等号右边的类型替换auto,然后类型推导auto类型。

 1. auto n1 = 10;
       auto url1 = "http://c.biancheng.net/cplus/";
 2. decltype(10) n2 = 99;
       decltype(url1) url2 = "http://c.biancheng.net/java/";
 3. int e = 4;
       const int* f = &e; 
       decltype(auto) j = f;

特殊函数

内联函数

内联函数在编译的时候是直接把函数代码嵌入到目标代码中,省去函数调用的过程,并且具有类型检查功能。

  1. 优点:
    1) 减少函数调用开销:内联函数会将函数调用处直接替换为函数体内容,避免了函数调用和返回的开销。
    2) 提高程序执行效率:减少了函数调用的开销,可以提高程序的执行效率,尤其是对于短小的函数。
  2. 注意事项:
    1)适当使用:内联函数适合用于函数体短小且频繁调用的情况。对于较大的函数或者不经常调用的函数,过度使用内联可能会导致代码膨胀,反而影响程序性能。
    2)自动内联:编译器会根据函数的复杂度和调用情况进行自动内联优化,无需显式指定 inline 关键字。
    3)强制内联:可以使用 inline 关键字来强制进行内联,但编译器不一定会遵循这个要求,具体取决于编译器的实现。

友元函数

友元函数被允许直接访问类的私有成员和保护成员,而无需通过类的对象来调用。这种机制通过 friend 关键字实现。

class p{
public:
    
    void add() {
        cout << b << endl;
    }
    virtual ~p() {
        cout << "p" << endl;
    }
    friend void jk(const p& p1);
protected:
    int b = 0;
private:
    int a = 10;
    
};

void jk(const p& p1) {
    cout << p1.a << endl;
}

各类构造函数与注意的问题

构造函数类型 功能描述
默认构造函数 不带参数或所有参数有默认值的构造函数,用于基本初始化。
参数化构造函数 带参数的构造函数,用于接受外部数据初始化对象。
拷贝构造函数 使用同类型的另一个对象来初始化新对象,通常用于深拷贝。
移动构造函数 获取临时对象或即将被销毁对象的资源,提高性能
委托构造函数 一个构造函数调用同类中另一个构造函数,减少代码重复。
显式构造函数 防止隐式类型转换,通过 explicit 关键字声明,用于避免意外的类型转换。

初始化列表的作用机理

  1. 初始化列表的作用机理在于直接在成员变量的内存空间上进行初始化,避免了临时对象的产生和赋值操作,从而提高了效率。
    1) 使用初始化列表可以直接初始化成员变量,而非先调用默认构造函数再赋值。这样可以避免了两次赋值操作,即在默认构造函数中初始化,再在构造函数体中再次赋值。
    2) 初始化列表可以指定成员变量的初始化顺序,而不受它们在类中声明的顺序的限制。这样可以确保在构造函数体执行之前,成员变量已经被正确初始化,避免了因为初始化顺序问题导致的不确定行为。(这一点很重要)
    3)对于常量成员和引用成员,它们只能通过初始化列表来进行初始化,因为它们必须在创建时被赋初值,所以初始化列表是唯一的选择。
  2. 列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。
  3. 一个派生类构造函数的执行顺序如下
    1)虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)
    2)基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)
    3)派生类自己的构造函数。

拷贝构造函数

  1. 特点
    1)参数列表引用类型,因为如果你传递的是一个值的话,那么你在传递的时候会有一个参数列表的对象的创建过程,即还会调用该类的拷贝构造函数,这样就成了无限循环调用了;
    2)用const修饰参数,因为语义为不可修改,不在函数内部修改该变量,所以一般为const int& a。
  2. 深浅拷贝
    在类的内部有指针变量的时候需要注意这个问题,不能使得拷贝的值指向同一个内存。

this指针和虚指针

this指针 v_ptr指针
产生时机 编译中产生 构造时编译器设置
指向内容 指向的是类,与第一个成员变量的地址相同(一个类如果有虚函数,那么第一个参数一般是虚指针) 指向的是虚函数表
作用 在成员函数内部用来访问该对象的成员变量和其他成员函数(同时成员函数的第一个参数其实是this) 虚指针指向虚函数表,而虚函数表存储了类的虚函数的地址。虚指针和虚函数表一起实现了C++中的动态多态性(即运行时多态性)
内存中的位置 寄存器中,很少有放在栈中,因为成员函数的调用和this指针相关,所以要求是快 存放在.rodata常量区

构造函数、析构函数能不能为虚函数的原因

  1. 构造函数:首先如果构造函数是一个虚函数,我们知道虚函数是需要虚指针调用的,但是虚指针其实是构造函数初始化的,这就出现了前后矛盾了;
  2. 析构函数:只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。如图所示:
class father
{
public:
    father() {}
    ~father(){
        cout << "father" << endl;
    }
};

class son : public father
{
public:
    son() {}
    ~son() {
        cout << "son" << endl;
    }
};

int main(){
    father* p = new son();//father析构函数为虚函数的时候son会调用两者的析构函数
    father* p1 = new son();//father析构函数不是虚函数的时候son只会调用父类的析构函数

}

继承和组合

继承通过生成子类的复用通常被称为白箱复用,父类的内部细节对子类可见,而对象组合要求被组合的对象具有良好定义的接口,因为对象的内部细节是不可见的,所以称为黑箱复用。两者的关系是is-a,has-a。

继承的权限问题

继承的时候访问权限一般为:

  1. public继承:
    父类的public在子类的权限是public。
    父类的protect在子类的权限是protect。
    父类的private在子类中是不能访问。
  2. protect继承:
    父类的public在子类的权限是protect。
    父类的protect在子类的权限是protect。
    父类的private在子类中是不能访问。
  3. private继承:
    父类的public在子类的权限是private。
    父类的protect在子类的权限是private。
    父类的private在子类中是不能访问。
    总的来说就是,两个权限取最高等级的权限,父类的private不管什么继承都是不可访问的。

菱形继承问题

  1. 数据冗余
class Person
{
protected:
    string _name; //姓名
    int _age;     //年龄
};

class Student : public Person
{
protected:
    int _stuid; //学号
};

class Teacher : public Person
{
protected:
    int _jobid; //工号
};

class Assistant : public Student, public Person
{
protected:
    int _id; //编号
};

类Assitant是从Student,Teacher继承而来。而Student和Teacher是从Person继承而来,此时Assitant中就会有两份Person的数据,造成数据的冗余。

class Assistant : public Student, public Person
{
protected:
    int _id; //编号
public:
    void f() {
        //无法确定是哪一个函数
        cout << _name << endl;
        //指定访问哪一个父类成员即可解决
        cout << Student::_name << endl;
    }
};
  1. 解决方法
    此时我们需要用到virtual,只需要在菱形继承的分叉点中加入virtual,此时就能解决菱形继承中的数据冗余和二义性的问题。
class Person
{
protected:
    string _name; //姓名
    int _age;     //年龄
};

class Student : virtual public Person //在继承方式前加上virtual
{
protected:
    int _stuid; //学号
};

class Teacher : virtual public Person //在继承方式前加上virtual
{
protected:
    int _jobid; //工号
};

class Assistant : public Student, public Teacher
{
protected:
    int _id; //编号
public:
    void f()
    {
        cout << _name << endl; //此时无二义性问题
    }
};
  1. 原理
    1)虚基表:用于实现虚继承,存储虚基类子对象的偏移量,解决多重继承中的菱形继承问题
    2)步骤:当一个类通过虚继承另一个类时,编译器会为这个类生成一个虚基表;虚基表中存储了到虚基类子对象的偏移量;
    当访问虚基类的成员时,通过虚基表中的偏移量定位到虚基类子对象。
    具体的讲解文章:https://blog.csdn.net/weixin_43819313/article/details/84572264

组合

对象组合是在运行时刻动态定义的,因为对象只能通过接口访问,所以不会破话封装性,还有,因为对象的实现是基于接口写的,所以实现上依赖关系较小;类之间的关系更为灵活。

组合与继承对比

  1. 优点
组合 继承
不破坏封装,整体类与局部类之间松耦合,彼此相对独立 子类能自动继承父类的接口
具有较好的可扩展性 创建子类的对象时,无须创建父类的对象
整体类可以对局部类进行包装,封装局部类的接口,提供新的接口
  1. 缺点
组合 继承
整体类不能自动获得和局部类同样的接口 子类不能使得父类的接口消失
创建整体类的对象时,需要创建所有局部类的对象 不支持动态继承。在运行时,子类无法选择不同的父类
支持扩展,但是往往以增加系统结构的复杂度为代价
破坏封装,子类与父类之间紧密耦合,子类依赖于父类的实现,子类缺乏独立性

多态9

什么是多态

通过父类的指针或者引用,在运行时动态调用实际绑定对象函数的行为。多态满足的条件:有继承关系、子类重写父类的函数、父类指针指向子类对象。多态分为静态多态(重载)和静态多态(重写)。\

  1. 静态多态:在编译期间就根据参数列表来决定调用哪一个函数(早绑定)。\
  2. 动态多态:需要在运行期间才能知道调用哪一个函数(晚绑定),动态多态与虚表、虚指针有关。\
    总结,多态就是我希望传进去的是什么东西,那么他对应什么方法,比如我传进去小狗,功能为小狗叫,传进去小猫,功能为小猫叫,那么怎么实现呢?首先肯定是函数重载(也就是静态多态),其次就是子类重写父类的函数(动态多态)。

重写、重载和隐藏

  1. 重载:在同一个作用域下不同的参数列表的同名函数,根据不同参数列表来调用哪个函数。(注意重载不能仅根据返回值来确定)
void add(int a) {}
void add(char a){}
  1. 重写:子类重写父类的函数,该函数必须是virtual虚函数,并且满足:
    1)与基类的虚函数有相同的参数个数
    2)与基类的虚函数有相同的参数类型
    3)与基类的虚函数有相同的返回值类型
//父类
class A{
public:
    virtual int fun(int a){}
}
//子类
class B : public A{
public:
    //重写,一般加override可以确保是重写父类的函数
    virtual int fun(int a) override{}
}
  1. 重定义(相当于隐藏了父类的函数):子类重新定义父类的函数,该函数同名,且没有virtual修饰。
    1)两个参数相同,但是不是虚函数
//父类
class A{
public:
    void fun(int a){
		cout << "A中的fun函数" << endl;
	}
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
    void fun(int a){
		cout << "B中的fun函数" << endl;
	}
};
int main(){
    B b;
    b.fun(2); //调用的是B中的fun函数
    b.A::fun(2); //调用A中fun函数
    return 0;
}
  1. 两个参数不同,无论是不是虚函数都会被隐藏
//父类
class A{
public:
    virtual void fun(int a){
		cout << "A中的fun函数" << endl;
	}
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
   virtual void fun(char* a){
	   cout << "A中的fun函数" << endl;
   }
};
int main(){
    B b;
    b.fun(2); //报错,调用的是B中的fun函数,参数类型不对
    b.A::fun(2); //调用A中fun函数
    return 0;
}

虚表与虚指针

  1. 虚表:编译器将类中虚函数的地址存放在虚函数表中,虚函数表存在于常量区(.rodata),每个类仅有一个,供所有对象共享(注意,要有虚函数才会生成这张虚表)。
  2. 虚指针:每一个类都有一个虚指针,该虚函数指针指向的是虚表。在构造函数的时候初始化,由编译器自己初始化。注意虚表指针是和对象存放在一起的,是根据该对象的存储位置决定的,如果该对象是申请在堆区那么虚指针就是在堆区。
  3. 虚表与类在继承中的关系

vector的需要注意的问题

  1. 频繁的push_back的效率
    拿一个person的类来举例。
class person
{
public:
    person(int num):num(num){
        std::cout << "gouzaohanshu" << endl;
    }
    person(const person& other) :num(other.num) {
        std::cout << "kaobeigouzao" << endl;
    }
    person(person&& other) :num(other.num) {
        std::cout << "yidonggouzao" << endl;
    }
private:
    int num;
};

如果频繁的push_back(a),那么应该执行以下步骤:
1)拷贝a到一个临时对象;
2)然后调用移动构造或者拷贝构造添加到末端;
3)调用析构函数销毁临时对象。
第2)个步骤,针对该问题可以参考一篇写的很好的文章:
https://blog.csdn.net/li123_123_/article/details/122099396

emplace_back方法:

emplace_back()在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。

指针失效问题

  1. 什么是指针失效?
    指针失效指的不是内存泄漏之类的错误,而是产生了超出预料的事情,比如你原本的指针是指向1的,但是现在指向了2
  2. 指针失效的情况:
    1)vector:调用erase删除元素的时候,erase函数是会返回该删除后的位置上的指针的,后续的指针都会往前移动一位,所以后面的指针会失去作用。
    2)map、set:删除当前的值不会对后面的结构产生影响,所以不会出现失效。
    3)list:也不会失效。

vector的空间释放

vector(Vec).swap(Vec); //将Vec中多余内存清除; 
vector().swap(Vec); //清空Vec的全部内存;

vector以外的容器的常考问题

https://blog.csdn.net/qq_45863980/article/details/138772986

仿函数

仿函数相对于普通的函数,具有更加复杂的代码设计,然而仿函数也有其过人之处,它具有如下的优点:

  1. 拥有状态
    仿函数的能力超越了operator(),仿函数可以拥有成员函数和成员变量,这就意味着仿函数可以同时拥有状态不同的两个实体。一般函数则达不到这样的能力。
  2. 拥有自己的型别
    仿函数(Functor)拥有自己的类型指的是仿函数本身也是一个类型,具体来说,它是一个类或者结构体,具有函数调用操作符 operator() 的重载。
  3. 仿函数速度更快
    就template概念而言,由于很多细节在编译期就已经确定,传入一个仿函数,就可能活动更好的性能
template <typename Func>
void process_data(const std::vector<int>& data, Func func) {
    for (const auto& item : data) {
        func(item);  // 可能被编译器内联优化
    }
}

模板的问题总结

模板的编译问题

  1. 编译器并不是把函数模板处理成能够处理任何类型的函数;函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译,在声明处对模板代码本身进行编译(检查语法),在调用处对参数替换后的代码进行编译
  2. 函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须有程序员在程序中显式地指定,即 函数模板允许隐式调用和显式调用而类模板只能显示调用。
  3. 注意在继承中,类模板的写法:
template<typename T>
class A{};
 
template<typename T>
class B:public A<typename T>{};
  1. 对于模板类,.h和.cpp的实现必须放在同一个.h文件下。

模板的特化

对于某些特定的类型,类模板的实现不能一概而论,所以才有特化,比如不同的精度的float和double类型:

#include <iostream>
using namespace std;
  
template <class T>
class Compare
{
public:
     bool IsEqual(const T& arg, const T& arg1);
};
  
// 已经不具有template的意思了,已经明确为float了
template <>
class Compare<float>
{
public:
     bool IsEqual(const float& arg, const float& arg1);
};
  
// 已经不具有template的意思了,已经明确为double了
template <>
class Compare<double>
{
public:
     bool IsEqual(const double& arg, const double& arg1);
};
  
template <class T>
bool Compare<T>::IsEqual(const T& arg, const T& arg1)
{
     cout<<"Call Compare<T>::IsEqual"<<endl;
     return (arg == arg1);
}
  
bool Compare<float>::IsEqual(const float& arg, const float& arg1)
{
     cout<<"Call Compare<float>::IsEqual"<<endl;
     return (abs(arg - arg1) < 10e-3);
}
  
bool Compare<double>::IsEqual(const double& arg, const double& arg1)
{
     cout<<"Call Compare<double>::IsEqual"<<endl;
     return (abs(arg - arg1) < 10e-6);
}
  
int main()
{
     Compare<int> obj;
     Compare<float> obj1;
     Compare<double> obj2;
     cout<<obj.IsEqual(2, 2)<<endl;
     cout<<obj1.IsEqual(2.003, 2.002)<<endl;
     cout<<obj2.IsEqual(3.000002, 3.0000021)<<endl;
}

偏特化

针对template参数更进一步的条件限制所设计出来的一个特化版本。

#include <iostream>
using namespace std;
  
// 一般化设计
template <class T, class T1>
class TestClass
{
public:
     TestClass()
     {
          cout<<"T, T1"<<endl;
     }
};
  
// 针对普通指针的偏特化设计
template <class T, class T1>
class TestClass<T*, T1*>
{
public:
     TestClass()
     {
          cout<<"T*, T1*"<<endl;
     }
};
  
// 针对const指针的偏特化设计
template <class T, class T1>
class TestClass<const T*, T1*>
{
public:
     TestClass()
     {
          cout<<"const T*, T1*"<<endl;
     }
};
  
int main()
{
     TestClass<int, char> obj;
     TestClass<int *, char *> obj1;
     TestClass<const int *, char *> obj2;
  
     return 0;
}

偏特化和特化的调用规则

对于模板、模板的特化和模板的偏特化都存在的情况下,编译器在编译阶段进行匹配时,是如何抉择的呢?从哲学的角度来说,应该先照顾最特殊的,然后才是次特殊的,最后才是最普通的。编译器进行抉择也是尊从的这个道理,所以应该是先最特殊的偏特化,之后才是特化,之后才是普通类模板。
https://www.cnblogs.com/xuelisheng/p/9329903.html

智能指针

智能指针是一个类,用来存储指向动态分配对象的指针,堆内存泄漏。动态分配的资源,交给一个类对象去管理,,当类对象声明周期结束时,自动调用析构函数释放资源。(总的来说运用的思想是RAII,即资源获取就是初始化)

unique_ptr

  1. 定义:“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现);
  2. unique_ptr的初始化
  unique_ptr<int> ptr = make_unique<int>(10);//生成该对象之后赋值
  unique_ptr<int> ptr1(new int(1));//调用构造函数
  unique_ptr<int> ptr2 = new int(1);//错误,不能直接赋值
  1. 不能将同一个裸指针赋值给多个 unique_ptr 实例(应该注意这里编辑的时候不会错误,但是编译会造成错误)
    int* p = new int(10);
    unique_ptr<int> ptr1(p);
    unique_ptr<int> ptr2(p);//代码上可以,但是执行会重复删除,出现错误
  1. unique_ptr其他函数
//1)get函数
unique_ptr<int> ptr(new int(10));
int* rawPtr = ptr.get(); // 获取ptr所管理对象的裸指针

//2)release函数,解绑定和裸指针的关系,之后需要手动释放
unique_ptr<int> ptr(new int(10));
int* releasedPtr = ptr.release(); // 释放ptr对对象的所有权,返回裸指针
delete releasedPtr; // 手动释放资源

//3) reset函数,释放当前管理的对象内存,之后接管新的指针
std::unique_ptr<int> ptr(new int(10));
ptr.reset(new int(20)); // 释放当前ptr的资源,接管新的资源

  1. 所有权转让
unique_ptr<int> uptr2 = std::move(uptr); 

shared_ptr

  1. 定义
    shared_ptr 实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在“最后⼀个引⽤被销
    毁”时候释放,即use_count()为0的时候释放。
  2. shared_ptr的初始化
shared_ptr<int> ptr1(new int(10));//拷贝构造
auto ptr2 = std::make_shared<int>(20);//使用make_shared
std::shared_ptr<int> ptr3 = ptr1; // 复制构造函数
std::shared_ptr<int> ptr4 = std::move(ptr2); // 移动构造函数,转让了ptr2的所有权,他的引用计数减一
  1. shared_ptr的其他函数
shared_ptr<int> ptr1 = make_shared<int>(10);
shared_ptr<int> ptr2 = make_shared<int>(1);

cout << "Data stored in ptr1: " << *ptr1<< endl;

ptr1.reset();
ptr1.use_count();
ptr1.swap(ptr2);//交换两个 shared_ptr 对象所管理的资源,而不会触发资源的复制或移动
ptr1.unique();//返回一个bool类型判断是不是和其他指针共享这个内存
ptr1.own_before();//返回一个bool类型,这个函数主要用于检查两个 shared_ptr 是否指向同一对象,或者它们之间的所有权关系

weak_ptr

1.定义
weak_ptr就是为了打破shared_ptr的循环引用问题,只引用,不计数。注意,如果shared_ptr和weak_ptr同时指向一个内存,那么shared_ptr引用计数归零之后就释放了,但是weak_ptr不会检查。
2. 初始化

//1. 从shared_ptr初始化
shared_ptr<int> sharedPtr = std::make_shared<int>(42);
weak_ptr<int> weakPtr(sharedPtr);
//2. 空的weak_ptr从reset初始化
weak_ptr<int> weakPtr;
weakPtr.reset(pointer1);
  1. weak_ptr的其他函数
weak_ptr<int> wp;
wp.use_count();
wp.weak.expired();//返回bool,判断指向的内容有效(判断是不是被shared_ptr释放)
wp.lock();//升级为shared_ptr指针(注意内容不能被shared_ptr释放掉)
wp.reset();//释放内存
wp.swap(wp1);

左右引用、move语义与完美转发

左值、右值

  1. 左值:是一个表达式,它指向内存中的一个固定地址,并且可以出现在赋值操作的左侧。左值通常指的是变量的名字,它们在程序的整个运行期间都存在。左值的判断标准:可以取地址、有名字的就是左值。
  2. 右值:是一个临时的、不可重复使用的表达式,它不能出现在赋值操作的左侧。右值通常包括字面量、临时生成的对象以及即将被销毁的对象。右值的判断标准:不可以取地址、没有名字的就是右值。
  3. 区别:主要区别是看能不能立即对其取地址操作。

右值引用

右值引用使用 && 符号声明,它可以绑定到将要销毁的临时对象或右值。右值引用的主要目的是支持移动语义,这是一种资源管理技术,允许资源从临时对象转移到另一个对象,而不是进行复制。

move函数

  1. 定义:
    将一个对象以右值的形式移动走,而不是直接拷贝,原指针的内容被移动走了。
  2. 主要用途:
    1) 触发移动构造函数,从而避免对象的复制构造,特别是在涉及到大量资源(如动态内存、文件句柄等)。
    2) 标准库中的容器和算法一起使用,以优化临时对象的处理。
  3. 应用:
//1) 对一般的对象进行操作 
class person{
public:
    int a
    person(int a) : a(a) {}
};
void process (person&& p) {
}

person r(10); // 创建一个 Resource 对象
process(move(r)); // 将 r 转换为右值引用并传递给函数
// 此时 r 已经是一个无效对象,因为它的资源已经被移动走了
//2)智能指针

完美转发

  1. 什么是完美转发?
    完美转发主要解决了在模板编程中参数转发时保留参数类型和值类别的问题。开发者可以确保转发的参数保持其原始的类型和值类别。
  2. 一个例子说明完美转发
#include <iostream>
#include <utility> // For std::forward
 
// 模拟一个可以接收左值引用和右值引用的函数
void process(int& x) {
    std::cout << "process(int&) - Lvalue reference" << std::endl;
}
 
void process(int&& x) {
    std::cout << "process(int&&) - Rvalue reference" << std::endl;
}
 
// 模板函数,使用完美转发将参数转发给process函数
template<typename T>
void wrapper(T&& arg) {
    // 使用std::forward来转发参数,保持其原始的类型和值类别
    process(std::forward<T>(arg));
}
 
int main() {
    int a = 10;
    wrapper(a);  // 编译器将调用 process(int&),因为a是左值
    wrapper(10); // 编译器将调用 process(int&&),因为10是临时对象(右值)
}

强制类型转换

reinterpret_cast

  1. 用途:
    1)改变指针或引用的类型;
    2)将指针或引用转换为一个足够长度的整形;
    3)将整型转换为指针或引用类型。
//1.改变指针或引用的类型;
    //father* p = reinterpret_cast<son*>(&son1);
    //2. 将指针或引用转换为足够长度的整型
    int value = 42;
    int* ptr = &value;
    int intPtr = reinterpret_cast<long int>(ptr);
    std::cout << "Pointer as integer: " << intPtr << std::endl;
    //3.  将整型转换为指针或引用类型
    const int MEMORY_ADDRESS = 0x00400000;
    int* pt = reinterpret_cast<int*>(MEMORY_ADDRESS);

type-id 必须是一个指针、引用、算术类型、函数针或者成员指针。它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针(先把一个指针转换成一个整数,在把该整数转换成原类型的指针,还可以得到原先的指针值)
2. 总结:
reinterpret_cast 提供了一种非常灵活但也非常危险的类型转换方法。它允许你在指针、引用和整数之间进行转换,但同时需要你非常清楚地理解你在做什么,以避免潜在的未定义行为和程序崩溃。在大多数情况下,尽量使用更安全的类型转换方式如 static_cast 或 dynamic_cast

const_cast(只针对指针)

  1. 用途
    1) 移除常量性,当需要修改一个被声明为 const 的对象时,可以使用 const_cast 将其转换为非常量类型。这通常发生在函数参数或者函数返回值是 const 类型,但实际上我们需要修改它们的情况下。
void modify(int* ptr) {
    *ptr = 10;
}

int main() {
    const int x = 5;
    modify(const_cast<int*>(&x));  // 移除 x 的常量性,允许修改
    // 现在 x 的值变成了 10
    return 0;
}
  1. 添加常量性
    可以使用 const_cast 将一个非 const 对象转换为 const 对象。这种情况下,编译器会视对象为不可修改,强制要求代码不应该尝试修改这个对象。
void print(const int* ptr) {
    std::cout << *ptr << std::endl;
}

int main() {
    int x = 5;
    print(const_cast<const int*>(&x));  // 添加 x 的常量性
    return 0;
}

static_cast

  1. 用途
    1)常规类型转换,用于基本数据类型之间的转换,如将浮点数类型转换为整数类型,将枚举类型转换为整型。例如
double d = 3.14;
int i = static_cast<int>(d);  // 将 double 类型的 d 转换为 int 类型的 i

enum color{red, bul};
int a = static_cast<int>(bul);
  1. 向上转换
    子类转换为父类,是安全的,如:
class Base {
};
class Derived : public Base {
};

Derived a;

Base* d = static_cast<Base*>(&a);  // 将 Derived* 转换为 Base*

  1. 向下转换
    父类转换为子类,是不安全的,向下转换后子类自己的方法和属性丢失了,一旦我们去调用子类的方法和属性那就糟糕了,这就是对类继承关系和内存分配理解不清晰导致的。如:
class Base {
};
class Derived : public Base {
};

Base* b = new Derived();
Derived* d = static_cast<Derived*>(b);  // 将 Base* 转换为 Derived*

4)void->other, other*->void

int* p = new int(10);
void* a = static_cast<void*>(p);

int* l = static_cast<int*>(a);

dynamic_cast

用途
dynamic_cast 是一种用于安全地在继承关系中进行类型转换的运算符。它主要用于向下转换(从基类指针或引用到派生类指针或引用),并且提供了运行时类型检查,因此可以在某些情况下比 static_cast 更安全。

#include <iostream>

class Animal {
public:
    virtual ~Animal() {} // 虚析构函数,确保正确的对象销毁
};

class Dog : public Animal {
public:
    void bark() {
        std::cout << "汪汪" << std::endl;
    }
};

class Cat : public Animal {
public:
    void meow() {
        std::cout << "喵喵" << std::endl;
    }
};

int main() {
    Animal* animal = new Dog(); // 使用基类指针指向派生类对象

    // 使用 dynamic_cast 进行向下转换到派生类指针
    Dog* dog = dynamic_cast<Dog*>(animal);

    if (dog) {
        dog->bark(); // 安全地调用派生类方法
    } else {
        std::cout << "Failed to cast to Dog." << std::endl;
    }

    delete animal; // 注意释放内存
    return 0;
}

Lamda表达式

  1. 基本用法
[capture list] (parameter list) -> return type { function body }

capture list: 捕获列表,用于指定 Lambda表达式可以访问的外部变量,以及是按值还是按引用的方式访问。

int x = 5;
auto f = [x] (int y) -> int { return x + y; }; // 值捕获 x
auto f = [&x] (int y) -> int { return x + y; }; // 引用捕获 x
auto f = [=, &x] (int y) -> int { return x + y; }; // 引用捕获 x,其他全部为值捕获
  1. 基本概念
    1) 大家的理解:每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了0)运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个个闭包。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。
    2) 自己的理解:相当于使用了闭包技术的仿函数,这也解释了为什么lambda可以代替仿函数首先对算法的规则进行改变
int count = count_if(vec.begin(),vec.end(),[](int x){return x % 7 == 0;} //代替仿函数
  1. 应用场景
    1)回调函数:在许多库中,你可能会遇到需要传递回调函数的情况。例如,当你使用定时器、线程、网络请求。
    2)STL的算法行为定义:lambda 表达式可以方便地作容器算法的参数从而代替仿函数。
    3)闭包特性:lambda 表达式可以捕获其所在作用域中的变量,这使得它们能够访问和操作这些变量。这种特性使得 lambda 表达式在需要访问局部变量的上下文中特别有用。

感觉频率比较高的知识点。

类的大小

  1. 原则一:从0开始存,存的位置一定是自己数据类型大小(int* b[10]是按照int*的大小来算的)的整数倍;
  2. 原则二:最后大小一定是基本数据类型大小(int* b[10]是按照int*的大小来算的)的整数倍;
    如:
class jk{
public:
    // int a;
    // char b[11];
    // char c;
    int a[3];               //0开始,存4*3 = 12字节,0 - 11;
    int* b[10];             //假设int*为8大小,从2*8 = 16开始存;16-95;
    char c;                 //从96开始存,96
};                          //总的大小为4(int), 8(int*),1(char)的整数倍,本来是96 - 0 + 1 = 97,所以是104大小;

顶层指针与底层指针

顶层指针 底层指针
形式 int* const a = &a; const int* p = &a;
性质 指的是该指针是不可以修改的 指的是对应的值a是不能修改的
别名 指针常量 常量指针

常考的问题总结(心里默记)

  1. define和const的区别。
  2. final,override,volatile,mutalbe,explicit,auto, decltype。
  3. 静态库和动态库?
  4. 内存分区是怎么样的?
  5. 堆区栈区的区别?
  6. new和malloc的区别?
  7. 指针和引用的区别?
  8. 拷贝构造函数的特点。
  9. 多态的条件以及它的底层。
  10. 析构函数和构造函数能不能为虚函数。
  11. 重写、重载和隐藏。
  12. vector的频繁push_back()要注意的点。怎么解决?
  13. push_back()和emplace_back()的区别。
  14. 你了解仿函数吗?
  15. 介绍一下你使用的智能指针。
  16. 智能指针的缺点和优点有哪些。
  17. 你知道强制类型转换吗?介绍一下几种强制转换。
  18. 顶层指针和底层指针。
  19. static关键字的作用。
posted @ 2024-06-17 13:39  铜锣湾陈昊男  阅读(71)  评论(0)    收藏  举报