构造函数
构造函数有什么特别之处?
构造函数从无到有构建对象。
构造函数就像“初始化函数”。它们将一堆任意比特转换为一个活生生的对象。它们至少会初始化内部使用的字段。它们也可能分配资源(内存、文件、信号量、套接字等)。
“ctor”是构造函数的典型缩写。
List x;
和 List x();
有什么区别吗?
一个大区别!
假设 List
是某个类的名称。那么函数 f()
声明了一个名为 x
的局部 List
对象
void f()
{
List x; // Local object named x (of class List)
// ...
}
但是函数 g()
声明了一个名为 x()
的函数,该函数返回一个 List
void g()
{
List x(); // Function named x (that returns a List)
// ...
}
一个类的构造函数能否调用同一类的另一个构造函数来初始化 this
对象?
下面的答案适用于经典(C++11之前)C++。这个问题涵盖了C++11中构造函数调用同类型构造函数的特性。
不。
我们来看一个例子。假设您希望您的构造函数 Foo::Foo(char)
调用同一类的另一个构造函数,例如 Foo::Foo(char,int)
,以便 Foo::Foo(char,int)
帮助初始化 this
对象。不幸的是,在经典C++中无法做到这一点。
有些人还是这么做了。不幸的是,它没有达到他们想要的效果。例如,行 Foo(x, 0);
没有在 this
对象上调用 Foo::Foo(char,int)
。相反,它调用 Foo::Foo(char,int)
来初始化一个临时的局部对象(不是 this
),然后当控制流过 ;
时,它会立即销毁该临时对象。
class Foo {
public:
Foo(char x);
Foo(char x, int y);
// ...
};
Foo::Foo(char x)
{
// ...
Foo(x, 0); // Does NOT help initialize the this object!!
// ...
}
您有时可以通过默认参数来组合两个构造函数
class Foo {
public:
Foo(char x, int y = 0); // Has the effect of combining the two constructors
// ...
};
如果这不起作用,例如,如果没有合适的默认参数来组合这两个构造函数,有时您可以将它们的公共代码放在一个私有的 init()
成员函数中
class Foo {
public:
Foo(char x);
Foo(char x, int y);
// ...
private:
void init(char x, int y);
};
Foo::Foo(char x)
{
init(x, int(x) + 7);
// ...
}
Foo::Foo(char x, int y)
{
init(x, y);
// ...
}
void Foo::init(char x, int y)
{
// ...
}
顺便说一句,不要试图通过placement new来实现这一点。有些人认为他们可以在 Foo::Foo(char)
的主体内部写 new(this) Foo(x, int(x)+7)
。然而,这是非常、非常、非常糟糕的。请不要写信告诉我这在您的特定编译器版本上似乎有效;这是糟糕的做法。构造函数在幕后做了一堆神奇的小事情,但是这种糟糕的技术会踩踏那些部分构造的比特。直接拒绝。
Fred
的默认构造函数总是 Fred::Fred()
吗?
不是。
“默认构造函数”是一个可以不带参数调用的构造函数。一个例子是不带参数的构造函数
class Fred {
public:
Fred(); // Default constructor: can be called with no args
// ...
};
“默认构造函数”的另一个例子是它可以带参数,前提是这些参数有默认值
class Fred {
public:
Fred(int i=3, int j=5); // Default constructor: can be called with no args
// ...
};
当我创建一个 Fred
对象数组时,哪个构造函数会被调用?
Fred
的默认构造函数(除了下面讨论的情况)。
class Fred {
public:
Fred();
// ...
};
int main()
{
Fred a[10]; // Calls the default constructor 10 times
Fred* p = new Fred[10]; // Calls the default constructor 10 times
// ...
}
如果您的类没有默认构造函数,那么当您尝试使用上述简单语法创建数组时,将会收到编译时错误
class Fred {
public:
Fred(int i, int j); // Assume there is no default constructor
// ...
};
int main()
{
Fred a[10]; // ERROR: Fred doesn't have a default constructor
Fred* p = new Fred[10]; // ERROR: Fred doesn't have a default constructor
// ...
}
然而,即使您的类已经有默认构造函数,您也应该尝试使用std::vector<Fred>
而不是数组(数组是邪恶的)。std::vector
允许您决定使用任何构造函数,而不仅仅是默认构造函数
#include <vector>
int main()
{
std::vector<Fred> a(10, Fred(5,7)); // The 10 Fred objects in std::vector a will be initialized with Fred(5,7)
// ...
}
尽管您应该使用 std::vector
而不是数组,但有时数组可能是正确的选择,对于这些情况,您可能需要“显式初始化数组”的语法。下面是如何做
class Fred {
public:
Fred(int i, int j); // Assume there is no default constructor
// ...
};
int main()
{
Fred a[10] = {
Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7), // The 10 Fred objects are
Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7) // initialized using Fred(5,7)
};
// ...
}
当然,您不必对每个条目都执行 Fred(5,7)
——您可以输入任何您想要的数字,甚至是参数或其他变量。
最后,您可以使用 placement-new 手动初始化数组的元素。警告:这很丑陋:原始数组不能是 Fred
类型,因此您需要一堆指针转换来执行数组索引操作等。警告:它依赖于编译器和硬件:您需要确保存储对齐,其对齐要求至少与 Fred
类的对象所需的一样严格。警告:使其异常安全很繁琐:您需要手动销毁元素,包括在调用构造函数的循环中途抛出异常的情况。但是,如果您真的想这样做,请阅读 placement-new。(顺便说一句,placement-new 是 std::vector
内部使用的魔法。正确处理所有事情的复杂性是使用 std::vector
的另一个原因。)
顺便问一下,我有没有说过数组是邪恶的?或者我有没有提到,除非有充分的理由使用数组,否则您应该使用 std::vector
?
我的构造函数应该使用“初始化列表”还是“赋值”?
初始化列表。事实上,构造函数通常应该在初始化列表中初始化所有成员对象。一个例外情况将在后面讨论。
关注此空间以了解C++11中非静态数据成员初始化的讨论
// Here is the taste of standard C++ NSDMI
struct Point {
int X = 0; // Look at that!!!
int Y = 0; //
};
考虑以下使用初始化列表初始化成员对象 x_
的构造函数:Fred::Fred() : x_(
whatever) { }
。这样做最常见的优点是提高了性能。例如,如果表达式 whatever 的类型与成员变量 x_
的类型相同,那么 whatever 表达式的结果将直接在 x_
中构造——编译器不会创建对象的单独副本。即使类型不同,编译器通常也能比使用赋值更好地完成初始化列表的工作。
另一种(低效的)构建构造函数的方法是通过赋值,例如:Fred::Fred() { x_ =
whatever; }
。在这种情况下,表达式 whatever 会导致创建另一个独立的临时对象,并将此临时对象传递给 x_
对象的赋值运算符。然后,该临时对象在 ;
处被销毁。这效率很低。
这还不够糟糕,构造函数中使用赋值还有另一个效率低下的来源:成员对象将通过其默认构造函数完全构造,这可能会,例如,分配一些默认数量的内存或打开一些默认文件。如果 whatever 表达式和/或赋值运算符导致对象关闭该文件和/或释放该内存(例如,如果默认构造函数没有分配足够大的内存池或它打开了错误的文件),所有这些工作都可能白费。
结论:在其他条件相同的情况下,如果使用初始化列表而不是赋值,您的代码将运行得更快。
注意:如果 x_
的类型是某种内置/固有类型,例如 int
或 char*
或 float
,则没有性能差异。但即使在这些情况下,我个人也倾向于在初始化列表中而不是通过赋值来设置这些数据成员,以保持一致性。另一个支持即使是内置/固有类型也使用初始化列表的对称性论证是:非静态 const
和非静态引用数据成员不能在构造函数中赋值,因此为了对称性,在初始化列表中初始化所有内容是有意义的。
现在来说说例外情况。每条规则都有例外(嗯;“每条规则都有例外”也有例外吗?让我想起了哥德尔不完备定理),对于“使用初始化列表”的规则有几个例外。底线是使用常识:如果不用它们更便宜、更好、更快等,那么就不要用它们。这可能发生在你的类有两个构造函数需要以不同顺序初始化 this
对象的数据成员时。或者它可能发生在两个数据成员是自引用时。或者当一个数据成员需要对 this
对象的引用,并且你想避免编译器在构造函数体开始的 {
之前使用 this
关键字时发出警告(当你的特定编译器恰好发出那个特定警告时)。或者当你需要对变量(参数、全局变量等)执行 if
…throw
测试,然后才能使用该变量初始化你的 this
成员时。这个列表并非详尽无遗;请不要写信让我添加另一个“或者当…”。重点很简单:使用常识。
构造函数的初始化列表中的初始化器应该如何排序?
直接基类(从左到右),然后是成员对象(从上到下)。
换句话说,初始化列表的顺序应该模仿初始化的实际发生顺序。此指南通过提供明显的视觉线索,阻止了特别微妙的一类顺序依赖错误。例如,下面包含一个严重的错误。
#include <iostream>
class Y {
public:
Y();
void f();
};
Y::Y() { std::cout << "Initializing Y\n"; }
void Y::f() { std::cout << "Using Y\n"; }
class X {
public:
X(Y& y);
};
X::X(Y& y) { y.f(); }
class Z {
public:
Z();
protected:
X x_;
Y y_;
};
Z::Z() throw()
: y_()
, x_(y_)
↑↑ // Bad: should have listed x_ before y_
{ }
int main()
{
Z z;
return 0;
}
此程序的输出如下。
Using Y
Initializing Y
请注意,y_
在初始化之前(Y::Y()
)就被使用了(Y::f()
)。如果程序员阅读并遵守了本 FAQ 中的指导方针,那么错误会更明显:Z::Z()
的初始化列表会写成 x_(y_), y_()
,从视觉上表明 y_
在初始化之前就被使用了。
并非所有编译器都会针对这些情况发出诊断消息。您已被警告。
在一个成员对象的初始化表达式中使用另一个成员对象进行初始化是否“道德”?
是的,但要小心,并且只有在增加价值时才这样做。
在构造函数的初始化列表中,最简单和最安全的方法是避免在后续初始化器的初始化表达式中使用 this
对象的其他成员对象。此指南可以防止如果有人重新组织类中成员对象的布局时,出现的微妙的顺序依赖错误。
由于此指南,下面的构造函数使用 s.len_ + 1u
而不是 len_ + 1u
,尽管它们在其他方面是等效的。s.
前缀避免了不必要且可避免的顺序依赖。
#include <memory>
class MyString {
public:
MyString();
~MyString();
MyString(const MyString& s); // copy constructor
MyString& operator= (const MyString& s); // assignment
// ...
protected:
unsigned len_;
char* data_;
};
MyString::MyString()
: len_(0u)
, data_(new char[1])
{
data_[0] = '\0';
}
MyString::~MyString()
{ delete[] data_; }
MyString::MyString(const MyString& s)
: len_ (s.len_)
, data_(new char[s.len_ + 1u]) <--Not {tt{new char[len_+1]}tt}
{ ↑↑↑↑↑↑ // not len_
memcpy(data_, s.data_, len_ + 1u);
} ↑↑↑↑ // no issue using len_ in ctor's {body}
int main()
{
MyString a; // default ctor; zero length MyString ("")
MyString b = a; // copy constructor
return 0;
}
如果构造函数对 data_
的初始化使用了 len_ + 1u
而不是 s.len_ + 1u
,那么就会引入对 len_
和 data_
的类布局不必要的顺序依赖。然而,在构造函数体({...}
)中使用 len_
是可以的。因为整个初始化列表在构造函数体开始执行之前保证完成,所以不会引入顺序依赖。
如果一个成员对象必须使用另一个成员对象进行初始化怎么办?
在受影响的数据成员声明处添加注释 //ORDER DEPENDENCY
。
如果构造函数使用 this
对象的另一个成员对象来初始化 this
对象的成员对象,那么重新排列类的成员数据可能会破坏构造函数。这个重要的维护约束应该在类体中注明。
例如,在下面的构造函数中,data_
的初始化器使用 len_
来避免重复调用 std::strlen(s)
,这会在类体中引入顺序依赖。
#include <memory>
class MyString {
public:
MyString(const char* s); // promote const char*
MyString(const MyString& s); // copy constructor
MyString& operator= (const MyString&); // assignment
~MyString();
// ...
protected:
unsigned len_; // ORDER DEPENDENCY
char* data_; // ORDER DEPENDENCY
};
MyString::MyString(const char* s)
: len_ (std::strlen(s))
, data_(new char[len_ + 1u])
{
std::memcpy(data_, s, len_ + 1u);
}
MyString::~MyString()
{
delete[] data_;
}
int main()
{
MyString s = "xyzzy";
return 0;
}
请注意,//ORDER DEPENDENCY
注释是写在受影响的类体中的数据成员旁边,而不是写在实际创建顺序依赖的构造函数初始化列表旁边。这是因为类体中成员对象的顺序至关重要;构造函数初始化列表中初始化器的顺序无关紧要。
你应不应该在构造函数中使用 this
指针?
有些人认为您不应该在构造函数中使用 this
指针,因为对象尚未完全形成。但是,如果您小心谨慎,您可以在构造函数中(在 {
body}
中,甚至在初始化列表中)使用 this
。
这里有一个总是有效的方法:构造函数(或从构造函数调用的函数)的 {
body}
可以可靠地访问在基类中声明的数据成员和/或在构造函数自己的类中声明的数据成员。这是因为所有这些数据成员在构造函数的 {
body}
开始执行时都保证已完全构造。
这里有一个永远不起作用的方法:构造函数(或从构造函数调用的函数)的 {
body}
不能通过调用在派生类中被覆盖的 virtual
成员函数来“下降”到派生类。如果您的目标是访问派生类中被覆盖的函数,您将无法得到您想要的结果。请注意,无论您如何调用 virtual
成员函数,您都无法访问派生类中的覆盖函数:显式使用 this
指针(例如,this->method()
),隐式使用 this
指针(例如,method()
),甚至调用在您的 this
对象上调用 virtual
成员函数的其他函数。底线是:即使调用者正在构造派生类的对象,在基类的构造函数期间,您的对象尚未成为该派生类的对象。您已被警告。
这里有一个有时有效的方法:如果您将 this
对象中的任何数据成员传递给另一个数据成员的初始化器,您必须确保另一个数据成员已经初始化。好消息是您可以使用一些与您使用的特定编译器无关的简单语言规则来确定另一个数据成员是否已(或未)初始化。坏消息是您必须了解这些语言规则(例如,基类子对象首先初始化(如果您有多个和/或virtual
继承,请查找顺序!),然后类中定义的数据成员按照它们在类声明中出现的顺序初始化)。如果您不知道这些规则,那么不要将 this
对象中的任何数据成员(无论您是否显式使用 this
关键字)传递给任何其他数据成员的初始化器!如果您知道这些规则,请务必小心。
什么是“命名构造函数习语”?
一种为您的类用户提供更直观和/或更安全构造操作的技术。
问题是构造函数总是与类同名。因此,区分一个类的各种构造函数的唯一方法是根据参数列表。但是,如果构造函数很多,它们之间的差异会变得有些微妙且容易出错。
使用命名构造函数习语,您将所有类的构造函数声明在 private
或 protected
部分,并提供返回对象的 public
static
方法。这些 static
方法就是所谓的“命名构造函数”。通常,每种不同的对象构造方式都有一个这样的 static
方法。
例如,假设我们正在构建一个表示X-Y平面上位置的 Point
类。结果发现有两种常用的方法来指定一个二维坐标:直角坐标(X+Y)和极坐标(半径+角度)。(如果您不记得这些,请不要担心;重点不是坐标系的细节;重点是有几种创建 Point
对象的方法。)不幸的是,这两种坐标系的参数是相同的:两个 float
。这将在重载的构造函数中造成歧义错误
class Point {
public:
Point(float x, float y); // Rectangular coordinates
Point(float r, float a); // Polar coordinates (radius and angle)
// ERROR: Overload is Ambiguous: Point::Point(float,float)
};
int main()
{
Point p = Point(5.7, 1.2); // Ambiguous: Which coordinate system?
// ...
}
解决这个歧义的一种方法是使用命名构造函数习语
#include <cmath> // To get std::sin() and std::cos()
class Point {
public:
static Point rectangular(float x, float y); // Rectangular coord's
static Point polar(float radius, float angle); // Polar coordinates
// These static methods are the so-called "named constructors"
// ...
private:
Point(float x, float y); // Rectangular coordinates
float x_, y_;
};
inline Point::Point(float x, float y)
: x_(x), y_(y) { }
inline Point Point::rectangular(float x, float y)
{ return Point(x, y); }
inline Point Point::polar(float radius, float angle)
{ return Point(radius*std::cos(angle), radius*std::sin(angle)); }
现在 Point
的用户有了一种清晰明确的语法,可以在任一坐标系中创建 Point
int main()
{
Point p1 = Point::rectangular(5.7, 1.2); // Obviously rectangular
Point p2 = Point::polar(5.7, 1.2); // Obviously polar
// ...
}
如果您期望 Point
有派生类,请确保您的构造函数位于 protected
部分。
命名构造函数习语也可以用来确保您的对象总是通过 new
创建。
请注意,命名构造函数习语,至少在上述实现中,与直接调用构造函数一样快——现代编译器不会对您的对象进行任何额外复制。
按值返回是否意味着额外的复制和额外的开销?
不一定。
所有(?)商业级编译器都会优化掉额外的副本,至少在上一个 FAQ 中所示的案例中。
为了保持示例的清晰,我们将其简化为最基本的部分。假设函数 caller()
调用 rbv()
(“rbv”代表“按值返回”),该函数按值返回一个 Foo
对象
class Foo { /*...*/ };
Foo rbv();
void caller()
{
Foo x = rbv(); // The return-value of rbv() goes into x
// ...
}
现在问题是,会有多少个 Foo
对象?rbv()
会创建一个临时 Foo
对象,然后将其复制构造到 x
中吗?会有多少个临时对象?换句话说,按值返回是否必然会降低性能?
本 FAQ 的重点是答案是否定的,商业级 C++ 编译器以一种能够消除开销的方式实现按值返回,至少在上一 FAQ 中所示的简单情况下。特别是,所有(?)商业级 C++ 编译器都会优化此情况
Foo rbv()
{
// ...
return Foo(42, 73); // Suppose Foo has a ctor Foo::Foo(int a, int b)
}
当然,编译器允许创建一个临时的局部 Foo
对象,然后将该临时对象复制构造到 caller()
中的变量 x
,然后销毁该临时对象。但所有(?)商业级 C++ 编译器都不会这样做:return
语句将直接构造 x
本身。不是 x
的副本,不是 x
的指针,也不是 x
的引用,而是 x
本身。
如果你不想真正理解上一段,你可以在这里停下来,但如果你想知道秘密武器(这样你就可以,例如,可靠地预测编译器何时能够和不能为你提供这种优化),关键是要知道编译器通常使用按指针传递来实现按值返回。当 caller()
调用 rbv()
时,编译器会秘密地传递一个指向 rbv()
应该构造“返回”对象的内存位置的指针。它可能看起来像这样(显示为 void*
而不是 Foo*
,因为 Foo
对象尚未构造)
// Pseudo-code
void rbv(void* put_result_here) // Original C++ code: Foo rbv()
{
// ...code that initializes (not assigns to) the variable pointed to by put_result_here
}
// Pseudo-code
void caller()
{
// Original C++ code: Foo x = rbv()
struct Foo x; // Note: x does not get initialized prior to calling rbv()
rbv(&x); // Note: rbv() initializes a local variable defined in caller()
// ...
}
因此,秘密武器的第一个成分是编译器(通常)将按值返回转换为按指针传递。这意味着商业级编译器不会费心创建临时对象:它们直接在 put_result_here
指向的位置构造返回的对象。
秘密武器的第二个组成部分是编译器通常使用类似的技术来实现构造函数。这取决于编译器并且有些理想化(我故意忽略了如何处理 new
和重载),但编译器通常会像这样实现 Foo::Foo(int a, int b)
// Pseudo-code
void Foo_ctor(Foo* this, int a, int b) // Original C++ code: Foo::Foo(int a, int b)
{
// ...
}
将这些结合起来,编译器可能会通过简单地将 put_result_here
作为构造函数的 this
指针来实现 rbv()
中的 return
语句
// Pseudo-code
void rbv(void* put_result_here) // Original C++ code: Foo rbv()
{
// ...
Foo_ctor((Foo*)put_result_here, 42, 73); // Original C++ code: return Foo(42,73);
return;
}
所以 caller()
将 &x
传递给 rbv()
,而 rbv()
反过来将 &x
传递给构造函数(作为 this
指针)。这意味着构造函数直接构造 x
。
在90年代早期,我为IBM在多伦多的编译器团队举办了一场研讨会,他们的一位工程师告诉我,他们发现这种按值返回的优化非常快,即使你不开启优化编译,你也能得到它。因为按值返回的优化使编译器生成更少的代码,它实际上除了使你生成的代码更小更快之外,还提高了编译时间。重点是,按值返回的优化几乎是普遍实现的,至少在上述代码示例中。
最后一点思考:此讨论仅限于按值返回调用中是否存在返回对象的额外副本。请勿将其与 caller()
中可能发生的其他事情混淆。例如,如果您将 caller()
从 Foo x = rbv();
更改为 Foo x; x = rbv();
(注意声明后的 ;
),编译器将需要使用 Foo
的赋值运算符,除非编译器能够证明 Foo
的默认构造函数后跟赋值运算符与其复制构造函数完全相同,否则语言要求编译器将返回的对象放入 caller()
内的未命名临时对象中,使用赋值运算符将临时对象复制到 x
中,然后销毁临时对象。按值返回优化仍然发挥作用,因为只会有一个临时对象,但通过将 Foo x = rbv();
更改为 Foo x; x = rbv();
,您阻止了编译器消除该最后一个临时对象。
按值返回局部变量呢?局部变量是作为独立对象存在,还是被优化掉了?
当您的代码按值返回局部变量时,您的编译器可能会完全优化掉局部变量——零空间成本和零时间成本——局部变量实际上从未作为调用者目标变量的独立对象存在(有关确切含义的详细信息,请参见下文)。其他编译器不会将其优化掉。
以下是一些(!)完全优化掉局部变量的编译器
- GNU C++ (g++) 至少从 3.3.3 版本开始
- (其他需要添加;需要更多信息)
以下是一些(!)不优化掉局部变量的编译器
- Microsoft Visual C++.NET 2003
- (其他需要添加;需要更多信息)
这是一个示例,展示了我们在本 FAQ 中所说的含义
class Foo {
public:
Foo(int a, int b);
void some_method();
// ...
};
void do_something_with(Foo& z);
Foo rbv()
{
Foo y = Foo(42, 73);
y.some_method();
do_something_with(y);
return y;
}
void caller()
{
Foo x = rbv();
// ...
}
本 FAQ 中要解决的问题是:在运行时系统中实际创建了多少个 Foo
对象?从概念上讲,可能有多达三个不同的对象:由 Foo(42, 73)
创建的临时对象,变量 y
(在 rbv()
中),以及变量 x
(在 caller()
中)。然而,正如我们前面看到的大多数编译器将 Foo(42, 73)
和变量 y
合并到同一个对象中,从而将对象的总数从 3 减少到 2。但本 FAQ 更进一步:y
(在 rbv()
中)是否显示为与 x
(在 caller()
中)不同的运行时对象?
一些编译器,包括但不限于上面列出的那些,会完全优化掉局部变量 y
。在这些编译器中,上述代码中只有一个 Foo
对象:caller()
的变量 x
与 rbv()
的变量 y
完全相同。
它们这样做的方式与之前描述的相同:函数 rbv()
中的按值返回被实现为按指针传递,其中指针指向返回对象将被初始化的位置。
因此,这些编译器不是将 y
构造为局部对象,而是直接构造 *put_result_here
,并且每次在原始源代码中看到变量 y
被使用时,它们都用 *put_result_here
替换。然后,行 return y;
就变成了简单的 return;
,因为返回的对象已经在调用者指定的位置构造好了。
这是结果(伪)代码
// Pseudo-code
void rbv(void* put_result_here) // Original C++ code: Foo rbv()
{
Foo_ctor((Foo*)put_result_here, 42, 73); // Original C++ code: Foo y = Foo(42,73);
Foo_some_method(*(Foo*)put_result_here); // Original C++ code: y.some_method();
do_something_with((Foo*)put_result_here); // Original C++ code: do_something_with(y);
return; // Original C++ code: return y;
}
void caller()
{
struct Foo x; // Note: x is not initialized here!
rbv(&x); // Original C++ code: Foo x = rbv();
// ...
}
警告:此优化只能在函数的所有 return
语句都返回相同的局部变量时应用。如果 rbv()
中的一个 return
语句返回局部变量 y
,而另一个返回其他内容,例如全局变量或临时变量,则编译器无法将局部变量别名为调用者的目标 x
。验证函数的所有返回语句都返回相同的局部变量需要编译器编写者额外的工作,这就是为什么有些编译器未能实现该按值返回局部变量优化的原因。
最后一点思考:此讨论仅限于按值返回调用中是否存在返回对象的额外副本。请勿将其与 caller()
中可能发生的其他事情混淆。例如,如果您将 caller()
从 Foo x = rbv();
更改为 Foo x; x = rbv();
(注意声明后的 ;
),编译器将需要使用 Foo
的赋值运算符,除非编译器能够证明 Foo
的默认构造函数后跟赋值运算符与其复制构造函数完全相同,否则语言要求编译器将返回的对象放入 caller()
内的未命名临时对象中,使用赋值运算符将临时对象复制到 x
中,然后销毁临时对象。按值返回优化仍然发挥作用,因为只会有一个临时对象,但通过将 Foo x = rbv();
更改为 Foo x; x = rbv();
,您阻止了编译器消除该最后一个临时对象。
为什么我不能在构造函数的初始化列表中初始化 static
成员数据?
因为您必须显式定义类的 static
数据成员。
Fred.h
:
class Fred {
public:
Fred();
// ...
private:
int i_;
static int j_;
};
Fred.cpp
(或 Fred.C
或其他)
Fred::Fred()
: i_(10) // Okay: you can (and should) initialize member data this way
, j_(42) // Error: you cannot initialize static member data like this
{
// ...
}
// You must define static data members this way:
int Fred::j_ = 42;
注意:在某些情况下,Fred::j_
的定义可能不包含 =
初始化器部分。详细信息请参见这里和这里。
为什么带有 static
数据成员的类会得到链接器错误?
因为static
数据成员必须在且仅在一个编译单元中显式定义。如果您没有这样做,您可能会收到 "undefined external"
链接器错误。例如
// Fred.h
class Fred {
public:
// ...
private:
static int j_; // Declares static data member Fred::j_
// ...
};
链接器会报错("Fred::j_ is not defined"
),除非您在(且仅在)一个源文件中定义(而不仅仅是声明)Fred::j_
// Fred.cpp
#include "Fred.h"
int Fred::j_ = some_expression_evaluating_to_an_int;
// Alternatively, if you wish to use the implicit 0 value for static ints:
// int Fred::j_;
定义 class
Fred
的 static
数据成员的常见位置是文件 Fred.cpp
(或 Fred.C
或您使用的任何源文件扩展名)。
注意:在某些情况下,您可以将 =
initializer;
添加到类范围 static
声明中,但是如果您使用了该数据成员,您仍然需要在且仅在一个编译单元中显式定义它。在这种情况下,您不需要在定义中包含 =
initializer。一个单独的 FAQ 涵盖了此主题。
我可以在类范围 static
const
数据成员的声明中添加 =
初始化器;
吗?
可以,但有一些重要的注意事项。
在讨论注意事项之前,这里有一个允许的简单示例
// Fred.h
class Fred {
public:
static const int maximum = 42;
// ...
};
并且,就像其他 static
数据成员一样,它必须在且仅在一个编译单元中定义,尽管这次不带 =
初始化器部分
// Fred.cpp
#include "Fred.h"
const int Fred::maximum;
// ...
注意事项是,您只能将其用于整数或枚举类型,并且初始化器表达式必须是可以在编译时求值的表达式:它只能包含其他常量,可能与内置运算符结合使用。例如,3*4
是一个编译时常量表达式,a*b
也是,只要 a
和 b
是编译时常量。在上述声明之后,Fred::maximum
也是一个编译时常量:它可以在其他编译时常量表达式中使用。
如果您曾经获取 Fred::maximum
的地址,例如通过引用传递它或显式地写 &Fred::maximum
,编译器将确保它有一个唯一的地址。否则,Fred::maximum
甚至不会占用您进程的静态数据区域的空间。
什么是“静态初始化顺序‘大灾难’(问题)”?
一种导致程序崩溃的微妙方式。
静态初始化顺序问题是 C++ 中一个非常微妙且经常被误解的方面。不幸的是,它很难检测——错误通常在 main()
开始之前就发生了。
简而言之,假设您有两个 static
对象 x
和 y
存在于不同的源文件中,例如 x.cpp
和 y.cpp
。再假设 y
对象的初始化(通常是 y
对象的构造函数)调用了 x
对象上的某个方法。
就是这样。就这么简单。
困难之处在于您有 50% 的机会破坏程序。如果 x.cpp
的编译单元碰巧先初始化,一切都会顺利。但如果 y.cpp
的编译单元先初始化,那么 y
的初始化将在 x
的初始化之前运行,您就完蛋了。例如,y
的构造函数可以调用 x
对象上的方法,而 x
对象尚未构造。
有关如何解决此问题,请参阅下一篇 FAQ。
注意:静态初始化顺序问题在某些情况下也适用于内置/固有类型。
如何防止“静态初始化顺序问题”?
为了防止静态初始化顺序问题,请使用下面描述的首次使用时构造习语。
首次使用时构造习语的基本思想是将您的 static
对象包装在一个函数中。例如,假设您有两个类,Fred
和 Barney
。有一个命名空间范围/全局 Fred
对象名为 x
,一个命名空间范围/全局 Barney
对象名为 y
。Barney
的构造函数调用 x
对象上的 goBowling()
方法。文件 x.cpp
定义了 x
对象
// File x.cpp
#include "Fred.h"
Fred x;
文件 y.cpp
定义了 y
对象
// File y.cpp
#include "Barney.h"
Barney y;
为了完整性,Barney
构造函数可能看起来像这样
// File Barney.cpp
#include "Barney.h"
Barney::Barney()
{
// ...
x.goBowling();
// ...
}
如果 y
在 x
之前被构造,您将会遇到static
初始化灾难。如上所述,这种灾难大约会发生 50% 的时间,因为这两个对象声明在不同的源文件中,并且这些源文件没有向编译器或链接器提示静态初始化的顺序。
这个问题有许多解决方案,但一个非常简单且完全可移植的解决方案是首次使用时构造习语:将命名空间范围/全局 Fred
对象 x
替换为返回 Fred
对象的命名空间范围/全局函数 x()
。
// File x.cpp
#include "Fred.h"
Fred& x()
{
static Fred* ans = new Fred();
return *ans;
}
由于 static
局部对象仅在控制流首次通过其声明时(仅此一次)构造,上述 new Fred()
语句只会发生一次:第一次调用 x()
时。后续每次调用都会返回相同的 Fred
对象(由 ans
指向的对象)。然后您所做的就是将对 x
的使用更改为 x()
// File Barney.cpp
#include "Barney.h"
Barney::Barney()
{
// ...
x().goBowling();
// ...
}
这被称为首次使用时构造习语,因为它确实如此:(逻辑上命名空间范围/全局) Fred
对象在首次使用时构造。
这种方法的缺点是 Fred
对象永远不会被销毁。如果 Fred
对象有一个带有重要副作用的析构函数,那么还有另一种技术可以解决这个问题;但它需要小心使用,因为它可能会导致另一个(同样令人讨厌的)问题。
注意:静态初始化顺序问题在某些情况下也适用于内置/固有类型。
为什么“首次使用时构造习语”不使用 static
对象而是使用 static
指针?
简短回答:可以使用静态对象而不是静态指针,但这样做会引出另一个(同样微妙,同样糟糕的)问题。
长答案:有时人们会担心前面的解决方案“泄露”的事实。在许多情况下,这不是问题,但在某些情况下是问题。注意:尽管前一个 FAQ 中 ans
指向的对象从未被删除,但当程序退出时,内存实际上不会“泄露”,因为操作系统会在程序退出时自动回收程序堆中的所有内存。换句话说,您唯一需要担心这种情况的是当 Fred
对象的析构函数执行某些重要操作(例如写入文件)时,这些操作必须在程序退出时发生。
在那些首次使用时构造的对象(本例中的 Fred
)最终需要被销毁的情况下,您可能会考虑将函数 x()
更改如下
// File x.cpp
#include "Fred.h"
Fred& x()
{
static Fred ans; // was static Fred* ans = new Fred();
return ans; // was return *ans;
}
然而,这种改变存在(或者说,可能存在)一个相当微妙的问题。为了理解这个潜在问题,让我们记住我们最初为什么要这么做:我们需要百分之百地确保我们的静态对象 (a) 在首次使用前被构造,并且 (b) 在最后一次使用后才被销毁。显然,如果任何静态对象在构造之前或销毁之后被使用,那将是一场灾难。这里要传达的信息是,您需要担心两种情况(静态初始化和静态去初始化),而不仅仅是一种情况。
通过将声明从 static Fred* ans = new Fred();
更改为 static Fred ans;
,我们仍然正确处理了初始化情况,但我们不再处理去初始化情况。例如,如果有 3 个静态对象,例如 a
、b
和 c
,它们在其析构函数中使用 ans
,那么避免静态去初始化灾难的唯一方法是 ans
在这三个对象中的最后一个被销毁之后才被销毁。
重点很简单:如果有任何其他静态对象的析构函数可能在 ans
销毁后使用 ans
,那么,你就完蛋了。如果 a
、b
和 c
的构造函数使用 ans
,你通常应该没问题,因为运行时系统在静态去初始化期间,会在这三个对象中的最后一个被销毁后销毁 ans
。然而,如果 a
和/或 b
和/或 c
未在其构造函数中使用 ans
,和/或如果任何地方的任何代码获取了 ans
的地址并将其传递给其他静态对象,那么一切都悬了,你必须非常非常小心。
还有第三种方法可以同时处理静态初始化和静态去初始化,但它有其他非平凡的成本。
有没有一种技术可以保证 static
初始化和 static
去初始化?
简短回答:使用巧妙计数器习语(但请确保您理解其非平凡的权衡!)。
动机
- 首次使用时构造习语使用指针并有意泄露对象。这通常是无害的,因为操作系统通常会在进程终止时清理进程内存。但是,如果对象具有非平凡的析构函数,并带有重要的副作用,例如写入文件或其他非易失性操作,那么您需要更多。
- 这就是“首次使用时构造习语”的第二个版本的由来:它不会泄露对象,但它不控制静态去初始化的顺序,因此在静态去初始化期间,即从另一个静态声明的对象的析构函数中,使用该对象是非常不安全的。
- 如果您需要控制静态初始化和静态去初始化的顺序,这意味着如果您希望从其他静态对象的构造函数和析构函数中访问静态分配的对象,请继续阅读。
- 否则,请离开。
TODO: 撰写此部分
TODO:撰写权衡——既然您已经知道如何使用巧妙计数器习语,请务必理解何时以及(特别是!)何时不使用它!一种尺寸不适合所有情况。
如何防止我的 static
数据成员的“静态初始化顺序问题”?
使用成员首次使用时构造习语,它基本上与常规的首次使用时构造习语相同,或者可能是其变体之一,但它使用 static
成员函数而不是命名空间范围/全局函数。
假设您有一个类 X
,它有一个 static
Fred
对象
// File X.h
class X {
public:
// ...
private:
static Fred x_;
};
当然,这个 static
成员是单独初始化的
// File X.cpp
#include "X.h"
Fred X::x_;
自然地,Fred
对象将在 X
的一个或多个方法中使用
void X::someMethod()
{
x_.goBowling();
}
但现在“灾难场景”是有人在某个地方以某种方式在 Fred
对象构造之前调用此方法。例如,如果其他人创建了一个静态 X
对象并在静态初始化期间调用其 someMethod()
方法,那么您将取决于编译器是否在调用 someMethod()
之前或之后构造 X::x_
。(请注意,ANSI/ISO C++ 委员会正在研究这个问题,但处理这些更改的编译器尚未普遍可用;请关注此空间以获取未来的更新。)
无论如何,将 X::x_
static
数据成员更改为 static
成员函数始终是可移植且安全的
// File X.h
class X {
public:
// ...
private:
static Fred& x();
};
当然,这个 static
成员是单独初始化的
// File X.cpp
#include "X.h"
Fred& X::x()
{
static Fred* ans = new Fred();
return *ans;
}
然后您只需将 x_
的所有用法更改为 x()
void X::someMethod()
{
x().goBowling();
}
如果您对性能超级敏感,并且担心每次调用 X::someMethod()
时额外函数调用的开销,您可以改用 static Fred&
。您会记得,static
局部变量只初始化一次(控制流首次通过其声明时),所以这只会调用 X::x()
一次:第一次调用 X::someMethod()
时
void X::someMethod()
{
static Fred& x = X::x();
x.goBowling();
}
注意:静态初始化顺序问题在某些情况下也适用于内置/固有类型。
我需要担心内置/固有类型变量的“静态初始化顺序问题”吗?
是的。
如果您的内置/固有类型是使用函数调用初始化的,那么静态初始化顺序问题同样会给您带来严重的麻烦,就像用户定义/类类型一样。例如,以下代码展示了这种失败
#include <iostream>
int f(); // forward declaration
int g(); // forward declaration
int x = f();
int y = g();
int f()
{
std::cout << "using 'y' (which is " << y << ")\n";
return 3*y + 7;
}
int g()
{
std::cout << "initializing 'y'\n";
return 5;
}
这个小程序会输出它在初始化 y
之前使用了 y
。解决方案和以前一样,是首次使用时构造习语
#include <iostream>
int f(); // forward declaration
int g(); // forward declaration
int& x()
{
static int ans = f();
return ans;
}
int& y()
{
static int ans = g();
return ans;
}
int f()
{
std::cout << "using 'y' (which is " << y() << ")\n";
return 3*y() + 7;
}
int g()
{
std::cout << "initializing 'y'\n";
return 5;
}
当然,您可以通过将 x
和 y
的初始化代码移到各自的函数中来简化此过程
#include <iostream>
int& y(); // forward declaration
int& x()
{
static int ans;
static bool firstTime = true;
if (firstTime) {
firstTime = false;
std::cout << "using 'y' (which is " << y() << ")\n";
ans = 3*y() + 7;
}
return ans;
}
int& y()
{
static int ans;
static bool firstTime = true;
if (firstTime) {
firstTime = false;
std::cout << "initializing 'y'\n";
ans = 5;
}
return ans;
}
而且,如果你能去掉打印语句,你可以进一步简化成非常简单的东西
int& y(); // forward declaration
int& x()
{
static int ans = 3*y() + 7;
return ans;
}
int& y()
{
static int ans = 5;
return ans;
}
此外,由于 y
是用常量表达式初始化的,它不再需要它的包装函数——它又可以是一个简单的变量了。
如何处理构造函数失败的情况?
抛出异常。详情请参阅此处。
什么是“命名参数习语”?
这是一种利用方法链相当有用的方式。
命名参数习语解决的根本问题是 C++ 只支持位置参数。例如,函数的调用者不允许说:“这是形式参数 xyz
的值,这是形式参数 pqr
的值。”在 C++(和 C、Java)中,你只能说:“这是第一个参数,这是第二个参数,等等。”另一种方法,称为命名参数并在 Ada 语言中实现,在函数接受大量大部分可默认参数时特别有用。
多年来,人们为 C 和 C++ 缺乏命名参数的问题想出了许多变通方法。其中一种方法是将参数值嵌入字符串参数中,然后在运行时解析此字符串。例如,fopen()
的第二个参数就是这样做的。另一种变通方法是将所有布尔参数组合成一个位图,然后调用者将一堆移位常量进行或运算以生成实际参数。例如,open()
的第二个参数就是这样做的。这些方法有效,但以下技术生成的调用者代码更明显,更容易编写,更容易阅读,并且通常更优雅。
这种想法被称为命名参数习语,它将函数的参数改为一个新创建的类的方法,其中所有这些方法都通过引用返回 *this
。然后您只需将主函数重命名为该类上的一个无参数的“do-it”方法。
我们将通过一个例子来让上一段更容易理解。
这个例子将是关于“打开文件”的概念。假设这个概念逻辑上需要一个文件名参数,并可选地允许设置文件是只读、读写还是只写打开,如果文件不存在是否应该创建,写入位置是在文件末尾(“追加”)还是文件开头(“覆盖”),如果文件要创建则设置块大小,I/O是缓冲还是非缓冲,缓冲区大小,是共享访问还是独占访问,可能还有其他一些。如果使用带有位置参数的普通函数来实现这个概念,调用者代码将非常难以阅读:可能有8个位置参数,并且调用者可能会犯很多错误。因此,我们改用命名参数习语。
在我们深入实现之前,假设您愿意接受所有函数的默认参数,调用者代码可能看起来像这样
File f = OpenFile("foo.txt");
那是最简单的情况。现在,如果您想更改一大堆参数,它可能看起来像这样。
File f = OpenFile("foo.txt")
.readonly()
.createIfNotExist()
.appendWhenWriting()
.blockSize(1024)
.unbuffered()
.exclusiveAccess();
请注意,“参数”(如果可以这样称呼它们的话)是随机顺序的(它们不是位置性的),并且它们都具有名称。因此,程序员不必记住参数的顺序,并且名称(希望)是显而易见的。
因此,实现方法如下:首先,我们创建一个类(OpenFile
),它将所有参数值作为 private
数据成员。必需的参数(在这种情况下,唯一必需的参数是文件名)作为 OpenFile
构造函数上的普通位置参数实现,但该构造函数实际上并不打开文件。然后,所有可选参数(只读 vs. 读写等)都成为方法。这些方法(例如,readonly()
、blockSize(unsigned)
等)返回对其 this
对象的引用,以便方法调用可以链式调用。
class File;
class OpenFile {
public:
OpenFile(const std::string& filename);
// sets all the default values for each data member
OpenFile& readonly(); // changes readonly_ to true
OpenFile& readwrite(); // changes readonly_ to false
OpenFile& createIfNotExist();
OpenFile& blockSize(unsigned nbytes);
// ...
private:
friend class File;
std::string filename_;
bool readonly_; // defaults to false [for example]
bool createIfNotExist_; // defaults to false [for example]
// ...
unsigned blockSize_; // defaults to 4096 [for example]
// ...
};
inline OpenFile::OpenFile(const std::string& filename)
: filename_ (filename)
, readonly_ (false)
, createIfNotExist_ (false)
, blockSize_ (4096u)
{ }
inline OpenFile& OpenFile::readonly()
{ readonly_ = true; return *this; }
inline OpenFile& OpenFile::readwrite()
{ readonly_ = false; return *this; }
inline OpenFile& OpenFile::createIfNotExist()
{ createIfNotExist_ = true; return *this; }
inline OpenFile& OpenFile::blockSize(unsigned nbytes)
{ blockSize_ = nbytes; return *this; }
唯一剩下的就是让 File
类的构造函数接受一个 OpenFile
对象
class File {
public:
File(const OpenFile& params);
// ...
};
这个构造函数从 OpenFile 对象获取实际参数,然后实际打开文件
File::File(const OpenFile& params)
{
// ...
}
注意,OpenFile
声明 File
为其friend
,这样OpenFile
不需要一堆(否则无用的)public:
get 方法。
由于链中的每个成员函数都返回一个引用,因此没有对象的复制,并且链效率很高。此外,如果各个成员函数是 inline
,生成的对象代码可能与设置 struct
各种成员的 C 风格代码相当。当然,如果成员函数不是 inline
,可能会导致代码大小略有增加,性能略有下降(但只有当构造发生在 CPU 密集型程序的关键路径上时;这是一个我将尽量避免打开的潘多拉魔盒),因此在这种情况下,这可能是为了使代码更可靠而进行的权衡。
为什么我通过 Foo x(Bar())
声明 Foo
对象后会收到错误?
因为它不创建 Foo
对象 - 它声明了一个非成员函数,该函数返回一个 Foo
对象。术语“最令人困惑的解析”(Most Vexing Parse)由 Scott Myers 创造,用于描述这种情况。
这真的会很痛苦;你可能想坐下来。
首先,这里是问题的更好解释。假设有一个名为 Bar
的类,它有一个默认构造函数。这甚至可能是一个库类,例如 std::string
,但现在我们只将其称为 Bar
class Bar {
public:
Bar();
// ...
};
现在假设有另一个名为 Foo
的类,它有一个接受 Bar
的构造函数。如前所述,这可能由您以外的人定义。
class Foo {
public:
Foo(const Bar& b); // or perhaps Foo(Bar b)
// ...
void blah();
// ...
};
现在您想使用临时 Bar
对象创建一个 Foo
对象。换句话说,您想通过 Bar()
创建一个对象,并将其传递给 Foo
构造函数以创建一个名为 x
的局部 Foo
对象
void yourCode()
{
Foo x(Bar()); // You think this creates a Foo object called x...
x.blah(); // ...But it doesn't, so this line gives you a bizarre error message
// ...
}
说来话长,但一个解决方案(希望您坐下了!)是在 Bar()
部分周围添加一对额外的 ()
void yourCode()
{
Foo x((Bar()));
↑ ↑ // These parens save the day
x.blah();
↑↑↑↑↑↑↑↑ // Ahhhh, this now works: no more error messages
// ...
}
另一种解决方案是在声明中使用 =
(参见下面的细则)
void yourCode()
{
Foo x = Foo(Bar()); // Yes, Virginia, that thar syntax works; see below for fine print
x.blah(); // Ahhhh, this now works: no more error messages
// ...
}
注意:上述解决方案要求 yourCode()
能够访问 Foo
拷贝构造函数。在大多数情况下,这意味着 Foo
拷贝构造函数需要是 public
,但在 yourCode()
是 class Foo
的友元的较不常见情况下,它不需要是 public
。如果您不确定这意味着什么,请尝试一下:如果您的代码编译通过,则您通过了测试。
这是另一个解决方案(更多细节在下面)
void yourCode()
{
Foo x = Bar(); // Usually works; see below for fine print on "usually"
x.blah();
// ...
}
注意:上述“通常”一词的意思是:上述情况仅在 Foo::Foo(const Bar&)
构造函数是 explicit
时,或者当 Foo
的拷贝构造函数不可访问时(通常当它是 private
或 protected
,并且您的代码不是 friend
时)才失败。如果您不确定这意味着什么,花 60 秒编译一下。您保证会在编译时发现它是否有效,所以如果它干净地编译通过,它将在运行时工作。
然而,最好的解决方案,其创建至少部分地是由于本 FAQ 的存在而受到启发,是使用统一初始化,它将 Bar()
调用周围的 ()
替换为 {}
。
void yourCode()
{
Foo x{Bar()};
x.blah(); // Ahhhh, this now works: no more error messages
// ...
}
这就是解决方案的结束;剩下的都是关于为什么需要这个(这是可选的;如果你不关心你的职业生涯到足以实际理解正在发生的事情,你可以跳过这一节;哈哈):当编译器看到 Foo x(Bar())
时,它认为 Bar()
部分正在声明一个返回 Bar
对象的非成员函数,所以它认为你正在声明一个名为 x
的函数的存在,该函数返回一个 Foo
,并接受一个类型为“不带参数并返回 Bar
的非成员函数”的单个参数。
现在来说说悲哀的部分。事实上,这很可悲。某个无脑的人会跳过上一段,然后他们会强加一个怪异、不正确、不相关且彻头彻尾的愚蠢编码标准,比如“永远不要使用默认构造函数创建临时对象”或“所有初始化都必须使用 =
”或类似的其他无意义的东西。如果你是那样的人,请在造成更多损害之前解雇自己。那些不理解问题的人不应该告诉别人如何解决它。哼。
(那大多是开玩笑。但其中也有一点道理。真正的问题是人们倾向于崇拜一致性,并且倾向于将晦涩的推断到常见的。那是不明智的。)
explicit
关键字的目的是什么?
explicit
关键字是构造函数和转换运算符的可选修饰符,用于告诉编译器某个构造函数或转换运算符不能用于将表达式隐式转换为其类类型。
例如,如果没有 explicit
关键字,以下代码是有效的
class Foo {
public:
Foo(int x);
operator int();
};
class Bar {
public:
Bar(double x);
operator double();
};
void yourCode()
{
Foo a = 42; // Okay: calls Foo::Foo(int) passing 42 as an argument
Foo b(42); // Okay: calls Foo::Foo(int) passing 42 as an argument
Foo c = Foo(42); // Okay: calls Foo::Foo(int) passing 42 as an argument
Foo d = (Foo)42; // Okay: calls Foo::Foo(int) passing 42 as an argument
int e = d; // Okay: calls Foo::operator int()
Bar x = 3.14; // Okay: calls Bar::Bar(double) passing 3.14 as an argument
Bar y(3.14); // Okay: calls Bar::Bar(double) passing 3.14 as an argument
Bar z = Bar(3.14); // Okay: calls Bar::Bar(double) passing 3.14 as an argument
Bar w = (Bar)3.14; // Okay: calls Bar::Bar(double) passing 3.14 as an argument
double v = w; // Okay: calls Bar::operator double()
}
但有时您想阻止这种隐式提升或隐式类型转换。例如,如果 Foo
实际上是一个类似数组的容器,而 42 是初始大小,您可能希望用户可以说 Foo x(42);
或者 Foo x = Foo(42);
,但不能仅仅说 Foo x = 42;
。如果是这种情况,您应该使用 explicit
关键字
class Foo {
public:
explicit Foo(int x);
explicit operator int();
};
class Bar {
public:
explicit Bar(double x);
explicit operator double();
};
void yourCode()
{
Foo a = 42; // Compile-time error: can't convert 42 to an object of type Foo
Foo b(42); // Okay: calls Foo::Foo(int) passing 42 as an argument
Foo c = Foo(42); // Okay: calls Foo::Foo(int) passing 42 as an argument
Foo d = (Foo)42; // Okay: calls Foo::Foo(int) passing 42 as an argument
int e = d; // Compile-time error: can't convert d to an integer
int f = int(d); // Okay: calls Foo::operator int()
Bar x = 3.14; // Compile-time error: can't convert 3.14 to an object of type Bar
Bar y(3.14); // Okay: calls Bar::Bar(double) passing 3.14 as an argument
Bar z = Bar(3.14); // Okay: calls Bar::Bar(double) passing 3.14 as an argument
Bar w = (Bar)3.14; // Okay: calls Bar::Bar(double) passing 3.14 as an argument
double v = w; // Compile-time error: can't convert w to a double
double u = double(w); // Okay: calls Bar::operator double()
}
您可以在同一个类中混合使用 explicit
和非 explicit
构造函数和转换运算符。例如,这个类有一个接受 bool
的 explicit
构造函数,但有一个接受 double
的非 explicit
构造函数,并且可以隐式转换为 double,但只能显式转换为 bool
#include <iostream>
class Foo {
public:
Foo(double x) { std::cout << "Foo(double)\n"; }
explicit Foo(bool x) { std::cout << "Foo(bool)\n"; }
operator double() { std::cout << "operator double()\n"; }
explicit operator bool() { std::cout << "operator bool()\n"; }
};
void yourCode()
{
Foo a = true; // Okay: implicitly promotes true to (double)1.0, then calls Foo::Foo(double)
Foo b = Foo(true); // Okay: explicitly calls Foo::Foo(bool)
double c = b; // Okay: implicitly calls Foo::operator double()
bool d = b; // Okay: calls Foo::operator double() and implicitly converts to bool
if(b) {} // Okay, explicitly calls Foo::operator bool()
}
上述代码将打印以下内容
Foo(double)
Foo(bool)
operator double()
operator double()
operator bool()
变量 a
是使用 Foo(double)
构造函数初始化的,因为 Foo(bool)
不能用于隐式转换,但是 true
可以被解释为 (double)true
,即 1.0
,并使用 Foo::Foo(double)
隐式转换为 Foo
。这可能是您想要的,也可能不是,但这就是会发生的情况。
为什么我的构造函数运行不正常?
这是一个以多种形式出现的问题。例如
- 为什么编译器在我不想复制对象时复制它们?
- 如何关闭复制?
- 如何阻止隐式转换?
- 我的 int 怎么变成了一个复数?
默认情况下,一个类会获得一个复制构造函数和一个复制赋值运算符来复制所有元素,以及一个移动构造函数和一个移动赋值运算符来移动所有元素。例如
struct Point {
int x,y;
Point(int xx = 0, int yy = 0) :x(xx), y(yy) { }
};
Point p1(1,2);
Point p2 = p1;
这里我们得到 p2.x==p1.x
和 p2.y==p1.y
。这通常正是您想要的(并且对于C兼容性至关重要),但请考虑
class Handle {
private:
string name;
X* p;
public:
Handle(string n)
:name(n), p(0) { /* acquire X called "name" and let p point to it */ }
~Handle() { delete p; /* release X called "name" */ }
// ...
};
void f(const string& hh)
{
Handle h1(hh);
Handle h2 = h1; // leads to disaster!
// ...
}
这里,默认复制使我们得到 h2.name==h1.name
和 h2.p==h1.p
。这会导致灾难:当我们退出 f()
时,h1
和 h2
的析构函数被调用,并且 h1.p
和 h2.p
指向的对象被删除两次。
我们如何避免这种情况?最简单的解决方案是将复制操作标记为已删除
class Handle {
private:
string name;
X* p;
Handle(const Handle&) = delete; // prevent copying
Handle& operator=(const Handle&) = delete;
public:
Handle(string n)
:name(n), p(0) { /* acquire the X called "name" and let p point to it */ }
~Handle() { delete p; /* release X called "name" */ }
// ...
};
void f(const string& hh)
{
Handle h1(hh);
Handle h2 = h1; // error (reported by compiler)
// ...
}
如果我们需要复制或移动,我们当然可以定义适当的初始化器和赋值来提供所需的语义。
现在回到 Point
。对于 Point
,默认的复制语义很好,问题在于构造函数
struct Point {
int x,y;
Point(int xx = 0, int yy = 0) :x(xx), y(yy) { }
};
void f(Point);
void g()
{
Point orig; // create orig with the default value (0,0)
Point p1(2); // create p1 with the default y-coordinate 0
f(2); // calls Point(2,0);
}
人们提供默认参数以方便 orig
和 p1
的使用。然后,有些人在调用 f()
时对 2
转换为 Point(2,0)
感到惊讶。这个构造函数定义了一个转换。默认情况下,这是一个隐式转换。要使这种转换成为显式的,请将构造函数声明为 explicit
struct Point {
int x,y;
explicit Point(int xx = 0, int yy = 0) :x(xx), y(yy) { }
};
void f(Point);
void g()
{
Point orig; // create orig with the default value (0,0)
Point p1(2); // create p1 with the default y-coordinate 0
// that's an explicit call of the constructor
f(2); // error (attempted implicit conversion)
Point p2 = 2; // error (attempted implicit conversion)
Point p3 = Point(2); // ok (explicit conversion)
}