文章目录
- 前言
- 认识类和对象
- 使用 struct 定义类
- class 定义类
- 类的声明和定义分离
- 类大小的计算
- this指针
- this指针的常见的面试题
- 构造函数与构析函数
- 构造函数
- 初始化列表
- 构析函数
- 默认生成的构造函数和构析函数
- 拷贝构造函数
- 默认类型转化与 explicit 关键字
- static 成员变量
- 运算符重载
- 友元
- 友元函数
- 友元类
- 内部类
- 匿名对象
大家好,我是纪宁。
类和对象是C++编程中非常重要的概念,在生活中我们要经常使用类和对象来解决问题,掌握类和对象的使用方法对于编写高质量、高效率的C++代码至关重要。这篇文章将讲解类和对象相关的内容。
前言
首先C++是兼容C的,所以 C 的语法在 C++中 99%都可以使用,但根据一些C++大佬的说法是:“学了C++之后就不想再使用C了”,到底是怎么回事呢?
在C中,如果我们要定义一个可以描述事物的集合是,通常会定义一个结构体的复杂类型,里面可以存放多种描述这个事物特征的成员变量。但是,如果我们想表示这个事物可以具体进行那些操作的时候,还要另外设置函数,并将这个结构体类型作为参数传递进去,对这个函数进行封装,形成一个个‘方法’(成员函数)。在需要使用这个事物的某些操作的时候,只需要调用这些方法即可。
但是!众所周知,C++是对C的改进和提升,C++中定义了一种新的编程方式:面向对象编程的类
,在一个类中,可以将数据与操作数据的方法封装在一起,描述一类对象的属性和行为,形成一个或多个对象,从而实现程序的可重用性、可扩展性。
认识类和对象
使用 struct 定义类
在C中,struct 用来定义结构体,而在C++中,struct 也可以用来定义类
定义一个汽车类 Car
struct Car
{
double price;
int year;
};
这个类中包含了一辆汽车的基本信息,现在可以尝试通过这个汽车类定义一个对象 car,并打印它的基本信息。
int main()
{
Car car;
car.year = 1997;
car.price = 134567.896;
cout << car.year << endl;
cout << car.price << endl;
return 0;
}
但是,一个汽车肯定不只有属性,还有各种功能,如果我们想调用这些功能应该如何做呢?答案是向类中添加成员函数,这种函数被称为类的成员函数或者类的方法。我们可以尝试在汽车这个类添加一些汽车的方法或功能并调用这些方法。
添加汽车启动、行驶、熄火
struct Car
{
double price;
int year;
void Startup()
{
cout << "汽车,启动!!!" << endl;
}
void Drive()
{
cout << "汽车行走了50km,用了半小时" << endl;
}
void Brake()
{
cout << "汽车在你一直踩刹车中停止!" << endl;
}
};
int main()
{
Car car;
car.Startup();
car.Drive();
car.Brake();
return 0;
}
运行结果:
现在,我们可以继续在Car这个类中添加其他的方法,但在此之前,要先补充一下基础知识。
class 定义类
要说到再C++中使用类,大家应该再熟悉不过的就是 class 了,它在定义类的基础语法上和 struct 是相同的。
class Car
{
double price;
int year;
void Startup()
{
cout << "汽车,启动!!!" << endl;
}
void Drive()
{
cout << "汽车行走了50km,用了半小时" << endl;
}
void Brake()
{
cout << "汽车在你一直踩刹车中停止!" << endl;
}
};
但是在调用的时候,编译器却报错了。
原因是在 class 定义的类中,里面的成员默认是私有的,只有在类内部才可以使用;而struct 定义的类里面的成员默认是公开的,在类内外都可以使用。所以在 class 定义的类中要加入访问限定符
:
- public 公有
- protected 保护
- private 私有
那么,经过访问限定符修饰后的类就变成了:
class Car
{
public:
void Startup()
{
cout << "汽车,启动!!!" << endl;
}
void Drive()
{
cout << "汽车行走了50km,用了半小时" << endl;
}
void Brake()
{
cout << "汽车在你一直踩刹车中停止!" << endl;
}
private:
double price;
int year;
};
一般会将事物的基本属性放在私有里面,然后将事物的一些方法和功能函数放在公有里面。
类的声明和定义分离
如果要将类的声明放在.h文件中,可以使用如下的方法
class Car
{
public:
void Startup();
void Drive();
void Brake();
bool Promote()
{
return 0;
}
private:
double price;
int year;
};
较长的函数,可以将它的声明放在.h文件中,较短的函数直接将函数定义放在类里。
声明后的.cpp文件中只需要写类的成员函数的定义部分就可以了。但是要在函数名前加 类名::
,如下
void Car::Startup()
{
cout << "汽车,启动!!!" << endl;
}
void Car::Drive()
{
cout << "汽车行走了50km,用了半小时" << endl;
}
void Car::Brake()
{
cout << "汽车在你一直踩刹车中停止!" << endl;
}
这样,就可以在 .cpp文件中正常调用类的成员函数了。
类大小的计算
关于类的大小,计算规则有点类似于结构体的内存对齐,这里需要注意的一点是:类大小只计算成员变量大小(需要考虑内存对齐),而成员函数其实是储存在公共代码区
的。
变量的声明和定义的区别:是否开了空间
一个类中,如果没有成员变量,它定义的对象大小并不是0而是1。这种类在定义对象的时候会开一个字节的空间,但这个字节不存储有效数据,作用是 标识定义的对象存在过。
this指针
如图,当d1调用成员函数 Print 的时候,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
其实,C++编译器在每个成员函数里定义了一个指针 this 作为成员函数的第一个形参,同时将调用此函数的对象地址作为参数传过去,默认在成员函数中访问成员变量就是对这个 this 指针指向对象里的成员变量进行访问。
this指针不能显式的写出this 相关的实参和形参,但在类中可以显式的使用 this 指针。如图:
this指针的常见的面试题
this 指针存在哪里?
前面已经学习过类的大小是由类中成员变量决定的,所以this指针肯定不是存储在类中。this指针是成员函数中定义的形参,与局部变量一样,一般存在函数栈帧上。但不同的编译器会对存储位置稍有优化,因为this指针会经常使用到,所以vs将this指针存储在 eax 这个寄存器中方便快速调用。
构造函数与构析函数
曾经,我们在定义栈,列表等这种复杂结构的时候,每次定义对应变量或对象后都要及时进行初始化,在即将要出作用域的时候要进行销毁。但粗心的我们经常会忘记调用初始化函数和销毁函数,但是有了C++后就可以利用构造函数和析构函数解决这一问题了。
构造函数
构造函数的定义写在类里面,用于初始化这个类创建的对象,构造函数有如下特征:
- 函数名与类名称相同
- 无返回值(void 都不用写)
- 对象实例化时编译器自动调用对应的构造函数
- 支持重载
如图,对日期类定义构造函数(全缺省非常合适)
声明
定义
那么,定义日期类的实例化对象的时候既可以使用如下方法:
Date d1;
Date d2(2023, 10, 24);
Date d3(2023);
当不传递参数的时候(也不需要带括号),默认实例化对象成员变量全部初始化为1;当正常传参的时候,就将实例化对象初始化为需要的值;当参数‘不足’时,就利用函数缺省的规则,优先对前面的形参进行传值。
初始化列表
初始化列表格式:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
相当于如下正常的构造函数
那为什么要使用初始化列表呢?
有的成员变量只能初始化一次,如const 限制的变量
、引用定义的成员变量
及无默认构造函数的自定义类型成员
等,所以只能使用初始化列表来初始化。
但需要注意一点:引用定义的成员变量在使用初始化列表进行初始化的时候,不能使用形参来给引用赋值,否则在构造函数结束后,引用的成员变量就会变为随机值。
初始化列表的顺序
成员变量在类中声明次序
就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。所以为了避免出现歧义,建议声明顺序和初始化列表顺序保持一致。
初始化列表和函数体内初始化可以配合使用
如下面的例子:
初始化列表的时候会将所有成员全部定义
,如果在初始化列表的时候没有显示写某些成员,编译器也会定义,只不过内置类型会被初始化为随机值,而自定义类型则会去调用它自己的默认构造函数。定义时就要初始化的变量必须显示写出来。
构析函数
定义方法:在类名前面加~,无参,无返回值。写在类中,在对象生命周期结束时自动调用,用来释放动态内存申请的资源。
如栈的构析函数:
~Stack()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
默认生成的构造函数和构析函数
不写构造函数和构析函数的情况下,编译器会生成默认的构造函数和析构函数。系统默认生成的,对此对象内置类型的成员变量不做处理,自定义类型的成员变量自动调用它的构造函数和析构函数(其实就是一种辅助,将写好的,能用的调用过来)。如果写了对应的构造函数和析构函数,编译器会优先调用。
对于内置类型不自动初始化的漏洞,C++11在原有基础上打了一个补丁,即:允许在声明成员变量的时候给初始值。
这样,即使内置类型没有在构造函数里自动初始化,也不影响它有初始值。
拷贝构造函数
在C语言中,有时会使用浅拷贝,如在结构体传参的时候不传指针,直接将结构体传过去,这种直接传值,形参进行拷贝的情况叫浅拷贝(也叫值拷贝),即将变量的值原封不动的复制一份。
但是,这种浅拷贝在C++中是有风险的,当在C++中拷贝对象的时候,如果实例化对象中申请了动态的空间,按值拷贝的话会导致指针指向同一块空间,而C++中有析构函数,在对象声生命周期结束时会自动调用,则这块空间会被释放两次,第二次释放的时候就产生的野指针和越界访问的问题。
编译器自己其实也生成了默认的拷贝构造函数,这个默认生成的拷贝构造函数是按值拷贝的,如果未涉及到内存申请,那么这样的值拷贝是可行的,但如果涉及到了内存申请,那么值拷贝就对导致同一块空间被释放两次,最终导致程序崩溃。
拷贝构造是函数构造的一种重载,那么它的形式和函数构造的差不多的,唯一需要注意的是,形参必须用引用来定义
。如果不传引用,自定义类型传参的时候就会引发无穷递归
:拷贝的时候要调用拷贝构造函数,拷贝构造函数传参的时候又要进行拷贝…无穷尽了。
拷贝构造函数通常为了规避写错,会在形参定义前加 const
如下面的代码:
Date::Date(const Date& dd)
{
_year = dd._year;
_month = dd._month;
_day = dd._day;
}
Stack(Stack&st)
{
_a = (int*)malloc(sizeof(int) * st.capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_a, st._a, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
就用上面的日期类来举例子看拷贝构造的内部原理:
将d2拷贝给d3,调用拷贝构造函数,如上图,dd是d2的别名,而拷贝构造函数内部的this指针是指向d3的,即将对象d2成员变量的值依次赋值给d3。
默认类型转化与 explicit 关键字
构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值的构造函数,还具有类型转换的作用。
先解释一个点:默认不同类型再进行赋值的时候,都要进行隐式类型转化(前提是能转化),转化的过程是先创建一个临时变量来存储转化后的,然后再进行同类型变量之间的赋值。
内置类型对象也可以转化为自定义类型的对象,能支持这个转换,是有自定义对象有带缺省值的构造函数(自己定义的构造函数)。
用内置类型直接给对象赋值/初始化的时候,实际编译器背后会用这个内置类型通过带缺省值默认构造函数构造一个无名对象,最后用无名对象给d1对象进行赋值。
但是说实话这种代码的可读性不是非常好,为了防止这种情况的出现,我们可以使用explicit
关键字来对构造函数进行修饰,explicit修饰过的构造函数,禁止类型转换
。
再次进行上面情况的赋值时,编译器就会报错:
static 成员变量
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用 static 修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外
进行初始化。
静态成员变量
静态成员函数
使用样例
类的静态成员的特性
- 静态成员为所有类对象所
共享
,不属于某个具体的对象,存放在静态区
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
- 类静态成员即可用
类名::静态成员
或者对象.静态成员
来访问 - 静态成员函数
没有
隐藏的this指针
,不能访问任何非静态成员 - 静态成员也是类的成员,受public、protected、private 访问限定符的限制
面试题目 static
的应用举例
面试题:实现一个类,计算程序中创建出了多少个类对象。
class A
{
public:
A()
{
++_scount;
}
A(const A& t)
{
++_scount;
}
~A()
{
--_scount;
}
static int GetACount()
{
return _scount;
}
private:
static int _scount;
};
int A::_scount = 0;
void TestA()
{
cout << A::GetACount() << endl;
A a1, a2;
A a3(a1);
cout << A::GetACount() << endl;
}
int main()
{
TestA();
}
程序解释:在类的私有部分定义一个静态成员变量,每个类都共享这个成员变量。在类外对静态成员变量定义并初始化为0,每调用一次构造函数和构造拷贝函数说明就多创建了一个对象,就对静态成员变量的值加1,等到最后程序即将结束的时候再利用 类:: 成员 来访问成员函数得到它的成员变量的值。这个成员变量的值就是程序中创建对象的个数。
运算符重载
内置类型使用运算符的时候,编译器会将它转化为指令,自定义类型却不可以直接使用运算符。C++中的运算符重载可以让自定义类型可以直接使用运算符。
使用方法:在使用的时候还是正常使用运算符,但需要在类中使用 operator
关键字提前定义好对应的运算符重载函数。
如下面的日期类的+=运算符重载(定义与声明分离)
int Date::GetMonth(int year,int month)
{
int Month[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;
}
else
{
return Month[month];
}
}
Date& Date::operator+=(int x)
{
_day+= x;
while (_day > GetMonth(_year, _month))
{
_day -= GetMonth(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
日期类的 >运算符重载代码(<直接对这个进行逻辑取反)
bool Date::operator>(const Date&d)
{
if (_year > d._year)
{
return true;
}
if (_year == d._year && _month > d._month)
{
return true;
}
if (_year == d._year && _month > d._month && _day > d._day)
{
return true;
}
return false;
}
日期类的==运算符重载代码
bool Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
日期类-=运算符重载
Date& Date::operator-= (int x)
{
if (x < 0)
{
*this += -x;
}
else
{
_day -= x;
while (_day <= 0)
{
_month--;
if (_month == 0)
{
_year--;
_month = 12;
}
_day += GetMonth(_year, _month);
}
}
return *this;
}
日期类-运算符重载(两个日期相减)
int Date::operator-(const Date& d)
{
int flag = 1;
Date max = *this;
Date min = d;
int count = 0;
if (max < min)
{
max = d;
min = *this;
flag = -1;
}
while (min != max)
{
count++;
min++;
}
return count * flag;
}
通俗的来讲,就是对 this 指针指向的对象的成员进行操作,使其在某种意义上根据使用者的意愿达到使用运算符进行计算的程度。
需要注意的点:运算符需要什么样的返回类型,运算符重载时就要使用什么样的返回值;比较函数需要放在类里。
是否使用引用返回?
出了作用域还未销毁的,就可以使用引用返回,如this指针(在+=,-=等的重载直接对this指针操作时可以使用引用返回);当在+、- 等需要先拷贝this指针内容的情况下,不能用引用返回,因为这些拷贝的对象出作用域后会被销毁。
友元
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
代码耦合度
代码耦合度是指代码中的模块与模块之间的相互依赖程度。当模块之间相互依赖度高时,就会导致代码的耦合度高,这会增加代码的复杂度,阻碍代码的维护和扩展。
友元分为:友元函数和友元类
友元函数
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类
,但需要在类的内部声明,声明时需要加friend关键字。
如要对 << 和 >> 进行运算符重载
在类里进行友元声明
此函数在类外也可访问这个类的私有
代码详解
class Date
{
friend ostream& operator<<(ostream& _cout, const Date& d);
friend istream& operator>>(istream& _cin, Date& d);
public:
Date(int year = 1, int month = 1, int day = 1)//构造函数
:_year(year)
, _month(month)
, _day(day)
, def(_year)
, zop(month)
{}
~Date(){
_year = _month = _day = 1;
}
Date(const Date& d)
:zop(d.zop)
, def(d.def){
cout << d.def << "d2.def" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print(){
cout << _year << '/' << _month << '/' << _day << endl;
cout << _year << '/' << _month << '/' << _day << endl;
cout << def << '/' << zop << endl;
}
private:
int _year;
int _month;
int _day;
int& def;
const int zop;
};
ostream& operator<<(ostream& _cout,const Date&d){
cout << d._year << "/" << d._month << "/" << d._day << endl;
return _cout;
}
istream& operator>>(istream& _cin, Date& d){
cin >> d._year;
cin >> d._month;
cin >> d._day;
return _cin;
}
int main()
{
Date d1;
cout << d1;
Date d2(2023, 11, 1);
cout << d2;
Date d3;
cin >> d3;
cout << d3;
return 0;
}
友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
友元类有以下特性:
- 友元关系是
单向的
,不具有交换性。
比如在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。
- 友元关系
不能传递
如果C是B的友元, B是A的友元,则不能说明C时A的友元。 - 友元关系
不能继承
内部类
内部类是指在类内定义的类,也被称为嵌套类。内部类可以访问外部类的所有成员,包括私有成员。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。
注意:内部类就是外部类的友元类
,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
内部类可以帮助我们更好的封装类的实现细节,同时也可以在外部类的成员函数中方便地使用内部类,从而减少代码的复杂度。
注意点
- 内部类可以定义在外部类的public、protected、private都是可以的。
- 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
- sizeof(外部类)=外部类,和内部类没有任何关系。
内部类举例
class Dog
{
private:
static int k;
int h;
public:
//定义Goddess类,是Dog的内部类
class Goddess // Goddess天生就是Dog的友元
{
public:
void foo(const Dog& a)
{
cout << k << endl;//OK
cout << a.h << endl;//OK
}
};
};
int Dog::k = 1;
int main()
{
Dog::Goddess b;
b.foo(Dog());
return 0;
}
代码解读:Goddess就是一个普通类,只是受Dog的类域和访问限定符的限制。Goddess天生是Dog的友元类,可以访问Dog的私有;但Gog却不是Goddess的友元类,Dog不能访问Goddess的任何私有!
匿名对象
在C++中,我们可以创建匿名对象。所谓匿名对象是指创建一个没有名字的临时对象。这种对象可以直接在函数调用或表达式中使用,而不需要定义变量名。
语法:类名()
如图,Date 是一个类
它的声明周期只有这一行,有时候需要对象来访问成员的时候用一个匿名对象就很好用。