C++类和对象:六大默认成员函数与深浅拷贝的底层逻辑
2026/7/17 6:37:05 网站建设 项目流程

文章目录

  • 一、六大默认成员函数总览
  • 二、构造函数:对象的出厂设置
    • 2.1 构造函数到底干了什么
    • 2.2 特性
    • 2.3 编译器默认生成的构造函数做了什么
  • 三、析构函数:给对象办后事
    • 3.1 析构函数不是销毁对象本身
    • 3.2 什么时候必须写析构
  • 四、拷贝构造函数:对象的克隆术
    • 4.1 什么场景触发拷贝构造
    • 4.2 为什么参数必须是引用
    • 4.3 浅拷贝的致命问题
    • 4.4 深拷贝的正确实现
  • 五、赋值运算符重载:对象间的数据转移
    • 5.1 赋值 vs 拷贝构造
    • 5.2 赋值运算符重载的基本格式
    • 5.3 深拷贝的赋值运算符
    • 5.4 前置++和后置++的重载
    • 5.5 流插入和流提取的重载
  • 六、const成员函数
  • 总结

C++的类有六个默认成员函数,你写了,编译器按你的来;你不写,编译器自己生成一套。这六个里,前四个(构造、析构、拷贝构造、赋值重载)是重点——它们分别对应对象的「出生」「死亡」「克隆」「赋值」四个生命周期节点。搞懂了这四个,类的设计能力直接上一个台阶。


一、六大默认成员函数总览

