目录

基础知识

构造函数与析构函数

虚函数

继承

单例模式

重载、隐藏和重写(覆盖)

vector 扩容机制应注意的问题

STL 迭代器


前言

快秋招了,专门用一篇博客整理一下 C++ 的一些基础概念、语法和细节。一次整理不完,基本上是遇到什么问题就添加什么,会持续更新。

基础知识

1. 引用和指针的区别?

(1) 指针指向变量的地址,而引用是变量的别名。

(2) sizeof 引用得到的是所指向的变量(对象)的大小,而 sizeof 指针得到的是指针本身的大小。

(3) 引用在定义的时候必须进行初始化,并且不能改变,即不能变成另一个变量的引用。指针在定义的时候不一定要初始化,并且可以改变指向的地址。(引用不能为 NULL )

(4) 有多级指针,但是没有多级引用。

(5) 引用访问一个变量是直接访问,而指针访问一个变量是间接访问。

(6) 引用底层是通过指针实现的。

(7) 作为参数时不同,传指针的实质是传值,传递的值是指针的地址;传递引用的实质是传地址,传递的是变量的地址。

2. 指针参数传递和引用参数传递

(1) 指针参数传递的本质是值传递,它所传递的是一个地址值。值传递的特点是,被调用函数对形参的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针的地址值变了,实参的地址值不会变)。传递指针参数的本质是值传递,这句讨论的是指针的值。在函数体中通过 *p 改变指针所指向地址的值,这个过程会影响主调函数中实参变量的值。而在函数体中使 p 指向另一个地址,这个过程不会影响主函数中相应实参。

(2) 引用参数传递过程中,被调用函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调用函数对形参的任何操作都会影响主调函数中的实参变量。

3. 形参和实参的区别

(1) 形参变量只有在被调用时才分配内存单元,在调用结束时,即刻释放所分配的内存单元。因此,形参只有在函数内部有效。

(2) 实参可以是常量、变量、表达式、函数等。无论实参是那种类型的量,在进行函数调用时,它们都必须是确定的值,以便将这些值传递给形参。

值传递:按值传递参数有一个形参向栈拷贝数据的过程,如果值传递的对象是类对象或是大的结构体对象,将耗费一定的时间和空间(栈空间很宝贵)。

指针传递:按指针进行传递时,同样有一个形参向栈拷贝数据的过程,但是拷贝的数据是一个地址。

引用传递:同样有上述的拷贝过程,但传的是实参变量的地址。该类型的形参的任何操作都会影响主调函数中的实参变量。

从效率上讲,指针传递和引用传递比值传递效率高。一般使用引用传递,代码逻辑上更紧凑、清晰。

4. 声明和定义

当定义一个变量的时候,就包含了对该变量声明的过程,同时在内存中申请了一块内存空间。如果在多个文件中使用相同的变量,为了避免重复定义,就必须将声明和定义分离开来。

(1) 变量的声明和定义

从编译原理上来说,声明是仅仅告诉编译器,有个类型的变量会被使用,但编译器并不会为它分配任何内存,而定义会分配内存。ps.变量的声明和定义方式默认是局部的。

(2) 函数的声明和定义

声明:一般在头文件中,告诉编译器有一个函数叫 asd(),让编译器知道这个函数的存在。

定义:一般在源文件中,具体就是函数的实现过程。

函数的声明和定义方式默认都是 extern,即函数默认是全局的。

5. static 的用法和作用

静态变量存储的位置

(1) static 可以保持变量内容的持久。存储在静态数据区的变量会在程序刚开始运行时就完成初始化(也是唯一的一次初始化),共有两种变量存储在静态存储区:全局变量和 static 变量,只不过和全局变量比起来,static 可以控制变量的可见范围。

(2) 函数中的 static 变量的作用范围为该函数体内,该变量的内存只被分配一次,因此其值在下次调用时仍然维持上次的值。

静态函数:在函数的返回类型前加上 static 关键字。

特点:静态函数与普通函数不同,它只能在声明它的文件当中可见,不能被其他文件可用。

类中的静态成员变量

(3) 在类中的 static 成员变量属于整个类所拥有,类的所有对象只有一份拷贝。

(4) static 成员变量必须要在类外进行初始化,static 修饰的变量先于对象存在,所以 static 修饰的变量要在类外初始化。

类中的静态函数成员

(5) 静态成员函数的多态可以通过重载来实现。可以通过类名调用静态成员函数,例如:Point::output(); 。

(6) 类的对象可以使用静态成员函数,类的非静态成员可以调用静态成员函数,但是在类的静态成员函数中不能引用非静态成员。

(7) 由于 static 修饰的类成员属于类,不属于对象,因此 static 类成员函数是没有 this 指针的,this 指针是指向本对象的指针。因为没有 this 指针,所以 static 类成员函数不能访问非 static 的类成员,只能访问 static 修饰的类成员。

(8) 函数不能同时声明为静态和虚函数,例如:virtual static void output(int a); 。static 成员函数不能被 virtual 修饰,static 成员不属于任何对象或实例,所以加上 virtual 没有任何意义。

