【C++】类和对象(下)

发布于:2025-05-08 ⋅ 阅读:(12) ⋅ 点赞:(0)

上文链接

【C++】类和对象(中)——默认成员函数详解(万字)

一、再探构造函数

1. 初始化列表

之前在类和对象(中)里我们讲了构造函数的大部分内容,还有一部分内容需要我们作进一步的探索。

之前我们实现构造函数时,初始化成员变量主要是使用函数体内赋值,构造函数初始化还有一种方式,就是初始化列表,初始化列表的使用方式是以一个冒号开始,接着是以逗号分隔的数据成员列表,每个“成员函数”后面跟着一个放在括号中的初始值或表达式

class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;  // 这是我们之前写的构造函数
    }
    
	Date(int year = 1, int month = 1, int day = 1) 
		:_year(year)
		, _month(month)
		, _day(day) // 初始化列表方式
	{
		// 函数体内也可以初始化,我们上面写的构造函数就是在这里进行初始化的。
	}
    
    Date(int year = 1, int month = 1, int day = 1)
        :_year(year)
		, _month(month)
    {
        _day = day;  // 我们还可以一部分用初始化列表,一部分在函数体内初始化
    }

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

2. 深入理解初始化列表

  1. 每个成员变量在初始化列表中只能出现一次语法理解上初始化列表可以认为是每个成员变量定义初始化的地方

前半句很好理解,一个成员变量不能在初始化列表中出现多次。那么后半句该如何理解?

首先我们知道,像 const 这样的变量我们是必须要在定义的地方就初始化的

const int x = 1; // OK

const int x;
int x = 1;  // ERROR

所以说如果我们的类中的成员变量有一个 const 类型,如果我们还像之前那样的方式写构造函数,那么它的行为就相当于我们上面代码的 ERROR 的那一种,是会报错的。

请添加图片描述

那么这个时候我们就必须要用到初始化列表的方式,它的初始化行为就相当于就在定义的地方将变量初始化,等同于我们上面代码中 OK 的那一种。

class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
        :_day(day)  // _day 成员是一个 const 类型,必须放在初始化列表中
    {
        _year = year;
        _month = month;
    }

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

除了 const 类型,引用也是必须放在初始化列表中的,因为引用在定义时也必须进行初始化。另外,还有一种类型也必须放在初始化列表中,就是没有默认构造函数的类对象

我们之前在讲 Stack 类和 MyQueue 类的时候就提到过说,因为 MyQueue 类中的成员变量是自定义类型,初始化 MyQueue 对象时如果它没有显式写构造函数那么编译器就会去调用 Stack 类中的默认构造函数。但是如果 Stack 类中没有默认构造函数,我们就需要在 MyQueue 中自己去写一个构造函数。

由于我们的类对象是在创建的时候就被初始化的,相当于也是在定义的地方初始化这样的逻辑,比如 Date d1(2025, 4, 22)。所以说我们在显式地在 MyQueue 中实现构造函数时,必须将它的两个 Stack 类的成员变量放在初始化列表中,才符合我们的逻辑,就跟我们普通实例化类对象一样。

class Stack
{
public:
	Stack(int n)  // 这里需要我们传参,因此它不是默认构造函数,我们需要在 MyQueue 中自己写构造函数
	{
		// ...
	}

private:
	int* _a;
	int _capacity;
	int _top;
};

class MyQueue
{
public:
    MyQueue(int n = 4)
        :_pushst(n)  // 必须用初始化列表的方式进行初始化
        , _popst(n)
    {}
    
private:
    Stack _pushst;
    Stack _popst;
};


int main()
{
    MyQueue q;

	return 0;
}

所以总结一下:

  1. const 成员变量,引用成员变量,没有默认构造函数的类类型变量,必须放在初始化列表位置进行初始化,否则会编译报错。其他类型的成员变量可以放在初始化列表位置进行初始化,也可以放在函数体内进行初始化。

  1. C++11 支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显式在初始化列表初始化的成员使用的

也就是说我们平时在类中定义成员变量的时候是只写了声明,比如 int _year;,而现在我们可以在这个声明的地方给它一个缺省值,比如 int _year = 1;,注意这里不是初始化!这个缺省值是给初始化列表用的。如果初始化列表没有显式初始化,默认就会用这个缺省值初始化。

class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
        :_day(day)  // 这里显式地实现了初始化列表,所以缺省值就不起作用了。如果没有写,_day就是缺省值
    {
        _year = year;
        _month = month;
    }

