类与对象-下【由浅入深-C++】

文章目录

  • 前言
  • 第一章:初始化列表------成员变量的"原生"定义
    • [1\. 什么是初始化列表?](#1. 什么是初始化列表?)
    • [2\. 为什么非要用初始化列表?](#2. 为什么非要用初始化列表?)
      • 必须使用初始化列表的场景
        • [(1) 引用成员变量 (`reference`)](#(1) 引用成员变量 (reference))
        • [(2) const 成员变量](#(2) const 成员变量)
        • [(3) 没有默认构造函数的自定义类型成员](#(3) 没有默认构造函数的自定义类型成员)
    • [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))
      • [6\. 总结图表](#6. 总结图表)
    • 补充:对象的初始化方式
    • [5\. C++11 的成员变量缺省值](#5. C++11 的成员变量缺省值)
      • [1\. 核心规则:谁是老大?](#1. 核心规则:谁是老大?)
      • [2\. 具体流程图解](#2. 具体流程图解)
      • [3\. 代码实战演示](#3. 代码实战演示)
      • [4\. 编译器视角(发生了什么?)](#4. 编译器视角(发生了什么?))
      • 总结
  • [第二章:Static 静态成员](#第二章:Static 静态成员)
  • [第三章:友元(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; 也能跑,为什么要学新语法?"

对于普通的 intdouble 类型,在函数体内赋值和在初始化列表中初始化,差异不大(仅是效率上的微小差别)。但在以下三种情况 下,必须使用初始化列表,否则编译会直接报错。

必须使用初始化列表的场景

(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
  • 初始化过程
    1. 编译器按照声明顺序,先初始化 _a2
    2. 在初始化列表中寻找 _a2 的初始化逻辑,发现是 _a2(_a1)
    3. 此时 _a1 还没有被初始化,包含的是随机值(垃圾值)。
    4. 所以 _a2 被赋予了一个随机值。
    5. 接着初始化 _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;      // 函数体内赋值
    }
};

执行流程如下:

  1. 内存分配malloc 或 栈调整,划出 8 字节空间(假设 int 是 4 字节)。此时 _n_x 的位置有了,但值是乱码。
  2. 初始化列表
    • _n 被定义,并立即填入 100。
    • _x 被定义,因为列表里没写它,它保持随机乱码(但它已经是一个合法的 int 变量了)。
  3. 构造函数体
    • 执行 _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)后,花括号 {} 可以用来触发多参数的隐式转换。

语法形式

这种转换通常发生在:

  1. 赋值初始化Class obj = {arg1, arg2};
  2. 函数传参func({arg1, arg2});
  3. 函数返回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),比较危险

特殊情况:默认初始化

当你创建一个对象但不给任何参数时。

  1. 不带括号MyClass obj;
    • 调用默认构造函数。
    • 注意 :如果是内置类型(如 int a;)且在函数内部,它的值是未定义的(随机垃圾值)。
  2. 带空花括号 (值初始化):MyClass obj{}; / int a{};
    • 对于类,调用默认构造函数。
    • 对于内置类型,会被清零 (例如 int 变为 0)。推荐写法
  3. 带空圆括号 (千万别用):MyClass obj();
    • ⚠️ 这是陷阱! 编译器会认为你声明了一个函数,而不是创建对象。

总结对照表

方式 语法示例 explicit 构造函数 特点
拷贝初始化 A a = 1; ❌ 不允许 依赖隐式转换,看着像赋值
直接初始化 A a(1); ✅ 允许 强力推荐用于多参数构建
列表初始化 A a{1}; ✅ 允许 (不带=时) 最安全,防止精度丢失,现代 C++ 首选

5. C++11 的成员变量缺省值

简单来说:你在类声明里给的"缺省值",本质上就是给"初始化列表"提供了一个"备胎"。

1. 核心规则:谁是老大?

在成员变量初始化的战场上,只有一条铁律:
初始化列表(显式) > 类内缺省值(隐式)

2. 具体流程图解

当编译器准备生成构造函数代码,处理成员变量初始化时,它会按照以下逻辑进行判断(以成员变量 _a 为例):

  1. 第一步:看初始化列表

    • 构造函数冒号后面有没有写 _a(xxx)
    • 如果有直接使用 你写的值。类里的缺省值被无视
    • 如果没有:进入第二步。
  2. 第二步:看类内声明

    • class 定义里,_a 后面有没有写 = val{ val }
    • 如果有 :编译器会自动把这个缺省值填入初始化列表。
    • 如果没有:进入第三步。
  3. 第三步:原生默认初始化

    • 如果是内置类型(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. 如果初始化列表显式初始化了(情况1),就用初始化列表的值(_a 为 10)。
  2. 如果初始化列表没有写(情况2),就会使用声明时给的这个缺省值(_a 为 1)。

第二章:Static 静态成员

在第一章中,我们定义的成员变量(如 age, name)都是属于对象的。每创建一个新对象,就会在内存中拷贝一份这些变量。

但有时候,我们需要一份所有对象共享 的数据(比如:统计总共创建了多少个学生?),或者一个不需要依赖对象就能调用 的功能(比如:数学工具函数 Math::max(a, b))。这就是 static 的舞台。

2.1 静态成员变量

1. 核心概念

  • 归属 :静态成员变量属于类,而不属于某个具体的对象。
  • 共享 :无论你创建了多少个对象(甚至不创建对象),静态成员变量只有一份。所有对象共享这同一块内存。
  • 形象比喻
    • 普通成员 是每个学生的"钱包"(每人一个,互不干扰)。
    • 静态成员 是班级的"黑板 "或"班费"(全班只有一份,大家都能看到,都能修改)。

2. 语法规则

这是初学者最容易报错的地方,分为 声明定义 两步:

  1. 类内声明 :加 static 关键字。
  2. 类外定义(初始化)必须 在类外面进行初始化(除非是 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 指针带来的后果

这是考试和面试的重灾区:

  1. 静态成员函数只能访问静态成员变量/函数。它不能直接访问普通的成员变量(因为不知道这些变量属于哪个对象)。
  2. 普通成员函数可以随意访问静态成员(毕竟静态成员是公共资源)。

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 静态成员函数不能给缺省值

要理解这个,你只需要懂两个概念:

  1. 头文件(.h)是图纸,会被复印。
  2. 静态变量(static)是"唯一的公共设施"。

第一步:理解"头文件会被复印"(类一般在头文件中声明)

在 C++ 里,当你写 #include "MyClass.h" 时,编译器的动作非常粗暴:它直接把 MyClass.h 里的代码复制粘贴 到了你的 .cpp 文件里。

如果你有两个 .cpp 文件(比如 A.cppB.cpp)都引用了这个头文件,那么这个头文件的内容就会在 A.cpp 里出现一次,在 B.cpp 里又出现一次。

第二步:如果允许在类里赋值,会发生什么灾难?

假设 C++ 允许你这样写(实际上不允许):

cpp 复制代码
// MyClass.h (这是一张图纸)
class MyClass {
public:
    static int money = 100; // 假设允许这样写
};

场景还原:

  1. 编译 A.cpp

    • 编译器看到头文件里的 static int money = 100;
    • 编译器想:"哦,这里要造一个叫 money 的变量,存了 100 块钱。"
    • 于是,在 A 的地盘(A.obj)里,划了一块内存专门放 money
  2. 编译 B.cpp

    • 编译器又看到了头文件里的 static int money = 100;(因为头文件被复印过来了)。
    • 编译器想:"哦,这里 要造一个叫 money 的变量,存了 100 块钱。"
    • 于是,在 B 的地盘(B.obj)里, 划了一块内存放 money
  3. 最后一步:链接(Linker)出场

    • 链接器要把 A 和 B 拼成一个完整的程序。
    • 突然它发现:"不对啊!为什么 A 里有一个 money,B 里也有一个 money?而且名字一模一样!"
    • 这就是**"重定义错误"**。因为 static 的定义是"全程序唯一",现在却出现了两份,计算机不知道该听谁的,直接报错罢工。

第三步:正确的做法(声明与定义分离)

为了不让链接器打架,C++ 规定了这样的规矩:

1. 在头文件(图纸)里,只准"贴告示"(声明):

你只能告诉大家:"我们这个类,将会有一个叫 money 的公共财产,但它不在这里,别在这里造它。"

cpp 复制代码
// MyClass.h
class MyClass {
public:
    static int money; // 👈 这叫声明:只给个名字,不给数值,不占内存
};

A.cppB.cpp 包含它时,只知道有个叫 money 的东西存在,但谁都不敢在自己的地盘里造它。

2. 在某一个 .cpp 文件(施工队)里,真正的"造出来"(定义):

你必须选定一个(且只能是一个).cpp 文件,在里面实实在在得分配内存。

cpp 复制代码
// MyClass.cpp
int MyClass::money = 100; // 👈 这叫定义:真正的分配内存,存入100

这样,整个程序里,money 只在 MyClass.cpp 里被造了一次。A.cppB.cpp 指向的都是这同一个唯一的 money

只要记住这一个底层的逻辑:

"赋值" = "分配内存"。

  • 如果头文件里能赋值,因为头文件会被到处包含,就会导致到处都在分配同名的内存
  • 静态变量要求全局只能有一份内存
  • 冲突! 所以编译器禁止在类里的静态变量直接赋值。

那为什么 const 整数可以?

因为 const int A = 10; 这种写法,编译器在底层并没有 给它分配内存,而是把它当成了"替换文本"。

编译器看到代码里用 A,直接就把用 A 的地方偷偷换成了数字 10。既然没有分配内存,就不会有重名冲突,所以允许。

2.5 总结与对比表

特性 普通成员 (Non-static) 静态成员 (Static)
存储位置 栈或堆 (随对象存在) 全局数据区 (静态区)
生命周期 对象创建生,对象销毁死 程序启动生,程序结束死
副本数量 每个对象一份 全类共享一份
this 指针
访问权限 可以访问静态和非静态成员 只能访问静态成员
调用方式 object.func() Class::func() (推荐)

什么时候使用 static?

  1. 计数器:统计该类有多少个实例化对象。
  2. 共享配置:所有对象都需要读取同一个配置(如银行利率、游戏全局重力系数)。
  3. 工具函数 :不需要操作对象状态的函数(如 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 的友元类。

场景设定:
  • 类 BTV(电视机)。内部有很多复杂的私有电路(音量、频道、亮度)。
  • 类 ARemote(遥控器)。遥控器需要直接调节电视机的私有状态。
代码示例:
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 的友元。
  • 解释RemoteTV 的朋友(Remote 能动 TV 的私有数据),但不代表 TVRemote 的朋友。我也许给你但我家钥匙,但不代表你能把你要是你家钥匙给我。
2. 不可传递性(朋友的朋友不是朋友)
  • 如果 A 是 B 的友元,B 是 C 的友元,A 不是 C 的友元。
  • 解释:你把你家钥匙给了你哥们,你哥们把这把钥匙给了流浪汉,流浪汉依然进不了你家门(编译器不认)。
3. 不可继承性(父债子不还)
  • 父亲的朋友,不自动成为儿子的朋友。
  • 解释:如果基类有友元,那个友元无法访问派生类的私有成员。

5

5. 什么时候必须用友元?(实战价值)

你可能会问:"直接写 get/set 函数不就行了吗?为什么要破坏封装?"

最经典、最无法替代的场景是:运算符重载(Operator Overloading),尤其是 <<>>

你想直接用 cout << user; 打印一个对象吗?

  • coutstd::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;
}

总结

  1. 友元是封装的破坏者,也是效率的救星。
  2. 友元函数:普通函数拿到了"VIP 卡",能访问私有成员。
  3. 友元类:整个类的所有方法都拿到了"VIP 卡"。
  4. 原则 :能不用就不用(为了安全和耦合度),但在运算符重载紧密协作的类(如迭代器、链表节点)中非常必要。

第四章:内部类(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 对象。
真相完全不会!

内部类仅仅是名字被限制在了外围类的作用域里 。在内存布局上,OuterInner 是两个完全独立的类型。

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

总结

  1. 定义:类中定义的类。
  2. 目的:逻辑分组、隐藏类型名称(把辅助类藏起来)。
  3. 内存独立存在。外围类对象不包含内部类对象。
  4. 权限
    • 内部类是"天生友元",可以看外围类的 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. 总结

  1. 写法类名()类名(参数)
  2. 寿命 :默认情况下,当前行执行完立即析构
  3. 用途:用于临时传参、返回值,省去起名的麻烦。
  4. 续命 :使用 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;
}

恐怖的"理论"流程(关闭优化时):

  1. makeA 中构造 temp
  2. return temp 时,为了把值带回 main,会生成一个临时对象(拷贝构造)。
  3. makeA 结束,temp 析构。
  4. main 中用临时对象拷贝构造 ret
  5. 语句结束,临时对象析构。
  6. ret 析构。
    (总计:1次构造,2次拷贝,3次析构)

现代编译器的流程(RVO):

编译器直接把 main 函数中 ret 的地址悄悄传给了 makeAmakeA 里的 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(); 这种代码。

相关推荐
Tandy12356_2 小时前
手写TCP/IP协议栈——ARP超时重新请求
c语言·c++·网络协议·计算机网络
水天需0102 小时前
VS Code C++ 环境配置及 HelloWorld 程序
c++
初圣魔门首席弟子2 小时前
第六章、[特殊字符] HTTP 深度进阶:报文格式 + 服务器实现(从理论到代码)
linux·网络·c++
永远都不秃头的程序员(互关)2 小时前
查找算法深入分析与实践:从线性查找到二分查找
数据结构·c++·算法
Sunsets_Red2 小时前
二项式定理
java·c++·python·算法·数学建模·c#
好评1242 小时前
C/C++ 内存管理:摆脱野指针和内存泄漏
开发语言·c++·内存管理·c/c++
威哥爱编程2 小时前
【鸿蒙开发案例篇】NAPI 实现 ArkTS 与 C++ 间的复杂对象传递
c++·harmonyos·arkts
0 0 02 小时前
CCF-CSP 37-3 模板展开(templating)【C++】
c++·算法
埃伊蟹黄面2 小时前
二分查找算法
c++·算法·leetcode