C++ 类与对象知识点总结
- 基础概念
- 类的定义规范
- 类的访问限定符及封装
- 类的实例化
- C++ 中类的存储方式(方法共享)与结构体内存对齐原则
- this 指针
- 类的 6 个特殊成员函数(默认函数)
- 运算符重载
- 再看构造函数
- static 成员(静态成员)
- 友元函数(friend function)
- C++ 中的内部类(Nested Class)
- 匿名对象(Temporary / Anonymous Object)
- 总结
基础概念
- 类(Class):类是用户定义的数据类型,是对现实世界中事物的抽象。类包含数据成员(属性)和成员函数(方法)。
- 对象(Object):对象是类的实例,是类的具体体现。
作为复习知识点,具体关于类与对象的定义讲解不做过多赘述。
类的定义规范
定义类时建议将类声明放在头文件(.h 或 .hpp)中,将成员函数的实现放在源文件(.cpp)中。
// MyClass.h
#ifndef MYCLASS_H
#define MYCLASS_H
class MyClass {
public:
MyClass(); // 构造函数
~MyClass(); // 析构函数
void display(); // 成员函数声明
private:
int data; // 数据成员
};
#endif
// MyClass.cpp
#include "MyClass.h"
#include <iostream>
MyClass::MyClass() : data(0) {} // 构造函数实现
MyClass::~MyClass() {} // 析构函数实现
void MyClass::display() { // 成员函数实现
std::cout << "Data: " << data << std::endl;
}
这样定义类增加了代码的可读性,更有助于代码的组织和维护。
类的访问限定符及封装
访问限定符
C++ 提供了三种访问限定符:
public:公有成员,可以被类外部访问。private:私有成员,只能被类的成员函数访问。protected:受保护成员,可以被类及其派生类访问。
访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
封装
面向对象有三大特性:封装、继承、多态。
封装是指将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,而仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类,以至于不让用户关心类的内部实现细节。就好比我们购买的电视机、手机等电子产品,用户只需要知道如何使用,而不需要了解其内部的工作原理。
类的实例化
类的实例化是指根据类的定义创建对象的过程。可以通过以下方式实例化对象:
MyClass obj; // 栈上实例化
MyClass* pObj = new MyClass(); // 堆上实例化
C++ 中类的存储方式(方法共享)与结构体内存对齐原则
一、类的存储方式:对象只存“数据”,方法是共享的
在 C++ 中,一个 类的成员函数并不会存储在对象实例内。不论你创建多少个对象,它们共享同一份成员函数代码。这一点与绝大多数面向对象语言相同。
1. 为什么方法不会存储在对象中?
原因在于成员函数是 代码段(text segment)的一部分,属于可执行程序的指令,而对象只负责存储数据。
对象的本质就是:
一块承载成员变量的内存,不包含成员函数,也不包含成员函数的地址。
因此:
class A {
public:
int x;
int y;
void foo() {}
};
无论你创建多少个 A 对象:
A a1, a2;
foo() 函数只会在程序的代码段存在一份。
2. 虚函数例外:需要存一份“指针”
如果类含有虚函数,则对象会多一个隐藏成员:虚函数指针(vptr),指向全局的虚函数表(vtable)。
例如:
class B {
public:
int x;
virtual void func();
};
对象 B 的内存布局为:
| vptr | x |
注意:
- vtable 只有一份(全局)。
- 每个对象仅存一份指针 vptr(通常 8 字节)。
因此虚函数的“共享”方式是:
对象存 vptr → vptr 指向 vtable → vtable 存虚函数地址
但虚函数本身的代码仍然只有一份。
3. 非虚函数与静态函数有什么区别?
- 非虚成员函数:共享,使用对象调用只是自动加隐含的 this 指针。
- 静态成员函数:也是共享,但没有 this 指针,因为不属于对象。
- 虚函数:共享 + 通过 vtable 动态分派。
二、结构体(以及类)内存对齐原则
C++ 中 struct 和 class 在内存布局上是一样的。
结构体的最终大小受到 对齐原则 的影响。
结构体对齐的三条规则
假设结构体中有多个成员,其整体布局遵循以下原则:
规则 1:每个成员按照其类型的对齐要求存放
例如:
| 类型 | 常见对齐要求 |
|---|---|
| char | 1 字节 |
| short | 2 字节 |
| int / float | 4 字节 |
| long / 指针 / double | 8 字节(64 位系统) |
举例:
struct S {
char c; // 1字节
int x; // 需要4字节对齐
};
布局为:
| c | padding(3 bytes) | x |
规则 2:结构体的总大小是其最大成员对齐数的倍数
例如前面的 S:
- 最大对齐数为 4(来自 int)
- 成员总共:1 + 3 + 4 = 8
- 8 刚好是 4 的倍数 → 不需要额外 padding
规则 3:结构体的嵌套对齐遵循相同原则
struct A {
char a; // 1
int x; // 4
}; // A 的大小是 8(上面例子)
struct B {
char b; // 1
A a; // 需要按 A 的对齐(4字节)对齐
double d; // 8
};
B 的对齐过程:
b (1 byte)
padding (3 bytes)
a (size 8, align 4)
d (8 bytes)
最终大小需要是 最大成员对齐数 8 的倍数:
总大小 = 1 +3 +8 + 8 = 20 → 下一个 8 的倍数是 24
所以 sizeof(B) = 24。
三、为什么需要内存对齐?
主要原因:
性能原因
CPU 通常按字对齐访问内存,如果数据跨字边界,会导致多次读取甚至硬件异常。硬件限制
某些 CPU 不允许非对齐访问(如某些 ARM 芯片)。平台 ABI 规范要求
保证二进制兼容性和函数调用规则一致。
四、如何改变结构体对齐方式?
1. 使用 #pragma pack(n)
#pragma pack(push, 1)
struct S {
char c;
int x;
};
#pragma pack(pop)
这样结构体按 1 字节对齐,大小变成 5。
但要注意:
pack 会影响性能甚至导致某些平台崩溃
建议谨慎使用,并用于跨平台协议、网络包解析等场景。
this 指针
在类的非静态成员函数中,隐含有一个名为 this 的指针,指向调用该成员函数的对象本身。通过 this 指针,可以访问对象的成员变量和其他成员函数。
class MyClass {
public:
void setData(int data) {
this->data = data; // 使用 this 指针访问成员变量
}
private:
int data;
};
实际上, setData 函数的定义在编译后会被转换为:
void MyClass::setData(MyClass* this, int data) {
this->data = data;
}
这也是为什么我们看到很多使用 std::function<?> 存储类的非静态成员函数的时候,需要利用 bind 将 this 指针绑定进去的原因:
std::function<void(int)> func = std::bind(&MyClass::setData, this, std::placeholders::_1);
因此,如果我们调用某个类的成员函数时,如果该成员函数没有利用任何类中的成员变量或其他成员函数,我们可以通过下面的方式访问它:
class FunC {
public:
void call() {
std::cout << "FunC called!" << std::endl;
}
};
int main() {
FunC* func = nullptr;
func->call();
return 0;
}
即便我们不创建 FunC 类的实例对象,直接通过空指针调用 operator() 也是合法的,因为 operator() 并没有访问任何成员变量或其他成员函数,所以不会使用 this 指针。
而我们就有了一个更好的解决方案:将该成员函数定义为静态成员函数,这样就不需要 this 指针了。
class FunC {
public:
static void call() {
std::cout << "FunC called!" << std::endl;
}
};
int main() {
FunC::call(); // 直接通过类名调用静态成员函数
return 0;
}
类的 6 个特殊成员函数(默认函数)
C++ 类在没有显式声明的情况下,编译器会自动生成 6 个默认成员函数(也叫 special member functions)。
编译器可能自动生成 6 个默认成员函数:
- 默认构造函数(Default Constructor)
- 析构函数(Destructor)
- 拷贝构造函数(Copy Constructor)
- 拷贝赋值运算符(Copy Assignment Operator)
- 移动构造函数(Move Constructor)—— C++11
- 移动赋值运算符(Move Assignment Operator)—— C++11
1. 默认构造函数
A::A();
当类中没有用户定义的构造函数时自动生成。
2. 析构函数
A::~A();
负责对象生命周期结束时的清理工作;如果你没有写,编译器会生成一个默认空析构。
3. 拷贝构造函数
A::A(const A& other);
逐个复制成员(浅拷贝)。
只要你使用过“按值传参/返回”或 A b = a; ,它就会被调用。
4. 拷贝赋值运算符
A& operator=(const A& other);
当你做:
a = b;
时被调用,默认行为也是浅拷贝。
5. 移动构造函数(C++11)
A::A(A&& other);
当你写:
A b = std::move(a);
时调用。
默认实现是成员的逐个移动(如果成员支持移动)。
6. 移动赋值运算符(C++11)
A& operator=(A&& other);
默认移动赋值是逐个对成员调用移动赋值。
(1)编译器不一定会生成全部 6 个,只会在需要时生成
例如:
- 只要你定义了任意一个构造函数 → 不会自动生成默认构造函数
- 类中包含不可拷贝成员(如
std::mutex)→ 不会生成拷贝构造/拷贝赋值 - 自定义了拷贝构造 → 不会生成移动构造(被禁用)
(2)Rule of Three / Rule of Five / Rule of Zero
Rule of Three:只要重写了
- 析构
- 拷贝构造
- 拷贝赋值
就必须把另外两个也写上。
Rule of Five(C++11)加入了移动语义
→ 可能需要写满 5 个(除了默认构造)Rule of Zero
不需要手写特殊成员函数 → 完全交给编译器(最推荐)。
C++ 类默认有 6 个特殊成员函数:默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值。编译器按需生成,不一定全部生成,且用户显式定义部分会阻止其他默认生成。
运算符重载
运算符重载(Operator Overloading)允许对类对象使用类似内置类型的运算符,例如 +、=、==、[] 等,使对象具备自然的语义表达能力,是 C++ 面向对象设计中最重要的特性之一。
C++ 运算符重载本质上是通过定义特殊格式的函数来扩展已有运算符的含义。
一、哪些运算符可以重载?
几乎所有运算符都可重载,常见的有:
- 算术:
+ - * / % - 关系:
== != < > <= >= - 逻辑:
&& || - 位运算:
<< >> & | ^ - 下标:
operator[] - 函数调用:
operator() - 解引用:
operator* - 前后置自增:
operator++ - 输入输出流:
operator<< / operator>> - 赋值:
operator= - 移动赋值:
operator=(T&&)
不能重载的运算符:
| 运算符 | 原因 |
|---|---|
. |
成员访问语义不可改变 |
?: |
三目运算符语法固定 |
:: |
作用域语义不可改变 |
sizeof |
编译期操作 |
typeid |
RTTI 固定 |
.* |
成员指针语义特殊 |
二、成员函数 vs 全局函数
运算符重载有两种写法:
1. 成员函数形式
class A {
public:
A operator+(const A& rhs) const;
};
等价于:
A::operator+(A* this, const A& rhs)
特点:
- 左操作数必须是该类对象
- 最常用(如赋值、下标、函数调用)
2. 全局函数形式
A operator+(const A& lhs, const A& rhs);
特点:
- 左右操作数都不必是类成员
- 用于“对称运算”,典型如输入输出流:
std::ostream& operator<<(std::ostream& os, const A& a);
三、常见运算符重载示例
(1)重载 +(常用)
class Vec {
public:
int x, y;
Vec(int x=0, int y=0) : x(x), y(y) {}
Vec operator+(const Vec& rhs) const {
return Vec(x + rhs.x, y + rhs.y);
}
};
使用:
Vec a(1, 2), b(3, 4);
Vec c = a + b; // (4, 6)
(2)重载 ==、!=
bool operator==(const Vec& rhs) const {
return x == rhs.x && y == rhs.y;
}
bool operator!=(const Vec& rhs) const {
return !(*this == rhs);
}
注意一定要使用 const 修饰。
(3)重载 <<(必须是全局函数)
std::ostream& operator<<(std::ostream& os, const Vec& v) {
return os << "(" << v.x << ", " << v.y << ")";
}
(4)重载赋值 operator=
编译器默认生成“浅拷贝”,多数情况够用。若类拥有资源(如 new / 文件句柄),必须自己实现:
class A {
int* p;
public:
A(int v) : p(new int(v)) {}
~A() { delete p; }
A& operator=(const A& rhs) {
if (this == &rhs) return *this;
*p = *(rhs.p);
return *this;
}
};
(5)重载下标 operator[]
int& operator[](size_t i) {
return data[i];
}
(6)重载前后置自增
// 前置 ++a
A& operator++() {
++value;
return *this;
}
// 后置 a++
A operator++(int) {
A tmp(*this);
++value;
return tmp;
}
注意后置需要用一个 int 虚参 区分。
四、为什么推荐使用 const?
因为运算符通常不应该修改对象本身,例如:
Vec operator+(const Vec& rhs) const;
- 右值对象传参必须
const & - 自身也应为
const,保证操作安全
五、可读性与语义性原则
运算符重载必须遵循:
行为应符合该运算符的直觉含义
举例:
+应表示相加,而不是发网络包==应判断相等,而不是写日志<<应输出,而不是进行二进制序列化
否则会导致代码难以维护,是反模式。
六、何时不能重载?
当重载会破坏语义或让代码难以阅读时。
例如:
- 重载
operator&&和operator||不会改变短路行为 - 重载
operator,会导致难以理解 - 重载比较运算符导致排序语义混乱
重载是“能力”,不是“义务”。
- 运算符重载通过特殊函数增强类的表达能力。
- 可用成员函数或全局函数实现。
- 常见的重载有:
+、==、[]、<<、=、++等。 - 重载应保持直觉语义,代码可维护性优先。
- 若类管理资源(如裸指针),需要重写拷贝/赋值运算符。
再看构造函数
下面给你一份 面试向 + 博客可直接放上去 的 C++ 初始化列表讲解,结构清晰、无废话。
C++ 初始化列表(Initializer List)详解
在 C++ 中,类的构造函数可以使用“初始化列表”对成员变量进行初始化,它是 C++ 对象构造过程中非常关键的一个概念。
1. 什么是初始化列表?
初始化列表是 构造函数冒号后的一段初始化语法,用于在对象创建时直接初始化成员变量:
class A {
public:
int x;
int y;
A(int a, int b) : x(a), y(b) {} // 初始化列表
};
其语法格式:
ClassName(...) : member1(value1), member2(value2), ... { }
2. 为什么使用初始化列表?
初始化列表的本质作用是:
在进入构造函数体({})之前完成成员的初始化。
相比于在构造函数体内赋值,它有以下优势:
✓ (1) 可以初始化 const 成员
因为 const 成员必须在创建时初始化,不能再赋值。
class A {
const int x;
public:
A(int a) : x(a) {} // OK
};
✓ (2) 可以初始化引用成员
引用必须在声明时绑定,不能再修改。
class A {
int& ref;
public:
A(int& r) : ref(r) {} // 必须用初始化列表
};
✓ (3) 初始化内置类型比赋值更高效
A(int a) : x(a) {} // 直接初始化
A(int a) { x = a; } // 先默认构造,再赋值
第二种方式会多一次操作。
✓ (4) 初始化自定义类型成员时避免重复构造
class B { ... };
class A {
B b;
public:
A() : b(10) {} // 直接用参数构造
};
如果你不用初始化列表:
A() { b = B(10); } // 先默认构造 b,再赋值一次
会有不必要的临时对象和赋值操作,效率更低。
3. 初始化顺序由 成员声明顺序 决定
这是一个常考点!
class A {
int y;
int x;
public:
A() : x(1), y(x) {} // 你以为 y = 1,但实际不一定!
};
成员初始化顺序 与列表书写顺序无关,而是:
以成员在类中定义的顺序进行初始化。
也就是:
- y 初始化
- x 初始化
所以 y 的值是 未定义的垃圾值。
4. 使用初始化列表的典型场景总结
| 类成员类型 | 是否必须/强烈建议用初始化列表 |
|---|---|
| const 成员 | 必须 |
| 引用成员 | 必须 |
| 无默认构造的成员对象 | 必须 |
| STL 容器 | 建议 |
| 类类型 | 建议(避免重复构造) |
| 内置类型 | 可选(但更高效) |
5. initializer_list(标准库类型)不是初始化列表
容易混淆!
- 初始化列表 (initializer list):构造函数
: xxx(y)语法。 std::initializer_list<T>:用于接收{1,2,3}这种花括号列表。
例如:
class A {
public:
A(std::initializer_list<int> list) {
for (auto v : list) { ... }
}
};
A a = {1, 2, 3}; // 调用 initializer_list 构造
两者只是名字相似而已。
初始化列表用于在构造函数体执行前初始化成员变量。它可以初始化 const 成员、引用成员、无默认构造的成员对象,并且比构造后赋值更高效。成员初始化顺序由成员在类中的声明顺序决定,与初始化列表书写顺序无关。
- 所有成员在进入构造函数体之前就必须完成初始化
- 若你不用初始化列表,编译器会 先默认构造 成员,然后在构造函数体内再执行一次 赋值
- 初始化列表让你可以在一开始就用正确的参数初始化对象,从而 避免无意义的默认构造 + 赋值
因此:
初始化列表不是额外的赋值行为,而是改变了成员变量的初始化方式,让成员在构造函数体之前就以你指定的方式完成构造。
explicit 关键字
explicit 是 C++ 中用于 禁止隐式类型转换 的关键字,它通常用于构造函数,偶尔也用于转换运算符。其核心作用可以总结为一句话:
防止编译器自动把某类型转换成目标类型,必须显式调用构造函数。
一、为什么需要 explicit?
有些构造函数可以被用来执行隐式转换,例如:
class A {
public:
A(int x) {} // 可隐式转换
};
void foo(A a) {}
foo(10); // 这里会发生 int → A 的隐式转换
对于不应该自动转换的场景,这种隐式行为可能导致意料之外的调用、降低类型安全。
二、explicit 的基本用法
给构造函数加上 explicit:
class A {
public:
explicit A(int x) {}
};
效果:
foo(10); // ❌ 错误,不允许隐式转换
foo(A(10)); // ✔ 必须显式构造
你必须写成:
foo(A(10));
或者:
A a(10);
foo(a);
三、 explicit 用在单参数构造函数(及可视为单参数的多参数)
通常:
- 单参数构造函数(或可视为单参数,如具有默认值)建议加 explicit
- 否则容易被当成“转换构造函数”
例如:
explicit A(int x, int y = 0);
虽然定义中有两个参数,但因为第二个有默认值,依旧可以被当成隐式转换,最好加上 explicit。
四、explicit 也能修饰类型转换运算符
用于禁止隐式转换到其他类型,例如:
class A {
public:
explicit operator int() const { return 42; }
};
效果:
A a;
int x = a; // ❌ 错误,不允许隐式转换
int y = int(a); // ✔ 必须显式转换
五、什么时候应该使用 explicit?
建议规则:
- 所有能被当成隐式转换的构造函数(单参数/有默认值)都应加 explicit
- 除非你明确希望允许隐式转换(例如
std::string接受const char*) - 对转换运算符也建议加 explicit,不要轻易让类型互相隐式转化
六、标准库中的常见例子
如 std::chrono::duration、std::unique_ptr 的构造都大量使用 explicit 来避免误用。
例如:
explicit unique_ptr(pointer p) noexcept;
避免这样危险的行为:
void f(unique_ptr<int>);
f(NULL); // 如果不是 explicit,将会导致语义模糊
explicit 用于构造函数和转换运算符,禁止隐式类型转换,避免意外调用和类型不安全。单参数构造函数几乎都应加 explicit,除非你明确需要隐式转换。
static 成员(静态成员)
static 成员是 C++ 中用于在类作用域内声明“共享资源”的关键机制。它可以修饰 成员变量 和 成员函数,其本质作用是:
让类的某些成员不属于对象,而属于整个类本身。
也就是说 —— 所有对象共享一份 static 成员。
一、static 成员变量
1. 特点
- 属于类本身,而不是某个对象
- 只存储一份,所有对象共享
- 不受对象生命周期影响
- 必须在类外进行一次性定义(除非是
constexpr static整型) - 可以通过
类名::变量或对象.变量访问,但本质相同
类定义:
class A {
public:
static int count; // 声明
};
类外定义(必须有且仅有一次):
int A::count = 0; // 定义 + 初始化
2. 使用场景
- 统计对象数量
- 全局共享配置
- 线程池、缓存、ID 分配器等需要全局共享状态的情况
二、static 成员函数
1. 特点
- 不依赖任何对象,不包含隐含的 this 指针
- 只能访问 static 成员,不能访问非 static 成员
- 可通过
类名::函数()调用,也可通过对象调用(不推荐)
示例:
class A {
public:
static int count;
static void inc() { count++; } // 可以访问 static 成员
void foo() { /* 可以访问全部成员 */ }
};
调用:
A::inc(); // 推荐
2. 为什么 static 成员函数不能访问普通成员?
因为普通成员依赖于对象(需要 this 指针),而 static 函数属于类,没有 this。
你无法在不知道“哪个对象”的情况下访问对象成员。
三、内存位置:static 成员存在哪里?
static 成员变量不存放在对象内,而是放在程序的 静态区(静态存储期)。
因此对象的 sizeof 不包含 static 成员。
例如:
class A {
static int x;
int y;
};
sizeof(A) == sizeof(int)
四、static 成员与普通成员的区别
| 特性 | static 成员 | 普通成员 |
|---|---|---|
| 所属 | 类 | 对象 |
| 存储空间 | 静态存储区 | 对象内存 |
| 生命周期 | 程序开始到结束 | 对象创建到销毁 |
| 访问方式 | 类名::成员 | 对象.成员 |
| 是否需要 this | 否 | 是 |
五、static 的常见用法示例
1. 实现单例模式
class Singleton {
public:
static Singleton& instance() {
static Singleton s; // 局部 static
return s;
}
};
2. 统计对象数量
class A {
public:
static int count;
A() { count++; }
};
int A::count = 0;
3. 工厂函数、工具类函数
class Math {
public:
static int add(int a, int b) { return a + b; }
};
六、static 与 const、constexpr 的组合
1. const static 成员
只需在类外定义:
class A {
static const int x = 10; // 可以在类内给出初值(整型)
};
2. constexpr static 成员
如果是字面值类型,可类内初始化,不需要类外定义:
class A {
static constexpr int x = 20; // 不再需要 A::x 在类外定义
};
static 成员属于类而不是对象,所有对象共享一份。
static 成员变量必须类外定义,并存储在静态区;static 成员函数没有 this,只能访问 static 成员。
常用于共享资源、对象计数、工具函数、单例等。
友元函数(friend function)
友元函数是 C++ 为了允许外部函数访问类的私有成员而设计的机制。通过 friend 关键字,某个函数(或类)可以被声明为某个类的“朋友”,从而获得访问其 private 和 protected 成员的权限。
一、什么是友元函数?
友元函数不是类的成员函数,而是一个普通函数,只不过能够访问该类的私有部分。
声明方式:
class A {
private:
int x;
friend void foo(A& a); // foo 是 A 的友元
};
实现:
void foo(A& a) {
std::cout << a.x; // 可以访问 private 成员
}
二、友元函数的特点
- 不是类成员函数(没有 this 指针)
- 却可以访问类的私有成员和保护成员
- 不具备继承性:子类不会继承友元关系
- 不具备传递性:A 的朋友不自动成为 B 的朋友
- 不具备对称性:A 是 B 的朋友,不代表 B 是 A 的朋友
三、为什么需要友元?
友元主要用于:
1. 某些操作需要访问内部数据,但不适合做成员函数
例如算术运算符重载:
class Point {
private:
int x, y;
public:
friend Point operator+(const Point& a, const Point& b);
};
Point operator+(const Point& a, const Point& b) {
return Point(a.x + b.x, a.y + b.y);
}
这是常见用法,因为两个对象相加不适合作为成员函数(需要两个参数)。
2. 某些工具函数、工厂函数需要访问对象的内部状态
比如调试输出:
class A {
private:
int x;
friend std::ostream& operator<<(std::ostream& os, const A& a);
};
std::ostream& operator<<(std::ostream& os, const A& a) {
return os << a.x;
}
3. 实现两个类之间的特殊协作关系
例如:
class B;
class A {
int x;
friend class B; // B 是 A 的友元类
};
class B {
public:
void f(A& a) {
a.x = 10; // 直接访问 private
}
};
四、友元函数的分类
- 普通友元函数
- 友元类(整个类都能访问)
- 友元成员函数(仅某个成员函数是朋友)
示例:让 B 的某个成员函数成为友元:
class B;
class A {
private:
int x;
friend void B::func(A&); // 只好友元成员函数
};
五、友元的优点与缺点
优点
- 在不破坏封装的前提下允许必要的访问权限
- 可以让运算符重载具备更自然的语法(如
a + b) - 加强类之间的协作灵活性
缺点
- 破坏封装性(虽然可控)
- 滥用友元会导致代码耦合严重
- 可读性和维护性降低
友元函数是能访问类私有成员的非成员函数,通过 friend 声明获得访问权限。常用于运算符重载、调试输出和类之间的紧密协作。友元不具备继承性、传递性和对称性。
C++ 中的内部类(Nested Class)
在 C++ 中,内部类(Nested Class) 是定义在另一个类内部的类。
它本质上仍是一个独立的类,但其作用域位于外部类之内,因此具备更强的逻辑关联性与封装性。
下面从语法、访问控制、使用场景等方面进行讲解。
1. 基本定义方式
内部类的定义非常直接:把一个完整的 class 或 struct 写在另一个类的内部即可。
class Outer {
public:
class Inner { // 这是内部类
public:
void hi();
};
void foo();
};
内部类 Inner 的作用域属于 Outer,因此外部使用要加作用域限定:
Outer::Inner obj;
obj.hi();
2. 内部类与外部类的关系
虽然内部类定义在外部类之内,但:
内部类不是外部类的成员对象,也不会自动拥有外部类的 this 指针。
它跟下面这样定义几乎等价(只是在作用域不同):
namespace OuterScope { class Inner; }
因此:
- 内部类不会隐式访问外部类成员
- 外部类也不会自动访问内部类成员
- 二者之间没有自动关联关系
3. 访问控制规则
内部类依然遵循 C++ 的访问控制语义:
- 如果内部类定义在
public:则可以被外部访问 - 如果定义在
private:或protected:则只能在外部类内部访问
示例:
class Outer {
private:
class PrivateInner { }; // 外部无法访问
public:
class PublicInner { }; // 外部可访问
};
int main() {
Outer::PublicInner a; // ✔ OK
// Outer::PrivateInner b; // ❌ 编译错误
}
4. 内部类访问外部类的成员?
默认不能访问外部类成员:
class Outer {
int x = 42;
public:
class Inner {
public:
void foo() {
// std::cout << x; // ❌ 错:Inner 没有 Outer 的 x
}
};
};
如果需要访问外部类成员,需要:
- 将内部类声明为外部类的
friend - 或者将外部类实例传进去
例如:
class Outer {
int x = 42;
public:
class Inner {
public:
void foo(Outer& o) {
std::cout << o.x << std::endl; // ✔ 访问 Outer 的成员
}
};
};
5. 外部类访问内部类的 private 成员
外部类也不能自动访问内部类的 private 成员:
class Outer {
public:
class Inner {
private:
int y = 10;
};
void func() {
Inner in;
// std::cout << in.y; // ❌ 错,不能访问内部类的 private
}
};
如果需要访问:
class Outer {
public:
class Inner {
private:
int y = 10;
friend class Outer; // ✔ 外部类可以访问 y
};
void func() {
Inner in;
std::cout << in.y; // ✔ OK
}
};
6. 内部类的典型用途
内部类通常用于表达 强关联关系 或 隐藏细节。
(1)隐藏实现细节(Pimpl Idiom)
class Widget {
class Impl; // 声明内部类
Impl* p; // 指向内部实现的指针
};
(2)作为外部类的数据结构
例如外部类实现树、图等结构时,将节点 Node 定义为内部类:
class Tree {
public:
class Node {
public:
int value;
Node* left;
Node* right;
};
};
(3)起名字防冲突
把辅助类放到外部类中,避免污染全局命名空间。
7. 内部类的静态成员
内部类也可以拥有自己的静态成员,与普通类完全一致:
class Outer {
public:
class Inner {
public:
static int count;
};
};
int Outer::Inner::count = 0;
8. 内部类的大小与布局
内部类的大小、布局、对齐方式完全独立,不依赖外部类,像普通类一样处理。
内部类(Nested Class)本质上是放在外部类作用域中的独立类,具备以下特点:
- 不自动持有外部类的
this - 外部访问需
Outer::Inner - 访问控制完全依赖
public/private/protected - 可以用于封装内部实现或构建复杂数据结构
- 与外部类之间没有自动的成员访问权限(需要 friend)
匿名对象(Temporary / Anonymous Object)
在 C++ 中,匿名对象 是指“没有变量名的对象”,通常是表达式求值过程中临时创建的 临时对象(temporary object) 或 临时值(prvalue)。
匿名对象生命周期通常很短,在表达式结束后销毁,但在某些情况下会延长生命周期。
下面从定义、典型使用场景、生命周期规则等方面进行讲解。
1. 什么是匿名对象?
当你创建一个对象,却没有为它提供变量名时,这个对象就叫 匿名对象。
最常见的例子:
class Test {
public:
Test(int) {}
};
Test(10); // ← 创建匿名对象,表达式结束后销毁
它调用构造函数,但没有变量名,也无法再访问。
2. 匿名对象的特点
✔ 没有变量名,只在当前表达式中有效
✔ 通常是 prvalue(纯右值)
✔ 生命周期短,表达式结束时销毁
✔ 可以作为函数的返回值、参数、临时中间值
✔ 能触发构造函数、移动构造函数或优化(RVO)
3. 匿名对象的典型使用场景
(1)临时返回值 (很常见)
Test func() {
return Test(10); // 匿名对象
}
大多数编译器会进行 RVO(返回值优化)避免生成临时对象。
(2)临时参数
void foo(const Test& t) {}
foo(Test(20)); // 匿名对象作为参数
这个匿名对象生命周期会延长到 foo 调用结束。
(3)运算表达式中的中间值
auto x = std::string("hello") + std::string("world");
std::string("hello") 和 std::string("world") 都是匿名对象。
(4)调用链式 API(常见于数学类、图形类)
Matrix().translate(2,3).rotate(30).scale(2);
每一步都构造匿名对象并继续修改。
4. 匿名对象的生命周期规则
(1)普通情况下:表达式结束销毁
Test(10); // 语句结束,临时对象销毁
(2)绑定到 const 引用 会延长生命周期
const Test& r = Test(10); // 匿名对象绑定到 const 引用
// 生命周期延长到 r 的生命周期
这种规则是 C++ 特有的“临时对象延长生命周期”。
但:
❌ 非 const 引用不能绑定匿名对象
❌ 指针也不会延长匿名对象的生命周期
(3)函数返回值中的临时对象(RVO)
return Test(10);
编译器会优化掉临时对象,不会真实构造和销毁它。
5. 匿名对象与构造行为
匿名对象可能触发:
- 构造函数
- 复制构造(可能被优化掉)
- 移动构造(可能被优化掉)
- 析构函数
可以用日志验证:
class T {
public:
T() { std::cout << "ctor\n"; }
~T() { std::cout << "dtor\n"; }
};
T(); // 输出:ctor → dtor
6. 匿名对象优点
✔ 使用方便,不需要命名
✔ 避免作用域污染
✔ 临时值,更利于编译器优化(RVO/NRVO)
✔ 用于链式编程、数学对象、表达式构造非常自然
✔ 可直接绑定到 const 引用延长生命周期
7. 匿名对象缺点
✘ 不能再次访问(除非绑定到 const 引用)
✘ 容易产生短生命周期引用问题
✘ 若涉及大量构造/析构,会有额外开销(但现代 C++优化强)
总结
匿名对象(temporary object)是没有变量名的临时对象,通常存在于表达式求值中,生命周期短,但可以被 “const 引用” 延长。
主要特征:
- 没有变量名
- 通常是右值(prvalue)
- 自动创建自动销毁
- 常用于函数返回、临时参数、中间运算
- 能触发或优化构造与析构
它在 C++ 编程中非常常见,也是理解 右值、引用、移动语义 的基础。
如需,我可以继续讲解:
- 匿名对象与右值引用的关系
- 匿名对象触发移动构造的规则
- 匿名对象在性能优化中的应用(特别是 RVO/NRVO)