private:
    int _year;
    int _month;
    int _day = 1; // 给了一个缺省值,注意这里不是初始化!
};

int main()
{
	Date d1(2025, 4, 22);
    // d1 -> 2025/4/22
    
    return 0;
}

还有像之前的 MyQueue 类,如果 Stack 类已经有了默认构造函数,但是 MyQueue 类中还有一个内置类型,我们就可以使用下面这样的缺省值,更加方便。

class MyQueue
{
public:
    
private:
    Stack _pushst;
    Stack _popst;
    int _n = 4;  // 给了一个缺省值,没有在 MyQueue 中写构造函数那么 _n 就是缺省值
};

除此之外,我们还可以给 const 变量、类对象等一个缺省值,并且缺省值可以是常量,也可以是一个表达式

class Test1
{
public:
    Test1(int t)
    {
		// ...	
    }
private:
    int _t;
};

class Test2
{
private:
    int _x = 0;
    const int _n = 1;
    Test1 a = 1;  // 给一个类对象一个缺省值
    int* _ptr = (int*)malloc(12);  // 缺省值为一个表达式
};

当我们给一个类对象一个缺省值时,就等同于我们在这个类中以初始化列表的方式对它进行初始化。

class Test2
{
public:
	Test2()
        :a(1)
    {}
private:
    Test1 a;    
};

  1. 尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显式地在初始化列表初始化地内置类型是否初始化取决于编译器,C++ 并没有规定。对于没有显式地在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造函数就会造成编译错误。

请添加图片描述

对于上图中还需要补充一点:如果引用成员变量 / const 成员变量 / 没有默认构造函数的成员变量如果没有显式地在初始化列表中初始化,那么给缺省值也可以

上面我们说能用初始化列表进行初始化就用初始化列表,但是也有一些我们需要在函数体中进行初始化,比如说我们在初始化列表中定义了一个数组,开了一块空间,我们需要对这个数组每个单元进行赋值,那么就需要在函数体中写一个循环之类的。


  1. 初始化列表中按照成员变量在类中的声明顺序进行初始化,跟成员变量在初始化列表出现的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。

关于这一点,下面有一道题考考大家:

下面程序的运行结果是什么( )

A. 输出 1 1

B. 输出 2 2

C. 编译报错

D. 输出 1 随机值

E. 输出 1 2

F. 输出 2 1

class A
{
public:
	A(int a)
		:_a1(a)
		,_a2(_a1)
	{}

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

private:
	int _a2 = 2;
	int _a1 = 2;
};

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

首先我们在类中的两个成员变量都在初始化列表中显式地写了,所以这里的缺省值完全就不起作用了,相当于是一个干扰点。其次由于我们在类中声明的顺序是先 _a2_a1,所以在初始化列表的时候会先执行 _a2(_a1),而此时 _a1 还没有被初始化,因此 _a2 是一个随机值,而之后才执行 _a1(a),这里 a 是 1,所以 _a1 的值是 1。

答案:D

请添加图片描述


二、类型转换

1. 单参数类型转换

在 C++ 中支持内置类型隐式转换成类类型对象,需要有相关内置类型为参数的构造函数。

现在我们有一个类 A

class A
{
public:
	A(int a1)
		:_a1(a1)
	{}

private:
	int _a1 = 1;
};

当我们创建一个对象并对 _a1 赋值时我们可以这样写:

A aa1(10);  // 正常走构造函数

而现在我们知道了隐式类型转换之后,我们还可以这样写:

A aa1 = 10;  // 隐式类型转换

那么这个隐式类型转换的原理是什么?它是基于我们写的 A(int a1) 这个构造函数来实现的,如果没有这个构造函数,就不能这样写。而有了这个构造函数之后,它会先去由这里的值 “1” 去构造一个临时对象,之后这个临时对象再通过拷贝构造给 aa1。也就是说它基本的原理是先构造再拷贝构造,但是一般我们的编译器会把这个过程进行优化,从而变成直接构造

class A
{
public:
	A(int a1)
		:_a1(a1)
	{
		cout << "A(int a1)" << endl;
	}

	A(const A& aa)  // 拷贝构造
	{
		cout << "A(const A& aa)" << endl;
	}

private:
	int _a1 = 1;
};

int main()
{
	A aa1(10);
	A aa2 = 10;

	return 0;
}

