文章目录
- 前言
- 第一章:初始化列表------成员变量的"原生"定义
-
- [1\. 什么是初始化列表?](#1. 什么是初始化列表?)
- [2\. 为什么非要用初始化列表?](#2. 为什么非要用初始化列表?)
-
- 必须使用初始化列表的场景
-
- [(1) 引用成员变量 (`reference`)](#(1) 引用成员变量 (
reference)) - [(2) const 成员变量](#(2) const 成员变量)
- [(3) 没有默认构造函数的自定义类型成员](#(3) 没有默认构造函数的自定义类型成员)
- [(1) 引用成员变量 (`reference`)](#(1) 引用成员变量 (
- [3\. 成员变量的初始化顺序(面试高频坑点)](#3. 成员变量的初始化顺序(面试高频坑点))
- 补充:对象创建过程
- [4\. explicit 关键字](#4. explicit 关键字)
- 补充:隐式类型转换
-
- [1\. 原理:构造函数即转换路径](#1. 原理:构造函数即转换路径)
- [2\. 单参数构造函数的隐式转换](#2. 单参数构造函数的隐式转换)
- [3\. 多参数构造函数的隐式转换 (C++11)](#3. 多参数构造函数的隐式转换 (C++11))
- [4\. 隐式转换的限制:只能"走一步"](#4. 隐式转换的限制:只能“走一步”)
- [5\. `explicit` 关键字:禁止隐式转换](#5.
explicit关键字:禁止隐式转换) -
- [单参数的 `explicit`](#单参数的
explicit) - [多参数的 `explicit` (C++11)](#多参数的
explicit(C++11))
- [单参数的 `explicit`](#单参数的
- [6\. 总结图表](#6. 总结图表)
- 补充:对象的初始化方式
-
- [1\. 拷贝初始化](#1. 拷贝初始化)
- [2\. 直接初始化](#2. 直接初始化)
- [3\. 列表初始化 / 统一初始化](#3. 列表初始化 / 统一初始化)
- 特殊情况:默认初始化
- 总结对照表
- [5\. C++11 的成员变量缺省值](#5. C++11 的成员变量缺省值)
-
- [1\. 核心规则:谁是老大?](#1. 核心规则:谁是老大?)
- [2\. 具体流程图解](#2. 具体流程图解)
- [3\. 代码实战演示](#3. 代码实战演示)
- [4\. 编译器视角(发生了什么?)](#4. 编译器视角(发生了什么?))
- 总结
- [第二章:Static 静态成员](#第二章:Static 静态成员)
-
- [2.1 静态成员变量](#2.1 静态成员变量)
-
- [1\. 核心概念](#1. 核心概念)
- [2\. 语法规则](#2. 语法规则)
- [3\. 代码示例](#3. 代码示例)
- [2.2 静态成员函数](#2.2 静态成员函数)
-
- [1\. 核心概念](#1. 核心概念)
- [2\. 因为没有 `this` 指针带来的后果](#2. 因为没有
this指针带来的后果) - [3\. 代码示例](#3. 代码示例)
- [2.3 深入细节与高级特性](#2.3 深入细节与高级特性)
-
- [1\. const static 成员](#1. const static 成员)
- [2\. C++17 的 inline static (新特性)](#2. C++17 的 inline static (新特性))
- [2.4 静态成员函数不能给缺省值](#2.4 静态成员函数不能给缺省值)
-
- 第一步:理解"头文件会被复印"(类一般在头文件中声明)
- 第二步:如果允许在类里赋值,会发生什么灾难?
- 第三步:正确的做法(声明与定义分离)
- 只要记住这一个底层的逻辑:
- [那为什么 `const` 整数可以?](#那为什么
const整数可以?)
- [2.5 总结与对比表](#2.5 总结与对比表)
-
- [什么时候使用 static?](#什么时候使用 static?)
- [C++ 规定:](#C++ 规定:)
- [第三章:友元(Friend)与友元类 ------ 严密防守下的"VIP通道"](#第三章:友元(Friend)与友元类 —— 严密防守下的“VIP通道”)
-
-
- [1\. 什么是友元?(底层逻辑:发放白名单)](#1. 什么是友元?(底层逻辑:发放白名单))
- [2\. 友元函数](#2. 友元函数)
- [3\. 友元类](#3. 友元类)
- [4\. 友元的三大"铁律"](#4. 友元的三大“铁律”)
-
- [1\. 单向性(单相思)](#1. 单向性(单相思))
- [2\. 不可传递性(朋友的朋友不是朋友)](#2. 不可传递性(朋友的朋友不是朋友))
- [3\. 不可继承性(父债子不还)](#3. 不可继承性(父债子不还))
- [5\. 什么时候必须用友元?(实战价值)](#5. 什么时候必须用友元?(实战价值))
- 总结
- [第四章:内部类(Nested Class)------ 寄生与隐藏](#第四章:内部类(Nested Class)—— 寄生与隐藏)
-
- [1\. 什么是内部类?](#1. 什么是内部类?)
- [2\. 内存模型:它们真的"住"在一起吗?(重点误区)](#2. 内存模型:它们真的“住”在一起吗?(重点误区))
- [3\. 访问权限:谁能看谁?](#3. 访问权限:谁能看谁?)
-
- [规则 A:内部类 -\> 访问 -\> 外围类 (VIP 通道)](#规则 A:内部类 -> 访问 -> 外围类 (VIP 通道))
- [规则 B:外围类 -\> 访问 -\> 内部类 (受限)](#规则 B:外围类 -> 访问 -> 内部类 (受限))
- [4\. 代码实战:链表与节点](#4. 代码实战:链表与节点)
- [5\. 如果内部类是 public 的?](#5. 如果内部类是 public 的?)
- 总结
- [第五章:匿名对象 ------ 刹那芳华](#第五章:匿名对象 —— 刹那芳华)
-
- [1\. 什么是匿名对象?](#1. 什么是匿名对象?)
- [2\. 生命周期:生得伟大,死得光速](#2. 生命周期:生得伟大,死得光速)
- [3\. 既然活这么短,拿来干嘛?](#3. 既然活这么短,拿来干嘛?)
-
- [场景 A:作为函数参数(最常用)](#场景 A:作为函数参数(最常用))
- [场景 B:作为函数返回值](#场景 B:作为函数返回值)
- [4\. 高级特性:如何给匿名对象"续命"?](#4. 高级特性:如何给匿名对象“续命”?)
- [5\. 总结](#5. 总结)
- [第六章:编译器优化 ------ 聪明的"偷懒"](#第六章:编译器优化 —— 聪明的“偷懒”)
-
- [1\. 准备工作:监控类](#1. 准备工作:监控类)
- [2\. 场景一:隐式类型转换的优化](#2. 场景一:隐式类型转换的优化)
- [3\. 场景二:传参时的优化(匿名对象)](#3. 场景二:传参时的优化(匿名对象))
- [4\. 场景三:返回值优化 (RVO/NRVO) ------ 终极优化](#4. 场景三:返回值优化 (RVO/NRVO) —— 终极优化)
- [5\. 什么时候**不能**优化?](#5. 什么时候不能优化?)
- [6\. 总结与实验方法](#6. 总结与实验方法)
-
前言
由于c++的类与对象较为繁琐复杂,本文介绍类与对象部分的相关内容,该篇可以看作对上中两篇知识的递进和查漏补缺
(【由浅入深】是一个系列文章,它记录了我个人作为一个小白,在学习c++技术开发方向计相关知识过程中的笔记,欢迎各位彭于晏刘亦菲从中指出我的错误并且与我共同学习进步,作为该系列的第三部曲-c++,大部分知识会根据本人所学和我的助手------通义,gimini等以及合并网络上所找到的相关资料进行核实编写,每一篇文章都可能会因为一些错误在后续时间增删改查,因为该系列会按照我在互联网中的学习笔记形式编写,我会使用绝大多数人使用的讲解顺序编写,所以基础框架和大部分内容案例会与他人一样,基础知识不会过于详细讲述)
第一章:初始化列表------成员变量的"原生"定义
在《类与对象(中)》中,我们学习了构造函数的基本语法。我们习惯像下面这样写构造函数,用来给成员变量赋初值:
cpp
class Date {
public:
Date(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
虽然上述构造函数调用后,对象确实有了初始值,但这在 C++ 的严格定义中,不能被称为"初始化",而只能被称为"赋初值"。
为什么?因为初始化只能有一次 ,而构造函数体内可以进行多次赋值。为了解决某些特定场景下的初始化问题,C++ 引入了初始化列表。
1. 什么是初始化列表?
初始化列表是构造函数的一部分,它以一个冒号 开始,接着是一个以逗号 分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
只要你放入的东西是一个合法的表达式,并且它的返回值类型能转成该成员变量的类型,编译器通常都会接受。
语法格式:
cpp
class Date {
public:
// 这才是真正的初始化
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{
// 构造函数体可以为空,也可以写其他逻辑
}
private:
int _year;
int _month;
int _day;
};
核心理解 :
初始化列表是对象成员变量定义的地方。无论你是否显式写出初始化列表,编译器都会默认生成。(不管你写不写,初始化列表这个步骤是"必须走"且"自动走"的。)对于内置类型,如果不写,它们在初始化列表中被定义时是随机值;对于自定义类型,会调用它的默认构造函数。
2. 为什么非要用初始化列表?
你可能会问:"我在函数体里写 _year = year; 也能跑,为什么要学新语法?"
对于普通的 int、double 类型,在函数体内赋值和在初始化列表中初始化,差异不大(仅是效率上的微小差别)。但在以下三种情况 下,必须使用初始化列表,否则编译会直接报错。
必须使用初始化列表的场景
(1) 引用成员变量 (reference)
引用在定义的时候必须初始化,且一旦绑定不可修改。因此,它不能在构造函数体内先定义再赋值。
cpp
class A {
public:
A(int& x)
// : _ref(x) // 正确写法:必须在这里初始化
{
_ref = x; // 错误写法:这里是赋值,但引用必须在定义时初始化
}
private:
int& _ref;
};
(2) const 成员变量
const 变量只有一次初始化的机会,初始化之后就不能被修改(赋值)。因此,必须在创建时(初始化列表中)给值。
cpp
class B {
public:
B(int n)
// : _n(n) // 正确写法
{
_n = n; // 错误写法:不能给 const 变量赋值
}
private:
const int _n;
};
(3) 没有默认构造函数的自定义类型成员
如果一个类 A 包含另一个类 B 的对象作为成员,且 B 没有默认构造函数 (即 B 只有带参构造函数),那么 A 必须在初始化列表中显式调用 B 的构造函数。
cpp
class B {
public:
B(int x) { _x = x; } // 只有带参构造,没有默认构造
private:
int _x;
};
class A {
public:
// 错误写法:
/*
A() {
_bb = B(10); // 此时 _bb 已经被定义了,这里尝试再次赋值,但定义时会因为没有默认构造而报错
}
*/
// 正确写法:
A(int a)
: _bb(a) // 显式调用 B 的带参构造进行初始化
{}
private:
B _bb;
};
3. 成员变量的初始化顺序(面试高频坑点)
这是初始化列表中最容易出错,也是面试最喜欢问的地方。
规则 :
成员变量在类中声明的次序 就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
请看下面的代码,输出结果是什么?
cpp
class A {
public:
A(int a)
: _a1(a)
, _a2(_a1)
{}
void Print() {
cout << "_a1=" << _a1 << " " << "_a2=" << _a2 << endl;
}
private:
int _a2; // 注意:_a2 先声明
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
解析:
- 声明顺序 :先
_a2,后_a1。 - 初始化过程 :
- 编译器按照声明顺序,先初始化
_a2。 - 在初始化列表中寻找
_a2的初始化逻辑,发现是_a2(_a1)。 - 此时
_a1还没有被初始化,包含的是随机值(垃圾值)。 - 所以
_a2被赋予了一个随机值。 - 接着初始化
_a1,执行_a1(a),_a1被赋值为 1。
- 编译器按照声明顺序,先初始化
- 结果 :
_a1=1,_a2=随机值。
建议 :始终保持初始化列表的顺序 与成员变量声明的顺序一致,以避免此类逻辑错误。
补充:对象创建过程
内存分配 -> 初始化列表(即成员变量定义) -> 构造函数体。
为了让你彻底弄清楚,我们将这个过程拆解为三个严格的阶段:
第一阶段:内存分配
------"占坑"
当你创建一个对象(比如 Date d; 或 new Date())时,第一件事是分配内存。
- 此时,对象所占用的总字节数(
sizeof(Date))已经确定。 - 操作系统在栈或堆上划出了一块空间给这个对象。
- 注意 :此时这块内存里的成员变量还只是"生肉",里面的数据是随机的垃圾值。它们仅仅有了空间,但还没有被"定义"(还没有开始生命周期)。
第二阶段:初始化列表
------"出生/定义"
这是成员变量真正的"定义"时刻。
- 不管你写没写初始化列表,编译器必须在这里一个个地把成员变量"生"出来。
- 顺序:严格按照成员变量在类中声明的顺序。
- 动作 :
- 如果你在列表里写了
_a(10),它就在定义时直接初始化为 10。 - 如果你没写,对于
int这种内置类型,它就在定义后保留随机值;对于自定义类型,它就在定义时调用默认构造。
- 如果你在列表里写了
- 关键点 :这就是为什么
const和引用必须在这里处理,因为它们要求定义即初始化,错过了这个阶段(这一行代码),它们就定义结束了,后面再想改就晚了。
第三阶段:构造函数体
------"整容/赋值"
这是最后一步。
- 当代码执行到
{时,所有的成员变量都已经定义完毕(已经出生了)。 - 在这里写的
_year = year;,严格来说不叫初始化,而是赋值。 - 这就像孩子已经生下来(初始化列表结束),你再给他穿衣服、洗澡(函数体内赋值)。
一张图总结
假设你有以下代码:
cpp
class A {
const int _n;
int _x;
public:
A() : _n(100) { // 初始化列表
_x = 0; // 函数体内赋值
}
};
执行流程如下:
- 内存分配 :
malloc或 栈调整,划出 8 字节空间(假设 int 是 4 字节)。此时_n和_x的位置有了,但值是乱码。 - 初始化列表 :
_n被定义,并立即填入 100。_x被定义,因为列表里没写它,它保持随机乱码(但它已经是一个合法的 int 变量了)。
- 构造函数体 :
- 执行
_x = 0;。将_x刚才的随机乱码覆盖为 0。
- 执行
4. explicit 关键字
在谈论构造函数初始化时,必须提及 explicit。
对于单参数 的构造函数(或者除第一个参数外其余参数都有默认值的多参构造),C++ 支持隐式类型转换。
cpp
class Date {
public:
Date(int year) : _year(year) {} // 单参数构造
private:
int _year;
};
int main() {
Date d1(2025); // 正常构造
// 隐式转换:
// 编译器会先用 2026 构造一个临时的 Date 对象,再把这个对象拷贝构造给 d2
// (现在的编译器优化后,直接把 2026 当作参数调用构造函数)
Date d2 = 2026;
}
如果你不希望发生这种隐式的、可能让人困惑的转换,可以在构造函数前加上 explicit 关键字。
cpp
explicit Date(int year) : _year(year) {}
加上后,Date d2 = 2026; 这种写法将直接编译报错,强制用户使用 Date d2(2026);。
补充:隐式类型转换
在 C++ 中,构造函数不仅用于初始化对象,它还扮演着类型转换器的角色。
只要构造函数能够被"隐含地"调用(即没有被声明为 explicit),编译器就会利用它在不同类型之间搭建桥梁。这不仅适用于单参数 构造函数,在 C++11 引入列表初始化后,也适用于多参数构造函数。
以下是关于通过构造函数进行隐式类型转换的详细讲解。
只要存在"非 explicit"的含参构造函数,且提供的内置类型数据能匹配(或通过标准转换匹配)该构造函数的参数列表,该内置数据就可以发生隐式转换。
1. 原理:构造函数即转换路径
当编译器遇到一个类型 A 的值,但上下文需要一个类型 B 的对象时,它会检查 B 类中是否有构造函数能接受 A 类型的数据:
- 如果有 :编译器会偷偷创建一个
B类型的临时对象,把A传进去构造,然后使用这个临时对象。 - 如果是赋值 :
B obj = A_value;本质上是B obj(A_value);的语法糖(虽然现代编译器会优化掉中间的拷贝/移动步骤,但语义上是转换)。
2. 单参数构造函数的隐式转换
这是 C++98 就有的经典特性。如果一个构造函数只接受一个参数(或者后续参数都有默认值),它定义了从参数类型 到类类型的隐式转换。
示例场景
假设我们有一个 String 类:
cpp
#include <iostream>
#include <string>
class String {
public:
// 单参数构造函数:定义了 const char* -> String 的隐式转换
String(const char* str) {
std::cout << "Implicit conversion: const char* -> String" << std::endl;
_data = str;
}
std::string _data;
};
void printString(const String& s) {
std::cout << "Printing: " << s._data << std::endl;
}
int main() {
// 1. 赋值时的隐式转换
String s1 = "Hello";
// 编译器行为:String tmp("Hello"); String s1 = tmp;
// 2. 函数传参时的隐式转换
printString("World");
// 编译器行为:printString(String("World"));
// 这里的 "World" 是 const char*,直接被转成了 String 对象
return 0;
}
带有默认参数的情况
如果构造函数有多个参数,但第一个参数之后的所有参数都有默认值,它依然被视为"单参数构造函数",支持隐式转换。
cpp
class Data {
public:
// 这也算单参数构造函数,支持 int -> Data 转换
Data(int a, int b = 0, int c = 0) {}
};
void func(Data d) {}
int main() {
func(10); // 合法:隐式调用 Data(10, 0, 0)
}
3. 多参数构造函数的隐式转换 (C++11)
在 C++98 时代,多参数构造函数不能用于隐式转换(除了极个别的逗号表达式特例)。但在 C++11 引入统一初始化(Uniform Initialization)后,花括号 {} 可以用来触发多参数的隐式转换。
语法形式
这种转换通常发生在:
- 赋值初始化 :
Class obj = {arg1, arg2}; - 函数传参 :
func({arg1, arg2}); - 函数返回 :
return {arg1, arg2};
示例场景
假设我们有一个 Point 类:
cpp
class Point {
public:
int _x;
int _y;
// 多参数构造函数
Point(int x, int y) : _x(x), _y(y) {
std::cout << "Point Constructed(" << x << ", " << y << ")" << std::endl;
}
};
void drawPoint(Point p) {
// ...
}
int main() {
// 1. 赋值初始化中的隐式转换
// {10, 20} 被视为一个初始化列表,隐式转换为 Point 对象
Point p = {10, 20};
// 2. 函数传参中的隐式转换(非常常用)
// 这里的 {5, 5} 被隐式构造为一个临时的 Point 对象
drawPoint({5, 5});
return 0;
}
解析:
这里的 {10, 20} 并不是一个 Point 对象,而是一个初始化列表 。编译器发现 Point 有一个接受两个 int 的构造函数,于是它允许这种"列表"到"对象"的隐式转换。
4. 隐式转换的限制:只能"走一步"
C++ 编译器非常严格,它只允许进行一次用户定义的隐式转换。如果你想通过连续两次隐式转换达到目的,编译器会报错。
cpp
class A {
public:
A(int x) {} // int -> A
};
class B {
public:
B(A a) {} // A -> B
};
void func(B b) {}
int main() {
A a = 10; // OK: int -> A (一步)
func(a); // OK: A -> B (一步)
// func(10); // 错误!
// 需要路径:int -> A -> B (两步用户定义转换)
// 编译器拒绝这种"多跳"转换
// 解决方案:显式走一步
func(A(10)); // OK: 显式构造 A,然后 A -> B 隐式转换
}
5. explicit 关键字:禁止隐式转换
由于隐式转换可能导致代码可读性下降或意外的逻辑错误(例如把一个整数意外当成数组长度去构造了一个数组对象),C++ 提供了 explicit 关键字。
一旦构造函数被声明为 explicit,它就只能用于显式初始化 和显式类型转换。
单参数的 explicit
cpp
class MyInt {
public:
explicit MyInt(int v) {}
};
void test(MyInt i) {}
int main() {
// MyInt a = 10; // 错误:拷贝初始化(需要隐式转换)被禁止
MyInt b(10); // 正确:直接初始化
// test(10); // 错误:不能将 int 隐式转换为 MyInt
test(MyInt(10)); // 正确:显式构造临时对象
}
多参数的 explicit (C++11)
对于多参数构造函数,explicit 会禁止 {...} 形式的拷贝列表初始化,但允许直接列表初始化。
cpp
class Point {
public:
// 禁止隐式将 {int, int} 转换为 Point
explicit Point(int x, int y) {}
};
void draw(Point p) {}
int main() {
// Point p = {1, 2}; // 错误:这是拷贝列表初始化(Copy List Initialization),被 explicit 禁止
Point p2{1, 2}; // 正确:这是直接列表初始化(Direct List Initialization)
// draw({1, 2}); // 错误:这是隐式转换
draw(Point{1, 2}); // 正确:显式构造
}
6. 总结图表
| 构造函数类型 | 代码示例 | 隐式转换行为 (explicit 缺省) |
explicit 后的行为 |
|---|---|---|---|
| 单参数 | A(int x) |
允许 A a = 10; 允许 func(10); |
禁止赋值初始化 禁止传参隐式转换 |
| 多参数 (C++11) | A(int x, int y) |
允许 A a = {1, 2}; 允许 func({1, 2}); |
禁止 {...} 形式的赋值初始化 必须用 A a{1, 2} 或 A{1, 2} |
最佳实践
- 原则上 :对于单参数构造函数,默认加上
explicit,除非你非常确定需要隐式转换(例如BigInt类,或者数学向量类)。 - 多参数 :如果
{x, y}的语义非常清晰对应对象的成员(如坐标点),通常不加explicit以方便使用;如果语义模糊,则加上。
补充:对象的初始化方式
在 C++ 中,对象的初始化方式虽然看起来五花八门,但主要可以归纳为以下 3 种核心方式(C++11 之后),以及一种特殊情况。
理解它们的区别对于避坑(特别是涉及 explicit 和类型转换时)非常重要。
1. 拷贝初始化
最传统的写法,特征是使用等号 =。
- 语法 :
类名 对象名 = 值; - 原理 :它在概念上是将右边的值隐式转换(如果类型不同)为临时对象,然后再拷贝构造给左边的对象。
- 限制 :
- 不能 调用
explicit构造函数。 - 必须有可访问的拷贝构造函数(虽然现代编译器会优化掉拷贝步骤,但语义上必须存在,即直接构造)。
- 不能 调用
cpp
string s = "hello"; // 先把 "hello" 转为 string,再拷贝初始化
int i = 10; // 内置类型直接赋值
MyClass obj = 100; // 依赖隐式转换:MyClass(int)
2. 直接初始化
特征是使用圆括号 ()。
- 语法 :
类名 对象名(参数); - 原理:直接调用匹配参数的构造函数。
- 优点 :
- 可以 调用
explicit构造函数(这是它和拷贝初始化最大的区别)。 - 通常比拷贝初始化效率更高(在老版本 C++ 中)。
- 可以 调用
cpp
string s("hello"); // 直接调用构造函数
MyClass obj(100); // 直接调用 MyClass(int)
MyClass obj2(10, 20); // 调用多参数构造函数
3. 列表初始化 / 统一初始化
这是 C++11 引入的"现代写法",特征是使用花括号 {}。官方推荐尽量使用这种方式。
- 语法 :
类名 对象名{参数};或类名 对象名 = {参数}; - 优点 :
- 防止窄化转换:如果你试图把一个精度的浮点数塞给整数,它会报错或警告,比较安全。
- 解决"最令人头秃的解析":它可以区分是"函数声明"还是"对象定义"。
cpp
MyClass obj{100}; // 直接列表初始化(推荐)
MyClass obj2 = {100}; // 拷贝列表初始化,= 号的存在,意味着它必须允许"隐式转换"。
//如果构造函数没有 explicit:上面两者效果等价(现代编译器都会优化掉中间的拷贝过程)。
//如果构造函数有 explicit:上面两者不等价,带 = 的会编译失败。
int x{3.14}; // ❌ 报错!禁止丢失精度(窄化)
int y(3.14); // ✅ 允许(虽然会截断成3),比较危险
特殊情况:默认初始化
当你创建一个对象但不给任何参数时。
- 不带括号 :
MyClass obj;- 调用默认构造函数。
- 注意 :如果是内置类型(如
int a;)且在函数内部,它的值是未定义的(随机垃圾值)。
- 带空花括号 (值初始化):
MyClass obj{};/int a{};- 对于类,调用默认构造函数。
- 对于内置类型,会被清零 (例如
int变为 0)。推荐写法。
- 带空圆括号 (千万别用):
MyClass obj();- ⚠️ 这是陷阱! 编译器会认为你声明了一个函数,而不是创建对象。
总结对照表
| 方式 | 语法示例 | explicit 构造函数 |
特点 |
|---|---|---|---|
| 拷贝初始化 | A a = 1; |
❌ 不允许 | 依赖隐式转换,看着像赋值 |
| 直接初始化 | A a(1); |
✅ 允许 | 强力推荐用于多参数构建 |
| 列表初始化 | A a{1}; |
✅ 允许 (不带=时) | 最安全,防止精度丢失,现代 C++ 首选 |
5. C++11 的成员变量缺省值
简单来说:你在类声明里给的"缺省值",本质上就是给"初始化列表"提供了一个"备胎"。
1. 核心规则:谁是老大?
在成员变量初始化的战场上,只有一条铁律:
初始化列表(显式) > 类内缺省值(隐式)
2. 具体流程图解
当编译器准备生成构造函数代码,处理成员变量初始化时,它会按照以下逻辑进行判断(以成员变量 _a 为例):
-
第一步:看初始化列表
- 构造函数冒号后面有没有写
_a(xxx)? - 如果有 :直接使用 你写的值。类里的缺省值被无视。
- 如果没有:进入第二步。
- 构造函数冒号后面有没有写
-
第二步:看类内声明
- 在
class定义里,_a后面有没有写= val或{ val }? - 如果有 :编译器会自动把这个缺省值填入初始化列表。
- 如果没有:进入第三步。
- 在
-
第三步:原生默认初始化
- 如果是内置类型(int, double):就是随机值(垃圾值)。
- 如果是自定义类型:调用它的默认构造函数。
3. 代码实战演示
我们通过代码来验证这个流程:
cpp
#include <iostream>
using namespace std;
class Test {
public:
int _a = 10; // 【备胎】类内缺省值,C++11 支持
// 情况 1:初始化列表里显式写了
Test(int x) : _a(x) {
// 此时 _a = x,缺省值 10 被无视
cout << "情况1 - 显式初始化: " << _a << endl;
}
// 情况 2:初始化列表里没写
Test() {
// 此时编译器自动把 10 填进来,_a = 10
cout << "情况2 - 使用缺省值: " << _a << endl;
}
};
int main() {
Test t1(888); // 打印 888
Test t2; // 打印 10
return 0;
}
4. 编译器视角(发生了什么?)
在情况 2 中,虽然你的代码写的是:
cpp
Test() { }
但在编译阶段,编译器发现你没处理 _a,而且 _a 有缺省值 10,它会悄悄地把代码改成:
cpp
Test() : _a(10) { }
// ^^^ 编译器自动补全了这一段
总结
缺省值给初始化列表的流程可以概括为:查漏补缺。
- 初始化列表是"正宫",只要它在,就听它的。
- 类内缺省值是"备胎",只有当初始化列表没提到该变量时,编译器才会把缺省值拿过来用。
- 最终结果 :无论如何,所有变量的初始化工作最终都是在初始化列表这个阶段完成的。
逻辑优先级:
- 如果初始化列表显式初始化了(情况1),就用初始化列表的值(
_a为 10)。 - 如果初始化列表没有写(情况2),就会使用声明时给的这个缺省值(
_a为 1)。
第二章:Static 静态成员
在第一章中,我们定义的成员变量(如 age, name)都是属于对象的。每创建一个新对象,就会在内存中拷贝一份这些变量。
但有时候,我们需要一份所有对象共享 的数据(比如:统计总共创建了多少个学生?),或者一个不需要依赖对象就能调用 的功能(比如:数学工具函数 Math::max(a, b))。这就是 static 的舞台。
2.1 静态成员变量
1. 核心概念
- 归属 :静态成员变量属于类,而不属于某个具体的对象。
- 共享 :无论你创建了多少个对象(甚至不创建对象),静态成员变量只有一份。所有对象共享这同一块内存。
- 形象比喻 :
- 普通成员 是每个学生的"钱包"(每人一个,互不干扰)。
- 静态成员 是班级的"黑板 "或"班费"(全班只有一份,大家都能看到,都能修改)。
2. 语法规则
这是初学者最容易报错的地方,分为 声明 和 定义 两步:
- 类内声明 :加
static关键字。 - 类外定义(初始化) :必须 在类外面进行初始化(除非是
const整数类型或 C++17 后的inline)。如果不初始化,链接时会报错。
3. 代码示例
cpp
#include <iostream>
using namespace std;
class Student {
public:
// 1. 类内声明
static int total_count;
int id; // 普通成员
Student(int i) : id(i) {
// 每创建一个学生,总数+1
total_count++;
}
};
// 2. 类外定义并初始化 (分配内存的关键步骤)
// 格式:类型 类名::变量名 = 初始值;
int Student::total_count = 0;
int main() {
// 此时还没有创建对象,但 total_count 已经存在了
cout << "初始人数: " << Student::total_count << endl; // 输出 0
Student s1(101);
Student s2(102);
// 访问方式 A:通过类名访问(推荐,最正规)
cout << "类名访问: " << Student::total_count << endl; // 输出 2
// 访问方式 B:通过对象访问(语法允许,但不推荐,容易误导以为它是普通成员)
cout << "对象访问: " << s1.total_count << endl; // 输出 2
// s1 改了,s2 也会变,因为它们看的是同一个变量
s1.total_count = 100;
cout << "s2看到的: " << s2.total_count << endl; // 输出 100
return 0;
}
注意 :静态成员变量不占用 对象的内存空间。
sizeof(Student)只会计算id的大小,不会计算total_count。它存储在全局数据区(静态存储区)。
2.2 静态成员函数
1. 核心概念
- 归属:属于类,不属于对象。
- 调用 :不需要创建对象就可以直接通过
类名::函数名()调用。 - 致命限制 :静态成员函数没有
this指针。
2. 因为没有 this 指针带来的后果
这是考试和面试的重灾区:
- 静态成员函数只能访问静态成员变量/函数。它不能直接访问普通的成员变量(因为不知道这些变量属于哪个对象)。
- 普通成员函数可以随意访问静态成员(毕竟静态成员是公共资源)。
3. 代码示例
cpp
class Box {
private:
int width; // 普通变量
static int height; // 静态变量
public:
Box(int w) : width(w) {}
// 【静态成员函数】
static void showHeight() {
cout << "Height: " << height << endl; // ✅ 正确:访问静态变量
// cout << "Width: " << width << endl; // ❌ 错误!
// 原因:width 属于具体对象,showHeight 被调用时可能根本没有对象
// 编译器内心OS:我怎么知道你要打印哪个 Box 的 width?
}
// 【普通成员函数】
void showAll() {
cout << width << endl; // ✅ 正确
cout << height << endl; // ✅ 正确:普通函数可以访问静态数据
}
};
int Box::height = 10;
int main() {
// 不需要创建对象就能调用
Box::showHeight();
Box b(5);
b.showAll();
}
2.3 深入细节与高级特性
1. const static 成员
如果静态成员变量同时是 const 的,且是整型家族(int, char, bool, long等),可以在类内直接初始化。
cpp
class Config {
public:
// ✅ 允许:const 整数类型可以直接赋值
const static int MaxUsers = 1000;
// ❌ 错误:非整数类型(double/string等)通常仍需类外初始化
// const static double Pi = 3.14;
};
2. C++17 的 inline static (新特性)
如果你觉得类外初始化太麻烦,且你的编译器支持 C++17,你可以使用 inline 关键字在类内直接初始化任何类型的静态变量。
cpp
class Tech {
public:
// C++17 只要加了 inline,任何类型都能在里面初始化
inline static double PI = 3.14159;
inline static string Name = "Gemini";
};
2.4 静态成员函数不能给缺省值
要理解这个,你只需要懂两个概念:
- 头文件(.h)是图纸,会被复印。
- 静态变量(static)是"唯一的公共设施"。
第一步:理解"头文件会被复印"(类一般在头文件中声明)
在 C++ 里,当你写 #include "MyClass.h" 时,编译器的动作非常粗暴:它直接把 MyClass.h 里的代码复制粘贴 到了你的 .cpp 文件里。
如果你有两个 .cpp 文件(比如 A.cpp 和 B.cpp)都引用了这个头文件,那么这个头文件的内容就会在 A.cpp 里出现一次,在 B.cpp 里又出现一次。
第二步:如果允许在类里赋值,会发生什么灾难?
假设 C++ 允许你这样写(实际上不允许):
cpp
// MyClass.h (这是一张图纸)
class MyClass {
public:
static int money = 100; // 假设允许这样写
};
场景还原:
-
编译
A.cpp:- 编译器看到头文件里的
static int money = 100;。 - 编译器想:"哦,这里要造一个叫
money的变量,存了 100 块钱。" - 于是,在 A 的地盘(
A.obj)里,划了一块内存专门放money。
- 编译器看到头文件里的
-
编译
B.cpp:- 编译器又看到了头文件里的
static int money = 100;(因为头文件被复印过来了)。 - 编译器想:"哦,这里也 要造一个叫
money的变量,存了 100 块钱。" - 于是,在 B 的地盘(
B.obj)里,也 划了一块内存放money。
- 编译器又看到了头文件里的
-
最后一步:链接(Linker)出场
- 链接器要把 A 和 B 拼成一个完整的程序。
- 突然它发现:"不对啊!为什么 A 里有一个
money,B 里也有一个money?而且名字一模一样!" - 这就是**"重定义错误"**。因为
static的定义是"全程序唯一",现在却出现了两份,计算机不知道该听谁的,直接报错罢工。
第三步:正确的做法(声明与定义分离)
为了不让链接器打架,C++ 规定了这样的规矩:
1. 在头文件(图纸)里,只准"贴告示"(声明):
你只能告诉大家:"我们这个类,将会有一个叫 money 的公共财产,但它不在这里,别在这里造它。"
cpp
// MyClass.h
class MyClass {
public:
static int money; // 👈 这叫声明:只给个名字,不给数值,不占内存
};
当 A.cpp 和 B.cpp 包含它时,只知道有个叫 money 的东西存在,但谁都不敢在自己的地盘里造它。
2. 在某一个 .cpp 文件(施工队)里,真正的"造出来"(定义):
你必须选定一个(且只能是一个).cpp 文件,在里面实实在在得分配内存。
cpp
// MyClass.cpp
int MyClass::money = 100; // 👈 这叫定义:真正的分配内存,存入100
这样,整个程序里,money 只在 MyClass.cpp 里被造了一次。A.cpp 和 B.cpp 指向的都是这同一个唯一的 money。
只要记住这一个底层的逻辑:
"赋值" = "分配内存"。
- 如果头文件里能赋值,因为头文件会被到处包含,就会导致到处都在分配同名的内存。
- 静态变量要求全局只能有一份内存。
- 冲突! 所以编译器禁止在类里的静态变量直接赋值。
那为什么 const 整数可以?
因为 const int A = 10; 这种写法,编译器在底层并没有 给它分配内存,而是把它当成了"替换文本"。
编译器看到代码里用 A,直接就把用 A 的地方偷偷换成了数字 10。既然没有分配内存,就不会有重名冲突,所以允许。
2.5 总结与对比表
| 特性 | 普通成员 (Non-static) | 静态成员 (Static) |
|---|---|---|
| 存储位置 | 栈或堆 (随对象存在) | 全局数据区 (静态区) |
| 生命周期 | 对象创建生,对象销毁死 | 程序启动生,程序结束死 |
| 副本数量 | 每个对象一份 | 全类共享一份 |
| this 指针 | 有 | 无 |
| 访问权限 | 可以访问静态和非静态成员 | 只能访问静态成员 |
| 调用方式 | object.func() |
Class::func() (推荐) |
什么时候使用 static?
- 计数器:统计该类有多少个实例化对象。
- 共享配置:所有对象都需要读取同一个配置(如银行利率、游戏全局重力系数)。
- 工具函数 :不需要操作对象状态的函数(如
Math::abs(-5),你不需要创建一个 Math 对象来算绝对值)。
C++ 规定:
静态成员变量
在类内部(头文件):只能做声明(告诉编译器有这么个变量,是什么类型)。
在类外部(源文件 .cpp):进行定义和初始化(真正申请内存,赋初始值)。
第三章:友元(Friend)与友元类 ------ 严密防守下的"VIP通道"
C++ 的封装 就像一座堡垒,private 区域是绝对的禁地,外部函数根本进不去。
但有时候,严防死守会带来麻烦。比如你需要一个特殊的助手函数,它必须能直接操作类的内部数据,或者两个类关系极其亲密(比如"遥控器"和"电视机")。如果每次都走公用的接口(get/set 函数),效率太低,代码也啰嗦。
这时候,C++ 提供了一个"开后门"的机制:友元(friend)。
1. 什么是友元?(底层逻辑:发放白名单)
友元的本质就是打破封装。
- 正常情况 :编译器看到外部函数访问
private变量,直接报错"权限不足"。 - 友元机制 :类自己在内部声明:"虽然我是私有的,但我把这个特定的函数/类加到了白名单里。"编译器看到白名单,就会放行。
核心比喻 :
你的家(Class)有卧室(private)。
- 普通客人(普通函数):只能坐在客厅(public)。
- 友元(friend):是你最好的死党,你给了他一把卧室钥匙,他可以直接进卧室拿东西。
2. 友元函数
友元函数不是类的成员函数,它只是一个普通的函数(或者是另一个类的成员函数),但它在类里面被"盖章认证"了。
场景设定:
有一个类叫 Girl,她有"年龄"和"体重",这些是秘密(private)。但是有一个特殊的全局函数 doctor_check(医生检查),需要直接读取这些秘密。
代码示例:
cpp
#include <iostream>
using namespace std;
class Girl {
// 1. 在类里面声明:告诉编译器,doctor_check 是我的朋友
// 位置放在 public 或 private 下面都可以,不影响
friend void doctor_check(Girl& g);
private:
int age;
int weight;
public:
Girl(int a, int w) : age(a), weight(w) {}
};
// 2. 这是一个普通的全局函数,不属于 Girl 类
void doctor_check(Girl& g) {
// 正常情况下,下面这两行会报错
// 但因为是友元,所以可以直接访问 private 数据
cout << "医生正在查看数据:" << endl;
cout << "年龄: " << g.age << endl; // 直接访问私有成员!
cout << "体重: " << g.weight << endl; // 直接访问私有成员!
}
int main() {
Girl alice(18, 50);
doctor_check(alice);
return 0;
}
这里的重点:
doctor_check没有Girl::前缀,它不是成员函数。- 它必须接受一个对象参数(比如
Girl& g),因为它自己没有this指针。
注意:成员函数想要访问成员变量,前提是因为它有this指针
在计算机的内存里,代码(函数)和数据(变量)其实是分开存放的。
成员变量:每个对象都有一份独立的拷贝(存在栈或堆上)。
成员函数:所有对象共用同一份代码(存在代码段)。
那么问题来了:当只有一份代码时,它怎么知道现在的操作是针对"对象 A"还是"对象 B"的呢?
答案靠隐式的 this 指针
3. 友元类
如果不是仅仅一个函数要访问,而是整个类 A 都要访问 类 B 的私有成员,我们可以把类 A 声明为类 B 的友元类。
场景设定:
- 类 B :
TV(电视机)。内部有很多复杂的私有电路(音量、频道、亮度)。 - 类 A :
Remote(遥控器)。遥控器需要直接调节电视机的私有状态。
代码示例:
cpp
#include <iostream>
using namespace std;
class TV {
// 声明 Remote 类是我的好朋友
// Remote 里的所有成员函数,都可以通过 TV 对象访问私有成员
friend class Remote;
private:
int volume; // 音量
int channel; // 频道
public:
TV() : volume(10), channel(1) {}
// 查看当前状态
void show_state() {
cout << "当前频道: " << channel << ", 音量: " << volume << endl;
}
};
class Remote {
public:
// 遥控器可以直接修改 TV 的私有数据
void volume_up(TV& tv) {
tv.volume++; // 直接访问私有成员 volume
}
void set_channel(TV& tv, int c) {
tv.channel = c; // 直接访问私有成员 channel
}
};
int main() {
TV myTV;
Remote myRemote;
myTV.show_state(); // 初始状态
myRemote.volume_up(myTV); // 遥控器修改私有数据
myRemote.set_channel(myTV, 5); // 遥控器修改私有数据
myTV.show_state(); // 修改后状态
return 0;
}
4. 友元的三大"铁律"
友元关系虽然好用,但它非常冷酷,不讲人情世故。记住这三点,考试面试必问:
1. 单向性(单相思)
- A 是 B 的友元 ≠ \neq = B 是 A 的友元。
- 解释 :
Remote是TV的朋友(Remote 能动 TV 的私有数据),但不代表TV是Remote的朋友。我也许给你但我家钥匙,但不代表你能把你要是你家钥匙给我。
2. 不可传递性(朋友的朋友不是朋友)
- 如果 A 是 B 的友元,B 是 C 的友元,A 不是 C 的友元。
- 解释:你把你家钥匙给了你哥们,你哥们把这把钥匙给了流浪汉,流浪汉依然进不了你家门(编译器不认)。
3. 不可继承性(父债子不还)
- 父亲的朋友,不自动成为儿子的朋友。
- 解释:如果基类有友元,那个友元无法访问派生类的私有成员。
5
5. 什么时候必须用友元?(实战价值)
你可能会问:"直接写 get/set 函数不就行了吗?为什么要破坏封装?"
最经典、最无法替代的场景是:运算符重载(Operator Overloading),尤其是 << 和 >>。
你想直接用 cout << user; 打印一个对象吗?
cout是std::ostream类的对象。user是你自定义类的对象。std::ostream这个类是系统写的,你改不了它的代码。- 如果你想让
cout能读取你类里的private数据打印出来,你必须把operator<<这个函数声明为你类的友元。
cpp
class Point {
friend ostream& operator<<(ostream& out, const Point& p); // 必须是友元
private:
int x, y;
public:
Point(int x, int y) : x(x), y(y) {}
};
// 只有成为了友元,才能在这里直接 p.x, p.y
ostream& operator<<(ostream& out, const Point& p) {
out << "(" << p.x << ", " << p.y << ")";
return out;
}
总结
- 友元是封装的破坏者,也是效率的救星。
- 友元函数:普通函数拿到了"VIP 卡",能访问私有成员。
- 友元类:整个类的所有方法都拿到了"VIP 卡"。
- 原则 :能不用就不用(为了安全和耦合度),但在运算符重载 或紧密协作的类(如迭代器、链表节点)中非常必要。
第四章:内部类(Nested Class)------ 寄生与隐藏
在上一章我们讨论了"友元",那是为了让两个独立的类互相"开后门"。
而内部类 (又叫嵌套类),则是彻底的从属关系。它不仅仅是朋友,它是"长在别人肚子里的类"。
这一章的核心概念在于:作用域(Scope) 和 访问权限。
1. 什么是内部类?
简单来说,就是把一个类的定义,写在另一个类的 {} 里面。. 内部类可以定义在外部类的public、protected、private都是可以的。
通常我们定义类都是平级的:
cpp
class A { ... };
class B { ... };
内部类则是包含关系:
cpp
class Outer { // 外围类
public:
class Inner { // 内部类
public:
void fun() { ... }
};
};
为什么要做这种"套娃"操作?
最主要的原因是辅助实现 和隐藏细节 。
比如你写一个链表 List,你需要一个节点类 Node。这个 Node 除了给 List 用,外面谁都不需要知道它的存在。这时候,把 Node 定义在 List 内部作为 private 成员,就完美隐藏了技术细节。
2. 内存模型:它们真的"住"在一起吗?(重点误区)
这是新手最容易搞错的地方,必须结合 this 指针 知识来理解。
误区 :实例化一个 Outer 对象,里面会自动包含一个 Inner 对象。
真相 :完全不会!
内部类仅仅是名字被限制在了外围类的作用域里 。在内存布局上,Outer 和 Inner 是两个完全独立的类型。
Outer对象的大小 :只计算Outer自己的成员变量,不包含Inner的成员变量。- 关系:它们就像是"公司"和"公司内部的职位"。虽然"高级经理"这个职位定义在"公司"里,但公司的大楼(对象)建好的时候,并不代表里面自动坐了一个经理(内部类对象)。
cpp
class Outer {
int x; // 4字节
class Inner {
int y; // 4字节
};
};
// sizeof(Outer) 是 4,而不是 8。
// 除非你在 Outer 里定义了一个 Inner 类型的成员变量。
3. 访问权限:谁能看谁?
在 C++11 标准之后,规则变得非常有意思,内部类天生就是外围类的"超级友元"。
规则 A:内部类 -> 访问 -> 外围类 (VIP 通道)
内部类可以直接访问外围类的所有成员(包括 private)。
原理:编译器认为内部类是外围类的一部分,所以拥有最高权限。
但是(极大注意事项) :
因为内部类对象和外围类对象是两个独立的对象 ,内部类的 this 指针和外围类的 this 指针没有任何关联。
所以,内部类想要访问外围类的私有成员,必须通过一个外围类的对象实例 (引用或指针)。它不能直接调用外围类的成员(因为它不知道是哪个 Outer 对象的)。
规则 B:外围类 -> 访问 -> 内部类 (受限)
外围类不能访问内部类的 private 成员。
除非内部类把外围类声明为 friend。
原理:虽然你住在我家里,但你的日记本(private)依然是你的隐私。
4. 代码实战:链表与节点
这是内部类最经典的用法。
cpp
#include <iostream>
using namespace std;
class LinkedList {
private:
// 【内部类定义】
// 放在 private 区域,意味着外面的人根本不知道 Node 的存在
// 只有 LinkedList 自己能用
class Node {
public:
int data;
Node* next;
Node(int d) : data(d), next(nullptr) {}
};
Node* head; // 外围类持有一个内部类的指针
public:
LinkedList() : head(nullptr) {}
void add(int value) {
// LinkedList 可以自由创建 Node 对象
Node* newNode = new Node(value);
newNode->next = head;
head = newNode;
}
void show() {
Node* temp = head;
while(temp) {
cout << temp->data << " -> ";
temp = temp->next;
}
cout << "NULL" << endl;
}
};
int main() {
LinkedList list;
list.add(10);
list.add(20);
list.show();
// LinkedList::Node n; // 报错!因为 Node 是私有的,外面看不见。
return 0;
}
5. 如果内部类是 public 的?
如果把内部类放在 public 区域,外面就可以使用它,但名字会变得很长,需要加上作用域限定符 ::。
这通常用于设计模式(如迭代器模式)。
cpp
class Outer {
public:
class PublicInner {
public:
void say_hello() { cout << "Hello" << endl; }
};
};
int main() {
// 必须指名道姓:我是 Outer 里面的 PublicInner
Outer::PublicInner obj;
obj.say_hello();
return 0;
}
总结
- 定义:类中定义的类。
- 目的:逻辑分组、隐藏类型名称(把辅助类藏起来)。
- 内存 :独立存在。外围类对象不包含内部类对象。
- 权限 :
- 内部类是"天生友元",可以看外围类的
private(需持有对象)。 - 外围类只是"房东",不能看内部类的
private。
- 内部类是"天生友元",可以看外围类的
一句话理解:内部类就像是你在家里(Outer)专门定义的一套"家规"(Inner),这套家规只有你们家人懂,外面的人不需要知道细节,但家规里写的内容可以随意引用家里的资源。
第五章:匿名对象 ------ 刹那芳华
如果说普通对象是"有名有姓"的正式居民,那么匿名对象就是代码世界里的"临时工"或者"一次性用品"。
这一章非常短,但极其重要,因为它是理解 C++ 效率优化(比如右值引用、移动语义)的基石。
1. 什么是匿名对象?
看代码最直观:
cpp
class A {
public:
int x;
A(int i) : x(i) { cout << "构造" << endl; }
~A() { cout << "析构" << endl; }
};
int main() {
A a(10); // 有名对象:名字叫 a
A(20); // 匿名对象:没有名字,只有类型和参数
}
- 有名对象 :
A a(10);------ 你给它起了个名字叫a,你可以通过a也就是句柄反复使用它。 - 匿名对象 :
A(20);------ 你直接调用构造函数生成了一个对象,但没给它起名。
2. 生命周期:生得伟大,死得光速
这是匿名对象最核心的特征:即用即毁。
- 有名对象 :生命周期伴随当前作用域 (比如当前函数或当前花括号
{}结束才挂掉)。 - 匿名对象 :生命周期仅限于当前这一行代码(准确说是当前表达式)。
代码实测:
cpp
int main() {
cout << "--- main start ---" << endl;
A(100); // 这一行执行完,匿名对象立刻析构!
cout << "--- middle ---" << endl;
A a(200); // 有名对象
cout << "--- main end ---" << endl;
return 0; // 此时 a 才析构
}
输出结果:
text
--- main start ---
构造
析构 <-- 看到没?还没打印 middle 它就挂了
--- middle ---
构造 <-- 这是 a
--- main end ---
析构 <-- a 坚持到了最后
3. 既然活这么短,拿来干嘛?
它的作用就是简化代码 和充当临时跳板。
场景 A:作为函数参数(最常用)
假设有一个函数需要一个对象作为参数:
cpp
void play(A obj) {
// ...
}
如果你只是为了传参,没必要专门建个变量:
cpp
// 麻烦写法:
A temp(10);
play(temp); // 为了传给函数,专门起个名,以后再也不用了,浪费起名脑细胞
// 匿名对象写法:
play(A(10)); //以此种方式传递,更简洁,传完即毁
场景 B:作为函数返回值
cpp
A getObject() {
return A(10); // 创建一个匿名对象返回
}
(注:现代 C++ 编译器这里会进行 RVO 返回值优化,效率极高,完全省略了拷贝过程)
4. 高级特性:如何给匿名对象"续命"?
这是一个非常重要的 C++ 规则,涉及引用。
如果你试图用一个普通的引用去引用匿名对象,会报错:
cpp
// A& r = A(10); // ❌ 错误!
// 原因:匿名对象是"右值"(将死的临时数据),非 const 引用不能绑定右值。
// 毕竟,引用一个下一秒就消失的东西,如果允许你改它,毫无意义。
但是!const 引用可以!
cpp
const A& r = A(10); // ✅ 正确!
神奇的现象发生了 :
一旦匿名对象被 const 引用绑定,它的生命周期就会被延长 ,变得和这个引用变量 r 一样长。
- 原理 :编译器发现你很想用这个临时对象,于是它就把这个对象的销毁时间推迟了,直到
r离开作用域,这个对象才会被析构。
5. 总结
- 写法 :
类名()或类名(参数)。 - 寿命 :默认情况下,当前行执行完立即析构。
- 用途:用于临时传参、返回值,省去起名的麻烦。
- 续命 :使用
const 引用可以延长匿名对象的生命周期(这一点在后续学习"右值引用"时非常关键)。
一句话理解:匿名对象就是代码里的"一次性纸杯",用完即扔,除非你用一只特别的手(const 引用)一直拿着它不放。
第六章:编译器优化 ------ 聪明的"偷懒"
在 C++ 的早期(或者说在教科书的理论中),对象的拷贝 和构造 是非常频繁且昂贵的。但现代编译器极其聪明,它们奉行一个原则:"如果能直接在目的地把房子建好,为什么非要在工厂建好再用卡车拉过去?"
这种技术统称为 Copy Elision(拷贝省略)。
为了看清真相,我们需要一个自带"监控"的类。请务必在你的编译器中运行这段代码,观察实际发生了什么。
1. 准备工作:监控类
我们需要显式写出构造函数 、拷贝构造函数 和赋值重载,并打印日志,才能抓到编译器的"小动作"。
cpp
#include <iostream>
using namespace std;
class A {
public:
int _a;
// 1. 普通构造
A(int a = 0) : _a(a) {
cout << "【构造】" << endl;
}
// 2. 拷贝构造
A(const A& aa) : _a(aa._a) {
cout << "【拷贝构造】" << endl;
}
// 3. 赋值运算符重载(虽然本章主要讲拷贝优化,但放这里对比)
A& operator=(const A& aa) {
cout << "【赋值重载】" << endl;
_a = aa._a;
return *this;
}
// 4. 析构
~A() {
cout << "【析构】" << endl;
}
};
2. 场景一:隐式类型转换的优化
代码:
cpp
int main() {
// 理论步骤:
// 1. 用 1 构造一个临时对象 A(1)
// 2. 用临时对象拷贝构造 a
// 3. 析构临时对象
A a = 1;
return 0;
}
未优化的理论路径 :构造 -> 拷贝构造 -> 析构(临时) -> 析构(a)。
现代编译器实际路径 :直接把 1 当作 a 的参数进行构造。
实际输出:
text
【构造】
【析构】
结论 :构造 + 拷贝 被优化为 直接构造。
3. 场景二:传参时的优化(匿名对象)
我们在上一章讲过匿名对象。
代码:
cpp
void func(A aa) { // 参数是值传递,需要拷贝
// ...
}
int main() {
// 写法 1:先定义,后传参(无法彻底优化)
A a(10);
func(a);
cout << "----------------" << endl;
// 写法 2:直接传匿名对象(极致优化)
func(A(20));
return 0;
}
写法 1 的过程 :a 构造 -> 传参时引发 拷贝构造 -> func 结束析构参数 -> main 结束析构 a。
写法 2 的过程(理论) :匿名对象构造 -> 传参拷贝 -> 析构匿名对象 -> ...
写法 2 的过程(实际) :编译器发现你传的是个刚出生的匿名对象,它直接把形参 aa 的构造 取代了匿名对象的生成。也就是说,A(20) 直接造在了 func 的参数栈帧里。
实际输出(写法 2):
text
【构造】
【析构】
结论 :构造 + 拷贝 被优化为 直接构造。
4. 场景三:返回值优化 (RVO/NRVO) ------ 终极优化
这是 C++ 中最著名的优化:Return Value Optimization。当函数返回一个对象时,理论上非常昂贵,但实际上几乎零成本。
代码:
cpp
A makeA() {
A temp(10);
return temp; // 返回局部对象
}
int main() {
A ret = makeA(); // 接收返回值
return 0;
}
恐怖的"理论"流程(关闭优化时):
makeA中构造temp。return temp时,为了把值带回main,会生成一个临时对象(拷贝构造)。makeA结束,temp析构。main中用临时对象拷贝构造ret。- 语句结束,临时对象析构。
ret析构。
(总计:1次构造,2次拷贝,3次析构)
现代编译器的流程(RVO):
编译器直接把 main 函数中 ret 的地址悄悄传给了 makeA,makeA 里的 temp 实际上是直接在 ret 的内存地址上构造的。
实际输出:
text
【构造】
【析构】
结论 :构造 + 拷贝 + 拷贝 被优化为 直接构造。中间商全部被干掉了。
5. 什么时候不能优化?
编译器虽然聪明,但它不能违背代码的逻辑。赋值操作是无法被优化成初始化的。
cpp
int main() {
A a(10); // 构造
cout << "--- 分割线 ---" << endl;
a = A(20); // 这里的匿名对象用于"赋值",而不是"初始化"
return 0;
}
输出:
text
【构造】 <-- a
--- 分割线 ---
【构造】 <-- A(20) 匿名对象生成
【赋值重载】 <-- 调用 operator=
【析构】 <-- A(20) 任务完成,立刻析构
【析构】 <-- main 结束,a 析构
注意 :这里生成了匿名对象,也进行了赋值操作,无法把这次构造优化掉(因为 a 已经存在了,不能重新构造它,只能改写它的值)。
6. 总结与实验方法
核心规律
在连续 的构造、拷贝构造过程中,编译器会极其激进地把它们合并成一次构造。
构造+拷贝→ \rightarrow →直接构造拷贝+拷贝→ \rightarrow →一次拷贝
如何看到"不优化"的样子?
如果你使用的是 Linux g++,可以加上编译选项 -fno-elide-constructors(禁止省略构造函数)。
bash
g++ test.cpp -fno-elide-constructors && ./a.out
你会看到一大堆的【拷贝构造】和【析构】,那就是没有优化时 C++ 笨重的样子。
只有一行结论需要记
**在初始化对象时,尽量使用匿名对象或直接返回对象,现代编译器会帮你把效率提升到极致。**不要因为担心拷贝开销而不敢写 return A(); 这种代码。