再谈初始化

以前整理过一篇笔记 《初始化》,前两天又翻出来浏览了一下,写得很烂。限于当时的水平,并未像现在这样透彻的理解“默认构造函数”、“拷贝构造函数”、“拷贝赋值运算符”等概念,读书《C++ Primer》时看到好多种“初始化”,整理出那一篇可能只是想罗列名字、定义,让自己知道哪个是哪个罢了,却并不深刻理解中间的关联与区别。我们从头再来。

讲 C++ 的初始化,我们有好几种切入的方式:

  • 可以从日常使用的形式,从我们惯用的代码讲起,引出相关的术语;
  • 可以直接讲构造函数,构造函数是本质,初始化是呈现,理解了构造函数,也就理解了初始化;
  • 可以罗列各种初始化的表现形式,然后归纳,总结出结论。

前一篇笔记带有第三种切入方式的味道,罗列了很多“XX初始化”,却没有归纳总结,意义不大。这一篇笔记我想以前两种方式来写。

内置类型

内置类型和用户自定义类型有些区别,比如内置类型很可能(原谅我,我并不百分百确定)没有构造函数,而对于类类型即便我们不定义构造函数,编译器一般也会默认给我们生成。总的来说,内置类型使用上比类类型简单,但同时也是类类型的基础。对于内置类型,比如 int,哪怕是新手也是很熟悉的。我们都会对 int 变量进行什么操作呢?我们会怎么写代码呢?

1
2
3
int var1;
// some codes
var1 = 11

这应该是最普遍的代码书写形式了。我们定义了一个 int 变量,没有明确地给出其初始值,在稍后使用时给它赋值。像 T t; 这种形式我们称为“默认初始化”,或者“缺省初始化”。都是从“default initialization” 翻译过来的。

This is the initialization performed when a variable is constructed with no initializer. 引用来源-default initialization

有些人烦恼上述代码执行效果的“不确定性”(后文详述),更喜欢在定义变量时明确地给出一个初始值。

1
2
3
int var2 = 12;
// other codes
var2 = 13;

T t = x; 这种形式我们称为“拷贝初始化”,即术语“copy initialization”。

Initializes an object from another object 引用来源-copy initialization

我们使用内置类型书写的形式一般就是上述两种,有时候我们写在函数里面,有时候写在函数外面(全局的)。至此,我们了解了两个术语:default initialization 和 copy initialization。

tip1:有时候潜意识会让我们写出这样的代码 int var();,类类型的话更形象 MyClass obj();,我们本意是想默认初始化一个变量 var 或 obj,但编译器的规则更倾向于将这行代码解析成函数声明。

tip2:T t = x;的形式有时会让菜鸟误以为是赋值操作。拷贝初始化还有一种不具迷惑性的书写形式:T t(x);

tip3:内置类型的初始化和赋值成本一样。

默认值是什么

如果定义变量时没有指定初值,则变量被默认初始化,此时变量被赋予了“默认值”。默认值到底是什么由变量类型决定,如果是内置类型,定义变量的位置也会对此有影响。内置类型的变量未被显式初始化时:

  • 定义于任何函数体之外的变量被初始化为 0
  • 定义在函数体内部的内置类型变量将不被初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

int global_var1;
int global_var2 = 100;
int main(void)
{
int inner_var1;
int inner_var2 = 12;
cout << "global_var1: " << global_var1 << endl;
cout << "global_var2: " << global_var2 << endl;
cout << "inner_var1: " << inner_var1 << endl;
cout << "inner_var2: " << inner_var2 << endl;
return 0;

}

执行结果如下:

1
2
3
4
5
vimer@debian8light:~/see-the-world/code/initializer_learn$ ./a.out 
global_var1: 0
global_var2: 100
inner_var1: 4196240
inner_var2: 12

这个现象源于,最初 C 语言诞生时机器硬件性能有限,对于节省资源做到了令人发指的地步。定义变量默认只分配内存块,但不对这块内存进行擦除(复位)操作,这种复位操作一般来说都是多余的。假设根据默认初始值 0 进行擦除操作,除非作者后续就是使用初值 0,否则赋值还会再次擦除内存块赋予别的值,初始化时的擦除是没有意义的。

C++ 最初由 C 扩展而来,为了向其兼容保留了这些特征。在性能不再是瓶颈的今天,反而由于程序员粗心使用了无初值且未赋值的变量而产生很多错误。

在堆上创建变量

除了在栈上定义变量,我们还会在堆上动态分配内存。这种情况下怎么初始化呢?

default initialization

  • int i; 可以对应 int *p = new int;
  • 还存在一种形式:int *p = new int();,虽然我们不能 int i(); 来定义变量。这个其实是值初始化,后文会提到。

copy initialization

  • int i = 12; 无法严格对应,但好在拷贝初始化还有更正统的写法
  • int i(12); 可以对应 int *p = new int(12);

