C++基础篇------类与对象(中)
系列文章目录
C++基础篇------命名空间与缺省参数
C++基础篇------函数重载、引用与运算符重载
C++基础篇------类与对象(上)
C++基础篇------类与对象(中)(本篇)
前言
上一篇我们把"默认成员函数"这张地图摊开了:一共六个,其中前四个是重点,当时只讲完了第一个------构造函数。我们还留下了一个悬念:析构函数真的只需要在"申请了资源"的时候才写吗?拷贝构造函数为什么死活不能传值?
本篇就来把这几件事讲透。先从析构函数 入手------它的定义、调用时机,以及那个经常被当作"面试八股"却很少有人真正想明白的性质:后进先出 。接着回答两个看似对称、答案却完全不对称的问题:为什么大多数类都要自己写构造函数 ,而只有少数类才需要自己写析构函数 。然后讲拷贝构造函数 ,重点是"为什么它只能传引用"这个语法层面的必然。最后落到两个实战细节:引用返回的四个坑 ,以及缺省参数为什么只能写在声明处。
这几个问题有一个共同点:它们都不是"记住规则就行",而是背后都有明确的因果关系。想清楚因果,规则就不用背了。
一、析构函数
1. 为什么需要析构函数
构造函数负责"生",析构函数负责"死"。对象在创建时申请了资源,销毁时就必须归还,否则资源泄漏------内存不释放、文件不关闭、锁不解锁、连接不断开。
C 语言里这件事只能靠程序员自觉:写了 Init 就得记得写 Destroy,中途 return 了要记得先 Destroy,出异常了更要记得。忘记,是常态。
C++ 的答案是:把"销毁"这件事交给编译器自动调度。 对象一旦离开作用域,析构函数必然被调用------不管你是正常走完流程、提前 return、还是抛了异常。
cpp
class Stack {
public:
Stack(int capacity = 4)
: _a(new int[capacity]), _top(0), _capacity(capacity) {}
~Stack() { // 析构函数:栈对象销毁时自动调用
delete[] _a; // 归还构造时申请的内存
_a = nullptr;
}
private:
int* _a;
int _top;
int _capacity;
};
void Func() {
Stack s; // 构造:申请内存
// ... 中间可能 return,也可能抛异常 ...
} // 离开作用域:析构自动调用,内存归还
注意 ~Stack() 里没有一句"我要被调用了"的提示------它是自动的,这才是它真正的价值。
2. 析构函数的四个特征
和构造函数对照着记最清楚:
| 特征 | 构造函数 | 析构函数 |
|---|---|---|
| 函数名 | 与类名相同 | ~ + 类名 |
| 返回值 | 无 | 无 |
| 参数 | 可以有(可重载、可缺省) | 不能有(不可重载) |
| 调用时机 | 对象创建时 | 对象销毁时 |
| 数量 | 可以有多个 | 只能有一个 |
cpp
class Date {
public:
Date(int year = 2026, int month = 10, int day = 22) // 可以重载多个
: _year(year), _month(month), _day(day) {}
~Date() {} // 只能有一个,且不带参数
private:
int _year, _month, _day;
};
因为析构函数不带参数、不能重载 ,所以一个类永远只有唯一一个析构函数。这一点直接决定了它不能像构造函数那样"按需定制"------它只能做一件事:把这个对象彻底收拾干净。
3. 析构函数的调用时机
析构函数在对象生命周期结束时被调用,具体有这几种情况:
cpp
int main() {
Date d1; // 栈上对象:出作用域时析构
{
Date d2; // 内层作用域结束就析构
}
Date* pd = new Date(); // 堆上对象:delete 时才析构
delete pd;
static Date d3; // 静态对象:程序结束时析构
return 0;
}
一个非常重要的提醒:new 出来的对象不会自动析构。 栈上的对象离开作用域就一定析构,但堆上的对象必须手动 delete,否则析构函数永远不执行,资源就泄漏了。这也是为什么现代 C++ 强调"尽量用栈对象、用智能指针代替裸 new"。
4. 析构函数的后进先出
这是本节的核心。析构的顺序,与构造的顺序完全相反------后构造的先析构。
原因不难理解:函数内部的局部变量都分配在栈上,栈的特性就是后进先出。构造时按声明顺序压栈,销毁时自然从栈顶往回弹。
cpp
#include <iostream>
class Item {
public:
Item(const char* name) : _name(name) {
std::cout << "构造 " << _name << std::endl;
}
~Item() {
std::cout << "析构 " << _name << std::endl;
}
private:
const char* _name;
};
void Func() {
Item a("A");
Item b("B");
Item c("C");
}
int main() {
Func();
return 0;
}
输出结果:
构造 A
构造 B
构造 C
析构 C
析构 B
析构 A
构造是 A→B→C,析构却是 C→B→A,完全是镜像的。
这个性质在"对象之间存在依赖关系"时是必不可少的 。设想 b 的构造依赖于 a(比如 a 是底层连接,b 是建立在它之上的会话)------那么 b 必须比 a 先销毁,否则销毁 a 的时候 b 还在引用一个已经失效的东西。而后进先出恰好保证了这一点:后构造的、依赖别人的对象,会先被销毁。
成员对象之间也是同样规则:
cpp
class Time { public: ~Time() { std::cout << "~Time" << std::endl; } };
class Date {
private:
int _year;
Time _t1;
Time _t2;
};
Date 对象销毁时,成员的析构顺序是 _t2 → _t1 → 再轮到 _year 这种内置类型(不涉及析构)------与声明顺序相反 。注意这里只看声明顺序,和构造函数初始化列表里写的顺序无关,这一点上一篇已经强调过。
还有一层容易忽略的细节:析构函数体执行完之后,成员对象才会析构。
cpp
class Date {
public:
~Date() {
std::cout << "~Date 函数体" << std::endl; // 先执行
}
private:
Time _t; // 后析构
};
输出是 ~Date 函数体 在前,~Time 在后。所以如果你在析构函数体里还想使用某个成员,那时它还是有效的。
二、构造函数和析构函数:为什么不对称
现在进入本篇最值得思考的一节:为什么大多数类都要自己写构造函数 ,而只有少数类才需要自己写析构函数?
这两个函数看起来是天生的一对,但它们的"必要程度"完全不对称。搞清这个不对称,等于理解了 C++ 对象模型的一半。
1. 大多数类都要自己写构造函数
原因有两条,第一条是上一篇讲过的硬伤:
(1)编译器生成的默认构造函数对内置类型不做初始化。
cpp
class Date {
private:
int _year, _month, _day;
};
int main() {
Date d;
std::cout << d._year << std::endl; // 随机值!
return 0;
}
这意味着"什么都不写"得到的对象,处于一个不确定的、事实上不可用的状态 。一个 Date 对象连年月日都是垃圾值,它根本没有意义。所以只要类里有内置类型成员(几乎所有的类都有),就必须自己写构造把它们初始化为合理的值。
(2)大多数类需要一个"合法的初始状态",而这个状态是编译器猜不出来的。
编译器的默认构造只会做一件事------调用成员的默认构造。它不知道你的 Stack 应该初始容量是 4 还是 100,不知道你的 String 应该指向空串还是 nullptr,不知道你的 Date 应该默认为今天还是 1970 年。"初始状态"是业务语义,只能由设计者给出。
cpp
class String {
public:
String(const char* str = "") // 必须自己写:默认要指向空串
: _str(new char[strlen(str) + 1]) {
strcpy(_str, str);
}
private:
char* _str;
};
所以结论是:构造函数几乎总要自己写------因为"如何开始"是每个类型都必须回答的问题,而编译器给不出答案。
2. 少数的类才需要写析构函数
对照来看,析构函数的处境完全不同。编译器生成的析构函数做的是:
- 对内置类型成员:什么都不做(不需要做);
- 对自定义类型成员:调用它们的析构函数。
关键就在于第一句。内置类型"不需要被销毁" ------一个 int、一个 double,它们占的空间随对象一起消失,没有任何额外动作要做。所以编译器生成的析构函数对它们不处理,是完全正确的,不是偷懒。
那什么时候编译器生成的析构不够用?只有当类自己"额外持有"了需要归还的东西时。 换句话说:
构造函数的必要性来自"初始化",而初始化的需求普遍存在;
析构函数的必要性来自"释放资源",而自己持有资源的情况并不普遍。
这就是不对称的根源。绝大多数类只是在组合已有的类型:
cpp
class Date {
private:
int _year, _month, _day; // 内置类型,无需销毁
}; // 不需要写析构函数
class Student {
private:
std::string _name; // 自定义类型:自动调用它的析构
int _age; // 内置类型:无需处理
}; // 不需要写析构函数
Student 里的 std::string 自己管着自己的内存,Student 只需要"让它自然地死掉"就对了------编译器生成的析构函数恰好就是这个行为。这就是所谓的**"不需要就别写"**:多写一个空的 ~Student() {} 不会出错,但它意味着你没想清楚这件事。
3. 需要自定义析构函数的典型场景
归纳起来,只有直接持有资源的类才需要写析构函数:
| 持有的资源 | 需要做的释放动作 |
|---|---|
new / new[] 出来的内存 |
delete / delete[] |
文件句柄 FILE* |
fclose |
| 动态库句柄 | dlclose |
| socket 描述符 | close |
| 锁(互斥量) | unlock / destroy |
| 数据库连接、线程句柄 | 对应的关闭接口 |
cpp
class FileGuard {
public:
FileGuard(const char* path) : _fp(fopen(path, "r")) {}
~FileGuard() {
if (_fp) { // 必须自己写:编译器不知道要 fclose
fclose(_fp);
_fp = nullptr;
}
}
private:
FILE* _fp;
};
反过来,判断标准可以这样记:看这个类有没有"构造函数里申请了、必须还回去"的东西。 有,就必须写析构函数;没有,就不要写。
最后一个必须警惕的规则是:如果一个类需要写析构函数,那它大概率也需要写拷贝构造函数和赋值运算符重载。 因为它们都要处理"那份资源由谁负责"的问题------这就是著名的"三法则"(Rule of Three),下一节讲完拷贝构造就能明白为什么。
4. 构造与析构带来的便利
最后说回"便利性"。构造函数和析构函数的真正价值,是把资源的生命周期 和对象的生命周期绑定在一起。这样一来,所有"手动管理"带来的问题都自动消失了。
cpp
// C 风格:每个返回路径都要记得清理
int Func() {
Stack s;
InitStack(&s);
if (cond1) { DestroyStack(&s); return -1; } // 漏一次就泄漏
if (cond2) { DestroyStack(&s); return -2; } // 再漏一次
DestroyStack(&s);
return 0;
}
// C++ 风格:一条也不用写
int Func() {
Stack s; // 自动构造
if (cond1) return -1; // 自动析构,不泄漏
if (cond2) return -2; // 自动析构,不泄漏
return 0; // 自动析构
}
具体好处可以列四条:
(1)消灭"忘记释放"。 不需要写 Destroy,也就不存在"忘了写"。
(2)消灭"提前返回漏释放"。 函数里有几个 return 就要写几次清理代码,这是 C 语言里最容易出 bug 的地方之一。C++ 里一次也不用写。
(3)异常安全。 栈展开时,局部对象的析构函数一定会被调用。而 C 风格的手动 Destroy,在异常路径上根本不会被执行。
(4)代码更短、意图更清晰。 对象的初始化和清理都集中在类的定义里,读代码的人不需要在几百行里翻找"哪里申请了、哪里释放了"。
这套思路有个正式的名字------RAII(Resource Acquisition Is Initialization,资源获取即初始化) 。它是 C++ 最核心的惯用法:把资源交给对象管,让编译器替你调度。 智能指针、std::vector、std::lock_guard 全都是这个思路的产物。理解了这个,才算理解 C++ 为什么要设计构造函数和析构函数这对机制。
三、拷贝构造函数
1. 什么是拷贝构造函数
拷贝构造函数是一个特殊的构造函数:用同类型的已存在对象,初始化一个正在创建的新对象。
它不是"赋值"------赋值是处理两个都已存在 的对象,拷贝构造是处理"新对象诞生"这一刻。
cpp
class Date {
public:
Date(int year = 2026, int month = 10, int day = 22)
: _year(year), _month(month), _day(day) {}
// 拷贝构造函数:参数是本类型对象的引用
Date(const Date& d) : _year(d._year), _month(d._month), _day(d._day) {}
private:
int _year, _month, _day;
};
int main() {
Date d1(2026, 10, 22);
Date d2(d1); // 拷贝构造:d2 是新建的,用 d1 初始化
Date d3 = d1; // 同样是拷贝构造(不是赋值!)
return 0;
}
最后两行要特别留意:Date d3 = d1; 虽然用了等号,但由于 d3 是刚刚创建的,这一行调用的是拷贝构造函数,而不是赋值运算符重载。 区分方法很简单:左边若是一个新出现的对象名 ,就是构造;左边若是一个已经存在的对象,才是赋值。
2. 为什么拷贝构造函数只能传引用
这是本节的核心问题,也是很多人只记住了"必须传引用"却没想过"为什么"的地方。
假设拷贝构造函数按值传参:
cpp
Date(Date d); // 假设这样写(编译直接报错)
那么调用 Date d2(d1); 时会发生什么?
- 要创建
d2,需要调用拷贝构造函数; - 调用它、传参
d1时,因为参数是按值传递 ,需要先把d1拷贝 一份给形参d; - 而"拷贝一份"这件事本身,又要调用拷贝构造函数;
- 于是又需要按值传参,又要拷贝......
- 无限递归,永远不会终止。
编译器早就看穿了这个死循环,所以在语法层面直接禁止:拷贝构造函数的第一个参数必须是本类型对象的引用 (一般是 const 类名&)。这不是"建议",是硬性规定:
cpp
Date(Date d); // 编译错误:拷贝构造的参数不能按值传递
Date(Date& d); // 正确,但无法拷贝 const 对象
Date(const Date& d); // 推荐:既支持普通对象也支持 const 对象
顺带解释一下为什么推荐加 const:加上 const 之后,既能接收普通对象,也能接收 const 对象和临时对象------这正是上一篇讲的"const T& 是全兼容传参方式"在这里的又一次应用。另外,拷贝构造不应该修改被拷贝的对象 ,加 const 也表达了这一语义。
再补一个细节:拷贝构造的参数可以带缺省值 ,但标准要求"带缺省值也必须能让它被调用为单参数形式",实际工程中几乎没人这么写,记住 const 类名& 这一个形式就够了。
3. 什么时候会调用拷贝构造
cpp
int main() {
Date d1(2026, 10, 22);
Date d2(d1); // ① 显式用括号
Date d3 = d1; // ② 用等号初始化新对象
Date d4 = Date(2026, 1, 1); // ③ 用临时对象初始化(通常被优化掉)
void Func(Date d); // ④ 按值传参
Func(d1); // 需要拷贝一份给形参
Date Func2() { return d1; } // ⑤ 按值返回
Date d5 = Func2(); // 返回时可能拷贝(通常被优化掉)
return 0;
}
其中 ④ 按值传参 是最需要警惕的一种:每次调用都要完整拷贝一个对象。这正是上一篇反复强调"参数用 const T&"的原因------既避免拷贝,又保证安全。
4. 编译器生成的拷贝构造做了什么
如果自己不写,编译器会生成一个拷贝构造函数,它的行为是:逐个成员进行"值拷贝"------内置类型直接复制值,自定义类型调用它们各自的拷贝构造。
cpp
class Date {
private:
int _year, _month, _day;
};
int main() {
Date d1(2026, 10, 22);
Date d2(d1); // 编译器生成的拷贝构造:三个 int 逐位复制
return 0;
}
对 Date 这种"只有值语义成员"的类,编译器的版本完全够用,不需要自己写 。问题出在类里含指针的时候。
5. 什么时候必须自己写拷贝构造:浅拷贝的陷阱
编译器生成的拷贝构造只复制指针的值 (也就是地址),不会复制指针指向的内容 。这叫浅拷贝。
cpp
class String {
public:
String(const char* str = "")
: _str(new char[strlen(str) + 1]) {
strcpy(_str, str);
}
~String() { delete[] _str; }
private:
char* _str;
};
int main() {
String s1("hello");
String s2(s1); // 编译器生成的拷贝构造:浅拷贝
return 0;
}
这里发生了灾难 :s1._str 和 s2._str 指向同一块堆内存 。程序结束时,s2 先析构(后进先出),delete[] 释放了那块内存;接着 s1 析构,又对同一块内存 delete[] 一次。
同一块内存被释放两次------这是典型的未定义行为,程序可能崩溃、可能静默损坏数据,也可能"看起来正常",然后在别的地方莫名出错。这类 bug 极难排查。
解决办法是自己写拷贝构造,做深拷贝:为新对象单独申请一块内存,把内容复制过去。
cpp
class String {
public:
String(const char* str = "")
: _str(new char[strlen(str) + 1]) {
strcpy(_str, str);
}
// 自定义拷贝构造:深拷贝
String(const String& s)
: _str(new char[strlen(s._str) + 1]) { // ① 自己申请一块新内存
strcpy(_str, s._str); // ② 把内容复制过来
}
~String() { delete[] _str; }
private:
char* _str;
};
现在 s1 和 s2 各自持有一块独立的内存,互不影响,各自析构各自释放,问题解决。
所以"什么时候需要自己写拷贝构造"的判断标准很清晰:类里有没有"必须独占"的资源。
| 类中的成员 | 编译器生成的拷贝构造是否够用 |
|---|---|
只有内置类型(int、double 等) |
够用(值拷贝正确) |
只有自定义类型成员(如 std::string) |
够用(调用它们自己的拷贝构造) |
| 含裸指针,且指向动态内存 | 不够用,必须写(深拷贝) |
| 含文件句柄、socket、锁等资源 | 不够用,必须写 |
这和上一节的结论完全对应上了:需要写析构函数的类,通常也需要写拷贝构造。 因为它们面对的是同一个问题的两面------一份资源,谁来释放、能不能共享。这也解释了为什么现代 C++ 建议用 std::string、std::vector、智能指针替代裸指针:它们已经把深拷贝和释放都写好了,你就不用自己操心了。
四、引用返回与它的注意事项
1. 为什么要有引用返回
函数返回引用,意味着返回的是对象本身,而不是它的副本。两个直接的好处:不拷贝(大对象时省开销)、可修改(拿到引用就能改原对象)。
cpp
class Stack {
public:
int& Top() { return _a[_top - 1]; } // 返回栈顶元素的引用,可读可写
private:
int _a[100];
int _top = 0;
};
int main() {
Stack s;
s.Push(1);
s.Push(2);
s.Top() = 100; // 直接修改栈顶元素!返回引用才能做到
return 0;
}
如果 Top() 返回 int(值),s.Top() = 100; 会直接编译报错------因为那是在给一个临时副本赋值,毫无意义。
2. 注意事项一:绝对不能返回局部变量
这是引用返回里最致命、也最常见的错误。
cpp
int& Bad() {
int a = 10;
return a; // 危险!a 在函数结束时已被销毁
}
int main() {
int& r = Bad();
std::cout << r << std::endl; // 未定义行为:r 是"悬空引用"
return 0;
}
a 是局部变量,函数一返回,它的栈空间就被回收了。此时返回的引用指向一块已经失效的内存 ------这叫悬空引用(dangling reference)。读它可能读到垃圾值,写它可能破坏别的数据,甚至可能"碰巧正常",然后在别处突然崩溃。
注意编译器通常只给一个警告,不会报错 ,所以这类 bug 特别容易溜过去。判断标准很简单:要返回的这块内存,在函数返回之后还存在吗?
3. 注意事项二:这五类对象可以安全返回引用
| 返回的对象 | 是否安全 | 说明 |
|---|---|---|
| 局部变量 | 不安全 | 函数结束即销毁 |
| 局部对象(类类型) | 不安全 | 同上,析构都执行了 |
| 全局变量 | 安全 | 生命周期到程序结束 |
静态变量 static |
安全 | 生命周期到程序结束 |
| 成员变量 | 安全(有条件) | 引用随对象存在,对象销毁后失效 |
堆上对象(new) |
安全 | 但调用方要负责 delete |
| 传入的参数引用 | 安全 | 实参本来就活着 |
cpp
// 安全:返回成员变量的引用
class Date {
public:
int& Year() { return _year; }
private:
int _year = 2026;
};
// 安全:返回静态变量的引用
int& Counter() {
static int cnt = 0;
return cnt;
}
// 安全:返回参数引用
Date& Max(Date& a, Date& b) {
return a > b ? a : b;
}
返回成员变量时有个隐含条件要记住:引用的有效期不超过对象的寿命。 如果对象被销毁了,从它身上拿到的引用也随之失效------这一点在返回"临时对象的成员引用"时尤其危险。
静态变量虽然安全,但它有两个代价:多线程不安全 (所有调用共享同一个变量),以及多次调用拿到的是同一个对象(第二次调用会覆盖第一次的结果)。所以只有在"确实需要共享一份状态"时才这么写。
4. 注意事项三:接收方不会延长生命周期
这是一个很隐蔽的坑。有人以为"用引用接收返回值,就能让临时对象活下来"------这是错的。
cpp
Date& r = Date(2026, 10, 22); // 错误:普通引用不能绑定临时对象,编译报错
const Date& r2 = Date(2026, 10, 22); // 正确:const 引用绑定临时对象,生命周期被延长
规则是:只有 const 引用(或右值引用)绑定临时对象时,临时对象的生命周期才会延长到与引用相同。 普通引用根本不允许绑定临时对象------这其实是一种保护,从语法层面挡住了大部分悬空引用。
但对于函数返回的引用,情况不同:
cpp
int& Bad() { int a = 10; return a; }
int& r = Bad(); // 编译能过!但 r 指向的是一个已销毁的局部变量
这里编译器无法判断------因为从类型上看,返回的就是一个 int&。这种"能编译但运行时出错"的情况,只能靠写代码的人自己把关。
5. 注意事项四:链式调用必须返回引用
这一点上一篇提过,这里从"引用返回"的角度再看一次:
cpp
class Date {
public:
Date& AddYear() { ++_year; return *this; } // 返回引用:改的是自己
Date AddYearCopy() { ++_year; return *this; } // 返回值:返回的是副本
private:
int _year = 2026;
};
int main() {
Date d;
d.AddYear().AddYear(); // 正确:两次都在改 d
d.AddYearCopy().AddYear(); // 危险:第二次改的是临时副本,随即销毁
return 0;
}
所以像 operator<<、operator=、operator[]、operator+ 这类需要支持连续调用的运算符,几乎都返回引用:
cpp
std::ostream& operator<<(std::ostream& out, const Date& d) {
out << d._year;
return out; // 返回流对象引用,才能 cout << a << b << c
}
一句话总结引用返回:只要你能确认"这个对象在函数返回后还活着",就可以返回它的引用;不能确认的,一律返回值。
五、缺省参数只能写在声明处
1. 规则本身
当函数的声明和定义分离时(典型场景就是上一篇讲的 .h + .cpp),缺省参数只能出现在声明中,定义中不能再写。
Date.h
cpp
#pragma once
class Date {
public:
Date(int year = 2026, int month = 10, int day = 22); // 缺省值写在这里
void Print() const;
private:
int _year, _month, _day;
};
Date.cpp
cpp
#include "Date.h"
Date::Date(int year, int month, int day) // 定义处:不能再写 = 2026 之类
: _year(year), _month(month), _day(day) {}
void Date::Print() const {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
如果两处都写:
cpp
// Date.h
Date(int year = 2026, int month = 10, int day = 22);
// Date.cpp
Date::Date(int year = 2026, int month = 10, int day = 22) { ... }
// 报错:重定义默认参数(redefinition of default argument)
2. 为什么只能写一次
原因在于缺省参数的本质:它不是函数的一部分,而是"调用点"的语法糖。
当编译器在 main.cpp 里看到 Date d; 时,它要做的判断是"这个调用合法吗、缺的参数补什么值"。而它唯一能看到的信息,就是 #include "Date.h" 带进来的那份声明 。.cpp 里的定义编译器根本看不到(也不需要看到)。
如果允许在定义处写缺省值,那这个缺省值对 main.cpp 就毫无意义------它看不到;而对 Date.cpp 来说,又因为编译器已经把"参数个数"按声明的规则处理过了,产生矛盾。同一个参数被赋予了两个默认值,编译器不知道以哪个为准。
所以 C++ 的规定是:同一作用域内,一个参数的缺省值只能指定一次。 由于声明是调用方唯一能看到的那一份,缺省值自然只能放在声明处。
3. 三个容易踩的坑
坑一:只在定义处写缺省值,调用方补不上。
cpp
// Date.h
Date(int year, int month, int day); // 声明处没有缺省值
// Date.cpp
Date::Date(int year = 2026, int month = 10, int day = 22) { ... }
// main.cpp
Date d; // 编译错误:构造函数需要 3 个参数
main.cpp 只看到了声明,那里没有缺省值,所以它认为必须传三个参数。这个错误很迷惑人------明明"定义里写了啊"。
坑二:先用后声明缺省值是合法的,反过来不行。
cpp
void Func(int a); // ① 先声明,没有缺省值
void Func(int a = 10); // ② 再次声明,补上缺省值 ------ 合法
Func(); // ③ 只有在这之后调用才能省略参数
在同一个作用域里补充缺省值是允许的(只要之前没指定过),但一旦指定过就不能再改或再写。工程上不建议这样分段声明,容易混乱。
坑三:头文件里的成员函数定义可以直接写缺省值。
如果函数直接定义在类内部(类内定义),就不存在"声明与定义分离",缺省值自然写在参数列表里即可,也不会有任何冲突。
4. 一个实用建议
缺省参数提供了便利,但也把"默认值"这个信息藏在了接口里------调用方看 d.Print() 时,看不出它到底用了什么默认值。所以:
- 缺省值应写在最容易看到的地方(头文件的类声明中);
- 缺省值要有明确的语义 (
2026-10-22不如today()之类更清晰的表达); - 参数多的时候,考虑用"配置结构体 + 具名参数"替代一长串缺省参数。
六、综合示例:一个完整的 Stack
把本篇的知识点全部落地:
cpp
#include <iostream>
class Stack {
public:
// ① 自定义构造:资源申请 + 初始状态
Stack(int capacity = 4)
: _a(new int[capacity])
, _top(0)
, _capacity(capacity) {}
// ② 自定义拷贝构造:深拷贝,避免两次释放同一块内存
Stack(const Stack& s)
: _a(new int[s._capacity]) // 新申请一块
, _top(s._top)
, _capacity(s._capacity) {
for (int i = 0; i < _top; ++i) {
_a[i] = s._a[i];
}
}
// ③ 自定义析构:归还资源
~Stack() {
delete[] _a;
_a = nullptr;
}
void Push(int x) { _a[_top++] = x; }
// ④ 引用返回:既免拷贝,又能修改原元素
int& Top() { return _a[_top - 1]; }
int Size() const { return _top; }
private:
int* _a;
int _top;
int _capacity;
};
// ⑤ 按值传参 ------ 演示拷贝构造被调用
void PrintSize(Stack s) {
std::cout << s.Size() << std::endl;
}
int main() {
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Top() = 100; // 引用返回:直接改栈顶
Stack s2(s1); // 拷贝构造:深拷贝,s2 独立
s2.Push(3); // 不影响 s1
std::cout << s1.Size() << " " << s2.Size() << std::endl; // 2 3
std::cout << s1.Top() << std::endl; // 100
PrintSize(s1); // 按值传参,调用了一次拷贝构造
return 0; // s2 先析构,s1 后析构(后进先出)
}
这段代码里有三处必须自己写,缺一不可:构造 (初始化 + 申请内存)、拷贝构造 (深拷贝,否则 s1 和 s2 会重复释放)、析构 (归还内存)。这正是"持有裸指针的类需要三法则"的完整示例。实际工程里,可以用 std::vector<int> 替代裸指针,这三个函数就全都不用写了。
总结
本篇的主线是"生命周期"------从一个对象怎么死,讲到它怎么被复制、怎么被返回。
析构函数 用 ~类名 定义、无参数、不可重载、只能有一个,在对象销毁时自动调用。它的顺序是后进先出:栈上对象按声明的逆序销毁,成员对象也按声明顺序的逆序析构,且析构函数体执行完、成员才析构。这个性质保证了"后构造的、依赖别人的对象先走",是资源安全的基础。
构造与析构的不对称 是最值得想通的一点:构造函数的必要性来自"初始化",而"对象需要一个合法初始状态"是普遍需求;析构函数的必要性来自"释放资源",而"自己持有资源"只是少数情况。所以大多数类要自己写构造,少数类才要写析构。判断标准是:构造函数里申请了什么、必须还回去,就一定要写析构函数。这对机制合起来就是 RAII------把资源交给对象管,让编译器替你调度。
拷贝构造函数 必须是 const 类名&,因为按值传参要先拷贝实参、而拷贝实参又要调用拷贝构造,会无限递归,编译器直接禁止。编译器生成的版本是浅拷贝 ,只在类里含裸指针等"必须独占的资源"时才不够用,需要自己写深拷贝。规矩是:写析构函数的类,通常也要写拷贝构造(三法则)。
引用返回 省拷贝、可修改,适用于链式调用和 operator[]。但铁律是"返回的对象必须在函数返回后还活着"------局部变量绝对不行,全局、静态、成员、堆对象、参数引用可以。另外要记住:普通引用不能绑定临时对象,只有 const 引用能延长临时对象的生命周期。
缺省参数只能写在声明处。因为它不是函数的一部分,而是给调用方的语法糖,而调用方唯一能看到的就是声明。两处都写会报"重定义默认参数",只在定义处写则调用方补不上参数。
这五个知识点串起来其实是一句话:C++ 把"对象的生死"这件事从程序员手里接了过去,代价是你要精确地告诉它"怎么生、怎么死、怎么复制"。 下一篇我们讲最后一个重要的默认成员函数------赋值运算符重载,届时"深拷贝"这个话题会再深化一层,还会遇到 this 指针判断自赋值的经典场景。
本文是《C++基础》系列的一篇,如需源代码或示例,可在评论区留言。