构造函数:别让对象揣着垃圾值出生
上一章咱们学了怎么定义一个类——写成员变量、写成员函数、用 public 和 private 控制访问权限。但有一个问题咱们一直绕过去了:对象被创建出来的时候,它的成员变量里装的是什么?答案是,如果什么都不做,局部对象的成员变量里装的是垃圾值,也就是上一次那块内存里残留的随机数据。
一个对象一旦被创建出来,它就应该处于一个合法、可用、可预测的状态。构造函数(constructor)就是 C++ 的解决方案:它在对象创建时自动执行,负责把成员变量带到正确的初始状态。只要咱们把构造函数写对,"忘记初始化"这种低级错误就不可能发生。
这一章咱们会把构造函数的几种形态全部拆开——默认构造、参数化构造、拷贝构造、成员初始化列表,还有 C++11 引入的委托构造。每一种都有它的使用场景和隐藏的坑。
默认构造——无参就能建对象
默认构造函数(default constructor)不需要任何参数。当咱们写 Point p; 时,调用的就是它。
class Point {
private:
double x_;
double y_;
public:
Point() : x_(0.0), y_(0.0) {}
};Point() 后面的 : x_(0.0), y_(0.0) 是成员初始化列表,咱们先混个眼熟,后面专门讲。关键是默认构造函数的职责:对象一出来就已经是一个合法的原点坐标。
咱们要是连一个构造函数都不写,编译器会帮忙生成一个默认构造函数。但它对 int、double 等基本类型不做任何初始化,值依然是垃圾。所以类里有基本类型成员时,咱们几乎总是需要自己写默认构造函数。
编译器生成默认构造函数的规则只有一条:只要咱们手写了任何一个构造函数(哪怕是有参数的),编译器就不再帮忙生成默认构造函数了。很多朋友写了一个 Point(double x, double y) 之后,发现 Point p; 编译不过,一头雾水。原因就在这里:咱们写了有参构造,编译器认为"你既然自己管初始化了,默认构造也得你自己写"。
解决方法很简单:要么咱们自己补一个 Point() : x_(0.0), y_(0.0) {},要么用 C++11 的 = default 语法让编译器继续帮忙生成:
class Point {
private:
double x_;
double y_;
public:
Point() = default; // 让编译器生成默认构造
Point(double x, double y) : x_(x), y_(y) {}
};注意 = default 生成的默认构造函数对基本类型仍然不会初始化为零。咱们要是需要零初始化,还是得自己写 : x_(0.0), y_(0.0) {} 或者用类内初始值(下一章会讲到)。
参数化构造——把初始化的权力交给调用者
很多时候咱们希望对象一创建就带上具体数据,而不是"零值"默认状态。参数化构造函数(parameterized constructor)接受参数来初始化成员变量。
class Point {
private:
double x_;
double y_;
public:
Point(double x, double y) : x_(x), y_(y) {}
};
Point origin(0.0, 0.0);
Point target(3.5, -2.1);构造函数支持重载,所以咱们可以同时提供默认构造和参数化构造,让调用者按需选择。不过接下来咱们得聊一个容易忽视的关键字——explicit。当一个构造函数只接受一个参数(或其余参数有默认值)时,它充当隐式类型转换函数。看代码:
class PWMChannel {
private:
int channel_;
public:
PWMChannel(int ch) : channel_(ch) {}
};
void set_active(PWMChannel ch);
set_active(3); // 编译通过!int 被隐式转换为 PWMChannel(3)set_active(3) 这个调用,函数签名要的是 PWMChannel,咱们传了一个 int,编译器帮忙调了构造函数做隐式转换。在简短示例里看起来没什么,但在大型项目里,这种隐式转换会制造难以定位的 bug——咱们可能只是参数类型写错了,编译器不但不报错,反而"好心办坏事"。
explicit 关键字就是用来禁止这种隐式转换的:
class PWMChannel {
private:
int channel_;
public:
explicit PWMChannel(int ch) : channel_(ch) {}
};
void set_active(PWMChannel ch);
// set_active(3); // 编译错误!不能隐式转换
set_active(PWMChannel(3)); // OK,显式构造笔者的建议是:所有单参数构造函数都应该加 explicit,除非您有非常明确的理由需要隐式转换。这是一个几乎零成本的防御措施。
成员初始化列表:初始化的正路
咱们前面一直在用成员初始化列表(member initializer list),现在来正式拆开讲。
咱们把构造函数的初始化列表写在参数列表后面的冒号之后,用逗号分隔,每个成员后面跟一个括号(或花括号)里的初始值:
class Sensor {
private:
int pin_;
double threshold_;
public:
Sensor(int pin, double threshold)
: pin_(pin), threshold_(threshold) {}
};您可能想问:直接在构造函数体内赋值不就行了吗?为什么要搞一个专门的初始化列表?
// 方式一:初始化列表(推荐)
Sensor(int pin, double threshold)
: pin_(pin), threshold_(threshold) {}
// 方式二:构造函数体内赋值(能编译,但不推荐)
Sensor(int pin, double threshold) {
pin_ = pin; // 这不是"初始化",而是"赋值"
threshold_ = threshold;
}对 int 和 double 这样的基本类型,咱们用两种方式得到的运行结果完全一样。但问题出在 const 成员和引用成员上:这两种东西只能被初始化,不能被赋值。等到构造函数体开始执行时,所有成员已经被默认构造完毕了,再去赋值,对 const 和引用来说已经晚了,编译器直接报错。
class Config {
private:
const int kMaxRetry;
int& counter_ref;
public:
// 唯一合法的方式:初始化列表
Config(int max, int& ref)
: kMaxRetry(max), counter_ref(ref) {}
// 下面这个版本编译直接炸:
// Config(int max, int& ref) {
// kMaxRetry = max; // 编译错误!const 不能赋值
// counter_ref = ref; // 编译错误!引用必须在初始化时绑定
// }
};即便没有 const 和引用成员,咱们用初始化列表也依然更优。对类类型成员(如 std::string),在函数体内赋值意味着先默认构造再赋值覆盖,两步操作;而初始化列表直接用目标值构造,一步到位。
成员的初始化顺序由它们在类定义中的声明顺序决定,跟初始化列表里的书写顺序无关。这一点非常重要:咱们的初始化列表里要是写了 : b(a), a(10),而类里 a 先声明、b 后声明,那实际执行顺序是先初始化 a 为 10,再初始化 b 为 a(此时 a 已经是 10),结果正确。但如果声明顺序反过来(b 在前、a 在后),那 b(a) 执行的时候 a 还没初始化,读到的就是垃圾值。大多数编译器会在两者顺序不一致时给出警告,但咱们最好还是养成让声明顺序和初始化列表顺序保持一致的习惯,别给自己埋雷。
拷贝构造——用已有的对象创建新对象
咱们用拷贝构造函数(copy constructor)从一个已存在的同类型对象创建新对象,签名固定为 ClassName(const ClassName& other):
class Point {
private:
double x_;
double y_;
public:
Point(double x, double y) : x_(x), y_(y) {}
// 拷贝构造函数
Point(const Point& other) : x_(other.x_), y_(other.y_) {}
};
Point a(1.0, 2.0);
Point b = a; // 调用拷贝构造函数
Point c(a); // 也是调用拷贝构造函数咱们会遇到拷贝构造函数被调用的三种场景:拷贝初始化(Point b = a;)、函数按值传参(形参通过拷贝构造创建)、函数按值返回(返回值通过拷贝构造复制,不过现代编译器通常用 RVO 省掉这次拷贝)。
咱们要是不自己写拷贝构造函数,编译器会生成一个默认版本——行为是逐成员拷贝(memberwise copy),即对每个成员分别调用其拷贝构造(对基本类型就是直接复制值)。对于 Point 这样只含基本类型的类,默认版本完全够用。
逐成员拷贝对包含裸指针的类来说是灾难性的。假设咱们的类里有一个 int* data_ 指向动态分配的内存,默认拷贝构造只会复制指针的值(地址),而不是指针指向的内容。结果就是两个对象的 data_ 指向同一块内存:其中一个析构释放了内存,另一个还在用,变成悬空指针(dangling pointer)。这就是经典的"浅拷贝"问题,咱们在后面讲 RAII 和智能指针的时候会深入讨论怎么解决。
class Buffer {
private:
int* data_;
std::size_t size_;
public:
Buffer(std::size_t size) : size_(size), data_(new int[size]()) {}
// 不写拷贝构造函数时,默认版本只复制指针地址
// 两个对象析构时对同一块内存 delete 两次——boom
};咱们目前只需要记住一点:咱们的类要是管理了资源(动态内存、文件句柄、网络连接等),就必须自己写拷贝构造函数(或者干脆禁用它,后面会讲怎么禁)。
委托构造——让构造函数之间互相帮忙
C++11 引入了委托构造(delegating constructor),允许咱们让一个构造函数在初始化列表里调用同一个类的另一个构造函数,减少重复代码。
class Point {
private:
double x_;
double y_;
public:
// "主"构造函数:干所有的活
Point(double x, double y) : x_(x), y_(y) {}
// 默认构造函数:委托给上面的主构造函数
Point() : Point(0.0, 0.0) {}
};Point() 的初始化列表里写的不是成员名,而是 Point(0.0, 0.0),调用另一个构造函数。咱们看执行顺序:先执行目标构造函数的初始化列表和函数体,然后控制权回到委托构造函数的函数体。
这个特性在构造函数比较多、初始化逻辑有重叠时特别有用:咱们把核心逻辑放在一个"主"构造函数里,其他构造函数委托给它就行。
不过委托构造有一条硬规则:初始化列表里一旦出现了委托,就不能再初始化任何成员。Point() : Point(0.0, 0.0), x_(0) {} 这种写法是非法的,要么咱们全部委托,要么全部自己初始化,不能混着来。
实战演练——constructors.cpp
咱们把这一章涉及的所有构造函数类型整合进一个 Student 类,把每一个构造函数的调用都用输出标记出来:
展开代码收起代码共 75 行
// constructors.cpp
// 构造函数综合演练:默认构造、参数化构造、拷贝构造、委托构造
#include <iostream>
#include <string>
class Student {
private:
std::string name_;
int age_;
double score_;
public:
Student() : name_("Unknown"), age_(0), score_(0.0)
{
std::cout << "[默认构造] " << name_ << ", "
<< age_ << " 岁, " << score_ << " 分" << std::endl;
}
Student(const std::string& name, int age, double score)
: name_(name), age_(age), score_(score)
{
std::cout << "[参数化构造] " << name_ << ", "
<< age_ << " 岁, " << score_ << " 分" << std::endl;
}
// 委托构造:只用名字,其余委托给上面的参数化构造
Student(const std::string& name) : Student(name, 18, 0.0)
{
std::cout << "[委托构造] 只指定姓名" << std::endl;
}
Student(const Student& other)
: name_(other.name_), age_(other.age_), score_(other.score_)
{
std::cout << "[拷贝构造] 复制: " << name_ << std::endl;
}
void print() const
{
std::cout << " " << name_ << ", " << age_
<< " 岁, " << score_ << " 分" << std::endl;
}
};
/// @brief 按值传递,触发拷贝构造
void enroll(Student s)
{
std::cout << " 注册: ";
s.print();
}
int main()
{
std::cout << "=== 默认构造 ===" << std::endl;
Student s1;
s1.print();
std::cout << "\n=== 参数化构造 ===" << std::endl;
Student s2("Alice", 20, 92.5);
s2.print();
std::cout << "\n=== 委托构造 ===" << std::endl;
Student s3("Bob");
s3.print();
std::cout << "\n=== 拷贝构造(拷贝初始化)===" << std::endl;
Student s4 = s2;
s4.print();
std::cout << "\n=== 拷贝构造(按值传参)===" << std::endl;
enroll(s2);
return 0;
}编译运行:g++ -std=c++17 -Wall -Wextra -o constructors constructors.cpp && ./constructors
预期输出:
=== 默认构造 ===
[默认构造] Unknown, 0 岁, 0 分
Unknown, 0 岁, 0 分
=== 参数化构造 ===
[参数化构造] Alice, 20 岁, 92.5 分
Alice, 20 岁, 92.5 分
=== 委托构造 ===
[参数化构造] Bob, 18 岁, 0 分
[委托构造] 只指定姓名
Bob, 18 岁, 0 分
=== 拷贝构造(拷贝初始化)===
[拷贝构造] 复制: Alice
Alice, 20 岁, 92.5 分
=== 拷贝构造(按值传参)===
[拷贝构造] 复制: Alice
注册: Alice, 20 岁, 92.5 分咱们验证一下:委托构造 Student("Bob") 先调用 Student("Bob", 18, 0.0)(先输出"参数化构造"),再执行自己的函数体(输出"委托构造")。拷贝构造在两种场景下都被正确触发。
动手试试
练习 1:Date 类
请您写一个 Date 类,包含 year_、month_、day_ 三个成员。要求提供默认构造函数(初始化为 2000/1/1)、参数化构造函数(接受年月日,做基本合法性检查——月份 1-12、日期 1-31),以及一个 print() 方法。验证方法:构造几个日期对象,包含一个不合法的日期(比如月份 13),观察校验逻辑是否生效。
参考答案
展开代码收起代码共 133 行
#include <iostream>
class Date
{
private:
int year_;
int month_;
int day_;
public:
// 默认构造函数
Date() : year_(2000), month_(1), day_(1)
{
std::cout << "[默认构造] "
<< year_ << "-" << month_ << "-" << day_
<< std::endl;
}
// 参数化构造函数
Date(int year, int month, int day)
: year_(year), month_(month), day_(day)
{
// 先判断月份
if (month_ < 1 || month_ > 12)
{
year_ = 2000;
month_ = 1;
day_ = 1;
std::cout << "错误:月份必须在 1~12 之间\n";
return;
}
// 根据月份判断日期是否合法
int max_day = 0;
switch (month_)
{
// 31 天的月份
case 1:
case 3:
case 5:
case 7:
case 8:
case 10:
case 12:
max_day = 31;
break;
// 30 天的月份
case 4:
case 6:
case 9:
case 11:
max_day = 30;
break;
// 2 月
case 2:
// 闰年:29 天
if ((year_ % 400 == 0) ||
(year_ % 4 == 0 && year_ % 100 != 0))
{
max_day = 29;
}
else
{
max_day = 28;
}
break;
}
// 判断日期
if (day_ < 1 || day_ > max_day)
{
year_ = 2000;
month_ = 1;
day_ = 1;
std::cout << "错误:"
<< year << "-" << month << "-" << day
<< " 不是合法日期\n";
return;
}
std::cout << "[有参构造] "
<< year_ << "-" << month_ << "-" << day_
<< std::endl;
}
// 打印日期
void print() const
{
std::cout << year_ << "-"
<< month_ << "-"
<< day_
<< std::endl;
}
};
int main()
{
Date d1;
d1.print();
Date d2(2023, 5, 15);
d2.print();
Date d3(2023, 13, 15);
d3.print();
Date d4(2023, 5, 32);
d4.print();
// 2 月 28 天
Date d5(2023, 2, 28);
d5.print();
// 2023 年不是闰年,2 月 29 日非法
Date d6(2023, 2, 29);
d6.print();
// 2024 年是闰年,2 月 29 日合法
Date d7(2024, 2, 29);
d7.print();
// 4 月只有 30 天
Date d8(2024, 4, 31);
d8.print();
return 0;
}编译运行:
g++ -std=c++17 -Wall -Wextra main.cpp -o main && ./main运行结果:
[默认构造] 2000-1-1
2000-1-1
[有参构造] 2023-5-15
2023-5-15
错误:月份必须在 1~12 之间
2000-1-1
错误:2023-5-32 不是合法日期
2000-1-1
[有参构造] 2023-2-28
2023-2-28
错误:2023-2-29 不是合法日期
2000-1-1
[有参构造] 2024-2-29
2024-2-29
错误:2024-4-31 不是合法日期
2000-1-1当输入的日期不合法时,咱们将
year_、month_、day_分别重置为2000、1、1,使对象回到与默认构造时一致的日期(2000 年 1 月 1 日),避免保留并输出无效日期而造成误导。
练习 2:Vector3D 类
请您写一个 Vector3D 类,包含 x_、y_、z_ 三个 double 成员。用委托构造让默认构造函数委托给 Vector3D(0.0, 0.0, 0.0),再实现拷贝构造函数和一个 length() 方法返回向量的模。验证方法:创建默认向量、自定义向量、拷贝向量,打印值和模。
参考答案
展开代码收起代码共 82 行
#include <cmath>
#include <iostream>
class Vector3D
{
private:
double x_;
double y_;
double z_;
public:
// 参数化构造函数
Vector3D(double x, double y, double z)
: x_(x), y_(y), z_(z)
{
std::cout << "[参数化构造] "
<< "x = " << x_
<< ", y = " << y_
<< ", z = " << z_
<< std::endl;
}
// 委托构造函数
// 委托给 Vector3D(0.0, 0.0, 0.0)
Vector3D()
: Vector3D(0.0, 0.0, 0.0)
{
std::cout << "[委托构造] 使用默认值 (0.0, 0.0, 0.0)"
<< std::endl;
}
// 拷贝构造函数
Vector3D(const Vector3D& other)
: x_(other.x_), y_(other.y_), z_(other.z_)
{
std::cout << "[拷贝构造]" << std::endl;
}
// 返回向量的模
double length() const
{
return std::sqrt(x_ * x_ +
y_ * y_ +
z_ * z_);
}
// 打印向量
void print() const
{
std::cout << "三维向量 Vector3D("
<< x_ << ", "
<< y_ << ", "
<< z_ << ")"
<< std::endl;
}
};
int main()
{
std::cout << "===== 创建默认三维向量 =====" << std::endl;
Vector3D v1;
v1.print();
std::cout << "模 = " << v1.length() << std::endl;
std::cout << "\n===== 创建指定三维向量 =====" << std::endl;
Vector3D v2(1.0, 2.0, 3.0);
v2.print();
std::cout << "模 = " << v2.length() << std::endl;
std::cout << "\n===== 创建拷贝向量 =====" << std::endl;
Vector3D v3(v2);
v3.print();
std::cout << "模 = " << v3.length() << std::endl;
return 0;
}编译运行:
g++ -std=c++17 -Wall -Wextra main.cpp -o main && ./main运行结果:
===== 创建默认三维向量 =====
[参数化构造] x = 0, y = 0, z = 0
[委托构造] 使用默认值 (0.0, 0.0, 0.0)
三维向量 Vector3D(0, 0, 0)
模 = 0
===== 创建指定三维向量 =====
[参数化构造] x = 1, y = 2, z = 3
三维向量 Vector3D(1, 2, 3)
模 = 3.74166
===== 创建拷贝向量 =====
[拷贝构造]
三维向量 Vector3D(1, 2, 3)
模 = 3.74166