读取未初始化的变量会导致未知的出现
“关于将对象初始化这事, C++似乎反复无常”
参考这句C++: int x;
, 在某些语境下, x 是会被初始化为 0 的, 但是某些语境下并不能保证:
class test
{
public:
test()
{
int x, y;
}
};
int main()
{
test t;
return 0;
}
有的时候, t 会将内部的 x 和 y 进行初始化, 有的时候又不会.
读取没有初始化的变量会导致一些奇怪的行为, 这些行为是未被定义的. 比如指针没有初始化, 他或许不会初始化为 nullptr
, 当你去访问这个地址时, 可能就导致了未知的错误和让人非常不愉快的调试出现. 现在我们有了一个规则: 读取未被初始化的变量, 结果是未知的.
这是一个无法决定的事情, 相当于程序员把自己编写的程序的命运交给编译器. 其实有一个非常好的解决方法, 那就是不管怎么样都对变量进行初始化(VS2019已经可以对你没有初始化变量提出警告了).
对于内置类型之外的变量, 初始化的任务就落在了构造函数上.
尽量或者必须使用初始化列表
在上节我们提到, 对于内置类型之外的变量, 初始化的任务落在了构造函数上. 我们对于构造函数的使用其实很熟悉了, 比如对于一个简单的电话簿类:
class PhoneBook
{
public:
PhoneBook(const std::string& name, const std::string& address,
const unsigned short& age, const std::list<PhoneNum> phoneNums)
{
name_ = name; // 他们都是赋值 并不是初始化
address_ = address;
age_ = age;
phoneNums_ = phoneNums;
}
private:
std::string name_;
std::string address_;
unsigned short age_;
std::list<PhoneNum> phoneNums_;
};
我们很快就可以写出这个函数, 并认为构造函数对成员进行了初始化. 但是, 其实情况不是我们想的这样的. 在我们调用构造函数的时候, 其实并不是去初始化, 真正的成员初始化发生在进入构造函数本体之前. name_
, address_
, phoneNums_
都不是真正地在进行初始化, 而是在进行赋值. 他们的初始化发生时间更早, 发生于这些成员的 default 构造函数之时(比进入 PhoneBook 构造函数本体还要早), 不过对于age_
是个例外, 由于他是一个内置类型, 所以按照之前讲的, 他或许在赋值前可以获得初值, 也或许不可以, 不过我们认为他没有获得初值.
对于构造函数的一个比较好的写法就是使用初始化列表, 来代替函数内的赋值.
class PhoneBook
{
public:
PhoneBook(const std::string& name, const std::string& address,
const unsigned short& age, const std::list<PhoneNum> phoneNums)
:name_(name), address_(address), age_(age), phoneNums_(phoneNums)
{
// 这里构造函数就不需要做任何事情了
}
private:
std::string name_;
std::string address_;
unsigned short age_;
std::list<PhoneNum> phoneNums_;
};
其实也可以这样来理解列表初始化, 我们的第一个写法可以认为是下面两句的组合:
int x;
x = 0;
这就是构造一个, 然后再把它赋值. 而列表初始化则可以认为是下面一句的等效:
int x = 0;
对于大多数类型而言, 比起先调用 default 构造函数然后再调用 拷贝函数, 只调用一次拷贝构造函数显然是更高效的, 有时候甚至高效地更多. 对于部分内置类型, 如刚才的 age_
, '使用列表和不使用列表效率是相同的'
, 但是为了统一性, 我们统一使用列表初始化. 甚至, 当你想要一个无参的拷贝构造时, 你可以指定 nothing (无物) 作为初始化值, 使用列表初始化, 如:
class PhoneBook
{
public:
PhoneBook()
:name_(), address_(), age_(), phoneNums_()
{}
private:
std::string name_;
std::string address_;
unsigned short age_;
std::list<PhoneNum> phoneNums_;
};
由于编译器会为用户自定义类型的成员变量自动调用默认构造函数(如果他们在初始化列表中没有指定初始值的话). 请确定一个规则: 总是在初始化列表中列出所有的成员, 否则之后如果在构造时遗忘了它, 潘多拉的盒子就会被打开, 而这个盒子将导致非常不明确的行为.
有些情况下, 即使面对的成员是内置类型, 也一定要使用初始化列表, 因为如果是 const 或者 references, 它就必须要初始值, 不可以被赋值. 为了避免出现有些时候要初始值, 有些时候不需要初始值的情况, 最好的方法就是每个都使用初始化列表.
C++ 通常有着比较严格的"初始化顺序", 这是因为初始化列表不管你怎么写, 初始化顺序都只和声明顺序有关, 为了保证顺序的严格性, 比如第二个成员为数组, 而他的大小为第一个成员, 这个时候就要严格起来了, 最好就是参照成员顺序.
不同编译单元内定义之 non-local static 对象的初始化次序
static 对象, 其寿命从构建出来一直到程序结束, 因此 stack 对象和 heap-based 对象都不属于 static 对象. 这种对象包括了 global 对象, 定义于 namespace 作用域内的对象, 在 classes 内, 在函数内, 以及在 file 作用域(代码块作用域)内被声明为 static 的对象. 函数内的 static 对象称为 local static 对象, 因为它对于函数来说是 local 的, 其他的static 对象称为 non-local static 对象.
所谓编译单元, 是指产出单一目标文件的那些源码, 基本上他是单一源码文件加上其包含的同文件(#include).
我们现在关心的是, 如果一个编译单元 使用 了另外一个单元的 non-local static 对象, 而该对象是有可能没有初始化的, 因为 C++ 对于不同单元的 non-local static 对象的初始化顺序是未知的, 它没有定义该如何去初始化. 下面有一个实例:
class FileSystem
{
public:
...
std::size_t numDisks() const;
...
};
extern FileSystem tfs; // 预留给用户使用的单例文件系统
如果在 FileSystem 初始化前使用, 相信会得到惨重的灾情.
现在假设, 客户建立了一个类用于处理目录, 显然要控制文件, 会用到 FileSystem 对象.
class Directory
{
public:
Directory()
{
...
std::size_t disks = tfs.numDisks();
...
}
};
现在问题来了, 除非 tfs 可以在使用前就初始化, 不然就会出现大错误, 那如何保证在使用前就一定被初始化了呢? 在该写法下, 你无法保证, 因为 C++ 本身就无法给你保证, 之前提到, C++ 对于不同的编译单元的 non-local static 对象是不能确定初始化顺序的, 根本无解. 在其最常见的形式中, 也就是多个编译单元的 non-local static 对象经由 模板隐式具现化形成, 不但不能决定正确的初始化顺序, 甚至往往不值得去找寻正确的初始化顺序.
不过, 我们可以用一个小设计使得该问题得到解决, 那就是对 non-local static 对象的直接使用改为对函数的调用. 这些函数返回内部的一个 static 成员的引用, 这就是单例模式的常见使用. 一个 non-local static 对象就变成了 local static 成员对象.
因为 C++ 保证, 函数内的 local static 对象会在该函数调用期间, 首次遇到该表达式时进行初始化, 所以你返回该引用是可以保证以及被初始化过的. 它是真正的调用了构造函数进行的初始化. 更美妙的是, 如果你从来没有调用该函数, 那么根本就不会产生构造和析构成本, 因为它会在调用该函数的第一次碰上该表达式初始化, 只要没有, 就不初始化, 也就不需要析构. 这里给出一个小样例:
当我们使用这份代码在不同的单元时, vs2022编译直接报错, 因为没有初始化, 导致它无法被找到, 报链接错误.
// file.hpp
class FileSystem
{
public:
FileSystem()
{
std::cout << "FileSystem()" << std::endl;
}
~FileSystem()
{
std::cout << "~FileSystem()" << std::endl;
}
void DoSomeThing()
{
std::cout << "do something" << std::endl;
}
};
extern FileSystem fts1;
// test.cpp
int main()
{
fts1.DoSomeThing();
return 0;
}
第二种方法, 在 g++ 不报错, 正常运行, 但是 vs2022 下会报错, 除非把该函数放到 调用的 test.cpp 下. 不太清楚是什么原因导致的.
// file.hpp
class FileSystem
{
public:
FileSystem()
{
std::cout << "FileSystem()" << std::endl;
}
~FileSystem()
{
std::cout << "~FileSystem()" << std::endl;
}
void DoSomeThing()
{
std::cout << "do something" << std::endl;
}
};
FileSystem& GetFileSystem()
{
static FileSystem fts;
return fts;
}
// test.cpp
int main()
{
// fts1.DoSomeThing();
GetFileSystem().DoSomeThing();
return 0;
}
运行结果如下,
此时我们多调用几次,
可以看到, 它只会在第一次调用的时候初始化一次, 并且会正常析构, 并且, 如果我们不调用的话:
是根本不会产生开销的.
由于该函数非常单纯, 就是返回一个引用, 所以是绝佳的 inline 函数候选人.但是, 从另一个角度看, 内含 static 对象在多线程下 是不确定的, 主要是因为, 不同线程间访问速度随时会变化, CPU 可能随时从一个线程切换到另一个线程, 导致该线程对于之前的理解都是不正确的, 因为它可能已经被另一个线程修改了.
当然, 如果你的系统有一个成员 B 必须要调用另一个成员 A, 表明 A 必须先初始化, 而 A 能否初始化成功又依赖于 B , 坦率讲, 这是自作自受, 只要你避开这种病态的情况, 上面的函数写法是可以帮助到你的, 当然, 最好在单线程下.
避免在初始化前使用, 做到三件事情:
- 手动初始化 no-member 对象(如, int x = 1;).
- 使用初始化序列.
- 在一种"不初始化就会出事" 的氛围下编程, 时刻警惕.
注意:
- 请为内置对象进行手动初始化, 因为 C++ 不会帮你做这件事.
- 构造函数最好用初始化序列. 并且排列顺序应该和声明顺序相同.
- 为避免不同编译单元间的 non-local static 成员不知道有没有初始化的问题, 请用 local static 对象(封装一个函数)代替 non-local static 成员.