C++类和对象(一)
文章目录
C++类和对象(一)
本次内容大纲:
面向过程和面向对象的初步认识
首先我们先给出以下结论:
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++是基于面向对象的,关注的是对象,将一件事拆分成不同的对象,靠对象之间的交互完成。
举例说明面向过程和面向对象的区别:
我们就外卖系统来看看面向过程和面向对象之间的区别:
面向过程,我们的关注点应该是用户下单、骑手接单以及骑手送餐这三个过程。
面向对象,那我们的关注点应该就是客户、商家以及骑手这三个类对象之间的关系。
类的引入
在C语言中,结构体中只能定义变量,但在C++中,结构体内不仅可以定义变量,还可以定义函数。
例如:
struct Test
{
//成员变量
int a;
double b;
//成员函数
int Add(int x, int y)
{
return x + y;
}
};
但上面结构体的定义,在C++中更喜欢用class来代替。
类的定义
class className
{
//类体:由成员变量和成员函数组成
}; //注意后面的分号
其中class为定义类的关键字,className为类的名字,{}中为类的主体,注意定义结束时加上后面的分号。
类中的元素称为类的成员:类中的数据称为类的属性或者成员变量,类中的函数称为类的方法或者成员函数。
类的两种定义方式:
1、声明和定义全部放在类体中。需要注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
2、声明放在头文件(.h)中,定义放在源文件(.cpp)中。
注意:一般情况下,更期望采用第二种方式。
类的访问限定符及类的封装
类的访问限定符
C++实现封装的方式:用类将对象的属性和方法结合在一块,让对象更加完善,通过访问权限,选择性的将其接口提供给外部的用户使用。
【访问限定符说明】
1、public修饰的成员可以在类外直接被访问。
2、protected和private修饰的成员在类外不能直接被访问。
3、访问权限从该访问限定符出现的位置开始直到下一个访问限定符出现时为止。
4、class的默认访问权限为private,struct为public(因为struct要兼容C)。
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
面试题:C++中的struct和class的区别是什么?
answer:C++需要兼容C语言,所以C++中的struct可以当成结构体去使用。此外,C++中的struct还可以用来定义类,和class定义类是一样的,区别是struct的成员默认访问权限是public,而class的成员默认访问权限是private。
类的封装
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理:想想我们是如何管理陕西省的兵马俑的。我们若什么都不管,兵马俑就被随意破坏了。所以我们建立了一座房子将兵马俑封装起来。但是我们封装的目的不是为了不给别人看,所以我们开放了售票通道,人们可以通过买票突破封装,在合理的监管机制下进去参观。
类也是一样,我们使用类将数据和方法都封装起来。不想对外开放的就用 protected/private 封装起来,用 public 封装的成员允许外界对其进行合理的访问。所以封装本质上是一种管理。
类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员,需要使用“::”作用域解析符指明成员属于哪个类域。
class Person
{
public:
//显示基本信息
void ShowInfo();
private:
char* _name; //姓名
char* _sex; //性别
int _age; //年龄
};
//这里需要指定ShowInfo是属于Person这个类域
void Person::ShowInfo()
{
cout << _name << "-" << _sex << "-" << _age << endl;
}
类的实例化
用类类型创建对象的过程,称为类的实例化。
1、类只是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它。
就像C语言中定义了一个结构体一样,当你还未用该自定义类型创建变量时,定义结构体类型这个过程并没有分配实际的内存空间来存储它。
2、一个类可以实例化出多个对象,实例化出的对象将占用实际的物理空间来存储类成员变量。
就像你在C语言中定义了一个结构体,然后用该自定义类型创建了一个变量,那么这个变量将占用实际的物理空间来存储其成员变量。
3、类实例化出对象就像现实中使用建筑设计图建造出房子,类就是设计图。
设计图只设计出需要什么东西,但是并没有实体的建筑存在。同样类也只是一个设计,只有实例化出的对象才能实际存储数据,占用物理空间。
类对象模型
如何计算类对象的大小
一个类当中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?类的大小又是如何计算的呢?
class Person
{
public:
//显示基本信息
void ShowInfo()
{
cout << _name << "-" << _sex << "-" << _age << endl;
}
public:
char* _name; //姓名
char* _sex; //性别
int _age; //年龄
};
类对象的存储方式猜测
猜测一:对象中包含类的各个成员
缺陷:每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同的代码保存了多次,浪费空间。
猜测二:只保存成员变量,成员函数存放在公共的代码段。
对于上述两种存储方式,计算机是按照哪种方式来存储的,我们可以通过对下面的不同对象分别获取大小来进行分析:
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1(){}
private:
int _a;
};
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 类中什么都没有---空类
class A3
{};
通过单目操作符sizeof来获取这三个对象的大小,结果A1的大小为4个字节,A2的大小为1个字节,A3的大小也为1个字节。
结论:一个类的大小,实际就是该类中“成员变量”之和,当然也要进行内存对齐,注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类(占位)。
结构体内存对齐规则
- 第一个成员在与结构体变量偏移量为0的地址处。(即结构体的首地址处,即对齐到0处)
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 结构体的总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
- 如果嵌套了结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
对齐数 = 该结构体成员变量自身的大小与编译器默认的一个对齐数的较小值。
不明白如何计算结构体大小->请戳这里
this指针
this指针的引出
我们先来定义一个日期类的Date:
#include <iostream>
using namespace std;
class Date
{
public:
void Display()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
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, d2;//实例化两个日期类
d1.SetDate(2021, 5, 25);//设置d1的日期
d2.SetDate(2021, 5, 26);//设置d2的日期
d1.Display();//打印d1的日期
d2.Display();//打印d2的日期
return 0;
}
上述Date类中有SetDate和Display两个成员函数,函数体中并没有关于不同对象的区分,那么当d1调用SetDate函数时,该函数是如何知道要设置的是d1对象,而不是d2对象呢?
C++中通过引入this指针解决该问题:C++编译器给每个“非静态的成员函数”增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问的。只不过所有操作对用户是透明的,即用户不需要来传递,而是编译器自动完成。
上述代码调用成员函数传参时,看似只传入了一些基本数据,实际上还传入了指向该对象的指针:
编译器进行编译时,看到的成员函数实际上也和我们所看到的不一样,每个成员函数的第一个形参实际上是一个隐含的this指针,该指针用于接收调用函数的对象的地址,用this指针就可以很好地访问到该对象中的成员变量:
this指针的特性
1、this指针的类型:类类型* const。
2、this指针只能在“成员函数”的内部使用。
3、this指针本质上其实是一个成员函数的形参,是对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
4、this指针是成员函数第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。
让我们通过下面这段代码更深入的理解this指针:
#include <iostream>
using namespace std;
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
void Show()
{
cout << "Show()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
//p->Show(); //第一句代码
//p->PrintA(); //第二句代码
}
程序分别运行第一句代码和第二句代码,你认为程序运行的结果如何?
你可能看到指针p是一个空指针,而第二句代码和第三句代码都通过操作符“->”,间接性的执行了对p的解引用操作,所以你认为程序会崩溃。
其实不然,当程序执行第一句代码时,程序不会崩溃,会正常打印出字符串"Show()",而当程序执行第二句代码时,程序才会因为内存的非法访问而崩溃。
解释如下:
指针p确实是一个类的空指针,但当执行第一句代码时,程序并不会崩溃。第一句代码并没有对空指针p进行解引用,因为Show等成员函数地址并没有存到对象里面,成员函数的地址是存在公共代码段的。
当程序执行第二句代码时,会因为内存的非法访问而崩溃。执行第二句代码时,调用了成员函数PrintA,这里并不会产生什么错误(理由同上),但是PrintA函数中打印了成员变量_a,成员变量_a只有通过对this指针进行解引用才能访问到,而this指针此时接收的是nullptr,对空进行解引用必然会导致程序的崩溃。
C++类和对象(二)—— 类的6个默认成员函数及日期类的实现
本次内容大纲:
在学习完C++的6个默认成员函数后,我们将用所学知识实现一个日期类:
类的6个默认成员函数
如果一个类中什么成员都没有,我们简称其为空类。但是空类中真的什么都没有吗?其实不然,任何一个类,即使我们什么都不写,类中也会自动生成6个默认成员函数。
class Date {}; //空类
1
注意:这里的“默认”和“缺省”的意思差不多,也就是你不写这6个函数,编译器会自动生成,你若是写了,则编译器就不生成了。
构造函数
构造函数的概念
构造函数:名字与类名相同,创建类类型对象时由编译器自动调用,保证每个数据成员都有 一个合适的初始值,并且在对象的生命周期内只调用一次。
例如,以下日期类中的成员函数Date就是一个构造函数。当你用该日期类创建一个对象时,编译器会自动调用该构造函数对新创建的变量进行初始化。
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)// 构造函数
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
注意:构造函数的主要任务并不是开空间创建对象,而是初始化对象。
构造函数的特性
一、构造函数的函数名与类名相同
二、构造函数无返回值
这里所说的构造函数无返回值是真的无返回值,而不是说返回值为void。
三、对象实例化时编译器自动调用对应的构造函数
当你用类创建一个对象时,编译器会自动调用该类的构造函数对新创建的变量进行初始化。
四、构造函数支持重载
这意味着你可以有多种初始化对象的方式,编译器会根据你所传递的参数去调用对应的构造函数。
五、无参的构造函数、全缺省的构造函数以及我们不写编译器自动生成的构造函数都称为默认构造函数,并且默认构造函数只能有一个
初学C++时,你可能认为只有当我们不写,编译器自动生成的构造函数才被称为默认构造函数。其实并不是这样的,以下3种都叫做默认构造函数:
1、我们不写,编译器自动生成的构造函数。
2、我们自己写的无参的构造函数。
3、我们自己写的全缺省的构造函数。
总而言之,无需传参就可以调用的构造函数就是默认构造函数。
六、如果类中没有显示定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,若用户显示定义了,则编译器就不再生成
说到这里,你可能会想:既然在我们不写的情况下,编译器会自动生成一个构造函数,那我们就没有必要自己写构造函数了。这种想法是不对的。
看看以下代码:
#include <iostream>
using namespace std;
class Date
{
public:
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; // 编译器将调用自动生成的默认构造函数对d1进行初始化
d1.Print();
return 0;
}
代码最终的打印结果:
嗯…最终d1当中的年月日都是随机值。
到这里你可能会产生疑问:d1对象调用了编译器自动生成的构造函数后,d1对象的_year/_month/_day依旧是随机值,那这编译器自动生成的构造函数还有什么意义?
编译器自动生成的构造函数机制:
1、编译器自动生成的构造函数对内置类型不做处理。
2、对于自定义类型,编译器会再去调用它们自己的默认构造函数。
总结一下:虽然在我们不写的情况下,编译器会自动生成构造函数,但是编译器自动生成的构造函数可能达不到我们想要的效果,所以大多数情况下都需要我们自己写构造函数。
成员初始化列表
原则 一(正确处理依赖关系)
一般而言 按照成员变量在类中声明的顺序编写初始化列表
如果成员间存在依赖关系(如一个成员的初始化依赖于另一个成员的值),那么遵循声明顺序就显得尤为重要。否则,可能会出现未初始化或错误初始化的情况,导致程序运行异常。
例子
class MyClass {
public:
int* array = nullptr;
int length = 0;
int derivedValue=0;
MyClass(int arraySize) :
length(arraySize),
derivedValue(length + 10),
array(new int[length ])
{
cout << length << endl;
for (int i = 0; i < length; ++i)
array[i] = i;
}
~MyClass() {
if (array != nullptr) {
delete[] array;
}
}
};
int main() {
MyClass obj(5);
std::cout << "Derived value: " << obj.derivedValue << std::endl;
return 0;
}
调试代码时,会出现,析构函数出错,
array[i] = i;
上面这行 代码出现 缓冲区溢出。
成员变量在构造函数初始化列表中的初始化顺序是按照声明的顺序进行的,而不是按照您在初始化列表中指定的顺序。因此,在初始化 array
时,length
可能尚未被正确设置。
要修改错误, 保证 length 在 array 之前
class MyClass {
public:
int length = 0;
int* array = nullptr;
int derivedValue=0;
或者
class MyClass {
public:
int length = 0;
int derivedValue=0;
int* array = nullptr;
作用 : 减少构造 次数
代码片段一 :
#include<iostream>
#include<string>
using std::cout;
using std::cin;
using std::string;
using std::endl;
class Example
{
public:
Example()
{
cout << "Created Entity!" << endl;
}
Example(int x)
{
cout << "Created Entity with " << x << "!" << endl;
}
};
class Entity {
private: string m_Name;
Example m_Example;
public:Entity() {
m_Name = "unKnow";
m_Example = Example(8);
};
Entity(const string& name) :m_Name(name) {
}
const string& GetName()const { return m_Name; }
};
int main()
{
Entity e0;
// cout << e0.GetName();
}
代码片段二 :
#include<iostream>
#include<string>
using std::cout;
using std::cin;
using std::string;
using std::endl;
class Example
{
public:
Example()
{
cout << "Created Entity!" << endl;
}
Example(int x)
{
cout << "Created Entity with " << x << "!" << endl;
}
};
class Entity {
private: string m_Name;
Example m_Example;
public:Entity() :m_Example(Example(8))
//or m_Example( 8) 结果一致
{
m_Name = "unKnow";
/*m_Example = Example(8);*/
};
Entity(const string& name) :m_Name(name) {
}
const string& GetName()const { return m_Name; }
};
int main()
{
Entity e0;
// cout << e0.GetName();
}
片段一 片段二 的 区别 仅仅是 赋值 m_Example 的 方式
m_Example = Example(8);
和 m_Example(Example(8));
在构造函数的上下文中含义和作用完全不同。
- 成员初始化列表中的形式:
Entity() : m_Example(Example(8))
{
// ...
};
在这个例子中,m_Example(Example(8))
是在成员初始化列表中使用。这意味着 m_Example
成员变量将会直接通过传递给 Example
构造函数的参数来初始化。这里会创建一个临时的 Example
对象(用 8 初始化),然后将这个临时对象作为实参来初始化 m_Example
成员变量。这种方式下,不会调用 m_Example
的默认构造函数,而是直接调用带有参数的构造函数来初始化它。
- 构造函数主体内的赋值操作:
Entity()
{
m_Example = Example(8);
// ...
};
而在构造函数的主体内执行 m_Example = Example(8);
语句,则意味着首先 m_Example
会被其默认构造函数初始化,然后在此之后立即进行赋值操作,用 Example(8)
创建的新对象替换原来的 m_Example
对象内容。这种方式会导致两次构造过程:一次是默认构造,另一次是拷贝或移动构造(取决于编译器实现和是否有合适的移动构造函数)。
所以,从效率和资源管理的角度来看,在可能的情况下,优先选择在成员初始化列表中直接初始化成员变量而非在构造函数体内赋值。
在 C++ 中,成员初始化列表的原则主要包括以下几点:
-
必需使用成员初始化列表的情况:
- 引用类型的成员必须在初始化列表中初始化,因为引用一旦绑定就不能更改。
- const 成员变量如果需要在构造函数中赋值,也必须通过初始化列表来完成,因为在构造函数体内不能对它们进行赋值操作。
- 基类的子对象(当你继承时)应该在其派生类的构造函数初始化列表中被初始化。
-
推荐使用成员初始化列表的情况:
- 当类中有资源管理型的对象(如指针、智能指针等),应尽量在初始化列表中分配和初始化这些资源。这有助于确保资源始终得到正确管理,避免潜在的内存泄漏问题。
- 对于那些没有默认构造函数或者使用默认构造函数不合适的复杂类型成员,应当在初始化列表中调用适当的构造函数进行初始化。
- 如果某些成员变量之间的初始化存在依赖关系,例如上面例子中的
derivedValue
依赖于length
的值,则应在初始化列表中按正确的逻辑顺序初始化,以保证初始化的正确性。
-
效率与顺序:
初始化列表的执行先于构造函数主体中的任何代码,因此使用初始化列表可以提高效率,因为它直接将初始值传递给成员变量,而无需额外的赋值操作。同时,初始化列表确保了成员初始化的顺序严格按照声明顺序进行(对于非 POD 类型尤其重要)。 -
清晰度与一致性:
初始化列表提供了集中初始化成员变量的地方,使得代码更易于阅读和理解,并且减少了构造函数内部可能出现的错误或混淆。
总结来说,原则是:尽可能利用成员初始化列表来确保成员变量在构造函数调用时就立即得到正确初始化,尤其是当成员变量有特殊要求(如 const 或引用)或者初始化逻辑较为复杂时。
析构函数
析构函数的概念
析构函数:与构造函数功能相反,析构函数负责完成对象的销毁,对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。
我们知道当一个类对象销毁时,其中的局部变量也会随着该对象的销毁而销毁,例如,我们用日期类创建了一个对象d1,当d1被销毁时,对象d1当中的局部变量_year/_month/_day也会被编译器销毁。
但是这并不意味着析构函数没有什么意义。像栈(Stack)这样的类对象,当该对象被销毁时,其中动态开辟的栈并不会随之被销毁,需要我们对其进行空间释放,这时析构函数的意义就体现了。
析构函数的特性
一、析构函数的函数名是在类名前加上字符‘~’
class Date
{
public:
Date()// 构造函数
{}
~Date()// 析构函数
{}
private:
int _year;
int _month;
int _day;
};
二、析构函数无参数,无返回值
析构函数所谓的无返回值也是真的无返回值,而不是返回值为void。
三、对象生命周期结束时,C++编译器会自动调用析构函数
这就大大降低了C语言中栈空间忘记释放问题的发生,因为当栈对象生命周期结束时,C++编译器会自动调用析构函数对其栈空间进行释放。
四、一个类有且只有一个析构函数。若未显示定义系统会自动生成默认的析构函数
编译器自动生成的析构函数机制:
1、编译器自动生成的析构函数对内置类型不做处理。
2、对于自定义类型,编译器会再去调用它们自己的默认析构函数。
五、先构造的后析构,后构造的先析构
因为对象是定义在函数中的,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合先进后出的原则。
拷贝构造函数
拷贝构造函数的概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用从const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)// 构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)// 拷贝构造函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2021, 5, 31);
Date d2(d1); // 用已存在的对象d1创建对象d2
return 0;
}
拷贝构造函数的特性
一、拷贝构造函数是构造函数的一个重载形式
因为拷贝构造函数的函数名也与类名相同。
二、拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用
要调用拷贝构造函数就需要先传参,若传参使用传值传参,那么在传参过程中又需要进行对象的拷贝构造,如此循环往复,最终引发无穷递归调用。
小贴士:自定义类型的对象进行函数传参时,一般推荐使用引用传参。使用传值传参也可以,但每次传参时都会调用拷贝构造函数。
三、若未显示定义拷贝构造函数,系统将生成默认的拷贝构造函数
看看以下代码:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)// 构造函数
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2021, 5, 30);
Date d2(d1); // 用已存在的对象d1创建对象d2
d1.Print();
d2.Print();
return 0;
}
打印结果:
代码中,我们自己并没有定义拷贝构造函数,但编译器自动生成的拷贝构造函数最终还是完成了对象的拷贝构造。
编译器自动生成的拷贝构造函数机制:
1、编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝)。
2、对于自定义类型,编译器会再去调用它们自己的默认拷贝构造函数。
四、编译器自动生成的拷贝构造函数不能实现深拷贝
上面说到,编译器自动生成的拷贝构造函数会对内置类型完成浅拷贝。对于以下这句代码,浅拷贝实际上就是将d1的内容完完全全的复制了一份拷贝给d2,所以说浅拷贝也叫做值拷贝。
Date d2(d1);// 用已存在的对象d1创建对象d2
1
但某些场景下浅拷贝并不能达到我们想要的效果。例如,栈(Stack)这样的类,编译器自动生成的拷贝构造函数就不能满足我们的需求了:
Stack s1;
Stack s2(s1);// 用已存在的对象s1创建对象s2
代码中,我们的本意是用已存在的对象s1创建对象s2,但编译器自动生成的拷贝构造函数,完成的是浅拷贝,拷贝出来的对象s2将不能满足我们的要求。
举个例子,现有以下栈(Stack)类:
class Stack
{
public:
Stack(int capacity = 4)
{
_ps = (int*)malloc(sizeof(int)* capacity);
_size = 0;
_capacity = capacity;
}
void Print()
{
cout << _ps << endl;// 打印栈空间地址
}
private:
int* _ps;
int _size;
int _capacity;
};
我们可以看到,类中没有自己定义拷贝构造函数,那么当我们用已存在的对象来创建另一个对象时,将调用编译器自动生成的拷贝构造函数。看看以下代码运行结果:
int main()
{
Stack s1;
s1.Print();// 打印s1栈空间的地址
Stack s2(s1);// 用已存在的对象s1创建对象s2
s2.Print();// 打印s2栈空间的地址
return 0;
}
5678
结果打印s1栈和s2栈空间的地址相同,这就意味着,就算在创建完s2栈后,我们对s1栈做的任何操作都会直接影响到s2栈。
这是我们想要的效果吗?显然不是,我们希望在创建时,s2栈和s1栈中的数据是相同的,但是在创建完s2栈后,我们对s1栈和s2栈之间的任何操作能够互不影响。
而且这种情况下,还会出现对同一块空间释放多次的问题。若我们自己定义的析构函数是正确的情况下,当程序运行结束,s2栈将被析构,此时那块栈空间被释放,然后s1栈也要被析构,再次对那一块空间进行释放。
可以看到,这种情况下编译器自动生成的拷贝构造函数就不能满足我们的要求了。
总结一下:
1、像Date这样的类,需要的就是浅拷贝,那么编译器自动生成的拷贝构造函数就够用了,我们不需要自己写。
2、像Stack这样的类,浅拷贝会导致析构两次、程序崩溃等问题,需要我们自己写对应的拷贝构造函数。
赋值运算符重载
运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,其目的就是让自定义类型可以像内置类型一样可以直接使用运算符进行操作。
d1 == d2;// 可读性高(书写简单)
IsSame(d1, d2);// 可读性差(书写麻烦)
运算符重载函数也具有自己的返回值类型,函数名字以及参数列表。其返回值类型和参数列表与普通函数类似。
运算符重载函数名为:关键字operator后面接需要重载的操作符符号。
函数原型:返回值 operator运算符(参数列表)
注意:
1.不能通过连接其他符号来创建新的操作符:比如operator@。
2.重载操作符必须有一个类类型或枚举类型的操作数。
3.用于内置类型的操作符,重载后其含义不能改变。
4.作为类成员的重载函数时,函数有一个默认的形参this,限定为第一个形参。
5.sizeof 、:: 、.* 、?: 、. 这5个运算符不能重载。
这里以重载 == 运算符作为例子:
我们可以将该运算符重载函数作为类的一个成员函数,此时该函数的第一个形参默认为this指针。
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
bool operator==(const Date& d)// 运算符重载函数
{
return _year == d._year
&&_month == d._month
&&_day == d._day;
}
private:
int _year;
int _month;
int _day;
};
我们也可以将该运算符重载函数放在类外面,但此时外部无法访问类中的成员变量,这时我们可以将类中的成员变量设置为共有(public),这样外部就可以访问该类的成员变量了(也可以用友元函数解决该问题)。并且在类外没有this指针,所以此时函数的形参我们必须显示的设置两个。
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
int _year;
int _month;
int _day;
};
bool operator==(const Date& d1, const Date& d2)// 运算符重载函数
{
return d1._year == d2._year
&&d1._month == d2._month
&&d1._day == d2._day;
}
19202 223
赋值运算符重载
这里以重载 = 运算符作为例子:
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)// 构造函数
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)// 赋值运算符重载函数
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
void Print()// 打印函数
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
重载赋值运算符需要注意以下几点:
一、参数类型设置为引用,并用const进行修饰
赋值运算符重载函数的第一个形参默认是this指针,第二个形参是我们赋值运算符的右操作数。
由于是自定义类型传参,我们若是使用传值传参,会额外调用一次拷贝构造函数,所以函数的第二个参数最好使用引用传参(第一个参数是默认的this指针,我们管不了)。
其次,第二个参数,即赋值运算符的右操作数,我们在函数体内不会对其进行修改,所以最好加上const进行修饰。
二、函数的返回值使用引用返回
实际上,我们若是只以d2 = d1这种方式使用赋值运算符,赋值运算符重载函数就没必要有返回值,因为在函数体内已经通过this指针对d2进行了修改。但是为了支持连续赋值,即d3 = d2 = d1,我们就需要为函数设置一个返回值了,而且很明显,返回值应该是赋值运算符的左操作数,即this指针指向的对象。
和使用引用传参的道理一样,为了避免不必要的拷贝,我们最好还是使用引用返回,因为此时出了函数作用域this指针指向的对象并没有被销毁,所以可以使用引用返回。
三、赋值前检查是否是给自己赋值
若是出现d1 = d1,我们不必进行赋值操作,因为自己赋值给自己是没有必要进行的。所以在进行赋值操作前可以先判断是否是给自己赋值,避免不必要的赋值操作。
四、引用返回的是*this
赋值操作进行完毕时,我们应该返回赋值运算符的左操作数,而在函数体内我们只能通过this指针访问到左操作数,所以要返回左操作数就只能返回*this。
五、一个类如果没有显示定义赋值运算符重载,编译器也会自动生成一个,完成对象按字节序的值拷贝
没错,赋值运算符重载编译器也可以自动生成,并且也是支持连续赋值的。但是编译器自动生成的赋值运算符重载完成的是对象按字节序的值拷贝,例如d2 = d1,编译器会将d1所占内存空间的值完完全全地拷贝到d2的内存空间中去,类似于memcpy。
对于日期类,编译器自动生成的赋值运算符重载函数就可以满足我们的需求,我们可以不用自己写。但是这也不意味着所有的类都不用我们自己写赋值运算符重载函数,当遇到一些特殊的类,我们还是得自己动手写赋值运算符函数的。
注意区别以下代码所调用的函数:
Date d1(2021, 6, 1);
Date d2(d1);
Date d3 = d1;
3
这里一个三句代码,我们现在都知道第二句代码调用的是拷贝构造函数,那么第三句代码呢?调用的是哪一个函数?是赋值运算符重载函数吗?
其实第三句代码调用的也是拷贝构造函数,注意区分拷贝构造函数和赋值运算符重载函数的使用场景:
拷贝构造函数:用一个已经存在的对象去构造初始化另一个即将创建的对象。
赋值运算符重载函数:在两个对象都已经存在的情况下,将一个对象赋值给另一个对象。
const成员
const修饰类的成员函数
我们将const修饰的类成员函数称之为const成员函数,const修饰类成员函数,实际修饰的是类成员函数隐含的this指针,表明在该成员函数中不能对this指针指向的对象进行修改。
例如,我们可以对类成员函数中的打印函数进行const修饰,避免在函数体内不小心修改了对象:
void Print()const// cosnt修饰的打印函数
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
思考下面几个问题(经典面试题):
1.const对象可以调用非const成员函数吗?
2.非const对象可以调用const成员函数吗?
3.const成员函数内可以调用其他的非const成员函数吗?
4.非cosnt成员函数内可以调用其他的cosnt成员函数吗?
答案是:不可以、可以、不可以、可以
解释如下:
1.非const成员函数,即成员函数的this指针没有被const所修饰,我们传入一个被const修饰的对象,用没有被const修饰的this指针进行接收,属于权限的放大,函数调用失败。
2.const成员函数,即成员函数的this指针被const所修饰,我们传入一个没有被const修饰的对象,用被const修饰的this指针进行接收,属于权限的缩小,函数调用成功。
3.在一个被const所修饰的成员函数中调用其他没有被const所修饰的成员函数,也就是将一个被const修饰的this指针的值赋值给一个没有被const修饰的this指针,属于权限的放大,函数调用失败。
4.在一个没有被const所修饰的成员函数中调用其他被const所修饰的成员函数,也就是将一个没有被const修饰的this指针的值赋值给一个被const修饰的this指针,属于权限的缩小,函数调用成功。
取地址及const取地址操作符重载
取地址操作符重载和const取地址操作符重载,这两个默认成员函数一般不用自己重新定义,使用编译器自动生成的就行了:
class Date
{
public:
Date* operator&()// 取地址操作符重载
{
return this;
}
const Date* operator&()const// const取地址操作符重载
{
return this;
}
private:
int _year;
int _month;
int _day;
};
日期类的实现
在学习了C++的6个默认成员函数后,我们现在动手实现一个完整的日期类,来加强对这6个默认成员函数的认识。
这是日期类中所包含的成员函数和成员变量:
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1);
// 打印函数
void Print() const;
// 日期+=天数
Date& operator+=(int day);
// 日期+天数
Date operator+(int day) const;
// 日期-=天数
Date& operator-=(int day);
// 日期-天数
Date operator-(int day) const;
// 前置++
Date& operator++();
// 后置++
Date operator++(int);
// 前置--
Date& operator--();
// 后置--
Date operator--(int);
// 日期的大小关系比较
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;
// 日期-日期
int operator-(const Date& d) const;
// 析构,拷贝构造,赋值重载可以不写,使用默认生成的即可
private:
int _year;
int _month;
int _day;
};
构造函数
进入构造函数体,首先需要检查日期的合法性,只有当日期合法时,才能进行后续的构造操作。
// 获取某年某月的天数
inline int GetMonthDay(int year, int month)
{
// 数组存储平年每个月的天数
static int dayArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
int day = dayArray[month];
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
{
//闰年2月的天数
day = 29;
}
return day;
}
// 构造函数
Date::Date(int year, int month, int day)
{
// 检查日期的合法性
if (year >= 0
&& month >= 1 && month <=
&& day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
// 严格来说抛异常更好
cout << "非法日期" << endl;
cout << year << "年" << month << "月" << day << "日" << endl;
}
}
GetMonthDay函数中的三个细节:
1.该函数可能被多次调用,所以我们最好将其设置为内联函数。
2.函数中存储每月天数的数组最好是用static修饰,存储在静态区,避免每次调用该函数都需要重新开辟数组。
3.逻辑与应该先判断month == 2是否为真,因为当不是2月的时候我们不必判断是不是闰年。
注意:当函数声明和定义分开时,在声明时注明缺省参数,定义时不标出缺省参数。
打印函数
这个不用说了,相当简单。
// 打印函数
void Date::Print() const
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
5
日期 += 天数
对于+=运算符,我们先将需要加的天数加到日上面,然后判断日期是否合法,若不合法,则通过不断调整,直到日期合法为止。
调整日期的思路:
1.若日已满,则日减去当前月的天数,月加一。
2.若月已满,则将年加一,月置为1。
反复执行1和2,直到日期合法为止。
// 日期+=天数
Date& Date::operator+=(int day)
{
if (day<0)
{
// 复用operator-=
*this -= -day;
}
else
{
_day += day;
// 日期不合法,通过不断调整,直到最后日期合法为止
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month > )
{
_year++;
_month = 1;
}
}
}
return *this;
}
25
注:当需要加的天数为负数时,转而调用-=运算符重载函数。
日期 + 天数
+运算符的重载,我们可以复用上面已经实现的+=运算符的重载函数。但是要注意:虽然我们返回的是加了之后的值,但是对象本身的值并没有改变。就像a = b + 1,b + 1的返回值是b + 1,但是b的值并没有改变。所以我们还可以用const对该函数进行修饰,防止函数内部改变了this指针指向的对象。
// 日期+天数
Date Date::operator+(int day) const
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator+=
tmp += day;
return tmp;
}
56789
注意:+=运算符的重载函数采用的是引用返回,因为出了函数作用域,this指针指向的对象没有被销毁。但+运算符的重载函数的返回值只能是传值返回,因为出了函数作用域,对象tmp就被销毁了,不能使用引用返回。
日期 -= 天数
对于-=运算符,我们先用日减去需要减的天数,然后判断日期是否合法,若不合法,则通过不断调整,直到日期合法为止。
调整日期的思路:
1.若日为负数,则月减一。
2.若月为0,则年减一,月置为 。
3.日加上当前月的天数。
反复执行1、2和3,直到日期合法为止。
// 日期-=天数
Date& Date::operator-=(int day)
{
if (day < 0)
{
// 复用operator+=
*this += -day;
}
else
{
_day -= day;
// 日期不合法,通过不断调整,直到最后日期合法为止
while (_day <= 0)
{
_month--;
if (_month == 0)
{
_year--;
_month = ;
}
_day += GetMonthDay(_year, _month);
}
}
return *this;
}
232425
注:当需要减的天数为负数时,转而调用+=运算符重载函数。
日期 - 天数
和+运算符的重载类似,我们可以复用上面已经实现的-=运算符的重载函数,而且最好用const对该函数进行修饰,防止函数内部改变了this指针指向的对象。
// 日期-天数
Date Date::operator-(int day) const
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator-=
tmp -= day;
return tmp;
}
56789
注意:-=运算符的重载函数采用的是引用返回,但-运算符的重载函数的返回值只能是传值返回,也是由于-运算符重载函数中的tmp对象出了函数作用域被销毁了,所以不能使用引用返回。
前置 ++
前置++,我们可以复用+=运算符的重载函数。
// 前置++
Date& Date::operator++()
{
// 复用operator+=
*this += 1;
return *this;
}
567
后置 ++
由于前置++和后置++的运算符均为++,为了区分它们的运算符重载,我们给后置++的运算符重载的参数加上一个int型参数,使用后置++时不需要给这个int参数传入实参,因为这里int参数的作用只是为了跟前置++构成重载。
// 后置++
Date Date::operator++(int)
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator+=
*this += 1;
return tmp;
}
5678
注意:后置++也是需要返回加了之前的值,只能先用对象tmp保存之前的值,然后再然对象加一,最后返回tmp对象。由于tmp对象出了该函数作用域就被销毁了,所以后置++只能使用传值返回,而前置++可以使用引用返回。
前置 –
前置–,我们也是可以复用前面的-=运算符的重载函数。
// 前置--
Date& Date::operator--()
{
// 复用operator-=
*this -= 1;
return *this;
}
567
后置–
后置–需要注意的事项和后置++是一样的,我这里就不过多阐述了。
// 后置--
Date Date::operator--(int)
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator-=
*this -= 1;
return tmp;
}
5678
日期类的大小关系比较
日期类的大小关系比较需要重载的运算符看起来有6个,实际上我们只用实现两个就可以了,然后其他的通过复用这两个就可以实现。
注意:进行日期的大小比较,我们并不会改变传入对象的值,所以这6个运算符重载函数都应该被const所修饰。
>运算符的重载
>运算符的重载很简单,先判断年是否大于,再判断月是否大于,最后判断日是否大于,这其中有一者为真则函数返回true,否则返回false。
bool Date::operator>(const Date& d) const
{
if (_year > d._year)
{
return true;
}
else if (_year == d._year)
{
if (_month > d._month)
{
return true;
}
else if (_month == d._month)
{
if (_day > d._day)
{
return true;
}
}
}
return false;
}
19202 2
==运算符的重载
==运算符的重载也是很简单,年月日均相等,则为真。
bool Date::operator==(const Date& d) const
{
return _year == d._year
&&_month == d._month
&&_day == d._day;
}
56
>=运算符的重载
>=,即大于或者等于,满足其中之一即可。
bool Date::operator>=(const Date& d) const
{
return *this > d || *this == d;
}
<运算符的重载
<,大于等于的反面即是小于。
bool Date::operator<(const Date& d) const
{
return !(*this >= d);
}
<=运算符的重载
<=,大于的返回即是小于等于。
bool Date::operator<=(const Date& d) const
{
return !(*this > d);
}
!=运算符的重载
!=,等于的反面即是不等于。
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
日期 - 日期
日期 - 日期,即计算传入的两个日期相差的天数。我们只需要让较小的日期的天数一直加一,直到最后和较大的日期相等即可,这个过程中较小日期所加的总天数便是这两个日期之间差值的绝对值。若是第一个日期大于第二个日期,则返回这个差值的正值,若第一个日期小于第二个日期,则返回这个差值的负值。
// 日期-日期
int Date::operator-(const Date& d) const
{
Date max = *this;// 假设第一个日期较大
Date min = d;// 假设第二个日期较小
int flag = 1;// 此时结果应该为正值
if (*this < d)
{
// 假设错误,更正
max = d;
min = *this;
flag = -1;// 此时结果应该为负值
}
int n = 0;// 记录所加的总天数
while (min != max)
{
min++;// 较小的日期++
n++;// 总天数++
}
return n*flag;
}
192021
代码中使用flag变量标记返回值的正负,flag为1代表返回的是正值,flag为-1代表返回的是负值,最后返回总天数与flag相乘之后的值即可。
C++类和对象(三)
本次内容大纲:
再谈构造函数
构造函数体赋值
在创建对象时,编译器会通过调用构造函数,给对象中的各个成员变量一个合适的初始值:
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
15
需要注意的是:虽然通过调用上述的构造函数后,对象中的每个成员变量都有了一个初始值,但是构造函数中的语句只能将其称作为赋初值,而不能称作为初始化。因为初始化只能初始化一次,而构造函数体内可以进行多次赋值。
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;// 第一次赋值
_year = 2022;// 第二次赋值
//...
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
初始化列表
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式。
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
注意事项:
一、每个成员变量在初始化列表中只能出现一次
因为初始化只能进行一次,所以同一个成员变量在初始化列表中不能多次出现。
二、类中包含以下成员,必须放在初始化列表进行初始化:
1.引用成员变量
引用类型的变量在定义时就必须给其一个初始值,所以引用成员变量必须使用初始化列表对其进行初始化。
int a = 10;
int& b = a;// 创建时就初始化
2.const成员变量
被const修饰的变量也必须在定义时就给其一个初始值,也必须使用初始化列表进行初始化。
const int a = 10;//correct 创建时就初始化
const int b;//error 创建时未初始化
3.自定义类型成员(该类没有默认构造函数)
若一个类没有默认构造函数,那么我们在实例化该类对象时就需要传参对其进行初始化,所以实例化没有默认构造函数的类对象时必须使用初始化列表对其进行初始化。
在这里再声明一下,默认构造函数是指不用传参就可以调用的构造函数:
1.我们不写,编译器自动生成的构造函数。
2.无参的构造函数。
3.全缺省的构造函数。
class A //该类没有默认构造函数
{
public:
A(int val) //注:这个不叫默认构造函数(需要传参调用)
{
_val = val;
}
private:
int _val;
};
class B
{
public:
B()
:_a(2021) //必须使用初始化列表对其进行初始化
{}
private:
A _a; //自定义类型成员(该类没有默认构造函数)
};
总结一下:在定义时就必须进行初始化的变量类型,就必须放在初始化列表进行初始化。
三、尽量使用初始化列表初始化
因为初始化列表实际上就是当你实例化一个对象时,该对象的成员变量定义的地方,所以无论你是否使用初始化列表,都会走这么一个过程(成员变量需要定义出来)。
严格来说:
1.对于内置类型,使用初始化列表和在构造函数体内进行初始化实际上是没有差别的,其差别就类似于如下代码:
// 使用初始化列表
int a = 10
// 在构造函数体内初始化(不使用初始化列表)
int a;
a = 10;
345
2.对于自定义类型,使用初始化列表可以提高代码的效率
class Time
{
public:
Time(int hour = 0)
{
_hour = hour;
}
private:
int _hour;
};
class Test
{
public:
// 使用初始化列表
Test(int hour)
:_t( )// 调用一次Time类的构造函数
{}
private:
Time _t;
};
对于以上代码,当我们要实例化一个Test类的对象时,我们使用了初始化列表,在实例化过程中只调用了一次Time类的构造函数。
我们若是想在不使用初始化列表的情况下,达到我们想要的效果,就不得不这样写了:
class Time
{
public:
Time(int hour = 0)
{
_hour = hour;
}
private:
int _hour;
};
class Test
{
public:
// 在构造函数体内初始化(不使用初始化列表)
Test(int hour)
{ //初始化列表调用一次Time类的构造函数(不使用初始化列表但也会走这个过程)
Time t(hour);// 调用一次Time类的构造函数
_t = t;// 调用一次Time类的赋值运算符重载函数
}
private:
Time _t;
};
这时,当我们要实例化一个Test类的对象时,在实例化过程中会先在初始化列表时调用一次Time类的构造函数,然后在实例化t对象时调用一次Time类的构造函数,最后还需要调用了一次Time类的赋值运算符重载函数,效率就降下来了。
四、成员变量在类中声明的次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后顺序无关
举个例子:
#include <iostream>
using namespace std;
int i = 0;
class Test
{
public:
Test()
:_b(i++)
,_a(i++)
{}
void Print()
{
cout << "_a:" << _a << endl;
cout << "_b:" << _b << endl;
}
private:
int _a;
int _b;
};
int main()
{
Test test;
test.Print(); //打印结果test._a为0,test._b为1
return 0;
}
代码中,Test类构造函数的初始化列表中成员变量_b先初始化,成员变量_a后初始化,按道理打印结果test._a为1,test._b为0,但是初始化列表的初始化顺序是成员变量在类中声明次序,所以最终test._a为0,test._b为1。
explicit关键字
构造函数不仅可以构造和初始化对象,对于单个参数的构造函数,还支持隐式类型转换。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 0) //单个参数的构造函数
:_year(year)
{}
void Print()
{
cout << _year << endl;
}
private:
int _year;
};
int main()
{
Date d1 = 2021; //支持该操作
d1.Print();
return 0;
}
在语法上,代码中Date d1 = 2021等价于以下两句代码:
Date tmp(2021); //先构造
Date d1(tmp); //再拷贝构造
所以在早期的编译器中,当编译器遇到Date d1 = 2021这句代码时,会先构造一个临时对象,再用临时对象拷贝构造d1;但是现在的编译器已经做了优化,当遇到Date d1 = 2021这句代码时,会按照Date d1(2021)这句代码处理,这就叫做隐式类型转换。
实际上,我们早就接触了隐式类型转换,只是我们不知道而已,以下代码也叫隐式类型转换:
int a = 10;
double b = a; //隐式类型转换
在这个过程中,编译器会先构建一个double类型的临时变量接收a的值,然后再将该临时变量的值赋值给b。这就是为什么函数可以返回局部变量的值,因为当函数被销毁后,虽然作为返回值的变量也被销毁了,但是隐式类型转换过程中所产生的临时变量并没有被销毁,所以该值仍然存在。
但是,对于单参数的自定义类型来说,Date d1 = 2021这种代码的可读性不是很好,我们若是想禁止单参数构造函数的隐式转换,可以用关键字explicit来修饰构造函数。
static成员
概念
声明为static的类成员称为类的静态成员。用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化。
特性
一、静态成员为所以类对象所共享,不属于某个具体的对象
鉴于此,我们看看以下代码的运行结果:
#include <iostream>
using namespace std;
class Test
{
private:
static int _n;
};
int main()
{
cout << sizeof(Test) << endl;
return 0;
}
结果计算Test类的大小为1,因为静态成员_n是存储在静态区的,属于整个类,也属于类的所有对象。所以计算类的大小或是类对象的大小时,静态成员并不计入其总大小之和。
二、静态成员变量必须在类外定义,定义时不添加static关键字
class Test
{
private:
static int _n;
};
// 静态成员变量的定义初始化
int Test::_n = 0;
注意:这里静态成员变量_n虽然是私有,但是我们在类外突破类域直接对其进行了访问。这是一个特例,不受访问限定符的限制,否则就没办法对静态成员变量进行定义和初始化了。
三、静态成员函数没有隐藏的this指针,不能访问任何非静态成员
class Test
{
public:
static void Fun()
{
cout << _a << endl; //error不能访问非静态成员
cout << _n << endl; //correct
}
private:
int _a; //非静态成员
static int _n; //静态成员
};
小贴士:含有静态成员变量的类,一般含有一个静态成员函数,用于访问静态成员变量。
四、访问静态成员变量的方法
1.当静态成员变量为公有时,有以下几种访问方式:
#include <iostream>
using namespace std;
class Test
{
public:
static int _n; //公有
};
// 静态成员变量的定义初始化
int Test::_n = 0;
int main()
{
Test test;
cout << test._n << endl; //1.通过类对象突破类域进行访问
cout << Test()._n << endl; //3.通过匿名对象突破类域进行访问
cout << Test::_n << endl; //2.通过类名突破类域进行访问
return 0;
}
2.当静态成员变量为私有时,有以下几种访问方式:
#include <iostream>
using namespace std;
class Test
{
public:
static int GetN()
{
return _n;
}
private:
static int _n;
};
// 静态成员变量的定义初始化
int Test::_n = 0;
int main()
{
Test test;
cout << test.GetN() << endl; //1.通过对象调用成员函数进行访问
cout << Test().GetN() << endl; //2.通过匿名对象调用成员函数进行访问
cout << Test::GetN() << endl; //3.通过类名调用静态成员函数进行访问
return 0;
}
五、静态成员和类的普通成员一样,也有public、private和protected这三种访问级别
所以当静态成员变量设置为private时,尽管我们突破了类域,也不能对其进行访问。
注意区分两个问题:
1、静态成员函数可以调用非静态成员函数吗?
2、非静态成员函数可以调用静态成员函数吗?
问题1:不可以。因为非静态成员函数的第一个形参默认为this指针,而静态成员函数中没有this指针,故静态成员函数不可调用非静态成员函数。
问题2:可以。因为静态成员函数和非静态成员函数都在类中,在类中不受访问限定符的限制。
C++11中成员初始化的新玩法
C++11支持非静态成员变量在声明时进行初始化赋值,但是要注意这里不是初始化,这里是给声明的成员变量一个缺省值。
class A
{
public:
void Print()
{
cout << _a << endl;
cout << _p << endl;
}
private:
// 非静态成员变量,可以在成员声明时给缺省值。
int _a = 10;
int* _p = (int*)malloc(4);
static int _n; //静态成员变量不能给缺省值
};
初始化列表是成员变量定义初始化的地方,你若是给定了值,就用你所给的值对成员变量进行初始化,你若没有给定值,则用缺省值进行初始化,若是没有缺省值,则内置类型的成员就是随机值。
友元
友元分为友元函数和友元类。友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元函数
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。
对于之前实现的日期类,我们现在尝试重载operator<<,但是我们发现没办法将其重载为成员函数,因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置:this指针默认是第一个参数,即左操作数,但是实际使用中cout需要是第一个形参对象才能正常使用。
所以我们要将operator<<重载为全局函数,但是这样的话,又会导致类外没办法访问成员,那么这里就需要友元来解决。(operator>>同理)
我们都知道C++的<<和>>很神奇,因为它们能够自动识别输入和输出变量的类型,我们使用它们时不必像C语言一样增加数据格式的控制。实际上,这一点也不神奇,内置类型的对象能直接使用cout和cin输入输出,是因为库里面已经将它们的<<和>>重载好了,<<和>>能够自动识别类型,是因为它们之间构成了函数重载。
所以,我们若是想让<<和>>也自动识别我们的日期类,就需要我们自己写出对应的运算符重载函数。
class Date
{
// 友元函数的声明
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
// <<运算符重载
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "-" << d._month << "-" << d._day<< endl;
return out;
}
// >>运算符重载
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
注意:其中cout是ostream类的一个全局对象,cin是istream类的一个全局变量,<<和>>运算符的重载函数具有返回值是为了实现连续的输入和输出操作。
友元函数说明:
1、友元函数可以访问类是私有和保护成员,但不是类的成员函数。
2、友元函数不能用const修饰。
3、友元函数可以在类定义的任何地方声明,不受访问限定符的限制。
4、一个函数可以是多个类的友元函数。
5、友元函数的调用与普通函数的调用原理相同。
友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中非公有成员。
class A
{
// 声明B是A的友元类
friend class B;
public:
A(int n = 0)
:_n(n)
{}
private:
int _n;
};
class B
{
public:
void Test(A& a)
{
// B类可以直接访问A类中的私有成员变量
cout << a._n << endl;
}
};
友元类说明:
1、友元关系是单向的,不具有交换性。
例如上述代码中,B是A的友元,所以在B类中可以直接访问A类的私有成员变量,但是在A类中不能访问B类中的私有成员变量。
2、友元关系不能传递。
如果A是B的友元,B是C的友元,不能推出A是C的友元。
内部类
概念
概念:如果一个类定义在另一个类的内部,则这个类被称为内部类。
注意:
1.此时的内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象区调用内部类。
2、外部类对内部类没有任何优越的访问权限。
3、内部类就是外部类的友元类,即内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
特性
1、内部类可以定义在外部类的public、private以及protected这三个区域中的任一区域。
2、内部类可以直接访问外部类中的static、枚举成员,不需要外部类的对象/类名。
3、外部类的大小与内部类的大小无关。
#include <iostream>
using namespace std;
class A //外部类
{
public:
class B //内部类
{
private:
int _b;
};
private:
int _a;
};
int main()
{
cout << sizeof(A) << endl; //外部类的大小
return 0;
}
15161718
这里外部类A的大小为4,与内部类的大小无关。
再次理解封装
C++是基于面向对象的程序,面向对象有三大特性:封装、继承、多态。
C++通过类,将一个对象的属性与行为结合在一起,使其更符合人们对于一件事物的认知,将属于该对象的所有东西打包在一起。通过访问限定符的将其部分功能开放出来与其他对象进行交互,而对于对象内部的一些实现细节,外部用户不需要知道,知道了有些情况下也没用,反而增加了使用或者维护的难度,让整个事情复杂化。
下面举个例子来让大家更好的理解封装性带来的好处,比如:乘火车出行
我们来看一下火车站:
售票系统:负责售票—用户凭票进入,对号入座。
工作人员:售票、咨询、安检、保全、卫生等。
火车:带用户到目的地。
火车站中所有工作人员配合起来,才能让大家坐车井然有序的进行,乘客不需要知道火车的构造、票务系统是如何运作的,只要能正常方便的应用即可。
想象一下,如果是没有任何管理的开放性站台会是怎么样的?火车站没有围墙,站内火车管理调度也是随意的,乘车没有规矩:
再次理解面向对象
可以看出,面向对象其实是在模拟抽象映射现实世界:
stream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
**注意**:其中cout是ostream类的一个全局对象,cin是istream类的一个全局变量,<<和>>运算符的重载函数具有返回值是为了实现连续的输入和输出操作。
**友元函数说明**:
1、友元函数可以访问类是私有和保护成员,但不是类的成员函数。
2、友元函数不能用const修饰。
3、友元函数可以在类定义的任何地方声明,不受访问限定符的限制。
4、一个函数可以是多个类的友元函数。
5、友元函数的调用与普通函数的调用原理相同。
### 友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中非公有成员。
```cpp
class A
{
// 声明B是A的友元类
friend class B;
public:
A(int n = 0)
:_n(n)
{}
private:
int _n;
};
class B
{
public:
void Test(A& a)
{
// B类可以直接访问A类中的私有成员变量
cout << a._n << endl;
}
};
友元类说明:
1、友元关系是单向的,不具有交换性。
例如上述代码中,B是A的友元,所以在B类中可以直接访问A类的私有成员变量,但是在A类中不能访问B类中的私有成员变量。
2、友元关系不能传递。
如果A是B的友元,B是C的友元,不能推出A是C的友元。
内部类
概念
概念:如果一个类定义在另一个类的内部,则这个类被称为内部类。
注意:
1.此时的内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象区调用内部类。
2、外部类对内部类没有任何优越的访问权限。
3、内部类就是外部类的友元类,即内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
特性
1、内部类可以定义在外部类的public、private以及protected这三个区域中的任一区域。
2、内部类可以直接访问外部类中的static、枚举成员,不需要外部类的对象/类名。
3、外部类的大小与内部类的大小无关。
#include <iostream>
using namespace std;
class A //外部类
{
public:
class B //内部类
{
private:
int _b;
};
private:
int _a;
};
int main()
{
cout << sizeof(A) << endl; //外部类的大小
return 0;
}
15161718
这里外部类A的大小为4,与内部类的大小无关。
再次理解封装
C++是基于面向对象的程序,面向对象有三大特性:封装、继承、多态。
C++通过类,将一个对象的属性与行为结合在一起,使其更符合人们对于一件事物的认知,将属于该对象的所有东西打包在一起。通过访问限定符的将其部分功能开放出来与其他对象进行交互,而对于对象内部的一些实现细节,外部用户不需要知道,知道了有些情况下也没用,反而增加了使用或者维护的难度,让整个事情复杂化。
下面举个例子来让大家更好的理解封装性带来的好处,比如:乘火车出行
[外链图片转存中…(img-w3E0ihHS-1728141415579)]
我们来看一下火车站:
售票系统:负责售票—用户凭票进入,对号入座。
工作人员:售票、咨询、安检、保全、卫生等。
火车:带用户到目的地。
[外链图片转存中…(img-HW2NTNa9-1728141415580)]
火车站中所有工作人员配合起来,才能让大家坐车井然有序的进行,乘客不需要知道火车的构造、票务系统是如何运作的,只要能正常方便的应用即可。
想象一下,如果是没有任何管理的开放性站台会是怎么样的?火车站没有围墙,站内火车管理调度也是随意的,乘车没有规矩:
[外链图片转存中…(img-JtCeMnSJ-1728141415581)]
再次理解面向对象
可以看出,面向对象其实是在模拟抽象映射现实世界:
[外链图片转存中…(img-cPTGd07e-1728141415581)]