面向过程和面向对象
顾名思义,面向过程关注的完成一个事件的整个过程,面向对象关注的是事件的参与者及其之间的交互。
C语言是面向过程的,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++是面向对象的,将一件事拆分成不同对象,靠不同对象的交互完成。
在面向对象编程的世界里,一切皆为对象,对象把数据与程序封装起来,提供对外访问的能力,提高了软件的重用性、灵活性和扩展性。
类的引入和定义
C++中的结构体
在C语言中,结构体只能定义变量;在C++中,结构体不仅能定义变量,而且定义函数,这是因为在C++中,结构体在一定程度上被升级为了类。
类的定义
在C++中,类的定义用class
关键字替代struct
来完成。
class className
{
// 类体:由成员函数和成员变量组成
};
类体中的内容称为类的成员,类中的变量称为类的属性或成员变量;类中的函数被称为类的方法或成员函数。类是对对象的描述,类似于一个模型,本身不占用内存空间。
类的定义方式主要是关于成员函数的,类有两种定义方式:
- 声明和定义全部放在类体中。在类体中定义的成员函数默认被inline修饰,可能被当做内联函数处理;
- 声明和和定义分离。此时注意要在成员函数之前用类名和域作用访问限定符进行标识。
class Date
{
private:
char c_;
int i_;
public:
//直接在类体中定义
void getChar(char c)
{
c_ = c;
}
void getInt(int i);
};
//在类体之外定义
void Date::getInt(int i)
{
i_ = i;
}
在定义类的成员变量时,为了将其与类成员函数的参数相区分,往往在成员变量名的前面或后面加下斜杠以示区分。
class GetNum
{
private:
int num_;
public:
void getNum(int num)
{
num_ = num;
}
};
类的封装
类的访问限定符
C++类中有三个访问限定符,分别是:public
、private
、protected
。类的访问限定符决定了类成员在类外能否被访问。
- public 修饰的成员在类外可以被访问,protected 和 private 修饰的成员在类外不能被访问。
- 访问权限的有效范围从该访问限定符开始,到下一个访问限定符或者 '
}
'结束。 - class 定义的类的成员访问权限默认为 private,struct 定义的类的成员访问权限默认是 public。
封装
封装、继承和多态是面向对象的三大特征。封装即是将对象的数据和操作数据的函数进行有机结合,隐藏对象的属性和实现细节,通过对外公开的接口和其他对象进行交互。
封装本质是一种管理,可以让用户更方便、安全地使用类,而不必关心其中细节。C++通过访问限定符选择性地将数据和接口提供对外使用。
类的作用域
每个类都定义了一个新的作用域,称为类域,类的所有成员都在类域中。在类外定义成员函数时需要用域作用访问限定符进行标识。
类的实例化
对对象的定义即为类的实例化。类是对对象进行描述的,决定了对象的特性和行为,本身不占用空间,一个类可以实例化出多个对象,对象占用实际的内存空间以存储成员变量。
//对类的定义
class GetNum
{
private:
int num_;
public:
void getNum(int num)
{
num_ = num;
}
void printNum(void)
{
cout << num_ << endl;
}
};
int main()
{
GetNum getN; //对象的定义(类的实例化)
return 0;
}
类对象模型
类对象模型描述了对象的存储方式。当试图计算一个对象的大小时,结合结构体的内存对齐规则,会发现结果只体现了成员变量的计算结果,而未将成员函数考虑在内。
class GetNum
{
private:
char c_;
int num_;
public:
void getNum(char c, int num)
{
c_ = c;
num_ = num;
}
void printNum(void)
{
cout << num_ << endl;
}
};
int main()
{
cout << sizeof(GetNum) << endl; // 8
return 0;
}
事实上,为了节省空间,成员函数并不存储在对象中,而是存储在一块公共的代码段,当类的不同对象调用函数名相同的成员函数时,调用的其实是同一个函数。
虽然对象的大小计算不考虑成员函数,但是空类的对象或者不含成员变量的类的对象的大小并不是 0。没有成员变量的类的对象大小为 1 byte 以表示占位,不存储有序数据。
this指针
this指针的引出
上文说道,当类的不同对象调用函数名相同的成员函数时,调用的其实是同一个函数,比如类 Class 的对象 d1 和 d2 调用成员函数 getMessage()
时,调用的其实是同一个成员函数,那么函数getMessage()
如何区分对象 d1 和 d2 呢?
class GetNum
{
private:
char c_;
int num_;
public:
void getNum(char c, int num)
{
c_ = c;
num_ = num;
}
void printNum(void)
{
cout << c_ << ' ' << num_ << endl;
}
};
int main()
{
GetNum getM;
GetNum getN;
getM.getNum('b', 13);
getN.getNum('a', 10);
return 0;
}
事实上,C++编译器给每个非静态成员函数隐式传递了一个指针参数,该指针参数指向调用该成员函数的对象,在函数体中对所有成员变量的操作,都是通过该指针进行访问的,这个指针便是 this 指针。上述的过程对用户来说是透明的,编译器自动完成。
上面代码的函数调用和执行过程可视为:
//调用
getM.getNum(&getM, 'b', 13);
getN.getNum(&getN, 'a', 10);
//执行
void getNum(GetNum* const this, char c, int num)
{
this->c_ = c;
this->num_ = num;
}
void printNum(GetNum* const this)
{
cout << this->c_ << ' ' << this->num_ << endl;
}
this指针的特性
- this 指针不能在形参和实参中显式传递,但是可以在成员函数内部显式使用。
- this 指针是被
const
修饰的,无法被修改。 - this指针作为成员函数的一个形参,不存储在对象中,在函数被调用时作为函数栈帧的一部分,存储在栈中。
- this指针是成员函数被调用时的第一个隐藏的指针形参,一般通过寄存器自动传递。
构造函数
概念
在C语言中定义链表和其他数据结构时,往往要额外定义初始化函数和清理函数,前者保证该容器能被正常使用,后者避免了内存泄漏问题。像这样总是需要手动初始化和销毁,有时难免会造成遗忘和繁琐的问题,C++中的构造函数和析构函数可以帮助解决这个问题。
构造函数是类的六个默认成员函数之一。默认成员函数即如果不显式定义,编译器会自动生成的成员函数,这些函数可以分为三大类,共六个。其中构造函数完成的是对象的初始化工作。
特性
- 构造函数是一个特殊的成员函数,任务是初始化对象。
- 构造函数的函数名与类名相同,且没有返回值,并不需要用
void
进行标识。 - 构造函数是对象实例化时编译器自动调用的。
- 构造函数可以重载。
- 构造函数的调用:对象名 + 参数列表(若有)。
- 没有显式定义构造函数时,编译器会自动生成一个无参构造函数,标准规定,该无参构造函数对内置数据类型不做处理(其实并不妥当),对自定义数据类型调用其默认构造函数。如果显式定义,则编译器不会自动生成该无参构造函数。
- C++11支持在声明类的内置数据类型时给其一缺省值,用以给默认构造函数使用,将这些内置数据类型初始化为其缺省值。
class Date
{
public:
// 1.无参构造函数
Date()
{}
// 2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
}
private:
int _year;
int _month;
int _day;
};
默认构造函数
默认构造函数为不传参就能调用的构造函数,包括:定义的无参构造函数、定义的全缺省构造函数、编译器自动生成的无参构造函数。定义的无参构造函数和全缺省构造函数在调用时会存在调用歧义,所以这两者不能同时存在;而一旦定义其中某一构造函数,编译器就不会生成无参构造函数,所以编译器生成的无参构造函数与显式定义的构造函数不会同时存在。即:默认构造函数只能存在一个。
析构函数
概念
析构函数与构造函数功能相反,完成的是对对象资源的清理。析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
特性
- 析构函数名为在类名前加上字符 ~ 。
- 析构函数无参数无返回值类型。
- 一个类只能有一个析构函数;若未显式定义,系统会自动生成默认的析构函数。
- 析构函数不能重载。
- 对象的生命周期结束时,C++编译器自动调用析构函数。
- 编译器自动生成的析构函数会对对象的自定义类型调用其析构函数。
- 对象不申请资源时,可以使用编译器自动生成的析构函数;申请资源时,一定要定义析构函数,否则会造成内存泄漏。
拷贝构造函数
概念
在开发中,往往需要创建一个与已存在对象一模一样的新对象,拷贝构造函数由此而生。
拷贝构造函数是构造函数的一种重载形式,其只有单个形参,该形参是本类类型对象的引用(或指针,不推荐),一般将该形参用const
修饰。在用已存在的对象创建新对象时,编译器自动调用拷贝构造函数。
class Date
{
private:
int _year;
int _month;
int _day;
public:
Date(int year = 1, int month = 1, int day = 1);
Date(const Date& d) //定义拷贝构造函数
{
this->_year = d._year;
this->_month = d._month;
this->_day = d._day;
}
void ShowDate(void);
};
int main()
{
Date d1(1949, 10, 1);
d1.ShowDate();
Date d2(d1); //该处调用拷贝构造函数
d2.ShowDate();
Date d3 = d1; //该处调用拷贝构造函数
d3.ShowDate();
return 0;
}
特性
- 拷贝构造函数有且仅有一个参数,该参数必须是同类对象的引用,如果采用直接传值传参,则会引发无穷递进调用,编译器报错。这是因为考虑到深浅拷贝问题,C++规定,在拷贝传参时,内置类型数据会直接拷贝,而自定义类型会调用其拷贝构造函数。
- 不显式定义时,编译器会生成一个默认的拷贝构造函数,该拷贝构造函数对内置类型数据进行浅拷贝,对自定义数据类型调用其拷贝构造函数。
- 默认生成的拷贝构造函数对内置类型的浅拷贝(例如指向某一块动态内存空间的指针变量)可能会造成一些问题,其一,对一块空间的修改会牵涉另一块空间;其二,会造成二次析构,程序崩溃。如果类中涉及资源申请,则一定要写拷贝构造函数。
- 调用拷贝构造函数的情景一般为:使用已存在对象创建新对象;函数参数为类类型对象;函数返回值为类类型对象。
运算符重载
概念
在实际生活中有时需要对一些特殊的事物进行比较,这些事物在计算机中不能像整型等数字型数据之间直接进行比较,而需要根据该事物的特性自行定义比较方法。考虑到这一点,为了提高程序的可读性,C++引入了运算符重载。运算符重载是具有特殊函数名的函数(以operator
关键字进行标识),同时具有返回值和参数列表。进行运算符重载的定义后,直接使用被重载的运算符即可调用对应的函数。
bool operator>(const Date& d) //对>的运算符重载
{
if (this->_year > d._year) {
return true;
}
if (this->_year == d._year && this->_month > d._month) {
return true;
}
if (this->_year == d._year && this->_month == d._month && this->_day > d._day) {
return true;
}
return false;
}
特性
.*
::
sizeof
?:
.
以上五个操作符不能被重载。- 不能通过
operator
连接其他符号来创造新的运算符。(例如: operator@)。 - 运算符重载的参数(包含this指针)数量与运算符的操作数数量相等;运算符重载的参数必须至少有一个类对象(包含this指针)。
- 是否对运算符进行重载需要取决于该运算符对类对象是否有意义。
- 运算符重载的使用是一个函数调用。
赋值运算符重载
赋值运算符重载是类的 6 个默认成员函数之一,是对赋值运算符的运算符重载,主要针对同类对象之间的赋值。
Date& operator=(const Date& d) //对赋值运算符的重载
{
this->_year = d._year;
this->_month = d._month;
this->_day = d._day;
return *this;
}
- 为了支持连续赋值,要将左值对象(*this)设为返回值,同时为了提高效率,赋值运算符的参数和返回值尽量设置为类对象的引用。
- 没有显式定义时,编译器自动生成一个默认的赋值运算符重载函数,该赋值运算符重载对内置类型进行浅拷贝,对自定义类型调用其赋值运算符重载。
- 赋值运算符重载,包括其他默认成员函数,不能全局声明和定义,否则会造成调用歧义。
前置单目运算符重载和后置单目运算符重载
前置单目运算符(前置++、前置--)和后置单目运算符的区别在于返回值不同,后置单目运算符的实现需要先记录++之前的对象状态,并返回运算之前的对象。C++规定:后置运算符的参数列表必须有一个 int
形参进行占位,以区分前置运算和后置运算,避免调用歧义。
// 前置++
Date& Date::operator++()
{
*this += 1;
return *this;
}
// 后置++
Date Date::operator++(int)
{
Date tmp = *this;
*this += 1;
return tmp;
}
流插入运算符重载
流插入运算符 << 应用于标准输出流,可以理解为其将数据插入到 ostream
类的 cout
对象,以实现数据的打印。因为 << 具有针对所有内置数据类型的重载,所以其适用于多种数据类型,在插入时自动调用合适的重载函数。<< 有两个操作对象,一个是cout对象,另一个是输出对象,当进行连续输出时,数据从左到右依次插入到cout中。
在实际使用时,会遇到打印自定义对象的需求,此时需要自定义流插入运算符的重载。由于运算符左边的操作对象(运算符重载函数的参数列表中左边的参数)是调用运算符的对象,为了避免将重载定义在类域中时,this指针隐式传递的影响,可以将流插入重载定义在全局,并将cout对象作为第一个参数可以使对象的输出更加直观。为了解决此时类私有成员的访问问题,可以将该函数声明为类的友元函数。
考虑到连续输出的情况,将返回值设置为cout对象。
//声明、定义在类中
//此时Date对象的指针总是操作符的第一个参数
//ostream& operator<<(ostream& cout)
//{
// cout << _year << "年" << _month << "月" << _day << "日";
// return cout;
//}
//声明、定义在全局
ostream& operator<<(ostream& cout, const Date& d)
{
cout << d._year << "年" << d._month << "月" << d._day << "日";
return cout;
}
int main()
{
Date d1(2023, 5, 6);
cout << d1; //operator<<(cout, d)
//d1 << cout; // d1.operator<<(&d1, cout)
return 0;
}
流提取运算符重载
>>流提取运算符有两个操作对象,一个为istream
类的cin
对象,另一个为待被输入对象。>> 同样有针对所有内置类型的重载。连续输入时,数据从cin中从左向右依次提取,并赋给目标对象。
与流插入运算符类似,当对自定义类型进行输入时,需要对流提取运算符进行自定义的重载。这里依然将重载定义在全局中。为了支持连续赋值,将返回值设置为cin对象。
istream& operator>>(istream& cin, Date& d)
{
cin >> d._year >> d._month >> d._day;
return cin;
}
取地址操作符重载
取地址操作符重载主要是对普通对象取地址和const对象取地址操作符重载。取地址操作符重载属于类的默认成员函数,这两个操作符一般不需要用户显式重载,使用编译器自动生成的即可,除非有特殊需求。
//Date类的两个取地址操作符重载
/*………………*/
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
/*………………*/
至此,我们已经了解了所有6个类的默认成员函数,它们的概览如下图:
这些默认成员函数用户不显式定义时,编译器会自动生成,所以不能定义在全局,否则会造成调用歧义。
const对象和const成员函数
const对象即是被const修饰的类对象。const对象在调用成员函数时,传递的this指针指向的内容被const修饰,由于传参时指针权限不能放大,所以要用const指针对成员函数的this指针参数进行限制。
/*………………*/
const Date* operator&() const //const Date* this
{
return this;
}
/*………………*/
const Date d1(1898, 5, 3);
d1.operator&();// d1.operator&(&d1),传递类型为 const Date*
/*………………*/
用const修饰的成员函数称为const成员函数。const修饰成员函数时,修饰的其实是this指针,表示不能对类的任何成员进行修改。