我们一鼓作气来冲刺C++类和对象的中篇👊
目录
一、构造函数
1.1 构造函数的使用
📋我们现在来用C++实现一个栈:
class Stack
{
public:
void StackInit(int a=4)
{
_a = (int*)malloc(sizeof(int) * a);
if (_a == nullptr)
{
perror("malloc");
exit(-1);
}
_capacity = 4;
_top = 0;
}
void StackPush(int data)
{
if (_capacity == _top)//判断栈是否已满,满了就扩容
{
int* temp = (int*)realloc(_a, (_capacity + 4) * sizeof(int));
if (temp == nullptr)
{
perror("realloc");
exit(-1);
}
_a = temp;
_capacity += 4;
}
_a[_top] = data;//向栈内添加数据
_top++;//栈顶增加1
}
private:
int* _a;
int _top; // 栈顶
int _capacity; // 容量
};
现在我们在使用这个栈时忘记了对其的初始化,导致了程序的崩溃:
💡那我们每次实例化栈这个类时都不能忘记对其初始化,这也太麻烦了吧,有没有一种方式可以自动对我们实例化的对象进行初始化呢?
当然有,构造函数就出现了:构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次
📌下面是构造函数的特性:
1. 函数名与类名相同
2. 无返回值(函数前也不需要void类型声明)
3. 对象实例化时编译器自动调用对应的构造函数
4. 构造函数可以进行函数重载
5.如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
📋根据构造函数的特性我们来改写一下这个类:
class Stack
{
public:
Stack()//构造函数
{
_a = nullptr;
_capacity = 0;
_top = 0;
}
Stack(int a)//构造函数
{
_a = (int*)malloc(sizeof(int) * a);
if (_a == nullptr)
{
perror("malloc");
exit(-1);
}
_capacity = 4;
_top = 0;
}
void StackPush(int data)
{
if (_capacity == _top)//判断栈是否已满,满了就扩容
{
int* temp = (int*)realloc(_a, (_capacity + 4) * sizeof(int));
if (temp == nullptr)
{
perror("realloc");
exit(-1);
}
_a = temp;
_capacity += 4;
}
_a[_top] = data;//向栈内添加数据
_top++;//栈顶增加1
}
private:
int* _a;
int _top; // 栈顶
int _capacity; // 容量
};
由于构造函数可以支持重载,这里写了两个Stack函数,其中一个有参数一个无参数
我们在主函数中实例化一下试试:
我们可以看到在我们实例化类时,所创建的对象会自动跳跃到对应的构造函数中进行初始化
❗这里要注意了:想要调用无参数类型的构造函数时,我们不能这样子不传参(因为这样编译器不能分辨这是返回值为Stack函数的声明,还是想要实例化对象):
1.2 默认构造函数
在构造函数的特性中我们可以看到:如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
📋那我们现在不去定义构造函数,来看看会发生什么
class Date
{
public:
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
运行效果:
怎么是随机值?不是说好有默认构造函数嘛?难道编译器生成的默认构造函数并没有什么用?
💡这是因为:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型。对于内置类型成员编译器生成默认的构造函数不会进行初始化处理,但是对于自定义类型成员编译器生成默认的构造函数会调用的它的默认构造函数
这样在这里就可以解释清楚了:Date类成员都是内置类型,默认构造函数不会对齐进行初始化。
📋我们现在来看看自定义类型成员默认函数是怎么个事:
class Time
{
public:
Time()
{
_hour = 0;
_minute = 0;
_second = 0;
cout << "Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
//自定义类型
Time _t;
//内置类型
int _year;
int _month;
int _day;
};
从上面的运行效果我们可以看到,对于自定义类型成员编译器生成默认的构造函数会调用的它的默认构造函数
C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
📋例如:
class Date
{
public:
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
//内置类型
int _year = 2023;
int _month = 6;
int _day = 6;
};
❗注意:这里类中所给的值并不是对其初始化,而是相当于缺省值,在实例化没有对其进行赋值时默认使用该值。
❗另外我们还得注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,并且默认构造函数只能有一个。
如果我们现在有两个默认构造函数:
class Stack
{
public:
Stack()//无参构造函数
{
_a = nullptr;
_capacity = 0;
_top = 0;
cout << "Init finish" << endl;
}
Stack(int a = 1)//全缺省构造函数
{
_a = (int*)malloc(sizeof(int) * a);
if (_a == nullptr)
{
perror("malloc");
exit(-1);
}
_capacity = 4;
_top = 0;
cout << "Init finish" << endl;
}
void StackPush(int data)
{
if (_capacity == _top)//判断栈是否已满,满了就扩容
{
int* temp = (int*)realloc(_a, (_capacity + 4) * sizeof(int));
if (temp == nullptr)
{
perror("realloc");
exit(-1);
}
_a = temp;
_capacity += 4;
}
_a[_top] = data;
_top++;
cout << "push finish" << endl;
}
private:
int* _a;
int _top;
int _capacity;
};
我们在实际使用时就会出现问题,毕竟无参数构造函数和全缺省构造函数都可以不要传值,编译器并不知道要去调用哪一个构造函数。
二、析构函数
2.1 析构函数的使用
💡如果构造函数是刚实例化对象就开始调用,那当一个对象的生命周期结束后我们能不能设计一个可以自动调用的函数来方便我们实现某些功能呢?
这时候就该析构函数登场了
📌析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
以下是析构函数的特性:
1. 析构函数名是在类名前加上字符 ~
2. 无参数无返回值类型
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
4. 对象生命周期结束时,C++编译系统自动调用析构函数
📋我们每次使用完栈都需要对其堆上所使用的内存进行释放,这十分的不方便,我们现在使用析构函数来在对象生命周期结束后让系统自动调用该函数来进行内存的释放:
class Stack
{
public:
Stack()
{
_a = nullptr;
_capacity = 0;
_top = 0;
cout << "Init finish" << endl;
}
Stack(int a)
{
_a = (int*)malloc(sizeof(int) * a);
if (_a == nullptr)
{
perror("malloc");
exit(-1);
}
_capacity = 4;
_top = 0;
cout << "Init finish" << endl;
}
void StackPush(int data)
{
if (_capacity == _top)//判断栈是否已满,满了就扩容
{
int* temp = (int*)realloc(_a, (_capacity + 4) * sizeof(int));
if (temp == nullptr)
{
perror("realloc");
exit(-1);
}
_a = temp;
_capacity += 4;
}
_a[_top] = data;
_top++;
cout << "push finish" << endl;
}
~Stack()//析构函数(对象生命周期结束后自动释放内存)
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
cout << "free finish" << endl;
}
private:
int* _a;
int _top;
int _capacity;
};
下面是演示效果,可以看到在S1生命周期结束后C++编译系统自动调用了析构函数进行了内存的释放
2.2 默认析构函数
从析构函数的特性:若未显式定义,系统会自动生成默认的析构函数
而系统所生成的默认析构函数和默认生成构造函数的方式是一样的:编译器生成的默认析构函数,对自定类型成员调用它的析构函数,而对于内置类型成员不做任何操作
❗注意:析构函数可以不写,直接使用编译器生成的默认析构函数;有资源申请时,一定要写,否则会造成资源泄漏
三、拷贝构造函数
我们在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
答案是肯定的,但是创建的过程可没那么简单。
在创建一个与已存在对象一某一样的新对象时,我们肯定需要拷贝已存在对象的数据,那我们直接让编译器来拷贝不就行了嘛,真的可以吗?
答案是否定的,因为编译器只能做到浅拷贝,例如顺序表、链表之类的拷贝我们怎么敢交给编译器自己去拷贝,会发生两个对象使用同一块空间的情景,在两个对象生命周期都结束时析构函数会将同一块空间释放多次,这显然是不合理的!
这样子我们需要在类中自行写一个拷贝构造函数,来进行对象之间的拷贝,具体怎么拷贝我们亲自上手操作
📌拷贝构造函数:函数名为类的名字,无返回值,只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰,防止程序员出错修改形参),在用已存在的类类型对象创建新对象时由编译器自动调用。
下面是拷贝构造函数的特性:
1. 拷贝构造函数是构造函数的一个重载形式
2. 拷贝构造函数的参数只有一个且必须是类型对象的引用,使用传值方式编译器会直接报错,因为会引发无穷递归调用
3. 若未显示定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝
📋我们来写一个拷贝构造函数:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& data)//拷贝构造函数
{
_year = data._year;
_month = data._month;
_day = data._day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 2023;
int _month = 6;
int _day = 6;
};
上面是使用效果,当我们实例化d2对象时,将对象d1传引用过去,系统就会调用拷贝构造函数来一步步将数据拷贝到d2中
那为什么不能直接传值呢?因为传值会造成函数的层层拷贝,造成无限递归
📋当然除了调用函数传值的方式,还可以酱紫(使用=):
这两种方式没有什么区别,底层都会调用拷贝构造函数
❗注意:当我们使用函数来传对象的形参时,如果存在拷贝构造函数,会调用拷贝构造函数来将实参的数据拷贝到形参内
📋例如:下面是Func()函数传入形参的过程
那这个拷贝构造函数不就是对对象中的内置类型成员进行浅拷贝嘛,我们如果不创建这个拷贝构造函数编译器不是也可以进行嘛,那我们构造这个函数的意义是什么呢?
❗注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。所以我们这样理解:有析构函数清理内存时,就必须有相对应的拷贝构造函数!
四、赋值运算符重载
4.1 运算符重载
在我们C++使用内置类型时可以使用运算符进行数据之间的基本运算,如:“+ - * / & < > ==”
但是对于自定义类型数据就使用不了这些运算符了
📋例如:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023,2,8);
Date d2(2023,2,8);
d1 == d2;//用==来比较这两个自定义类型是不行的
return 0;
}
如果我们想要比较这两个Date类型的日期是否相等,难道只能之间写一个函数来调用判断了吗?
在C++中我们还可以使用==这些运算符来进行自定义类型之间的计算,不过首先要自己对运算符重载
📌C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。 函数名字为:关键字operator。 函数原型:返回值类型 operator操作符(参数列表)
📋现在我们来写一个运算符重载来试试看:
bool operator==(const Date& x, const Date& y)
{
return x._day == y._day && x._month == y._month && x._year == y._year;
}
int main()
{
Date d1(2023, 1, 2);
Date d2(2023, 1, 2);
cout << (d1 == d2) << endl;//这里的()是为了让程序先执行==的运算,<<的优先级比==高
return 0;
}
我们可以看到对==这个运算符进行了新的定义,如果==两边的参数为Date类型的话,系统会自动调用该函数进行判断,下面是运行效果:
我们可以看到通过运算符重载,系统很好的对d1和d2这两个对象进行了==运算的处理
那operator==不也是函数吗,为什么不调用它呢?
📋例如:
那这样子做和自己创建一个普通函数来比较有什么区别呢?运算符重载本来就是增强程序的可读性嘛,这样写就失去了其初衷。
当然我们可以将运算符重载函数写进类里面,方便其调用:
不过奇怪,为什么会报错呢?
这是因为我们写进类的函数都默认有一个this指针呀,这样子我们只需要一个参数即可:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
bool operator==( const Date& d)
{
return _day == d._day && _month == d._month && _year == d._year;
}
private:
int _year;
int _month;
int _day;
};
❗不过使用运算符要注意以下几点:
不能通过连接其他符号来创建新的操作符:比如operator@
重载操作符必须有一个类型参数用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐 藏的this
.* :: sizeof ?: . 以上5个运算符不能重载。这个经常在笔试选择题中出现。
4.2 赋值(=)运算符重载
我们在实现运算符重载时难免会对=进行重载
📋现在我们针对上面的Date类,对=进行一下重载:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
void operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
这样子可以吗?
可以,那这种情况呢?
我们发现连续赋值就行不通了,这是为什么呢?
💡这是因为平时对内置类型进行赋值时,每一次将后值赋予前值时都会产生一个相对应的返回值,而在我们对赋值运算符进重载时却没有返回值!
现在我们优化一下:
Date& operator=(const Date& d)//这里使用传引用返回是因为函数结束后this指针所指向的空间还存在,此时使用传引用返回会减少临时空间的开辟
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
成功了!
4.3 流插入(<<)运算符重载
📋我们现在来看到这个类:
class date
{
public:
date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
如果我们想对date这个自定义类型进行打印,我们是否可以使用cout<<呢?这是不可以的,cout<<插入流中并不能对自定义类型进行输出,所以在这里我们需要对流插入运算符(<<)进行重载:
class date
{
public:
date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
//在date类中定义了一个流插入运算符重载函数
void operator<<(ostream& out)//ostream是一个流类型,在这里我们只需要会用即可
{
out << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
我们来实验一下:
实验很成功,不过这个d1<<cout怎么怪怪的?正常来说不应该是cout<<d1才对啊
可是在类中的运算符重载是按参数的默认顺序来使用的,第一个参数默认是*this,难道就没有办法了吗?
我们可以将这个函数作为全局函数不就没有*this这个默认参数了吗?
说干就干:
不过现在出现了一个新的问题:在全局的函数怎么访问类的私有成员呢?
在这里我们可以用到友元函数:friend
📌具体使用方法为:friend 已定义的函数 (在类中定义)
这样子被friend关键字所修饰的函数就可以访问其类的私有成员了:
class date
{
friend void operator<<(ostream& out, const date& d);//使用友元函数
public:
date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void operator<<(ostream& out, const date& d)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
}
这样运行起来就没问题了:
但是我们还面临着另一个问题:
我们并不能连续使用<<来进行多个自定义类型的输出,这是因为在每次调用完<<运算符重载后并没有返回值,在遇到另一个<<时只有一个参数并不能再进行调用运算符重载
对于这种情况,我们可以给该类型一个ostraem&类型的返回值,因为传入的cout参数在函数结束后还是存在的:
class date
{
friend ostream& operator<<(ostream& out, const date& d);
public:
date(int year = 0, int month = 0, int day = 0)
{
_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;
}
结果很令人满意:
4.4 流输入(>>)运算符重载
有了上面流插入运算符重载的经验,我们现在很容易实现一个自定义类型的流输入运算符的重载:
class date
{
//友元函数
friend ostream& operator<<(ostream& out, const date& d);
friend istream& operator>>(istream& in, date& d);
public:
date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, const date& d)//流插入(ostream)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
istream& operator>>(istream& in, date& d)//流输入(istream)
{
in >> d._year >> d._month >> d._day;
return in;
}
运行效果良好
五、const成员
我们在类和对象中会遇到这样的问题:
我们创建了一个被const修饰的对象,结果在调用函数打印时编译器却说类型不兼容
仔细一想,我们传入的是一个不可被修改的常量,但是Print函数第一个默认形参是没有被const修饰的*this指针,这样子确实会造成类型的不兼容。那要怎么办才能避免这种问题呢?
💡我们可以在类函数的最后加上一个const修饰一下:
class date
{
public:
date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
void Print()const//在函数末尾使用const修饰
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
const date d1;
d1.Print();
return 0;
}
编译成功了!
📌像这样将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
本期博客到这结束~
咱们明天再见👊