6. extern 的用法

(1) extern 修饰变量

如果文件 a.c 需要使用 b.c 中的一个变量 int asd,就可以在 a.c 中声明 extern int asd; ,然后就可以使用变量 asd。

(2) extern 修饰函数

如果文件 a.c 需要使用 b.c 中的一个函数。例如在 b.c 中原型是 int asd(int a),那么就可以在 a.c 中声明 extern int asd(int a);,然后调用 asd() 完成任务。

(3) 函数的声明和定义方式默认都是 extern 的,即函数默认是全局的。

(4) extern 修饰符可用于指示 C 或者 C++ 函数的调用规范

若在 C++ 中调用 C 的库函数,就需要在 C++ 程序中用 extern "C" 声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用 C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成后在目标代码中命名规则不同。

7. const

(1) 可以使用 const 阻止一个变量被改变。通常需要对它进行初始化,因为以后就没有机会再去改变它了。

(2) 对指针而言,可以指定指针本身为 const,也可以指定指针所指向的数据为 const,或者将它们同时指定为 const。

(3) 在一个函数声明中,const 可以修饰形参,表明它在函数内部不可以改变它的值。

(4) 对于类的成员函数,若指定为 const 类型,则表明它是一个常函数,不能修改类的成员变量,类的常对象只能访问类的常成员函数。这是因为一个没有声明为 const 的成员函数被看作是将要修改对象中数据成员的函数,而且编译器不允许它被一个 const 对象调用。因此,const 对象只能调用 const 成员函数。

(5) 同时使用 static 和 const 修饰一个函数时,会报 cannot have cv-qualifier 的错误(在 C++ 中 cv 限定符指 const 和 volatile)。这是因为 static 表示静态的,const 表示静态不变的。因为 const 已经是静态的了,所以这两个放在一起就像你用两个 static 修饰同一个变量。

(6) const 成员函数可以访问非 const 对象的非 const 数据成员、const 数据成员,也可以访问 const 对象内的所有数据成员。 

(7) 非 const 成员函数可以访问非 const 对象的非 const 数据成员和 const 数据成员(可以访问,但是尝试修改 const 数据成员会报错)但不可以访问 const 对象的任意数据成员。

例:const Asd & Asd::test( const Asd &a) const
第一个const:确保返回的Asd对象在以后的使用中不能被修改。
第二个const:确保此方法不修改传递的参数a。
第三个const:保证此方法不修改调用它的对象,const对象只能调用const成员函数,不能调用非const函数。

8. 指针和 const 的用法

(1) 当 const 修饰指针时,const 的位置不同,它修饰的对象也会有所不同。const 与指针的结合使用,有两种情况:一是用 const 修饰指针(常指针),即修饰存储在指针里的地址;二是修饰指针指向的对象(常量)。为了防止混淆使用,采用 "靠近" 原则,即 const 离哪个量近则修饰哪个量。如果 const 修饰符离变量近,则表达的意思为指向常量的指针;如果离指针近,则表示指向变量的常指针。

(2) const int * p1 或者 int const *p1。在这两种情况下,const 离 int 近,所以修饰的是指针指向的值(常量)。不可以改变 p1 所指对象的值,但是可以让 p1 改变所指向的对象,即指向常量的指针。

(3) int * const p2 中 const 修饰 p2 的值,所以 p2 的值不可以改变,即 p2 指向一个固定的地址(常指针)。而 p2 所指对象的值是可以改变的。

(4) const int * const p3 表示的是 p3 是一个常指针,它指向的对象的值是一个常量。

9. #define 与 inline 的区别

(1) #define 是关键字,inline 是函数。

(2) 宏定义在预处理阶段进行文本替换,inline 函数在编译阶段进行替换。

(3) inline 函数有类型检查,比宏定义更安全。

#define 与 const 的区别

(1) const 定义的常量是带数据类型的,而 #define 定义只是个常数而不带类型。

(2) #define 只在预处理阶段起作用,做简单的文本替换。而 const 在编译、链接过程中起作用。

(3) #define 只是简单的字符串替换,没有类型检查。而 const 是有数据类型的。

(4) #define 预处理后,占用代码段空间,const 占用数据段空间。

(5) const 不能重定义,而 #define 可以通过 #undef 取消某个符号的定义,进行重定义。

(6) #define 可以用来防止文件重复引用。

10. 野指针和悬空指针

野指针:就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)。指针变量在定义时如果未初始化,其值是随机的。或者指针变量的值是别的变量的地址,意味着指针指向了一个地址是不确定的变量,此时去解引用就是去访问了一个不确定的地址,所以结果是不可知的。

悬空指针是:一个指针的指向对象已被删除,那么就成了悬空指针。

野指针的成因:

(1) 指针变量未初始化

任何指针变量刚被创建时不会自动成为 NULL 指针,它的缺省值是随机的。所以,指针变量在创建的同时应当被初始化,要么将指针设置为 NULL,要么让它指向合法的内存。

(2) 指针释放后之后未置空

