前言
在学习c++类和对象之前,我们来讨论下建造房子的过程,首先我们需要明白想要筑造什么样子的房子,是高楼大厦或者乡间小屋亦或者小区楼层,然后我们需要在建造前设计出蓝图,而c++中的类就类似于蓝图,由类实例化出对象(相当于根据蓝图找到一块地皮来建造出来毛胚房)。而后面对毛胚房进行精装或者修饰则相当于对对象进行数据结构上的操作。
C++ 是一种面向对象的编程语言,它支持类(Class)和对象(Object)的概念。面向对象编程(OOP)是一种编程范式,它使用“对象”来设计应用程序和计算机程序。在 C++ 中,类是创建对象的蓝图或模板,它定义了对象的属性(也称为成员变量或数据成员)和方法(也称为成员函数或成员函数)。
一、类的定义
1.1内置类型和自定义类型
在C++中,自定义类型和内置类型(也称为基本数据类型)是两种不同类型的变量或数据结构,它们在定义方式、用途、性能以及可扩展性方面存在显著区别。
内置类型(Basic Types)内置类型是C++语言直接支持的数据类型,它们预定义在语言中,不需要用户自定义。内置类型常用的有(int,char,float,bool,void,指针,等)。
自定义类型(User-Defined Types)自定义类型是由用户根据内置类型或其他自定义类型定义的数据结构。
自定义类型常用的有(类(Classes),结构体(Structs),枚举(Enums),联合体(Unions),等)
定义方式:内置类型由C++语言直接支持,不需要额外定义;自定义类型需要用户通过特定的语法(如struct、class等)来定义。
自定义类型通常会包含内置类型来进行构造,以建筑蓝图的视角来看,内置类型更像是木头,砖头,钢筋这些材料,自定义类型将他们以一种特定的方式作为成员进行结合来组成更为复杂的结构。
用途:内置类型适用于简单的数据存储和运算;自定义类型则适用于复杂的数据结构和行为封装,是实现面向对象编程的基石。
1.2 类定义格式
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
实例:
class Date
{
//访问限制符
public:
//类的方法(或者叫做类的成员函数)
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//类的属性(或者叫做类的成员变量)
private:
// 为了区分成员变量,⼀般习惯上成员变量
// 会加⼀个特殊标识,如_ 或者 m开头
int _year; // year_ m_year
int _month;
int _day;
};
int main()
{
Date d;
d.Init(2024, 3, 31);
return 0;
}
代码解释:
class为定义类的关键字,Date为类的名字,{}中为类的主体,注意类定义结束时后⾯分号不能省略。类体中内容称为类的成员:类中的变量(_year,_month,_day)称为类的属性或成员变量;类中的函数称为类的⽅法或者成员函数。
• C++中struct也可以定义类,C++兼容C中struct的⽤法,同时struct升级成了类,明显的变化是struct中可以定义函数,⼀般情况下我们还是推荐⽤class定义类。
• 定义在类⾯的成员函数默认为inline。当我们在代码块中使用到成员函数时,如果编译器认可成员函数作为内联函数的使用,那么就不需要开辟额外的帧栈来调用函数,而是直接执行这个内联函数。
1.2 访问限定符
• C++⼀种实现封装的⽅式,⽤类将对象的属性与⽅法结合在⼀块,让对象更加完善,通过访问权限选择性的将其接提供给外部的⽤⼾使⽤。
• public修饰的成员在类外可以直接被访问;protected和private修饰的成员在类外不能直接被访问,protected和private是⼀样的,以后继承才能体现出他们的区别。
• 访问权限作⽤域从该访问限定符出现的位置开始直到下⼀个访问限定符出现时为⽌,如果后⾯没有访问限定符,作⽤域就到}即类结束。
• class定义成员没有被访问限定符修饰时默认为private,struct默认为public。
• ⼀般成员变量都会被限制为private/protected,需要给别⼈使⽤的成员函数会放为public。
1.3 类域
• 类定义了⼀个新的作⽤域,类的所有成员都在类的作⽤域中,在类体外定义成员时,需要使⽤::作⽤域操作符指明成员属于哪个类域。
• 类域影响的是编译的查找规则,下⾯程序中Init如果不指定类域Stack,那么编译器就把Init当成全局函数,那么编译时,找不到array等成员的声明/定义在哪⾥,就会报错。指定类域Stack,就是知道Init是成员函数,当前域找不到的array等成员,就会到类域中去查找。
#include<iostream>
using namespace std;
class Stack
{
public:
// 成员函数的定义,没有声明
void Init(int n = 4);
private:
// 成员变量
int* array;
size_t capacity;
size_t top;
};
// 成员函数的声明,声明和定义分离,需要指定类域
void Stack::Init(int n)
{
array = (int*)malloc(sizeof(int) * n);
if (nullptr == array)
{
perror("malloc申请空间失败");
return;
}
capacity = n;
top = 0;
}
int main()
{
Stack st;
st.Init();
return 0;
}
1.3.1 域(Domain)的扩展
- 作用域(Scope):
• 全局作用域:全局变量和函数定义在全局作用域中,它们在程序的任何地方都是可见的(除了被局部作用域中的同名变量隐藏的情况)。
• 局部作用域:局部变量定义在函数或代码块内部,它们只在定义它们的函数或代码块内部可见。
• 命名空间作用域:命名空间提供了一种封装标识符(如变量名、函数名等)的方法,以防止命名冲突。命名空间作用域内的标识符在命名空间外部是不可见的,除非通过命名空间名或using声明/指令来访问。
2.•类域(Class Scope):
• 当你在类中定义成员变量或成员函数时,它们位于类的作用域内。这意味着它们只能通过类的对象(对于非静态成员)或类名(对于静态成员)来访问。
3.作用域解析运算符(::):
• 这不是一个"域"的概念,但它与作用域紧密相关。作用域解析运算符用于指定某个标识符的作用域,特别是在存在命名冲突时。例如,std::vector表示vector是在std命名空间内定义的。
总而言之,域的出现是为了更好的运用C++程序中元素的可见性和生命周期。
二、类的实例化
2.1实例化概念
• ⽤类类型在物理内存中创建对象的过程,称为类实例化出对象。
• 类是对象进⾏⼀种抽象描述,是⼀个模型⼀样的东西,限定了类有哪些成员变量,这些成员变量只
是声明,没有分配空间,⽤类实例化出对象时,才会分配空间。
• ⼀个类可以实例化出多个对象,实例化出的对象占⽤实际的物理空间,存储类成员变量。打个⽐
⽅:类实例化出对象就像现实中使⽤建筑设计图建造出房⼦,类就像是设计图,设计图规划了有多
少个房间,房间⼤⼩功能等,但是并没有实体的建筑存在,也不能住⼈,⽤设计图修建出房⼦,房
⼦才能住⼈。同样类就像设计图⼀样,不能存储数据,实例化出的对象分配物理内存存储数据
#include<iostream>
using namespace std;
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
// 这⾥只是声明,没有开空间
int _year;
int _month;
int _day;
};
int main()
{
// Date类实例化出对象d1和d2
Date d1;
Date d2;
d1.Init(2024, 3, 31);
d1.Print();
d2.Init(2024, 7, 5);
d2.Print();
return 0;
}
2.2 对象内存⼤⼩
分析⼀下类对象中有哪些成员呢?类实例化出的每个对象,都有独⽴的数据空间,所以对象中肯定包含
成员变量,那么成员函数是否包含呢?⾸先函数被编译后是⼀段指令,对象中没办法存储,这些指令
存储在⼀个单独的区域(代码段),那么对象中⾮要存储的话,只能是成员函数的指针。再分析⼀下,对
象中是否有存储指针的必要呢,Date实例化d1和d2两个对象,d1和d2都有各⾃独⽴的成员变量
_year/_month/_day存储各⾃的数据,但是d1和d2的成员函数Init/Print指针却是⼀样的,存储在对象
中就浪费了。如果⽤Date实例化100个对象,那么成员函数指针就重复存储100次,太浪费了。这⾥需
要再额外哆嗦⼀下,其实函数指针是不需要存储的,函数指针是⼀个地址,调⽤函数被编译成汇编指
令[call地址],其实编译器在编译链接时,就要找到函数的地址,不是在运⾏时找,只有动态多态是在
运⾏时找,就需要存储函数地址。
2.2.1 成员函数的存储设计
当我们实例化多个对象时,它们共有相同的成员函数,我们在VS上调试上面的代码,运行到Init函数执行阶段转到汇编可以看出来d1和d2在调用Init函数时call调用的地址相同
下面的第二个存储设计方式就是当前运用的存储设计方式
2.2.2 成员变量的内存对齐原则
上⾯我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对⻬的规则。
内存对⻬规则
• 第⼀个成员在与结构体偏移量为0的地址处。
• 其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。
• 注意:对⻬数=编译器默认的⼀个对⻬数与该成员⼤⼩的较⼩值。
• VS中默认的对⻬数为8
• 结构体总⼤⼩为:最⼤对⻬数(所有变量类型最⼤者与默认对⻬参数取最⼩)的整数倍。
• 如果嵌套了结构体的情况,嵌套的结构体对⻬到⾃⼰的最⼤对⻬数的整数倍处,结构体的整体⼤⼩
就是所有最⼤对⻬数(含嵌套结构体的对⻬数)的整数倍。
当我们用A实例化出对象时
内存大小分析:
第一个成员_ch在定义A类型对象的偏移量为0处,然后int型在为4个字节,小于vs的默认八字节,那么对齐数就是4,接着寻找4的倍数处。最后计算最大对齐数的整数倍为8。
class B
{
public:
void Print()
{
//...
}
};
class C
{
};
上面代码B/C实例化对象后,无论有没有定义成员变量,都会给1个字节,为什么没有成员变量还要给1个
字节呢?怎么表⽰对象存在过呢!所以这⾥给1字节,纯粹是为了占位标识。
2.2.2.1 为什么使用对齐数(仅作为了解)
对齐数(或称为对齐边界、对齐要求、对齐粒度)的使用在计算机编程和硬件设计中起着至关重要的作用。它们主要用于优化内存访问、提高数据处理的效率以及确保系统的稳定性和性能。以下是几个主要原因:
1.提高内存访问速度:
现代计算机系统中,内存(RAM)通常以字节为单位进行访问,但某些硬件(特别是CPU缓存和内存子系统)可能以更大的块(如4字节、8字节、16字节等)来有效地访问内存。当数据按照这些硬件优化的块大小对齐时,可以减少CPU访问内存的次数,因为每次访问都可以直接加载到CPU寄存器中所需的数据块,而无需额外的内存操作(如读取部分数据然后重新组合)。
2.减少缓存未命中率:
缓存未命中是指CPU在缓存中找不到所需数据而必须从较慢的主内存中检索数据的情况。数据对齐有助于减少这种情况,因为当数据块按缓存行(cache line)对齐时,相关的数据更有可能在同一缓存行中被找到,从而提高了缓存的命中率。
简化硬件设计:
3.在硬件层面,使用对齐数可以简化内存访问和控制逻辑的设计。例如,对齐的内存访问可以减少地址解码器的复杂性,因为不需要处理跨边界的访问请求。此外,对齐的数据还可以使得数据在内存中的布局更加规则,有利于预测和并行处理。
4.提高系统稳定性:
在某些情况下,未对齐的内存访问可能导致硬件异常或错误,尤其是在某些处理器架构中。使用对齐数可以确保所有内存访问都是合法的,从而避免这些问题,提高系统的稳定性和可靠性。
5.跨平台兼容性:
不同的处理器架构和操作系统可能对内存对齐有不同的要求。遵循通用的对齐规范可以确保软件在不同平台上的兼容性和可移植性。
综上所述,对齐数的使用是为了优化内存访问、提高系统性能、减少资源消耗、增加系统稳定性和确保跨平台兼容性。在编程实践中,程序员需要了解并遵循目标平台或编程语言的内存对齐规则,以确保软件的高效运行。
2.3 this指针
this指针的特性:
- this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传 递,不需要用户传递
————————————————
2.3.1 this指针的使用
• Date类中有Init与Print两个成员函数,函数体中没有关于不同对象的区分,那当d1调⽤Init和Print函数时,该函数是如何知道应该访问的是d1对象还是d2对象呢?那么这⾥就要看到C++给了⼀个隐含的this指针解决这⾥的问题
• 编译器编译后,类的成员函数默认都会在形参第⼀个位置,增加⼀个当前类类型的指针,叫做this指针。⽐如Date类的Init的真实原型为, void Init(Date* const this, int year, int month, int day)
• 类的成员函数中访问成员变量,本质都是通过this指针访问的,如Init函数中给_year赋值, this->_year = year;
• C++规定不能在实参和形参的位置显⽰的写this指针(编译时编译器会处理),但是可以在函数体内显
⽰使⽤this指针。
class Date
{
public:
// void Init(Date* const this, int year, int month, int day)
void Init(int year, int month, int day)
{
// 编译报错:error C2106: “=”: 左操作数必须为左值
// this = nullptr;
// this->_year = year;
_year = year;
this->_month = month;
this->_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
// 这⾥只是声明,没有开空间
int _year;
int _month;
int _day;
};
int main()
{
// Date类实例化出对象d1和d2
Date d1;
Date d2;
// d1.Init(&d1(隐函数指针), 2024, 3, 31);
d1.Init(2024, 3, 31);
d1.Print();
d2.Init(2024, 7, 5);
d2.Print();
return 0;
}
2.3.2 this指针存储方式
其实编译器在生成程序时加入了获取对象首地址的相关代码。编译器有并把获取的首地址存放在了寄存器ECX中(VC++编译器是放在ECX中,其它可能不同)。也就是成员函数的其它参数正常都是存放在栈中。而this指针参数则是存放在寄存器中。类的静态成员函数因为没有this指针这个参数,所以类的静态成员函数也就无法调用类的非静态成员变量。
this可以为空,当我们在调用函数的时候,如果函数内部并不需要使用到this,也就是不需要通过this指向当前对象并对其进行操作时才可以为空(当我们在其中什么都不放或者在里面随便打印一个字符串),如果调用的函数需要指向当前对象,并进行操作,则会发生错误(空指针引用)就跟C中一样不能进行空指针的引用。
如下面例子:
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
当我们注释掉上面的cout << _a << endl;时可以正常运行,否则会运行崩溃。
此外我们需要知道this指针因为是函数参数,所以它存在于栈区的。
三、类的默认成员函数
默认成员函数就是⽤⼾没有显式实现,编译器会⾃动⽣成的成员函数称为默认成员函数。
⼀个类,我们不写的情况下编译器会默认⽣成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可。其次就是C++11以后还会增加两个默认成员函数移动构造和移动赋值。默认成员函数很重要,也⽐较复杂,我们要从两个⽅⾯去学习:
• 第⼀:我们不写时,编译器默认⽣成的函数⾏为是什么,是否满⾜我们的需求。
• 第⼆:编译器默认⽣成的函数不满⾜我们的需求,我们需要⾃⼰实现,那么如何⾃⼰实现?
<