天天看点

C++:类和对象(下)1 再谈构造函数2 static成员3 友元4 内部类5 匿名对象6 拷贝对象时的一些编译器优化7 再次理解类和对象

文章目录

  • 1 再谈构造函数
    • 1.1 构造函数体赋值
    • 1.2 初始化列表
    • 1.3 explicit关键字
  • 2 static成员
    • 2.1 概念
    • 2.2 特性
  • 3 友元
    • 3.1 友元函数(流插入(<<)及流提取(>>)运算符重载)
    • 3.2 友元类
  • 4 内部类
  • 5 匿名对象
  • 6 拷贝对象时的一些编译器优化
  • 7 再次理解类和对象

1 再谈构造函数

1.1 构造函数体赋值

在创建对象时,编译器通过调用构造函数,给对象中的各个成员变量一个合适的初始值。如下:

class Date{
public:
	Date(int year, int month, int day){
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
           

虽然上述构造函数调用后,对象中已经有了一个初始值,但是不能将其称之为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。

1.2 初始化列表

前言:

我们知道,当我们编写一个类时,只是对这个类整体进行了一个声明,只有当我们通过类的实例化创建出该类类型对象,才算是完成一个对象的定义,但实际上,这只能算是对对象整体的定义,那其中的每个成员变量又是在什么时候定义的呢?如下以一个类为例,这里类中没有主动编写的构造函数,在定义对象时调用编译器默认生成的构造函数。问题来了,当我们在类中增加const成员变量后,再运行程序会发现编译错误,原因是作为内置类型成员变量,编译器默认生成的构造函数不会对其进行处理,而const修饰的变量必须在定义的时候初始化(注意,在C++98时还不能在成员变量声明时给缺省值),此时直接定义对象,const成员变量没有实现初始化,因此会发生编译报错。

class A {
private:
	int _a1;
	int _a2;
	//const int _x; //const变量必须在定义的时候初始化
	//const int _x = 0; //C++98不支持缺省值
};

int main() {
	A aa; //对象整体的定义,每个成员变量什么时候定义?
	return 0;
}
           

为了解决成员变量初始化的问题,C++引入了初始化列表的概念。

初始化列表:以一个

冒号:

开始,接着是一个以逗号分割的数据成员列表,每个成员变量后面跟一个放在

括号()

中的初始值或表达式。

我们可以理解为初始化列表是调用该构造函数的对象的每个成员变量定义的地方。

示例:

class Date{
public:
	Date(int year, int month, int day)
		:_year(year)
		,_month(month)
		,_day(day)
	{}
private:
	int _year;
	int _month;
	int _day;
};
           

初始化列表的相关注意事项:

  • 拷贝构造函数也有初始化列表。
  • 每个成员变量在初始化列表中只能出现一次(换句话说,初始化只能初始一次)。如果某个成员变量没有在初始化列表中,则再使用声明时的缺省值进行初始化,如果缺省值也没有,则以随机值初始化。
  • 类中如果包含以下成员,必须放在初始化列表位置进行初始化:
    • 引用成员变量(必须在定义时进行初始化)
    • const成员变量(必须在定义时进行初始化)
    • 没有默认构造函数的自定义类型成员

      示例:

class A{
public:
	//带参构造函数
	A(int a)
		:_a(a)
	{}
	
private:
	int _a;
};

class B{
public:
	B(int a, int ref)
		:_aobj(a)
		,_ref(ref)
		,_n(10)
	{}
	
private:
	A _aobj; //没有默认构造函数
	int& _ref; //引用
	const int _n; // const 
};
           
  • 尽量使用初始化列表初始化,因为不管你是否使用初始化列表,类中每个成员变量都会先使用初始化列表进行初始化。对于内置类型成员变量,如果没有显示使用初始化列表,则以缺省值进行初始化,如果缺省值也没有,则以随机值初始化;对于自定义类型成员,如果显示使用了初始化列表,则调用其对应构造函数,如果没有显示使用初始化列表,则调用其默认构造函数,如果默认构造函数也没有,则不能完成初始化,也就不能通过编译 。

    示例:

class Time{
public:
	Time(int hour = 0)
		:_hour(hour)
	{
		cout << "Time()" << endl;
	}

private:
	int _hour;
};

class Date{
public:
	Date(int day)
	{}

private:
	int _day;
	Time _t;
};

int main()
{
	Date d(1);
}
//输出:Time()
           
  • 成员变量在类中的声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
class A{
public:
	A(int a)
		:_a1(a) //先定义_a1
		, _a2(_a1)
	{}

	void Print() {
		cout << _a1 << " " << _a2 << endl;
	}

private:
	int _a2; //先声明_a2
	int _a1;
};

int main() {
	A aa(1);
	aa.Print();
}

//输出:1 -858993460
//即按声明顺序进行初始化,在_a1在_a2之后初始化,而_a2又以_a1的值进行初始化,此时_a1还是随机值,所以_a2为随机值
           

1.3 explicit关键字

构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值而其余参数均有默认值的构造函数,还可以进行隐式类型转换。而如果不想使其能进行隐式类型转换,可以用关键字

explicit

修饰构造函数。

示例:

class A {
public:
	//单参构造函数 - 支持隐式类型转换
	A(int a)
		:_a1(a)
	{
		cout << "A(int a)" << endl;
	}

	不支持隐式类型转换
	//explicit A(int a)
	//	:_a1(a)
	//{
	//	cout << "A(int a)" << endl;
	//}

	//多参构造函数 - C++98不支持隐式类型转换,C++11可以以{参数……}的方式支持
	A(int a1, int a2)
		:_a1(a1)
		,_a2(a2)
	{}

	不支持隐式类型转换
	//explicit A(int a1, int a2)
	//	:_a1(a1)
	//	, _a2(a2)
	//{}

	A(const A& aa) 
		:_a1(aa._a1)
	{
		cout << "A(const A& aa)" << endl;
	}

private:
	int _a2;
	int _a1;
};

int main() {
	A aa1(1); //单参构造函数
	
	//下面语句发生了隐式类型转换:通过创建一个临时变量将1转换成A类类型对象,再用临时的对象拷贝创建aa2对象
	//按理来说,运行程序,下面语句会调用一次构造函数加一次拷贝构造函数,
	//但实际输出表示只调用了一次构造函数,这是因为编译器进行了优化,直接用1构造了aa2对象
	A aa2 = 1; 
	
	//下面语句如果不加const,则无法通过编译,而加了const则编译通过,
	//正是因为隐式类型转换产生了临时对象,而临时对象具有常属性,
	//因此需要用const修饰才能进行引用,这也侧面说明了隐式类型转换的过程中产生了临时变量
	const A& ref = 2;

	A aa3(1, 2);//多参构造函数
	A aa4 = { 1, 2 };//隐式类型转换 - C++11支持,C++98不支持
	return 0;
}
           

2 static成员

2.1 概念

声明为 static 的类成员称为类的静态成员,用 static 修饰的成员变量,称之为静态成员变量;用 static 修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化。

示例:要求实现一个类,计算程序中创建出了多少个类对象。

① 方法一:使用全局变量计数

错误示例:

#include <iostream>

using namespace std;

int count = 0;

class A {
public:
	//创建对象要么使用构造函数,要么使用拷贝构造函数
	A() { ++count; } //只要调用构造函数就让计数值加一

	A(const A& t) { ++count; } //调用拷贝构造函数也让计数值加一

	~A() { --count; } //如果对象被析构,则计数值减一
};

int main() {
	cout << count << endl;
	A a1, a2;
	A a3(a1);
	cout << count << endl;
	return 0;
}
           

上述代码运行后报错,指出count是不明确的符号,这是因为在C++的

xutility(5263,45)

文件中有个与 count 的同名函数

std::count(const _InIt,const _InIt,const _Ty &)

,而我们选择将整个 std 命名空间展开,这就造成了命名冲突。基于此,可以做出以下修改:只将用到 cout 及 endl 展开。

正确写法:

#include <iostream>
using std::cout;
using std::endl;

int count = 0;

class A {
public:
	//创建对象要么使用构造函数,要么使用拷贝构造函数
	A() { ++count; } //只要调用构造函数就让计数值加一

	A(const A& t) { ++count; } //调用拷贝构造函数也让计数值加一

	~A() { --count; } //如果对象被析构,则计数值减一
};

int main() {
	cout << count << endl;
	A a1, a2;
	A a3(a1);
	cout << count << endl;
	return 0;
}
           

可以看到,使用这种方法统计创建的对象要稍微复杂一些,且因为全局变量可以在任意位置被修改,所以存在安全隐患。

② 方法二:使用静态成员变量计数

#include <iostream>

using namespace std;

class A{
public:
	//创建对象要么使用构造函数,要么使用拷贝构造函数
	A() { ++_scount; } //只要调用构造函数就让计数值加一

	A(const A & t) { ++_scount; } //调用拷贝构造函数也让计数值加一

	~A() { --_scount; } //如果对象被析构,则计数值减一

	//受访问限定符限制,为了保证封装性,提供GetACount()函数来获取静态成员变量值
	//静态成员函数
	static int GetACount1() { return _scount; }

	//非静态成员函数也可以调用静态成员函数
	int GetACount2() { return A::GetACount1(); }

	//静态成员函数只有在包含类类型对象参数时,才能用对象调用非静态成员函数
	static int GetACount3(A& a) { return a.GetACount2(); }

private:
	static int _scount;//静态成员变量
};

int A::_scount = 0;//受类域限制,需以 类名:: 的方式访问

int main(){
	cout << A::GetACount1() << endl; // 类名::静态成员方式调用
	A a1, a2;
	A a3(a1);
	cout << a3.GetACount1() << endl; // 对象.静态成员方式调用
	A a4[10];//对象数组

	cout << a3.GetACount2() << endl;//非静态成员函数调用静态成员函数
	cout << A::GetACount3(a3) << endl;//静态成员函数调用非静态成员函数
	return 0;
}

//输出:0 3 13 13
           

2.2 特性

  • 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区。
  • 静态成员变量必须在类外定义,定义时不添加 static 关键字,类中只是声明。
  • 类静态成员即可用

    类名::静态成员

    或者

    对象.静态成员

    的方式访问。
  • 静态成员函数没有隐藏的 this指针,不能访问任何非静态成员。
  • 静态成员也是类的成员,受public、protected、private访问限定符的限制。
  • 非静态成员函数可以调用静态成员函数,静态成员函数只有在包含类类型对象参数时采用通过对象调用非静态成员函数。

3 友元

友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不建议过多使用。

友元分为:友元函数和友元类

3.1 友元函数(流插入(<<)及流提取(>>)运算符重载)

以下以实现对

流插入运算符>>

流提取运算符<<

重载为例说明友元函数使用:

以日期类为例,可以看到,当我们想输出日期时,通常时会先编写一个成员函数,然后通过对象去调用成员函数来输出相应的年、月、日。我们知道,在C++中通常使用

cin >>

cout <<

进行内置类型的输入输出,那能不能也使用这种方式来对自定义类型对象进行输入输出呢?答案是当然可以。

通过 cplusplus 网站查看,可以发现,实际上

cout

cin

分别是

ostream

istream

类型的对象,而之所以在C++中

cin >>

cout <<

输入输出可以自动识别类型,是因为在

istream

ostream

类中分别实现了的对

流提取运算符 >>

流插入运算符 <<

的重载。也就是说,如果我们也能实现对这两个运算符的重载,那就可以采用

cin >>

cout <<

的方式进行自定义类型对象的输入输出了。

C++:类和对象(下)1 再谈构造函数2 static成员3 友元4 内部类5 匿名对象6 拷贝对象时的一些编译器优化7 再次理解类和对象
C++:类和对象(下)1 再谈构造函数2 static成员3 友元4 内部类5 匿名对象6 拷贝对象时的一些编译器优化7 再次理解类和对象

但这里需要注意的是,该运算符重载是实现在

istream

ostream

这两个类中的,而这两个类是无法被修改的,因此我们只能选择在全局实现这两个运算符的重载,或是在需要用到的该运算符的自己编写的类中进行运算符重载。

考虑到为了能访问对象中的私有成员,我们通常会将运算符重载实现在对应的类中,如下:

class Date{
public:
	Date(int year = 2023, int month = 1, int day = 1){
		_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(2023, 2, 1);
	//cout << d1; //编译报错
	d1 << cout; //输出2023-2-1
	d1.operator<<(cout); //输出2023-2-1
	return 0;
}
           

但运行程序发现:我们采用

cout << d1;

的方式输出日期时会发生编译报错,而采用

d1 << cout;

d1.operator<<(cout);

的方式却可以正常输出。这是因为当我们在日期类中实现运算符重载时,默认第一个参数即为隐含参数

*this

,而对于双目操作符来说,第一个参数即为左操作数。但是这样的输出方式与我们平时的输出写法有所差异,那怎么能采用

cout << d1;

的方式进行输出呢?

于是我们考虑将运算符重载实现在类外,这样我们就可以主动使

ostream

类对象作为第一个参数,而日期类对象为第二个参数。但这样还面临一个问题,我们无法在类外访问私有成员,当然,我们也可以将成员变为公有,但这会失去封装性,一般不建议这样处理;此外,也可以通过设置对应的

Get_year()

等公有成员函数来在类外获取私有成员;而还有一种方法则是使用

friend

修饰函数(即友元函数),可以理解为经过修饰后该函数成为了对应类的朋友,因此可以访问类中私有成员。此外,还有一点需要注意,我们平常使用的流插入运算符是可以连续使用的,也就是说,还运算符重载应该要有返回值,返回

ostream

类型对象的引用。如下所示:

class Date{
	//友元函数声明
	friend ostream& operator<<(ostream& out, const Date& date);
	
public:
	Date(int year = 2023, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}

	//void operator<<(ostream& out) {
	//	out << _year << "-" << _month << "-" << _day << endl;
	//}

private:
	int _year;
	int _month;
	int _day;
};

ostream& operator<<(ostream& out, const Date& date) {
	out << date._year << "-" << date._month << "-" << date._day << endl;
	return out;
}

int main() {
	Date d1(2023, 2, 1);
	Date d2(2023, 2, 7);
	cout << d1 << d2; 
	//输出:
	//2023-2-1
	//2023-2-7
	return 0;
}
           

同样的,接下来我们也可以实现流提取运算符重载如下:

class{
	friend istream& operator>>(istream& in, Date& date);
public:
	//其它方法
private:
	int _year;
	int _month;
	int _day;
};

istream& operator>>(istream& in, Date& date) {
	in >> date._year >> date._month >> date._day;
	return in;
}

int main() {
	Date d1;
	cin >> d1;
	cout << d1;
	return 0;
}
           

说明:

  • 友元函数可以直接访问类的私有和保护成员,但它不是类的成员函数,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加

    friend

    关键字。
  • 友元函数不能用

    const

    修饰。因为 const 只能修饰成员函数,更直接的说 const 是用来修饰 this指针 的,而友元函数没有 this指针。
  • 友元函数可以在类定义的任何地方声明,不受类访问限定符限制。
  • 一个函数可以是多个类的友元函数。
  • 友元函数的调用与普通函数的调用原理相同。

3.2 友元类

  • 友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
  • 友元关系是单向的,不具有交换性。

    如下,在 Time 类中声明 Date 类为其友元类,那么可以在 Date 类中直接访问 Time 类中的私有成员变量,但想在 Time 类中访问 Date 类中的私有成员变量则不行。

class Time{
	friend class Date; //声明日期类为时间类的友元类,则在日期类中就可直接访问Time类中的私有成员变量
public:
	Time(int hour = 0, int minute = 0, int second = 0)
		: _hour(hour)
		, _minute(minute)
		, _second(second)
	{}

private:
	int _hour;
	int _minute;
	int _second;
};

class Date{
public:
	Date(int year = 2023, int month = 1, int day = 1)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

	void SetTimeOfDate(int hour, int minute, int second){
		//直接访问时间类私有的成员变量
		_t._hour = hour;
		_t._minute = minute;
		_t._second = second;
	}

private:
	int _year;
	int _month;
	int _day;
	Time _t;
};
           
  • 友元关系不能传递。 (如果 C 是 B 的友元,B 是 A 的友元,也不能说明 C 是 A 的友元。)
  • 友元关系不能继承。

4 内部类

概念:如果一个类定义在另一个类的内部,这个类就叫做内部类。 内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越访问权限。

注意:内部类就是外部类的友元类。 内部类可以通过外部类的对象参数来访问外部类中的所有成员。但外部类不是内部类的友元。

特性:

  • 内部类可以定义在外部类的public、protected、private任意位置。
  • 内部类可以直接访问外部类中的 static 成员,不需要外部类的对象/类名。
  • sizeof(外部类) = 外部类

    ,和内部类没有任何关系。

示例:

class A {
private:
	static int k;
	int h;

public:
	class B { // B天生就是A的友元
	private:
		int _b = 2;

	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());
	cout << sizeof(A) << endl; //输出:4,其中静态成员变量存储在静态区
	return 0;
}
           

5 匿名对象

class A{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

	~A(){
		cout << "~A()" << endl;
	}
private:
	int _a;
};

class Solution {
public:
	int Sum_Solution(int n) {
		//...
		return n;
	}
};

int main(){
	A aa1;

	// 不能这么定义对象,因为编译器无法识别下面是一个函数声明,还是对象定义
	//A aa1();
	// 
	// 但是我们可以这么定义匿名对象,匿名对象的特点不用取名字,
	// 但是他的生命周期只有这一行,我们可以看到下一行他就会自动调用析构函数
	A();

	A aa2(2);
	// 匿名对象在这样场景下就很好用
	Solution().Sum_Solution(10);
	return 0;
}
           

输出结果:

C++:类和对象(下)1 再谈构造函数2 static成员3 友元4 内部类5 匿名对象6 拷贝对象时的一些编译器优化7 再次理解类和对象

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;
}
           

结果输出:

C++:类和对象(下)1 再谈构造函数2 static成员3 友元4 内部类5 匿名对象6 拷贝对象时的一些编译器优化7 再次理解类和对象

说明: 不同的编译器的优化程度不同,从上述结果也可以看出,本文中所使用的VS2022的编译器的优化程度比较大。对于函数 f2 来说,不优化的情况下应该是调用一个构造函数和一个拷贝构造函数,而这里直接优化成了一次构造,显然优化方式稍有些激进,因为函数 f2 中的对象构造和返回是分开的,为了避免中间可能还有使用对象的地方,保守些的编译器在此是不做优化的。

总结:

  • 关于对象返回的总结:
    • 接收返回值对象时,尽量使用拷贝构造方式接收,不要赋值接收;
    • 函数中返回对象时,尽量返回匿名对象。
  • 关于函数传参的总结:
    • 尽量使用

      const 类型&

      的方式传参。

7 再次理解类和对象

现实生活中的实体,计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要:

  • 用户先要对现实中洗衣机实体进行抽象 – 即在人为思想层面对洗衣机进行认识,洗衣机有什么属性,有哪些功能,即对洗衣机进行抽象认知的一个过程。
  • 经过上述步骤后,在人的头脑中已经对洗衣机有了一个清晰的认识,只不过此时计算机还不清楚,想要让计算机识别人想象中的洗衣机,就需要人通过某种面向对象的语言(比如:C++、Java、Python等)将洗衣机用类来进行描述,并输入到计算机中。
  • 经过上述步骤后,计算机中就有了一个洗衣机类,但洗衣机类只是站在计算机的角度对洗衣机对象进行描述的,通过洗衣机类,可以实例化出一个个具体的洗衣机对象,此时计算机才能清楚洗衣机是什么东西。
  • 接着用户就可以借助计算机中的洗衣机对象,来模拟现实中的洗衣机实体了。

注意:类是对某一实体(对象)来进行描述的,描述该对象具有哪些属性,哪些方法,描述完之后就形成了一种新的自定义类型,使用该自定义类型就可以实例化出具体的对象。

C++:类和对象(下)1 再谈构造函数2 static成员3 友元4 内部类5 匿名对象6 拷贝对象时的一些编译器优化7 再次理解类和对象

以上是我对C++中类和对象相关知识的一些学习记录总结,如有错误,希望大家帮忙指正,也欢迎大家给予建议和讨论,谢谢!

继续阅读