C++知识要点总结笔记

avatar
作者
猴君
阅读量:2

文章目录


前言

总结c++语法、内存等知识。仅供个人学习记录用


一、c++基础

1.指针和引用

指针和引用的区别

指针存放某个对象的地址,其本身就是变量(命了名的对象),本身就有地址,所以可以有指向指针的指针;可变,包括其所指向的地址的改变和其指向的地址中所存放的数据的改变
引用就是变量的别名,从一而终,不可变,必须初始化

  1. 定义和声明
    指针是⼀个变量,其值是另⼀个变量的地址。声明指针时,使用 * 符号。
    引用是⼀个别名,它是在已存在的变量上创建的。在声明引用时,使用 & 符号。
  2. 使用和操作
    指针可以通过解引用操作符 * 来访问指针指向的变量的值,还可以通过地址运算符 & 获取变量的地址。
    引用在声明时被初始化,并在整个生命周期中一直引用同一个变量。不需要使用解引用操作符,因为引用本身就是变量的别名
  3. 空值和空引用
    指针可以为空(nullptr)表示不指向任何有效的地址。
    引用必须在声明时初始化,并且不能在后续改变引用的绑定对象。因此,没有空引用的概念
    不存在指向空值的引用,但是存在指向空值的指针
  4. 可变性
    指针可以改变指针的指向,使其指向不同的内存地址。
    引用⼀旦引用被初始化,它将⼀直引用同一个对象,不能改变绑定。
  5. 用途
    指针通常用于动态内存分配、数组操作以及函数参数传递。
    引用通常用于函数参数传递、操作符重载以及创建别名。

函数指针

什么是函数指针,如何定义和使用场景
函数指针是指向函数的指针变量。它可以用于存储函数的地址,允许在运行时动态选择要调用的函数。
格式为:返回类型 (*指针变量名)(参数列表)

