条款04 确定对象被使用前已经初始化

读取未初始化的变量会导致未知的出现

“关于将对象初始化这事, 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 , 坦率讲, 这是自作自受, 只要你避开这种病态的情况, 上面的函数写法是可以帮助到你的, 当然, 最好在单线程下.

避免在初始化前使用, 做到三件事情:

  1. 手动初始化 no-member 对象(如, int x = 1;).
  2. 使用初始化序列.
  3. 在一种"不初始化就会出事" 的氛围下编程, 时刻警惕.

注意:

  • 请为内置对象进行手动初始化, 因为 C++ 不会帮你做这件事.
  • 构造函数最好用初始化序列. 并且排列顺序应该和声明顺序相同.
  • 为避免不同编译单元间的 non-local static 成员不知道有没有初始化的问题, 请用 local static 对象(封装一个函数)代替 non-local static 成员.
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

roseisbule

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值