C++ 变量零初始化需要注意的问题

brokkr

在我们刚开始学习 C 语言的时候,就被教导过:“变量在使用前必须要初始化,很多bug都来自于未使用了未初始化的变量,因为某些编译器分配空间时,默认值并不为0。”
变量的零初始化是一个很普通的需求,但正是这个看起来很常见的需求,其实有很多需要注意的问题。

对于单一标准数据类型的零初始化,我们在定义变量的时候直接在后面加一句 = 0 就好了。但结构呢?每个结构变量逐个设置为0?数组呢?循环设置为0?
这些写法太啰嗦了,而且效率很低,我们需要一种更为简洁的方法。

memset

这是 C 语言时代就有的函数,但直至今日,仍然有大量的 C++ 程序员仍然使用 memset 来进行变量的零初始化工作。

这是用最原始的方法循环零初始化一个数组,显得非常的笨拙:

int a[10];

for (int i=0;i<10;I++)
    a[i] = 0;

而与之相对的, memset 只要一行就搞定了:

memset(a, 0, 10*sizeof(int));

甚至更进一步可以简化为:

memset(a, 0, sizeof(a));

编译器会帮我们计算 a 到底是多大。

memset 可以方便的清空一个结构类型的变量或数组,而让你不需要关心里面的细节,所以这种用法使用的非常普及。

坑1,对 const 数据执行 memset

void f1(char* a, size_t len)
{
    // ...
    memset(a, 0, len);
    // ...
}

int main()
{
    const char* a = "Hello World";
    f1((char*)a, strlen(a));

    return 0;
}

a 是一个 const char 类型的字符串指针,但 f1 函数需要的是一个 char 类型的指针,也许 f1 函数是一个第三方库里面的函数,
你为了让程序编译通过,调用 f1 时对 a 做了强制类型转换,但其实在 f1 函数内部对 a 执行了 memset,这时候程序会就会崩溃。

坑2,指定值初始化

如果我们希望数组 a 里面的初始化值不是 0 而是 1,下面的代码可以实现:

int main()
{
    char a[20];
    memset(a, 1, sizeof(a));

    return 0;
}

同理,你或许认为其它类型也可以这么做,譬如:

int main()
{
    int a[20];
    memset(a, 1, sizeof(a));

    return 0;
}

但你会惊奇的发现 a[0] = 0x01010101,a[1] = 0x01010101,...,这并不是你所希望的结果。
原因很简单,memset 处理的对象是字节,也就是说只有在 char 或 unsigned char 的时候,指定值初始化才符合你的预期。

坑3,对带有虚函数的类或结构做 memset

我们先看一个例子:

// 基类
class C0
{
public:
    virtual void f0() = 0;    // 这是一个纯虚函数
};

// 派生类
class C1 : public C0
{
public:
    C1()
    {
        memset(this, 0, sizeof(C1));
    }

    virtual void f0()
    {
        std::cout << "this f0" << std::endl;
    }
};

int main()
{
    C1 c1;
    C0* c0 = &c1;   // 
    c0->f0();        // 这里会崩,因为 f0 指针已经被清零了

    return 0;
}

对于 C++ 来说,类的虚表是算在类本身占用的空间中的,C1 的构造函数中的 memset 会把虚表的指针 B_vfptr 也一并清空,那么在调用派生类的 f0 的时候就会崩溃。

坑4,对带有 STL 元素的类或结构做 memset

struct ST
{
    string str1;
    string str2;
    char str3[8];
};

// memset 会破坏 string 的结构
int main()
{
    ST s;

    memset(&s, 0, sizeof(ST));
    s.str1 = "hello";
    s.str2 = "world";

    printf("%s %s %x,%x,%x,%x,%x,%x,%x,%x\n", s.str1.c_str(), s.str2.c_str(), s.str3[0], s.str3[1], s.str3[2], s.str3[3], s.str3[4], s.str3[5], s.str3[6], s.str3[7]);

    return 0;
}

这个程序在 Windows 用 VS2017 编译后的输出是:
?7籑 7籑 00,00,00,00,00,00,00,00

在 ubuntu 下用 g++ 7.5.0 编译后的输出是:
hello world 00,00,00,00,00,00,00,00
这个版本的结果看上去是正确的,但用 gcc 4.8.5 编译结果是会崩的

对带有 string, map, vector 等 STL 元素的类或结构使用 memset,会产生崩溃、内存泄漏...等一系列未知的问题,问题的严重性与编译器有关。
这个错误是很多程序员最容易犯的错误,而且不容易排查,所以要养成习惯:不对任何使用了 STL 容器的类或结构使用 memset 来做零初始化。

对 memset 的总结

那么我们什么时候可以使用 memset 做零初始化呢?这里有一个规则,当初始化的对象是一个POD类型(Plain Old Data)时是可以用 memset 做零初始化的。
POD用来表明C++中与C相兼容的数据类型,可以按照C的方式来处理(运算、拷贝等)。非POD数据类型与C不兼容,只能按照C++特有的方式进行初始化。
C 语言的标准数据类型:char、short、int、long、long long、float、double 这些都是 POD类型,都可以用 memset 做零初始化。
使用 C 语言的标准数据类型定义的数组也是 POD 类型,也可以用 memset 做零初始化。
对于类和结构来说,很多东西会破坏 POD 的约定,只使用了 C 语言的标准类型定义的类和结构还是 POD 的,前提是你没定义基类、没使用虚函数...,
具体到底哪些 C++ 规则会破坏 POD 约定请自行百度,但总的来说,不建议使用 memset 对类或结构进行零初始化工作。