有时指针在 free 或 delete 后未赋值 NULL,便会让程序员以为是合法的。free 和 delete 只是把指针所指的内存给释放掉,但并没有对指针本身做处理。此时指针指向的就是 "垃圾" 内存。释放后的指针应立即将指针置为 NULL,防止产生 "野指针"。

(3) 指针操作超越变量作用域。

11. C 语言 struct 和 C++ struct 区别

(1) C 语言中,struct 是用户自定义数据类型(UDT),在 C++ 中 struct 是抽象数据类型(ADT),支持成员函数的定义,C++ 中的 struct 能继承,能实现多态。

(2) C 语言中的 struct 是没有权限设置的,且 struct 中只能是一些变量的集合体,可以封装数据,但是不能隐藏数据,而且成员不可以是函数。

(3) C++ 中,struct 的成员默认访问权限是 public,class 中的默认访问权限是 private。

12. C/C++的编译过程

  • (1) 预处理
  • 使用 -E 选项:gcc -E hello.c -o hello.i
  • 预处理阶段的过程有:头文件展开,宏替换,条件编译,去掉注释等。
  • (2) 编译
  • 使用 -S 选项:gcc -S hello.c -o hello.s
  • 编译阶段的工作是通过词法分析和语法分析将高级语言翻译成机器语言,生成对应的汇编代码。
  • (3) 汇编
  • 使用 -c 选项:gcc -c hello.c -o hello.o
  • 汇编阶段将源文件翻译成二进制文件。
  • (4) 链接 gcc hello.o -o a.out
  • 链接过程将二进制文件与需要用到的库链接。连接后便可以生成可执行文件。

13. C++类型转换

(1) static_cast 能进行基础类型之间的转换。主要可以完成:

① 用于类层次结构中父类和子类之间指针或引用的转换。进行向上强制转换是安全的(将派生类引用或指针转换为基类引用或指针)。进行向下强制转换(将基类引用或指针转换为派生类引用或指针),是不安全的。如果不使用显示类型转换,则向下强制转换是不允许的。

② 用于基本数据类型之间的转换,如把 int 转换成 char,把 int 转换成 enum。这种转换的安全性也要开发人员来保证。

③ 把空指针转换成目标类型的空指针。

④ 把任何类型的表达式转换成 void 类型。

(2) const_cast 主要作用是:修改类型的 const 或 volatile 属性。使用该运算方法可以返回一个指向非常量的指针(或引用),然后通过该指针(或引用)对它的数据成员任意改变。

需要注意的是:const_cast 不是用于去除变量的常量性,而是去除指向常数对象的指针或引用的常量性,它去除常量性的对象必须是指针或引用。

(3) reinterpret_cast 它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针(先把一个指针转换成一个整数,再把该整数转换成原类型的指针,还可以得到原先的指针值)。

(4) dynamic_cast 主要用在继承体系中的安全向下转换。它能安全的将指向基类的指针或引用转换为指向子类的指针或引用,并且能知道类型转换是否成功。转换失败会返回 NULL(转换对象为指针)或抛出异常 bad_cast(转型对象为引用)。dynamic_cast 会动用运行时信息(RTTI,Run Time Type Info)来进行类型安全检查,因此 dynamic_cast 存在一定的效率损失。当使用 dynamic_cast 时,该类型必须含有虚函数,这是因为 dynamic_cast 使用了存储在 VTABLE 中的信息来判断实际的类型。

在 C++ 中,typeid 用于返回指针或引用所指对象的实际类型(头文件是#include <typeinfo>)。typeid 运算符用来获取一个数据类型或者表达式的类型信息,运行时获知变量类型名称,可以使用 typeid(变量).name()。类型信息对于编程语言非常重要,它描述了数据的各种属性:

对于基本类型(int、float 等 C++ 内置类型)的数据,类型信息所包含的内容比较简单,主要是指数据的类型。

对于类类型的数据(也就是对象),类型信息是指对象所属的类、所包含的成员、所在的继承关系等。

14. C++ 模板底层原理

编译器并不是把函数模板处理成能够处理任何类型的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:① 在声明的地方对模板代码本身进行编译;② 在调用的地方对参数替换后的代码进行编译。函数模板要被实例化后才能成为真正的函数。

15. mutable 关键字和 volatile 关键字

如果需要在 const 成员方法中修改一个成员变量的值,那么需要将这个成员变量修饰为 mutable。即用 mutable 修饰的成员变量不受 const 成员方法的限制。被 mutable 修饰的变量,将永远处于可变的状态,即使在一个 const 函数中。

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其他线程等。遇到 volatile 关键字声明的变量,编译器对访问该变量的代码不再进行优化,从而可以提供对特殊地址的稳定访问。声明语法为:int volatile number; 当要使用 volatile 声明的变量的值时,即使之前的指令刚刚读取过数据,而且立即保存了下来,系统还是要重新从它的内存读取数据。volatile 可以用在以下场景:

(1) 中断服务程序中修改的供其他程序检测的变量需要加 volatile。

(2) 多线程环境下各线程间共享的标志应该加 volatile。

(3) 存储器映射的硬件寄存器通常也要加 volatile。

16. 一个函数调用另一个函数时栈的变化

esp 是堆栈(stack)指针寄存器,指向堆栈顶部ebp 是基址指针寄存器,指向当前堆栈底部

(1) 调用者把被调用函数所需的参数按从右向左依次压入栈中。

(2) 调用者使用 call 指令调用被调函数,并把 call 指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在 call 指令中)。