int add(int a, int b) {  return a + b; } int subtract(int a, int b) {  return a - b; } int main() {  // 定义⼀个函数指针,指向⼀个接受两个int参数、返回int的函数  int (*operationPtr)(int, int);  // 初始化函数指针,使其指向 add 函数  operationPtr = &add;  // 通过函数指针调⽤函数  int result = operationPtr(10, 5);  cout << "Result: " << result << endl;  // 将函数指针切换到 subtract 函数  operationPtr = &subtract;  // 再次通过函数指针调⽤函数  result = operationPtr(10, 5);  cout << "Result: " << result << endl;  return 0; } 

使用场景:

  1. 回调函数: 函数指针常用于实现回调机制,允许将函数的地址传递给其他函数,以便在适当的时候调用。
  2. 函数指针数组: 可以使用函数指针数组实现类似于状态机的逻辑,根据不同的输入调用不同的函数。
  3. 动态加载库: 函数指针可用于在运行时动态加载库中的函数,实现动态链接库的调用。
  4. 多态实现: 在C++中,虚函数和函数指针结合使用,可以实现类似于多态的效果。
  5. 函数指针作为参数: 可以将函数指针作为参数传递给其他函数,实现⼀种可插拔的函数行为。
  6. 实现函数映射表: 在⼀些需要根据某些条件调用不同函数的情况下,可以使用函数指针来实现函数映射表。

函数指针和指针函数的区别
函数指针是指向函数的指针变量。可以存储特定函数的地址,并在运行时动态选择要调用的函数。通常用于回调函数、动态加载库时的函数调用等场景。

int add(int a, int b) {  return a + b; } int (*ptr)(int, int) = &add; // 函数指针指向 add 函数 int result = (*ptr)(3, 4); // 通过函数指针调⽤函数 

指针函数是⼀个返回指针类型的函数,⽤于返回指向某种类型的数据的指针。

int* getPointer() {  int x = 10;  return &x; // 返回局部变ᰁ地址,不建议这样做 } 

2.数据类型

整型 short int long 和 long long

C++ 整型数据长度标准:
short 至少 16 位
int 至少与 short ⼀样长
long 至少 32 位,且至少与 int ⼀样长
long long 至少 64 位,且至少与 long ⼀样长

在使用8位字节的系统中,1 byte = 8 bit。
很多系统都使用最小长度,short 为 16 位即 2 个字节,long 为 32 位即 4 个字节,long long 为 64 位即 8 个字节,int 的长度较为灵活,⼀般认为 int 的长度为 4 个字节,与 long 等长。
可以通过运算符 sizeof 判断数据类型的长度。例如sizeof (int)
头文件climits定义了符号常量:例如:INT_MAX 表示 int 的最大值,INT_MIN 表示 int 的最小值

无符号类型

即为不存储负数值的整型,可以增大变量能够存储的最大值,数据长度不变。
int 被设置为自然长度,即为计算机处理起来效率最高的长度,所以选择类型时⼀般选用 int 类型。

强制类型转换

关键字:static_cast、dynamic_cast、reinterpret_cast和 const_cast

static_cast

  • 没有运行时类型检查来保证转换的安全性
  • 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
  • 进行下行转换(把基类的指针或引用转换为派生类表示)由于没有动态类型检查,所以是不安全的。
  • 使用:
    用于基本数据类型之间的转换,如把int转换成char。
    把任何类型的表达式转换成void类型。

dynamic_cast

  • 在进行下行转换时,dynamic_cast具有类型检查(信息在虚函数中)的功能,比static_cast更安全。
  • 转换后必须是类的指针、引用或者void*,基类要有虚函数,可以交叉转换。
  • dynamic本身只能用于存在虚函数的父子关系的强制类型转换;对于指针,转换失败则返回nullptr,对于引用,转换失败会抛出异常。

reinterpret_cast

  • 可以将整型转换为指针,也可以把指针转换为数组;可以在指针和引用里进行肆无忌惮的转换,平台移植性比较差。

const_cast

  • 常量指针转换为非常量指针,并且仍然指向原来的对象。常量引用被转换为非常量引用,并且仍然指向原来的对象。去掉类型的const或volatile属性。

3.关键字

const

const的作用
const 关键字主要用于指定变量、指针、引用、成员函数等的性质

  1. 常量变量:声明常量,使变量的值不能被修改。
  2. 指针和引用:声明指向常量的指针,表示指针所指向的值是常量,不能通过指针修改。声明常量引用,表示引用的值是常量,不能通过引用修改。
  3. 成员函数:用于声明常量成员函数,表示该函数不会修改对象的成员变量(对于成员变量是非静态的情况)。
  4. 常量对象:声明对象为常量,使得对象的成员变量不能被修改。
  5. 常引用参数:声明函数参数为常量引用,表示函数不会修改传入的参数。
  6. 常量指针参数:声明函数参数为指向常量的指针,表示函数不会通过指针修改传⼊的数据。

常量指针(底层const)
是指定义了一个指针,这个指针指向⼀个只读的对象,不能通过常量指针来改变这个对象的值。常量指针强调的是指针对其所指对象的不可改变性。
特点:靠近变量名。
形式:
(1)const 数据类型 * 指针变量 = 变量名
(2)数据类型 const * 指针变量 = 变量名

int temp = 10; const int* a = &temp; int const *a = &temp; // 更改: *a = 9; // 错误:只读对象 temp = 9; // 正确 

指针常量(顶层const)
指针常量是指定义了一个指针,这个指针的值只能在定义时初始化,其他地方不能改变。指针常量强调的是指针的不可改变性。
特点:靠近变量类型。
形式:数据类型 * const 指针变量 = 变量名

int temp = 10; int temp1 = 12; int* const p = &temp; // 更改: p = &temp2; // 错误 *p = 9; // 正确 

拓展:
顶层const:指针本身是常量;
底层const:指针所指的对象是常量;
左定值,右定向:指的是const在*的左还是右边
const在*左边,表示不能改变指向对象的值,常量指针;
const在*右边,表示不能更换指向的对象,指针常量

若要修改const修饰的变量的值,需要加上关键字volatile;
若想要修改const成员函数中某些与类状态无关的数据成员,可以使用mutable关键字来修饰这个数据成员;

static

static关键字主要用于控制变量和函数的生命周期、作用域以及访问权限。
实现多个对象之间的数据共享 + 隐藏,并且使用静态成员还不会破坏隐藏原则;

  1. 静态变量
    在函数内部使用 static 关键字修饰的变量称为静态变量。
    静态变量在程序的整个生命周期内存在,不会因为离开作用域而被销毁。
    静态变量默认初始化为零(对于基本数据类型)。
void exampleFunction() {  static int count = 0; // 静态变量  count++;  cout << "Count: " << count << endl; } 
  1. 静态函数
    在类内部使用 static 关键字修饰的函数是静态函数。
    静态函数属于类而不是类的实例,可以通过类名直接调用,而无需创建对象。
    静态函数不能直接访问非静态成员变量或非静态成员函数。
class ExampleClass { public:  static void staticFunction() {  cout << "Static function" << endl;  } }; 
  1. 静态成员变量
    在类中使用 static 关键字修饰的成员变量是静态成员变量。
    所有类的对象共享同一个静态成员变量的副本。
    静态成员变量必须在类外部单独定义,以便为其分配存储空间。
class ExampleClass { public:  static int staticVar; // 静态成员变量声明 }; // 静态成员变量定义 int ExampleClass::staticVar = 0; 
  1. 静态成员函数
    在类中使用 static 关键字修饰的成员函数是静态成员函数。
    静态成员函数不能直接访问非静态成员变量或非静态成员函数。
    静态成员函数可以通过类名调用,而不需要创建类的实例。
class ExampleClass { public:  static void staticMethod() {  cout << "Static method" << endl;  } }; 
  1. 静态局部变量
    在函数内部使用 static 关键字修饰的局部变量是静态局部变量。
    静态局部变量的生命周期延长到整个程序的执行过程,但只在声明它的函数内可见。
void exampleFunction() {  static int localVar = 0; // 静态局部变量  localVar++;  cout << "LocalVar: " << localVar << endl; } 

const和static的区别

在这里插入图片描述

define 和 typedef 的区别

define

  1. 只是简单的字符串替换,没有类型检查
  2. 是在编译的预处理阶段起作用
  3. 可以用来防止头文件重复引用
  4. 不分配内存,给出的是立即数,有多少次使用就进行多少次替换
    typedef
  5. 有对应的数据类型,是要进行判断的
  6. 是在编译、运行的时候起作用
  7. 在静态存储区中分配空间,在程序运行过程中内存中只有⼀个拷贝

define 和 inline 的区别

define:
定义预编译时处理的宏,只是简单的字符串替换,没有类型检查,不安全。
inline:
inline是先将内联函数编译完成生成了函数体,直接插入被调用的地方,减少了压栈,跳转和返回的操作。没有普通函数调用时的额外开销;
内联函数是一种特殊的函数,会进行类型检查;
对编译器的一种请求,编译器有可能拒绝这种请求;

C++中inline编译限制:

  1. 不能存在任何形式的循环语句
  2. 不能存在过多的条件判断语句
  3. 函数体不能过于庞大
  4. 内联函数声明必须在调用语句之前

const和define的区别

const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:

  1. const生效于编译的阶段;define生效于预处理阶段。
  2. const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接地操作数,并不会存放在内存中。
  3. const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。

constexpr

const 表示“只读”的语义,constexpr 表示“常量”的语义
constexpr 只能定义编译期常量,而const 可以定义编译期常量,也可以定义运行期常量。
你将一个成员函数标记为constexpr,则顺带也将它标记为了const。如果你将⼀个变量标记为constexpr,则同样它是const的。但相反并不成立,一个const的变量或函数,并不是constexpr的。

constexpr变量
复杂系统中很难分辨一个初始值是不是常量表达式,可以将变量声明为constexpr类型,由编译器来验证变量的值是否是一个常量表达式。
必须使用常量初始化:

constexpr int n = 20; constexpr int m = n + 1; static constexpr int MOD = 1000000007; 

如果constexpr声明中定义了一个指针,constexpr仅对指针有效,和所指对象无关。

constexpr int *p = nullptr; //常量指针 顶层const const int *q = nullptr; //指向常量的指针, 底层const int *const q = nullptr; //顶层const 

constexpr函数:
constexpr函数是指能用于常量表达式的函数。
函数的返回类型和所有形参类型都是字面值类型,函数体有且只有一条return语句。

constexpr int new() {return 42;} 

为了可以在编译过程展开,constexpr函数被隐式转换成了内联函数。
constexpr和内联函数可以在程序中多次定义,一般定义在头文件。

constexpr 构造函数:
构造函数不能说const,但字面值常量类的构造函数可以是constexpr。
constexpr构造函数必须有一个空的函数体,即所有成员变量的初始化都放到初始化列表中。对象调用的成员函数必须使用 constexpr 修饰

constexpr的好处

  1. 为一些不能修改数据提供保障,写成变量则就有被意外修改的风险。
  2. 有些场景,编译器可以在编译期对constexpr的代码进行优化,提高效率。
  3. 相比宏来说,没有额外的开销,但更安全可靠。

volatile

volatile是与const绝对对立的类型修饰符
影响编译器编译的结果,用该关键字声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化;会从内存中重新装载内容,而不是直接从寄存器拷贝内容。
作用:
指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值,保证对特殊地址的稳定访问
使用场合:
在中断服务程序和cpu相关寄存器的定义
举例说明:
空循环:

for(volatile int i=0; i<100000; i++); // 它会执⾏,不会被优化掉  

extern

定义:声明外部变量(在函数或者文件外部定义的全局变量)

前置++与后置++

self &operator++() { //前置++  node = (linktype)((node).next);  return *this; } const self operator++(int) { //后置++  self tmp = *this;  ++*this;  return tmp; } 

为了区分前后置,重载函数是以参数类型来区分,在调用的时候,编译器默默给int指定为⼀个0

  1. 为什么后置返回对象,而不是引用
    因为后置为了返回旧值创建了⼀个临时对象,在函数结束的时候这个对象就会被销毁,如果返回引用,那么我请问你?你的对象对象都被销毁了,你引用啥呢?
  2. 为什么后置前面也要加const
    其实也可以不加,但是为了防止你使⽤i++++,连续两次的调用后置++重载符,为什么呢?
    原因:
    它与内置类型行为不一致;你无法获得你所期望的结果,因为第一次返回的是旧值,而不是原对象,你调用两次后置++,结果只累加了一次,所以我们必须手动禁止其合法化,就要在前面加上const。
  3. 处理用户的自定义类型
    最好使用前置++,因为他不会创建临时对象,进而不会带来构造和析构而造成的格外开销。

std::atomic

问题:a++ 和 int a = b 在C++中是否是线程安全的?
答案:不是!

a++:
从C/C++语法的级别来看,这是一条语句,应该是原子的;但从编译器得到的汇编指令来看,其实不是原子的。
其一般对应三条指令,首先将变量a对应的内存值搬运到某个寄存器(如eax)中,然后将该寄存器中的值自增1,再将该寄存器中的值搬运回a代表的内存中

mov eax, dword ptr [a] # (1) inc eax # (2) mov dword ptr [a], eax # (3) 

现在假设a的值是0,有两个线程,每个线程对变量a的值都递增1,预想⼀下,其结果应该是2,可实际运行结构可能是1!是不是很奇怪?

int a = 0; // 线程1(执⾏过程对应上⽂汇编指令(1)(2)(3)) void thread_func1()  {a++;} // 线程2(执⾏过程对应上⽂汇编指令(4)(5)(6)) void thread_func2() {a++;} 

我们预想的结果是线程1和线程2的三条指令各自执行,最终a的值变为2,但是由于操作系统线程调度的不确定性,线程1执行完指令(1)和(2)后,eax寄存器中的值变为1,此时操作系统切换到线程2执行,执行指令(3)(4)(5),此时eax的值变为1;接着操作系统切回线程1继续执⾏,执行指令(6),得到a的最终结果1。

int a = b
从C/C++语法的级别来看,这是条语句应该是原子的;但从编译器得到的汇编指令来看,由于现在计算机CPU架构体系的限制,数据不能直接从内存某处搬运到内存另外一处,必须借助寄存器中转,因此这条语句一般对应两条计算机指令,即将变量b的值搬运到某个寄存器(如eax)中,再从该寄存器搬运到变量a的内存地址
中:

mov eax, dword ptr [b] mov dword prt [a], eax 

既然是两条指令,那么多个线程在执行这两条指令时,某个线程可能会在第一条指令执行完毕后被剥夺CPU时间片,切换到另一个线程而出现不确定的情况。

解决办法
C++11新标准发布后改变了这种困境,新标准提供了对整形变量原子操作的相关库,即std::atomic,这是⼀个模板类型:

template<class T> struct atomic: 

我们可以传⼊具体的整型类型对模板进行实例化,实际上stl库也提供了这些实例化的模板类型

// 初始化1 std::atomic<int> value; value = 99;  // 初始化2 // 下⾯代码在Linux平台上无法编译通过(指在gcc编译器) std::atomic<int> value = 99; // 出错的原因是这⾏代码调⽤的是std::atomic的拷贝构造函数 // ⽽根据C++11语⾔规范,std::atomic的拷贝构造函数使⽤=delete标记禁止编译器⾃动⽣成 // g++在这条规则上遵循了C++11语言规范。 

4. struct和class的区别

  • 通常, struct 用于表示一组相关的数据,而 class 用于表示一个封装了数据和操作的对象。
  • 在实际使用中,可以根据具体的需求选择使用 struct 或 class 。如果只是用来组织一些数据,而不涉及复杂的封装和继承关系, struct 可能更直观;如果需要进行封装、继承等面向对象编程的特性,可以选择使用 class 。
  • struct结构体中的成员默认是公有的(public)。类中的成员默认是私有的(private)。
  • struct 继承时默认使用公有继承。class 继承时默认使用私有继承。
  • 如果结构体没有定义任何构造函数,编译器会生成默认的无参数构造函数。如果类没有定义任何构造函数,编译器也会生成默认的无参数构造函数。

5. 静态局部变量\全局变量\局部变量

  • 静态局部变量
    作用域: 限定在定义它的函数内。
    生命周期: 与程序的生命周期相同,但只能在定义它的函数内部访问。
    关键字: 使用 static 关键字修饰。
    初始化: 仅在第一次调用函数时初始化,之后保持其值。
    当希望在函数调用之间保留变量的值,并且不希望其他函数访问这个变量时,可以使用静态局部变量
void exampleFunction() {  static int count = 0; // 静态局部变ᰁ  count++;  cout << "Count: " << count << endl; } 
  • 全局变量
    作用域: 整个程序。
    生命周期: 与程序的生命周期相同。
    关键字: 定义在全局作用域,不使用特定关键字。
    当多个函数需要共享相同的数据时,可以使用全局变量。
int globalVar = 10; // 全局变量 void function1() {  globalVar++; } void function2() {  globalVar--; } 
  • 局部变量
    作用域: 限定在定义它的块(大括号内)。
    生命周期: 在块结束时销毁。
    关键字: 定义在函数、语句块或类的成员函数中。
    当变量只在某个特定作用域内有效,并且不需要其他作用域访问时,可以使用局部变量。
  • 总结
    静态局部变量用于在函数调用之间保留变量的值。
    全局变量适用于多个函数需要共享的数据。
    局部变量适用于仅在特定作用域内有效的情况。

二、C++内存管理

1. C++内存分区

五个区域

C++程序运行时,内存被分为几个不同的区域,每个区域负责不同的任务。
在这里插入图片描述

  1. 栈:用于存储函数的局部变量、函数参数和函数调用信息的区域。函数的调用和返回通过栈来管理。
  2. 堆:用于存储动态分配的内存的区域,由程序员手动分配和释放。使用 new 和 delete 或 malloc 和 free 来进行堆内存的分配和释放。
  3. 全局/静态区:存储全局变量和静态变量。生命周期是整个程序运行期间。在程序启动时分配,程序结束时释放。
  4. 常量区:也称为只读区。存储常量数据,如字符串常量。
  5. 代码区:存储程序的代码。

堆和栈的区别

栈和堆都是用于存储程序数据的内存区域。
栈是一种有限的内存区域,用于存储局部变量、函数调用信息等。堆是一种动态分配的内存区域,用于存储程序运行时动态分配的数据。
栈上的变量生命周期与其所在函数的执行周期相同,而堆上的变量生命周期由程序员显式控制,可以(使用 new 或 malloc )和释放(使用 delete 或 free )。
栈上的内存分配和释放是自动的,速度较快。而堆上的内存分配和释放需要手动操作,速度相对较慢。

2. 内存泄漏

  • 什么是内存泄露
    内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
    可以使用Valgrind, mtrace进行内存泄漏检查。
  • 内存泄漏的分类
    • 堆内存泄漏 (Heap leak)
      堆内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者 delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生 Heap Leak.
    • 系统资源泄露(Resource Leak)
      主要指程序使用系统分配的资源比如 Bitmap,handle,SOCKET 等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
    • 没有将基类的析构函数定义为虚函数
      当基类指针指向子类对象时,如果基类的析构函数不是 virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。
  • 什么操作会导致内存泄露?
    指针指向改变,未释放动态分配内存。
  • 如何防止内存泄露?
    将内存的分配封装在类中,构造函数分配内存,析构函数释放内存;使用智能指针
  • 智能指针有了解哪些?
    智能指针是为了解决动态分配内存导致内存泄露和多次释放同一内存所提出的,C11标准中放在< memory>头文件。包括:共享指针,独占指针,弱指针
  • 构造函数,析构函数要设为虚函数吗,为什么?
    • 析构函数
      析构函数需要。当派生类对象中有内存需要回收时,如果析构函数不是虚函数,不会触发动态绑定,只会调用基类析构函数,导致派生类资源无法释放,造成内存泄漏。
    • 构造函数
      构造函数不需要,没有意义。虚函数调用是在部分信息下完成工作的机制,允许我们只知道接口而不知道对象的确切类型。
      要创建⼀个对象,你需要知道对象的完整信息。 特别是,你需要知道你想要创建的确切类型。 因此,构造函数不应该被定义为虚函数。

3. 智能指针

智能指针用于管理动态内存的对象,其主要目的是在避免内存泄漏和方便资源管理。

  1. std::unique_ptr 独占智能指针
    std::unique_ptr 提供对动态分配的单一对象所有权的独占管理。通过独占所有权,确保只有⼀个std::unique_ptr 可以拥有指定的内存资源。移动语义和右值引用允许 std::unique_ptr 在所有权转移时高效地进行转移。
#include <memory> std::unique_ptr<int> ptr = std::make_unique<int>(42); 
  1. std::shared_ptr (共享智能指针):
    std::shared_ptr 允许多个智能指针共享同一块内存资源。内部使用引用计数来跟踪对象被共享的次数,当计数为零时,资源被释放。提供更灵活的内存共享,但可能存在循环引用的问题。
#include <memory> std::shared_ptr<int> ptr1 = std::make_shared<int>(42); std::shared_ptr<int> ptr2 = ptr1; 
  1. std::weak_ptr (弱引用智能指针):
    std::weak_ptr 用于解决 std::shared_ptr 可能导致的循环引用问题。
    std::weak_ptr 可以从 std::shared_ptr 创建,但不会增加引用计数,不会影响资源的释放。通过 std::weak_ptr::lock() 可以获取一个 std::shared_ptr 来访问资源。
#include <memory> std::shared_ptr<int> sharedPtr = std::make_shared<int>(42); std::weak_ptr<int> weakPtr = sharedPtr; 

4. new / delete 和 malloc / free 的区别

1、new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。
2、使用new操作符申请内存分配时无须指定内存块的大小,而malloc则需要显式地指出所需内存的尺⼨。
3、opeartor new /operator delete可以被重载,而malloc/free并不允许重载。
4、new/delete会调用对象的构造函数/析构函数以完成对象的构造/析构。而malloc则不会,只是分配和释放内存块
5、malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符
6、new操作符从自由存储区上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。
7、delete 释放的内存块的指针值会被设置为 nullptr ,以避免野指针。free 不会修改指针的值,可能导致野指针问题。
8、delete 可以正确释放通过 new[] 分配的数组。free 不了解数组的大小,不适用于释放通过 malloc 分配的数组。
在这里插入图片描述

5. 野指针

野指针是指指向已被释放的或无效的内存地址的指针。使⽤野指针可能导致程序崩溃、数据损坏或其他不可预测的行为。

野指针产生原因

  1. 释放后没有置空指针
int* ptr = new int; delete ptr; // 此时 ptr 成为野指针,因为它仍然指向已经被释放的内存 ptr = nullptr; // 避免野指针,应该将指针置为 nullptr 或赋予新的有效地址 
  1. 返回局部变量的指针
int* createInt() {  int x = 10;  return &x; // x 是局部变量,函数结束后 x 被销毁,返回的指针成为野指针 } // 在使用返回值时可能引发未定义⾏为 
  1. 函数参数指针被释放
void foo(int* ptr) {  // 操作 ptr  delete ptr; } int main() {  int* ptr = new int;  foo(ptr);  // 在 foo 函数中 ptr 被释放,但在 main 函数中仍然可⽤,成为野指针  // 避免:在 foo 函数中不要释放调用方传递的指针 } 

野指针避免措施

  1. 在释放内存后将指针置为 nullptr
  2. 避免返回局部变量的指针
  3. 使用智能指针(如 std::unique_ptr 和 std::shared_ptr )
  4. 注意函数参数的生命周期, 避免在函数内释放调用方传递的指针,或者通过引用传递指针。

野指针和悬浮指针的区别

野指针是指向已经被释放或者无效的内存地址的指针。通常由于指针指向的内存被释放,但指针本身没有被置为nullptr 或者重新分配有效的内存,导致指针仍然包含之前的内存地址。使⽤野指针进行访问会导致未定义行为,可能引发程序崩溃、数据损坏等问题。
悬浮指针是指向已经被销毁的对象的引用。当函数返回⼀个局部变量的引用,而调用者使用该引用时,就可能产生悬浮引用。访问悬浮引⽤会导致未定义行为,因为引用指向的对象已经被销毁,数据不再有效。

区别

  • 关联对象类型:
    野指针涉及指针类型。
    悬浮指针涉及引用类型。
  • 问题表现:
    野指针可能导致访问已释放或无效内存,引发崩溃或数据损坏。
    悬浮指针可能导致访问已销毁的对象,引发未定义行为。
  • 产生原因:
    野指针通常由于不正确管理指针生命周期引起。
    悬浮指针通常由于在函数中返回局部变量的引用引起。

如何避免悬浮指针
避免在函数中返回局部变量的引用。
使用返回指针或智能指针而不是引用,如果需要在函数之外使用函数内部创建的对象。

6. 内存对齐

什么是内存对齐

内存对齐是指数据在内存中的存储起始地址是某个值的倍数。
在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是⼀些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。
为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,⽐如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果⼀个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
比如在32位cpu下,假设⼀个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

为什么需要考虑内存对齐

需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的⼀个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。
而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果⼀个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。
大多数计算机硬件要求基本数据类型的变量在内存中的地址是它们大小的倍数。例如,⼀个 32 位整数通常需要在内存中对齐到 4 字节边界。
内存对齐可以提高访问内存的速度。当数据按照硬件要求的对齐方式存储时,CPU可以更高效地访问内存,减少因为不对齐而引起的性能损失。
许多计算机体系结构使用缓存行(cache line)来从内存中加载数据到缓存中。如果数据是对齐的,那么一个缓存行可以装载更多的数据,提高缓存的命中率。
有些计算机架构要求原子性操作(比如原子性读写)必须在特定的内存地址上执行。如果数据不对齐,可能导致无法执行原子性操作,进而引发竞态条件。

7. 计算机中的乱序执行

  1. ⼀定会按正常顺序执行的情况:
    (1)对同⼀块内存进行访问,此时访问的顺序不会被编译器修改
    (2)新定义的变量的值依赖于之前定义的变量,此时两个变量定义的顺序不会被编译器修改
  2. 其他情况计算机会进行乱序执行
    单线程的情况下允许,但是多线程情况下就会产生问题
  3. C++中的库中提供了六种内存模型
    用于在多线程的情况下防止编译器的乱序执行
    • memory_order_relaxed:最放松的
    • memory_order_consume:当客户使用,搭配release使用,被release进行赋值的变量y,获取的时候如果写成consume,那么所有与y有关的变量的赋值⼀定会被按顺序进行
    • memory_order_acquire:用于获取资源
    • memory_order_release:⼀般用于生产者,当给⼀个变量y进行赋值的时候,只有自己将这个变量释放了,别人才可以去读,读的时候如果使用acquire来读,编译器会保证在y之前被赋值的变量的赋值都在y之前被执行,相当于设置了内存屏障
    • memory_order_acq_rel(acquire/release)
    • memory_order_seq_cst(squentially consistent) 好处:不需要编译器设置内存屏障,morden c++开始就会有底层汇编的能力

副作用

  1. 无副作用编程
    存在一个函数,传一个参数x进去,里面进行一系列的运算,返回一个y。中间的所有过程都是在栈中进行修改
  2. 有副作用编程
    比如在一个函数运行的过程中对全局变量进行了修改或在屏幕上输出了一些东西。此函数还有可能是类的成员方法,在此方法中如果对成员变量进行了修改,类的状态就会发生改变
  3. 在多线程情况下的有副作用编程
    在线程1运行的时候对成员变量进行了修改,此时如果再继续运行线程2,此时线程2拥有的就不是这个类的初始状态,运行出来的结果会收到线程1的影响。 解决办法:将成员方法设为const,此时就可以放心进行调用

信号量

  1. binary_semaphore
    定义:可以当事件来用,只有有信号和无信号两种状态,一次只能被一个线程所持有。
    使用步骤:
    (1)初始创建信号量,并且一开始将其置位成无信号状态 std::binary_semaphore sem(0)
    (2)线程使用acquire()方法等待被唤醒
    (3)主线程中使用release()方法,将信号量变成有信号状态
  2. counting_semaphore
    定义:一次可以被很多线程所持有,线程的数量由自己指定
    使用步骤:
    (1)创建信号量:指定一次可以进入的线程的最大数量,并在最开始将其置位成无信号状态:std::biinary_semaphore<8> sem(0);
    (2)主线程中创建10个线程:并且这些线程全部调用acquire()方法等待被唤醒。但是主线程使用release(6)方法就只能随机启用6个线程。

future库

用于任务链(即任务A的执行必须依赖于任务B的返回值)

  1. 例子:生产者消费者问题
    (1)子线程作为消费者,参数是一个future,用这个future等待一个int型的产品:std::future& fut
    (2)子线程中使用get()方法等待一个未来的future,返回一个result
    (3)主线程作为生产者,做出一个承诺:std::promise prom
    (4)用此承诺中的get_future()方法获取一个future
    (5)主线程中将子线程创建出来,并将刚刚获取到的future作为参数传入
    (6)主线程做一系列的生产工作,最后生产完后使用承诺中的set_value()方法,参数为刚刚生产出的产品
    (7)此时产品就会被传到子线程中,子线程就可以使用此产品做一系列动作
    (8)最后使用join()方法等待子线程停止,但是join只适用于等待没有返回值的线程的情况
  2. 如果线程有返回值
    (1)使⽤async方法可以进行异步执行
    参数一: 可以选择是马上执行还是等一会执行(即当消费者线程调用get()方法时才开始执行)
    参数二: 执行的内容(可以放一个函数对象或lambda表达式)
    (2)生产者使用async方法做生产工作并返回一个future
    (3)消费者使用future中的get()方法可以获取产品

8. 字符串操作函数

常见的字符串函数实现

  1. strcpy()
    把从strsrc地址开始且含有’\0’结束符的字符串复制到以strdest开始的地址空间,返回值的类型为char*
    在这里插入图片描述
  2. strlen()
    计算给定字符串的⻓度。
    在这里插入图片描述
  3. strcat()
    作用是把src所指字符串添加到dest结尾处。
    在这里插入图片描述
  4. strcmp()
    比较两个字符串设这两个字符串为str1,str2,
    若str1 == str2,则返回零
    若str1 < str2,则返回负数
    若str1 > str2,则返回正数
    在这里插入图片描述

9. 测试题目

以下为WindowsNT 32位C++程序,请计算下面sizeof的值

char str[] = "hello"; char* p = str; int n = 10; // 请计算 sizeof(str) = ? sizeof(p) = ? sizeof(n) = ? void Func(char str[100]){  // 请计算  sizeof(str) = ?  } void* p = malloc(100); // 请计算 sizeof(p) = ? 

参考答案:

sizeof(str) = 6; 

sizeof()计算的是数组的所占内存的大小包括末尾的 ‘\0’

sizeof(p) = 4; 

p为指针变量,32位系统下大小为 4 bytes

sizeof(n) = 4; 

n 是整型变量,占用内存空间4个字节

void Func(char str[100]){ sizeof(str) = 4; } 

函数的参数为字符数组名,即数组首元素的地址,大小为指针的大小

void* p = malloc(100); sizeof(p) = 4; 

p指向malloc分配的大小为100 byte的内存的起始地址,sizeof§为指针的大小,而不是它指向内存的大小

分析运行下面的Test函数会有什么样的结果

void GetMemory1(char* p){  p = (char*)malloc(100); } void Test1(void){  char* str = NULL;  GetMemory1(str);  strcpy(str, "hello world");  printf(str); } char *GetMemory2(void){  char p[] = "hello world";  return p; } void Test2(void){  char *str = NULL;  str = GetMemory2();  printf(str); } void GetMemory3(char** p, int num){  *p = (char*)malloc(num); } void Test3(void){  char* str = NULL;  GetMemory3(&str, 100);  strcpy(str, "hello");  printf(str); } void Test4(void){  char *str = (char*)malloc(100);  strcpy(str, "hello");  free(str);  if(str != NULL) {  strcpy(str, "world");  cout << str << endl;  } } 

参考答案:
Test1(void):
程序崩溃。 因为GetMemory1并不能传递动态内存,Test1函数中的 str一直都是NULL。strcpy(str, “hello world”)将使程序奔溃
Test2(void):
可能是乱码。 因为GetMemory2返回的是指向“栈内存”的指针,该指针的地址不是NULL,使其原现的内容已经被清除,新内容不可知。
Test3(void):
能够输出hello, 内存泄露。GetMemory3申请的内存没有释放
Test4(void):
篡改动态内存区的内容,后果难以预料。非常危险。因为 free(str);之后,str成为野指针,if(str != NULL)语句不起作用。

实现内存拷贝函数

char* strcpy(char* strDest, const char* strSrc); 

参考答案:(函数实现)

char* strcpy(char *dst,const char *src) {// [1]  assert(dst != NULL && src != NULL); // [2]  char *ret = dst; // [3]  while ((*dst++=*src++)!='\0'); // [4]  return ret; } 
  1. const修饰:
    源字符串参数用const修饰,防止修改源字符串。
  2. 空指针检查:
    (1)不检查指针的有效性,说明答题者不注重代码的健壮性。
    (2)检查指针的有效性时使用 assert(!dst && !src):char *转换为 bool 即是类型隐式转换,这种功能虽然灵活,但更多的是导致出错概率增大和维护成本升高。
    (3)检查指针的有效性时使用 assert(dst != 0 && src != 0):直接使用常量(如本例中的0)会减少程序的可维护性。而使用NULL代替0,如果出现拼写错误,编译器就会检查出来。
  3. 返回目标地址:
    忘记保存原始的strdst值。
  4. ‘\0’:
    (1)循环写成 while (*dst++=*src++); 明显是错误的。
    (2)循环写成 while (*src!=‘\0’) *dst++ = *src++:循环体结束后,dst字符串的末尾没有正确地加上’\0’。
    (3)为什么要返回char *? :返回dst的原始值使函数能够支持链式表达式 ,链式表达式的形式如: int l=strlen(strcpy(strA,strB)); char * strA=strcpy(new char[10],strB);
  5. 返回strSrc的原始值是错误的。理由:
    (1)源字符串肯定是已知的,返回它没有意义
    (2) 不能支持形如第二例的表达式
    (3)把 const char *作为char * 返回,类型不符,编译报错

假如考虑dst和src内存重叠的情况,strcpy该怎么实现

char s[10]="hello"; strcpy(s, s+1); // 应返回 ello strcpy(s+1, s); // 应返回 hhello 但实际会报错 // 因为dst与src重叠了,把'\0'覆盖了 

所谓重叠,就是src未处理的部分已经被dst给覆盖了,只有一种情况: src<=dst<=src+strlen(src)
C函数 memcpy 自带内存重叠检测功能,下面给出 memcpy 的实现my_memcpy

char * strcpy(char *dst,const char *src) {  assert(dst != NULL && src != NULL);  char *ret = dst;  my_memcpy(dst, src, strlen(src)+1);  return ret; } /* my_memcpy的实现如下 */ char *my_memcpy(char *dst, const char* src, int cnt) {  assert(dst != NULL && src != NULL);  char *ret = dst;  /*内存重叠,从⾼地址开始复制*/  if (dst >= src && dst <= src+cnt-1){   dst = dst+cnt-1;   src = src+cnt-1;   while (cnt--){   *dst-- = *src--;   }  }  else {//正常情况,从低地址开始复制   while (cnt--){   *dst++ = *src++;   }  }  return ret; } 

按照下面描述的要求写程序

已知String的原型为:

class String { public:  String(const char *str = NULL);  String(const String &other);  ~ String(void);  String & operate =(const String &other); private:  char *m_data; }; 

请编写上述四个函数
参考答案:
此题考察对构造函数赋值运算符实现的理解。实际考察类内含有指针的构造函数赋值运算符函数写法。

// 构造函数 String::String(const char *str) {  if(str==NULL){   m_data = new char[1]; //对空字符串自动申请存放结束标志'\0'   *m_data = '\0';  }   else{   int length = strlen(str);   m_data = new char[length + 1];   strcpy(m_data, str);  } } // 析构函数 String::~String(void) {  delete [] m_data; // 或delete m_data; } //拷⻉构造函数 String::String(const String &other) {  int length = strlen(other.m_data);  m_data = new char[length + 1];  strcpy(m_data, other.m_data); } //赋值函数 String &String::operate =(const String &other) {   if(this == &other){   return *this; // 检查自赋值  }   delete []m_data; // 释放原有的内存资源  int length = strlen(other.m_data);  m_data = new char[length + 1]; //对m_data加NULL判断  strcpy(m_data, other.m_data);   return *this; //返回本对象的引⽤ } 

说一说进程的地址空间分布

参考答案:
对于一个进程,其空间分布如下图所示:
在这里插入图片描述
如上图,从高地址到低地址,一个程序由命令行参数和环境变量、栈、文件映射区、堆、BSS段、数据段、代码段组成。

  1. 命令行参数和环境变量:命令行参数是指从命令行执行程序的时候,给程序的参数。
  2. 栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。
  3. 文件映射区:位于堆和栈之间。
  4. 堆区:动态申请内存用。堆从低地址向高地址增长。
  5. BSS 段:存放程序中未初始化的 全局变量和静态变量 的一块内存区域。
  6. 数据段:存放程序中已初始化的 全局变量和静态变量 的一块内存区域。
  7. 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

说⼀说C与C++的内存分配方式

  1. 从静态存储区域分配
    内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在,如全局变量,static变量。
  2. 在栈上创建
    在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
  3. 从堆上分配(动态内存分配)
    程序在运行的时候用malloc或new申请任意多少的内存,程序员负责在何时用free或delete释放内存。动态内存的生存期自己决定,使⽤非常灵活。

new[]和delete

参考答案:
如果是带有自定义析构函数的类类型,用new[]来创建类对象数组,而用delete来释放会发生什么?用例子来说明:

class A {}; A* pAa = new A[3]; delete pAa; 

那么 delete pAa; 做了两件事:

  1. 调用一次 pAa 指向的对象的析构函数
  2. 调用 operator delete(pAa);释放内存
    显然,这里只对数组的第一个类对象调用了析构函数,后面的两个对象均没调用析构函数,如果类对象中申请了大量的内存需要在析构函数中释放,而你却在销毁数组对象时少调用了析构函数,这会造成内存泄漏。
    上面的问题你如果说没关系的话,那么第二点就是致命的了!直接释放pAa指向的内存空间,这个总是会造成严重的段错误,程序必然会崩溃!因为分配的空间的起始地址是 pAa 指向的地方减去 4 个字节的地方。你应该传入参数设为那个地址!

三、C++ 面向对象

1. 面向对象的三大特性

访问权限

C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。
在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。
在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。
无论公有继承、私有和保护继承,私有成员不能被“派生类”访问,基类中的共有和保护成员能被“派生类”访问。
对于共有继承,只有基类中的共有成员能被“派生类对象”访问,保护和私有成员不能被“派生类对象”访问。对于私有和保护继承,基类中的所有成员不能被“派生类对象”访问。

1. 继承

定义:让某种类型对象获得另一个类型对象的属性和方法
功能:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展
常见的继承有三种方式:
1、实现继承:指使用基类的属性和方法而无需额外编码的能力
2、接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
3、可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力
例如:
将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法。

2. 封装

定义:数据和代码捆绑在⼀起,避免外界干扰和不确定性访问;
功能:把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏,例如:将公共的数据或方法使用public修饰,而不希望被访问的数据或方法采用private修饰。

3. 多态

定义:同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)
功能:多态性是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作; 简单一句话:允许将子类类型的指针赋值给父类类型的指针。
实现多态有两种方式

  1. 覆盖(override): 是指子类重新定义父类的虚函数的做法
  2. 重载(overload): 是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)
    例如:基类是一个抽象对象——人,那学生、运动员也是人,而使用这个抽象对象既可以表示学生、也可以表示运动员。

2. 访问修饰符

C++提供了三个访问修饰符: public 、 private 和 protected 。这些修饰符决定了类中的成员对外部代码的可见性和访问权限。
public 修饰符用于指定类中的成员可以被类的外部代码访问。公有成员可以被类外部的任何代码(包括类的实例)访问。
private 修饰符用于指定类中的成员只能被类的内部代码访问。私有成员对外部代码是不可见的,只有类内部的成员函数可以访问私有成员。
protected 修饰符用于指定类中的成员可以被类的派生类访问。受保护成员对外部代码是不可见的,但可以在派生类中被访问。

3. 什么是多重继承?

一个类可以从多个基类(父类)继承属性和行为。在C++等支持多重继承的语言中,一个派生类可以同时拥有多个基类。
多重继承可能引入一些问题,如菱形继承问题, 比如当一个类同时继承了两个拥有相同基类的类,而最终的派生类又同时继承了这两个类时, 可能导致二义性和代码设计上的复杂性。为了解决这些问题,C++ 提供了虚继承, 通过在继承声明中使用 virtual 关键字,可以避免在派生类中生成多个基类的实例,从而解决了菱形继承带来的二义性。
在这里插入图片描述

#include <iostream> class Animal { public:  void eat() {  	std::cout << "Animal is eating." << std::endl;  } }; class Mammal : public Animal { public:  void breathe() {  	std::cout << "Mammal is breathing." << std::endl;  } }; class Bird : public Animal { public:  void fly() {  	std::cout << "Bird is flying." << std::endl;  } }; // 菱形继承,同时从 Mammal 和 Bird 继承 class Bat : public Mammal, public Bird { public:  void navigate() {  // 这⾥可能会引起⼆义性,因为 Bat 继承了两个 Animal  // navigate ⽅法中尝试调⽤ eat ⽅法,但不明确应该调⽤ Animal 的哪⼀个实现  	eat();  } }; int main() {  Bat bat;  bat.navigate();  return 0; } 

虚继承:

#include <iostream> class Animal { public:  void eat() {  	std::cout << "Animal is eating." << std::endl;  } }; class Mammal : virtual public Animal { public:  void breathe() {  	std::cout << "Mammal is breathing." << std::endl;  } }; class Bird : virtual public Animal { public:  void fly() {  	std::cout << "Bird is flying." << std::endl;  } }; class Bat : public Mammal, public Bird { public:  void navigate() {  // 不再存在二义性,eat ⽅法来自于共享的 Animal 基类  	eat();  } }; int main() {  Bat bat;  bat.navigate();  return 0; } 

简述一下 C++ 的重载和重写,以及它们的区别

  • 重载是指在同一作用域内,使用相同的函数名但具有不同的参数列表或类型,使得同⼀个函数名可以有多个版本。
    overload是重载,这些方法的名称相同而参数形式不同;一个方法有不同的版本,存在于一个类中。
    规则:
    1. 不能通过访问权限、返回类型、抛出的异常进行重载
    2. 不同的参数类型可以是不同的参数类型,不同的参数个数,不同的参数顺序(参数类型必须不⼀样)
    3. 方法的异常类型和数⽬不会对重载造成影响
int add(int a, int b) {  return a + b; } double add(double a, double b) {  return a + b; } 
  • 重写是指派生类(子类)重新实现(覆盖)基类(父类)中的虚函数,以提供特定于派生类的实现。重写是面向对象编程中的多态性的一种体现,主要涉及基类和派生类之间的关系,用于实现运行时多态。
    override是重写(覆盖)了一个方法,以实现不同的功能,⼀般是用于子类在继承父类时,重写父类方法。
    规则:
    1. 重写方法的参数列表,返回值,所抛出的异常与被重写方法⼀致
    2. 被重写的方法不能为private
    3. 静态方法不能被重写为非静态的方法
    4. 重写方法的访问修饰符⼀定要大于被重写方法的访问修饰符(public>protected>default>private)
class Base { public:  virtual void print() {  cout << "Base class" << endl;  } }; class Derived : public Base { public:  void print() override {  cout << "Derived class" << endl;  } }; 

使⽤多态是为了避免在父类里大量重载引起代码臃肿且难于维护。
重写与重载的本质区别是,加入了override的修饰符的方法,此方法始终只有一个被你使用的方法。

4. c++的多态如何实现

C++中的多态性是通过虚函数(virtual function)和虚函数表(vtable)来实现的。多态性允许在基类类型的指针或引用上调用派生类对象的函数,以便在运行时选择正确的函数实现。

  1. 基类声明虚函数:在基类中声明虚函数,使用 virtual 关键字,以便派生类可以重写(override)这些函数。
class Shape { public:  virtual void draw() const {  // 基类的默认实现  } }; 
  1. 派生类重写虚函数:在派生类中重写基类中声明的虚函数,使用 override 关键字
class Circle : public Shape { public:  void draw() const override {  // 派⽣类的实现  } }; 
  1. 使用基类类型的指针或引用指向派生类对象。
Shape* shapePtr = new Circle(); 
  1. 调用虚函数:通过基类指针或引用调用虚函数。在运行时,系统会根据对象的实际类型来选择调用正确的函数实现。
shapePtr->draw(); // 调⽤的是 Circle 类的 draw() 函数 
  1. 虚函数表:编译器在对象的内存布局中维护了一个虚函数表,其中存储了指向实际函数的指针。这个表在运行时用于动态查找调用的函数。

5. 成员函数/成员变量/静态成员函数/静态成员变量

  1. 成员函数
    • 成员函数是属于类的函数,它们可以访问类的成员变量和其他成员函数。
    • 成员函数可以分为普通成员函数和静态成员函数。
    • 普通成员函数使用对象调用,可以访问对象的成员变量。
    • 普通成员函数的声明和定义通常在类的内部,但定义时需要使用类名作为限定符。
  2. 成员变量
    • 成员变量是属于类的变量,存储在类的每个对象中。
    • 每个对象拥有一份成员变量的副本,它们在对象创建时分配,并在对象销毁时释放。
    • 成员变量的访问权限可以是 public 、 private 或 protected 。
class MyClass { public:  int memberVariable; // 成员变量的声明  void memberFunction() {  // 成员函数的实现  } }; 
  1. 静态成员函数
    • 静态成员函数属于类而不是对象,因此可以直接通过类名调用,而不需要创建类的实例。
    • 静态成员函数不能直接访问普通成员变量,因为它们没有隐含的 this 指针。
    • 静态成员函数的声明和定义也通常在类的内部,但在定义时需要使用类名作为限定符。
  2. 静态成员变量
    • 静态成员变量是属于类而不是对象的变量,它们在所有对象之间共享。
    • 静态成员变量通常在类的声明中进行声明,但在类的定义外进行定义和初始化。
    • 静态成员变量可以通过类名或对象访问。
class MyClass { public:  static int staticMemberVariable; // 静态成员变量的声明  static void staticMemberFunction() {  // 静态成员函数的实现  } }; int MyClass::staticMemberVariable = 0; // 静态成员变量的定义和初始化 

6. 构造函数和析构函数

定义

  1. 构造函数
    构造函数是在创建对象时自动调用的特殊成员函数。它的主要目的是初始化对象的成员变量,为对象分配资源,执行必要的初始化操作。构造函数的特点包括:
    • 函数名与类名相同: 构造函数的函数名必须与类名相同,且没有返回类型,包括 void。
    • 可以有多个构造函数:一个类可以有多个构造函数,它们可以根据参数的类型和数量不同而重载。
    • 默认构造函数: 如果没有为类定义任何构造函数,编译器会自动生成一个默认构造函数。默认构造函数没有参数,也可能执行一些默认的初始化操作。
  2. 析构函数
    析构函数是在对象生命周期结束时自动调用的特殊成员函数。它的主要目的是释放对象占用的资源、执行必要的清理操作。析构函数的特点包括:
    • 函数名与类名相同,前面加上波浪号 ~ : 析构函数的函数名为 ~ClassName ,其中 ClassName 是类名。
    • 没有参数: 析构函数没有参数,不能重载,每个类只能有一个析构函数。
    • 默认析构函数: 如果没有为类定义任何析构函数,编译器会自动生成一个默认析构函数,执行简单的清理操作。

C++构造函数种类

  1. 默认构造函数:没有任何参数的构造函数。如果用户没有为类定义构造函数,编译器会自动生成一个默认构造函数。默认构造函数用于创建对象时的初始化,当用户不提供初始化值时,编译器将调用默认构造函数。
class MyClass { public:  // 默认构造函数  MyClass() {  // 初始化操作  } }; 
  1. 带参数的构造函数:接受一个或多个参数,用于在创建对象时传递初始化值。可以定义多个带参数的构造函数,以支持不同的初始化方式。
class MyClass { public:  // 带参数的构造函数  MyClass(int value) {  // 根据参数进⾏初始化操作  } }; 
  1. 拷贝构造函数:用于通过已存在的对象创建一个新对象,新对象是原对象的副本。参数通常是对同类型对象的引用。
class MyClass { public:  // 拷⻉构造函数  MyClass(const MyClass &other) {  // 进⾏深拷贝或浅拷贝,根据实际情况  } }; 
  1. 委托构造函数:在一个构造函数中调用同类的另一个构造函数,减少代码重复。通过成员初始化列表或构造函数体内部调用其他构造函数。
class MyClass { public:  // 委托构造函数  MyClass() : MyClass(42) {  // 委托给带参数的构造函数  }  MyClass(int value) {  // 进⾏初始化操作  } }; 

7. 虚函数

虚函数

C++中的虚函数的作用主要是实现了多态的机制。虚函数允许在派生类中重新定义基类中定义的函数,使得通过基类指针或引用调用的函数在运行时根据实际对象类型来确定。这样的机制被称为动态绑定或运行时多态。
在基类中,通过在函数声明前面加上 virtual 关键字,可以将其声明为虚函数。派生类可以重新定义虚函数,如果派生类不重新定义,则会使用基类中的实现。

class Base { public:  virtual void virtualFunction() {  // 虚函数的实现  } }; class Derived : public Base { public:  void virtualFunction() override {  // 派⽣类中对虚函数的重新定义  } }; 

虚函数表

虚函数的实现通常依赖于一个被称为虚函数表(虚表)的数据结构。每个类(包括抽象类)都有一个虚表,其中包含了该类的虚函数的地址。每个对象都包含一个指向其类的虚表的指针,这个指针被称为虚指针(vptr)。
当调用一个虚函数时,编译器会使用对象的虚指针查找虚表,并通过虚表中的函数地址来执行相应的虚函数。这就是为什么在运行时可以根据实际对象类型来确定调用哪个函数的原因。

虚函数和纯虚函数的区别

  1. 虚函数
    有实现: 虚函数有函数声明和实现,即在基类中可以提供默认实现。
    可选实现: 派生类可以选择是否覆盖虚函数。如果派生类没有提供实现,将使用基类的默认实现。
    允许实例化: 虚函数的类可以被实例化。即你可以创建一个虚函数的类的对象。
    调用靠对象类型决定: 在运行时,根据对象的实际类型来决定调用哪个版本的虚函数。
    用 virtual 关键字声明: 虚函数使用virtual 关键字声明,但不包含 = 0 。
class Base { public:  // 虚函数有实现  virtual void virtualFunction() {  // 具体实现  } }; 
  1. 纯虚函数
    没有实现: 纯虚函数没有函数体,只有函数声明,即没有提供默认的实现。
    强制覆盖: 派生类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
    禁止实例化: 包含纯虚函数的类无法被实例化,只能用于派生其他类。
    用 = 0 声明: 纯虚函数使用 = 0 在函数声明末尾进行声明。
    为接口提供规范: 通过纯虚函数,抽象类提供一种接口规范,要求派生类提供相关实现。
class AbstractBase { public:  // 纯虚函数,没有具体实现  virtual void pureVirtualFunction() = 0;  // 普通成员函数可以有具体实现  void commonFunction() {  // 具体实现  } }; 

什么是抽象类和纯虚函数?

抽象类是不能被实例化的类,它存在的主要目的是为了提供一个接口,供派生类继承和实现。抽象类中可以包含普通的成员函数、数据成员和构造函数,但它必须包含至少一个纯虚函数。即在声明中使用 virtual 关键字并赋予函数一个 = 0 的纯虚函数。
纯虚函数是在抽象类中声明的虚函数,它没有具体的实现,只有函数的声明。通过在函数声明的末尾使用 = 0 ,可以将虚函数声明为纯虚函数。派生类必须实现抽象类中的纯虚函数,否则它们也会成为抽象类。

class AbstractShape { public:  // 纯虚函数,提供接⼝  virtual void draw() const = 0;  // 普通成员函数  void commonFunction() {  // 具体实现  } }; 

虚析构函数

虚析构函数是一个带有 virtual 关键字的析构函数。 主要作用是确保在通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而释放对象所占用的资源。
通常,如果一个类可能被继承,且在其派生类中有可能使用 delete 运算符来删除通过基类指针指向的对象,那么该基类的析构函数应该声明为虚析构函数。

class Base { public:  // 虚析构函数  virtual ~Base() {  // 基类析构函数的实现  } }; class Derived : public Base { public:  // 派⽣类析构函数,可以覆盖基类的虚析构函数  ~Derived() override {  // 派⽣类析构函数的实现  } }; 

为什么要虚析构,为什么不能虚构造

虚析构函数允许在运行时根据对象的实际类型调用正确的析构函数,从而实现多态性。
如果基类的析构函数不是虚的,当通过基类指针删除指向派生类对象的对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类的资源未被正确释放,造成内存泄漏。
构造函数在对象的创建阶段被调用,对象的类型在构造函数中已经确定。因此,构造函数调用不涉及多态性,也就是说,在对象的构造期间无法实现动态绑定。虚构造函数没有意义,因为对象的类型在构造过程中就已经确定,不需要动态地选择构造函数。

  1. 从存储空间角度:虚函数对应一个vtale,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable 中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
  2. 从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
  3. 从实际含义上看:在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有必要成为虚函数。
class Base { public:  // 错误!不能声明虚构造函数  virtual Base() {  // 虚构造函数的实现  }  virtual ~Base() {  // 基类析构函数的实现  } }; 

哪些函数不能被声明为虚函数?

常见的不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。

  1. 构造函数:
    构造函数在对象的创建期间调用,对象的类型在构造期间已经确定。因此,构造函数不能是虚函数,因为虚函数的动态绑定是在运行时实现的,而构造函数在对象还未创建完全时就会被调用。
  2. 普通函数
    普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。
  3. 静态成员函数
    静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,没有要动态绑定的必要性。并且没有this指针,无法访问vptr
  4. 友元函数
    因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。
  5. 内联成员函数
    内联函数就是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。并且inline函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数。

待续

 

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!