请添加图片描述

从上面的结果可以看到,隐式类型转换是没有经过拷贝构造的,这是编译器优化之后的结果,并不是说隐式类型转换就跟拷贝构造没关系,有些编译器不会优化。

有了这个隐式类型转换,我们还可以和 const 引用结合起来使用。

const A& aa3 = aa2;
const A& aa4 = 10;

但是注意普通的引用不能直接使用隐式类型转换

A& aa4 = 10; // ERROR

这是因为我们类型转换的时候会生成一个临时变量,而临时对象具有常性,直接把这个临时对象给 aa4 就扩大权限了,因此会报错。


除了内置类型,类类型的对象之间也可以隐式转换,需要相应的构造函数支持。

class A
{
public:
	A(int a1)
		:_a1(a1)
	{}

	int Get() const
	{
		return _a1 + _a2;
	}

private:
	int _a1 = 1;
	int _a2 = 2;
};

class B
{
public:
	B(const A& a)
		:_b(a.Get())
	{}

private:
	int _b = 0;
};

int main()
{
	A aa1 = 10;

	B bb1(aa1);  // 普通走构造
	B bb2 = aa1;  // 隐式类型转换

	return 0;
}

2. 多参数类型转换

class A
{
public:
	A(int a1)
		:_a1(a1)
	{}

	A(int a1, int a2)
		:_a1(a1)
		,_a2(a2)
	{}
    
private:
	int _a1 = 1;
	int _a2 = 2;
};

当我们的构造函数有多个参数的时候,在 C++98 及之前的版本是不支持这种多参数的隐式类型转换的,但是在 C++11 及以上的版本中,对于这种多参数的构造函数,我们也可以使用隐式类型转换。

在语法上我们需要用一个花括号 {} 将我们的参数括起来。

int main()
{
	A aa1(10, 20);  // 普通走构造函数
	
    // C++11
	A aa2 = { 10, 20 };  // 隐式类型转换

	return 0;
}

3. explicit

当我们不想支持这样的类型转换的语法时,我们可以在对应的构造函数的前面加上一个关键字 explicit

class A
{
public:
	explicit A(int a1, int a2)
		:_a1(a1)
		,_a2(a2)
	{}

private:
	int _a1 = 1;
	int _a2 = 2;
};

int main()
{
	A aa1 = { 10, 20 };  // 会报错

	return 0;
}

请添加图片描述


4. 类型转换的意义

class A
{
public:
	A(int a1)
		:_a1(a1)
    {}

private:
	int _a1 = 1;
};

class Stack
{
public:
	void push(const A& aa)
	{
		// ...
	}
};

int main()
{
	Stack st;
    
    // 以前我们的写法
	A aa10(10);
	st.push(aa10);
	
    // 有了类型转换之后
	st.push(10);

	return 0;
}

可以看到我们以前的写法中我们会先创建一个对象 aa10,之后再把这个 aa10 通过传参传给 push 函数。但是有了类型转换我们可以直接写成 st.push(10),更简洁。


补充:学习时的一点小发现

class A
{
public:
	A(int a1)
		:_a1(a1)
	{}

	int Get() const
	{
		return _a1 + _a2;
	}

private:
	int _a1 = 1;
	int _a2 = 2;
};

class B
{
public:
	B(const A& a)
		:_b(a.Get())
	{}

private:
	int _b = 0;
};

class Stack
{
public:
	void push(const A& aa)
	{
		// ...
		cout << "aa" << endl;
	}

	void push(const B& bb)
	{
		// ...
		cout << "bb" << endl;
	}
};


int main()
{
	Stack st;

	A aa1 = 10;
	B bb1 = aa1;  // 隐式类型转换

	st.push(aa1);

	return 0;
}

对于上面的程序,st.push(aa1) 这一句按照语法逻辑上它既可以调用 Stack 类中的第一个 push 又可以调用第二个 push。因为它既可以是直接把 aa1 传给第一个 push 中的 aa,也可以是通过隐式类型转换用 aa1 生成一个临时对象然后通过构造把这个值传给第二个 push 中的 bb。但是从运行结果来看,编译器没有报错,并且输出的是 aa。也就是说它传给的是第一个 push,通过询问得知:当函数重载时,编译器会优先选择参数类型完全匹配的版本(最合适的版本),而非需要隐式类型转换的版本。但是如果把第一个 push 注释掉,运行结果就是 bb。


