上一篇 C++ 类与对象(上) 讲了类的定义、实例化和 this 指针。这一篇深入类的内部机制——那些你不写编译器也会帮你生成的函数。

为什么需要默认成员函数

上篇我们手动写了 InitDestroy 来初始化和清理对象:

Stack st;
st.Init();      // 每次创建对象都要手动调用——忘了就出事
st.Push(1);
st.Destroy();   // 每次销毁对象都要手动调用——忘了就泄漏

如果一个类的使用者有 100 个人,每个人都要记住"创建后调 Init、销毁前调 Destroy"——封装的秩序感荡然无存。C++ 用默认成员函数解决这个问题:编译器在特定时机自动调用它们,把初始化和清理写进对象的生命周期里,而不是靠使用者的自觉。

一个类在不写任何代码的情况下,编译器会默认生成 6 个成员函数:

序号 函数 重要性
1 构造函数(Constructor) ★★★
2 析构函数(Destructor) ★★★
3 拷贝构造函数(Copy Constructor) ★★★
4 赋值运算符重载(Copy Assignment Operator) ★★★
5 普通取地址运算符重载
6 const 取地址运算符重载

C++11 之后还新增了两个:移动构造和移动赋值,后面再讲。学习每个默认成员函数,都从两个维度入手:

  1. 我们不写时,编译器默认生成的版本做了什么?是否满足需求?
  2. 不满足需求时,我们如何自己实现?

构造函数

基本规则

构造函数(Constructor)的名称与类名相同,没有返回值(连 void 也不写——这是 C++ 的规定)。对象实例化时系统自动调用对应的构造函数。

它的主要任务不是"开空间创建对象"(局部对象的空间在栈帧创建时就已经分配好了),而是初始化对象。本质上,构造函数就是要替代上篇中手动写的 Init 函数。

#include <iostream>

class Date {
public:
    // 1. 无参构造函数
    Date() {
        _year = 1;
        _month = 1;
        _day = 1;
    }

    // 2. 带参构造函数
    Date(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
    }

    // 3. 全缺省构造函数(和无参构造只能二选一,原因见下文)
    // Date(int year = 1, int month = 1, int day = 1) { ... }

    void Print() {
        std::cout << _year << "/" << _month << "/" << _day << std::endl;
    }

private:
    int _year;
    int _month;
    int _day;
};

int main() {
    Date d1;                    // 调用无参构造函数
    Date d2(2025, 1, 1);        // 调用带参构造函数

    // 注意:无参构造的对象后面不要跟括号!
    // Date d3();   // 编译器会把它当成函数声明,而非对象定义
    return 0;
}

关键规则总结:

  • 无参构造全缺省构造编译器默认生成的构造,三者统称为默认构造函数——即不需要传实参就能调用的构造函数。三者只能存在一个,无参构造和全缺省构造同时存在时,Date d1; 会让编译器不知道调用谁。
  • 构造函数支持重载,可以根据参数类型和数量定义多个版本。
  • 一旦你显式定义了任意构造函数,编译器就不再自动生成无参默认构造函数。如果你写了一个带参构造但没写无参构造,Date d1; 就会报错——找不到默认构造函数。
  • 无参创建对象时,对象名后面不能加括号Date d3(); 会被解析为"声明一个返回 Date 的函数",而非定义对象。

💡 背景补充:这条语法歧义继承自 C——C 语言中 int f(); 是函数声明,C++ 不得不兼容它。在 C++ 中这被称为"最烦人的解析(Most Vexing Parse)"。

编译器默认生成的构造函数

不写构造函数时,编译器生成的默认构造:

  • 内置类型(int、char、指针等)成员变量:不保证初始化——值取决于编译器,可能是随机值。
  • 自定义类型成员变量:调用该成员自己的默认构造函数。
class Stack {
public:
    Stack(int n = 4) {  // 自定义构造函数,带缺省参数
        _a = (int*)malloc(sizeof(int) * n);
        _capacity = n;
        _top = 0;
    }
private:
    int* _a;
    size_t _capacity;
    size_t _top;
};

// MyQueue 没有写构造函数,但它的两个 Stack 成员会调用 Stack 的默认构造
class MyQueue {
private:
    Stack pushst;   // 自动调用 Stack(4) 初始化
    Stack popst;    // 自动调用 Stack(4) 初始化
};