(3) 被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即,当前被调用函数的栈底地址(mov ebp, esp)。

(4) 在被调函数中,从 ebp 的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:先定义的变量先入栈,后定义的变量后入栈。

(5) 当被调用的函数完成了相应的功能后,mov esp ebp,将 ebp 的值赋给 esp,也就等于将 esp 指向 ebp,销毁被调用函数的栈帧。再调用 pop ebp,ebp 出栈,将栈中保存的 main 函数的基址赋值给 ebp。

(6) 将之前保存的函数返回地址出栈,返回 main 函数继续运行程序。

17. 回调函数

回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就称这个函数是回调函数。调用者与被调用者分开,调用者不需要关心谁被调用,调用者需要知道的,只是存在一个具有某种特定原型、某些限制条件的被调用函数。

构造函数与析构函数

构造函数,是一种特殊的方法。主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值。特别的是一个类可以有多个构造函数 ,可根据其参数个数的不同或参数类型的不同来区分它们,即构造函数的重载。

构造函数初始化时,可以在函数体内进行赋值初始化,也可以使用列表完成初始化。对于在函数体内初始化,是在所有的数据成员被分配内存空间后才进行的。而列表初始化是给数据成员分配内存空间时就进行初始化。

析构函数与构造函数相反,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。析构函数往往用来做 "清理善后" 的工作。析构函数没有参数,也没有返回值,而且不能重载,每个类中只能有一个析构函数。

构造函数的执行顺序和析构函数的执行顺序:

(1) 构造函数顺序

① 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在派生表中出现的顺序,而不是它们在成员初始化表中的顺序。

② 成员类对象构造函数。如果有多个成员类对象(如:string)则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。

③ 派生类构造函数。

(2) 析构函数顺序

① 调用派生类的析构函数。

② 调用成员类对象的析构函数。

③ 调用基类的析构函数。

构造函数和析构函数可以调用虚函数吗?

(1) 在 C++ 中,提倡不在构造函数和析构函数中调用虚函数。

(2) 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是构造函数或析构函数本身所在类的定义的版本。

(3) 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数是不安全的,所以 C++ 不会进行动态联编。

(4) 析构函数是用来销毁一个对象的,在销毁一个对象时调用析构函数没有任何意义。

虚函数

简单地说,那些被 virtual 关键字修饰的成员函数,就是虚函数。虚函数的作用是实现多态性,多态性是将接口与实现进行分离;用形象的语言来解释就是实现一个方法,但因个体差异,而采用不同的策略。

使用基类引用或指针指向派生类对象时,想要根据其指向的对象调用相应的方法时,就要将相应的函数声明为虚函数。没有 virtual 关键字,程序根据引用类型或指针类型调用方法。virtual 关键字告诉程序要根据引用或指针指向的对象的类型来调用方法。

静态绑定与动态绑定

绑定,又称联编,是使一个计算机程序的不同部分彼此关联的过程。根据进行绑定所处阶段的不同,有两种不同的绑定方法,静态绑定和动态绑定。

(1) 静态绑定在编译阶段完成,所有绑定过程都在程序开始之前完成。静态绑定具有执行速度快的特点,因为在程序运行前,编译程序能够进行代码优化。函数重载(包括成员函数重载和派生类对基类函数的重载)就是静态绑定。静态绑定对函数的选择是基于指向对象的指针或引用的类型,而与指针或引用实际指向的对象无关,这也是静态绑定的限定性。

(2) 动态绑定是在程序运行时动态地进行。如果编译器在编译阶段不确切地知道把发送到对象的消息和实现消息的哪段代码具体联系到一起,而是运行时才把函数调用与函数具体联系在一起,就称作动态绑定(这虚函数的实现)。相对于静态绑定,动态绑定是在编译后绑定,也称晚绑定,又称运行时识别。动态绑定具有灵活性好、更高级、更自然的问题抽象、易于扩充和易于维护等特点。通过动态绑定,可以动态地根据指针或引用指向的对象实际类型来选择调用的函数。

构造函数为什么不能为虚函数

虚函数用于实现运行时的多态,需要使用到 vtable 虚函数表(是在编译期间创建的)。在调用相应虚函数时,会使用到指向 vtable 的指针 vptr(创建对象的时候创建 vptr),而这个指针存储在类对象的内存空间中。如果构造函数是虚函数,就需要使用 vtable 来调用构造函数。但是,此时对象还没有实例化,没有虚函数指针,所以找不到 vtable。所以构造函数不能是虚函数。

虚析构函数是为了防止内存泄露。具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时,不会触发动态绑定,所以只会调用基类的析构函数,而不会调用派生类的析构函数。在这种情况下,派生类中申请的空间得不到释放就会产生内存泄露。

哪些函数不能是虚函数

(1) 构造函数:当有虚函数时,每个类都有一个虚函数表,每一个对象都有一个虚函数表指针,而虚函数表指针是在构造函数中初始化的。

(2) 静态成员函数:静态函数不属于对象而是属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。

(3) 友元函数:友元函数不属于类的成员函数,不能被继承,也不需要表现出多态性。因此,友元函数不能是虚函数。

(4) 普通函数:普通函数不属于类的成员函数,不能被继承,也不需要表现出多态性。因此,普通函数不能是虚函数。

继承

若逻辑上 B 是 A 的一种,则应该使用继承。继承建立一种 is-a 关系(is-a-kind-of 关系)。例如:可以从水果类中派生出榴莲类,榴莲类有自己的特性(外壳带刺、果肉绵软、有核等),但是其他的水果并没有。派生类对象也是一个基类对象,可以对基类对象执行的操作,也可以对派生类对象执行。

若逻辑上 B 是 A 的一部分,则应该使用组合而不是继承,组合建立一种 has-a 关系。例如:眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类 Head 应该由类 Eye、Nose、Mouth、Ear 组合而成,而不是派生。

private 和 protected 之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。对外,保护成员的行为与私有成员相似。对外,保护成员的行为与公有成员相似。

多继承的优缺点

比如有三个类,人类-士兵类-步兵类,三个依次继承,这样的继承称为单继承。

class Person {};
class Soldier :public Person {};
class Infantryman :public Soldier {};

多继承是如果一个类有多个基类,比如农民工类继承了农民类和工人类。多继承会出现菱形继承的情况。

class Worker {};
class Farmer {};
class MigrantWorker:public Worker,public Farmer {};

多继承的有点很明显,就是派生类对象可以调用多个基类中的接口。缺点是当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。这个时候需要在成员名字前面加上类名和域解析符 ::,以显式地指明到底使用哪个类的成员,消除二义性。

若类 B、C 继承了类 A,同时类 D 继承类 B 和类 C。此时,就出现了菱形继承的问题。在实例化 D 的时候,就会继承两个 A 的成员,造成数据的冗余,为了解决这个问题,引入了虚继承的方式,即:如果 B 和 C 是虚继承 A 的话,那么实例化 D 以后,D 中只有一份 A 的数据成员,不会产生冗余数据。

class B:virtual public A// 虚基类 {};
class C:virtual public A {};
class D:public B,public C {};

在成员函数中调用 delete this会出现什么问题?

(1) 在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个 this 指针,让成员函数知道当前是哪个对象在调用它。当调用 delete this 时,类对象的内存空间被释放。在 delete this 之后进行的任何操作,只要不涉及到 this 指针的内容,都能正常运行。一旦涉及到 this 指针,如操作数据成员,调用虚函数等,就会产生不可预期的结果。

(2) delete this 之后释放了类对象的内存空间,这段内存应该已经还给系统,不再属于这个进程。从逻辑上看,应该发生指针错误,无访问权限之类的问题。但这个问题涉及到操作系统的内存管理策略。delete this 释放了类对象的内存空间,但是内存空间并没有被系统收回。此时这段内存是可以访问的,但是其中的值却是不确定的。当你获取数据成员时,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,容易造成系统崩溃。

(3) 如果在类的析构函数中调用 delete this 会导致堆栈溢出。原因很简单,delete 的本质是 "为将要被释放的内存调用一个或多个析构函数,然后再释放内存"。delete this 会去调用本对象的析构函数,而析构函数中又调用 delete this,形成无限递归,造成堆栈溢出,系统崩溃。

this 指针调用成员函数

当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址作为隐含参数传递给函数,这个隐含参数就是 this 指针。即使程序员没有写 this 指针,编译器在链接的时候也会加上 this 的,对各成员的访问都是通过 this 的。 调用过程中,this 指针首先入栈,然后成员函数的参数从右向左入栈,最后函数返回地址入栈。

单例模式

单例模式(Singleton Pattern)是最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

#include <iostream>
using namespace std;class Singleton
{private:static Singleton *p;Singleton(){cout << "构造" <<endl;}~Singleton(){cout << "析构" <<endl;}Singleton(const Singleton&);Singleton operator= (const Singleton&);   //声明但是不实现public:int a, b;static Singleton *get(){static Singleton s;return &s;}void print(){cout << a << " " << b <<endl; }
};Singleton* Singleton::p = NULL;int main()
{Singleton *s1 = Singleton::get();cout << "Address:" << s1 <<endl;s1->print();s1->a = 1;s1->b = 2;s1->print();Singleton *s2 = Singleton::get();cout << "Address:" << s2 <<endl;s2->print();  s2->a = 2;s2->print();return 0;
}

重载、隐藏和重写(覆盖)

重载 Overload:函数名字相同,特征标(形参)不同。返回类型可以相同也可以不同,但只有返回值不同不能达到重载的目的,且会报错。函数必须在同一个域(类)中。

隐藏 Hide:派生类中的函数会隐藏父类中所有的同名函数。

