C++ 类和对象入门(六):友元、内部类、匿名对象和编译器优化

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 ✨ 数据即知识,压缩即智能
目录
- [C++ 类和对象入门(六):友元、内部类、匿名对象和编译器优化](#C++ 类和对象入门(六):友元、内部类、匿名对象和编译器优化)
-
- 前言
- 一、友元:有权限访问私有成员的外部角色
-
- [1.1 为什么需要友元?](#1.1 为什么需要友元?)
- [1.2 什么是友元函数?](#1.2 什么是友元函数?)
- [1.3 一个函数可以成为多个类的友元](#1.3 一个函数可以成为多个类的友元)
- [1.4 友元函数不受 public/private 位置影响](#1.4 友元函数不受 public/private 位置影响)
- 二、友元类:整个类都获得访问权限
-
- [2.1 什么是友元类?](#2.1 什么是友元类?)
- [2.2 友元关系是单向的](#2.2 友元关系是单向的)
- [2.3 友元关系不能传递](#2.3 友元关系不能传递)
- [2.4 友元要少用](#2.4 友元要少用)
- 三、内部类:定义在类里面的类
-
- [3.1 什么是内部类?](#3.1 什么是内部类?)
- [3.2 内部类是一个独立的类](#3.2 内部类是一个独立的类)
- [3.3 内部类默认是外部类的友元](#3.3 内部类默认是外部类的友元)
- [3.4 什么时候适合使用内部类?](#3.4 什么时候适合使用内部类?)
- 四、匿名对象:只活一行的临时对象
-
- [4.1 什么是匿名对象?](#4.1 什么是匿名对象?)
- [4.2 匿名对象的生命周期](#4.2 匿名对象的生命周期)
- [4.3 匿名对象和 Date d() 的区别](#4.3 匿名对象和 Date d() 的区别)
- 五、对象拷贝时的编译器优化
-
- [5.1 为什么同一段代码在不同编译器下输出不同?](#5.1 为什么同一段代码在不同编译器下输出不同?)
- [5.2 连续构造和拷贝可能被合并](#5.2 连续构造和拷贝可能被合并)
- [5.3 传值返回也可能被优化](#5.3 传值返回也可能被优化)
- [5.4 优化不改变语义,只减少中间过程](#5.4 优化不改变语义,只减少中间过程)
- 六、这几个知识点之间的关系
- 七、本文总结
前言
前一篇我们讲了初始化列表、explicit 和 static 成员。
这一篇继续梳理 C++ 类和对象几个补充知识点:
- 友元
- 内部类
- 匿名对象
- 对象拷贝时的编译器优化
这些内容不像构造函数、析构函数那样每天都会写,但它们在理解 C++ 类的边界、对象生命周期、编译器行为时非常重要。
其中友元和内部类主要和"封装边界"有关。
匿名对象和编译器优化主要和"临时对象生命周期、拷贝优化"有关。
一、友元:有权限访问私有成员的外部角色
1.1 为什么需要友元?
在 C++ 中,private 成员不能被类外直接访问。
这是封装的基本规则。
例如:
cpp
class Date
{
private:
int _year;
int _month;
int _day;
};
类外不能直接写:
cpp
cout << d._year << endl;
这很好,因为它保护了对象内部状态。
但有些场景下,外部函数确实需要访问类的私有成员。
比如我们重载流插入运算符:
cpp
cout << d;
通常会把 operator<< 写成全局函数,而不是成员函数。
但是全局函数不是类成员,不能直接访问 _year、_month、_day。
这时就可以使用友元。
1.2 什么是友元函数?
友元函数是在类内部用 friend 声明的外部函数。
它不是类的成员函数,但可以访问类的私有成员和保护成员。
示例:
cpp
class Date
{
friend ostream& operator<<(ostream& out, const Date& d);
private:
int _year;
int _month;
int _day;
};
这里的 operator<< 是 Date 的友元函数。
它可以在函数体中访问:
cpp
d._year
d._month
d._day
注意:
友元声明只是授权,不是成员函数声明。
友元函数不属于这个类。
它没有 this 指针。
它的调用方式也不是:
cpp
d.operator<<(...)
而是普通全局函数调用形式。

1.3 一个函数可以成为多个类的友元
有时候一个外部函数需要同时访问两个类的私有成员。
比如:
cpp
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;
};
这个 func 同时是 A 和 B 的友元函数。
因此它既能访问 A 的私有成员,也能访问 B 的私有成员。
cpp
void func(const A& aa, const B& bb)
{
cout << aa._a1 << endl;
cout << bb._b1 << endl;
}
这里还有一个细节:
如果 A 的友元声明里用到了 B,但 B 还没定义,就需要提前写前置声明:
cpp
class B;
否则编译器不认识 B。
1.4 友元函数不受 public/private 位置影响
友元声明可以写在类中的任何访问区域里。
比如写在 public、private、protected 下面都可以。
它不受访问限定符影响。
因为 friend 本身只是告诉编译器:
这个外部函数被授予访问当前类私有成员的权限。
二、友元类:整个类都获得访问权限
2.1 什么是友元类?
除了友元函数,还有友元类。
如果在类 A 中声明:
cpp
friend class B;
意思是:
B 类中的所有成员函数,都可以访问 A 类的私有成员和保护成员。
示例:
cpp
class A
{
friend class B;
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
void func1(const A& aa)
{
cout << aa._a1 << endl;
}
void func2(const A& aa)
{
cout << aa._a2 << endl;
}
};
因为 B 是 A 的友元类,所以 B 的成员函数可以访问 A 的私有成员。
2.2 友元关系是单向的
友元关系不是互相默认开放。
如果 A 声明:
cpp
friend class B;
表示 B 可以访问 A 的私有成员。
但这并不表示 A 可以访问 B 的私有成员。
也就是说:
A 把 B 当朋友,不代表 B 也把 A 当朋友。
友元关系是单向的。

2.3 友元关系不能传递
友元关系也不能传递。
如果:
cpp
A 是 B 的友元
B 是 C 的友元
不能推出:
cpp
A 是 C 的友元
友元授权只对明确声明的关系生效。
这点很像现实生活中"我朋友的朋友不一定是我的朋友"。
2.4 友元要少用
友元确实方便。
但它也会带来问题:
友元会突破封装,增加类之间的耦合。
如果一个类的很多内部细节都依赖友元访问,说明这个类的接口设计可能不够合理。
所以友元适合在确实必要的场景使用,比如:
- 流插入、流提取运算符重载;
- 两个类关系非常紧密;
- 某些工具类需要访问内部实现。
不要把友元当成偷懒访问私有成员的工具。
三、内部类:定义在类里面的类
3.1 什么是内部类?
如果一个类定义在另一个类的内部,这个类就叫内部类。
cpp
class A
{
public:
class B
{
public:
void Foo()
{
}
};
};
这里 B 就是 A 的内部类。
在类外使用时,需要通过类域访问:
cpp
A::B b;
3.2 内部类是一个独立的类
这一点很重要。
内部类虽然定义在外部类里面,但它仍然是一个独立的类。
外部类对象中不会自动包含内部类对象。
例如:
cpp
class A
{
public:
class B
{
private:
int _b;
};
private:
int _a;
};
sizeof(A) 只和 A 自己的成员有关。
不会因为 A 里面定义了 B,就自动把 B 的成员算进 A 对象里。
可以理解成:
内部类只是名字被放进了外部类的类域中,并不等于外部类对象包含了它。
3.3 内部类默认是外部类的友元
内部类有一个特别点:
内部类默认是外部类的友元类。
也就是说,内部类的成员函数可以访问外部类的私有成员。
例如:
cpp
class A
{
private:
static int _k;
int _h = 1;
public:
class B
{
public:
void foo(const A& a)
{
cout << _k << endl;
cout << a._h << endl;
}
};
};
这里 B 是 A 的内部类。
B::foo 可以访问 A 的静态成员 _k,也可以通过对象 a 访问 A 的普通私有成员 _h。
注意,访问非静态成员仍然需要具体对象:
cpp
a._h
因为 _h 属于某个具体的 A 对象。

3.4 什么时候适合使用内部类?
内部类本质上也是一种封装手段。
如果一个类主要是给另一个类内部使用的,而且和外部类关系非常紧密,就可以考虑定义成内部类。
例如:
- 某个容器内部的迭代器类;
- 某个算法类内部的辅助节点类;
- 某个类专用的工具类型。
如果这个内部类不希望外部随便使用,还可以把它放到 private 区域。
这样它就成了外部类的专属实现细节。
四、匿名对象:只活一行的临时对象
4.1 什么是匿名对象?
平时我们创建对象,一般会给对象取名字:
cpp
A aa1;
A aa2(2);
这种叫有名对象。
匿名对象没有名字,写法是:
cpp
A();
A(1);
它表示临时创建一个对象,用完就销毁。
4.2 匿名对象的生命周期
匿名对象的生命周期通常只在当前这一行。
例如:
cpp
A();
A(1);
执行完这一行之后,对象就会被销毁,析构函数会被调用。
所以匿名对象适合"临时用一下"的场景。
比如:
cpp
Solution().Sum_Solution(10);
这里:
cpp
Solution()
创建了一个匿名对象。
然后马上调用它的成员函数:
cpp
.Sum_Solution(10)
调用结束后,这个匿名对象就销毁了。

4.3 匿名对象和 Date d() 的区别
前面学构造函数时提到过:
cpp
Date d();
这不是创建对象,而是函数声明。
但是:
cpp
Date();
这是创建匿名对象。
区别在于:
cpp
Date d();
有一个名字 d,会被解析成函数声明。
cpp
Date();
没有对象名,就是创建一个临时匿名对象。

五、对象拷贝时的编译器优化
5.1 为什么同一段代码在不同编译器下输出不同?
学习拷贝构造时,我们经常会通过打印构造、拷贝构造、析构来观察对象行为。
例如:
cpp
class A
{
public:
A(int a = 0)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
{
cout << "A(const A& aa)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
};
然后测试:
cpp
f1(A(2));
A aa2 = f2();
aa1 = f2();
有时你会发现:
不同编译器、不同编译模式下,输出结果不完全一样。
这是因为现代编译器会尽量减少不必要的临时对象和拷贝构造。
这种优化通常叫:
拷贝消除,或者返回值优化。
5.2 连续构造和拷贝可能被合并
比如:
cpp
f1(A(2));
不优化时,可能是:
- 构造临时对象
A(2); - 再拷贝构造形参;
- 函数结束析构。
但编译器可能会直接优化成:
直接在形参位置构造对象。
这样就少了一次拷贝构造。
所以你打印出来的构造次数可能比理论分析更少。
5.3 传值返回也可能被优化
看这个函数:
cpp
A f2()
{
A aa;
return aa;
}
不优化时,可能会出现局部对象到临时返回对象的拷贝。
但现代编译器可能会把局部对象和返回对象合并。
如果写:
cpp
A aa2 = f2();
有些编译器甚至会进一步把返回对象和 aa2 合并,直接构造 aa2。
这就是为什么同一个例子在 VS2019、VS2022、GCC 不同设置下可能看到不同输出。
5.4 优化不改变语义,只减少中间过程
要注意:
编译器优化的前提是:
不改变程序最终语义。
它只是尽量减少中间临时对象、拷贝构造和析构的过程。
所以学习时可以先从"不优化"的角度理解完整过程。
理解清楚以后,再知道:
实际编译器可能会把连续的构造、拷贝过程合并掉。
在 Linux 下,如果想观察更原始的构造和拷贝过程,可以尝试关闭拷贝消除相关优化:
bash
g++ test.cpp -fno-elide-constructors
这样有助于观察对象拷贝过程。

六、这几个知识点之间的关系
这一篇内容看起来比较散,但其实可以按两条线理解。
第一条线是封装边界:
private默认不让外部访问内部数据;- 友元允许特定函数或类突破访问限制;
- 内部类把强关联类型放进类域内部;
- 友元和内部类都要控制使用范围,不要滥用。
第二条线是对象生命周期:
- 匿名对象是临时对象,通常只活一行;
- 传值传参、传值返回可能产生临时对象;
- 编译器会尽量优化掉不必要的拷贝;
- 不同编译器和不同编译选项下,构造/拷贝/析构打印结果可能不同。
把这两条线分清楚,这一篇就不会显得零散。
七、本文总结
这一篇主要讲了 C++ 类和对象后半部分的几个补充知识点。
友元:
- 可以让外部函数或其他类访问当前类的私有成员;
- 分为友元函数和友元类;
- 友元关系是单向的,不能传递;
- 友元会破坏封装,所以不宜滥用。
内部类:
- 定义在另一个类内部;
- 是独立的类;
- 受外部类类域和访问限定符限制;
- 内部类默认是外部类的友元类;
- 适合表示强关联的内部辅助类型。
匿名对象:
- 没有名字;
- 生命周期通常只在当前语句;
- 适合临时创建对象并立即使用;
- 要区分
A()和A aa()。
对象拷贝优化:
- 编译器会尽量减少不必要的临时对象和拷贝;
- 连续构造和拷贝可能被合并;
- 传值返回可能触发返回值优化;
- 不同编译器的打印结果可能不同,但语义不变。
友元和内部类是在处理"类的边界",匿名对象和编译器优化是在处理"对象的生命周期"。