classDate{public:// 1. 构造函数Date();// 2. 析构函数~Date();// 3. 拷贝构造函数Date(constDate&d);// 4. 赋值运算符重载Date&operator=(constDate&d);// 5. 取地址运算符重载(普通)Date*operator&();// 6. 取地址运算符重载(const)constDate*operator&()const;};

五个关键原则:

  • 你不写,编译器自动生成。但编译器生成的版本行为固定,不一定满足需求。
  • 一旦你显式定义了一个,编译器就不再生成那个版本
  • 前四个是重点,后两个(取地址重载)绝大多数情况下直接用编译器的就行,不需要自己写。

二、构造函数:对象的出厂设置

2.1 构造函数到底干了什么

构造函数的名字容易让人误解——它不开空间。局部对象在栈帧创建时空间已经开辟好了,构造函数做的事是初始化这片空间

它的本质是替代以前C里写的Init()函数。区别在于:Init()需要你手动调,构造函数自动调。这是质的飞跃——你再也不会忘记初始化一个对象了。

2.2 特性

classDate{public:// 1. 无参构造函数Date(){_year=1;_month=1;_day=1;}// 2. 带参构造函数Date(intyear,intmonth,intday){_year=year;_month=month;_day=day;}// 3. 全缺省构造函数(和无参构造不能同时存在,调用会引起歧义)// Date(int year = 1, int month = 1, int day = 1)// {// _year = year;// _month = month;// _day = day;// }voidPrint(){cout<<_year<<"/"<<_month<<"/"<<_day<<endl;}private:int_year;int_month;int_day;};intmain(){Date d1;// 调用无参构造函数(默认构造)Dated2(2025,1,1);// 调用带参构造函数// 坑:下面这句不是定义一个对象,是声明一个函数!// Date d3(); // 编译器会当成:函数声明,返回Date,参数为void// 正确写法——无参构造调用时,不加括号Date d3;// 这才是调用默认构造return0;}

六条铁律

  1. 函数名与类名相同,无返回值(不需要写void,这是语法规定)。
  2. 对象实例化时自动调用
  3. 可以重载
  4. 你不写编译器自动生成一个无参的默认构造;你一旦写了,编译器就不再生成
  5. 默认构造函数指的是「不传实参就能调用的构造函数」,包括三种形式:无参构造函数、全缺省构造函数、编译器自动生成的构造函数。这三种有且只能存在一个
  6. Date d1;Date d1()不一样——后者是函数声明。

2.3 编译器默认生成的构造函数做了什么

很多人以为编译器什么都不做。实际上它做了,只是选择性地做

// 内置类型classDate{public:// 不写构造,编译器生成默认构造private:int_year;// 内置类型:编译器不做初始化,值是随机的int_month;int_day;};// 自定义类型classStack{public:Stack(intn=4){_a=(int*)malloc(sizeof(int)*n);_capacity=n;_top=0;}private:int*_a;size_t _capacity;size_t _top;};classMyQueue{public:// 没写构造,编译器生成默认构造// 这个默认构造会调用 Stack 的默认构造,完成 pushst 和 popst 的初始化private:Stack pushst;Stack popst;};intmain(){MyQueue mq;// OK:编译器生成的默认构造调了 Stack 的构造return0;}

结论一句话

  • 内置类型成员:编译器默认生成的不一定会初始化,值是未定义的。
  • 自定义类型成员:编译器会调用该成员自己的默认构造函数。如果这个成员没有默认构造函数,编译报错。

这就是为什么MyQueue不写构造也能正常工作——它的两个Stack成员各有自己的默认构造,编译器默认生成的构造函数会去调用它们。


三、析构函数:给对象办后事

3.1 析构函数不是销毁对象本身

局部对象的栈空间是栈帧回收时自动归还的,析构函数做的是对象内部的资源清理。比如动态申请的内存、打开的文件、网络连接,这些系统不会替你收拾。

classStack{public:Stack(intn=4){_a=(int*)malloc(sizeof(int)*n);_capacity=n;_top=0;}~Stack(){cout<<"~Stack()"<<endl;free(_a);// 释放构造函数里malloc出来的内存_a=nullptr;_top=_capacity=0;}// 其余成员函数省略...private:int*_a;size_t _capacity;size_t _top;};

六条关键特性

  1. 函数名:~+ 类名。无参数,无返回值。
  2. 一个类只能有一个析构函数——不能重载,没有参数,怎么重载?
  3. 对象生命周期结束时自动调用。
  4. 不写编译器自动生成——行为规则和构造一样:内置类型不做处理,自定义类型调用它的析构。
  5. 显式写了析构函数后,自定义类型成员的析构仍然会被自动调用(编译器会帮你插入调用代码,不需要你手动写)。
  6. 局部对象的析构顺序是后构造的先析构(LIFO,和栈帧的工作方式一致)。

3.2 什么时候必须写析构

情况是否需要写析构例子
类中没有资源申请不需要Date——存三四个int,不需要析构
类中有资源申请必须写Stack——有malloc,必须free
类中成员都是「会自己析构的」不需要MyQueue——两个Stack成员各自有析构,不需要额外管

判断标准很简单:如果你的构造函数里 new/malloc/fopen 了,析构函数里就必须写对应的 delete/free/fclose。其他情况没写不用担心,反而多写可能制造问题。

有了构造和析构,以前在C里写Stack时频繁忘记Init()Destroy()的问题就不再存在。对象创建时自动初始化,离开作用域时自动清理——这就是RAII的雏形。


四、拷贝构造函数:对象的克隆术

4.1 什么场景触发拷贝构造

Dated1(2025,1,1);Dated2(d1);// 场景1:用同类对象初始化另一个对象Date d3=d1;// 场景2:看起来是赋值,但d3刚创建,实际调的是拷贝构造

两个场景本质一样:用已存在的对象去初始化一个正在创建的新对象

4.2 为什么参数必须是引用

// 错误写法——参数是值传递Date(Date d){_year=d._year;_month=d._month;_day=d._day;}

如果参数是值传递,调用Date d2(d1)时:

  1. 需要把d1传给参数d
  2. 传值调用→又触发拷贝构造→又需要把d1传给参数……
  3. 无限递归,栈溢出

正确写法必须是const 类名&

Date(constDate&d){_year=d._year;_month=d._month;_day=d._day;}

const修饰保证了不会意外修改原对象,引用避免了无限递归。

4.3 浅拷贝的致命问题

编译器默认生成的拷贝构造是浅拷贝(值拷贝)——把每个成员变量按字节复制过去。对于Date这种只含内置类型的类,浅拷贝完美够用。对于Stack这种有指针指向动态内存的类,灾难来了:

classStack{public:Stack(intn=4){_a=(int*)malloc(sizeof(int)*n);_capacity=n;_top=0;}~Stack(){free(_a);}// 没写拷贝构造——用编译器生成的浅拷贝private:int*_a;// 指向堆上的数组size_t _capacity;size_t _top;};intmain(){Stack st1;st1.Push(1);Stackst2(st1);// 浅拷贝!st2._a 和 st1._a 指向同一块内存// st1 和 st2 析构时,同一块内存被 free 两次 → 崩溃return0;}

问题根源:两个对象的_a指向同一块堆内存。一个对象修改数据,另一个也被影响;析构时同一块内存被释放两次,直接崩。

4.4 深拷贝的正确实现

Stack(constStack&st){// 1. 申请一块新的独立内存_a=(int*)malloc(sizeof(int)*st._capacity);if(_a==nullptr)throwbad_alloc();// 2. 把数据拷贝过去(注意:用 _top 而非 _capacity,只复制有效数据)memcpy(_a,st._a,sizeof(int)*st._top);// 3. 复制其他值类型的成员_top=st._top;_capacity=st._capacity;}

这样st1._ast2._a指向两块独立的堆内存,内容和大小相同,但互不干涉。各自析构时各自释放自己的内存,相安无事。


五、赋值运算符重载:对象间的数据转移

5.1 赋值 vs 拷贝构造

Dated1(2025,1,1);Dated2(2026,6,1);d2=d1;// 赋值:d2 已经存在,把 d1 的数据拷给 d2Date d3=d1;// 拷贝构造:d3 正在创建,用 d1 初始化 d3

赋值和拷贝构造是两个独立的概念,不要因为语法上都有=而混掉:关键在于对象是否已经存在

5.2 赋值运算符重载的基本格式

classDate{public:Date&operator=(constDate&d){if(this!=&d)// 防止自己给自己赋值{_year=d._year;_month=d._month;_day=d._day;}return*this;// 返回 *this 支持连续赋值:d1 = d2 = d3}};

三点解释:

  1. 返回值是类名&:支持d1 = d2 = d3这种连续赋值。
  2. 参数是const 类名&:和拷贝构造一样,避免不必要的拷贝。
  3. 自赋值检查if (this != &d)是必要的——如果自己赋给自己,先释放再拷贝就等于自己把自己弄没了。Date这种纯值类型不检查也能活,但如果有资源管理(类似Stack),不检查就是灾难。

5.3 深拷贝的赋值运算符

classStack{public:Stack&operator=(constStack&st){if(this!=&st){// 1. 释放旧资源free(_a);// 2. 申请新资源_a=(int*)malloc(sizeof(int)*st._capacity);// 3. 拷贝数据memcpy(_a,st._a,sizeof(int)*st._top);// 4. 更新值类型成员_top=st._top;_capacity=st._capacity;}return*this;}};

四步操作:释放旧资源 → 申请新资源 → 拷贝数据 → 更新成员。顺序不能乱——先释放再申请是为了避免旧资源泄漏;先检查自赋值再释放是为了避免自己把自己干掉了。

5.4 前置++和后置++的重载

这是运算符重载里最精巧的设计之一:

classDate{public:// 前置++:++d1 → d1.operator++()// 返回引用,因为++后的d1还在,不需要产生临时对象Date&operator++(){*this+=1;return*this;}// 后置++:d1++ → d1.operator++(0)// 多一个 int 参数仅用于区分前置和后置,没有实际意义// 返回值不能是引用,因为返回的是加之前的临时副本Dateoperator++(int){Datetmp(*this);// 先保存当前状态*this+=1;// 再自增returntmp;// 返回自增前的副本}};

区别一目了然:

特性前置++d后置d++
函数签名Date& operator++()Date operator++(int)
返回值引用(自增后的自己)值(自增前的拷贝)
性能无额外拷贝有一次临时拷贝的构造和析构
效率更高较低

能用前置++就不要用后置++——不是为了炫技,是少了那一次不必要的临时拷贝。这个原则所有带自增自减运算符的类都适用。

5.5 流插入和流提取的重载

<<>>的重载有一个「坑」:不能写成成员函数

// 错误写法——成员函数版本classDate{public:voidoperator<<(ostream&out){out<<_year<<"-"<<_month<<"-"<<_day;}};// 使用:d1 << cout; ← 和直觉相反,谁是左操作数?

成员函数的第一个参数隐含是thisDate*),这意味着d1 << cout才是对的——d1是左操作数,cout是右操作数。这和所有人的使用习惯反着来,没法用。

正确写法——全局函数(配合友元)

classDate{friendostream&operator<<(ostream&out,constDate&d);friendistream&operator>>(istream&in,Date&d);private:int_year;int_month;int_day;};ostream&operator<<(ostream&out,constDate&d){out<<d._year<<"年"<<d._month<<"月"<<d._day<<"日";returnout;// 返回 ostream& 支持连续输出:cout << d1 << d2}istream&operator>>(istream&in,Date&d){in>>d._year>>d._month>>d._day;returnin;// 返回 istream& 支持连续输入:cin >> d1 >> d2}

friend给了这两个全局函数访问Date私有成员的权限。返回值是流对象的引用,支持cout << d1 << d2这种链式调用。


六、const成员函数

classDate{public:voidPrint()const{// 等价于 void Print(const Date* const this)// 不能修改任何成员变量cout<<_year<<"-"<<_month<<"-"<<_day<<endl;}};intmain(){Dated1(2025,1,1);d1.Print();// OK:非const对象可以调用const成员函数constDated2(2025,1,1);d2.Print();// OK:const对象只能调用const成员函数// d2.Modify(); // 错误:const对象不能调用非const成员函数return0;}

const修饰的是隐含的this指针,从Date* const变成const Date* const。权限规则:只缩小不放宽——非const对象可以调用const成员函数,const对象不能调用非const的。

一个常被忽略的点:只要一个成员函数不修改成员变量,就应该加上const。不光是安全性,更重要的是const对象调用它才能用——你不加constconst Date就不能调用Print()

总结

四件事串起来:

  1. 构造函数管初始化,替代手动Init()。内置类型不管,自定义类型调用它们的构造。
  2. 析构函数管清理,有malloc/new就必须写。MyQueue那种全靠成员的不用写。
  3. 拷贝构造函数管克隆。编译器默认浅拷贝——纯值类型够用,有指针动态内存必须自己实现深拷贝。
  4. 赋值运算符重载管两个已存在对象之间的数据转移。自赋值检查是必要的,对资源型类尤其关键。

以上四个合起来叫Big Four,是C++类设计的核心骨架。下面还有两个默认成员函数(取地址重载),绝大多数场景用编译器的即可,不需要显式实现,除非你想刻意禁止别人获取该类对象的地址。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询