int main() {
    MyQueue mq;     // pushst 和 popst 都自动初始化好了
    return 0;
}

📖 参考:《C++ Primer Plus》第10章:构造函数的自动调用机制;《高质量C++/C编程指南》第9章:编译器自动生成函数的规则。

析构函数

析构函数(Destructor)与构造函数功能相反。它的名字是 ~类名,无参数无返回值。对象生命周期结束时,系统自动调用析构函数。

注意:析构函数不是销毁对象本身(局部对象的空间由栈帧自动回收),而是释放对象持有的资源——比如 malloc 申请的内存、打开的文件句柄等。

#include <iostream>

typedef int STDataType;

class Stack {
public:
    Stack(int n = 4) {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        _capacity = n;
        _top = 0;
    }

    ~Stack() {
        std::cout << "~Stack()" << std::endl;
        free(_a);            // 释放 malloc 的资源——不能漏
        _a = nullptr;
        _top = _capacity = 0;
    }

    // ... Push, Pop, Top 等方法

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

几条重要规则:

  • 一个类只能有一个析构函数,不可重载。
  • 编译器默认生成的析构函数,对内置类型成员不做处理,对自定义类型成员会调用它的析构函数。
  • 如果类没有申请资源(如 Date 只有三个 int),可以不写析构函数,用编译器生成的就够了。
  • 如果类申请了资源(如 Stackmalloc_a),必须自己写析构函数释放——否则就是资源泄漏。

📖 参考:《Effective C++》条款13:以对象管理资源——构造函数获取资源,析构函数释放资源,这就是 RAII 的基本实践。

析构顺序

同一个局部域内的多个对象,C++ 规定后定义的先析构

int main() {
    Stack st;       // 先构造
    MyQueue mq;     // 后构造
    return 0;
    // 析构顺序:先 ~MyQueue(),再 ~Stack()
}

C vs C++:构造函数 + 析构函数的实际价值

用括号匹配 isValid 为例对比。C 版本必须手动调用 STInit + STDestroy,每个 return 分支都要记得销毁——漏一处就是内存泄漏;C++ 版本创建即初始化,离开作用域自动清理,所有分支都不需要操心。

// C++ 版本:构造函数和析构函数自动管理,代码干净
bool isValid(const char* s) {
    Stack st;                        // 自动调用构造
    while (*s) {
        if (*s == '[' || *s == '(' || *s == '{') {
            st.Push(*s);
        } else {
            if (st.Empty()) return false;  // 自动析构——不用手动写 Destroy
            char top = st.Top();
            st.Pop();
            if ((*s == ']' && top != '[')
             || (*s == '}' && top != '{')
             || (*s == ')' && top != '('))
                return false;        // 自动析构——不用手动写 Destroy
        }
        ++s;
    }
    return st.Empty();               // 自动析构——不用手动写 Destroy
}

拷贝构造函数

什么是拷贝构造

如果一个构造函数的第一个参数是自身类类型的引用,且其他参数都有默认值,那它就是拷贝构造函数(Copy Constructor)。本质上是构造函数的一个重载。

class Date {
public:
    Date(int year = 1, int month = 1, int day = 1) {
        _year = year; _month = month; _day = day;
    }

    // 拷贝构造函数
    Date(const Date& d) {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

private:
    int _year, _month, _day;
};

⚠️ 拷贝构造的第一个参数必须是引用。如果写成传值 Date(const Date d),编译器直接报错——因为传值本身就要调用拷贝构造,触发无限递归。

拷贝构造的调用时机:

Date d1(2024, 7, 5);

Date d2(d1);       // 场景一:用同类型对象初始化新对象——调用拷贝构造
Date d3 = d1;      // 场景二:写法不同,本质也是拷贝构造(不是赋值!)

void Func(Date d) { /* ... */ }
Func(d1);           // 场景三:传值传参——拷贝构造

Date Func2() {
    Date tmp;
    return tmp;     // 场景四:传值返回——拷贝构造(产生临时对象)
}

浅拷贝 vs 深拷贝

编译器默认生成的拷贝构造做的是浅拷贝(Shallow Copy)——把每个成员变量逐字节复制过去。对于 Date 这样所有成员都是 int 的类,浅拷贝完全够用。

但对于 Stack 这样的类,_a 是指向 malloc 出来的堆内存的指针。浅拷贝只会复制指针的值,结果 st1st2_a 指向同一块内存:

浅拷贝前:  st1._a → [1, 2, 3, ...]
浅拷贝后:  st1._a → [1, 2, 3, ...] ← st2._a   (两个指针指向同一块!)

析构时,两个对象的析构函数都会 free(_a)——同一块内存被释放两次,程序崩溃。

必须自己实现深拷贝(Deep Copy)

class Stack {
public:
    Stack(int n = 4) {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        _capacity = n;
        _top = 0;
    }

