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

智码微光
• 阅读 4688

在我们刚开始学习 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 也没被初始化。也就是说,一个类或结构一旦你定义了自己的构造函数,编译器就不会再帮你去做初始化工作了。原因很简单,你自己的构造函数替代了编译器帮你默认生成的构造函数,而原来的初始化工作是这个默认的构造函数帮你完成的,一旦定义了自己的构造函数,那么初始化工作也就得自己来做了。

点赞
收藏
评论区
推荐文章
九路 九路
4年前
go语言定义“零值可用”的类型
1.Go类型的零值作为C程序员出身的我,我总是喜欢用在使用C语言的”受过的苦“与Go语言中得到的”甜头“做比较,从而来证明Go语言设计者在当初设计Go语言时是做了充分考量的。在C99规范中,有一段是否对栈上局部变量进行自动清零初始化的描述:如果未显式初始化且具有自动存储持续时间的对象,则其值是不确定的。规范的用语总是晦涩难懂的。
Wesley13 Wesley13
3年前
java成员变量的初始化
类变量(static变量,不需要实例化对象也可以引用)实例变量(非static变量,需要实例化对象)局部变量(类的成员函数中的变量)初始化方式:构造函数初始化变量声明时初始化代码块初始化java自动初始化(在构造函数执行之前执行) java保证所有变量被使用之前都是经过初始化的(声明并且定义过,被赋值
待兔 待兔
4年前
[Dart]Dart语言之旅<二>:变量
变量以下是创建变量并为其分配值的示例:varname'Bob';变量是引用。名为name的变量包含对值为“Bob”的String类型的对象的引用。默认值未初始化的变量的初始值为null。即使是数字类型的变量,初始值也为null,因为数字也是对象。intlineCount;assert(lineCountnull)
xiguaapp xiguaapp
4年前
jvm
类的加载连接与初始化加载:查找并加载类的二进制数据连接验证:确保被加载的类的正确性准备:为类的静态变量分配内存,并将其初始化为默认值解析:把类中的符号引用转换成为直接引用初始化:为类的静态变量赋予正确的初始值主动使用创建类的实例访问某个类或接口的静态变量,或者对该静态变量赋值调用类的
Wesley13 Wesley13
3年前
C语言中内存分布及程序运行中(BSS段、数据段、代码段、堆栈)
BSS段:(bsssegment)通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是英文BlockStartedbySymbol的简称。BSS段属于静态内存分配。数据段:数据段(datasegment)通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。代码段:
Wesley13 Wesley13
3年前
IOS全局变量
IOS中的全局变量和JAVA中的全局变量定义和使用方法不一样,在Java中,只需要将变量定义为static就行了。而在IOS中这种方法不适合。IOS中定义全局变量有三种方法:1.使用extern关键字在AppDelegate.m或AppDelegate.h中写入你需要的全局变量名,例如:int name;注意定义全局变量时候不能初始化,否则报错
Wesley13 Wesley13
3年前
C++GOW系列之(1):变量初始化
原文请链接http://www.gotw.ca/gotw/001.htm变量有多少种初始化方式呢?要小心注意某些似是而非的变量初始化BUG问题:下面的代码有什么区别呢?  SomeTypetu;SomeTypet(u);SomeTypet();SomeTyp
Wesley13 Wesley13
3年前
C++中内置变量的初始化
对于全局的变量如果内置类型的变量未被显示地初始化,它的值将由定义的位置决定。(1).定义在函数体之外的变量将被初始化为0;(2).定义在函数体内部的变量将不被初始化,它的值将是任意的。对于(1)举例如下:shortsn;intin;longln;longlonglln;
Wesley13 Wesley13
3年前
Java 构造方法
构造方法什么是构造方法:构造方法就是与类同名的那个方法且没有返回值。就是一个方法。有什么作用:就是初始化对象的成员变量,无参的构造方法,系统自动初始化。有参则根据你的要求初始化不同的类型,默认值如下:实例成员变量默认值:boolean:falsebyte:0short:0char:int:
Wesley13 Wesley13
3年前
Java构造器的实质作用
Java构造器的实质作用构造器的本质作用就是为对象初始化,即为实例变量初始化,赋初值;而不是创建对象,创建对象时通过new关键字来完成的,当使用new关键字时就会为该对象在堆内存中开辟一块内存,只等构造器来初始化这块内存,为实例变量赋初始值。在未赋初始值之前是默认值。看代码中的构造器和编译后构造器是不一样的,编译后的构造器包含了更多的内容。
Wesley13 Wesley13
3年前
Java类的初始化顺序 (静态变量、静态初始化块、变量、初始...
大家在去参加面试的时候,经常会遇到这样的考题:给你两个类的代码,它们之间是继承的关系,每个类里只有构造器方法和一些变量,构造器里可能还有一段代码对变量值进行了某种运算,另外还有一些将变量值输出到控制台的代码,然后让我们判断输出的结果。这实际上是在考查我们对于继承情况下类的初始化顺序的了解。我们大家都知道,对于静态变量、静态初始化块、变量、初始化块