构造函数

要讲构造函数,我们就得进入类类型(class type),我们无法使用内置类型来讲解构造函数。

从类类型的角度来说,所有的初始化都是匹配参数,找到与之对应的构造函数并执行。参考 std::string 类的构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// default (1)	
string();
// copy (2)
string (const string& str);
// substring (3)
string (const string& str, size_t pos, size_t len = npos);
// from c-string (4)
string (const char* s);
// from buffer (5)
string (const char* s, size_t n);
// fill (6)
string (size_t n, char c);
// range (7)
template <class InputIterator>
string (InputIterator first, InputIterator last);
// initializer list (8)
string (initializer_list<char> il);
// move (9)
string (string&& str) noexcept;

与之对应的构造实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// string constructor
#include <iostream>
#include <string>

int main ()
{
std::string s0 ("Initial string");

// constructors used in the same order as described above:
std::string s1;
std::string s2 (s0);
std::string s3 (s0, 8, 3);
std::string s4 ("A character sequence");
std::string s5 ("Another character sequence", 12);
std::string s6a (10, 'x');
std::string s6b (10, 42); // 42 is the ASCII code for '*'
std::string s7 (s0.begin(), s0.begin()+7);

std::cout << "s1: " << s1 << "\ns2: " << s2 << "\ns3: " << s3;
std::cout << "\ns4: " << s4 << "\ns5: " << s5 << "\ns6a: " << s6a;
std::cout << "\ns6b: " << s6b << "\ns7: " << s7 << '\n';
return 0;
}

我们看到这么多构造函数每一个都有其特点。但其中 1、2、9是最具通用性的,是所有类的共性。因为其特殊性,所以也就值得拥有名字。

  • 1 默认构造函数,在默认初始化时执行;
  • 2 拷贝构造函数,在拷贝初始化时执行;
  • 9 移动拷贝构造函数,
  • 其余的我们可以统称为“直接初始化”

Initializes an object from explicit set of constructor arguments. 引用来源-direct initialization

接下来“斤斤计较”一些,前文提到所有的初始化都是调用相应的构造函数而已(参数匹配),函数调用的形式就是使用括号 (),但除此之外,C++ 也提供了两种其他的形式调用构造函数:

  • 使用等号 =
  • 使用花括号 {}

恶心的是是这三种书写形式都不具有“全局适用性”——我自己造的词,三种书写形式都有广泛适用性,但又各有不足,不能调用 XXX 形式的构造函数——即便是使用 () 来调用构造函数也有其不能用的情况。接下里我们详细叙述:

使用括号调用

按道理来说,既然所有的函数调用都是使用 () 来完成,那么调用构造函数应该也没啥问题才对,囧。先说结论—— () 调用不能适用于默认构造函数。问题出现 C++ 的语法解析上,其优先解释成函数声明。如果我们这么写 MyClass obj(); 表示调用 MyClass 的默认构造函数来初始化 obj 对象,那么我们怎么声明“返回 MyClass 类型的空参数函数”呢?或许可以这么写 MyClass obj(void);,我不确定是否是一种解决方案,即便可行,那么以前写的代码怎么办呢?解决方式就是我们这么多年一直写的 MyClass obj;,这样就调用默认构造函数了,囧。

有一种略显丑陋的写法,似乎是满足了使用圆括号调用默认构造函数 MyClass obj = MyClass();

ps:它是“使用默认构造函数初始得到一临时变量,然后又使用这个临时变量调用拷贝构造函数初始化的 obj 对象”吗?no,使用 g++ 验证,只执行了默认构造函数,未执行拷贝构造函数,猜测是直接使用默认构造函数构建了 obj 对象。

使用等号调用

前文说 = 用来调用拷贝构造函数,严格来说并不准确。在 C++11 标准之前,= 可以用来调用所有【只有单独一个参数的】构造函数:

  1. 一个参数,其余参数都有默认值的可以吗? ok
  2. 怎么定义“copy initialization”这个概念,狭义的 or 广义的? 我个人更倾向于限制性更强的定义,从相同类型的变量拷贝
  3. 隐式类型转换);

在 C++11 之后,= 更是可以和 {} 组合用来调用所有的构造函数,但此种情形其实只是 {} 调用的变形。所以,如果使用 =(且不使用 {})就不能调用多参数(两个及其以上? 两个及其以上无默认值)的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Student
{
public:
// other functions
Student(const string name, bool gender = true, unsigned age = 20);
private:
// some parameters
string m_name;
bool m_gender;
unsigned m_age;
};

int main()
{
Student niel = "niel"; // it is wrong
Student long = string("long"); // it is OK.
}

《C++ Primer》第 263 页“隐式的类类型转化”中明确提到:只允许一步类类型转化。

使用花括号调用