重写 Override:子类对父类函数的重新编写,返回值和特征标(形参)都不能变,只能改变内部代码。被 override 的函数必须在父类中,且为 virtual。

重载

#include <iostream>
using namespace std;
//重载
class Asd
{private:int a;public:Asd(int b):a(b){}void test(){cout << "Asd test \n";}void test(int a){cout << "Asd test [int] \n";}int test(int a){cout << "Asd test int \n"; return 1;}void test(double a){cout << "Asd test [double] \n";}virtual int geta() {return a;}virtual int seta(int b) { a = b; }
};int main()
{Asd aa(1);aa.test();aa.test(1);aa.test(1.2);return 0;
}

正如前面描述一样,只有返回值不同不能达到重载的目的,且会报错。注释掉相应代码就可以正常运行了。

隐藏

#include <iostream>
using namespace std;
//隐藏
class Asd
{private:int a;public:Asd(int b):a(b){}void test(){cout << "Asd test \n";}void test(int a){cout << "Asd test [int] \n";}void test(double a){cout << "Asd test [double] \n";}virtual int geta() {return a;}int seta(int b) { a = b; }int seta(char *a) { cout << a <<endl;}
};class Bsd:public Asd
{public:Bsd(int a):Asd(a){}void test(){cout << "Bsd test\n";}int geta() final {return Asd::geta();}
};int main()
{Bsd aa(1);char a[] = "Output sting";aa.seta(3);aa.seta(a);aa.test();aa.test(1);//aa.test(1.2);cout << aa.geta() <<endl;return 0;
}

派生类 Bsd 公有继承 Asd,同时声明了一个父类中包含的函数 test()。此时,在派生类中的函数会隐藏父类中所有的同名函数。如果调用别父类中的 test() 函数也会报错。

重写(覆盖)

管理虚方法:override 和 final

在 C++11 之后,可使用虚说明符 override 指出你要覆盖的一个虚函数:将其放在参数列表后面。如果声明于基类方法不匹配,编译器将视为错误。说明符 final 解决了另一个问题。你可能想禁止派生类覆盖特定的虚方法,为此可在参数列表后面加上 final。

虚方法对实现多态类层次结构很重要,让基类引用或指针能够根据指向的对象类型调用相应的方法,但虚方法也带来了一些编程陷阱。例如,假设基类声明了一个虚方法,而你决定在派生类中提供不同的版本,这将覆盖旧版本。如果特征标不匹配且没有使用 override 进行说明,将隐藏而不是覆盖旧版本。

#include <iostream>
using namespace std;class Asd
{private:int a;public:Asd(int b):a(b){}virtual void test(){cout << "Asd test\n";}void test(int a){cout << "Asd test int \n";}void test(double a){cout << "Asd test float " << a <<"\n";}virtual int geta() {return a;}virtual int seta(int b) { a = b; }int seta(char *a) { cout << a <<endl;}
};class Bsd:public Asd
{public:Bsd(int a):Asd(a){}//void test(int a){cout << "Bsd test\n";}void test(int a)  {cout << "Bsd test\n";}int geta() final {return Asd::geta();}//int seta(int b) final {Asd::seta(b);}//int seta(char *a) override { cout << a <<endl;}
};int main()
{Asd asd(1);asd.test();asd.test(1);Bsd aa(1);char a[] = "Output sting";aa.seta(1);aa.seta(a);cout << aa.geta() <<endl;aa.test(1);return 0;
}

总结:

(1) 对父类不声明为 virtual 成员函数添加 final 和 override 会报错。

(2) 对一个父类成员函数进行重写后,父类中重载的同名函数不能使用。

(3) final 禁止派生类重写其虚方法。

(4) override 指出一个要重写,如果特征标与基类不匹配,编译器将视为错误。

在 C++ 中 override 和 final 是说明符而不是关键字,它们是具有特殊含义的标识符。这意味着编译器根据上下文确定它们是否有特殊含义;在其他上下文中,可将它们用作常规标识符,如变量名或枚举。

标识符

标识符是用来标识变量、函数、类、模块,或任何其他用户自定义项目的名称,用它来命名程序正文中的一些实体,比如函数名、变量名、类名、对象名等。

关键字

关键字就是预先定义好的标识符,C++ 编译器对其进行特殊处理。关键字又称为保留字,这些保留字不能作为常量名、变量名或其他标识符名称。

vector 扩容机制应注意的问题

问题1:当 vector 中的容量是 10 时,已经插入了 9 个元素了,再插入一个元素会不会引起扩容?

问题2:当 vector 中有备用空间时,能不能引起扩容?

下面是一段简单的代码,就是在 push_back() 插入的过程中调用 capacity() 函数,它的功能是返回容器当前已分配空间的元素的个数。

#include <iostream>
#include <vector>
using namespace std;int main()
{vector<int> asd;cout << asd.capacity() <<endl;for(int i = 0; i < 20; i++) { asd.push_back(i);cout << asd.capacity() << "  ";}cout << endl;return 0;
}

