浅谈C++初始化

失望,迷茫。

C++中广义的对象,既可以是内置类型,也可以使自定义类型。对象的初始化并没有看起来那么简单,这里面的存在不少容易犯错的地方,下文会一一讲解。

定义与声明

如果想声明一个对象而非定义它,则添加关键字extern,任何包含了显式初始化的声明即成为定义。

1
2
3
extern int i; /* declaration */
int j ; /* definition */
extern double pi = 3.14; /* definition */

声明和定义主要考虑C++语言的分离式编译问题,如果要多个文件中使用同一个对象,就必须将声明和定义分离。对象的定义必须出现且只能出现在一个文件中,其他用到该对象的文件必须对其进行声明,却绝不能重复定义。

内置类型的声明和自定义类型的声明有一些差异。自定义类型支持前向声明,此时其为imcomplete类型,主要是作为函数的参数或者返回值类型,并且只能是该类的引用或者指针。

由于只有当类体完成后类才算定义完成,所以一个自定义类型不能有自己类型的数据成员。但是只要出现了类名,一个类就被声明了,所以一个类可以由自己类型的指针或者引用。

内置类型初始化

对于内置类型变量的初始化,我们需要掌握两点:

  • 默认初始化
  • 列表初始化

默认初始化时,对象的初始值由其定义的位置决定。定义于任何函数之外的变量被初始化为0。定义于函数体内部的内置类型将不被初始化(局部静态变量除外),一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式方位此类值将引发错误。

为内置类型对象进行手工初始化,因为C++不保证初始化它们。

使用列表初始化来初始化内置类型对象,如果初始值存在丢失信息的风险,则编译器将报错。

1
2
3
4
5
6
7
8
int data1 = 0;
int data2 = {0};
int data3(0);
int data4{0};
long double ld = 3.1415926538;
int a{ld}, b = {ld}; /* error */
int c(ld), d = ld;

自定义类型初始化

这里的自定义类型也就是类类型。相对于内置类型,自定义类型对象初始化会复杂很多,这是因为自定义类型的可能位于派生体系中,同时数据成员可能为const或者引用。

初始化列表

大家应该都知道,构造函数体内执行的是赋值操作,并非初始化操作,初始化发生在构造函数体执行之前——初始化列表中。

很多时候使用初始化列表是为了执行速度(减少一次拷贝操作),下面两种情况除外:

  • 数据成员被const修饰
  • 数据成员是对象的引用

我们仅能在初始化列表中初始化const成员或者reference成员。

构造函数最好使用初始化列表,而不要在构造函数体内使用赋值操作。

初始化顺序

自定义对象数据成员的初始化顺序,依照其在类定义中出现的顺序,基类更早于派生类被初始化。从全局看,变量的初始化顺序如下:

  • 基类的静态成员
  • 派生类的静态成员
  • 基类的成员变量
  • 派生类的成员变量

派生类的成员变量的初始化顺序是按照其在类中声明的顺序,而不是按照其在初始化列表中的顺序。同时,我们需要注意,静态成员在类定义时被初始化,并不由构造函数初始化。

默认初始化

自定义类型的默认初始化采用的是其默认构造函数,如果我们在定义类时没有显式定义默认构造函数,编译器会为我们合成一个默认的构造函数。

对于合成的默认构造函数,其按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员;
  • 如果不存在类内的初始值,则默认初始化该成员。

这就存在三个问题:

  • 只有当类没有声明任何构造函数时,编译器才会自动生成默认构造函数;但如果声明了其他构造函数,则必须定义自己的默认构造函数,因为编译器不在合成默认的构造函数。
  • 类内内置类型被默认初始化,其值是未定义的。如果类包含有内置类型或者复合类型的成员,则只有当这些成员全部被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
  • 类中包含一个其他类类型的成员,且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。

我们建议:一个类必须要定义自己的默认构造函数,而不能依赖于合成的默认构造函数。

如果合成的默认构造函数就是我们需要的,我们也可以采用=default来要求编译器生成构造函数,完全等同于合成默认构造函数。

常对象的初始化

常对象不能改变其内部的数据成员,从表面上看,这样我们就无法对齐进行初始化了。C++规定:

当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。

所以,当我们定义一个常对象,我们可以再起构造函数内部执行一系列赋值操作。

1
2
3
4
5
6
7
8
9
class test
{
public:
test(){x = 1};
private:
int x;
};
const test t;

静态成员初始化

静态成员不是由类的构造函数初始化的。不能在类的内部初始化静态数据成员,必须在类的外部定义和初始化静态成员,静态数据成员只能定义一次。当在类外定义静态成员,不能重复static,该关键字只出现在类内部的声明语句。

