1. 再谈构造函数
构造函数体赋值
在创建对象时,编译器会通过调用构造函数,给对象中的各个成员变量一个合适的初始值:
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;
};
虽然上述构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量
的初始化.
构造函数体中的语句只能将其称为赋初值,而不能称作初始化。
因为初始化只能初始化一次,而构造函数体内可以多次赋值。
比如下面这段
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、每个成员变量在初始化列表中只能出现一次
因为初始化只能进行一次,所以同一个成员变量在初始化列表中不能多次出现。
2、类中包含以下成员,必须放在初始化列表进行初始化:
- 引用成员变量
- const成员变量
- 自定义类型成员(且该类没有默认构造函数时)
下面这个代码因为_x1,_x2没有在定义时就初始化而报错。
class Test
{
public :
Test()
{
}
private:
const int _x1;
int& _x2;
};
int main()
{
Test t1;
return 0;
}
或者若一个类没有默认构造函数,那么我们在实例化该类对象时就需要传参对其进行初始化,所以实例化没有默认构造函数的类对象时必须使用初始化列表对其进行初始化。
在这里再声明一下,默认构造函数是指不用传参就可以调用的构造函数:
1.我们不写,编译器自动生成的构造函数。
2.无参的构造函数。
3.全缺省的构造函数。
class A //该类没有默认构造函数
{
public:
A(int val) //注:这个不叫默认构造函数(需要传参调用)
{
_val = val;
}
private:
int _val;
};
class B
{
public:
B()
:_a(2021) //必须使用初始化列表对其进行初始化
{}
private:
A _a; //自定义类型成员(该类没有默认构造函数)
};
总结一下:在定义时就必须进行初始化的变量类型,就必须放在初始化列表进行初始化。
3、尽量使用初始化列表初始化
因为初始化列表实际上就是当你实例化一个对象时,该对象的成员变量定义的地方,所以无论你是否使用初始化列表,都会走这么一个过程(成员变量需要定义出来)。
严格来说:
1.对于内置类型,使用初始化列表和在构造函数体内进行初始化实际上是没有差别的.:
2.对于自定义类型,使用初始化列表可以提高代码的效率
class Time
{
public:
Time(int hour = 0)
{
_hour = hour;
}
private:
int _hour;
};
class Test
{
public:
// 使用初始化列表
Test(int hour)
:_t(12)// 调用一次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类的赋值运算符重载函数,效率就降下来了。
所以建议使用初始化列表。
4、成员变量在类中声明的次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后顺序无关
举个例子:
#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。
为了更好的理解这一块的内容,我们来看看这段代码:
class A
{
public:
A(int val)
{
_val = val;
}
private:
int _val; //没有缺省值
};
class B
{
public:
B()
:_a(20)
{}
private:
A _a;
int b = 1; //有缺省值
};
我们能看到A类中有一个内置类型
_val
B类中有一个自定义类型_a
,和一个内置类型b
并且在这两个类的私有域Private中给_val没有赋缺省值但是给b赋缺省值
这一块的缺省值就是在用初始化列表的缺省值
因为每次实例化一个对象时,初始化第一步要走的地方就是去初始化列表找有没有值可赋,如果有就有该值赋值,没有的话就去看有没有缺省值,要是都没有的话就只好赋随机值
所以这一块的代码走出来的结果时:
我们再来看一个:
class A
{
public:
A(int val)
//:_val(30)
{
_val = val;
}
private:
int _val =10;
};
class B
{
public:
B()
:_a(20)
, b(2)
{
b = 5;
}
private:
A _a;
int b = 1;
};
这里B类中的函数体也有给b赋值,所以最终的结果是:
这里建议最好自己手动调试一边,理清楚中间的细节!
同时C++11中也支持这样写:
class A
{
public:
//explicit A(int a1, int a2)
A(int a1, int a2)
:_a1(a1)
,_a2(a2)
{}
private:
int _a1;
int _a2;
};
int main()
{
// C++11
A aa1 = { 1, 2 };
const A& aa2 = { 1, 2 };
return 0;
}
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 = 2024; //支持该操作
d1.Print();
return 0;
}
在上述实例化d1这个对象时用到了隐式类型转换:
在语法上可以这样理解,先构造一个tmp,再拷贝给d1,该代码可以理解如下:
Date tmp(2024); //先构造
Date d1(tmp); //再拷贝构造
下面这幅图可以更好的帮助理解:
这里有一个隐式的类型转换,所以可以直接这样简洁写,编译器会自己优化。
但是,对于单参数的自定义类型来说,Date d1 = 2024这种代码的可读性不是很好,我们若是想禁止单参数构造函数的隐式转换,可以用关键字explicit来修饰构造函数。
2. Static成员
2.1概念
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化
2.2特性
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
#include <iostream>
using namespace std;
class test
{
private:
static int _t;
};
int main()
{
cout << sizeof(test) << endl;
return 0;
}
计算的结果是1,因为静态成员_t
是存储在静态区的,所以在计算这个类的大小时,静态成员并不计入总大小之和。
- 静态成员变量必须在类外定义,定义时不添加static关键字
class Test
{
private:
static int _n;
};
// 静态成员变量的定义初始化
int Test::_n = 0;
注意:这里静态成员变量_n虽然是私有,但是我们在类外突破类域直接对其进行了访问。这是一个特例,不受访问限定符的限制,否则就没办法对静态成员变量进行定义和初始化了。
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
class A
{
public:
static int GetNum()
{
a++;
return n;
}
private:
static int n;
int a = 0;
};
这里的GetNum
函数是静态成员函数,无法访问到a
,所以编译出现错误:
**小贴士:**含有静态成员变量的类,一般含有一个静态成员函数,用于访问静态成员变量。
- 访问静态成员变量的方法
(1).当静态成员变量为公有时,有以下几种访问方式:
class test
{
public:
static int _n;
};
int test::_n = 0; //静态成员变量的初始化
int main()
{
test t;
t._n++; //通过类对象突破类域来访问
test::_n++; //通过类名突破类域来访问
test()._n; //通过匿名对象突破类域来访问
}
(2).当静态成员变量为私有时,有以下几种访问方式:
class test
{
public:
static int GetN()
{
return _n;
}
private:
static int _n;
};
int test::_n = 0; //静态成员变量的初始化
int main()
{
test t;
t.GetN(); //通过类对象突破类域来访问
test::GetN(); //通过类名突破类域来访问
test().GetN; //通过匿名对象突破类域来访问
}
- 静态成员和类的普通成员一样,也有public、private和protected这三种访问级别
所以当静态成员变量设置为private时,尽管我们突破了类域,也不能对其进行访问。
为了巩固已学知识,我们来看一看下面这两个问题:
1、静态成员函数可以调用非静态成员函数吗?
2、非静态成员函数可以调用静态成员函数吗?
1:不可以。因为非静态成员函数的第一个形参默认为this指针,而静态成员函数中没有this指针,故静态成员函数不可调用非静态成员函数。
2:可以。因为静态成员函数和非静态成员函数都在类中,在类中不受访问限定符的限制。
3. 友元
友元分为友元函数和友元类。友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
3.1友元函数
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。
我们可以用一个例子来帮助理解这个友元函数:小王和小明是好朋友,小王今天去小明家做客,那么小明就会用家里的水果来招待小王。
这里的水果就指的类的内部变量,小王去小明家做客就是要把小王这个类在小明家用friend友元声明,所以小王就可以访问小明的私有变量。
对于之前实现的日期类,我们现在尝试重载operator<<,但是我们发现没办法将其重载为成员函数,因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置:this指针默认是第一个参数,即左操作数,但是实际使用中cout需要是第一个形参对象才能正常使用。
比如:
class Date
{
public:
Date(int year,int month,int day)
:_year(year)
,_month(month)
,_day(day)
{}
void operator<<(ostream& out)
{
out << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 2, 10);
d1 << cout; //这里的对象是反的
return 0;
}
所以我们要将operator<<重载为全局函数,但是这样的话,又会导致类外没办法访问成员,那么这里就需要友元来解决。(operator>>同理)
class Date
{
friend void operator<<(ostream& out,Date& d); //友元声明,需要访问私有成员。
public:
Date(int year,int month,int day)
:_year(year)
,_month(month)
,_day(day)
{}
private:
int _year;
int _month;
int _day;
};
void operator<<(ostream& out,Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
}
int main()
{
Date d1(2024, 2, 10);
cout << d1;
return 0;
}
我们都知道C++的<<和>>很神奇,因为它们能够自动识别输入和输出变量的类型,我们使用它们时不必像C语言一样增加数据格式的控制。实际上,这一点也不神奇,内置类型的对象能直接使用cout和cin输入输出,是因为库里面已经将它们的<<和>>重载好了,<<和>>能够自动识别类型,是因为它们之间构成了函数重载。
class Date
{
friend ostream& operator<<(ostream& out,Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
Date()
{
}
Date(int year,int month,int day)
:_year(year)
,_month(month)
,_day(day)
{}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out,Date& d) // << 运算符重载
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d) // >> 运算符重载
{
cout << "请依次输入年月日>" << endl;
in >> d._year >> d._month >> d._day ;
return in;
}
友元函数说明:
1、友元函数可以访问类是私有和保护成员,但不是类的成员函数。
2、友元函数不能用const修饰。
3、友元函数可以在类定义的任何地方声明,不受访问限定符的限制。
4、一个函数可以是多个类的友元函数。
5、友元函数的调用与普通函数的调用原理相同。
3.2友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一在这里插入代码片
个类中非公有成员。
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的友元。
4. 内部类
概念:如果一个类定义在另一个类的内部,则这个类被称为内部类。
注意:内部类就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访
问外部类中的所有成员。但是外部类不是内部类的友元。
特性:
- 内部类可以定义在外部类的public、protected、private都是可以的。
- 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
- sizeof(外部类)=外部类,和内部类没有任何关系。
class A
{
private:
static int k;
int h;
public:
class B // B天生就是A的友元
{
public:
void foo(const A& a)
{
cout << k << endl;//OK
cout << a.h << endl;//OK
}
};
};
int A::k = 1;
int main()
{
A::B b;
b.foo(A());
}
下面的这段代码的大小是4,外部类的大小和内部类无关
#include <iostream>
using namespace std;
class A //外部类
{
public:
class B //内部类
{
private:
int _b;
};
private:
int _a;
};
int main()
{
cout << sizeof(A) << endl; //外部类的大小
return 0;
}
5.匿名对象
匿名对象我们现在先只简单做以了解其基本特性,后续还会继续学习。
匿名对象的声明周期与其他不一样,他的生命周期只在其定义的这一行,代码运行到下一行就会自动销毁:
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
int main()
{
A();
A();
A();
return 0;
}
6.拷贝对象时的一些编译器优化
在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝,这个在一些场景下还是非常有用的。
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
:_a(aa._a)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a = aa._a;
}
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void f1(A aa)
{}
A f2()
{
A aa;
return aa;
}
int main()
{
// 传值传参
A aa1;
f1(aa1);
cout << endl;
// 传值返回
f2();
cout << endl;
// 隐式类型,连续构造+拷贝构造->优化为直接构造
f1(1);
// 一个表达式中,连续构造+拷贝构造->优化为一个构造
f1(A(2));
cout << endl;
// 一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造
A aa2 = f2();
cout << endl;
// 一个表达式中,连续拷贝构造+赋值重载->无法优化
aa1 = f2();
cout << endl;
return 0;
}
-
按值传递:当使用按值传递(f1(aa1))调用f1()时,会调用拷贝构造函数A(const A& aa)来在函数作用域内创建aa1的副本。然后,在f1()退出时,局部副本的析构函数~A()被调用。
-
按值返回:在f2()中,创建一个A对象aa并按值返回。这涉及构造aa,然后按值返回它,调用拷贝构造函数A(const A& aa)创建返回的临时副本。然后,当临时对象超出作用域时,调用其析构函数。
-
隐式类型转换:当调用f1(1)时,整数1被隐式转换为A对象,使用构造函数A(int a)。然后,这个临时的A对象被按值传递给f1(),调用拷贝构造函数A(const A& aa)。最后,临时对象被销毁。
-
带临时对象的隐式类型转换:f1(A(2))涉及使用构造函数A(int a)创建临时A对象,然后将其按值传递给f1()。这与前一个情况类似,但是直接在函数调用中构造了临时对象。
-
链式拷贝构造函数:在A aa2 = f2();中,f2()按值返回一个A对象,调用构造函数A(int a)创建一个局部对象。然后,使用拷贝构造函数A(const A& aa)将这个局部对象复制到aa2中。然后,随着对象超出作用域,析构函数被调用。
-
拷贝赋值:aa1 = f2();涉及在f2()内部创建一个临时对象,然后将其赋值给aa1。这触发了拷贝构造函数来创建临时对象,然后触发了赋值运算符A& operator=(const A& aa)将其赋值给aa1。最后,临时对象被销毁。
7. 再次理解封装
C++是基于面向对象的程序,面向对象有三大特性:封装、继承、多态。
C++中,类将一个事物的属性和行为组合在一起,更符合人们对于事物的认知,使得对象的所有相关内容都能被封装在一起。通过访问限定符,部分功能得以开放,使得对象可以与其他对象进行交互。而对于对象内部的一些实现细节,外部用户无需了解,有时即使了解也无益,反而会增加使用和维护的难度,使得事物变得复杂化。
当你准备去旅行时,你可能会准备一个行李箱。这个行李箱就像一个对象,它有自己的属性和行为,而这些属性和行为是封装在一起的。
1. 属性:行李箱有大小、颜色、重量等属性。这些属性描述了行李箱的特征,但其他人不需要了解行李箱内部是如何设计的。
2. 行为:行李箱可以打开、关闭、装入物品、拉动等。这些行为是行李箱可以执行的操作,其他人只需要知道如何使用行李箱的这些功能,而不需要了解它们是如何实现的。
通过封装,行李箱的内部细节被隐藏起来,只暴露了一些对外公开的接口,使得其他人可以方便地使用它。这样,就可以避免其他人无意间改变了行李箱的设计,也不需要其他人关心行李箱内部是如何组织的。
8.再次理解面向对象
在类和对象阶段,大家一定要体会到,类是对某一类实体(对象)来进行描述的,描述该对象具有那些属性,那些方法,描述完成后就形成了一种新的自定义类型,才用该自定义类型就可以实例化具体的对象。