    // 深拷贝:重新分配内存,复制数据内容
    Stack(const Stack& st) {
        _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
        memcpy(_a, st._a, sizeof(STDataType) * st._top);
        _top = st._top;
        _capacity = st._capacity;
    }

    ~Stack() {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

一个实用判断法则:如果类显式实现了析构函数并释放了资源,那么几乎一定需要显式写拷贝构造函数

📖 参考:《高质量C++/C编程指南》第9章:编译器默认生成的拷贝构造是位拷贝(浅拷贝),含指针成员的类必定出错。《C++ Primer Plus》第12章:需要深拷贝的三种信号——自定义析构、复制构造、赋值运算符。

赋值运算符重载

运算符重载基础

C++ 允许给自定义类型定义运算符行为,叫做运算符重载(Operator Overloading)。格式为 operator运算符

class Date {
public:
    // 重载 == 为成员函数:左操作数通过 this 传入,右操作数是参数 d
    bool operator==(const Date& d) const {
        return _year == d._year
            && _month == d._month
            && _day == d._day;
    }

    // 前置++:返回自增后的引用
    Date& operator++() {
        *this += 1;
        return *this;
    }

    // 后置++:int 参数仅为区分前置,不实际使用
    Date operator++(int) {
        Date tmp(*this);
        *this += 1;
        return tmp;
    }

private:
    int _year, _month, _day;
};

int main() {
    Date d1(2024, 7, 5), d2(2024, 7, 6);

    d1 == d2;     // 编译器转换为 d1.operator==(d2)
    ++d1;         // 转换为 d1.operator++()
    d1++;         // 转换为 d1.operator++(0)
}

运算符重载的关键约束:

  • 不能重载的五个运算符.* :: sizeof ?: .
  • 不能创建新的运算符(如 operator@
  • 必须至少有一个操作数是类类型——不能通过重载改变 int + int 的含义
  • 重载为成员函数时,第一个操作数通过 this 传入,参数比操作数少一个

<<>> 的重载必须写成全局函数(搭配友元),因为如果写成成员函数,this 会占据左侧操作数——结果你得写 d << cout 而不是 cout << d,不符合使用习惯。

赋值运算符重载

赋值运算符重载也是默认成员函数,用于在两个已存在的对象之间赋值。注意和拷贝构造的区分:

场景 调用的是
Date d2 = d1; 拷贝构造(用 d1 初始化正在创建的 d2
d2 = d1; 赋值重载(两个对象都已经存在)
class Date {
public:
    Date(int year = 1, int month = 1, int day = 1) {
        _year = year; _month = month; _day = day;
    }

    Date(const Date& d) {              // 拷贝构造
        _year = d._year; _month = d._month; _day = d._day;
    }

    Date& operator=(const Date& d) {   // 赋值重载
        if (this != &d) {              // 防止自我赋值
            _year = d._year;
            _month = d._month;
            _day = d._day;
        }
        return *this;                  // 返回引用以支持连续赋值
    }

private:
    int _year, _month, _day;
};

int main() {
    Date d1(2024, 7, 5);
    Date d2(d1);                       // 拷贝构造——d2 正在被创建
    Date d3(2024, 7, 6);
    d1 = d3;                           // 赋值重载——d1 早已存在

    Date d4 = d1;                      // 这仍然是拷贝构造!不是赋值!
    // d4 是刚创建的对象,= 被解释为初始化而非赋值
}

和拷贝构造类似的规律:

  • 编译器默认生成的赋值重载也是浅拷贝,逐字节复制。
  • Date 这种纯内置成员的类,默认赋值就够了。
  • Stack 这种有资源指针的类,必须自己实现深拷贝赋值(释放旧资源 → 分配新资源 → 复制 → 返回 *this)。
  • 一旦显式写了析构函数释放资源,就需要同时显式写拷贝构造和赋值重载——这就是"Big Three"原则。

📖 参考:《高质量C++/C编程指南》第9章:赋值函数的标准四步——检查自赋值 → 释放旧资源 → 分配新资源并复制 → 返回 *this 引用。

日期类实战

以下是一个完整的 Date 类实现,综合运用了构造函数、拷贝构造、赋值重载、运算符重载、<</>> 流插入、const 成员函数。这里只展示接口和关键实现:

class Date {
    friend std::ostream& operator<<(std::ostream& out, const Date& d);
    friend std::istream& operator>>(std::istream& in, Date& d);

public:
    Date(int year = 1900, int month = 1, int day = 1);

    bool operator<(const Date& d) const;
    bool operator<=(const Date& d) const { return *this < d || *this == d; }
    bool operator>(const Date& d) const  { return !(*this <= d); }
    bool operator>=(const Date& d) const { return !(*this < d); }
    bool operator==(const Date& d) const;
    bool operator!=(const Date& d) const { return !(*this == d); }

    Date& operator+=(int day);           // 返回引用,支持链式,高效
    Date  operator+(int day) const;      // 返回新对象,不改变自身
    Date& operator-=(int day);
    Date  operator-(int day) const;
    int   operator-(const Date& d) const; // d1 - d2 相差天数

    Date& operator++();                  // 前置++
    Date  operator++(int);               // 后置++(int 仅为区分)
    Date& operator--();
    Date  operator--(int);

private:
    int _year, _month, _day;
};

设计原则在代码中体现得很清楚:

  • operator+ 复用 operator+=(反之则不行——+ 如果复用 +=,每次 + 都要多构造一次临时对象)。
  • 关系运算符复用核心判断逻辑——六个比较符只需写好 <==,其余四个都通过组合实现,保证一致性。
  • const 成员函数中不能调用非 const 版本的操作——这是 const 正确性的基本要求。

const 成员函数

const 写在成员函数的参数列表后面,修饰的是隐式 this 指针:

class Date {
public:
    void Print() const {
        // 编译器实际将 this 的类型从 Date* const
        //   转为 const Date* const
        std::cout << _year << "-" << _month << "-" << _day << std::endl;
    }
};

int main() {
    Date d1(2024, 7, 5);
    d1.Print();                // 非 const 对象可以调用 const 函数(权限缩小)

    const Date d2(2024, 8, 5);
    d2.Print();                // const 对象只能调用 const 成员函数
    // d2 += 100;              // 编译报错:非 const 函数修改成员,不允许
}

const 对象只能调用 const 成员函数,非 const 对象两者都能调。这是权限逻辑:const 承诺不修改,传给更宽松的非 const 函数自然不行——你不能把一个"只读"标签贴给一个"可写"的操作。

取地址运算符重载

一般不需要自己写——编译器生成的默认版本返回 this 就够用了。只有极端场景(比如不想让别人获取对象地址)才需要重载,返回 nullptr 或其他地址。

常见误区

误区一:Date d1(); 是想创建对象

编译器把 Date d1(); 解析为函数声明而非对象定义。无参构造调用不要加括号——Date d1; 即可。

误区二:分不清拷贝构造和赋值

Date d2 = d1;   // 拷贝构造——d2 刚出生,= 是初始化语法糖
d2 = d3;        // 赋值重载——d2 早已存在

误区三:有指针成员的类不写深拷贝

浅拷贝让两个对象的指针指向同一块堆内存,析构时重复释放导致崩溃。

本节要点

  • 构造函数 = 自动调用的 Init。名同类名、无返回、可重载。默认构造函数是不传实参就能调用的。
  • 析构函数 = 自动调用的 Destroy~类名(),无参无返回,不可重载。有资源申请的类必须自己写。
  • 拷贝构造第一个参数必须是自身类类型的引用。传值会触发无限递归。
  • 浅拷贝只复制指针,深拷贝复制指针指向的数据。类中有 malloc/new 出来的资源,必须实现深拷贝。
  • 赋值 ≠ 拷贝构造。前者用于已存在的对象,后者用于正在创建的对象。两者都需要深拷贝时一并实现——Big Three 缺一不可。

📖 参考:《C++ Primer Plus》第10-12章 —— 构造函数、析构函数、运算符重载、动态内存与深拷贝;《高质量C++/C编程指南》第9章 —— 构造函数、析构函数与赋值函数的 Big Three 规范;《Effective C++》条款13、22 —— RAII 与封装原则。
迪亚波罗学编程

更多推荐