三、static成员

1. 静态成员变量

  1. 用 static 修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外进行初始化
  2. 静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。

当我们要统计一个类到底创建了多少个对象的时候,我们可以用到静态成员来统计。

class A
{
public:
	A(int i = 0)
	{
		++_scount;
	}

	A(const A& t)
	{
		++_scount;
	}

	~A()
	{
		--_scount;
	}

private:
	static int _scount;  // 声明
};

int A::_scount = 0;  // 定义

那么如何去访问到到我们的静态成员变量呢?

第一种情况:当静态成员变量是公有的

在这种情况下,我们通常有多种方式去访问到静态成员变量。

class A
{
public:
	// ...
    
	static int _scount;  // 声明
};

int A::_scount = 0;  // 定义

int main()
{
	A a1, a2;
	A a3(a1);
	A a4 = 1;
	
    // 只适用于公有的情况
	cout << a1._scount << endl;
	cout << a4._scount << endl;
	cout << A::_scount << endl;

	return 0;
}

请添加图片描述

可以看到无论是通过某个具体的类去访问静态成员变量 _scount 或者是通过指定类域去访问都可以达到目的。它们的本质都是告诉编译器这个变量定义在哪个类,是从哪里来的,只不过这个变量是所有对象共享的。


第二种情况:当静态成员变量是私有的

这种情况下,通常我们需要单独写一个函数来获取到我们的静态成员变量。

class A
{
public:
	// ...
    
	int GetCount()
	{
		return _scount;
	}

private:
	static int _scount;
};

int A::_scount = 0; 

int main()
{
	A a1, a2;
	A a3(a1);
	A a4 = 1;

	cout << a1.GetCount() << endl;
	cout << a4.GetCount() << endl;

	return 0;
} 

请添加图片描述

但是这种方式只适用于已经创建了对象的情况,那如果我们想在某个函数中去访问静态成员变量但是这个函数中又没有创建对象该怎么办?难道要把对象传到这个函数中或者在函数中再创建一个对象吗?这样可以但是不太好,那么这种情况下我们可以使用静态成员函数来完成。


2. 静态成员函数

  1. 用 static 修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针
  2. 静态成员函数中可以访问其他的静态成员,但是不能访问非静态成员,因为没有this指针

由上面两个特点,我们就可以通过定义一个静态成员函数实现在某个函数中没有创建对象的情况下直接访问到静态成员变量,使用时指定类域即可。

class A
{
public:
	// ...
    
	static int GetCount()  // 静态成员函数(没有this指针)
	{
		return _scount;
	}

private:
	static int _scount;
};

int A::_scount = 0; 

void Func()
{
	cout << A::GetCount() << endl;  // 只需指定类域就可以访问
}

int main()
{
	A a1, a2;
	A a3(a1);
	A a4 = 1;

	cout << a1.GetCount() << endl;
	cout << a4.GetCount() << endl;
	Func();

	return 0;
} 

请添加图片描述

如果我们想调用一次性创建很多个对象,我们还可以写成一个数组的形式。

int main()
{
	A a[10];
	Func();

	return 0;
} 

请添加图片描述


3. 静态成员总结

除了上面我们罗列的关于静态成员变量和静态成员函数的相关特点,还有几条特点需要我们注意:

  1. 非静态的成员函数,可以访问任意的静态成员变量和静态成员函数
  2. 突破类域就可以访问静态成员,可以用类名::静态成员或者对象. 静态成员两种方式来访问静态成员变量。
  3. 静态成员也是类的成员,受public、protected、private访问限定符的限制。
  4. 静态成员变量不能在声明位置给缺省值初始化,因为缺省值是给构造函数初始化列表的,而静态成员变量不属于某个对象,不走构造函数初始化列表。

4. OJ 练习

题目链接——求1+2+3+…+n_牛客题霸_牛客网

请添加图片描述

这是一道要求很简单的的题,但是它限制了我们的手段,由题目的要求可知,我们不能用等差数列公式、循环、递归等的方法。那么我们可以利用我们上面讲的静态成员的特性来解决这道题。

由于静态成员是存在静态区的,所以我们可以定义一个新的类 Sum,这个类中定义两个静态成员变量 _i_ret。其中 _i 用来记录每次我们要加多少,_ret 则用来保存我们的累加和。

如何实现累加的操作?

我们可以利用每次创建对象的时候都要调用构造函数的特点,创建 n 个对象,那么就调用 n 次构造函数,然后让静态成员变量 _i 每次加1,然后让 _ret 加上 _i,最后返回 _ret 即可。全程我们只用到了加法以及类和对象的知识,符合题目要求。

class Sum
{
public:
    Sum()
    {
        _ret += _i;
        _i++;
    }

    static int GetSum()
    {
        return _ret;
    }
private:
    static int _i;
    static int _ret;
};

int Sum::_i = 1;
int Sum::_ret = 0;

class Solution 
{
public:
    int Sum_Solution(int n) 
    {
        Sum arr[n];  // 变长数组
        return Sum::GetSum();
    }
};

注:边长数组只有 C99 才支持,VS的编译器不支持这样的语法。


四、友元

1. 引入

在 C++ 基础知识篇我们有提到过,在 C 语言中既然我们已经有了 printfscanf 能输入输出,那为什么 C++ 还要新增一个 cincout 呢?其中一个很重要的原因就是 C 语言中的 printfscanf 无法对自定义类型进行输入输出。所以在这一点上,C++ 新增了两种运算符重载 <<>>,它们都被写在了某个类中的运算符重载函数被赋予了新的使用方式。

请添加图片描述

具体来说,在 C++ 库中有两个类:istreamostream,分别对应输入流 (in) 和输出流 (out),用这两个类分别创建出了两个全局的对象 cincout,而 istream 中定义了 >> 运算符的重载, ostream 定义了 << 运算符的重载。因此我们才可以用 cin >>cout << 对数据进行输入输出。而之所以它们可以支持自定义类型的输入输出,是因为它们的本质都是函数,我们可以自己编写一个运算符重载函数来实现输入或者输出一个自定义类型的逻辑。

请添加图片描述

请添加图片描述

可以看到上面的运算符重载函数有多个重载函数,这也解释了为什么在 C++ 中使用 cin >>cout << 进行输入 / 输出能够自动识别类型。就是因为你要输入 / 输出什么类型,就调用对应的类型的函数。

所以说我们平时写的如下形式的代码:

int i = 1;
cout << i;

其实就等价于:

int i = 1;
cout.operator<<(i);

并且我们在一行中连续输出的内容,本质上就是转化成多次函数调用

int i = 1;
double j = 2.2;
cout << i << " " << d << endl;

请添加图片描述

由于它会转化成这样的函数调用以及其他种种原因, cincout 会比 scanfprintf 的效率低一丢丢,但不会低很多,因为像这种函数一般会设置成内联。

在 io 需求比较高的地方,如部分大量输入的竞赛题中,加上下面三行代码,可以提高 C++ io 的效率。

// 加在 main 函数中
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);

了解了流插入和流提取的逻辑,那么我们可以试着编写一个运算符重载函数来实现对一个日期类进行输出。

假如说 d1 是一个日期类的对象,我们要实现 cout << d1 打印出来的是 “xxxx年xx月xx日”,如果我们把这个运算符重载函数写在 Date 类中的话,你会发现 << 有左右两个操作数,而写在类中那么第一个参数是 this 指针,只能是 d1 传过去,而现在 d1 是右操作数,两边顺序颠倒了。

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

	ostream& operator<<(ostream& out)
	{
		out << _year << "年" << _month << "月" << _day << "日" << endl;
		return out;
	}

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

int main()
{
	Date d1(2025, 4, 28);

	cout << d1; // 会报错

	return 0;
}

请添加图片描述

难道我们要写成 d1 << cout 吗?这样可以是可以,但是这不倒反天罡吗。因此我们可以考虑把这里的运算符重载函数写在全局。但是写在全局又有一个问题,我访问不到成员变量了,除非我写多个函数比如 GetYear()GetMonth() 等等这样的。但是太麻烦了,我不想这样做,那么怎么办?这个时候就可以用到我们的友元


2. 友元

我们在类外定义的全局运算符重载函数虽然无法直接访问类里面的成员,但是我可以让这个函数称为我这个类的"朋友",之后就可以允许这个"朋友"访问类中的成员变量。在语法上,我们需要把要称为“朋友”的函数的声明放在类中(一般喜欢放在类的开头),并在函数前面加上一个 friend 即可。这样的声明叫做友元声明

class Date
{
    // 友元声明
	friend ostream& operator<<(ostream& out, Date& d);
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _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;
}

