构造函数和析构函数
构造函数
构造函数基本职能
构造函数主要包括以下职能:
- 内存布局的确定:构造函数所在的类决定了对象的内存布局,包括成员变量的类型和顺序。
- 成员变量的初始化:构造函数负责初始化对象的成员变量。
- 虚表指针的设置:对于包含虚函数的类,构造函数负责设置虚表指针,以支持运行时多态。
有关虚表的内容,请参阅 继承 - 虚表。
构造函数的调用顺序
在执行构造时,首先会调用基类的构造函数,然后依次调用成员变量的构造函数,最后执行派生类的构造函数体。
class Base {
public:
Base() {
std::cout << "Base constructed" << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
std::cout << "Derived constructed" << std::endl;
}
};
int main (int argc, char *argv[]) {
Derived d;
// 输出:
// Base constructed
// Derived constructed
}
可以看到,Derived 隐式地调用了 Base 的默认构造函数。在任何情况下,子类都必须先显式或隐式地调用父类构造函数,才能完成对象的构造。在不显式调用父类构造函数的情况下,编译器会自动调用父类的默认构造函数。
父类对象的内存布局是子类对象内存布局的一个子集,在构造子类对象时,必须先构造父类对象,以确保父类部分的成员变量和虚表指针等基础设施被正确初始化。只有在父类部分被正确构造后,子类才能安全地访问和使用这些成员变量和虚表指针。在几乎所有的面向对象的编程语言中,子类构造函数中的第一条语句都是调用父类构造函数的语句。
在继承场景下,子类有时需要显式调用父类的构造函数:
class Base {
public:
Base(int value) : x(value) {}
private:
const int x;
};
class Derived : public Base {
public:
Derived(int value) : Base(value) {} // 显式调用父类构造函数
};
编译器总是会为没有定义任何构造函数的类自动生成一个默认构造函数,称作 合成默认构造函数(synthesized default constructor) 。但是,如果类定义了任何带参构造函数,编译器就不会生成默认构造函数,这时除了显式定义默认构造函数外,还可通过 = default 让编译器生成默认构造函数。
考虑这样一种情况,父类中定义了一个带参构造函数,但没有定义默认构造函数,在派生的子类构造函数中没有显式调用父类构造函数:
class Base {
public:
Base(int value) : x(value) {}
private:
int x;
};
class Derived : public Base {
public:
Derived() {} // error: no matching constructor for initialization of 'Base'
};
在这种情况下,编译器无法自动调用父类的默认构造函数(因为父类没有默认构造函数),因此会导致编译错误。
要解决这个问题,可以在子类构造函数中显式调用父类的带参构造函数:
class Derived : public Base {
public:
Derived() : Base(0) {} // 显式调用父类构造函数
};
当然,也可以在父类中定义一个默认构造函数:
class Base {
public:
Base() = default; // 让编译器生成默认构造函数
Base(int value) : x(value) {}
private:
int x;
};
成员的初始化
C++ 提供了两种初始化成员变量的方式:
- 初始化列表(优先级更高,覆盖类内初始化);
- 类内初始化(in-class initializers),即在类定义中直接初始化成员变量(C++11 引入的特性)。
class Foo {
public:
Foo() {};
Foo(int a, int b) : x(a), y(b) {} // 初始化列表
private:
const int x = 0;
const int y = 0;
}
注意严格区分 C++ 中初始化和赋值的区别:
int x = 5; // 初始化
int y = { 5 }; // 初始化
int z{ 5 }; // 初始化
x = 10; // 赋值
当在类内定义 const 或引用类型的成员时,必须通过上述两种初始化方式之一进行初始化,不能通过赋值的方式进行初始化。
对于非 const 和非引用类型的成员,除了初始化,在可以在构造函数的函数体内进行赋值操作,但需要指出的是这并不是初始化。
执行初始化时,初始化的顺序与成员变量被定义的顺序一致,而与初始化列表中的初始化器排列顺序无关。 当成员变量的初始化依赖于其他成员变量,这一规则尤为重要,这时需要确保被依赖的成员变量在类定义中出现在依赖它的成员变量之前。例如,下面的写法会导致未定义行为:
class X {
public:
X(int val) : j(val), i(j) {}
private:
int i;
int j;
}
在这个例子中,i 的初始化依赖于 j,因此,j 必须定义在 i 之前。目前,主流的编译器会针对这种情况发出警告:
默认构造函数
默认构造函数(default constructor)是指不需要参数的构造函数。在没有定义任何构造函数的类中,编译器会自动生成一个默认构造函数。由编译器自动生成的构造函数称作 合成默认构造函数(synthesized default constructor) 。但是,如果类定义了任何带参构造函数,编译器就不会生成默认构造函数,这时除了显式定义默认构造函数外,还可通过 = default 让编译器生成默认构造函数。
class Foo {
public:
Foo(const Foo& foo);
private:
std::string name;
};
int main (int argc, char *argv[]) {
Foo foo; // error: no matching constructor for initialization of 'Foo'
}
只要用户自行定义了任何构造函数,编译器就不会合成默认构造函数。需要指出,= default 本质上仍然算作用户自行定义的构造函数。
默认构造函数通过如下方式使用:
Foo foo; // 正确,调用默认构造函数
Foo foo(); // 错误,被解析为函数声明
explicit 与隐式转换
explicit 关键字用于修饰构造函数,表示该构造函数不能用于隐式类型转换。默认情况下,单参数构造函数可以用于隐式类型转换。例如:
class Foo {
public:
Foo(int value) : x(value) {}
private:
int x;
};
void do_something(const Foo& foo) {}
int main() {
do_something(42); // 正确,隐式将 int 转换为 Foo
}
很多时候,我们并不希望发生隐式转换,例如:
class Resource {
public:
Resource(int value) : id(value) {}
private:
int id;
};
void process_resource(const Resource& resource) {}
int main() {
process_resource(3);
}
我们的本意是通过 process_resource 处理一个已经存在的 Resource 对象,但上面的代码会隐式创建一个临时的 Resource 对象并传递给函数,这样的操作并非我们的本意。为避免这种情况,可以将构造函数声明为 explicit,这样,要创建 Resource 对象,只能显式地调用构造函数:
class Resource {
public:
explicit Resource(int value) : id(value) {}
private:
int id;
};
void process_resource(const Resource& resource) {}
int main() {
Resource res(3); // 正确,显式创建 Resource 对象
process_resource(res); // 正确,传递已有的 Resource 对象
process_resource(3); // 错误,不能隐式转换
}
复制构造函数
复制构造函数的参数必须是对同类型对象的引用,并且几乎总是 const 引用。之所以必须是引用类型,是因为如果传递的是值类型,则会调用复制构造函数来创建参数对象,从而导致无限递归。
复制构造函数(若有)几乎不可避免地要在隐式转换中被调用,因此复制构造函数不应该声明为 explicit,例如:
class Foo {
public:
Foo(const Foo& other) : x(other.x) {}
private:
int x;
};
void do_something(Foo foo) {}
int main() {
Foo foo1;
do_something(foo1);
}
在不考虑编译优化的情况下,调用 do_something 时,foo1 实际上会先被复制一份(隐式调用复制构造函数)并作为参数传递,因此,复制构造函数的隐式调用几乎是不可避免的。
默认情况下,编译器会自动生成一个复制构造函数,称作 合成复制构造函数(synthesized copy constructor) ,其行为是逐成员复制(member-wise copy)。实际开发中,若需要用到复制构造函数(即不标记为 = delete),则应总是显示定义复制构造函数(自行定义或使用 = default)。
移动构造函数
移动构造函数用于实现资源的移动语义,用于在资源所有权可转移的情况下替代复制构造函数,从而提高性能。移动构造函数总是只包含简单的资源所有权转移操作,因此常常标记为 noexcept。
class Foo {
public:
Foo(Foo&& other) noexcept : x(other.x) {
other.x = nullptr; // 转移资源所有权
}
private:
int* x;
};
与复制构造函数类似,若需要用到移动构造函数(即不标记为 = delete),则应总是显示定义移动构造函数(自行定义或使用 = default)。
委托构造函数
C++11 引入了委托构造函数(delegating constructor)的概念,允许一个构造函数调用 同一类中的另一个构造函数 ,从而实现代码复用。例如:
class Foo {
public:
Foo(int value) : x(value) {}
Foo() : Foo(0) {} // 委托构造函数
private:
int x;
};
需要特别注意的是,在继承场景下,子类调用父类的构造函数并不属于委托构造函数的范畴,委托构造只发生在同一类内的构造函数之间。
constexpr 构造函数
提供 constexpr 构造函数的类可以形成字面类,即可以在编译期完成构造:
class Foo {
int bar;
constexpr Foo(int bar) : bar(bar) {}
};
constexpr Foo foo(1);
异常
若在构造函数中抛出了异常,由于对象没有被完全构造,因此析构函数不会被调用。因此,若必须在构造函数中抛出异常,必须保证申请的资源正确释放。
析构函数
析构函数用于在对象生命周期结束时释放资源。与构造函数类似,编译器会为没有定义析构函数的类自动生成一个 合成析构函数(synthesized destructor) 。
析构函数的调用顺序
与对象构造顺序相反,对象析构时,首先会执行派生类的析构函数体,然后依次调用成员变量的析构函数,最后调用基类的析构函数。
对于存在虚函数的类,析构函数 必须 也定义为虚函数。通过指针析构基类对象时,若基类析构函数不是虚函数,则只会调用基类的析构函数,而不会调用派生类的析构函数,从而导致资源泄漏。
class Base {
public:
Base() {
std::cout << "Base constructed" << std::endl;
}
~Base() {
std::cout << "Base destructed" << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
std::cout << "Derived constructed" << std::endl;
}
~Derived() {
std::cout << "Derived destructed" << std::endl;
}
};
int main (int argc, char *argv[]) {
Derived d; // 析构时 Derived 和 Base 的析构函数都会被调用
Base* b = new Derived;
delete b; // 只会调用 Base 的析构函数,导致 Derived 的资源泄漏
}
异常
析构函数 严禁 抛出异常,任何析构函数必须标记为 noexcept。
底层实现分析
除了类中的静态函数,其他所有的成员函数以及构造函数、析构函数实际上都包含了一个隐藏的 this 指针参数,指向调用该成员函数的对象。
class Foo {
public:
Foo(int number) : number(number) {};
private:
int number;
};
int main() {
Foo foo;
}
使用 clang++ -S -emit-llvm foo.cpp -o foo.ir 生成中间表示 foo.ir,可以看到 Foo 的构造函数实际上被转换为如下形式:
; ModuleID = 'foo.cpp'
source_filename = "foo.cpp"
target datalayout = "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-windows-msvc19.44.35220"
%class.Foo = type { i32 }
$"??0Foo@@QEAA@H@Z" = comdat any
; Function Attrs: mustprogress noinline norecurse optnone uwtable
define dso_local noundef i32 @main() #0 {
%1 = alloca %class.Foo, align 4
%2 = call noundef ptr @"??0Foo@@QEAA@H@Z"(ptr noundef nonnull align 4 dereferenceable(4) %1, i32 noundef 114514)
ret i32 0
}
; Function Attrs: mustprogress noinline nounwind optnone uwtable
define linkonce_odr dso_local noundef ptr @"??0Foo@@QEAA@H@Z"(ptr noundef nonnull returned align 4 dereferenceable(4) %0, i32 noundef %1) unnamed_addr #1 comdat align 2 {
%3 = alloca i32, align 4
%4 = alloca ptr, align 8
store i32 %1, ptr %3, align 4
store ptr %0, ptr %4, align 8
%5 = load ptr, ptr %4, align 8
%6 = getelementptr inbounds %class.Foo, ptr %5, i32 0, i32 0
%7 = load i32, ptr %3, align 4
store i32 %7, ptr %6, align 4
ret ptr %5
}
attributes #0 = { mustprogress noinline norecurse optnone uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #1 = { mustprogress noinline nounwind optnone uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
!llvm.module.flags = !{!0, !1, !2, !3}
!llvm.ident = !{!4}
!0 = !{i32 1, !"wchar_size", i32 2}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"uwtable", i32 2}
!3 = !{i32 1, !"MaxTLSAlign", i32 65536}
!4 = !{!"clang version 19.1.5"}
alloca 指令用于在栈上分配内存,call 指令用于调用函数。
可以看到,调用构造函数的过程实际上是现在栈上分配一块内存,然后将该内存地址作为隐藏的第一个参数传递给构造函数。
@"??0Foo@@QEAA@H@Z" 即为 Foo 的构造函数,其中 %0 即为隐藏的 this 指针,指向正在被构造的 Foo 对象。构造函数内部,%0、%4、%5 均为对象的起始地址,通过偏移访问成员变量。getelementptr 指令用于计算成员变量的地址偏移,随后由对象的基址加上偏移访问成员变量 number 并初始化。