可以看到,当插入一个元素的时候,只分配一个空间(引发扩容)。插入第二个元素的时候,分配的空间为 2(引发扩容)。插入第三个元素的时候,已分配空间的元素的个数为 4(引发扩容),那么就会有一个备用空间。所以插入第 4 个元素是不会引起扩容的。同理,当 vector 内已分配的空间容量为 N 时,插入第 N 个元素时,是不会引发扩容的。

当使用 push_back() 将元素插入 vector 的尾端时,该函数首先检查是否还有备用空间,如果有就直接在备用空间上构造元素,并调整迭代器 finish,使 vector 变大。如果没有备用空间了,就扩充空间(重新配置、移动数据、释放原空间)。以下代码片段源自《STL源码剖析》。

void push_back(const T& x){if(finish != end_of_storage) {construct(finish, x);++finish;}elseinser_aux(end(), x);
}template <class T, class Alloc = alloc>
void vector<T, Alloc>::insert_aux(iterator position, const T& x)
{if(finish != end_of_storage) {construct(finish, *(finish-1));++finish;T x_copy = x;copy_backward(position, finish-2, finish-1);*position = x_copy;}else {const size_type old_size = size();const size_type len = old_size != 0 ? 2*old_size : 1;//以上配置原则:如果原大小为0,则配置1;如果原大小不为0,则配置原大小的两倍。iterator new_start = data_allocator::allocate(len);iterator new_finish = new_start;try {//将原vector的内容拷贝到新vectornew_finish = uninitialized_copy(start, position, new_start);construct(new_finish, x);++new_finish;//将安插点的原内容也拷贝过来new_finish = uninitialized_copy(position, finish, new_finish);}catch(...) {destroy(new_start, new_finish);data_allocator::deallocate(new_start, len);throw;}//析构并释放原vectordestroy(begin(), end());deallocate();//调整迭代器,指向新vectorstart = new_start;finish = new_finish;end_of_storage = new_start + len;}
}

按上述源码的配置原则:如果原大小为 0,则配置 1;如果原大小不为 0,则配置原大小的两倍。对于第一个问题而言,若是 vector 中的容量是 10,已经插入了 9 个元素了,再插入一个元素是不会引发扩容的,这是这个问题的标准答案。不过面试官的问题是容量是 10,与上面代码中一直使用 push_back() 函数出现的容量不一样,这就引出来另一个问题:什么情况下会引起扩容的 old_size 的变化?

改变vector容量的情况

void shrink_to_fit();

shrink_to_fit() 请求删除未使用的容量,将 capacity() 减小为 size()。size() 函数返回目前 vector 使用的空间,capacity() 函数返回的是目前已分配空间。

#include <iostream>
#include <vector>
using namespace std;int main()
{vector<int> asd;cout << asd.capacity() <<endl;for(int i = 0; i < 20; i++) {asd.push_back(i);cout << asd.capacity() << "  ";if(i == 4)asd.shrink_to_fit();}cout << endl;return 0;
}

可以看到,插入了第 5 个元素后,容量扩充到了8,调用 shrink_to_fit() 后实际空间变成了 5。所以在插入下一个元素的时候,立马引发了扩容,将空间扩展到了 10。

void reserve( size_type new_cap );

reserve() 将 vector 的容量增加到大于或等于的值 new_cap。

如果 new_cap 大于当前的 capacity(),则分配新的存储,否则该方法不执行任何操作。reserve() 不会更改 vector 的元素个数,如果 new_cap 大于 capacity(),则所有迭代器(包括过去的迭代器)以及对元素的所有引用都将无效。否则,没有迭代器或引用无效。

#include <iostream>
#include <vector>
using namespace std;int main()
{vector<int> asd;cout << asd.capacity() <<endl;for(int i = 0; i < 20; i++) {asd.push_back(i);cout << asd.capacity() << "  ";if(i == 3)asd.reserve(7);}cout << endl;return 0;
}

以上代码在插入 4 个元素后,调用 reserve() 函数调整了 vector 容量。根据 vector 底层源码可以知道,这个调整的容量就是 old_size,在下次进行扩充的时候,就会扩展成 2*old_size。当 vector 还有备用空间时,可以调用 reserve() 函数完成扩容(第二个问题的答案)。

STL 迭代器

迭代器是一种抽象的设计理念,通过迭代器可以在不了解容器内部原理的情况下遍历容器。除此之外,STL 中迭代器最重要的作用是作为容器和 STL 算法的粘合剂。

迭代器提供了一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是 * 运算符与 -> 运算符,以及 ++、--  等可能需要重载的运算符。

unordered_map 容器实现了++,但是没有实现 --。例如下述代码:

auto it = find_if(nums1.begin(), nums1.end(), [](auto &a){return a.second == 1;});
cout << (*(--it)).first << " " << (*(it)).second<<endl;

会报以下错误:

main.cpp:18:13: error: no match for ‘operator--’ (operand type is ‘std::__detail::_Node_iterator<std::pair<const int, int>, false, false>’)

vector 的下标运算符和 map 的下标运算符

通过下标访问 vector 中的元素时不会做边界检查,即使越界了,程序也不会报错(前提是越界)。通过使用 at 函数不但可以通过下标访问 vector 中的元素,而且在 at 函数内部会对下标进行边界检查。

map 的下标运算符 [ ] 的作用是:将 key 作为下标去执行查找,并返回相应的值;如果不存在这个 key,就将一个具有该 key 和 value 的默认值插入这个 map 中。

map 的 find 函数:用 key 进行查找,找到了返回相应位置的迭代器;如果不存在该 key,则返回 end 迭代器。

C++ 基础概念、语法和易错点整理相关推荐

  1. 证券从业资格考试——金融市场基础知识关键点和错题整理

    证券从业资格考试--金融市场基础知识错题及关键知识点整理 金融市场基础知识错题整理 一.各种申请条件(只记录关键的数字) 二.与数字相关的点(时间,百分比,人数等) 三.其他 金融市场基础知识错题整理 ...

  2. 狂神Java面试题总结:基础及语法169道

    狂神Java面试题总结:基础及语法169道 收集整理:秦疆 联系方式QQ:24736743 微信:qinlvejiang 答案来源收集与互联网,部分内容经供参考,代码全部为手写验证通过. 1~20 1 ...

  3. 软件设计师2014上午题基础知识(易错整理)

    软件设计师2014上午题基础知识(易错整理) 2014 上半年 木马程序的客户端运行在攻击者的机器上 海明码检验位计算:有效信息位 + 校验位个数 <= 2^校验位个数 - 1 防火墙工作层次越 ...

  4. 原python基础概念整理_Python从头学之基础概念整理

    学程序真的是一个无法间断的过程,只要你懈怠,种种原因都是你的理由.然而造成的后果就是到目前位置,一个心目中的项目都没有完美的做出来: 归根结底,其实就是基础没有打好,因为每一个复杂的功能都是由很多简单 ...

  5. 整理总结:零基础英语语法

    参考资料:<零基础英语语法> 文章目录 参考资料:<零基础英语语法> 一.词法篇 1.实词 1.名词 I. 根据意义分为专有名词和普通名词 1. 专有名词 2.普通名词 3.名 ...

  6. 软件设计师2010上午题基础知识(易错整理)

    软件设计师2010上午题基础知识(易错整理) 2010 上半年 指令寄存器保存当前正在执行的指令,指令译码器测试指令操作码识别操作,地址寄存器保存当前CPU所访问的内存单元地址,程序计数器保存下一条指 ...

  7. 常用色彩模式及基础概念整理

    这篇将UI设计时的颜色模式,混合页面开发时的颜色设置,混在一起,整理了一下常用到的几种模式及其基础概念,其中标有 图标的,为 CSS 中可以直接使用的颜色模式.  RGB 光色模式 由 红(R | R ...

  8. Elasticsearch7.15.2 基础概念和基础语法

    文章目录 一.基础概念 1. ES是什么? 2. 名词定义 3. 对应关系 4. 索引 5. 分词 二.基础概念 2.1. 索引创建 2.2. 索引/文档删除 2.3. 索引修改 三.ES 查询 3. ...

  9. 一文看懂Java虚拟机——JVM基础概念整理

    1 基础概念 2 垃圾回收 3 虚拟机调优

最新文章

  1. ”过程”在敏捷开发中的位置
  2. C语言最简单的sleep sort睡眠排序实现(附完整源码)
  3. vector的简单实现
  4. 限制ul显示高度_HP Envy 34寸超宽曲屏 显示器评测
  5. php5.2 array,详解php 5.2.x 数组操作实例
  6. 网络协议:TCP拥塞控制
  7. 2018年9月8日 笔试小结
  8. mysql连17张表_mysql连表查询
  9. 网页扫雷html css js,GitHub - zsr204/Sweep: js + html + css 实现一个简单的扫雷~~ 附加 难度选择 计时 计雷数 开始 重新开始 功能...
  10. TCP/UDP的区别
  11. Matlab中FracLab计算分形维数方法
  12. python3 print和format函数
  13. CentOS7下部署Mantis详细步骤
  14. Install Mysql MMM On Redhat6.3
  15. 古罗马花园石头雕像喷泉原理
  16. Vue3+elementplus搭建通用管理系统实例十五:界面美化及样式调整
  17. Java中如何实现一个函数返回多个值
  18. C++类对象的创建与释放过程
  19. 激光雷达运动物体分割论文汇总(2021-2022)
  20. AutoCAD入门——常用指令

热门文章

  1. 如何使用Appverifier ?
  2. 精美技术图赏|技术精华
  3. Kafka解析之topic创建(3)——合法性验证
  4. Ukiyo-e faces dataset 浮世绘面孔数据集
  5. Facebook、谷歌、微软和亚马逊的网络架构揭秘
  6. 海量服务 | 论服务器极致化海量运营交付的未来
  7. linux下mysql安装
  8. springMVC——SSM整合(IDEA 搭建简单 ssm 框架最详细最简单教程)
  9. leetcode 365. Water and Jug Problem | 365. 水壶问题(Java)
  10. leetcode 332. Reconstruct Itinerary | 332. 重新安排行程(Java)