int main()
{
	Date d1(2025, 4, 28);

	cout << d1;

	return 0;
}

请添加图片描述

  1. 友元提供了一种突破类访问限定符封装的方式,友元分为:友元函数友元类。在函数声明或者类声明的前面加上 friend,并且把友元声明放到一个类的里面。
  2. 外部友元函数可以访问类的私有和保护成员,友元函数仅仅是一种声明,它不是类的成员函数
  3. 友元函数可以在类定义的任何地方声明,不受访问限定符限制。
  4. 一个函数可以是多个类的友元函数。

关于第四点,我们需要注意的是如果你在一个类 A 中声明了一个友元函数,但是这个友元函数声明里既包含了类 A 又包含了另一个类 B,且 B 定义在类 A 的下面,编译器走到类 A 的友元函数的声明的时候就不认识这个类 B,因此我们需要在类 A 的前面加上一个类 B 的前置声明

// 前置声明, 否则编译器走到 A 中的友元函数时就不认识 B
class B;

class A
{
	// 友元声明
	friend void func(const A& aa, const B& bb);
private:
	int _a1 = 1;
	int _a2 = 2;
};

class B
{
	// 友元声明
	friend void func(const A& aa, const B& bb);
private:
	int _b1 = 3;
	int _b2 = 4;
};

void func(const A& aa, const B& bb)
{
	cout << aa._a1 << endl;
	cout << bb._b1 << endl;
}
  1. 友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员
  2. 友元类的关系是单向的不具有交换性,比如 A 类是 B 类的友元,但是 B 类不是 A 类的友元
  3. 友元类的关系不能传递,如果 A 类是 B 类的友元,B 类是 C 类的友元,但是 A 不是 C 的友元。
  4. 友元有时提供了便利,但是友元会增加耦合度,破坏了封装,所以友元不宜多用

五、内部类

1. 内部类的定义

如果一个类定义在另一个类的内部,这个内部的类就叫做内部类


2. 内部类的特点

  1. 内部类是一个独立的类,跟定义在全局相比,只是受外部类类域访问限定符限制,所以外部类定义的对象不包含内部类
  2. 内部类默认是外部类的友元类
class A
{
public:
	class B
	{
	public:
		void f(const A& a) // B 默认就是 A 的友元类
		{
			cout << _k << endl;  // OK
			cout << a._h << endl;  // OK
		}
	private:
		int _b1;
		int _b2;
	};

private:
	static int _k;
	int _h = 1;
};

int A::_k = 1;

int main()
{
    A aa;  // 创建一个 A 类的对象
    A::B bb;  // 创建一个 B 类的对象,受外部类类域的限制
    
    return 0;
}

那么如果我想求 A 实例化出来的对象的大小,应该是多大呢?我们肯定要把 A 类的成员变量算上(静态成员变量除外,因为它是放在静态区的),那我要不要算 B 类的成员变量呢?答案是不算,因为根据上面所讲的第一点,内部类是一个独立的类,所以这里实际上用 A 实例化出来的对象是不包含 B 的

因此最终我们只需要计算 _h 的大小,是 4 个字节。

请添加图片描述


  1. 内部类本质也是一种封装,当 A 类跟 B 类紧密关联,B 类实现出来主要就是给 A 类使用,那么可以考虑把 B 类设计为 A 的内部类,如果放到 private / protected 位置,那么 B 类就是 A 类的专属内部类,其他地方都用不了。

根据这一点,我们可以将我们上面所做的 OJ 练习题进行一定程度的改进。

由于我们的 Sum 类是专门用来计算这里的累加和的,它和 Solution 类的关系紧密,于是我们就可以把这个 Sum 类设计成为一个 Solution 类的专属类。并且我们在 Sum 类中的成员变量也可以直接放在 Solution 类中,在外部初始化的时候就不用连续指定两次类域了。并且由于内部类是外部类的友元类,所以我们也不需要这里的 GetSum() 函数了,直接可以访问到成员变量 _ret

class Solution 
{
public:
    class Sum
    {
    public:
        Sum()
        {
            _ret += _i;
            _i++;
        }
    };

    int Sum_Solution(int n) {
        Sum arr[n];
        return _ret;
    }
private:
    static int _i;
    static int _ret;
};

int Solution::_i = 1;
int Solution::_ret = 0;

六、匿名对象

