类和对象核心知识目录:
一、面向过程和面向对象初步认识
二、类的引入+定义(struct ==> class)
2.1自定义类型 struct 和 class 的区别
2.2类放在内存中的什么存储区?
2.3类中函数定义的方式
2.3.1声明和定义分离(增强代码可读性,强烈推荐)
2.3.2声明和定义一起(隐式内联函数)
2.3.3类中变量的声明方式
三、类的访问限定符+封装+作用域
3.1访问限定符
3.2封装
3.3类作用域
四、类的实例化(类 类型创建对象的过程)+计算类的大小(考虑内存对齐)
4.1什么是类?什么是对象?
4.2类大小计算的疑惑点
4.3类对象的存储方式
4.4结构体内存对齐规则(必须会,面试常问)
4.5空类大小的计算(面试常考)
编辑
五、隐藏的this指针
5.1非静态成员函数的this指针
5.2this指针的特点
5.3this指针存在哪里?
5.4关于this指针为空的问题
六、类的六个默认成员函数
6.1构造函数(初始化和对象内的资源申请,不是对象的创建)
6.1.1特性分析 - 自动生成(特性5)
6.1.2特性分析 - 选择处理
6.1.3特性分析 - 默认构造(特性7)
6.1.4C++11补丁 - 成员变量的缺省值(是声明,不是初始化)
6.2析构函数(是对象的销毁,不是对象中资源的清理)
6.3拷贝构造(已经存在的对象初始化创建出一个对象)
6.3.1拷贝构造函数是什么
6.3.2拷贝构造函数怎么写
6.3.3拷贝构造参数为什么加const ??
6.3.4深浅拷贝(简单讲解)
6.3.5调用拷贝构造的场景
6.3.6怎么提高程序效率
6.4运算符重载(不是默认成员函数)
6.4.1引入
6.4.2运算符重载函数的位置
6.4.3运算符重载的特性
6.4.4常见的运算符重载
6.5赋值重载(是默认成员函数)
6.5.1基础知识
6.5.2特性分析 - 函数格式
6.5.3特性分析 - 重载为成员函数(避免冲突)
6.5.4特性分析 - 深浅拷贝
6.6取地址及const取地址重载(这俩个函数不重要,但是const成员重要)
6.6.1const成员函数
6.6.2取地址重载
6.6.3const取地址重载
6.7总结
七、初始化列表(声明远远不够,还得定义对象)
7.1什么是初始化列表?
7.2 初始化变量的小细节
7.3什么时候必须使用初始化变量(重点)
7.4初始化列表使用建议
7.5成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关
八、explict关键字
九、static成员
十、友元
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题;而C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。我们以洗衣服为例
面向过程 – 逐步求解问题:
面向对象 – 通过对象之间的交互解决问题:
又比如我们的外卖系统:面向过程关注的是顾客应该如何下单、商家应该如何做出菜品、骑手应该如何将外卖送达;而面向对象关注的是顾客、商家、骑手这四个对象之间的交互,比如顾客下单后商家出餐,然后骑手送餐,而不必关心顾客如何下单、商家如何出餐、骑手如何送达这类面向过程的问题
C语言中的struct只能定义变量,但C++中的struct不仅可以定义变量也可以定义函数,他和class关键字的作用是相同的,都可以定义类,当然这是C++为了兼容C语言对struct做出的改变,所以struct所定义的类的成员默认是公有的,就是无论谁都可以使用,因为在C语言中我们是可以直接获取到结构体里面的所有数据的
C++中class定义的类的成员默认是私有的,类内不被限制,类外不可以直接获取到类中的变量,也就是类中的数据,这也正是面向对象语言的一大特性,封装,你只能通过类中的函数来访问数据,不可以直接访问到类里面的数据
//C语言
struct Student
{
char name[20];
char id[11];
int weight;
int height;
};
int main()
{
struct Student stu1 = { "zhangsan", "2202101001", 60, 180 };
}
//C++
struct Stack
{
//类体,由成员函数和成员变量组成
void Init(int N = 4)
{
top = 0;
capacity = 0;// 访问限定符限制的是类外面的,类里面不会被限制
}
void Push(int x)
{
}
int* array;
int top;
int capacity;//C++把类看作一个整体,编译器搜索的时候会在整个类里面去搜索。C语言为了编译的效率只会向上搜索。
};//不要丢掉分号
这里的struct是C++中的用法,不仅可以定义函数还可以定义变量
小羊注:C++结构体直接使用 structName 代表类,而不用加 struct 关键字,但是C++兼容C语言结构体的全部用法,使用我们之前使用 struct + structName 的方式定义变量也是没问题的:
typedef struct ListNode
{
/*struct*/ListNode* next; //SListNode 可以直接代表这个类,所以此处可以不用加 struct
int data;
}SL;
int main()
{
//C语言用法 加truct
struct ListNode* sl1;
SL* sl2;
//C++用法 直接结构体名字上
ListNode* sl3;
}
最后,在C++中更喜欢用 class 来代替 struct,并且把变量称为属性/成员变量,把函数称为成员函数/成员方法
a. 首先大家要知道,类只是一个抽象的描述,对象才是一个具体的东西,就像int,double,char等类型,他们也只是一个描述,他们所定义出来的变量才是具体的东西,所以程序中是不存在类这样的东西的,因为它不是具体化的东西,自然类是不占用内存的。
b. 类的描述信息,程序在编译的时候只要其语义,运行时是不需要他的,因为到运行阶段的时候,代码都已经转换为二进制指令了,二进制指令命令电脑去做对象与对象之间的交互,哪还有类这样的概念。当然如果要用类中的成员函数,成员函数是在代码段的,所以光对于类来说,他仅仅是方便程序猿写程序的一个抽象化描述
c. 在C++的程序中,编译链接之后的指令,宏观角度来说,其实是对象之间利用接口进行交互以及单个对象和接口之间的交互,这种宏观的概念里面哪还有类啊,只要编译器了解类的语义之后,类就没用了,所以根本没有必要分配给它内存。
总的来说,类只是方便人来解决问题的,对于编译器该怎么走怎么走,所以不分配内存给类
class className
{
//...
};
类体中的内容称为类的成员:
类中的变量称为类的属性或成员变量;
类中的函数称为类的方法或者成员函数
a. 声明和定义分离的话,那函数就会被当作正常函数对待,在调用的地方建立函数栈帧,分配堆栈的空间。
b. 函数定义时的函数名前要加上类域名
//使用时,要指定类域
class Person
{
public:
void PrintPersonInfo();
private:
char _name[20];
char _gender[3];
int _age;
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
cout << _name << " " << _gender << " " << _age << endl;
}
甚至为了更加的模块化,还可以这样做:类声明放在.h文件中,成员函数定义放在.cpp文件中 (注意:成员函数名前需要使用类名+域限定符)
class Person
{
public:
void PrintPersonInfo() //inline void PrintPersonInfo()
{
cout << _name << " " << _gender << " " << _age << endl;
}
private:
char _name[20];
char _gender[3];
int _age;
};
void PrintPersonInfo() //inline void PrintPersonInfo()
如果声明和定义都放在类里面,函数会向编译器发出内联请求,是否同意这取决于编译器,这个知识了解一下就好
推荐加下划线的方式来声明成员变量
class Person
{
public:
void PrintPersonInfo();
private:
char _name[20];
char _gender[3];
int _age;
};
注意:访问修饰限定符限定的只是类外的访问权限,类内可以随意访问;并且访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类,在数据结构初阶时,我们曾用C语言来实现栈,其中关于返回栈顶元素的函数接口 – Top就很好的体现了封装的作用
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域
注意:类域和我们之前学习的命名空间域不同,命名空间域中存放的是变量和函数的定义,而类域中虽然可以定义函数,但对于变量来说,仅仅只是声明,并没有为变量开辟空间,只有用这个类实例化出的对象才会开辟空间;这也就是为什么结构体和类中的成员变量都不能直接初始化,而是必须先定义出变量的原因
所以类域调用函数是非法的,必须创建对象 (运算符,类是.,结构体是->)
声明是对成员变量private中写的内容就叫做声明,也就是告知有哪些家庭成员
定义对象因为类的本质就是图纸,模板,框架,所以叫做定义对象
创建对象就是造房子,有了类,创建好了对象
一个最形象的例子:如果类是图纸,对象就是房子,类中的成员变量就是一个个房子中的成员,函数就像是小区中的公共健身器材(函数怎么存储下面4.3内容会有详细讲解,不要错过)
1、类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息;
2、一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量;
类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间
在C语言阶段我们学习了如何计算一个结构体类型的大小,那么对于升级版的结构体 – 类来说,类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?我们又如何计算一个类的大小?
class A
{
public:
void PrintA()
// 从操作系统角度来看,不可能把所有的指令都存下来,只存函数的地址,指针大小4个字节,所以大小应该是5个字节,内存对齐就是8个字节
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
cout << sizeof(A) << endl;
}
在类中甚至还有成员函数需要计算,那么类的大小看起来非常复杂,但我认为读者们看到结构体内存对齐的时候就一定可以豁然开朗了,请看我慢慢分解~
a.
为了节省实例化对象所占空间,我们将每个对象的成员函数抽离出来,放在公共代码段,这样在使用函数时,每个对象只要去公共代码段里面调用就可以了,里面放着该类所有成员函数的有效地址
b.
函数经过编译后形成的指令是由编译器放置到代码段中去的,所以编译器在调用该函数时也能轻松的找到指令在代码段中所处的位置,并且编译器并也不会将不同类中成员函数所形成的指令混淆
即一个类的大小,实际就是该类中 ”成员变量” 之和
验证:
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
char _a;
};
int main()
{
cout << sizeof(A) << endl;
}
我们看到,类A的大小是1,说明只存储了成员变量 _a 的地址,而并没有存储成员函数 PrintA 的地址
类大小的计算方式和结构体大小的计算方式完全相同,都需要进行内存对齐
点击进入:结构体内存对齐规则http://t.csdn.cn/5KtHd
上面我们探讨的是普通类的大小,那么对于一些特殊的类,比如空类或者是只有成员函数,没有成员变量的类,他们的大小是多少呢?是0还是别的值
class B
{
};
class C
{
void PrintC()
{
cout << "PrintC()" << endl;
}
void InitC()
{
cout << "InitC()" << endl;
}
};
int main()
{
cout << sizeof(B) << endl;
cout << sizeof(C) << endl;
}
首先,函数体不占用空间这个我们都知道,那么为什么空类的大小是1?原因1:即使是空类,也可以创建对象,所以赋予大小。原因2:是为了占位
a.
我们知道一个成员函数是可以被多个对象所调用的,那成员函数怎么知道它现在作用的是哪个对象呢?万一有10个对象都调用同一个函数,函数只作用于第一个对象,这可怎么办啊?无法满足我们的需求啊
b.
所以C++编译器给每个“非静态成员函数”增加了一个隐藏的this指针来作为函数的形参, 并且规定该参数必须在函数形参的最左边位置,这个指针中存储的就是对象的地址,在函数体中所有访问对象成员变量的操作都是通过this指针来完成的,只不过这些操作对于用户是透明的,用户不需要手动传递对象地址,编译器可以自动完成(哪个对象调用,就传递哪个对象的地址给this)
c.
我们不可以手动去传递this指针,这是编译器的工作,我们不能去抢,但是我们可以在函数体内部使用这个this指针
验证:
#include
#include
using namespace std;
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;//使用this指针访问到对象的成员变量_year
_month = month;
_day = day;
}
void Print()// 在函数体内部我们可以使用this指针。
{
cout << this << endl;
cout << this->_year << "-" << this->_month << "-" << _day << endl;//我们加了this,编译器就不加了,我们不加,编译器就会加
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Init(2023, 9, 9);
Date d2;
d2.Init(2023, 9, 10);
d1.Print();//d1调用,访问的就是d1的成员
d2.Print();//d2调用,访问的就是d2的成员
cout << &d1 << endl;
cout << &d2 << endl;
return 0;
}
由结果可以看到this指针的的确确就是对象的地址:
- this 指针只能在 “成员函数” 的内部使用;
- this 指针使用 const 修饰,且 const 位于指针*的后面;即 this 本身不能被修改,但可以修改其指向的对象 (我们可以通过 this 指针修改成员变量的值,但不能让 this 指向其他对象)
- this 指针本质上是“成员函数”的一个形参,当对象调用成员函数时,将对象地址作为实参传递给 this 形参,所以对象中不存储this 指针;
- this 指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过建立“成员函数”的函数栈帧时压栈传递,不需要用户主动传递。(注:由于this指针在成员函数中需要被频繁调用,所以VS对其进行了优化,由编译器通过ecx寄存器传递)
有关const和指针的三种写法:
const Date* p1;
Date const* p2;
上面这两种写法都是等价的,const放在星号的左边,修饰的就是 * p1和* p2,也就是指针指向的对象
Date* const p3;// 下面这种写法修饰的是p3,const放在*右边,修饰的是指针变量p3本身
我们的this指针,用的就是第二种方法,因为this指针的所指不可以改
我们的第一反应都是存在对象中,那么问题来了,我们计算类的大笑的时候,难道还计算this指针了吗?说明this指针一定不在对象中存放
答案:this 指针作为函数形参,存在于函数的栈帧中,而函数栈帧在栈区上开辟空间,所以 this 指针存在于栈区上;不过VS这个编译器对 this 指针进行了优化,使用 ecx 寄存器保存 this 指针
小羊注:如果成员函数被当成内联处理,this就不存在,也不会存在存在哪里的问题了,也就是说调用的位置直接展开了,比如内部的_str,直接替换为s._str,这样不需要this指针也可以进行访问
this 指针作为参数传递时是可以为空的,但是如果成员函数中使用到了空的this 指针,那么就会造成对空指针的解引用
//下面两段程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A //程序1
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
//***********************************//
class A //程序2
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
答:程序1正常运行。原因如下:
第一,虽然我们用空指针A访问了成员函数Print,但是由于成员函数并不存在于对象中,而是存在于代码段中,所以编译器并不会通过类对象p去访问成员函数,即并不会对p进行解引用;
第二,当对象是指针类型时,编译器会直接把这个指针作为形参传递给Print函数的 this 指针,而 this 作为参数传递是时可以为空的,在Print函数内部我们也并没有对 this 指针进行解引用
程序2运行崩溃。原因如下:
程序2在 p->Print 处虽然可以正常运行,但是在Print函数内部,_a 会被转化为 this->_a,发生了空指针的解引用,this是p传递的,p是空,所以存在空指针的解引用
我们上面提到过类型占一个字节的空类,空类中什么都没有吗?还是他有但是我们看不到?
其实空类中是有东西的,他有编译器默认生成的6个成员函数,如果我们不主动去写默认成员函数,编译器是会自动生成他们的
在使用C语言练习初阶数据结构,即线性表、链表、栈、队列、二叉树、排序等内容时,大家可能会经常犯两个错误,特别是第二个错误,可以说是十分普遍:
- 在使用数据结构创建变量时忘记对其进行初始化操作而直接进行插入等操作;
- 在使用完毕后忘记对动态开辟的空间进行释放而直接返回;
而C++是在C语言的基础上生长起来的 – 修正C语言中的一些不足,并加入面向对象的思想;面对上面C语言存在的问题,C++设计出了默认成员
默认成员函数:当用户没有显式实现时,编译器会自动生成的成员函数称为默认成员函数
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的任务并不是创建对象,而是当对象被创建之后完成对象的初始化工作;同时构造函数不能由用户调用,而是在创建类类型对象时由编译器自动调用,并且在对象整个生命周期内只调用一次
构造函数有如下特性:
- 函数名与类名相同;
- 无返回值;
- 对象实例化时编译器自动调用对应的构造函数;
- 构造函数支持重载与缺省参数;
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;
- 构造函数对内置类型不处理,对自定义类型调用它自身的默认构造;
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个(容易晕)
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成,现在让我们验证:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
可以看到上面的日期类中我们是没有显式的去自己实现构造函数的,所以编译器应该会自己生成一个无参的默认构造函数完成初始化工作:
但是我们发现一个问题,默认的构造函数好像并没有完成初始化工作,即d1对象中的_year,_month,_day仍然是随机值;那么是因为这里编译器生成的默认构造函数并没有什么用吗?这个问题我们需要构造函数的第六个特性来回答
构造函数的第六点特性如下:构造函数对内置类型不处理,对自定义类型调用它自身的默认构造
对于这个特性,我们使用 Date、Stack 和 Myqueue 三个类来对比理解:
(Myqueue是232. 用栈实现队列 - 力扣(LeetCode))中的题目
注意:在vscode中调试的时候,一定得先打断点再调试
Date:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
Stack:
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
_top = 0;
_capacity = capacity;
cout << "Stack 构造" << endl;
}
void Push(int x)
{
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
Queue:
class MyQueue
{
public:
void Push(int x)
{
_pushST.Push(x);
}
Stack _pushST;
Stack _popST;
};
分析:1.Stack 的成员变量全部为内置类型,所以当我们不显式定义构造函数时,编译器自动生成一个默认构造函数,但默认生成的构造函数并不会对内置类型进行处理,所以这里我们看到的是随机值;Date 类的情况也是如此,也就是说,编译器自动生成的构造函数不能满足我们的需求,所以我们需要手动定义构造函数
2.而对于MyQueue来说,它的成员变量全部为自定义类型,所以即使我们不提供构造函数时,编译器自动生成的构造函数也会去调用自定义类型的默认构造,满足需求
3.那么,到底什么时候需要我们自己提供构造函数,什么时候使用编译器默认生成的构造函数呢?难道是内置类型全部自己定义,自定义类型全部使用默认生成的吗?答案是:面向需求 – 当编译器默认生成的构造函数就能满足我们的需求时我们就不需要自己提供构造函数,如MyQueue;当编译器提供/的构造函数不能满足我们的需求时就需要我们自己定义,如Date/Stack;
这里有两个需要注意的地方:
1、构造函数虽然支持重载和缺省参数,但是无参构造和有参全缺省构造不能同时出现,因为在调用时会产生二义性(注意,我这里说的是全缺省)
同时,当参数有多个时可以构成很多个重载,使得构造函数变得十分冗余,所以一般我们只会显式定义一个全缺省的构造函数,因为这一种就可以构造就可以代表很多种参数情况;
2、当我们调用无参构造或者全缺省构造来初始化对象时,不要在对象后面带括号,这样使得编译器分不清这是在实例化对象还是函数声明(一定要注意)
好的,现在让我们继续讲解特性7:
构造函数的第七点特性如下:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个
上面这句话的意思就是当我们使用无参的方式实例化一个对象时,编译器会自动去调用该对象的默认构造函数,而默认构造函数有三种:编译器自动提供的无参构造函数、显式定义的无参构造函数、显式定义的全缺省的构造函数
如果类中没有默认构造函数,那么我们实例化对象时就必须传递参数:
Date d1(2023,9,28); //这样就可以通过编译
本质上:是为了弥补编译器自己生成的默认构造函数是个废物的漏洞(我创建对象,不写构造函数,确实编译器自己有,但是我开了还没有初始化,这开的个屁)
经过上面的学习我们发现,自动生成的默认构造函数对内置类型不处理,对自定义类型要处理的特性使得构造函数变得很复杂,因为一般的类都有需要初始化的内置类型成员变量,这就使得编译器默认生成的构造函数看起来没什么作用
C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给缺省值;缺省值的意思就是如果构造函数没有对该变量进行初始化,那么该变量就会使用缺省值:
总结:这里对成员变量给定缺省值并不是对其初始化,因为类中的成员变量只是声明,只有当实例化对象之后它才具有物理空间,才能存放数据;而缺省一块动态内存也不难理解,相当于我设计了一份房屋的图纸,我知道某个房间具体要多大,所以我可以在图纸上可以进行标注,当实际建造房屋的时候根据标注给定大小即可
特性:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
- 对象生命周期结束时,也就是对象即将被销毁的时候,一般随着栈帧的销毁对象生命也会结束,这时候C++编译系统会自动调用析构函数
a. 编译器默认生成的析构函数对于内置类型并不会处理,在对象生命结束时,操作系统会自动回收内置类型的内存,但对于自定义类型,编译器默认生成的析构函数会调用该类类型的析构函数。
b. 值得注意的是,由于编译器默认生成的默认析构不会处理内置类型,这也就为它功能的缺陷埋下了隐患,例如内置类型中声明了malloc开辟在堆上的空间呢?这种情况下继续依靠编译器默认生成的析构显然无法满足资源的清理工作,这时候就需要我们手动去将申请的空间还给操作系统。例如栈类的析构函数就需要我们自己来写,他的构造函数同样也需要我们自己来写,因为编译器提供的默认构造无法满足我们的要求,所以写不写析构函数依然是面向需求的一个问题!!
~Stack()
{
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
特性:
- 拷贝构造函数是构造函数的一个重载形式,当我们使用拷贝构造实例化对象时,编译器不再调用构造函数
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
- 若未显式定义,编译器会生成默认的拷贝构造函数
- 默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数
它其实是构造函数的一个重载形式
拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
Date(const Date d) // 错误写法:编译报错,会引发无穷递归
{
_year = d._year;
_month = d._month;
_day = d._day;
}
这里有一个潜在的知识盲区,就是传值会进行传值拷贝,产生的临时变量就又会使用拷贝构造,总的来说:传值会对下面造成影响,但是传引用就会实形一体,不会有影响
你像递归里面也是呀,有些地方你就需要传引用或者传指针,这种它是同一个,有些地方就要传拷贝,你下一层的改变不影响上一层,就是实践当中的逻辑,需要等你做题做的更多时候,写代码写的更多了才能体会。
下面这个图放大网页才可以看清楚:
拷贝构造函数的参数通常使用 const 修饰,这是为了避免在函数内部拷贝出错,类似下面这样:
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝
为什么要存在深拷贝??
在Stack类中:他的内置类型里面有指针,此时一旦发生拷贝构造,两个对象中的指针指向的就是同一块空间(因为默认的不会开空间),那么在在两个对象生命结束时所调用的析构函数就会讲相同指针所指向的空间释放两次,第二次释放的地址就是一个无效的地址,这块地址根本没有指向一块儿有效的空间,自然程序就会出现错误
总结:
如果类中没有资源申请,则不需要手动实现拷贝构造,直接使用编译器自动生成的即可;如果类中有资源申请,就需要自己定义拷贝构造函数,否则就可能出现浅拷贝以及同一块空间被析构多次的情况;
其实,拷贝构造和函数析构函数在资源管理方面有很大的相似性,可以理解为需要写析构函数就需要写拷贝构造,不需要写析构函数就不需要写拷贝构造
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用
为了增强代码的可读性,C++为自定义类型引入了运算符重载,运算符重载是具有特殊函数名的函数 – 其函数 - 名为关键字operator+需要重载的运算符符号,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似;换句话说,运算符重载函数只有函数名特殊,其他方面与普通函数一样
void operator+=(Date& d, int day)
{
d._day += day;
while (d._day > GetMonthDay(d._year, d._month))
{
d._day -= GetMonthDay(d._year, d._month);
d._month++;
if (d._month > 12)
{
d._month -= 12;
d._year++;
}
}
}
如果大家实际上手编写我们上面的 AddDay 和 operator+= 函数就会发现一个问题:类中的成员函数 _year、_month、_day 都是私有的,我们在类外并不能直接修改它们;
但是我们又不能直接把成员变量设为共有,这样类的封装线得不到保证;那么如果我们把函数放到类里面呢?
上面这种情况是由我们在上方第五点 this指针:提到的 this 指针引起的 – 类的每个成员函数的第一个参数都是一个隐藏的 this 指针,它指向类的某一个具体对象,且 this 不能显示传递,也不能显示写出,但是可以在函数内部显示使用
也就是说,本来 += 这个操作符只能有两个操作数,所以使用 operator 重载 += 得到的函数也只能有两个参数;但是由于我们为了使用类的成员变量将函数放在了类内部,所以编译器自动传递了对象的地址,并且在函数中使用一个 this 指针来接收,导致函数参数变成了三个;所以出现了 “operator += 的参数太多” 这个报错
那么为了解决这个问题,我们在定义 operator+= 函数时,就只显式的传递一个参数 – 右操作数,而左操作数由编译器自动传递;当我们在函数内部需要操作左操作数时,也直接操作 this 指针即可
void operator+=(int day)
{
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month > 12)
{
_month -= 12;
_year++;
}
}
}
总代码如下:
#include
using namespace std;
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int GetMonthDay(int year,int month)
{
static int day[13]={0,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 day[month];
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
void operator+=(int day)
{
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month > 12)
{
_month -= 12;
_year++;
}
}
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1+=100;
d1.Print();
return 0;
}
注意
1、当我们将函数放在类内部时,不管操作数有几个,this 默认指向第一个操作数;
2、对于在类外部无法访问类的私有成员变量的问题其实也可以使用友元解决,我们后面再学习;
- 不能通过连接其他符号来创建新的操作符:比如operator@;
- 重载操作符必须有一个类类型参数 (因为运算符重载只能对自定义类型使用);
- 用于内置类型的运算符,其含义不能改变,即不能对内置类型使用运算符重载;
- 作为类类的成员函数重载时,其形参看起来比操作数数目少1,是因为成员函数的第一个参数为隐藏的 this;
- 以下5个运算符不能重载: .* :: sizeof . ?: 注意这个经常在笔试选择题中出现,特别是 .* 操作符,希望大家记住,是.* 不是*
常见的运算符重载有:operator+ (+)、operator- (-)、operator* (*)、operator/(/)、operator+= (+=)、operator-= (-=)、operator== (==)、operator= (=)、operator> (>)、operator< (<)、operator>= (>=)、operator<= (<=)、operator!= (!=)、operator++ (++)、operator-- (–)等
其中,对于 operator++ 和 operator-- 来说有一些不一样的地方 – 因为 ++ 和 – 分为前置和后置,二者虽然都能让变量自增1,但是它们的返回值不同;但是由于 ++ 和 – 只有一个操作数,且这个操作数还会由编译器自动传递;所以正常的 operator++ 和 operator-- 并不能对二者进行区分;最终,C++规定:后置++/–重载时多增加一个int类型的参数,此参数在调用函数时不传递,由编译器自动传递;
其次,上面重载函数中的 operator= 就是默认成员函数之一 – 赋值重载函数
赋值重载函数是C++的默认六个成员函数之一,它也是运算符重载的一种,它的作用是两个已存在的对象之间的赋值,其特性如下:
- 赋值重载的格式规范;
- 赋值运算符只能重载成类的成员函数不能重载成全局函数;
- 若未显式定义,编译器会生成默认的赋值重载函数;
- 默认的赋值重载函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的赋值重载函数;
其中赋值重载和拷贝构造有一个很恶心的小题目:
d1=d2;是复制拷贝 因为是已经存在的俩个对象
Date d3(d2)是初始化 因为一个存在,一个不存在
那么,Date d1=d2 是拷贝构造还是赋值重载? 答案是拷贝构造,因为创建了d1对象
赋值重载函数的格式一般有如下要求:
Tip1:使用引用做参数,并以 const 修饰
我们知道,使用传值传参时函数形参是实参的一份临时拷贝,所以传值传参会调用拷贝构造函数;而使用引用做参数时,形参是实参的别名,从而减少了调用拷贝构造在时间和空间上的消耗;另外,赋值重载只会改变被赋值对象,而不会改变赋值对象,所以我们使用 const 来防止函数内部的误操作
void operator=(const Date& d);
Tip2:使用引用做返回值且返回值为*this
我们可以对内置类型进行连续赋值 d1=d2=d3 所以我们要对函数的返回值做一定的约束和限制
同时,由于我们的对象是类创建的,赋值重载函数的工作完成的时候,对象依然存在,所以可以直接引用的返回值,提高效率(对象不在函数空间开辟,作用域在类)
另外我们一般用左操作数作为函数的返回值,也就是this指针指向的对象,综上所述,我们需要返回*this
Date& operator=(const Date& d);
{
//...
return *this;
}
Tip3:检查是否给自己赋值
用户在调用成员函数时有可能发生下面这种情况:Date d1; Date& d2 = d1; d1 = d2; 这种情况对于只需要浅拷贝的对象来说并没有什么大碍,但对于有资源申请,需要进行深拷贝的对象来说就会发生不可控的事情
if(this == &d) //比较两个对象的地址是否相同
return *this;
综上所述:
//赋值重载
Date& operator=(const Date& d)
{
//自我赋值
if (this == &d)
{
return *this;
}
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
赋值运算符只能重载成类的成员函数不能重载成全局函数,这是因为赋值重载函数作为六个默认成员函数之一,如果我们不显示实现,编译器会默认生成;此时用户如果再在类外自己实现一个全局的赋值运算符重载,就会和编译器在类中生成的默认赋值运算符重载冲突,从而造成链接错误
赋值重载函数的特性和拷贝构造函数非常类似 – 如果我们没有显式定义赋值重载,则编译器会自动生成一个赋值重载,且自动生成的函数对内置类型以字节为单位直接进行拷贝,对自定义类型会去调用其自身的赋值重载函数
这里的情况和 Stack 默认析构函数的情况很类似,但是比它要严重一些 – 自动生成的赋值重载函数进行浅拷贝,使得 st1._a 和 st2._a 指向同一块空间,而 st1 和 st2 对象销毁时编译器会自动调用析构函数,导致 st2._a 指向的空间被析构两次;同时,st1._a 原本指向的空间并没有被释放,所以还发生了内存泄漏
总结:
自动生成的赋值重载函数对成员变量的处理规则和析构函数一样 – 对内置类型以字节方式按值拷贝,对自定义类型调用其自身的赋值重载函数;我们可以理解为:需要写析构函数的类就需要写赋值重载函数,不需要写析构函数的类就不需要写赋值重载函数
我们将 const 修饰的 “成员函数” 称之为 const 成员函数,const 修饰类成员函数实际上修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对 this 指向的类中的任何成员变量进行修改
当我们定义一个只读Date对象时,我们再去调用 d 的成员函数 Print 时编译器会报错
原因在于类成员函数的第一个参数默认是 this 指针,而 this 指针是 Date* const this(限制的是this,但是Date*不受限制),而我们的第一个参数即 d 的类型是 const Date*(限制类型,说明只读,不可写);将一个只读变量赋值给一个可读可写的变量时权限扩大,导致编译器报错
为了解决上面这个问题,C++ 允许我们定义 const 成员函数,即在函数最后面使用 const 修饰,该 const 只修饰函数的第一个参数,即使得 this 指针的类型变为 const Date* const this;函数的其他参数不受影响
所以,当我们在实现一个类时,如果我们不需要改变类的成员函数的第一个参数,即不改变 *this,那么我们就应该使用 const 来修饰 this 指针,以便类的 const 对象在其他 (成员) 函数中也可以调用本函数
总的来说:只要不希望*this成员内容改变,就可以在函数后面加上const
思考:
取地址重载函数是C++的默认六个成员函数之一,同时它也是运算符重载的一种,它的作用是返回对象的地址
Date* operator&()
{
return this;
}
const 取地址重载也是C++的默认六个成员函数之一,它是取地址重载的重载函数,其作用是返回 const 对象的地址
const Date* operator&() const
{
return this;
}
使用场景:
在某些极少数的特殊情况下需要我们自己实现取地址重载与 const 取地址重载函数,比如不允许获取对象的地址,那么在函数内部我们直接返回 nullptr 即可:
//取地址重载
Date* operator&()
{
return nullptr;
}
//const 取地址重载
const Date* operator&() const
{
return nullptr;
}
C++的类里面存在六个默认成员函数 – 构造、析构、拷贝构造、赋值重载、取地址重载、const 取地址重载,其中前面四个函数非常重要,也非常复杂,需要我们根据具体情况判断是否需要显式定义,而最后两个函数通常不需要显示定义,使用编译器默认生成的即可
构造函数:
- 构造函数完成对象的初始化工作,由编译器在实例化对象时自动调用;
- 默认构造函数是指不需要传递参数的构造函数,一共有三种 – 编译器自动生成的、显式定义且无参数的、显式定义且全缺省的;
- 如果用户显式定义了构造函数,那么编译器会根据构造函数的内容进行初始化,如果用户没有显式定义,那么编译器会调用默生成的构造函数;
- 默认生成的构造函数对内置类型不处理,对自定义类型会去调用自定义类型的默认构造;
- 为了弥补构造函数对内置类型不处理的缺陷,C++11打了一个补丁 – 允许在成员变量声明的地方给缺省值;如果构造函数没有对该变量进行初始化,则该变量会被初始化为缺省值;
- 构造函数还存在一个初始化列表,初始化列表的存在有着非常大的意义,具体内容我们在下一个大标题讲解讲解
析构函数:
- 析构函数完成对象中资源的清理工作,由编译器在销毁对象时自动调用;
- 如果用户显式定义了析构函数,编译器会根据析构函数的内容进行析构;如果用户没有显示定义,编译器会调用默认生成的析构函数;
- 默认生成的析构函数对内置类型不处理,对自定义类型会去调用自定义类型的析构函数;
- 如果类中有资源的申请,比如动态开辟空间、打开文件,那么需要我们显式定义析构函数;
拷贝构造:
- 拷贝构造函数是用一个已存在的对象去初始化另一个正在实例化的对象,由编译器在实例化对象时自动调用;
- 拷贝构造的参数必须为引用类型,否则编译器报错 – 值传递会引发拷贝构造函数的无穷递归;
- 如果用户显式定义了拷贝构造函数,编译器会根据拷贝构造函数的内容进行拷贝;如果用户没有显示定义,编译器会调用默认生成的拷贝构造函数;
- 默认生成的拷贝构造函数对于内置类型完成值拷贝 (浅拷贝),对于自定义类型会去调用自定义类型的拷贝构造函数;
- 当类里面有空间的动态开辟时,直接进行值拷贝会让两个指针指向同一块动态内存,从而使得对象销毁时对同一块空间析构两次;所以这种情况下我们需要自己显式定义拷贝构造函数完成深拷贝;
运算符重载:
- 运算符重载是C++为了增强代码的可读性而引入的语法,它只能对自定义类型使用,其函数名为 operator 关键字加相关运算符;
- 由于运算符重载函数通常都要访问类的成员变量,所以我们一般将其定义为类的成员函数;同时,因为类的成员函数的一个参数为隐藏的 this 指针,所以其看起来会少一个参数;
- 同一运算符的重载函数之间也可以构成函数重载,比如 operator++ 与 operator++(int);
赋值重载:
- 赋值重载函数是将一个已存在对象中的数据赋值给另一个已存在的对象,注意不是初始化,需要自己显示调用;它属于运算符重载的一种;
- 如果用户显式定义了赋值重载函数,编译器会根据赋值重载函数的内容进行赋值;如果用户没有显示定义,编译器会调用默认生成的赋值重载函数;
- 默认生成的赋值重载函数对于内置类型完成值拷贝 (浅拷贝),对于自定义类型会去调用自定义类型的赋值重载函数;
- 赋值重载函数和拷贝构造函数一样,也存在着深浅拷贝的问题,且其与拷贝构造函数不同的地方在于它还很有可能造成内存泄漏;所以当类中有空间的动态开辟时我们需要自己显式定义赋值重载函数来释放原空间以及完成深拷贝;
- 为了提高函数效率与保护对象,通常使用引用作参数,并加以 const 修饰;同时为了满足连续赋值,通常使用引用作返回值,且一般返回左操作数,即 *this;
- 赋值重载函数必须定义为类的成员函数,否则编译器默认生成的赋值重载会与类外自定义的赋值重载冲突;
const成员函数:
- 由于指针和引用传递参数时存在权限的扩大、缩小与平移的问题,所以 const 类型的对象不能调用成员函数,因为成员函数的 this 指针默认是非 const 的,二者之间传参存在权限扩大的问题;
- 同时我们为了提高函数效率以及保护对象,一般都会将成员函数的第二个参数使用 const 修饰,这就导致了该对象在成员函数内也不能调用其他成员函数;
- 为了解决这个问题,C++设计出了 const 成员函数 – 在函数最后面添加 const 修饰,该 const 只修饰 this 指针,不修饰函数的其他参数;
- 所以如果我们在设计类时,只要成员函数不改变第一个对象,我们建议最后都使用 const 修饰;
取地址重载与const取地址重载:
- 取地址重载与 const 取地址重载是获取一个对象/一个只读对象的地址,需要自己显式调用;它们属于运算符重载,同时它们二者之间还构成函数重载;
- 大多数情况下我们都不会去显示实现这两个函数,使用编译器默认生成的即可;只有极少数情况需要我们自己定义,比如防止用户获取到一个对象的地址;
声明是对成员变量private中写的内容就叫做声明,也就是告知有哪些家庭成员
定义对象因为类的本质就是图纸,模板,框架,所以叫做定义对象
创建对象就是造房子,有了类,创建好了对象
初始,化列表:以一个冒号开始也就是,接着是一个以逗号分隔的数据成员家庭成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式
构造函数函数体内执行的是赋值语句,成员变量只能在初始化列表进行定义与初始化
class Date
{
public:
Date(int year, int month, int day)
: _year(year)// 初始化列表
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
而且不可以用= ;只可以用括号
- const修饰
- 引用成员变量
- 自定义类型的类中没有合适的默认构造时必须要在初始化列表的位置进行初始化
不可以通过构造函数对成员变量进行赋初值
原因:对于const修饰,和引用必须使用的原因解释起来大同小异,因为这二者必须在定义的时候初始化,且初始化之后在被的地方不可以被修改。初始化列表最直白的定义就是:定义对象,所以对于这二者不可以赋值,必须使用初始化列表;对于没有默认构造函数的自定义类型来说,我们也必须在初始化列表处对其进行初始化,否则编译器就会报错。
我们在之前C++11时候,打了一个补丁叫做基于缺省值,它其实就是通过初始化列表来初始化的(即使没有写,也会走)
也就是说,无论我们是否使用初始化列表,类的成员变量都会先使用初始化列表进行初始化
既然他每次都要走,以后就尽量不在函数体内写赋值语句了,直接通通写上初始化列表
class A
{
public:
A(int a)
:_a1(a)
, _a2(_a1)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2;
int _a1;
};
由于在类中 _a2 的声明在 _a1 之前,所以在初始化列表处 _a2(_a1) 语句被先被执行,而此时 _a1 还是一个随机值,所以最终 _a2 输出随机值
写不动了,未完待续,明天补完。。。。。
初始化列表中,定义对象,创建对象,初始化对象,声明对象还是分不清楚
类就是一个生成对象的模版,对象是类的一个实例 所以初始化列表是定义对象这句话没错