在 C++11 之前,容器类无法使用一个序列初始化。直到在 C++11 中新添了 initializer_list 标准库类型,其形式 {a,b,c};,以此 Container x = {a,b,c};。在此基础上,扩展 {} 的使用场景(依据?),不仅仅可以用在所有使用 () 的场景,还可以用来默认初始化 MyClass obj{};,用来调用构造函数创建临时变量:

1
2
3
string str{};
// str = ("abc", 2);
str = {"abc", 2}; // it is OK.

这种使用 {} 来初始化对象的形式被称为“列表初始化”,可以表现默认初始化、拷贝初始化、STL 容器的初始化等等。

Initializes an object from braced-init-list 引用来源-list initialization

美中不足

我原本以为 {} 是“全局适用”的,是可以用来调用所有构造函数的语法特性,囧,理论上应该是。问题是它有时候不会调用我们预期的构造函数,因为 {} 优先解析成 initializer_list。举例来说:

1
2
3
4
// 42 is the ASCII code for '*'
string str0 (42, 'x'); // 42 个 ‘x’ 的字符串
string str{42, 'x'}; // WOW,str 的值是 “*x”,而非我们更直观理解的“42 个 x”
str = {42, 'a'}; // 将 str 的值改为了 “*a”

语法解析时更倾向于 {'*', 'x'} 的序列,而非等价于 (42, 'x'),使用 {} 应该怎么调用 string(size_t n, char c); 构造函数呢? no idea。ps string str('*', 'x') 是 42 个 ‘x’ 的字符串。

A std::initializer_list object is automatically constructed when:

  • a braced-init-list is used in list-initialization, including function-call list initialization and assignment expressions
  • a braced-init-list is bound to auto, including in a ranged for loop

推荐 《Effective Modern C++》 Item 7: Distinguish between () and {} when creating objects.

备忘:实际测试代码,见 Debian8Light 虚拟机 /home/vimer/see-the-world/code/initializer_learn/MyString 目录。后续修改时将 Demo 嵌到合适的位置。

new 调用构造函数

显然 new 操作不能通过 = 来匹配构造函数,它可以使用 (){},所受的限制和之前一致,唯一的不同之处是此时可以使用 new MyClass(); 来调用默认构造函数。

其它

  1. 拷贝构造函数和拷贝赋值运算符

    = 可以用来初始化,可以用来赋值。不同情况下调用的函数不同。强调一遍又一遍,有 = 不一定是赋值操作。

  2. std::initializer_listmember initializer list

    前者是标准库类型,后者是在构造函数中初始化成员变量的。

  3. 关于 std::initializer_list 类型,推荐一篇帖子:C++11中新特性之:initializer_list详解

  4. Is it better to use C void arguments “void foo(void)” or not “void foo()”?

值初始化

值初始化到底是什么?对此一直都有困惑,和 new Type; new Type(); 两者之间的区别有关系吗?和容器类初始化每个元素的“初始化器”有关系吗?

This is the initialization performed when a variable is constructed with an empty initializer. 引用来源-value initialization

我们来和“默认初始化”(或者叫做“缺省初始化”)的定义对比一下:

This is the initialization performed when a variable is constructed with no initializer.

我们前面提到,以下形式都会调用默认构造函数:

1
2
3
4
5
6
7
MyClass obj;                   // (1)
MyClass obj2 = MyClass();
MyClass obj3{};

MyClass *p = new MyClass; // (4)
MyClass *p = new MyClass();
MyClass *p = new MyClass{};

这要学究式地较真起来,1 和 4 是“no initializer”,所以是缺省初始化;其它 4 个是“empty initializer”,所以是值初始化。

这两者在初始化类类型的时候没有区别,都是调用默认构造函数。差别发生在初始化内置类型的时候,默认初始化是“向前看齐”(C 语言)妥协的结果,前面已经介绍过“默认值到底是什么”;值初始化是新时代的新态度,总是会有初值的。在 《POD 类型》 中有一个表格,执行 new 的结果表示了同样的结果。其参考来源于维基百科。

关于值初始化的更详细结果请移步 The effects of value initialization 一节:

  1. 大括号的“意外”

    In all cases, if the empty pair of braces {} is used and T is an aggregate type, aggregate-initialization is performed instead of value-initialization.

    If T is a class type that has no default constructor but has a constructor taking std::initializer_list, list-initialization is performed.

  2. 数组类型

    if T is an array type, each element of the array is value-initialized;

何为 aggregate-initialization 呢?

Initializes an aggregate from braced-init-list 引用来源-aggregate initialization

Aggregate initialization is a form of list-initialization, which initializes aggregates

总结

C++ 中的初始化,经历了由少变多,由简入繁,让人不胜其烦;但从 C++11 开始,踏上了由繁入简的尝试。

有哪些 xxx-initialization 呢?除了上面提到的,其实还有一些概念……

看完以上整理的内容,可以再看几篇问答: