C++基础:Stanford CS106L学习笔记 13 特殊成员函数(SMFs)

目录

      • [13.1 概述(Overview)](#13.1 概述(Overview))
      • [13.2 拷贝构造与拷贝赋值(Copy and Copy Assignment)](#13.2 拷贝构造与拷贝赋值(Copy and Copy Assignment))
      • [13.3 `delete` 关键字](#13.3 delete 关键字)
      • [13.4 移动构造与移动赋值(Move and Move Assignment)](#13.4 移动构造与移动赋值(Move and Move Assignment))
        • [13.4.1 左值引用和右值引用](#13.4.1 左值引用和右值引用)
        • [13.4.2 移动语义](#13.4.2 移动语义)
        • [13.4.3 强制移动语义`std::move`](#13.4.3 强制移动语义std::move)
        • [13.4.4 完美转发`std::forward()`](#13.4.4 完美转发std::forward())
        • **零原则**
        • **三原则**
        • 五原则

在 C++ 中, 特殊成员函数(Special Member Functions) 是编译器会在特定条件下自动生成的类成员函数,用于处理对象的核心操作(如创建、复制、移动、销毁等)。

13.1 概述(Overview)

特殊成员函数共 6 个,均与对象的 "生命周期管理" 和 "值传递语义" 直接相关,编译器默认生成的前提是:​类中未显式定义该函数,且程序中需要用到它​(即 "按需生成")。

我们不必写出其中任何一个!它们都有自动生成的默认版本! 6 个特殊成员函数包括:

构造函数 表达
默认构造函数(Default constructor) T()
析构函数(Destructor) ~T()
拷贝构造函数(Copy constructor) T(const T&)
拷贝赋值运算符(Copy assignment operator) T& operator=(const T&)
移动构造函数(Move constructor) T(T&&)
移动赋值运算符(Move assignment operator) T& operator=(T&&)

默认构造函数:不接收参数创建一个新对象。

拷贝构造:创建一个新对象,作为另一个对象的逐成员副本。

cpp 复制代码
Widget widgetOne;
Widget widgetTwo = widgetOne;    // 此时复制构造函数被调用

拷贝赋值运算符:将一个已存在的对象分配给另一个对象。

cpp 复制代码
Widget widgetOne;
Widget widgetTwo;
widgetOne = widgetTwo
// 请注意,这里两个对象都是在使用 = 运算符之前构建的。

析构函数:当对象超出作用域时调用。

13.2 拷贝构造与拷贝赋值(Copy and Copy Assignment)

二者均实现 "深拷贝" ​语义(编译器默认是 "浅拷贝",需手动优化),核心是 "用一个已存在的对象,创建 / 赋值给另一个对象"。

类型 功能 函数原型示例(假设类名为MyClass)
拷贝构造函数 用已有对象初始化新对象(如MyClass a = b;) MyClass(const MyClass& other);
拷贝赋值运算符 用已有对象给已存在的对象赋值(如a = b;) MyClass& operator=(const MyClass& other);
cpp 复制代码
template <typename T>
Vector<T>::Vector()
{
    _size = 0;
    _capacity = 4;
    _data = new T[_capacity];
}

这里发生了两个步骤:首先是_size、_capacity 和_data 可能已经被默认初始化了。然后是对变量的赋值,这实际上使工作量加倍了。

初始化成默认值,再重新赋值,是低效的!

我们可以使用初始化列表一次性声明它们并为其赋予所需的值:

cpp 复制代码
template <typename T>
Vector<T>::Vector() : _size(0), _capacity(4), _data(newT[_capacity]) { }

直接用预期值构造成员变量更快、更高效。

它适用于任何构造函数,甚至是带参数的非默认构造函数!

要是变量是不可赋值类型呢?

cpp 复制代码
template <typename T>
class MyClass {
    const int _constant;
    int& _reference;
public:
    // Only way to initialize const and reference members
    MyClass(int value, int& ref) : _constant(value), _reference(ref) { }
};

当类成员变量是不可赋值类型(如const常量或引用)时,必须使用初始化列表来初始化,而不能在构造函数体内赋值,原因如下:

  1. ​常量 (​​​ const​​) 的特性const变量一旦创建就必须初始化,且之后不能被修改。如果在构造函数体内尝试为const int _constant赋值,这本质上是修改操作,违反了const的只读属性,编译器会报错。
  2. 引用 (​​ &) 的特性 :引用必须在创建时就绑定到一个对象,且之后不能再绑定到其他对象。如果在构造函数体内为int& _reference赋值,实际是给引用所指向的对象赋值,而不是完成引用的初始化绑定,这不符合引用的语法规则。
  3. 初始化与赋值的区别:初始化列表在对象成员创建时直接完成初始化,而构造函数体中的操作是在成员已经创建后的赋值。对于不可赋值类型,必须在创建的同时完成初始化,因此只能通过初始化列表实现。
  • 编译器默认行为 :逐成员复制(浅拷贝 )------ 若类中包含指针、动态内存(如new分配的空间),会导致 "double free"(重复释放内存)等错误,此时必须显式重写这两个函数,实现深拷贝。

很多时候,你会希望创建一个副本,它所做的不仅仅是复制成员变量。

深拷贝

浅拷贝:

a和b的指针指向了同一块内存,就是浅拷贝,只是数据的简单赋值;

深拷贝:

在拷贝对象时,如果被拷贝对象内部还有指针引用指向其它资源,自己需要重新开辟一块新内存存储资源

是原始对象完整且独立的副本的对象

在这些情况下,你会希望用自己的实现来覆盖默认的特殊成员函数!

像任何函数一样,在头文件中声明它们,并在.cpp 文件中编写它们的实现!

这段代码是C++中Vector模板类的拷贝构造函数实现,用于实现对象的"深拷贝"(deep copy),解析如下:

  1. 这是一个模板类Vector<T>的拷贝构造函数,参数为const Vector<T>& other,表示以常量引用的方式接收另一个Vector对象
  2. 初始化列表部分:
    • _size(other._size):将当前对象的大小设置为与被拷贝对象相同
    • _capacity(other._capacity):将当前对象的容量设置为与被拷贝对象相同
    • _data(new T[other._capacity]):为当前对象分配新的内存空间,大小与被拷贝对象的容量一致
  3. 循环部分:
    • 通过for循环遍历被拷贝对象的数据
    • other._data[i]的值逐个复制到当前对象的_data[i]
  4. 深拷贝的意义:
    • 这种方式会创建新的内存空间并复制数据内容,而不是简单地共享指针
    • 这样两个对象(当前对象和被拷贝的other对象)会拥有各自独立的数据副本
    • 避免了浅拷贝可能导致的双重释放(double free)问题,以及一个对象修改数据影响另一个对象的问题

图示中两个对象的data指针指向不同的内存地址,但存储的内容相同,直观展示了深拷贝的特点。

13.3 delete 关键字

delete 用于​禁止编译器生成特定的特殊成员函数​,本质是 "主动关闭某种语义",避免程序中出现不安全的对象操作。

使用场景 ​:当类不应该支持复制 / 移动时(如单例类、管理独占资源的类),显式用delete禁用对应的函数。

示例​:

cpp 复制代码
class NoCopy {
public:
    // 禁止复制构造和复制赋值
    NoCopy(const NoCopy&) = delete;
    NoCopy& operator=(const NoCopy&) = delete;
    // 允许默认构造
    NoCopy() = default;
};
  • 注意:delete 也可用于普通函数(如禁止特定参数类型的重载),但核心用途是控制特殊成员函数。

将一个特殊成员函数设为删除状态会移除其功能!

cpp 复制代码
vector<int> func(vector<int> vec0) {    // 拷贝构造函数
    vector<int> vec1;                // 默认构造函数
    vector<int> vec2(3);             // 自定义构造函数,不是SMF
    vector<int> vec3{3};             // 统一初始化,不是SMF
    vector<int> vec4();              // 函数定义
    vector<int> vec5(vec2);          // 拷贝构造函数
    vector<int> vec6{};              // 统一初始化,初始化列表是空的
    vector<int> vec7{static_cast<int>(vec2.size() + vec6.size())};    // 统一初始化
    vector<int> vec8 = vec2;         // 拷贝构造函数
    vec8 = vec2;                     // 拷贝赋值函数
    return vec8;                     // 拷贝构造函数
}

13.4 移动构造与移动赋值(Move and Move Assignment)

13.4.1 左值引用和右值引用
cpp 复制代码
void foo(Photo pic) {
  Photo beReal = pic;             // pic是左值
  Photo insta = takePhoto();      // takePhoto()是右值
}

void foo(Photo pic) {
  Photo* p1 = &pic;         // pic是左值,有栈上地址,&pic能获取有效地址
  Photo* p2 = &takePhoto(); // ❌ Doesn't work!右值不能用&引用,报错:takePhoto()是右值,无持久地址,&操作符无效
}

判断等号右边的是左值or右值:

cpp 复制代码
int a = 4;                      // 右
int& b = a;                     // 左   
vector<int> c = {1, 2, 3};      // 右
int d = c[1];                   // 左
int* e = &c[2];                 // 右
size_t f = c.size();            // 右

左值的生命周期持续到作用域结束,是持久的。

右值的生命周期持续到行尾,是临时的。


如果我们有一个左值,如何避免复制它的内存?左值引用

cpp 复制代码
void upload(Photo pic);
int main() {
  Photo selfie =takePhoto(); // selfie is lvalue
  upload(selfie); // 🤦 Unnecessary copy is made here
}

// 通过按引用传递!
void upload(Photo&​ ​pic);
int main() {
  Photo selfie = takePhoto(); // selfie is lvalue
  upload(selfie); // ✅ No copy is made here
}

如果我们有一个右值,如何避免复制它的内存?

cpp 复制代码
void upload(Photo& pic);
int main() {
  upload(takePhoto()); // Does this work?
}
// ❌ candidate function not viable: expects lvalue as 1st argument

// 用&&实现右值引用!
void upload(Photo&&​ ​pic);
int main() {
  upload(takePhoto());
}

左值引用

  • 语法:Type&
  • 持久存在,函数终止后必须保持对象处于有效状态

右值引用

  • 语法:Type&&
  • 临时的,我们可以窃取(移动)其资源
  • 对象可能会处于无效状态,但没关系!因为它是临时的!
13.4.2 移动语义

我们之所以有移动语义(move semantics),是因为在某些情况下,我们将要获取的资源,其原所有者(original owner)已不再需要该资源。

cpp 复制代码
Photo selfie = pic;
// copy persistent objects (e.g. variables)
// that might get used in the future

Photo selfie = takePhoto();
// move temporary objects (e.g return values)
// since we no longer need to use them

C++11 引入,用于实现 ​**"移动语义"**​------ 核心是 "转移对象的资源所有权",而非复制资源,避免不必要的内存拷贝(提升性能)。

油管视频

类型 功能 函数原型示例
移动构造函数 用 "即将销毁的临时对象" 初始化新对象(如函数返回值) MyClass(MyClass&& other);(&&为右值引用)
移动赋值运算符 用 "临时对象" 给已存在对象赋值 MyClass& operator=(MyClass&& other);
  • 核心逻辑 :移动时,只 "窃取" 源对象的资源(如指针指向的内存),并将源对象的资源指针设为nullptr(避免源对象析构时释放资源);
  • 编译器默认行为:若类中未显式定义移动函数,且未显式定义复制函数 / 析构函数,编译器会自动生成 "逐成员移动" 的版本;若显式定义了复制函数或析构函数,移动函数默认不会生成。

代码练习:

Photo selfie = takePhoto();

在这节课中,我们认为它发生两个行为:

1、调用拷贝构造函数,拷贝给selfie

2、调用析构函数,析构takePhoto()

事实上,不会真正调用拷贝构造函数和析构函数,原因是编译器会有返回值优化Return-value optimization (RVO)

函数的返回值是临时的,会在下一行代码执行前被销毁。

拷贝语义:

移动语义(避免了复制):assigment7/unique_ptr.h的移动构造函数处体现


比喻:

13.4.3 强制移动语义std::move

使用 std::move(x) 可将左值(l-value)x 转换为右值(r-value),以便你能直接获取它所拥有的资源。

以下代码使用拷贝语义,然而在这个场景下,原位置的元素 (elems[i - 1]) 在被拷贝后就不再被使用了(因为后续会被其他元素覆盖)。

因此,使用拷贝语义会造成不必要的性能开销。

cpp 复制代码
void PhotoCollection::insert(const Photo& pic, int pos) {
    for (int i = size(); i > pos; i--) 
        elems[i] = elems[i -- 1]; // Shuffle elements down 
    elems[i] = pic;
 }

通过强制使用移动语义(例如使用std::move),可以避免不必要的拷贝操作,提高代码效率,尤其是在处理大型对象或进行频繁插入操作时。

cpp 复制代码
void PhotoCollection::insert(const Photo& pic, int pos) {
     for (int i = size(); i > pos; i--) 
         elems[i] = std::move(elems[i -- 1]); 
     elems[i] = pic;
 }

注意,不要用std::move移动左值,否则会导致未知状态!

另一个例子:

cpp 复制代码
class Photo {
public:
  Photo::Photo(Photo&& other) {
  keywords = other.keywords;    // other是临时的,我们真的需要拷贝吗?
  }

private:
  std::vector<string> keywords;
};

// 使用std::move
class Photo {
public:
  Photo::Photo(Photo&& other) {
  keywords = std::move(other.keywords);
  }

private:
  std::vector<string> keywords;
};

std::move的本质​:它本身并不执行任何 "移动" 操作,只是将左值(lvalue)强制转换为右值引用(rvalue reference),其内部实现类似:

static_cast<typename std::remove_reference<T>::type&&>(t)

风险提示 ​:和const_cast一样,使用std::move是一种 "主动选择" 可能有风险的行为。因为被移动后的对象可能处于未定义状态,如果继续使用会导致不可预期的错误。

使用建议 ​:除非有充分理由(如确实需要优化性能,且明确知道被移动的对象不会再被使用),否则应避免显式使用std::move

13.4.4 完美转发std::forward()

视频1视频2

在 C++11 之前,泛型函数在传递参数时无法保持参数的原始类型 (左值或右值), 导致额外的拷贝或移动操作。完美转发 (Perfect Forwarding) 是一种高效传递参数的技术,能够保持参数的原始特性,避免额外的性能开销。

完美转发是指在泛型模板函数中,以参数的原始形式 (左值或右值) 传递给目标函数,从而避免不必要的拷贝或移动操作。

cpp 复制代码
#include <iostream>
using namespace std;

void process (int& x) { cout << "Lvalue reference:"<< x << endl;}
void process (int&&x) ( cout << "Rvalue referendCe:"<< x <<endl;

// 泛型函数,使用完美转发
template <typename T>
void forwardExample(T&& arg) {    // 万能引用 = 右值引用 + 模板
    process(std::forward<T>(arg));// 关键:std::forrward 保持原始类型
}
int main (){
    int a = 10;
    forwardExample (a);    // 传递左值
    forwardExample (20);   // 传递右值
    return 0;
}

std::forward<T>(arg)通过引用折叠​类型推导决定参数是否应该保留右值特性。

T类型 T&&推导后 std::forward(arg)结果
int int&& 右值int&&
int&左值 int& && -> int& 左值int&
int&&右值 int&& &&-> int&& 右值int&&

应用

cpp 复制代码
// 传递构造函数参数
class MyClass {
public:
    template <typename T>
    MyClass (T&& arg) : data(std::forward<T>(arg)) {}
private:
    int data;
};
// std::forward<T>(arg) 确保 arg 以最佳方式传递给 data, 避免不必要的拷贝

///////////////////////////////////////////////////////////////////////////
// 传递函数参数
#include <utility>
void print (const std::string& s){
    std::cout << "Lvalue:"<< s << std::endl;
void print (std::string&& s){
    std::cout << "Rvalue:"<< s << std::endl;
// 通过完美转发调用 print
template <typename T>
void callPrint(T&& arg){
    print(std::forward<T>(arg));
}

std::move和std::forward区别

1、应用场景(语义)

std::move 用于移动语义,从而实现资源转移

std::forward 用于完美转发,使目标函数接收到的实参与被传递给转发函数的实参保持一致 (值类别)

2、转换右值

std::move无条件的将变量 (const 变量除外) 转换为右值引用

std::forward有条件的 (变量被右值初始化时)将变量转换为右值引用

零原则

"零规则"(Rule of Zero)是 C++ 中的一个重要设计原则,核心思想是:当类不需要手动管理资源时,应避免定义任何特殊成员函数(SMF),包括构造函数、析构函数、拷贝构造函数和拷贝赋值运算符等。

cpp 复制代码
class a_string_with_an_id() {
    public:
        /// getter and setter methods for our private variables
    private:
        int id;
        std::string str;
}
a_string_with_an_id object;

在你提供的示例中,a_string_with_an_id类包含两个成员变量:int idstd::string str。这两个类型都是 C++ 标准库中的基础类型或已正确实现资源管理的类型:

  • int是基本数据类型,无需特殊资源管理
  • std::string内部已经妥善实现了所有必要的特殊成员函数,能够自动管理字符串内存

因此,根据零规则,a_string_with_an_id类不需要显式定义任何特殊成员函数。编译器会自动生成默认版本,这些默认版本会正确调用成员变量的相应函数(如std::string的拷贝构造函数),从而实现正确的资源管理。

这样做的好处是:

  1. 减少代码冗余,避免重复实现已有功能
  2. 降低出错风险,避免手动管理资源时可能出现的错误
  3. 使代码更简洁、更专注于类的核心功能
三原则

要理解C++中的"三法则(Rule of Three)",核心是抓住编译器默认函数的局限性 与​手动内存管理的冲突​,具体可拆解为以下逻辑:

1. 三法则的核心定义

"三法则"是C++类设计的基础准则:当一个类需要自定义析构函数(destructor) 时,它几乎必然也需要自定义​拷贝构造函数(copy constructor)​和​**拷贝赋值运算符(copy assignment operator)**​。这三个函数共同负责类的"资源管理",缺一不可。

2. 为什么需要自定义析构函数?

类需要自定义析构函数的核心场景是:​类内部手动管理了动态资源 ​(比如用new分配的内存、打开的文件句柄、网络连接等)。

编译器生成的默认析构函数 只会做​浅清理​------它只会销毁类的成员变量本身(如指针变量),但不会释放指针指向的动态内存(或关闭资源)。若不自定义析构函数,会导致内存泄漏(动态内存永远无法回收)或资源泄漏(文件/连接一直占用)。

举个例子:

cpp 复制代码
class MyString {
private:
    char* data; // 手动管理动态内存的指针
public:
    // 构造函数:用new分配内存
    MyString(const char* s) {
        data = new char[strlen(s) + 1];
        strcpy(data, s);
    }
    // 必须自定义析构函数:释放new的内存,否则内存泄漏
    ~MyString() { delete[] data; } 
};

3. 为什么缺了拷贝构造/赋值会出问题?

当类有自定义析构函数(即手动管理资源)时,编译器生成的默认拷贝构造函数默认拷贝赋值运算符会执行"浅拷贝"------只复制成员变量的"值",而不复制其指向的动态资源。

这会导致两个严重问题:

​**双重释放(double free)**​:两个对象的指针指向同一块动态内存。当它们生命周期结束时,析构函数会被调用两次,试图释放同一块内存,触发程序崩溃。

​**悬垂指针(dangling pointer)**​:若一个对象释放了内存,另一个对象的指针会变成"悬垂指针"(指向已释放的内存),后续访问会导致未定义行为(程序崩溃、乱码等)。

用上面的MyString举例(未自定义拷贝函数的错误情况):

cpp 复制代码
MyString s1("hello");
MyString s2 = s1; // 调用默认拷贝构造:s2.data = s1.data(浅拷贝,指向同一块内存)
// 生命周期结束时:s2先析构,释放data指向的内存;s1再析构,试图释放已释放的内存→崩溃

4. 总结:三法则的本质

"三法则"的本质是"资源管理的一致性":

自定义析构函数:负责"释放资源",避免泄漏;

自定义拷贝构造/赋值:负责"复制资源"(深拷贝),避免多个对象共享同一份资源导致的双重释放或悬垂指针。

编译器无法自动生成能处理"手动资源管理"的拷贝函数------它只能做浅拷贝,而浅拷贝在有动态资源的场景下必然出错。因此,只要需要自定义析构函数,就必须配套自定义另外两个拷贝相关的函数。

五原则

Rule of Five 是 C++ 中关于类资源管理的核心设计准则,本质是为了避免"资源泄漏""浅拷贝错误",并优化性能,其核心逻辑围绕"类是否管理了堆内存、文件句柄等非自动释放的资源"展开。

1. 核心背景:为何需要"五法则"?

C++ 编译器会为每个类默认生成 5 个特殊成员函数(默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值------实际是 6 个,默认构造单独归类)。但这些默认函数仅适用于"无资源管理"的简单类 (如仅包含 intdouble 等基础类型的类)。

一旦类需要管理"资源"(如 new 分配的堆内存、打开的文件、网络连接等),默认函数的"浅拷贝""无资源释放"特性会导致严重问题:

  • 默认析构函数不会释放堆内存 → 资源泄漏
  • 默认拷贝构造/赋值会直接复制指针(浅拷贝)→ 多个对象指向同一块内存,析构时重复释放 → 程序崩溃

此时就需要手动定义部分特殊成员函数,而"五法则"则规定了:手动定义其中一个,就该考虑定义全部,尤其要补全移动语义相关函数以优化性能

2. "五法则"包含的 5 个特殊成员函数

这 5 个函数均与"对象的创建、复制、移动、销毁"及"资源处理"直接相关,具体功能如下:

函数类型 核心作用 关键场景
析构函数(Destructor) 释放类管理的资源(如delete堆内存、关闭文件),避免资源泄漏 对象生命周期结束时自动调用
拷贝构造函数(Copy Constructor) 用一个对象"深拷贝"初始化新对象(复制资源本身,而非仅复制指针) A a = b;func(A a)
拷贝赋值运算符(Copy Assignment Operator) 用一个对象"深拷贝"赋值给另一个已存在的对象(先释放目标对象旧资源,再复制) a = b;(a 已初始化)
移动构造函数(Move Constructor) "窃取"一个临时对象的资源(不复制,直接接管指针),避免无用拷贝 临时对象初始化新对象(如函数返回值)
移动赋值运算符(Move Assignment Operator) "窃取"一个临时对象的资源,赋值给已存在的对象 a = func();(func 返回临时对象)

3. 核心准则:"要一个,就尽量要全部"

  • 强制逻辑 :若手动定义了"析构函数""拷贝构造""拷贝赋值"中的任意一个,说明类需要管理资源------此时编译器会取消默认的移动构造/移动赋值 。若不手动定义这两个移动函数,当代码中需要"移动对象"时(如临时对象传递),会退而使用"拷贝函数",导致不必要的深拷贝,性能下降
  • Optional的含义:移动构造/移动赋值的"可选",并非指"可以不定义",而是指"编译器不会强制报错"------但从性能和资源效率角度,只要类需要管理资源,就应该定义它们,避免冗余拷贝。

4. 一句话总结

当你的 C++ 类需要管理堆内存、文件等资源(不得不手动写析构/拷贝函数)时,务必补全移动构造和移动赋值函数------否则代码能跑,但会做无用功,变慢。

相关推荐
小智RE0-走在路上2 小时前
Python学习笔记(6)--列表,元组,字符串,序列切片
笔记·python·学习
云泽8082 小时前
STL容器性能探秘:stack、queue、deque的实现与CPU缓存命中率优化
java·c++·缓存
J ..2 小时前
C++ Lambda 表达式完全指南
c++
Qt程序员2 小时前
从 C++11 到 C++23:枚举的原理升级与实践
c++·c++23
CC.GG2 小时前
【C++】红黑树
java·开发语言·c++
d111111111d2 小时前
什么是内存对齐?在STM32上面如何通过编辑器指令来实现内存对齐。
笔记·stm32·单片机·嵌入式硬件·学习·编辑器
闻缺陷则喜何志丹3 小时前
【计算几何 线性代数】仿射矩阵的秩及行列式
c++·线性代数·数学·矩阵·计算几何·行列式·仿射矩阵得秩
xu_yule3 小时前
算法基础-背包问题(01背包问题)
数据结构·c++·算法·01背包
特立独行的猫a3 小时前
C++ Core Guidelines(C++核心准则):2025现代C++开发关键要点总结
c++·core guidelines·核心准测