文章目录
前言(C++——面向对象的语言)
今天我们一起来学习C++类和对象的内容。
友情提示:文章会很长,请观众老爷们慢慢观看~
什么是类和对象?
在C语言中,我们没有类和对象的概念,C语言是面向过程的,向我们前面实现的数据结构中的知识,就拿栈来举例:
我们定义了栈的结构体:
typedef int STDataType;
typedef struct Stack
{
STDataType* arr; //栈数组
int capacity; //栈的空间大小
int top; //栈顶位置
}ST;
那如果想要使用栈我们应该怎么做呢?
我们又写了很多方法,比如StackInit,StackPush等等一系列的函数
//栈的初始化
void STInit(ST* ps)
{
assert(ps);
ps->arr = NULL;
ps->capacity = 0;
ps->top = 0;
}
//数据入栈
void StackPush(ST* ps, STDataType x)
{
assert(ps);
if (ps->capacity == ps->top) //空间满了需要扩容
{
int newcapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity; //三目运算符如果原本栈为空,就赋初始为4个空间,若不为空,则双倍扩容
STDataType* tem = (STDataType*)realloc(ps->arr, newcapacity * sizeof(STDataType));
//判断所开空间是否成功
if (tem == NULL)
{
perror("realloc fail!");
exit(1);
}
ps->arr = tem;
ps->capacity = newcapacity;
}
//入栈开始
ps->arr[ps->top++] = x;
}
我们在主函数中创建这么一个栈的对象,然后通过调用这个函数来进行对栈的操作。
C语言是面向过程的,而C++是面向对象的,我们来看这个例子,来理解什么是面向对象~
泡一杯咖啡。
- 面向过程编程(C语言风格):
面向过程编程关注的是任务的步骤,按照逻辑顺序一步步执行。比如,泡咖啡可以看成一系列操作。
泡咖啡的步骤:
- 烧水。
- 磨咖啡豆。
- 把磨好的咖啡粉倒入杯中。
- 倒入热水。
- 搅拌。
在面向过程编程中,每个步骤都是一个函数,程序会按顺序调用这些函数,逐步完成泡咖啡的过程。
C语言风格的代码:
#include <stdio.h>
void boilWater() {
printf("水已经烧开。\n");
}
void grindCoffeeBeans() {
printf("咖啡豆已经磨好。\n");
}
void brewCoffee() {
printf("咖啡已经泡好。\n");
}
void makeCoffee() {
boilWater();
grindCoffeeBeans();
brewCoffee();
printf("一杯咖啡准备好了!\n");
}
int main() {
makeCoffee();
return 0;
}
在这个例子中,我们按照步骤执行任务。每个任务都是一个函数,函数调用体现了面向过程编程。
- 面向对象编程(C++风格):
面向对象编程的核心思想是将任务和数据封装到对象中,(也就是说,C++结构的定义,以及使用它的方法是定义在一起的)
并通过对象来操作。在泡咖啡的例子中,咖啡机可以看作一个对象,而水、咖啡豆也是对象。我们通过与这些对象来泡咖啡。
对象的设计:
- 咖啡机:负责烧水、泡咖啡。
- 咖啡豆:有磨豆功能。
- 水:可以加热。
在面向对象的思想中,我们关注的是谁在做,以及这些对象有哪些属性和行为。
C++风格的代码:
#include <iostream>
using namespace std;
class CoffeeBeans {
public:
void grind() {
cout << "咖啡豆被磨成粉末。" << endl;
}
};
class Water {
public:
void boil() {
cout << "水被烧开了。" << endl;
}
};
class CoffeeMachine {
public:
CoffeeBeans beans;
Water water;
void makeCoffee() {
water.boil();
beans.grind();
cout << "咖啡被冲泡好了!" << endl;
}
};
int main() {
CoffeeMachine machine;
machine.makeCoffee();
return 0;
}
在面向对象的编程中,我们将咖啡机、咖啡豆和水设计为独立的对象,它们各自具有不同的行为(如烧水、磨豆)。通过调用咖啡机对象的makeCoffee()
方法,咖啡机会自动与其他对象(咖啡豆、水)进行交互,完成泡咖啡的整个过程。
总结:
- 面向过程:任务被分解为一个个独立的函数,按顺序执行。比如烧水、磨豆、泡咖啡,整个过程依赖于任务的顺序。
- 面向对象:任务被分配给对象,任务通过对象之间的交互完成。咖啡机负责协调水和咖啡豆来完成泡咖啡的过程。
一、类的定义
1. 类定义格式
为了将我们的结构以及方法定义在一起,C++提供了一种新的方法
——Class(类)。
- 定义类:在 C++ 中,用
class
关键字来定义一个类,比如这里的Stack
。类的主体放在{}
中,最后一定要加个分号;
。类是一个非常重要的概念,它把数据和相关的操作封装在一起。
class className
{
// 类体:由成员函数和成员变量组成}; // 一定要注意后面的分号
- 类的成员:类里面的内容分为两类:
一般来说,类的定义和声明可以放在一起,也可以把声明与定义分来。这样可以增加代码的可读性,但我们平常练习的时候代码简短,我们放在一起就可以了。
像这样,写在一起:
- 成员变量:也就是类的属性,用来存储状态数据,比如栈的容量或栈顶的位置。为了区分这些变量,开发者通常会在变量前面加个
_
或m
这样的前缀。这虽然不是硬性规定,但作为一个好习惯,能让代码更易读。
比如说,接下来我要实现一个日期类:
它包含三个成员变量:year, month, day
如果说我要对他进行初始化的话,请大家看下面这段代码可不可以?
class Date
{
void DateInit(int year = 2024, int month = 10, int day = 1)
{
year = year;//?究竟哪个是形参,哪个是成员变量,傻傻分不清~
month = month;
day = day;
}
int year;
int month;
int day;
};
我们就会遇到很多问题,成员函数和形参——傻傻分不清
因此,我们要做以区分:(在变量前面加个 _
或 m
这样的前缀)
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;
};
- 成员函数:也称为方法,这些是用来定义类行为的函数,比如栈的
push()
和pop()
操作。在 C++ 中,类里的成员函数默认是inline
,也就是说编译器在调用时会尽量直接把代码嵌入到调用的地方,帮助提高性能。
就比如我们刚刚写进日期类里面的,初始化操作。
也就是说,在类里面我们是可以写一个函数的,而在C语言函数是和结构分开的
void Init(int year, int month, int day)
{
_year = year;//我们的目的是传来的形参初始化赋值给成员变量
_month = month;
_day = day;
}
struct
与class
:在 C++ 中,struct
也可以用来定义类,并且比 C 语言的struct
进化了不少,支持成员函数。主要的区别是:class
的成员默认是私有的,而struct
的成员默认是公有的。尽管struct
也能定义类,但一般来说,还是推荐用class
,这样更符合面向对象编程的习惯。
2. 访问限定符
C++ 是一种支持封装的面向对象编程语言,通过类将对象的属性与方法紧密结合,形成一个功能完整的对象。这种设计使得对象不仅能存储数据,还能提供对数据的操作接口,同时允许开发者通过访问权限来控制外部访问。
我们现在有一个——人类。
class Person
{
public: //公有的接口
void Init(string name, int age, int phoneNumber)
{
_name = name;
_age = age;
_phoneNumber = phoneNumber;
}
private:
//私有的成员变量
string _name;
int _age;
int _phoneNumber;
};
访问权限 :
- 公开访问 (
public
):- 使用
public
修饰的成员可以被外部直接访问。比如,如果一个类有一个公开的函数,任何其他代码都可以调用这个函数。
- 使用
就像人类里的void Init(char* name, int age, int phoneNumber);
,
我们可以像这样在外部访问他们:
int main()
{
Person ps;
ps.Init("张三", 28, 10086);
return 0;
}
- 保护访问 (
protected
) 和私有访问 (private
):protected
和private
修饰的成员无法被外部直接访问。虽然在某些方面这两个关键字看起来相似,但它们的区别将在继承时显现。在继承关系中,protected
成员允许子类访问,而private
成员则不能。
像这样,类外访问编译器是不通过的
注意:
protected和private只对外部访问的情况作出限制,对于类的内部不做任何限制,比如我们前面这个函数里不就使用了private里的成员变量了嘛~
我们建议成员变量作为私有,提供外部的接口作为公有
作用域
- 访问权限的作用域从访问限定符出现的位置开始,直到下一个访问限定符出现时为止。
- 如果类内部没有其他访问限定符,作用域将一直延续到类的结束
}
。
默认权限
- 当你定义一个
class
时,如果没有明确指定访问权限,所有的成员默认是private
的。默认情况下,其他代码无法访问这些成员。 - 而在
struct
中,成员默认是public
。
通过这种封装机制,C++ 让你可以有效地管理对象的状态,提高代码的安全性和可维护性。设计良好的类能够将复杂性隐藏在内部,使得用户只需关注接口,而无需了解实现细节。
3. 类域
在 C++ 中,当我们定义一个类时,它会创建一个新的作用域。这个作用域内的所有成员变量和成员函数都被归属于这个类。为了在类的外部定义成员函数时指明它们所属的类,我们需要使用作用域解析运算符::
。
作用域的影响
类的作用域会影响编译器在查找成员时的规则。以下是一个例子来说明这一点:
class Stack
{
public:
// 成员函数
void Init(int n = 4);
private:
// 成员变量
int* array;
size_t capacity;
size_t top;
//size_t 是在 <stddef.h> 或 <cstddef> 头文件中定义的。
//它的实际类型由编译器决定,通常是 unsigned int、unsigned long
//或其他适合于表示内存大小的无符号整数类型。
};
因此我们需要指定类域Stack::
,编译器就会去类中去找,像这样:
void Stack::Init(int n)
{
array = (int*)malloc(sizeof(int) * n);
if (nullptr == array)
{
perror("malloc申请空间失败");
return;
}
capacity = n;
top = 0;
}
在上面的例子中,如果你在类外定义 Init
函数时没有指定 Stack::
,编译器就会将 Init
视为全局函数。这意味着,当编译器在全局作用域中查找 array
、capacity
和 top
时,它会找不到这些成员变量,因为它们属于 Stack
类的作用域。结果会导致编译错误,提示找不到这些成员的声明或定义。
通过使用 Stack::Init
,你明确告诉编译器 Init
函数是 Stack
类的成员,这样编译器就能在 Stack
类的作用域中找到 array
和其他成员变量,从而避免错误。
二、实例化
1. 实例化的概念
用类类型创建对象的过程,称为类的实例化
在 C++ 中,创建对象的过程叫做 类实例化。想象一下,类就像是一张建筑设计图,它告诉我们房子应该长什么样:有多少个房间、每个房间的大小和用途。但这张图本身并不能住人,因为它没有实际的物理存在。
当我们根据这张设计图来建造房子时,就完成了实例化。此时,系统为这个房子分配了内存,房子也终于可以住人了!每个根据同一设计图建造的房子都是一个独立的实例,拥有自己的空间和特点。
所以,类是我们描述对象的蓝图,而通过实例化,我们得到了可以实际使用的对象。就像现实中的房子,类设计了结构,而对象则是能让我们真正居住的地方。
2. 对象大小
下面有三个类,我们来计算一下他们的大小:
class A
{
public:
void Print()
{
cout << _ch << endl;
}
private:
char _ch;
int _i;
};
class B
{
public:
void Print()
{
//...
}
};
class C
{};
我们先来回忆一下内存对齐的规则:
我们来看一下运行结果:
为什么会有这样的结果呢?
对于A来说:
这里就有一个问题了,为什么要进行内存对齐呢?
内存对齐是为了提高计算机在读取或写入内存时的效率。在32位操作系统中,这与数据总线和地址总线的设计有关。
- 数据总线和地址总线
- 数据总线32位:表示CPU一次可以读写4字节(32位)的数据。因此,如果数据在内存中是4字节对齐的,CPU可以一次性完整地读取这些数据,而不用分成多次读取。
- 地址总线32位:意味着CPU能够访问的内存地址是按4的倍数递增的。也就是说,每个地址步进4字节,这与数据总线一次读取4字节的能力相匹配。
- 内存对齐的原因
-
性能优化:如果数据存放在一个未对齐的地址(例如,存放在地址0x01而不是0x00),那么CPU读取时需要进行两次内存访问(因为数据被分成了两部分),这会降低效率。而对齐后的数据能确保一次读取或写入操作完成整个数据的处理。
-
兼容性:在32位操作系统中,即使使用64位架构,很多编程还是要兼容32位的设计。因此,为了确保代码的可移植性和性能,内存对齐仍然非常重要。
- 内存对齐的影响
不对齐的数据会导致CPU额外的操作,例如:
- 额外的内存访问:因为不能一次读取完全部数据,可能需要分两次从两个不同的内存位置读取。
- 额外的处理时间:需要额外的指令来处理非对齐的访问,导致程序执行速度下降。
因此,内存对齐有助于利用CPU的架构特点,提高程序运行的效率。
对于B和C来说:
B和C有一个共同的特点,就是B和C都没有成员变量
成员函数是另外开辟的
即使一个类没有成员变量,编译器仍然会为它分配1个字节的空间,这是为了保证每个对象都有唯一的内存地址。
想象一下,假如一个类大小为0,那创建的所有对象在内存中就没有区别,因为它们的地址会完全相同。这样不仅会导致程序逻辑混乱,甚至指针操作也会变得不可预测。所以,给它1个字节其实是为了给对象一个“存在感”,在内存中有一个明确的位置。
为什么是1字节?
1字节是最小的单位,占据极少的内存空间,足够表示“这个对象确实存在”。虽然它不包含任何实际的数据,但它的存在意义在于让程序可以正常区分这些对象,并且在内存操作上保持一致性。
在继承中的作用
如果这个类是一个基类,它占的那1字节也非常重要。因为派生类可能需要访问基类的成员,即使基类什么都没有,它还是要占点空间,标志“基类是存在的”,不然派生类的继承结构就会出问题。
所以,这1个字节的存在,虽然看似多余,却是编译器确保内存管理、对象地址唯一性和继承结构完整性的关键。
3. this 指针
1)this指针概念
找回我们的日期类:
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;
Date d2;
d1.Init(2024, 10, 1);
d2.Init(2003, 7, 8);
d1.Print();
d2.Print();
}
现在有这样一个问题,
我们在调用Init
以及 Print
函数的时候是这样调用的:
但是,在类的内部,并没有一个方法来区分到底是哪个对象调用了函数,那当d1调用 Init 函 数时,该函数是如何知道应该处理d1对象,而不是处理d2对象呢?
那么这里就要看到C++给了⼀个隐含的this指针解决这⾥的问题。
对于我们的成员函数来说,编译器会自动为我们提供一个形参,这个形参就是当前类类型的指针,放在形参列表的第一个位置:(如Init函数)
void Init(int year, int month, int day);
实际上他还有一个形参,他真实的原型长这样子:
void Init(Date* const this, int year, int month, int day);
类的成员函数中访问成员变量时,实际上是通过隐含的this
指针来完成的。每个成员函数都会接收一个this
指针,指向调用该函数的对象实例。比如在Init
函数中给_year
赋值的操作,其实是this->_year = year;
。
this
指针指向当前的对象实例,确保函数在操作成员变量时,是在操作正确的对象。如果没有 this
指针,编译器就无法知道成员变量 _year
属于哪个对象。
这个过程解释为:
void Init(int year) {
this->_year = year; // 通过this指针访问对象的_year成员变量
}
虽然代码中没有显式写出 this->
,但是编译器会隐式地将 this
添加上去。
C++规定不能在实参和形参的位置显⽰的写this指针(编译时编译器会处理),但是可以在函数体内显
⽰使⽤this指针。
但是我们可以在函数体内部显示的调用:
void Init(int year, int month, int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
2)this指针测试题
1.下⾯程序编译运⾏结果是()
A、编译报错 B、运⾏崩溃 C、正常运⾏
答案选 C ,正常运行。
我们可能认为这里有解引用操作,
但是p不发生解引用,因为成员函数的地址不存在对象中,在公共代码区域。因此这里没问题。
我们说传参的时候其实隐含了一个this指针,实际上我们就是传了一个空指针进去没有影响。
2.下⾯程序编译运⾏结果是()
A、编译报错 B、运⾏崩溃 C、正常运⾏
答案选 B ,运行崩溃。
这道题和上一道题区别在哪里呢?
区别就在于这里我们对成员变量进行了访问。
因为我们传进来了this指针,因此其实在访问成员变量时是这样的。
这里对空指针解引用了,因此程序运行崩溃。
- this指针存在内存哪个区域的 ()
A. 栈 B.堆 C.静态区 D.常量区 E.对象里面
答案是 A.栈。
实际上this指针就是一个形参,学函数栈帧的时候我们已经知道了,在函数调用的时候会把形参压栈,因此this指针存在栈区。
这个和编译器有关,在VS中,是用ECX寄存器存储的
4. C++和C语言实现Stack的对比
在这个对比中,我们通过两个实现栈(Stack)的代码,一个用C语言,一个用C++,来理解面向对象三大特性之一——封装。
C语言实现:
#include <iostream>
#include <assert.h>
typedef int DataType;
typedef struct Stack
{
DataType* array;
int capacity;
int size;
}Stack;
void StackInit(Stack* ps)
{
assert(ps);
ps->array = (DataType*)malloc(sizeof(DataType)* 3);
if (NULL == ps->array)
{
assert(0);
return;
} ps->capacity = 3;
ps->size = 0;
}
void StackDestroy(Stack* ps)
{
assert(ps);
if (ps->array)
{
free(ps->array);
ps->array = NULL;
ps->capacity = 0;
ps->size = 0;
}
}
void CheckCapacity(Stack* ps)
{
if (ps->size == ps->capacity)
{
int newcapacity = ps->capacity * 2;
DataType* temp = (DataType*)realloc(ps->array,
newcapacity*sizeof(DataType));
if (temp == NULL)
{
perror("realloc申请空间失败!!!");
return;
}
ps->array = temp;
ps->capacity = newcapacity;
}
}
void StackPush(Stack* ps, DataType data)
{
assert(ps);
CheckCapacity(ps);
ps->array[ps->size] = data;
ps->size++;
}
int StackEmpty(Stack* ps)
{
assert(ps);
return 0 == ps->size;
}
void StackPop(Stack* ps)
{
if (StackEmpty(ps))
return;
ps->size--;
}
DataType StackTop(Stack* ps)
{
assert(!StackEmpty(ps));
return ps->array[ps->size - 1];
}
int StackSize(Stack* ps)
{
assert(ps);
return ps->size;
}
int main()
{
Stack s;
StackInit(&s);
StackPush(&s, 1);
StackPush(&s, 2);
StackPush(&s, 3);
StackPush(&s, 4);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackPop(&s);
StackPop(&s);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackDestroy(&s);
return 0;
}
C++实现
#include <iostream>
typedef int DataType;
class Stack
{
public:
void Init()
{
_array = (DataType*)malloc(sizeof(DataType)* 3);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = 3;
_size = 0;
} void Push(DataType data)
{
CheckCapacity();
_array[_size] = data;
_size++;
}
void Pop()
{
if (Empty())
return;
_size--;
}
DataType Top(){ return _array[_size - 1]; }
int Empty() { return 0 == _size; }
int Size(){ return _size; }
void Destroy()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
void CheckCapacity()
{
if (_size == _capacity)
{
int newcapacity = _capacity * 2;
DataType* temp = (DataType*)realloc(_array, newcapacity *
sizeof(DataType));
if (temp == NULL)
{
perror("realloc申请空间失败!!!");
return;
}
_array = temp;
_capacity = newcapacity;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
printf ("%d\n", s.Top());
printf("%d\n", s.Size());
s.Pop();
s.Pop();
printf("%d\n", s.Top());
printf("%d\n", s.Size());
s.Destroy();
return 0;
}
封装的意义
封装可以理解为一种将数据和功能“包裹”在一起的方式,以便对外界隐藏内部实现的细节。C++中的封装不仅是简单的将数据和方法放在类里,还提供了一种更规范的访问控制机制,比如通过private
、public
等访问限定符来约束访问权限,避免外部随意修改内部数据。相比之下,C语言没有这种机制,程序员需要自己规范访问方式。
两份代码的比较
-
C语言栈实现:
- 栈的数据结构和操作函数是分离的,栈的数据需要通过
struct Stack
来定义,操作栈的方法都是独立的函数。 - 没有访问控制,所有的变量都是公开的,任何地方都可以直接修改栈的内部数据。这种自由度可能会导致误用和数据错误。
- 栈的数据结构和操作函数是分离的,栈的数据需要通过
-
C++栈实现:
- 数据和操作方法都放到了
class Stack
类里,通过面向对象的封装,外部只能通过类提供的成员函数来操作栈,无法直接访问和修改内部的数据结构。 - 访问控制得到了增强,成员变量
_a
、_capacity
和_top
都是私有的(private
),这避免了外部程序意外或不当修改数据。 - C++还引入了一些方便的特性,例如
this
指针的隐式传递,减少了需要手动传递对象的麻烦。
- 数据和操作方法都放到了
封装的优势
C++通过封装提升了代码的安全性和可维护性。封装不仅限于简单的数据隐藏,还包括对数据访问的控制和逻辑的组织。通过访问限定符,C++让开发者可以更好地管理数据,减少错误发生的可能。这种结构使得代码看起来更清晰,同时也更加健壮。
这也是为什么虽然两个实现的栈在功能上差别不大,但从设计上,C++的封装方式提供了更强的灵活性和安全性。随着你学习C++的深入,你会看到封装的更多应用,尤其是在标准模板库(STL)中的应用。
三、类的默认成员函数
1. 类的默认成员函数
默认成员函数是指在我们不显式定义时,C++编译器会自动生成的一些函数。这些函数的目的是让类可以自动具备基本的操作能力,比如创建对象、复制对象等。默认成员函数主要有6个(在C++11之前),其中最重要的是前四个,后两个较少使用。C++11之后,又增加了两个与移动操作相关的成员函数。
六个默认成员函数
-
默认构造函数:
- 这是一个没有参数的构造函数,如果我们没有定义任何构造函数,编译器会自动生成一个默认的构造函数,用来初始化对象的成员变量。默认情况下,这个构造函数不会初始化任何值,所有成员变量会保留其默认值或不确定值。
- 什么时候需要自己实现:如果你希望类在创建时进行一些特定的初始化操作,比如初始化指针或赋值初始值,那么你就需要自己定义构造函数。
一般来说都需要写
-
析构函数:
- 当对象生命周期结束时,编译器会调用析构函数清理资源。默认情况下,析构函数不执行任何操作,编译器只是简单地销毁对象。
- 什么时候需要自己实现:如果类中有动态内存分配(如
new
/malloc
),或者你管理了一些外部资源(文件、网络连接等),那么你需要自己编写析构函数来正确释放这些资源。
-
拷贝构造函数:
- 编译器生成的拷贝构造函数用于创建对象的副本,默认是“浅拷贝”,即简单地复制每个成员的值。
- 什么时候需要自己实现:当类包含动态内存或者指针成员时,浅拷贝可能会导致两个对象指向同一块内存区域,这时需要定义
深拷贝
来避免数据冲突。
-
拷贝赋值运算符:
- 用于将一个对象的内容赋值给另一个对象。默认情况下,这也是浅拷贝。
- 什么时候需要自己实现:同样地,当类中涉及动态分配的资源时,浅拷贝可能会引起内存问题,因此需要自定义赋值运算符来确保正确的
深拷贝
行为。
-
取地址运算符(重载
&
):一般不需要自己实现
- 编译器会自动生成取对象地址的运算符(
&
),用于获取对象的内存地址。这通常不会被修改,除非你有特殊的需求。
- 编译器会自动生成取对象地址的运算符(
-
常量取地址运算符(重载
const &
):一般不需要自己实现
- 同样是编译器生成的,用于处理常量对象的取地址操作。
C++11 引入的两个新函数(我们先不做要求,后续会讲解
)
-
移动构造函数:
- 用于优化临时对象的移动。它不会创建新的副本,而是将临时对象的资源直接“搬”到新对象中。这对于性能优化非常重要,尤其是在处理大量数据时。
-
移动赋值运算符:
- 类似于移动构造函数,用于将一个临时对象的资源转移给另一个已存在的对象,而无需进行深拷贝。
学习重点
-
默认生成的函数是否满足需求?:当编译器生成的默认函数无法正确处理类中的资源管理时,我们需要自定义这些函数。例如,动态内存管理、指针的深拷贝等场景下,默认的浅拷贝可能会引发问题。
-
如何实现自己的函数?:理解了默认行为后,我们可以通过自己实现拷贝构造、赋值运算符等,确保类在拷贝、赋值、销毁时能够正确管理资源,避免内存泄漏或双重释放问题。
2. 构造函数
1)构造函数的特点
构造函数是C++中特殊的成员函数,主要负责在对象实例化时对其进行初始化,而不是简单地为对象分配内存。它的功能类似于我们在C语言中用Init
函数实现的初始化操作,但构造函数有个更强大的特点——它会在对象创建时自动调用
,无需手动指定。以下是关于构造函数的主要特点与注意事项:
构造函数的特点
-
函数名与类名相同:构造函数的名称必须与类名保持一致,这是构造函数的一个标志。
-
没有返回值:构造函数不需要返回值,甚至连
void
也不需要写,这是C++的规定。 -
自动调用:当对象实例化时,系统会自动调用构造函数。这就是为什么构造函数可以替代我们手动调用的
Init
函数。 -
构造函数可以重载:C++允许多个构造函数存在,只要它们的参数不同。这样我们可以根据需要,提供不同的初始化方式。
-
默认构造函数:如果你没有显式定义构造函数,编译器会自动生成一个无参的默认构造函数。但是,一旦你自己定义了任何构造函数,编译器就不再生成这个默认的无参构造函数。
-
无参构造函数与全缺省构造函数:
- 无参构造函数:没有参数,直接初始化对象。
- 全缺省构造函数:所有参数都有默认值。
- 这两者都属于“默认构造函数”,即在不传递实参时可以被调用。但要注意,虽然它们构成重载,若同时存在,可能引发歧义,因此只能存在其中一个。
-
默认构造函数的行为:
- 编译器生成的默认构造函数不会对内置类型的成员变量进行初始化,也就是说,这些成员变量的初始值是不确定的,取决于编译器。
- 但是对于用户定义类型的成员变量,编译器要求调用该成员变量的默认构造函数来初始化它。如果这个成员变量没有默认构造函数,编译器会报错。这时,我们可以通过初始化列表来初始化这些成员变量。
2)构造函数特点解析
这里以我们的日期类为例
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 10, 1);
return 0;
}
如果我们不写构造函数,编译器就会生成一个默认构造。
当然默认构造包含三种方式
- 编译器自己生成的默认构造
- 不含形参的构造函数
- 全缺省的构造函数
//无参构造
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
//全缺省构造
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
首先我们来看如果是无参构造会是什么情况
注意,无参构造和全缺省构造不能同时出现,因为这两个都不用传参,编译器不知道我们要调用哪一个
很容易观察到,他被初始化了
接下来我们看全缺省构造
注意,全缺省构造不能和无参构造和有参构造同时存在
那如果,我们不写构造函数呢?
编译器会给我们一个默认构造。
这个构造有这样一个特点:
- 对于内置类型的数据编译器不做处理
- 对于自定义类型的数据,编译器会调用它的构造函数
说明:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语⾔提供的原生数据类型,
如:int/char/double/指针等,自定义类型就是我们使⽤class/struct等关键字自己定义的类型。
比如我们现在有一个栈:
typedef int STDataType;
class Stack
{
public:
// ...
private:
STDataType * _a;
size_t _capacity;
size_t _top;
};
其中,这三个就是内置类型:
那我们来定义一个对象,看看编译器会怎么做。
可以看到,编译器对内置类型并不做处理。
那如果是自定义类型呢?
我们之前用两个栈实现一个队列的题,就用到了全是自定义类型的结构
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// ...
private:
STDataType * _a;
size_t _capacity;
size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
//编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造,完成了两个成员的初始化
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq;
return 0;
}
在定义mq时,会自动调用Stack这个类的构造函数
因此我们总结为:构造函数内置类型不处理,自定义类型会处理。
一般情况下我们需要自己实现构造函数。
3. 析构函数
1)析构函数特点
重构析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,它就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们之前 Stack 实现的 Destroy
功能,而像 Date
没有 Destroy
,其实就是没有资源需要释放,所以严格说 Date
是不需要析构函数的。
析构函数的特点:
- 析构函数名是在类名前加上字符
~
。 - 无参数无返回值。(这里跟构造类似,也不需要加
void
) - 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,系统会自动调用析构函数。
- 跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用它们的析构函数。
- 还需要注意的是我们显式写析构函数,对于自定义类型成员也会调用它们的析构函数,也就是说自定义类型成员无论什么情况都会自动调用析构函数。
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如
Date
;如果默认生成的析构就可以用,也就不需要显式写析构,如MyQueue
;但是有资源申请时,一定要自己写析构,否则会造成资源泄漏,如Stack
。 - 一个局部域的多个对象,C++ 规定后定义的先析构。
2)析构函数特点解析
我们有一个日期类:(日期类是不需要写析构函数的,这里打印只是为了方便观察,因为日期类没有自己开辟资源)
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
~Date()
{
cout << "~Date()" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
return 0;
}
我们来看一下运行结果,可以看到它确实被析构了
这就是对内置类型我们不用管,系统自动在main函数栈帧被销毁时回收了
再来看一个栈类:(
这里有资源的申请必须要写析构函数,不然会造成内存泄漏
)
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
int main()
{
Date d;
Stack st;
//Myqueue mq;
return 0;
}
对于栈来说,它的成员变量有一个是我们malloc出来的,放在堆上的指针,他不会随着main函数栈帧的销毁而释放,我们必须手动进行释放,因此要写析构函数。
来看一下运行结果
最后来看用两个队列实现栈,它的成员变量都是Stack类型的变量,因此也不需要我们处理,他会自己调用Stack的析构函数
class Myqueue
{
public:
~Myqueue()
{
cout << "~Myqueue()" << endl;
//free(_ptr);
// 不需要
//_pushst.~Stack();
}
private:
Stack _pushst;
Stack _popst;
//int* _ptr;
};
这里想先提醒一点,只要有自定义类型的成员函数,如果类中自己也写了析构函数,那么他自己的析构和自定义类型的析构都会去调用
回归正题,我们来看一下运行结果:
从我们打印的结果来看,我们也可以发现后定义的被先析构了~
最后我们来看一个例子,从代码的简洁程度上就可以看出C++构造与析构的优点
这是我们之前实现的左右括号的匹配问题:
//用之前C版本Stack实现
bool isValid(const char* s) {
ST st;
STInit(&st);
// "[]]"
while (*s)
{
// 左括号入栈
if (*s == '(' || *s == '[' || *s == '{')
{
STPush(&st, *s);
}
else // 右括号取栈顶左括号尝试匹配
{
if (STEmpty(&st))
{
STDestroy(&st);
return false;
}
char top = STTop(&st);
STPop(&st);
// 不匹配
if ((top == '(' && *s != ')')
|| (top == '{' && *s != '}')
|| (top == '[' && *s != ']'))
{
STDestroy(&st);
return false;
}
}
++s;
}
//"["
// 栈不为空,说明左括号比右括号多,数量不匹配
bool ret = STEmpty(&st);
STDestroy(&st);
return ret;
}
//C++
bool isValid(const char* s) {
Stack st;
// "[]]"
while (*s)
{
// 左括号入栈
if (*s == '(' || *s == '[' || *s == '{')
{
//STPush(&st, *s);
st.Push(*s);
}
else // 右括号取栈顶左括号尝试匹配
{
if (!st.Empty())
{
return false;
}
char top = st.Top();
st.Pop();
// 不匹配
if ((top == '(' && *s != ')')
|| (top == '{' && *s != '}')
|| (top == '[' && *s != ']'))
{
return false;
}
}
++s;
}
// 栈不为空,说明左括号比右括号多,数量不匹配
return st.Empty();
}
4. 拷贝构造
1)拷贝构造特点
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是一个特殊的构造函数。
拷贝构造的特点:
- 拷贝构造函数是构造函数的一个重载。
- 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。
- C++ 规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以自定义类型传值传参和传值返回都会调用拷贝构造完成。
- 若未显式定义拷贝构造,编译器会生成自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用它们的拷贝构造。
- 像
Date
这样的类成员变量全是内置类型且没有指向资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显式实现拷贝构造。像Stack
这样的类,虽然也都是内置类型,但_a
指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue
这样的类型内部主要是自定义类型Stack
成员,编译器自动生成的拷贝构造会调用Stack
的拷贝构造,也不需要我们显式实现MyQueue
的拷贝构造。这里还有一个小技巧,如果一个类显式实现了析构并释放资源,那么它就需要显式写拷贝构造,否则就不需要。 - 传值返回会产生一个临时对象调用拷贝构造,传引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回。
2)拷贝构造特性讲解
首先,拷贝构造的基本语法是这样的,先以日期类为例:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
~Date()
{
cout << "~Date()" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 8, 9);
Date d3(d1);
Date d2 = d1;
return 0;
}
首先,对于这种内置类型没有额外开辟空间的类,我们不需要写构造函数,编译器为我们提供的构造函数就够用了。
如果非要写就写成这样:
在调用的时候一共有两种方式:
我们来看运行结果:(可以看到,他确实都被拷贝过来了)
这里有一个问题,为什么我们再写拷贝构造的时候要用引用传参?
C++规定,传值传参要调用拷贝构造
拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错, 因为会引发无穷递归调用。
我们再以栈类为例:
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
void Push(STDataType x)
{
if (_top == _capacity)
{
int newcapacity = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
STDataType Top()
{
assert(_top > 0);
return _a[_top - 1];
}
// st2(st1)
Stack(const Stack& st)
{
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
memcpy(_a, st._a, sizeof(STDataType) * st._top);
_top = st._top;
_capacity = st._capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
我们先思考,栈这一个类,我们不去实现拷贝构造,我们使用系统默认生成的拷贝构造行不行?
答案是不行的,默认生成的拷贝构造只是浅拷贝,是一个字节一个字节拷贝的,就导致 _a这个数组的地址被拷贝成一样的,再最后析构的时候会析构两次发生报错,而且我期望的是有独立的空间,而不是我们共用一个空间,因此不能使用系统默认的拷贝构造,我们需要自己写。
我们需要为 _a 开辟空间
这里还有一个问题,为什么构造函数要加const修饰?假设有这样一个函数
Date Func()
{
Date ret;
return ret;
}
int main()
{
Date d4 = Func(ret);
return 0;
}
那么这里就出现权限放大的问题了,我们函数返回的是临时变量它具有常性,而我们调用拷贝构造的时候将
const Date
类型放大权限成了const
,因此要加const修饰
我们以两个栈实现一个队列为例
class MyQueue
{
public:
private:
Stack pushst;
Stack popst;
};
对于这种自定义类型,编译器会自动调用Stack的拷贝函数。
四、赋值运算符重载
1.运算符重载
重构当运算符被⽤于类类型的对象时,C++语⾔允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错。
1)运算符重载的特点:
-
运算符重载是具有特殊名字的函数,其名字由
operator
和要定义的运算符共同构成。它具有返回类型、参数列表和函数体。 -
重载运算符函数的参数个数与该运算符作用的运算对象数量相同。一元运算符有一个参数,二元运算符有两个参数。对于二元运算符,左侧的运算对象传给第一个参数,右侧的运算对象传给第二个参数。
-
如果重载运算符函数是成员函数,则其第一个运算对象会自动传递给隐式的
this
指针,因此作为成员函数的运算符重载,其参数比运算对象少一个。 -
重载运算符后,其优先级和结合性与对应的内置类型运算符保持一致。
-
不允许重载不存在的运算符,如
operator@
。只能重载 C++ 语法中已有的运算符。 -
以下运算符不能重载:
.*
成员指针访问运算符::
作用域运算符sizeof
操作符?:
条件运算符(三目).
成员访问运算符
(这些运算符常作为选择题考点,需记住。)
-
重载运算符时,至少要有一个类类型参数。不能通过运算符重载改变内置类型对象的含义。例如,不能重载
int operator+(int x, int y)
。 -
重载哪些运算符,取决于该运算符在类中是否有意义。例如,
Date
类重载operator-
可能有意义,但重载operator*
就没有意义。 -
重载
++
运算符时,有前置++
和后置++
。两者的函数名都是operator++
,无法直接区分。C++ 规定后置++
重载时增加一个int
形参,以区别于前置++
,形成函数重载。 -
重载
<<
和>>
时,需要将其重载为全局函数。因为作为成员函数时,this
指针占用了第一个参数位置,而第一个参数应是左侧的流对象(如ostream
或istream
)。因此,为了符合常用的语法习惯,<<
和>>
运算符应重载为全局函数,左侧的ostream
/istream
为第一个参数,类类型对象为第二个参数。
2)运算符重载特性讲解
我们现在有一个日期类:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
对于内置类型的数据,我们可以通过
+,-,*,/
等等一系列运算符来进行运算,那如果我们像相减两个日期,是不可以直接d1 - d2
的,因为编译器不认识我们的日期类,要实现比较日期大小,就要通过我们的运算符重载:operator
如果定义在全局,
operator
的用法是:bool operator<(const Date& x1, const Date& x2)
bool operator<(const Date& x1, const Date& x2)
{
if (x1._year < x2._year)
{
return true;
}
else if (x1._year == x2._year
&& x1._month < x2._month)
{
return true;
}
else if (x1._year == x2._year
&& x1._month == x2._month
&& x1._day < x2._day)
{
return true;
}
return false;
}
这里会发现运算符重载成全局的就需要成员变量是公有的,那么问题来了,无法保证封装性。
这里其实可以用我们后面学习的友元解决,或者干脆重载成成员函数,还有一种方法是使用接口。
成员函数的operator:
写在成员函数里面,就不用传两个参数了,因为成员函数隐含了this指针,通过this我们可以访问到一个成员
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator<(const Date& d)
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year&& _month < d._month)
{
return true;
}
else if (_year == d._year&& _month == d._month&& _day < d._day)
{
return true;
}
return false;
}
private:
int _year;
int _month;
int _day;
};
这里需要注意的是,左操作数是this,指向调用函数的对象 。
使用的时候,可以:
- d1.operator<(d2);
- d1 < d2;
这两个是一样的~
这里先简单提一下,重载运算符调用的特性,已经前置++,和后置++的区别,下面做日期类的时候,会详细讲解。
// 运算符重载函数可以显⽰调⽤
d1.operator==(d2);
// 编译器会转换成 d1.operator==(d2);
d1 == d2;
// 编译器会转换成 d1.operator++();
++d1;
// 编译器会转换成 d1.operator++(0);
d1++;
2. 赋值运算符重载
1)赋值运算符重载的特点
重构赋值运算符重载是一个默认成员函数,用于实现两个已存在对象之间的直接拷贝赋值。要注意与拷贝构造函数的区别:拷贝构造函数用于将一个对象拷贝初始化为另一个要创建的对象。
-
成员函数:
- 赋值运算符重载必须重载为成员函数。参数建议使用
const
当前类类型引用,以避免通过值传参引起的拷贝。
- 赋值运算符重载必须重载为成员函数。参数建议使用
-
返回值:
- 必须有返回值,建议返回当前类类型的引用,以提高效率,同时支持连续赋值场景。
-
默认实现:
- 如果未显式实现,编译器会自动生成一个默认的赋值运算符重载。这个默认行为类似于默认的拷贝构造函数:对于内置类型成员变量,会进行值拷贝/浅拷贝(逐字节拷贝);对于自定义类型成员变量,会调用其赋值重载函数。
-
深拷贝的必要性:
- 对于像
Date
这样的类,其成员变量全是内置类型且不指向任何资源,编译器生成的赋值运算符重载即可满足需求,因此不需要显式实现。而像Stack
类,即使成员都是内置类型,但_a
指向资源,编译器生成的赋值运算符重载仅能完成值拷贝/浅拷贝,这并不符合需求,因此需要我们自行实现深拷贝。对于如MyQueue
这样的类,内部主要是自定义类型Stack
的成员,编译器生成的赋值运算符重载会调用Stack
的赋值运算符重载,因此不需要显式实现MyQueue
的赋值运算符重载。如果一个类显式实现了析构函数并释放资源,那么也需要显式写赋值运算符重载。
- 对于像
2)赋值运算符重载特点解析
首先,我们理解重载赋值运算符就是重载
=
,也就是说,对于日期类来说,我们要实现一个已经存在的日期,赋值给另外一个存在的日期,即:d2 = d1
。
赋值运算符还有一个特点:就是它可以连等,即:d3 = d2 = d1
- 先来看函数名,不用说一定是要用到operator的,即:
operator=
.- 下面的例子是将其写成了成员函数的样子,那么
this指针
就会自带一个自身的对象,我们传参只需要右值就可以了,即:operator=(Date d)
- 这里我们建议用引用传参,因为本身传值会调用构造函数,引用传参就不会调用提高效率。
同时,建议用const修饰,因为有时会传临时变量的情况,即:operator=(const Date& d)
- 对于函数的返回值,因为赋值运算符需要我们有连等的情况,因此返回值需要返回
日期类
如图:
同样,这里我们建议传引用返回,因为返回的对象出去后不会被销毁,而且这样可以减少构造函数的调用,提高效率。
即:Date& operator=(const Date& d);
代码实现:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << " Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
// 传引⽤返回减少拷⻉
// d1 = d2;
Date& operator=(const Date& d)
{
// 要检查⾃⼰给⾃⼰赋值的情况
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// d1 = d2表达式的返回对象应该为d1,也就是*this
return *this;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 7, 5);
Date d2(d1);
Date d3(2024, 7, 6);
d1 = d3;
// 需要注意这⾥是拷⻉构造,不是赋值重载
// 请牢牢记住赋值重载完成两个已经存在的对象直接的拷⻉赋值
// ⽽拷⻉构造⽤于⼀个对象拷⻉初始化给另⼀个要创建的对象
Date d4 = d1;
return 0;
}
3)拷贝构造,赋值运算符重载,析构函数相同 / 不同点分析
类似于析构函数,拷贝构造,这里的赋值运算符重载拥有一些共同点:
- 对于Date这样的类的数据,编译器会提供默认的拷贝构造,就够我们使用了。
- 对于Stack这样的类的数据,我们自己开辟了资源,编译器只会提供浅拷贝,不能满足我们的需求,我们需要自己实现
- 对于myQueue这样的类,都是自定义类型的数据,会调用这个类型的各类函数,也不需要我们写
拷贝构造和赋值运算符的不同点:
拷贝构造用于一个
已经存在对象拷贝初始化
给另一个要创建的对象。
赋值运算符用于完成两个
已经存在的对象直接的拷贝赋值
int main()
{
Date d1(2024, 8, 10);
// 拷贝构造用于一个已经存在对象拷贝初始化给另一个要创建的对象。
Date d2(d1);
Date d4 = d1;
Date d3(2024, 9, 11);
// 用于完成两个已经存在的对象直接的拷贝赋值
//d1 = d3;
//d1.operator=(d3);
d1 = d2 = d3;
d1 = d1;
int i, j, k;
i = j = k = 1;
return 0;
}
五、日期类的实现
1. 日期类实现代码
//Date.h
#pragma once
#include<iostream>
#include<assert.h>
using namespace std;
class Date
{
//友元声明
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
int GetMonthDay(int year, int month);
void Print();
Date(int year = 1999, int month = 1, int day = 1);
bool CheckDate();
bool operator<(const Date& d);
bool operator>(const Date& d);
bool operator<=(const Date& d);
bool operator>=(const Date& d);
bool operator==(const Date& d);
bool operator!=(const Date& d);
Date& operator+=(int day);
Date operator+(int day);
Date& operator-=(int day);
Date operator-(int day);
// ++d1
Date& operator++();
// d1++
Date operator++(int);
// --d1
Date& operator--();
// d1--
Date operator--(int);
//d1 - d2
int operator-(const Date& d);
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);
//Date.cpp
#include"Date.h"
int Date::GetMonthDay(int year, int month)
{
assert(month > 0 && month < 13);
static int arr[13] = { -1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
return 29;
return arr[month];
}
void Date::Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
bool Date::CheckDate()
{
if (_month < 1 || _month > 12 || _day < 1 || _day > GetMonthDay(_year, _month))
return false;
else
return true;
}
Date::Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
if (!CheckDate())
{
cout << "日期非法 -> ";
cout << *this;
}
}
bool Date::operator<(const Date& d)
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year
&& _month < d._month)
{
return true;
}
else if (_year == d._year
&& _month == d._month
&& _day < d._day)
{
return true;
}
return false;
}
bool Date::operator>(const Date& d)
{
return !(*this <= d);
}
bool Date::operator<=(const Date& d)
{
return *this < d || *this == d;
}
bool Date::operator>=(const Date& d)
{
return !(*this < d);
}
bool Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool Date::operator!=(const Date& d)
{
return !(*this == d);
}
Date& Date::operator+=(int day)
{
if (day < 0)
{
return *this -= -day;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_month = 1;
_year++;
}
}
return *this;
}
Date Date::operator+(int day)
{
Date tmp = *this;
tmp += day;
return tmp;
}
Date& Date::operator-=(int day)
{
if (day < 0)
{
return *this += -day;
}
_day -= day;
while (_day <= 0)
{
_month--;
if (_month == 0)
{
_month = 12;
_year--;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
Date Date::operator-(int day)
{
Date tmp = *this;
tmp -= day;
return tmp;
}
// ++d1
Date& Date::operator++()
{
*this += 1;
return *this;
}
// d1++
Date Date::operator++(int)
{
Date tmp = *this;
*this += 1;
return tmp;
}
// --d1
Date& Date::operator--()
{
*this -= 1;
return *this;
}
// d1--
Date Date::operator--(int)
{
Date tmp = *this;
*this -= 1;
return tmp;
}
//d1 - d2
int Date::operator-(const Date& d)
{
int flag = 1;
Date min = d;
Date max = *this;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int count = 0;
while (min != max)
{
++min;
++count;
}
return count * flag;
}
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d)
{
while (1)
{
cout << "请输入年,月,日:";
in >> d._year >> d._month >> d._day;
if (d.CheckDate())
{
break;
}
else
{
cout << "日期非法,请重新输入" << endl;
}
}
return in;
}
//测试用例
#include"Date.h"
//int main()
//{
// Date d1(2024, 8, 10);
// d1.Print();
//
// //Date d2(d1 + 100);
// Date d2 = d1 + 100;
// d2.Print();
// d1.Print();
//
// d1 += 100;
// d1.Print();
//
// int i = 1;
// i += 100;
//
// d1 -= 100;
// d1.Print();
//
// cout << endl;
//
// Date d3(2024, 8, 10);
// d3.Print();
// d3 += 7000;
// d3.Print();
//
// return 0;
//}
//int main()
//{
// Date d1(2024, 8, 10);
// d1.Print();
//
// d1 += 100;
// d1.Print();
//
// d1 += -100;
// d1.Print();
//
// Date ret1 = ++d1;
// d1.Print();
//
// Date ret2 = d1++;
// d1.Print();
//
// ret1.Print();
// ret2.Print();
//
// return 0;
//}
int main()
{
Date d1(2024, 10, 2);
Date d2(2024, 10, 19);
cout << d1 - d2 << endl;
cout << d2 - d1 << endl;
//d1 << cout;
//d1.operator<<(cout);
Date d(2023, 2, 29);
cout << d1;
operator<<(cout, d1);
cin >> d1 >> d2;
cout << d1 << d2 << endl;
return 0;
}
2. 日期类注意的点
1)前置++与后置++
// ++d1
Date& Date::operator++()
{
*this += 1;
return *this;
}
// d1++
Date Date::operator++(int)
{
Date tmp = *this;
*this += 1;
return tmp;
}
区别:
- 形参不同,不论是
后置
还是前置++
,都只需要一个操作符,而为了区分这两个的运算符重载,我们要在后置++形参中加一个int
,表示与前置++不同,C++在这个地方做了特殊处理。- 返回值不同,前置++——
++d1
是先自增在返回,因此返回的是他自身,因此需要使用引用返回。
后置++——d1++
要先运算再自增,因此实际是创建了临时变量保存this
当前的值,然后自增this
,最后返回保存的临时变量,因此只等返回值,不能返回引用。
2)日期 - 日期
//d1 - d2
int Date::operator-(const Date& d)
{
int flag = 1;
Date min = d;
Date max = *this;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int count = 0;
while (min != max)
{
++min;
++count;
}
return count * flag;
}
要实现日期 - 日期有三种方式:
- 计算出年月日化为天数,再相互减去
计算过多不推荐
- 把先分别计算当前天数到当年1月1日的差值,在比较年
精简了一些但也不推荐
- 从小的开始++,直到与大的相等,用一个计数器来记录
推荐
用第三种方法需要注意的是我们需要定义一个
flag
表示到底我们计算出来结果的正负,如果*this < d
,计算出来的应该是负值,最后返回count * flag
,其中,flag
应为-1
,反之应为1
。
3)<< 与 >> 重载
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d)
{
while (1)
{
cout << "请输入年,月,日:";
in >> d._year >> d._month >> d._day;
if (d.CheckDate())
{
break;
}
else
{
cout << "日期非法,请重新输入" << endl;
}
}
return in;
}
这里首先要注意
istream
与ostream
必须要传引用返回,因为会出现连>>/<<
的情况,如:
cout << d1 << d2 << endl;
,cin >> d1 >> d2;
然后要注意,重载
>>
时,形参Date& d,不能加const
修饰,因为它本身要改变,同理ostream& out与istream& in也不能加const
修饰。
最关键的一点是如果它们写到成员函数,那么默认的形参一定在左值的地方,与我们的需求不符
如图:
因此,重载<</>>
时,必须要把它们写到全局变量
如图:
六、取地址运算符重载
1. const成员函数
- 用
const
修饰的成员函数称为const
成员函数,const
应放在成员函数参数列表的后面。 - 实际上,
const
修饰的是该成员函数隐含的this
指针,表示在该函数中不能修改类的任何成员。 - 例如,
const
修饰了Date
类的Print
成员函数时,Print
隐含的this
指针由Date* const this
变为const Date* const this
。
现在有这样一个需求:要对两个const修饰的日期类对象进行减法
这里报错就是因为出发了权限的放大
我们默认的形参this指针实质是
Date* const this
,const修饰的是这个指针,我们还是可以改变指针的内容。但是上方图片const Date d1(2024, 8, 10);
const限定的是指向的内容,也就是说我们在传参的时候造成了权限放大。
因此,我们可以在成员函数后加上const
来修饰,缩小权限
注意:如果要修改成员变量那么就不能加这个const,而且如果加了,声明和定义都要加
那我们前面写的日期类声明部分就会变成这样:
#pragma once
#include<iostream>
#include<assert.h>
using namespace std;
class Date
{
//友元声明
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
int GetMonthDay(int year, int month) const;
void Print() const;
Date(int year = 1999, int month = 1, int day = 1);
bool CheckDate() const;
bool operator<(const Date& d) const;
bool operator>(const Date& d) const;
bool operator<=(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator==(const Date& d) const;
bool operator!=(const Date& d) const;
Date& operator+=(int day);
Date operator+(int day) const;
Date& operator-=(int day);
Date operator-(int day) const;
// ++d1
Date& operator++();
// d1++
Date operator++(int);
// --d1
Date& operator--();
// d1--
Date operator--(int);
//d1 - d2
int operator-(const Date& d) const;
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);
-
const
对象可以调用非const
成员函数吗?- 不能。
const
对象只能调用const
成员函数,无法调用非const
成员函数。
- 不能。
-
非
const
对象可以调用const
成员函数吗?- 能。非
const
对象可以调用const
成员函数。
总结:
const
对象只能调用const
成员函数,非const
对象可以调用任意成员函数。 - 能。非
-
const
成员函数内可以调用其它的非const
成员函数吗?- 不能。由于
const
修饰的成员函数不能修改对象的成员,因此无法调用可能修改对象状态的非const
成员函数。
- 不能。由于
-
非
const
成员函数内可以调用const
成员函数吗?- 能。非
const
成员函数可以调用const
成员函数,因为const
成员函数不修改对象状态,符合调用的要求。
- 能。非
const
修饰的是该成员函数的隐含 this
指针,表示在该函数内不允许修改对象成员。调用非 const
成员函数可能导致修改,因此在 const
环境中是被禁止的。
2. 取地址运算符重载
取地址运算符重载可以分为普通取地址运算符重载和 const
取地址运算符重载。通常情况下,这两个函数由编译器自动生成,已经足够使用,无需手动实现。除非在一些特殊场景下,例如我们不希望外界获取当前类对象的地址时,可以自行实现,并返回一个伪造的地址。
自己写的话要写两份,非const和const都要写
class Date
{
public :
Date* operator&()
{
return this;
// return nullptr;
}
const Date* operator&()const
{
return this;
// return nullptr;
}
private :
int _year ; // 年
int _month ; // ⽉
int _day ; // ⽇
};
七、再探构造函数
1. 初始化列表的特点
• 之前实现构造函数时,成员变量的初始化通常是在函数体内赋值。其实,构造函数的初始化还有一种方式,叫初始化列表。使用初始化列表的方式是以一个冒号开始,接着列出逗号分隔的成员变量,每个"成员变量"后面跟上括号中的初始值或表达式。
• 每个成员变量在初始化列表中只能出现一次。语法上可以理解为初始化列表就是为每个成员变量定义初始化的地方。
• 对于引用类型、const类型以及没有默认构造函数的类类型变量,必须在初始化列表中进行初始化,否则会导致编译错误。
• C++11 支持在成员变量声明时直接赋予默认值,这个默认值会用于那些没有在初始化列表中明确初始化的成员。
• 尽量使用初始化列表进行初始化,因为即使你不在初始化列表中写明初始化,成员变量依然会通过初始化列表初始化。如果成员声明时有默认值,初始化列表会使用这个默认值。如果没有默认值,内置类型的初始化行为依赖于编译器,C++ 没有强制规定。而自定义类型如果没有在初始化列表中显式初始化,且没有默认构造函数,则会导致编译错误。
• 初始化列表会按照成员变量在类中声明的顺序进行初始化,和你在初始化列表中书写的顺序无关。因此,建议初始化列表中的顺序与成员变量的声明顺序保持一致。
总结:
- 无论是否显式写了初始化列表,每个构造函数都有初始化列表;
- 无论是否显式初始化,每个成员变量都会通过初始化列表进行初始化。
2. 初始化列表特点解析
我们先来看这样一段代码:
class Time
{
public:
Time(int hour = 0)
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date(int year = 2, int month = 2, int day = 2)
:_year(year)
, _month(month)
{}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
// 声明给缺省值 ->初始化列表
int _year = 1;
int _month = 1;
int _day = 1;
Time _t;
const int _n = 2;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
首先我们可以看到初始化列表的基本语法是:
对于引用类型、const类型以及没有默认构造函数的类类型变量,必须在初始化列表中进行初始化,否则会导致编译错误。
假如,成员函数中有这样的变量:
class Time
{
public:
Time(int hour)
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date(int year = 2, int month = 2, int day = 2)
:_year(year)
, _month(month)
{}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
// 声明给缺省值 ->初始化列表
int _year = 1;
int _month = 1;
int _day = 1;
Time _t; // 没有默认构造函数的类类型变量
Date& a; // 引用类型
const int _n; // const 成员变量
};
int main()
{
Date d1;
d1.Print();
return 0;
}
这里面的变量有这样一个特点:
编译器就会报错:
解决办法就是在初始化列表进行初始化:
#include <iostream>
using namespace std;
class Time
{
public:
Time(int hour)
: _hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
// 使用引用传递时,删除动态分配,避免递归
Date(int year = 2, int month = 2, int day = 2, Date& ref = dummy, int n = 2)
: _year(year)
, _month(month)
, _day(day)
, _t(12) // 没有默认构造函数的类类型变量,需要显式指定初始值
, a(ref) // 引用类型变量必须在初始化列表中初始化
, _n(n) // const 类型变量必须在初始化列表中初始化
{
// 构造函数体内无需再初始化这些变量
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
Time _t; // 没有默认构造函数的类类型变量
Date& a; // 引用类型
const int _n; // const 成员变量
static Date dummy;
};
// 定义静态的 dummy 对象
Date Date::dummy(0, 0, 0);
int main()
{
Date d1;
d1.Print();
return 0;
}
额外的,对于没有默认构造函数的类类型变量,我们还有一种方式,就是将它自己的构造函数写成默认构造:
class Time
{
public:
Time(int hour)
: _hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
// 使用引用传递时,删除动态分配,避免递归
Date(int year = 2, int month = 2, int day = 2)
: _year(year)
, _month(month)
, _day(day)
//, a(ref) // 引用类型变量必须在初始化列表中初始化
//, _n(n) // const 类型变量必须在初始化列表中初始化
{
// 构造函数体内无需再初始化这些变量
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
Time _t; // 没有默认构造函数的类类型变量
//Date& a; // 引用类型
//const int _n; // const 成员变量
};
这是不写成默认构造的情况:
• C++11 支持在成员变量声明时直接赋予默认值,这个默认值会用于那些没有在初始化列表中明确初始化的成员。
这话是什么意思呢?
也就是说,我们在下图这里可以直接给成员变量赋值,它会作用到初始化列表里面去:
class Time
{
public:
Time(int hour = 0)
: _hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
// 使用引用传递时,删除动态分配,避免递归
Date(int year = 2, int month = 2, int day =2)
/* : _year(year)
, _month(month)
, _day(day)*/
//, a(ref) // 引用类型变量必须在初始化列表中初始化
//, _n(n) // const 类型变量必须在初始化列表中初始化
{
// 构造函数体内无需再初始化这些变量
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 2;
int _month = 2;
int _day = 2;
Time _t; // 没有默认构造函数的类类型变量
//Date& a; // 引用类型
//const int _n; // const 成员变量
};
int main()
{
Date d1;
d1.Print();
return 0;
}
这里我们可以看到,我们根本没有使用初始化列表,我们只是在下图这里声明的时候初始化了:
尽量使用初始化列表进行初始化,因为
即使你不在初始化列表中写明初始化
,成员变量依然
会通过初始化列表初始化
。如果成员声明
时有默认值
,初始化列表会使用这个默认值
。如果没有默认值
,内置类型
的初始化行为依赖于编译器
,C++ 没有强制规定。而自定义类型如果没有在初始化列表中显式初始化,且没有默认构造函数,则会导致编译错误。
这里这样理解:
class Date
{
public:
// 使用引用传递时,删除动态分配,避免递归
Date(int year = 2, int month = 2, int day =2)
: _year(year)
, _month(month)
//, _day(day)
//, a(ref) // 引用类型变量必须在初始化列表中初始化
//, _n(n) // const 类型变量必须在初始化列表中初始化
{
// 构造函数体内无需再初始化这些变量
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 2;
int _month = 2;
int _day = 2;
Time _t; // 没有默认构造函数的类类型变量
//Date& a; // 引用类型
//const int _n; // const 成员变量
};
对于内置类型的成员变量:
对于自定义类型的成员变量:
3. 初始化列表特性面试题
初始化列表会按照成员变量在类中声明的顺序进行初始化,和你在初始化列表中书写的顺序无关。因此,建议初始化列表中的顺序与成员变量的声明顺序保持一致。
来看下面这道题:
下面代码会是怎么样?
A. 输出1 1
B.程序崩溃
C.编译不通过
D.输出1 随机值
class A
{
public:
A(int a)
:_a1(a)
, _a2(_a1)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2;
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
答案选 D,输出 1 随机值。
解析:
八、类型转换
1. 类型转换的特点
• C++ 支持将内置类型隐式转换为类类型对象,这需要类中有接受该内置类型作为参数的构造函数。
• 如果在构造函数前加上 explicit
关键字,则不再支持这种隐式类型转换。
• 类类型对象之间也可以进行隐式转换,这同样依赖于相应的构造函数的支持。
2. 类型转换的用法
1)内置类型->类类型
我们现在有一个A类:
class A
{
public:
// 构造函数,不加 explicit,允许隐式类型转换
A(int a1) : _a1(a1) {}
// 多参数构造函数,同样允许隐式类型转换
A(int a1, int a2) : _a1(a1), _a2(a2) {}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
int Get() const {
return _a1 + _a2;
}
private:
int _a1 = 1;
int _a2 = 2;
};
现在我们要从主函数取调用A类,下面我们一种情况一种情况的分析:
这里1
是一个内置类型的常量,将他赋值给了A类
当中aa1
这个对象,它的实质是:
下一个场景是这样的
这里用的是引用的方式来接受,因此就需要加上const修饰,因为临时变量具有常性,不加会造成权限的放大。
我们可以用简单的方式来理解这个问题:
int i = 0;
double d = i;//1//double& rd = i;//报错
const double& rd = i;//2(1)int类型定义i,将i赋值与double类型的d,这里会出现隐式转换。
(2)这里我们加上必须加上const,因为i这里是零时变量具有常性,所以接受i时必须用const修饰
下一个场景是,如果我们想避免编译器的这种优化,我们可以加入关键字
explicit
只要在构造函数的前面加上explicit
就可以避免这种隐式的转换了:
// 构造函数explicit就不再⽀持隐式类型转换
// explicit A(int a1)
A(int a1)
:_a1(a1)
{}
下一个场景是,多参数构造(
c++98是不支持多参数的,在c++11才开始支持
)
它的实质是:
2)类类型->类类型
现在我们有一个
B类
class B
{
public:
// 接受 A 类对象的构造函数
B(const A& a) : _b(a.Get()) {}
private:
int _b = 0;
};
int main()
{
// 1. 隐式类型转换:整数 1 被隐式转换为 A 类的对象
A aa1 = 1;
aa1.Print(); // 输出: 1 2
// 2. 通过临时对象隐式转换为 A 类的引用
const A& aa2 = 1; // 隐式创建一个 A 对象
// 3. C++11 支持多参数初始化,构造一个包含两个参数的 A 类对象
A aa3 = { 2, 2 };
aa3.Print(); // 输出: 2 2
// 4. A 类对象被隐式转换为 B 类对象
B b = aa3;
// 5. A 类对象隐式转换为 B 类对象并绑定为常量引用
const B& rb = aa3;
return 0;
}
第一种场景是:
它的实质是:
第二种场景也是同前面讲的,
rb
是一个别名,需要加const
来修饰
总结,要支持类类型的转换,需要些相应支持的构造函数。
九、static成员
1. static成员变量的特点
- 用
static
修饰的成员变量称为静态成员变量,必须在类外进行初始化。 - 静态成员变量为所有类对象共享,存储在静态区,不属于某个具体对象。
- 用
static
修饰的成员函数称为静态成员函数,它没有this
指针。 - 静态成员函数可以访问静态成员,但不能访问非静态成员,因为没有
this
指针。 - 非静态成员函数可以访问静态成员变量和静态成员函数。
- 静态成员可通过
类名::静态成员
或对象.静态成员
访问,但仍受访问控制符限制。 - 静态成员变量不能在声明时初始化(给缺省值),因为它不走构造函数的初始化列表。
2. static修饰成员变量
现在有这样一个场景,有一个A类,我要进行一系列操作,最后想知道A类到底创建了多少个对象:
// 实现⼀个类,计算程序中创建出了多少个类对象?
#include<iostream>
using namespace std;
class A
{
public:
A()
{
++_scount;
}
A(const A& t)
{
++_scount;
}
static int _scount;
};
// 类外⾯初始化
int A::_scount = 0;
int main()
{
cout << A::_scount<< endl;
A a1, a2;
A a3(a1);
cout << A::_scount << endl;
cout << a._scount << endl;
return 0;
}
首先,这个静态成员变量是这样用的:
注意:静态成员变量不能在声明时初始化(给缺省值),因为它不走构造函数的初始化列表。
它在使用的时候可以这样:
运行的结果是这样的,调用了两次构造,一次拷贝
3. static修饰成员函数
在前面讲static修饰成员变量有一个小问题,那就是我们把
写到了类公有的作用域下,但我们不期望这种方式,如果我们放到了私有里面,调用的时候就会编译报错:
// 编译报错:error C2248: “A::_scount”: ⽆法访问 private 成员(在“A”类中声明)
我们需要提供一个接口来获取_scount
。
假设我的接口是这样的:
int GetACount()
{
return _scount;
}
那么如果有一个函数也想获取到A类创建了多少个对象,我们只能这样用:
void fxx()
{
A a1;
int ret = a1.GetACount() - 1;
}
这样子会很别扭,我们要获取还必须提供一个A类的对象,然后还有把它本身减去,不符合我们的预期。
因此再C++中提供了 static 修饰的成员函数,来解决这个问题
用 static 修饰的成员函数称为静态成员函数,它没有 this 指针。
它是这样用的:
static int GetACount()
{
return _scount;
}
它没有this指针,因此不需要对象就可以调用,但是他不能调用非静态的成员变量
void fxx()
{
int ret = A::GetACount();
}
4. static题目
结合前面所学知识,再力扣上有这样一道题目:
求1+2+3+…+n_牛客题霸_🐂客⽹
解答:
class sum
{
public:
sum()
{
_ret += _i;
_i++;
}
static int getret()
{
return _ret;
}
private:
static int _ret;
static int _i;
};
int sum:: _ret = 0;
int sum:: _i = 1;
class Solution {
public:
int Sum_Solution(int n)
{
sum arr[n];
return sum::getret();
}
};
解答:
- E,因为全局变量先被初始化,接下来进入main函数内部,从上至下依次初始化,静态区的也只在第一次遇到的时候初始化
- B,局部变量的生命周期最短,main函数结束就会回收,而后定义的先析构,因此B > A,接下来就会析构静态区的D,最后析构全局变量C
十、友元
1. 友元的特性
- 友元提供了一种突破类访问限定符封装的方式,分为友元函数和友元类,在函数声明或类声明前加
friend
,并将友元声明放在类内部。 - 外部友元函数可以访问类的私有和保护成员,友元函数只是声明,不是类的成员函数。
- 友元函数可以在类定义的任意位置声明,不受访问限定符限制。
- 一个函数可以是多个类的友元函数。
- 友元类的成员函数可以作为另一个类的友元,访问该类的私有和保护成员。
- 友元类关系是单向的,A 类是 B 类的友元,但 B 类不是 A 类的友元。
- 友元关系不能传递,A 是 B 的友元,B 是 C 的友元,但 A 不是 C 的友元。
- 友元提供便利,但增加了耦合性,破坏了封装,使用不宜过多。
2. 友元的特性运用
1)友元函数
前面我们实现的日期类就用到了友元函数,他是这样用的,再类中声明一下这个函数,而且要在函数前加关键字
friend
,一旦作为好朋友
,就可以访问
类其中的私有的成员变量
。
class Date
{
//友元声明
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
int GetMonthDay(int year, int month);
void Print();
Date(int year = 1999, int month = 1, int day = 1);
bool CheckDate();
bool operator<(const Date& d);
bool operator>(const Date& d);
bool operator<=(const Date& d);
bool operator>=(const Date& d);
bool operator==(const Date& d);
bool operator!=(const Date& d);
Date& operator+=(int day);
Date operator+(int day);
Date& operator-=(int day);
Date operator-(int day);
// ++d1
Date& operator++();
// d1++
Date operator++(int);
// --d1
Date& operator--();
// d1--
Date operator--(int);
//d1 - d2
int operator-(const Date& d);
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d)
{
while (1)
{
cout << "请输入年,月,日:";
in >> d._year >> d._month >> d._day;
if (d.CheckDate())
{
break;
}
else
{
cout << "日期非法,请重新输入" << endl;
}
}
return in;
}
其中,这里就是友元函数的使用方式。
一个函数还可以是多个类的友元,但友元不具备传递性
#include<iostream>
using namespace std;
// 前置声明,都则A的友元函数声明编译器不认识B
class B;
class A
{
// 友元声明
friend void func(const A & aa, const B & bb);
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
// 友元声明
friend void func(const A& aa, const B& bb);
private:
int _b1 = 3;
int _b2 = 4;
};
void func(const A& aa, const B& bb)
{
cout << aa._a1 << endl;
cout << bb._b1 << endl;
}
int main()
{
A aa;
B bb;
func(aa, bb);
return 0;
}
2)友元类
类也可以作为类的好朋友
#include<iostream>
using namespace std;
class A
{
// 友元声明
friend class B;
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
void func1(const A& aa)
{
cout << aa._a1 << endl;
cout << _b1 << endl;
}
void func2(const A& aa)
{
cout << aa._a2 << endl;
cout << _b2 << endl;
}
private:
int _b1 = 3;
int _b2 = 4;
};
int main()
{
A aa;
B bb;
bb.func1(aa);
bb.func1(aa);
return 0;
}
其中,B类是A类的好朋友,B类可以访问A类中的成员变量。
十一、内部类
1. 内部类的特性
如果一个类被定义在另一个类的内部,则称为内部类。内部类是一个独立的类,与全局定义的类相比,它只是受外部类的作用域和访问限定符的限制,因此外部类的对象并不包含内部类的实例。
- 内部类默认是外部类的友元类。
- 内部类本质上也是一种封装方式。当类 A 和类 B 紧密关联,且类 A 的实现主要是为类 B 服务时,可以考虑将类 A 设计为类 B 的内部类。如果将其放在
private
或protected
区域,则类 A 成为类 B 的专属内部类,无法被外部使用。
2. sizeof相关内部类
#include<iostream>
using namespace std;
class A
{
private:
static int _k;
int _h = 1;
public:
class B // B默认就是A的友元
{
public:
void foo(const A& a)
{
cout << _k << endl;
cout << a._h << endl;
}
private:
int _b;
};
};
int A::_k = 1;
int main()
{
cout << sizeof(A) << endl;
A::B b;
return 0;
}
我们来计算一下这个sizeof的大小,结果为4。
说明:
- 静态的不算在类的大小中
- 内部类是独立的,他只是受到类域和访问限定符的限制
3. 内部类特性使用
如果我们要访问上面B这个类需要这样:
如果他被写在A类的私有里面,这样是访问不到的。
我们之前写过一道题,
求1+2+3+…+n_牛客题霸_🐂客⽹
它可以改成用内部类来实现:
class Solution {
// 内部类
class Sum
{
public:
Sum()
{
_ret += _i;
++_i;
}
};
static int _i;
static int _ret;
public:
int Sum_Solution(int n) {
// 变⻓数组
Sum arr[n];
return _ret;
}
};
int Solution::_i = 1;
int Solution::_ret = 0;
十二、匿名对象
1. 匿名对象的特点
-
类型(实参)定义的对象叫匿名对象,相比类型 对象名(实参)定义的叫有名对象。
-
匿名对象生命周期只在当前一行,一般临时使用即可。
2. 匿名对象特点运用
我们基于下面这段程序讲解匿名对象的特点:
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
Solution() = default;
};
void func(A aa = A(1))
{}
int main()
{
A aa1(1);
A aa2;
// 匿名对象,生命周期当前一行
A(1);
A();
Solution s1;
cout << s1.Sum_Solution(10) << endl;
cout << Solution().Sum_Solution(10) << endl;
func();
const A& r = A();
return 0;
}
首先,我们来看匿名对象的定义:
接下来我们来看匿名对象的生命周期:
匿名对象生命周期只在当前一行,一般临时使用即可
如果使用引用来接收匿名对象,就会延长它的生命周期,使得匿名对象的生命周期变得像它的别名一样。
注意:匿名对象像临时变量一样具有常性,因此需要加const
匿名对象我们这里再看一个用法:
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
Solution() = default;
private:
};
有这样一个函数,我们要在主函数调用,就可以用这种方法。
最后在介绍一种用法:我们现在有这样一个函数,我想给它的形参写成缺省的形式,以往我们写缺省值都是一些常量,这里可以借助匿名对象给
类类型
的一个缺省值。
十三、对象拷贝时的编译器优化
1. 背景介绍
-
现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下,尽可能减少一些传参和返回值过程中可以省略的拷贝。
-
C++标准并没有严格规定如何优化,各个编译器会根据情况自行处理。当前主流的相对新一点的编译器对于一个表达式步骤中的连续拷贝会进行合并优化,有些更新、更“激进”的编译器还会进行跨行跨表达式的合并优化。
2. 优化方式(VS2019版本)
我们将通过下面这段代码来讲解编译器进行的优化
using namespace std;
class A
{
public:
A(int a = 0)
:_a1(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
:_a1(aa._a1)
{
cout << "A(const A& aa)" << endl;
}
A & operator=(const A & aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a1 = aa._a1;
}
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a1 = 1;
};
void f1(A aa)
{}
A f2()
{
A aa;
return aa;
}
int main()
{
// 传值传参
A aa1;
f1(aa1);
cout << endl;
// 隐式类型,连续构造+拷⻉构造->优化为直接构造
f1(1);
// ⼀个表达式中,连续构造+拷⻉构造->优化为⼀个构造
f1(A(2));
cout << endl;
cout << "***********************************************" << endl;
// 传值返回
// 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019 debug)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,直接变为构造。(vs2022 debug)
f2();
cout << endl;
// 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019 debug)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,直接变为构造。(vs2022 debug)
A aa2 = f2();
cout << endl;
// ⼀个表达式中,连续拷⻉构造+赋值重载->⽆法优化
aa1 = f2();
cout << endl;
return 0;
}
我们先来看第一个: 传值传参
第二个:
第三个:和上面原理一样
接下来我们看传值返回的情景:
传值返回里面,VS2019和VS2022就有所不同了,这里先拿2019版本举例子
第一个场景:
第二个场景,
第三个场景:
3. VS2019与VS2022优化对比
VS2019与VS2022在这里的优化主要体现在传值返回上:
我们先用linux看一下不优化和优化的做法:
VS2022把拷贝构造都干掉了,相当于这里
aa
直接是aa2
的别名,是一种相当新且激进的优化方法
再来详细对比一下VS2019与VS2022优化的差别:
可以看到VS2022已经优化的相当恐怖了,拷贝构造全都没了
总结
到这里,C++类和对象的部分就结束了,文章很长能看到这里的观众老爷相必都很有收获吧~
C++类和对象每一个点不是独立的,而是相互交织在一起的,比较难理解,但只要这关跨过去了,C++后面会好学很多~
最后,J桑想说,本文历时多天,创作不易,也欢迎大家多多互动,求各位大大一个免费的三连支持一下~~~
谢谢大家!