在之前的学习中,我们用 “类型 对象名(实参)” 的对象我们称之为有名对象,在某些过程中编译器自己产生的一个临时的对象叫临时对象,而我们用 “类型(实参)” 定义出来的对象叫做匿名对象

int main()
{
    A aa1(1);  // 有名对象
    
    const A& aa2 = 1;  // 类型转化产生的临时对象
    
    A(2);  // 匿名对象
    
	return 0;
}

匿名对象也有它的作用,但是匿名对象生命周期只在当前一行,一般临时定义一个对象当前用一下即可,就可以定义匿名对象。

临时对象的生命周期也只在当前一行。

比如说我们上面的 OJ 练习题。

int main()
{
    // 正常情况下我们要创建一个对象,然后再调用函数
    Solotion s;
    s.Sum(10);
    
    // 我们还可以使用匿名对象来调用,因为我们这个函数只用一次
    Solution().Sum(10);
    // 在这一行匿名对象已经销毁了
    
	return 0;
}

但是在某些情况下,临时对象和匿名对象的生命周期可以被延长。比如说它们被 const 引用引用的时候。

int main()
{
    const A& aa1 = 1; 
    const A& aa2 = A(3);
    // 这两种情况依次是临时对象和匿名对象
    // 它们的生命周期被延长,跟着 aa1 和 aa2 走
    // aa1 和 aa2 被销毁时它们才被销毁
    
	return 0;
}

七、对象拷贝时编译器优化

现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下尽可能减少一些传参和传返回值的过程中的拷贝

对于如何优化,C++ 标准并没有严格规定,各个编译器会根据情况自行处理。当前主流的相对新一点的编译器对于连续一个表达式步骤中的连续拷贝会进行合并并优化,有些更新更“激进”的编译器还会进行跨行跨表达式的合并优化。

1. 一般场景

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

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

	~A()
	{
		cout << "~A()" << endl;
	}

private:
	int _a = 1;
};

void func(A aa)
{}

int main()
{
	// 构造临时对象,临时对象再拷贝构造aa1 -> 优化为直接构造
	A aa1 = 10;
	cout << endl;

	// 传值传参 -> 无优化
	A aa2;
	func(aa1);
	cout << endl;
	
	// 隐式类型转换,连续构造+拷贝构造 -> 优化为直接构造
	func(1);
	cout << endl;

	// 一个表达式中,连续构造+拷贝构造 -> 优化为一个构造
	func(A(2));
	cout << endl;

	return 0;
}

请添加图片描述


2. 传值返回

传值返回中编译器有时也会进行一些优化。

如果我们没有一个值去接收这个返回值。

A func2()
{
	A aa;
	return aa;  // 传值返回
}

int main()
{
	func2();

	return 0;
}

这是优化后的版本,编译器看到你调用 func2() 之后没有用任何值去接收,所以它干脆就不去创建这个临时对象了,所以之后构造和析构的过程。

请添加图片描述

这是没有优化的版本,编译器还是老老实实地把返回的结果拷贝给临时对象。

请添加图片描述


如果说我们用一个返回值去接收这个参数。

A func2()
{
	A aa;
	return aa;  // 传值返回
}

int main()
{
    // 返回一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造 (vs2019 debug)
    // 一些编译器优化得更厉害,进行跨行合并优化,直接变为构造 (vs2022 debug)
	A ret = func2();

	return 0;
}

对于没有优化的版本,那么应该是进入函数之后构造,传值返回拷贝构造,然后 A ret = func2() 这一句再来一个拷贝构造。

请添加图片描述

下面是 vs2019 debug 版本的优化,它把两次拷贝构造优化成了一次拷贝构造。

请添加图片描述

下面是 vs2022 debug 版本的优化,直接就变成了构造,合三为一。简单说明一下就是,其实这里没有产生 aa,只产生了 ret,这里的 aa 可以看作是 ret 的别名。如果你把 aaret 的地址打印下来会发现两个是一样的。

请添加图片描述


3. 拷贝构造 + 赋值重载

A func2()
{
	A aa(6);
	return aa;
}

int main()
{
	A aa1 = 1;
	cout << endl;

	// 一个表达式中,连续拷贝构造+赋值重载 -> 无法优化(赋值)
	aa1 = func2();
	cout << endl;

	return 0;
}

这是没有优化的版本,按照正常的逻辑走。

请添加图片描述

这是优化之后的版本,少调用了一个拷贝构造。

请添加图片描述


网站公告

今日签到

点亮在社区的每一天
去签到