【深入理解C++面试常考知识点】

【深入理解C++面试常考知识点】

1.bool类型

  • C++中的新类型,C语言中没有bool类型
  • bool类型:0为假,非0为真
  • bool类型的正确使用,一定不要出现下列写法

bool a = (3 > 2);
if (a == true)

  • C语言中如何使用bool类型,C语言中一般使用宏定义
  • typedef 为C语言的关键字,作用是为一种数据类型定义一个新名字,这里的数据类型包括内部数据类型(int,char等)和自定义的数据类型(struct等)。

typedef int BOOL
sizeof(bool) c++中bool类型占一个字节
sizeof(BOOL) C语言中通过宏定义的BOOL类型占四个字节

2.const关键字

  • 为什么需要const关键字?

当程序中出现有数值不变,并且需要多次重复使用的变量时,就可以使用常量来表示

  • C语言中如何使用常量?——宏定义

#define PI 3.14

  • 使用宏定义表示常量有什么不好?

宏定义在程序中是直接进行替换,而不进行类型检查

  • C++中const常量的定义方式?

const float FPI = 3.14f;

  • 常量与变量的区别?

常量的值一旦定义就不可以修改,因此在定义常量时一定要进行赋值;
变量的值可以进行修改

  • 常量和变量一样也会在内存中分配空间,既然常量在内存中分配了空间,我们就可以通过指针对常量的值进行修改,因此常量不可以被修改是在程序的编译期间,而在程序的运行期间仍然可以通过指针来修改常量的值

const float FPI = 3.14f;
float r = 0.5;
float* p = (float*) & FPI;
#FPI为常量,直接取地址&FPI获得的也是常量指针,而float* p是float类型的指针,不能直接将常量float指针赋值给float类型指针,因此需要做强制类型转换
#同样也因为常量指针的指向是不可以修改的,所以必选强转为float*
通过修改指针指向的地址来修改常量的值

  • 常量在编译器编译时存在优化现象:即编译器在编译时如果遇到已知数据类型和值的常量,其会自动进行字符替换
int main()
{
	const int nConst = 5;//已知数据类型的值
	//在程序运行期间即使通过指针修改了常量的值,打印出来的仍然是5,因为编译器在编译时进行了优化
	printf("nConst = %d\r\n",nConst);//因此在这里进行了直接替换
	
	return 0;
}

int main(int nArgc, char* pArgv[])
{
	const int nConst = nArgc;//程序在编译时并不知道nArgc的数值,不会进行优化
	//此时在程序运行期间修改const的值,打印输出也会修改,因此此时编译器在编译时无法进行优化
	printf("nConst = %d\r\n",nConst);
	
	return 0;
}

3.const指针

  • 为什么需要常量指针?看下面的例子
int main()
{
	char* p = "hello world!"; //该指针所指向的地址存放的是一个常量字符串,常量字符串不应该被修改
	p[0] = 'a';//通过指针,修改指针p所指向的第一个元素
	//编译通过,单运行时会报错,“hello world”字符串是一个常量,在内存中分配的空间应该是一个只能够读不能够写的内存空间,不可以对其进行修改

	return 0;
}

int main()
{
	const char* p = "hello world!";
	p[0] = 'a';//编译器提示不可以修改

	return 0;
}
  • const char *p; 常量指针,指针内容不可以被修改
  • char const *p; 同上
  • char* const p; 指针常量,指针的地址不可以被修改
  • const_cast类型转换,用于去除const修饰

const int n = 5;
int* p = const_cast(&n);

  • 也可以直接使用强制类型转换代替上面使用const_cast去除const修饰,但是与强制类型转换相比,const_cast专门用于去除const修饰,目的更加明确

4.默认参数

  • 函数允许提供默认参数
  • 默认参数既可以写在函数声明中也可以写在函数定义处,但是只能出现在一个地方,否则会重定义,一般写在函数声明处,因为声明可以暴露给外部,函数代码一般不能暴露给外部
  • 默认参数要靠右对齐,当一个参数有默认参数时,该参数右边的参数必须都具有默认参数

5.内联函数

  • 内联函数一般用于函数体比较简单,并且多次重复调用的函数,内联函数的作用就是直接将函数的二进制代码放到调用处,不需要重复进出函数体,执行效率高
  • inline内联函数是对编译器的建议,编译器可以采纳也可以不采纳
  • 为了方便调试,debug版本没有inline
  • 内联函数必须写在头文件中,*但是需要注意在头文件中定义的函数,如果在多个cpp文件中进行了include,会出现重复定义的错误,因此想要在头文件中定义函数,并在多个cpp文件中使用需要加上inline关键字,因为加上inline关键字后函数代码会被直接放到各自的cpp文件中,而不是在不同的cpp文件中进行了多次定义

6.引用

  • 引用的本质是给变量起别名,注意是变量

int a = 5;
int& n = a;
改变n的值,a的值也会变,改变a的值,n的值也会变,即a和n都是指向同一片内存空间

  • 引用在定义时必须初始化不能用常量为引用赋值

int& m = 5;错误,引用本身就是给变量起别名,怎么能用常量进行初始化

  • 只有常量引用才可以使用常量来进行初始化

const int& k = 5;常量的引用可以使用常量进行初始化

  • 引用的关系一旦建立就无法被修改

int n = 5;
int m = 6;
int& refN = n;//引用refN与n建立关系
refN = m;//因此这里是赋值,而不再是将m与引用refN建立关系

  • 引用的使用(引用作为函数的返回值使用时存在安全隐患)

作为函数参数使用
作为函数返回值使用,但是需要特别注意,函数体内部定义的变量一旦出了函数的作用域就会被释放,或者将内存空间分配给其他变量进行使用,而此时将函数体内部的变量作为返回值返回,在函数体外部进行修改本质上是在修改已经释放或者分配给其他变量的内存空间,存在着安全隐患

  • 二级指针存在,二级引用并不存在
  • 引用的本质和底层实现就是指针,传引用其实就是传入变量的地址

7.作用域与数据隐藏

  • 有哪些作用域:

全局作用域,命名空间作用域
局部作用域,块作用域
类域(class)

  • 数据隐藏

不同作用域定义多个相同名字的变量时会存在数据隐藏的现象
内部作用域的变量会隐藏外部作用域的变量(即相同名字的变量其会从内向外查找,如果最外部仍找不到它的值,程序就会报错)

8.命名空间

  • 命名空间是一种作用域的划分,通常用于区分项目中的模块或组件
  • 命名空间的关键字是namespace
  • 同一个命名空间可以分开定义和使用
namespace shellMad
{
	int width = 5;
}

namespace shellMad
{
	int height = 6;
}
  • 命名空间可以嵌套
namespace shellMad
{
	int height = 6;
	namespace neibu
	{
		int neibubianliang = 10;
	}
}
  • 命名空间可以取别名
namespace shellMad
{
	int height = 6;
}
namespace n = shellMad;//给shellMad命名空间起一个别名n
n::height = 7;
  • 命名空间中还可以定义函数,命名空间的本质就是作用域的划分,当然可以在不同的作用域定义函数
  • 如何在局部作用域使用全局作用域中的变量

::width 直接使用 ::加变量名 表示全局作用域中的width变量(四饼)

  • 命名空间的使用方法:

1.直接使用命名空间::变量名/函数名(推荐这种使用方式
2.using namespace ShellMad;从这句话开始后面的变量默认都是使用ShellMad命名空间中的变量或函数(不推荐使用,这种写法仍然可能会造成冲突)
3.声明只使用命名空间中的部分变量和函数 using ShellMad::n;
4.::变量名/函数名 表示使用全局作用域中的变量

  • 命名空间的原理:

编译原理
.c / .cpp —cl.exe(编译器)—> .obj —linker.exe(链接器) —>.exe
命名空间中的变量在编译生成.obj文件的过程中会附加变量的命名空间以及作用域信息,因此可以通过命名空间区分不同的变量
通过名称粉碎来实现不同命名空间变量的区分
undname -f ?foo@ShellMad@@YAXXZ

9.函数重载

  • C语言中没有函数重载机制,C语言中不允许出现同名的函数
  • C++中有函数重载机制,C++中允许出现同名的函数
  • 函数重载的条件:

1.函数名必须相同
2.函数参数个数,类型,顺序不同
3.返回值类型不同不能构成重载,调用约定不同不能构成函数重载
4.作用域必须相同

  • 函数重载的调用规则

1.根据函数名找对应的函数,作为候选函数
-----1.1如果候选函数的个数为0,则报未定义错误(找不到标识符)

2.如果候选函数的个数>0,从候选中找到匹配的函数(完全匹配,可以转换的匹配(char <-> int ,float <-> double, float <-> int等)
----- 2.1如果匹配的函数个数 == 0,则错误(隐式转换失败)
------2.2如果匹配的函数个数 > 0,找最佳匹配

3.最佳匹配的个数 = 1, 就会调用该函数
最佳匹配的个数 > 1,就会报二义性

  • 函数重载的本质:

与命名空间的本质相同,是通过名称粉碎来区分不同的重载函数

10.类

面向对象:封装

  • 面向对象的语言:C++,java, C# , python, go
  • 面向过程的语言:C
  • 面向对象的语言的三个特点:

1.封装
2.继承
3.多态

  • 面向对象的语言:什么是对象?

对象 = 数据(数据类型) + 行为(函数)

  • C语言中可以使用结构体来实现对象中的数据的定义,但是C语言中结构体中不能定义函数,因此无法实现对象的行为
  • C++中对结构体进行了拓展,允许将函数放入结构体中,因此可以使用结构体来定义对象中的数据和行为
  • 既然C++中对结构体进行了扩展,结构体也同样可以定义对象的属性和行为,那么结构体定义对象和类定义对象有什么区别呢?

C++中结构体和类的唯一区别就是访问权限不同
访问权限:指在当前类域之外访问类的规则
1.public:公有权限,类内类外均可访问
2.protected:保护权限(继承中使用)
3.private:私有权限(类域内部可以访问但类域外部不可访问
结构体的默认访问权限是公有权限
(默认访问权限就是指没有写明访问权限时)

类的默认访问权限是私有权限

  • 类的标准写法

1.类名一般加一个大写的C,例如:class CClock{};
2.函数成员(member)名前一般加上m,例如:int m_hour = 10;
3.类中主要包含两部分数据成员函数成员,数据成员一般写私有函数成员一般部分公有,部分提供给外部进行使用
4.数据成员私有的原因是:类本身就是一种封装,数据成员属于类自身的属性,我们不希望外界直接对对象的属性进行修改
5.函数成员部分公有的原因是:既然类中的数据成员是私有的,外界无法直接访问,那么我们如果希望修改对象的属性就只能通过类中提供的函数进行get或set,因此类中会提供部分公有函数成员
6.类的最标准的写法:
-----6.1类中的数据成员仍然正常定义,类的函数成员中只写函数声明函数的定义在外部书写,但是需要注意在类的外部书写函数定义时一定要加上类域
-----6.2一般将函数定义(定义内只写成员函数的声明)放到头文件中,而将类中函数成员的定义放到.cpp文件中,以时钟为例,这个类就存在两个文件:clock.h文件和clock.cpp文件,在使用clock类时我们只需要将clock.h的头文件include进来就可以正常使用

// clock.h文件
class CClock
{
private:
	int m_hour = 10;
public:
	int getHour(){};
};
// clock.cpp文件
#include"clock.h"
CClock::getHour()
{
	return m_hour;
}
//使用clock类
#include"clock.h"
CClock clock;//定义对象
hour = clock.getHour();
  • 类的访问权限的检查时机

在类的编译时期进行访问权限的检查,而在类的运行时期不做检查,因此可以通过指针的方式修改对象的私有属性

CClock clock;
clock.m_hour = 10;//编译器报错,类的私有属性不可以修改
*(int*)&clock = 10;//编译通过,此时类中的m_clock的值被修改为10
  • 类的大小:类中仅数据成员有大小函数成员公用,不算在类的大小中
//类的大小
class CClock
{
private:
	int m_hour = 1;
	int m_minute = 2;
	int m_second = 3;
};

classSize = sizeof(CClock);
  • 获取类的大小,类的大小为12个字节=3 × 4个字节
10.1 使用C语言模拟类的封装(了解即可,不要求掌握)
  • C语言的结构体中只能有数据成员,不能有函数成员,因此我们只能使用函数指针作为数据成员来代替函数成员
  • C语言中如何定义函数指针
int(*p)(int, int);

(1)*p表示该变量是一个指针
(2) int 表示该函数指针所指向的函数的返回值为int类型
(3) (int , int)表示该函数指针所指向的函数的形参为两个int型的变量

//C语言中模拟C++中类的封装
typedef void (*PFN_SetHour)(int n);
struct CClock
{
	int m_hour = 1;
	int m_minute = 2;
	int m_second = 3;
	//C语言中使用函数指针来代替函数成员
	PFN_SetHour pfnSetHour;
};
void SetHour(struct CClock* cl, int n)
{
	cl->m_hour = n;
}

int main()
{
	//首先创建结构体变量
	struct CClock cl;
	cl.pfnSetHour = SetHour;//首先给结构体中的函数指针赋值
	cl.pfnSetHour(&cl,1);//这里注意使用函数指针调用函数时,函数并不知道调用该函数的当前对象是谁,因此在调用该函数时必须调用对象的地址也传进去
	return 0;
}
  • 使用函数指针调用函数时,函数并不知道调用该函数的对象是谁,因此在调用该函数时必须将调用该函数的对象的地址也传进去,这也揭示了为什么类中会存在this指针,this指针的作用就是来指明当前调用该函数的对象是谁
10.2 this指针
  • 首先思考一个问题,上面我们在求对象的大小时,获得的结果仅仅是数据成员的大小,那么对象中函数成员有没有地址呢?
  • 同一个类的对象,其函数成员的地址是一样的,也就是说同一个类创建的对象其函数成员是公用的

(1)数据成员是独立的
(2)函数成员是公用的

  • 既然类中的函数成员是公用的,那么由同一个类创建的不同对象调用函数成员时,它是怎么区分出当前是哪个对象在调用该函数成员呢? (好问题,引出this指针)

其实C++中当对象调用函数成员时,会偷偷的向函数中传入一个this指针,通过寄存器ecx传递,这种传递方式称之为thiscall

//clock.h
class CClock
{
private:
	int m_hour = 10;
public:
	void setHour(int n){};
};
//clock.cpp
#include"clock.h"
CClock::setHour(int n)
{
	m_hour = n;//这是我们之前的写法
	this->m_hour = n;//其实类的不同对象在调用函数成员时偷偷传入了一个this指针
}

//func.cpp
#include"clock.h"
//创建类域的函数指针
typedef int(CClock::*PFN_GETHOUR)(void);
int main()
{
	CClock clock1;
	CClock clock2;
	//通过类域中的函数指针获得类域中函数的地址,单步调试发现在clock1和clock2调用setHour函数时,函数的地址并没有发生改变,说明类中的成员函数是公有的
	PFN_GETHOUR lp = &CClock::setHour;
	clock1.setHour(10);//不同对象在调用setHour时给this指针赋予了不同的地址
	clock2.setHour(20);
	return 0;
}
  • 类的成员函数指针的调用
CClock clock1;//类对象
CClock* clock2;//类对象指针

typedef int(CClock::*PFN_GETHOUR)(void);
PFN_GETHOUR lp = &CClock::setHour;
(clock1.*lp)();//类的对象调用类域成员函数
(clock2->*lp)();//类的对象指针调用类域成员函数
10.3构造函数
  • 构造函数的作用:用于给对象进行初始化
  • 构造函数的写法:

(1)函数名必须是类名
(2)不写函数的返回值类型
(3)可以有多个参数
(4)可以没有参数

  • 构造函数的调用:

(1)构造函数允许函数重载
(2)如果类中没有将构造函数进行重载,编译器会提供一个默认构造函数(无参构造,可能会被优化)
(3)当构造函数只有一个参数时,该函数既表示构造函数又表示一种隐式转换从而支持:CTest obj = 1;这种写法,在实际使用中可以通过explicit关键字表示当前的构造函数只支持显示的调用构造函数,不允许隐式转换
(4)CTest() = default; //显式的指明使用默认的构造函数
(5)CTest() = delete; //表示禁止使用默认构造函数

class CTest
{
	//无参构造函数
	CTest();
	//有一个参数的构造函数
	CTest(int n)
	{

	};
	//有多个参数的构造函数
	CTest(int n, char* name)
	{

	};
}
int main()
{
	//调用无参构造函数
	CTest t1;
	//调用一个参数的构造函数
	CTest t2 = 10;
	CTest t3 (10);
	//调用多个参数的构造函数
	CTest t4 (10,"mike");
	CTest t5 = CTest (1,"mike");
}
10.4析构函数
  • 析构函数的作用:完成资源的反初始化
  • 析构函数的写法:

(1)类名前面加上~
(2)通常是由编译器决定调用时机,不需要手动调用
(3)析构函数不能有参数和返回值
(4)析构函数不能函数重载(构造函数可以函数重载)

  • C/C++中动态分配内存
//malloc创建一个堆空间
//使用malloc进行堆空间的分配,首先必须指明要分配的堆空间的大小,然后因为malloc返回的是void指针,因此需要将其强转为不同类型的指针
m_szName = (char*)malloc(255);//这里就可以说明new与malloc的区别
//堆空间释放
free(m_szName);
  • 什么叫作内存泄漏

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

  • 析构函数的调用时机

默认情况下系统会自动为我们调用一次析构函数,因此一般情况下我们不要自己手动调用析构函数,否则会造成内存空间的重复释放,报错

  • 构造和析构函数访问权限的问题

构造和析构函数默认情况下必须是公有访问权限
为什么不能是私有访问权限呢?
(1)如果构造函数是私有访问权限,在类域外部创建类的对象时对于类的构造函数没有访问权限,此时也就无法创建对象
(2)如果析构函数是私有访问权限,当对象消亡时同样无法释放内存空间

  • 构造函数和析构函数的调用时机

先讲一句提纲挈领的:构造和析构函数的调用都是为了使用而服务的,在进入作用域之前,构造函数需要被调用,在出作用域之后,析构函数会对内存进行释放
(1)栈上的局部对象的调用时机:
构造:声明该对象时构造
析构:对象出作用域时调用析构
(2)全局对象的调用时机:
构造:进入main函数之前调用
析构:出main函数之后调用

10.5拷贝构造函数
  • 拷贝构造函数的作用:

拷贝构造函数本质上也是一种构造函数

  • 拷贝构造函数的调用时机:

当用一个对象创建另外一个对象时调用

CTest m(10,"nike");
CTest n = m;//使用对象m来创建对象n
  • 缺省的(默认的)拷贝构造函数的作用:

完全的把对象1拷贝给对象2,memcpy,默认拷贝构造函数是一种浅拷贝

  • 自定义拷贝构造函数
class CTest
{
	CTest(){};//构造函数
	CTest(CTest& obj){};//自定义拷贝构造函数
  • 拷贝构造函数和构造函数相同也可以使用=default和=delete
CTest(CTest& obj) = default;//使用默认的拷贝构造函数
CTest(CTest& obj) = delete; //不使用默认的拷贝构造函数
10.6浅拷贝与深拷贝
  • 浅拷贝:使用一个对象给另一个对象赋值的时候属于浅拷贝,浅拷贝并没有为新创建的对象分配新的内存空间,因此新创建的对象与原先创建的对象使用同一块内存空间
  • 浅拷贝存在的问题:
class CTest
{
	CTest()
	{
		//构造函数中有内存空间的开辟
		m_size = (char*)malloc(255);
	}
	~CTest()
	{	
		//析构函数中有内存空间的释放
		free(m_size);
	}
}
int main()
{
	CTest t1(10,"nike");
	CTest t2 = t1;
}

在上面这个例子中,调试程序几回发现,创建t1对象时调用了一次构造函数,创建t2对象时调用了默认的拷贝构造函数,在出main函数之前编译器会默认调用两次析构函数释放t1和t2的内存空间,但是由于默认拷贝构造函数是浅拷贝,即t1和t2实际是使用同一块内存空间,因此两次释放内存空间会报错

  • 进一步思考可以知道,之所以会造成内存空间重复释放,是因为上面类中在构造和析构时进行了内存的动态分配如果我们在类的析构和构造中并未进行内存的动态分配,则不会出现重复释放的情况
  • 如何解决上面的问题呢?——自定义拷贝构造函数,在拷贝构造函数中使用深拷贝进行实现
class CTest
{
	CTest(){};
	CTest(CTest& obj)
	{
		//浅拷贝的实现方式
		this->m_size = obj.m_size;	
		//深拷贝的实现方式
		m_size = (char*)malloc(255);
		if(m_size == nullptr)
		{
			return;
		}
		memcpy(this->m_size,obj.m_size,255);
	}
}
10.7 new与delete
  • 对象可以有全局对象,栈上的对象,那么有没有堆上的对象呢?——答案是有的
  • C语言中如何在堆上创建对象
//本质是在堆上分配内存空间,并将其地址转化为char类型
char* pBuf = (char*)malloc(10);
//C语言中在堆上给对象分配内存空间
CTest* pBuf = (CTest*)malloc(sizeof(CTest));

但是使用malloc为对象在堆上分配内存空间的方式存在以下问题:
(1)并不会调用类的构造和析构函数,因此无法完成对象的初始化和资源释放
(2)使用malloc在堆上分配内存空间的本质其实就是在堆上开辟了内存地址并将其赋值给类指针,与普通对象在堆上开辟内存空间没有区别

  • 因此,C++中提供了两个新的运算符用于在堆上开辟空间,同时也可以完成对象的初始化

new:先分配空间,然后调用构造函数进行初始化
delete:先调用析构函数,然后再释放空间

//使用new和delete为对象分配和释放堆上的内存空间
CStudent* pStu = new CStudent;
if(pStu != nullptr)
{
	delete pStu;
}	
  • 对于基本数据类型而言,new与delete也可以为其开辟和释放内存空间,但是因为基本数据类型没有构造和析构函数

int* pN = new int(123);
delete pN;

  • new与delete使用注意事项

问题1:C++中的new与delete和C语言中的malloc与free有什么区别呢?
答:new与delete和malloc与free在对普通变量进行分配和释放时没有任何区别,并且可以混用,但是在为对象分配和释放堆空间时,new和delete会调用类的构造和析构函数,而malloc和free则不会

//混用1:
char* pN = (char*)malloc(sizeof(char));
delete pN;
//混用2:
char* pN = new char;
free(pN);

问题2:如何在堆上为数组分配和释放内存空间呢?
答:
(1)对于普通数据类型变量

//使用malloc和free
int* pN = (int*)malloc(sizeof(int) * 10);
free(pN);
//使用new和delete
int* pN = new int[10];
delete [] pN;

(2)对于对象而言:
CTest* pBuf = new CTest[10];
delete [] pBuf;

  • 总结:
  • (1)当申请一个堆上对象时,使用new和delete, 不能使用malloc和free替换
  • (2)new[]分配数组, delete[]释放数组空间
  • (3)new[]与delete[]要配套使用(特别是在申请对象数组时)
  • (4)vs编译器会在new[]申请对象数组时,在堆开始的前4个字节写入当前数组的长度,用于delete[]释放时,析构调用(也就是说这4个字节存放的长度是为了告诉编译器需要调用多少次析构函数)

面向对象:继承

  • 继承的作用:

假如我们需要一个学生类和一个老师类,学生类和老师类中有许多重复的属性,为了避免在代码中重复的定义,我们可以创建一个人类,然后将学生类和老师类公有的属性写在人类中,通过继承的方式创建学生类和老师类

  • 继承中的一些叫法:

子类 ---------------父类
派生类-------------基类

  • 继承的写法:
class CPerson
{
	//构造,析构,get,set,数据成员
}
class CStudent : public CPerson
{
}
class CTeacher : public CPerson
{
}
10.8 继承的可见性

public:类域内和类域外均可访问
protected:类域本身和其派生类可以访问,其他类和类域外部均不可访问
private:类域内可以访问,类域外部不可以访问

父类 继承方式 子类
public public继承 public
protected public继承 protected
private public继承 不可见
public protected继承 protected
protected protected继承 protected
private protected继承 不可见
public private继承 private
protected private继承 private
private private继承 不可见

继承关系与基类中的可见性中取其最严格的部分

  • 从内存的角度看继承的可见性

(1)子类从父类中继承是将父类中的所有数据成员都继承过来,这一点可以通过sizeof查看内存大小来验证
(2)继承的可见性只是编译器在编译时进行的检查
(3)既然可见性是编译器在编译时做的检查,那么在程序运行时期,我们就可以通过指针的方式来修改子类从父类中继承而来的不可修改的数据成员
(4)继承的可见性实际上只是编译器的限制,是编译器一种善意的做法,在使用时尽量不要使用指针绕过编译器的限制来进行数据成员的修改

CStudent stu;
int* p = (int*)((char*)&stu + 8);
*p = 123;//通过指针将stu对象中原本不可访问的数据成员的值进行了修改
  • 指针转换的安全性问题
CPerson per;//父类创建对象
CStudent stu;//子类创建对象

CPerson* pPer = &stu;//子类转换为父类
CStudent* pStu = (CStudent*)&per;//父类转换为子类

(1)子类转父类是安全的,子类中的数据成员更多,内存空间更大,转换后相当于可访问的内存空间变小
(2)父类转子类是不安全的,父类中数据成员更少,内存空间更小,转换后子类可以访问到不属于父类的内存空间,会存在越界访问

10.9 父子类,成员类构造顺序
  • 父子类
class CStudent : public CPerson
{
}
  • 成员类
class CTest
{
private:
	CPerson per;//成员类
}
  • 父子类的构造和析构顺序

(1)构造:先父类,再子类
(2)析构:先子类,再父类

  • 成员类的构造和析构顺序

(1)构造:先成员类,再自己
(2)析构:先自己,再成员类

  • 既有父子类又有成员类时的构造和析构顺序

(1)构造:先父类,再成员类,最后子类
(2)析构:先自己,再成员类,再父类

10.10初始化列表和函数隐藏
  • 初始化列表的作用:

(1)用于调用父类中的有参构造
(2)用于自身成员的初始化
(3)用于常量成员的初始化

class CStudent : Public CPerson
{
	//没有初始化列表的构造函数
	//CStudent()
	//{
	//}
	//有初始化列表的构造函数
	CStudent():CPerson(110,4),m_nStuID(10),n(12)
	{
	}
private:
	int m_nStuID;
	const int n;//也可以直接写const int n = 10;
}
  • 类中的数据隐藏,函数隐藏
class CPerson
{
public:
	void test(){};//父类中的成员函数
private:
	int m_test = 20;//父类中的数据成员
};
class CStudent
{
publicvoid test(){};//子类中的成员函数
private:
	int m_test = 10;//子类中的数据成员
};

int main()
{
	CStudent stu;
	stu.m_test = 1;//修改的是子类中的数据成员
	stu.test();//调用的是子类中的成员函数
}

总结:
(1)类中的数据隐藏和函数隐藏都是由内向外的,当从子类中找到数据成员或者函数成员时,就不会再去找父类中的
(2)构成函数隐藏的条件:
作用域不同
函数名相同
返回值类型,参数列表,调用约定均不作考虑

  • 这里个人还需要对函数隐藏函数重载进行一个区分:
函数重载 函数隐藏
函数名相同 函数名相同
作用域相同 作用域不同
参数列表的顺序,类型不同也可以构成函数重载 参数列表的顺序,类型不同不可以构成函数隐藏
调用约定不同不构成函数重载 调用约定不同不构成函数隐藏

面向对象:多态

  • 应用场景举例:

一个父类:人类,有一个功能:说人话
两个子类:中国人类,英国人类,在这两个子类中都重写了说话的功能函数,实现函数隐藏
目标:一群人在说话,创建一个对象指针数组,希望不同对象调用说话功能函数时,都可以使用各自的说话函数
例如:中国人:说中文;英国人:说英文
而不是都调用父类中的功能函数:说人话

  • 上面这种功能实现就是多态,在C++中可以使用virtual关键字实现多态
class CPerson
{
public:
	virtual voidty speak() {};
}
10.11从内存的角度理解虚函数的原理
class CPerson
{
public:
	virtual void speak()
	{
		printf("说人话\r\d");
	};
private:
	int ID = 10;
};
class CStudent:public SPerson
{
public:
	virtual void speak()
	{
		printf("说中文\r\d");
	};
};
int main()
{
	CStudent stu;
}
  • sizeof(CStudent)发现它的内存大小是8个字节,其中后4个字节存放的是从父类继承来的ID,那么前4个字节存放的是什么呢?
增加virtual关键字
stu
stu对象地址 前4个字节存放指针,该指针指向CStudent中的speak函数 CStudent函数地址
ID = 10
  • 上面表格描述的就是一个虚函数表,当父类中的函数前加上关键字virtual时,子类对象的大小就会增加4个字节,这4个字节存放的是一个指针,该指针指向其对应的函数(例如中国人说中文)
  • 虚函数的实现过程其实就是创建对象后,当需要调用对象对应的函数成员时,首先找到该对象的地址,然后找到该对象的前4个字节,通过该对象的前4个字节中存放的指针,找到对应的函数,然后调用,达到多态的效果
  • 虚函数的调用原理:

(1)虚函数的调用方法是间接调用,先查虚表地址,再查虚表中的虚函数指针
(2)增加了虚函数virtual关键字的对象头部4个字节是虚表地址(某些情况,单继承)

10.12函数覆盖(虚函数)
  • 函数覆盖的条件:

1.作用域不同(父子类之间的继承关系)
2.函数名,参数列表(参数类型,参数个数,参数顺序),调用约定(__thiscall),返回值类型必须相同
3.必须要有virtual关键字
【深入理解C++面试常考知识点】_第1张图片

  • 虚函数的个人理解:

1.为什么一定要有virtual关键字,我觉的virtual关键字其实更像是一个标识符,它既要写在父类中,也要写在子类中,凡是前面有virtual关键字的成员函数都会被放到虚函数表中
2.如果父类中有写virtual关键字,而子类中没有写virtual关键字,当子类继承父类时,虚函数表中只存在父类的虚函数,因此此时只能继承来自父类的虚函数
3.如果子类和父类中都写了virtual关键字,在创建子类对象,并调用其函数成员时就会存在函数覆盖的现象,即子类中虚函数覆盖了父类中的虚函数

  • 函数重载,函数隐藏,函数覆盖,三个非常容易混淆的概念

1.函数重载:
(1)函数名称相同
(2)参数列表(个数,类型,顺序)不同
(3)作用域相同
(4)函数重载不考虑返回值,调用约定的不同

2.函数隐藏:
(1)函数名相同
(2)参数列表(个数,类型,顺序)不做考虑
(3)作用域不同
(4)函数隐藏不考虑返回值,调用约定的不同

3.函数覆盖:
(1)函数名相同
(2)参数列表(个数,类型,顺序)必须相同
(3)作用域不同(父子类之间继承关系)
(4)函数覆盖要求返回值,调用约定也必须相同

  • 补充:当类中没有数据成员时,使用sizeof()取类的大小为1,1个字节的占位符
  • 如果子类中的虚函数的参数列表的个数,顺序,类型与父类中不一致,实际上并不是构成虚函数,而是相当于子类中进行了函数重载
  • 虚表中虚函数的顺序:

1.子类继承了所有的父类虚函数(公有,私有虚函数没有意义)
2.父类中进行虚函数定义时的顺序就决定了子类中虚函数的顺序
3.子类重写了父类的虚函数,则会在子类自己的虚表中覆盖对应位置的虚函数
4.子类如果未重写父类中的某个虚函数,则直接继承父类的该虚函数
5.子类自己定义的虚函数(父类中未定义)则会出现在子类虚表中所有父类虚函数的后面

  • 虚表大小的确定:

(1)由编译器在编译时期确定的
(2)在运行时,内存中并没有虚函数个数的表示
(3)虚表并不是以00结尾的(视频中仅仅是VS编译器中的巧合)

10.13虚函数的调用方式
  • 1.直接调用

根据函数名称,直接调用该函数(在编译器编译时候就已经确定)
(1)普通的函数调用
(2)对象的普通成员函数的调用
(3)对象的虚函数的调用

  • 2.间接调用(虚调用,即通过查虚表来调用)

虚函数通过查找对象虚表下标来调用函数的方法(在运行时期确定调用谁)
(1)通过对象的指针调用虚函数
(2)通过对象的引用调用虚函数

class CTest()
{
public:
	void foo1(){};
	virtual void foo2(){};
};
void foo()
{
};
int main()
{
	//下面这三个例子都是直接调用的例子
	foo();//普通函数的调用
	CTest test;
	test.foo1();//对象普通函数成员的调用
	test.foo2();//对象的虚函数的调用

	//下面再举两个间接调用的例子
	CTest* test1 = &test;
	test1->foo2();//使用对象的指针通过查找续表地址来调用
	CTest& test2 = test;
	test2.foo2();//使用对象的引用通过查找虚表地址来调用

	//第三个值得思考的例子
	//当我们明确了类域范围后,不论是指针还是引用,此时的调用均是直接调用
	test1->CTest::foo2();
	test2.CTest::foo2();//不太确定有没有这种写法
}
10.14如何区分复杂函数中的函数重载,函数隐藏和函数覆盖

调用者类型来决定查找的起点
(1)在调用者的类中,查找同名函数
(11)如果没有,则在上一层查找,如果均找不到,则报错(函数未定义错误)
(12)如果有,则不会再往上面查找,可见的域就是当前找到的同名函数所在的域(函数隐藏)
。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。
(2)在当前可见的域中,找到最佳函数(函数重载规则)
(21)如果唯一的最佳函数不是虚函数,那么该调用就是直接调用
(22)如果唯一的最佳函数是虚函数,判断该调用是否是指针或引用
(221)调用者是指针或引用,则是间接调用(函数覆盖规则)
(222)调用者不是指针或引用,则是直接调用
(223)调用者是指针或引用,但指定了类域,则也是直接调用

10.15 常成员函数
  • 常成员函数的作用和意义:

使得无法修改数据成员,一般用来修饰类中的get成员函数
让编译器提醒开发者函数不能修改类的成员变量,用于const对象(引用或指针)

  • 常成员函数的写法:
class CTest
{
public:
	char getNumber() const;//函数声明
};

char CTest::getNumber() const
{
	return m_nNumber;	
}

  • 常成员函数为什么就不能对其数据成员进行修改,其本质是什么?

(1)普通的成员函数:函数内this指针类型:T* const(指针常量):表示指针本身不能被修改,但是可以修改指针指向的内容,也就是修改数据成员的值
(2)常成员函数:函数内this指针类型:const T* const
(2.1)该指针本身不能被修改
(2.2)该指针指向的内容也不能被修改

因此,常成员函数的本质就是将this指针变为const T* const类型,限制开发者对其内容和指向的修改

如果我们的需求是大部分的数据成员不能被修改,而部分成员函数确实有在成员函数中修改的需求,该怎么办呢?
(1)第一种实现方式:强行将this指针修改为T* const 类型(不安全,因为一旦修改this指针的类型,就意味着其他我们不想要修改的数据成员也可以被修改了)
(2)第二种实现方式:在需要被修改的数据成员前加上mutable关键字

class CInteger
{
public:
	CInteger(int nNumber)
	{
		m_Number = nNumber;
	}
	int getNumber();//一个只写了函数声明
	void SetNumber(int nNumber)//一个写了完整的函数实现
	{
		m_Number = nNumber;
	}
private:
	//mutable int m_Number;	//添加mutable关键字可以进行修改
	int m_Number;
};

int CInteger::getNumber()
{
	m_Number = 1;//理论上来说,我们不希望get函数可以修改数据成员,因此常成员函数的需求就来了
	//学过this指针我们知道m_Number的本质是this-m_Number = 1;
	//因为普通成员函数中的this指针是T* cosnt类型,可以修改指针指向的内容,因此允许修改
	return m_Number;
}
/*
int CInteger::getNumber() const
{
	m_Number = 1;//此时修改编译器就会报错
	//学过this指针我们知道m_Number本质是this->m_Number
	//因为常成员函数内部的this指针为const T* const类型,我们既不能修改指针的指向,也不能修改指针指向的内容,因此无法修改

	//那么如果我们希望强行修改呢?
	//方法一:
	CIntegr* const p = (Cinteger* const)this;
	p->m_Number = 123;//此时可以修改
	//方法二:
	通过在类的成员变量定义时添加mutable关键字
	m_Number = 1234;
	return m_Number;
}
*/

int main(int argc, char* argv[])
{
	CInteger i = 1;
	return 0;
}
10.16常成员变量及初始化列表
  • 常成员变量要么在定义时就进行赋值,要么就使用类的初始化列表进行赋值,否则会报错
  • 初始化列表的作用:

(1)可以用于初始化普通成员变量/常成员变量
(2)通常用来构造有参数的成员对象

class CTest
{
public:
	CTest(int n, int m)
	{
		m_n = n;
		m_m = m;
	}
private:
	int m_n;
	int m_m;
};

class CInteger
{
public:
	CInteger(int nNumber):
	m_Num(10),//初始化列表给const成员变量初始化
	m_Number(200),//初始化列表给普通成员变量进行初始化
	m_t(1,2) //构造有参对象(初始化)
	{
		m_Number = nNumber;
	}
private:
	int m_Number;
	const int m_Num;
	//const成员变量要么在定义时直接初始化
	//const int m_Num = 10;
	CTest m_t;//将另一个类的有参对象作为该类的数据成员	
};
10.17静态成员变量
  • 关键字 static
  • 静态成员变量的标准写法

首先回忆下,类的标准写法:
(1)类的定义写在头文件中,类中成员函数的实现写在cpp文件中
(2)头文件中只写类的成员函数的声明,cpp文件中写类的成员函数的实现

//头文件
class CTest
{
publicCTest() = default;
	int getNumber();//函数声明
public:
	static int m_nStaic;//静态成员变量必须在cpp中写实现,静态成员变量和类的成员函数类似,是属于同一个类的所有对象所公用的
private:
	int m_Number;
};
//cpp文件中
int CTest::getNumber()
{
	return m_Number;
}
int CTest::m_nStatic = 10;//这里我们给静态成员变量进行了初识化,如果未进行初始化,编译器默认会赋值为0
int globalVar = 10;
int main()
{
	CTest t;
	t.m_nStatic = 100;
	CTest::m_nStatic = 10;//不需要进行对象的实例化就可以使用静态成员变量
}
  • 静态成员变量总结:

(1)静态成员变量和类的成员函数类似,都是同一个类的不同对象所共用的
(2)因此静态成员变量也要和成员函数类似,要单独把其实现写在类额外面
(3)如果我们未给静态成员变量赋值,默认情况下系统会用0对其进行初始化
(4)静态成员变量和全局变量非常类似,其本质就是一个带类域的全局变量

你可能感兴趣的:(C++,c++)