C++ 的定义初始化

如果你不是在函数内部需要对外部传递进来的数据指针做零初始化工作的化(这种情况下还是需要使用memset),在 C++ 11 以后,在进行数据定义的同时做零初始化是更为优雅的一种方式。

int a[10] = { 0 };

这种定义的方式会让你直接获得一个已经清零的数组(注意并不是只有a[0]是0,而是全部都已清零)。这种方式看起来比 memset 还简单、便捷,似乎没有理由不去用它。

坑1,指定值初始化

上面这种写法,很容易让人写下下面的代码:

int a[10] = { 1 };

你是不是觉得自己会得到全部元素已被初始化成 1 的数组?但答案不是这样的。
上面的写法你得到的是 a[0] = 1, a[1] = 0, a[2] = 0, ... 也就是说除了 a[0] 以外,其它的数组元素被初始化为 0。
为什么是这样?因为有一条基本规则,数组初始化列表中的元素个数小于指定的数组长度时,不足的元素用默认值来初始化。
初始化列表里面只给了 1 个值,那么这个值被用来初始化 a[0],而 a[1] - a[9] 使用默认值 0 来做初始化。

坑2,对带有 STL 元素的类或结构做零初始化

我们去看看前面的例子,我们用定义初始化的方式来初始化带有 STL 元素的结构会怎么样

struct ST
{
    string str1;
    string str2;
    char str3[8];
};

int main()
{
    ST s = { 0 };

    s.str1 = "hello";
    s.str2 = "world";

    printf("%s %s %x,%x,%x,%x,%x,%x,%x,%x\n", s.str1.c_str(), s.str2.c_str(), s.str3[0], s.str3[1], s.str3[2], s.str3[3], s.str3[4], s.str3[5], s.str3[6], s.str3[7]);

    return 0;
}

你是不是觉得这样没问题?因为你用的是 C++ 的方法来做的初始化。但答案是程序可能在 ST s = { 0 }; 处崩溃,即使不崩溃,也不会输出期望的结果。
为什么会这样?仔细理解一下坑1里面讲的基本规则,ST s = { 0 }; 初始化列表里面只有一个 0 (int 类型),而结构的第一个元素是 string,它没有通过 int 来进行构造的方法。
这时候编译器就会把这个 string 类型当作是一个结构来处理,执行: memset(&str1, 0, sizeof(string)),也就是说编译器又去调用了 memset 来对 str1 执行初始化,不崩才怪。

如何进行正确的零初始化?

我们已经讨论了很多错误的零初始化方式,那么对于一个类或结构来说,什么才是正确的零初始化方式?
还是使用上面的例子:
我们把 ST s = { 0 }; 改为 ST s = { };
这下就 OK 了,因为你给了初始化列表,但初始化列表是空的,这时候编译器就会用 string 的默认值 "" 来初始化 str1, str2, 用 char 的默认值 0 来初始化数组,这正是我们想要的。

这个方法可以延展到所有的类型定义上:

int a{};    // 这种写法跟 a = {} 是等价的
float b = {};
int c[10] = {};

动态分配的数据的初始化工作

对于动态分配的数据,你也可以采用这种方法来做初始化工作。

int* a = new int[10] { };
string* b = new string[10] { };

我们看看如果不做初始化会怎么样:

int* a = new int[10];
string* b = new string[10];

这个时候 b 里面的 string 还都是 "",这是因为 string 的构造函数会默认初始化成 "",但 int 可没有构造函数,所以 a 里面的内容就是随机的。

还有坑?

看到这的时候,是不是觉得已经找到版本答案了?但生活中总是充满了惊(yi)喜(wai)。

struct ST
{
    string str1;
    string str2;
    char str3[8];

    ST() { }
};

int main()
{
    ST s = { };

    s.str1 = "hello";
    s.str2 = "world";

    printf("%s %s %x,%x,%x,%x,%x,%x,%x,%x\n", s.str1.c_str(), s.str2.c_str(), s.str3[0], s.str3[1], s.str3[2], s.str3[3], s.str3[4], s.str3[5], s.str3[6], s.str3[7]);

    return 0;
}

我们给 ST 添加了一个空的构造函数,现在得到的结果是:

hello world ffffffcc,ffffffcc,ffffffcc,ffffffcc,ffffffcc,ffffffcc,ffffffcc,ffffffcc

即使我写了 ST s = { }; str3 也没被初始化。也就是说,一个类或结构一旦你定义了自己的构造函数,编译器就不会再帮你去做初始化工作了。原因很简单,你自己的构造函数替代了编译器帮你默认生成的构造函数,而原来的初始化工作是这个默认的构造函数帮你完成的,一旦定义了自己的构造函数,那么初始化工作也就得自己来做了。

阅读 113
555 声望
2 粉丝
0 条评论
555 声望
2 粉丝
宣传栏