🍕 🍕 关于上期留下的几个思考题,小伙伴们有没有好好思考呢?如果没有也没关系,接下来就为大家一一介绍,同时还会引入C++类和对象(上篇),希望各位小伙伴们看完后能够有所收获。
文章目录
一、思考题
🍔思考(面试题):为什么C++中支持函数重载,而C语言中不支持?
关于函数重载这块,就要谈到程序的运行过程了。(如图)
🍎 执行过程:
预处理:头文件展开,宏替换,去掉注释,条件编译。
编译过程会对语法,代码进行检查,如果没有问题就会生成汇编代码。汇编代码是给程序员看的,计算机并不认识。所以通过汇编过程,汇编代码就会被转换成计算机所认识的二进制机器码,最后通过链接生成可执行的程序。
🔍 🔍 而函数重载的关键就在编译后链接前这个过程中。
我们知道一个项目通常都有一个头文件和多个源文件,比如test.cpp中调用了Swap.cpp中的Swap函数时。在编译后链接前这个过程中,test.o的目标文件中没有Swap的函数地址,当链接器看到这个问题,它就会去Swap.o中的符号表去找Swap函数的地址,然后链接到一起。
✈️ 那么它要怎么去寻找呢?
编译器通过对函数名进行修饰,最后通过被修饰的函数名找到函数的地址。
在Windoms下vs中的修饰规则比较复杂,这里我们就通过Linux进行演示:
🏀 函数名修饰规则(如下图)
(1)C语言编译器编译后
(在Linux下的gcc中运行)从图中我们可以发现,函数名被修饰后没有发生变化。
(2)C++编译器编译后
(在Linux下的g++中运行)从图中我们可以看到,函数名被修饰后发生了变化,加入了函数参数类型的信息。
📜 总结:在编译后链接前这个过程中,通过被修饰的函数名,链接器会通过被修饰的函数名找到函数的地址。在Linux中的gcc中,我们可以看到函数名被修饰后没有变化;而在g++中被修饰后函数名中加入了参数类型信息,当函数的参数类型、参数的个数不同时,被修饰后的函数名也不同,所以可以实现函数重载。
✏️ 延申:通过函数名修饰规则,我们也能明白为什么函数的返回值不同,不能构成函数重载。
🍔思考(面试题):C++能否将一个函数按照C的风格进行编译?
在C++中,有时候需要将函数按照C的风格进行编译,在函数前加上extern”c“,就是告诉编译器按照C的风格进行编译。
🍔思考(面试题):C++中为什么不推荐使用宏?并采用了什么手段来代替宏?
谈到C++中为什么不推荐使用宏,这里我们首先要明确宏的优缺点。
🍏 优点:
(1)复读性强
(2)避免函数入栈,出栈的操作,提高运行效率,减少系统开销
🍋 缺点:
(1)在预处理中宏会被替换,对宏难以进行调试。
(2)可读性差,容易误用。
(3)没有类型安全的检查。
(4)宏不能递归。
对于这些优缺点,我们来逐句分析分析:
⏳ (1)复读性强
原因:
#include<iostream>
using std::cout;
using std::cin;
using std::endl;
//当我们要修改数组的大小时,可以直接修改宏定义的值
#define _MAX 40
int main() {
int arr1[_MAX]{ 0 };
int arr2[_MAX]{ 1 };
return 0;
}
即当我们要修改数组的大小时,可以直接修改宏定义的值。
⏳ (2)避免函数入栈,出栈的操作,提高运行效率,减少系统开销.
原因:在预处理过程中,宏会被替换(展开),类似于内联函数。宏定义不分配内存,而变量的定义会分配内存。
⏳ (3)在预处理中宏会被替换,对宏难以进行调试。
原因:我们进行调试的时间是在通过编译、链接生成可执行程序之后。
⏳ (4)可读性差,容易误用
原因:
#include<iostream>
using std::cout;
using std::cin;
using std::endl;
//当我们用宏来定义一个函数
#define _ADD1(a,b) a+b//错误示范
#define _ADD2(a,b) ((a)+(b))//正确示范
int main() {
//这里看起来没问题
int ret1 = _ADD1(1, 2);
cout << ret1 << endl;
//但是如果计算下面这个值呢
int ret2 = _ADD1(1, 2) * 2;
//按道理来说应该是6,但结果却是5
cout << ret2 << endl;
int ret3 = _ADD2(1, 2) * 2;
//正确的定义过程比较难以理解,坑多
cout << ret3 << endl;
return 0;
}
用定义一个ADD函数时:如果我们定义的是_ADD1,那么在计算ret2=_ADD2(1,2)2时,通过预处理的宏替换后原式会变成ret2=1+22,所以就得不到想要的结果。正确的定义方式为#define _ADD2(a,b) ((a)+(b)),但是这种定义方式很容易因为某个地方的考虑不全导致定义错误,所以可读性差,容易误用。
⏳ (5)没有类型安全的检查
原因:宏定义与类型无关,而且在预处理的时候就会被替换,而在编译过程才会对类型和语法的进行检查。所以宏不够严谨,安全性能有待提高。
⏳ (6)宏不能递归
原因:宏会在预处理时被替换,如果递归中采用了宏,那么一次次的替换会导致代码量过大,消耗大量的空间,本质上来说宏提高效率是通过空间换时间的方式。(不能递归的原因与内联函数相似)
❗️ ❗️ C++中采用内联函数来替换宏的函数定义,const替换常量定义。
思考题解决完了,我们也要开始今天的正题了 😎 😎 :
二、面向过程和面向对象初步认识
🌳 疑问:
🌻 面向过程和面向对象是什么?
🌻 面向对象是怎么出现的呢?
🌻 它们有什么关联呢?
🌵 C语言是面向过程的,关注的是过程。面向过程是一种以事件为中心的编程思想。分析出将解决问题的步骤,并用函数一一实现。
🌵 在平日的生活和编程中,简单的问题可以通过面向过程来解决。但是一旦问题的规模变得十分复杂,通过面向过程是难以实现的。于是面向对象这一思想出现了。
🌵 C++是面向对象的,关注的是对象。世界上有很多人和事物,每一个都可以看做一个对象,而每个对象都有自己的属性和行为,对象与对象之间通过方法来交互。
🌵 面向对象是一种以“对象”为中心的编程思想,把要解决的问题分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个对象在整个解决问题的步骤中的属性和行为。
三、类
(1)类的引入
🚁 🚁 因为C++兼容C的语法,所以结构体在C++中仍然适用。但是C++以结构体为基础,加入了一些新的内容,并引入了类的概念。
在结构体(类)内不仅可以定义变量,也可以定义函数。
代码⬇️ ⬇️ :
typedef struct Stu
{
//成员函数
void InfoPrint();
//成员变量
char _name[5];
char _sex[5];
int _age;
int _password[20];
int _English;
int _math;
}Stu;
class Date
{
//成员函数
void Print();
//成员变量
int _year;
int _month;
int _day;
};
📜 在C++中,类用class进行定义。class和struct作用相似,唯一的区别就是:class定义的结构体默认成员访问方式是private,而struct定义的结构体默认成员访问方式是public的,这点也与C中结构体内容同步。(关于访问限定符,接下来会进行详细介绍)
(2)类的定义
代码⬇️ ⬇️ :
class Date
{
void Print();
int _year;
int _month;
int _day;
};
🌴 class是定义类的关键字,Date是类的名字,花括号中是类的主体。
🌴 类中的元素被称作成员,其中类的数据称为类的属性或者成员变量,类中的函数被称为类的方法或者成员函数。
🌴 类只是一个模板(Template),编译后不占用内存空间,所以在定义类时不能对成员变量进行初始化,因为没有地方存储数据。只有在创建对象以后才会给成员变量分配内存,这个时候就可以赋值了。
类的定义方式:
(1)声明和定义都放在类中。
代码⬇️ ⬇️ :
class Date
{
public:
//在类中定义
void Print()
{
cout << _year << " " << _month
<< " " << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
✒️ 注意!!
成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
(2)声明放在.h头文件中,定义放在.cpp源文件中。
代码⬇️ ⬇️ :
//头文件Date.h
#include<iostream>
using std::cin;
using std::cout;
using std::endl;
class Date
{
public:
//在类中定义
void Print();
private:
int _year;
int _month;
int _day;
};
//源文件Date.cpp
#include"Date.h"
void Date::Print()
{
cout << _year << " " << _month
<< " " << _day << endl;
}
✒️ 注意!!
一般推荐将函数的声明和定义分离。
(3)类的访问限定符以及封装
(这里主要介绍public和private,因为protected涉及到了后面的内容)
🍉 public:表明该数据成员、成员函数是对所有用户开放的,所有用户都可以直接进行调用。
🍉 private:表示私有,除了自己可以使用外,任何人都不可以直接使用,继承都不可以使用。
class Date
{
public:
//在类中定义
void Print();
private:
int _year;
int _month;
int _day;
};
void Date::Print()
{
cout << _year << " " << _month
<< " " << _day << endl;
}
int main() {
Date a;
a.Print();
//报错
//a._year;
return 0;
}
如果用a去访问_year(私有成员),则会报错:
🔧 几点说明:
🎂 public修饰的成员在类外可以直接被访问。
🎂 protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)。
🎂 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止。
🎂 class的默认访问权限为private,struct为public(因为struct要兼容C)。
🎂 一般我们将成员函数访问方式设置为public,同时将成员变量的访问方式设置为private。一般,因为我们在类外是要访问成员函数的,同时我们并不希望外界可以随意的更改对象的成员变量,要进行修改也只能通过成员函数来实现。
🎂 访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
🍔思考 (面试题):C++中class和struct有什么区别?
2.封装
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
(这里只简单说明一下,学到了后面会详细介绍的)
(4)类的作用域
类定义了一个新的作用域,类的作用域简称为类域,指的是类的定义中由一对花括号括起来的内容,每个类都有该类的类域。在类外定义类的成员时需要使用“::”作用域限定符来指明成员的归属。
(5)类的实例化
类的实例化:通过类创建对象的过程称为类的实例化。
🍒 区别一下对象和变量:
对象:对象是一段存储空间,简单来说就是一块内存。类比到现实中就是具体的某个人。
变量:变量就是一个符号,或者说是名字。类比到现实中相当于是身份证。
回到正题:
🍒 定义类与创建对象的区别:
定义一个类将相当于自定义一种新的数据类型,然后我们通过定义的这个类来创建对象。类比定义的类是int类型,这个时候并没有创建变量,创建对象就是用int定义一个a。
(6)类的对象大小
1.类中成员的存储问题
对于类的对象大小计算,就不得不谈到类中成员的存储问题了。
🍆 创建对象的过程中,系统会为对象分配存储空间,这块空间包含了成员变量,而成员函数却被放在了公共的代码段。
🌻 为什么成员函数会被放入公共的代码段呢?
当用类创建对象时,系统都会为对象分配空间。如果将成员函数放入创建的对象中,当我们用同一个类创建多个对象时,每一个对象都会为它的成员函数分配一块空间。
🌻 那么这样做的缺点在哪里呢?
因为创建的对象都是同一个类类型,那它的成员函数必然都是一样的,这样的话会造成空间的大量浪费。所以系统将成员函数放入公共代码段中,当对象需要使用成员函数的时候,直接去公共代码段进行调用就可以了。
2.类的对象计算及分析
那了解了这些内容,我们就可以计算对象的大小了:
代码⬇️⬇️:
#include<iostream>
using std::cin;
using std::cout;
using std::endl;
//空类
class A
{
};
class B
{
private:
int b;
int c;
};
class C
{
public:
void Print();
private:
int b;
int c;
};
class D
{
private:
int b;
int c;
char d;
};
int main()
{
cout << "A=" << sizeof(A) << endl;
cout << "B=" << sizeof(B) << endl;
cout << "C=" << sizeof(C) << endl;
cout << "D=" << sizeof(D) << endl;
return 0;
}
计算的结果为:1,8,8,12。
🌻 小伙伴们对这个结果是不是很疑惑?
对象B的大小为8可以理解,因为B中有两个int型的成员变量。
对象C的大小为8也可以理解,因为成员函数放入了公共代码段,计算的时候不包括成员函数,同时存在两个int型的成员变量。
🌻 那么为什么对象A的大小为1呢?
其实系统为空类分配一个字节的空间,起到的是一个"占位’的作用,标识这个空类,表示存在空类A类型。
🌻 同时为什么对象D的大小为12?
这里就要引出我们接下来要讲的结构体内存对齐原则了。
3.结构体内存对齐原则
🌻 为什么会出现内存对齐原则呢?
我们都知道计算机以字节作为单位,按理来说CPU可以访问任意一个字节,CPU又是根据地址总线来访问内存的。在32位中,CPU的实际寻址步长是4个字节(64位中是8个字节),即访问是一块、一块空间进行的,所以CPU访问的起始位置都是4的倍数。当没有内存对齐的时候,如果读取一个int的变量,很难通过一次读取完成,如下图:
当CPU从第一个字节开始读取时,因为一次读取4个字节,取得int类型的变量就需要通过两次来完成(第一次获得int的3个字节,第二次获得1个),先读取4个字节放入寄存器中,然后再把后面4个字节中的第一个字节放入寄存器中进行整合,同时把第二次中的另外3个字节剔,因为内存没有对齐导致CPU进行了大量额外的操作,这样就会导致CPU的读取效率下降。而通过内存对齐就可以很好地解决这个问题。
内存对齐的原则:
🍪 第一个成员在与结构体偏移量为0的地址处。
🍪 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
🍪 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
🍪 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8
🍔思考(面试题):
- 结构体怎么对齐? 为什么要进行内存对齐?
- 如何让结构体按照指定的对齐参数进行对齐?
- 如何知道结构体中某个成员相对于结构体起始位置的偏移量?
- 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景?
(下期解答)
四、this指针
(1)this指针的引出
代码⬇️⬇️:
#include<iostream>
using std::cin;
using std::cout;
using std::endl;
class Date
{
public:
void SetDate(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.SetDate(2018,5,1);
Date d2;
d2.SetDate(2018,7,1);
return 0;
}
🌻 Date类中有SetDate成员函数,函数体中没有关于不同对象的区分,那当s1调用SetDate函数时,该函数是如何知道应该设置s1对象,而不是设置s2对象呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。编译器会自动完成成员函数中的this指针。
(2)this指针的作用
this指针的作用域在类的内部,作为一个隐含形参,类的非静态对象去访问类的非静态成员函数时,编译器会创建一个指向该对象的this指针,该指针在函数调用结束后会被自动清除。
(3)this指针的特点
🍑 this指针只能出现在类的非静态成员函数之中,静态函数和全局函数都不能使用this指针。
🍑 this指针作为隐含形参,它也可以显示使用。
🍑 成员函数的第一个默认参数就是* const this。
代码⬇️ ⬇️ :
class Date
{
public:
void SetDate(int year,int month,int day)
{
_year = year;
//this指针的显示使用
this->_month = month;
this->_day = day;
}
//Date operator+=(Date* const this, int day)这种写法不可以
Date operator+=(int day)
{
//...
//this指针的显示使用
return *this;
}
private:
int _year;
int _month;
int _day;
};
🍔思考(面试题):
- this指针存在哪里?
- this指针可以为空吗?
(下期解答)
如果小伙伴们觉得有所收获的话,不妨收藏一波,谢谢老铁们的支持!!