静态数据成员的定义(初始化)不应该被放在头文件中。

不要试图在头文件中定义(初始化)静态数据成员。在大多数的情况下,这样做会引起重复定义这样的错误。即使加上#ifndef #define #endif或者#pragma once也不行。

要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

常静态成员是可以在类内声明的时候进行初始化。可以为静态成员提供const整数类型的类内初始值,但要求静态成员必须是字面值常量类型的constexpr。初始值必须是常量表达式。
即使一个常量静态数据成员在类内部被初始化,通常情况下也应该在类的外部定义一下该成员。但不能在指定一个初始值。

1
2
3
4
5
6
class time {
static constexpr int period=30 ; /* period is const expression, here is statement not definition */
int elems [period]; /* error, if we don't define period */
};
constexpr int time::period ; /* cannot set initilization value here */

延迟初始化

延迟初始化是一个很有用的概念。一个对象的延迟初始化(也称延迟实例化)意味着该对象的创建将会延迟至第一次使用该对象时。延迟初始化主要用于提高性能,避免浪费计算,并减少程序内存要求。

1
2
3
4
5
6
7
8
class LazyInstance {
}
LazyInstance& getInstance()
{
static LazyInstance instance;
return instance;
}

上述代码中,instance实例对象直到getInstance()函数被第一次调用时,才会进行初始化,对比下列代码:

1
2
3
4
class LazyInstance {
}
static LazyInstance instance;

instance实例对象是静态成员,会在程序一开始调用默认构造函数初始化。

延迟初始化同时也解决了跨编译单元中非局部静态成员的初始化次序不定的问题——函数内局部静态对象会在第一次调用时被初始化。我们通过下列例子来说明该问题(摘自Effective C++):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* FileSystem.cpp */
class FileSystem {
public:
std::size_t numDisks() const;
};
extern FileSystem tfs;
/* Directory.cpp */
class Directory {
public:
Directory(params);
};
Directory::Directory(params)
{
std::size_t disks = tfs.numDisks();
}
Director dir(tfs);

由于FileSystem.cpp和Directory.cpp是两个编译单元,其初始化次序在C++标准中并没有被定义,我们无法确定tsf是否一定在dir初始化之前初始化。解决办法就是采用延迟初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class FileSystem {};
FileSystem& tfs()
{
static FileSystem fs;
return fs;
}
class Directory {};
Directory::Directory(params)
{
std::size_t disks = tfs().numDisks();
}
Directory& dir()
{
static Directory td;
return td;
}

显式初始化

自定义类型的单参数构造函数支持隐式初始化(隐式转换),但需要知道,隐式初始化只支持隐式一次,而不能像下面的例子一样:

1
2
3
4
5
6
7
8
9
10
11
12
class Sales_data
{
public:
Sales_data(std ::string &str );
Sales_data& combine (const Sales_data &);
};
std::string null_book = "9-999-99999-9" ;
Sales_data item ;
item.combine (null_book ); /* ok */
item.combine ("9-999-99999-9" ); /* error */
item.combine (std ::string ("9-999-99999-9" )); /* ok */

“9-999-99999-9”可以隐式初始化为std::string,null_book能够隐式初始化为Sales_data,但是不能直接从”9-999-99999-9”隐式初始化为Sales_data。

使用explicit关键字可以显式抑制隐式初始化,这有时候就是我们需要的:

explicit之所以被导入到C++,是为了提供程序员一种方法,使他们能够制止“单一参数的constructor”被当作一个conversion运算符。

标准库中用explicit修饰的构造函数如下:

  • (1) 接受一个容量参数的vector构造函数是explicit的。
  • (2) 接受一个指针参数的shared_ptr(auto_ptr)构造函数是explicit的。
  • (3) 接受一个string参数的sstream构造函数是explicit的
  • (4) 接受一个string或C风格字符串参数的fstream构造函数是explicit的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::auto_ptr <int > ap1 (new int ); /* ok */
std::auto_ptr <int > ap2 = new int (); /* error */
std::shared_ptr <int > sp1 (new int ); /* ok */
std::shared_ptr <int > sp2 = new int (); /* error */
std::vector <int > v1 (3); /* ok */
std::vector <int > v1 = 3; /* error */
std::string stringvalues = "125 320 512 750 333" ;
std::istringstream iss1 (stringvalues ); /* ok */
std::istringstream iss2 = stringvalues ; /* error */
std::string fileName = "test.txt" ;
std::fstream fs1 (fileName ); /* ok */
std::fstream fs2 = fileName ; /* ok */

只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。


本文作者:ZeroJiu
本文链接: http://www.freehacker.cn/foundation/initialization/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!