第3篇-C/C++ 类和内存分配
内存分配过程影响类应动态分配其自己的内存的设计方式。 因此,在本篇中,我们主要讨论这些new和delete运算符的特性与处理C中的内存分配的函数集(即malloc/realloc等)相反,C ++中的内存分配由运算符new和delete这两个操作符去处理。 malloc和new之间的重要区别是
- 函数malloc不理会分配的内存用来干什么的。 相反,new需要指定类型; sizeof表达式由编译器隐式处理。 因此,使用new是类型安全的。
例如,当分配用于double的内存时,程序员必须
malloc(sizeof(double))
在C中,如果对于char指针分配一个内存宽,那情况复杂得多了,首先你需要预估加载到堆空间的字符个数。是否需要在现已分配的基础上扩容?扩充多少内存空间?
- malloc分配的内存是由calloc初始化的,将分配的字符初始化为可配置的初始值。 当对象可用时,这不是很有用。 当new的操作符知道分配的实体的类型时,它可以(并将)调用分配的类类型对象的构造函数。 该构造函数也可以提供参数。
- 在C中执行内存分配操作 ,必须检查所有C分配函数是否有NULL返回。 而C++中,使用new时不需要。 实际上,当遇到失败的内存分配时,new的行为可以通过使用new_handler来配置
释放和删除之间存在类似的关系:删除可确保在释放对象时自动调用其析构函数。
当对象被创建和销毁时,构造函数和析构函数的自动调用会产生一些后果,我们将在本文和后续文章中进行讨论。 C程序开发过程中遇到的许多问题是由不正确的内存分配或内存泄漏引起的:未分配,未释放,未初始化,边界被覆盖等。虽然C++不能完全解决这些问题,但确实为我们提供了解决方案 使用预防此类问题的工具。
在C++编程中 ,通常不建议使用malloc这类的已经涉及系统底层的函数,因此应避免在C ++程序中使用所有经常基于malloc的字符串的内存分配。恰好,应该使用标准库的string类的函数以及new和delete运算符。
运算符“ new”和“ delete”
C ++定义了两个运算符new和delete分别用来分配内存并在用完之后将其内存空间返回到“公共池(它由C++STL中的allocator托管的一个内存管理工具)”本系列文章中以后会涉及这些话题。
这是说明其用法的简单示例。 一个int指针变量指向由运算符new分配的内存。 稍后通过操作员删除释放此内存。
#include <iostream>
using namespace std;
int main(int argc, char const *argv[])
{
double *d = new double;
cout << *d << endl;
cout << sizeof(d) << endl; //z
cout << sizeof(*d) << endl;
delete d;
}
以下是new和delete运算符的一些特征:
- new和delete是运算符,因此不需要像malloc和free这样的函数所需要的括号;
- new返回一个指向其操作数所要求的内存类型的指针(例如,它返回一个指向int的指针 )
- new使用类型作为其操作数,这具有重要的好处,即在给定要分配的对象类型的情况下,可以使用正确数量的内存;
- new是一个类型安全的运算符,因为它总是返回指向作为其操作数提到的类型的指针。 另外,接收指针的类型必须与用运算符new指定的类型匹配;
- new可能会失败,但是程序员通常不必担心,程序不必测试内存分配是否成功.
- delete 返回void
- 对于每个对new的调用,最终应执行一个匹配的delete操作,以免发生内存泄漏;
- delete可以安全地对0指针进行操作(不执行任何操作),一般不会蛋痛都这么做,没意义。
- delete只能用于释放由new分配的内存。 它不能用于释放由malloc和friends分配的内存。
- 在C ++中,不建议使用malloc和friends,应避免使用。
基本数据类型的内存分配
运算符new可以用于分配基本类型,但也可以分配对象。 当分配没有构造函数的原始类型或结构类型时,不能保证分配的内存被初始化为0,但是可以提供一个初始化表达式:
#include <iostream>
using namespace std;
int main(int argc, char const *argv[])
{
double *d = new double(3.14);
int *i1 = new int(123);
int *i2 = new int(4 * (*i1));
cout << "d=" << *d << endl;
cout << "i1=" << *i1 << endl;
cout << "i2=" << *i2 << endl;
delete d, i1, i2;
}
分配类类型的对象时,将在新表达式中紧随类型说明后指定其构造函数的参数(如果有),然后将该对象初始化为这样指定的构造函数。 例如,要分配字符串对象,可以使用以下语句:
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char const *argv[])
{
string *s1 = new string;
string *s2 = new string{"Hello World"};
cout << *s2 << endl;
delete s1,s2;
}
除了使用new为单个实体或实体数组分配内存(请参阅下一节)外,还存在一种分配原始内存的变体:运算符new(sizeInBytes)。 原始内存作为空*返回。 在这里new为未指定的目的分配了一块内存。 尽管原始内存可能包含多个字符,但不应将其解释为字符数组。 由于new返回的原始内存以void *的形式返回,因此可以将其返回值分配给void *变量。 通常,使用强制转换将其分配给char *变量。 这是一个例子:
char *chPtr = static_cast<char *>(operator new(12));
cout << sizeof(*chPtr) << endl;
动态数组分配内存
操作符 new []用于分配数组。 通用符号new []在C ++注释中使用。 实际上,必须在方括号之间指定要分配的元素数,并且必须在其前面加上必须分配的实体的类型作为前缀。 例:
using namespace std;
int main(int argc, char const *argv[])
{
int *pi = new int[5];
for (int i = 0; i < 5; i++)
{
pi[i] = i * 21;
}
for (int i = 0; i < 5; i++)
{
cout << pi[i] << " ";
}
cout << endl;
}
输出:0 21 42 63 84
备注: new是与new []不同的操作符。 下一节将讨论这种差异的结果
由运算符new []分配的数组称为动态数组。 它们是在程序执行期间构造的,其生存期可能超过创建它们的函数的生存期。 只要程序运行,动态分配的数组就可以持续使用。
当new []用于分配原始值数组或对象数组时,必须在new []的方括号之间指定类型和(无符号)表达式。 编译器使用类型和表达式一起确定所需的可用内存块大小。 使用new []时,数组的元素连续存储在内存中。 此后可以使用数组索引表达式访问数组的各个元素:intarr [0]表示第一个int值,紧随其后是intarr [1],依此类推,直到最后一个元素(intarr [19])。
struct类型的内存分配
接下来讲解一下struct的内存分配的问题,它经常被谈论到的就是内存对齐的问题,可以考虑一下,我写本文的时候是64位的ubuntu ,下面struct _student最终在内存池中会分配多少字节的内存?,如果你的回答是27的话,拜托请回去好好补习一下有关内存对齐的基础知识。正确的答案应该是40
这里是上一篇关于内存对齐的传送门《第2篇:C/C++ 结构体及其数组的内存对齐》
typedef struct _student
{
unsigned int sid; //4+4
char name[6]; //6+2
double score; //8
double height; //8
char sex; //1+7
} Student;
int main(int argc, char const *argv[])
{
Student *st = new Student;
cout << sizeof(*st) << endl;
return 0;
}
内存对齐分析
因为x86_64的环境下是按8字节对齐的,而在上面的struct student的接口定义中,定义的数据类型的顺序分别是
- unsigned int sid是4个字节,为了筹够8个字节,额外填充4个字节
- char name[6],char的数组占用6个字节,同理,额外填充2个字节
- double score 本身就是8个字节,不需要填充
- double height本是8字节,不需要填充
- char sex占用1个字节,char类型紧挨着height的内存块。同理 ,额外填充7个字节。
struct student的内存布局如下图
很明显,内存对齐操作导致内存分配上的大小是跟结构体/类接口内部定义的数据成员的顺序很很大关系。我们不妨按照基本数据类型的大小由大到小重新变更一下结构体内部的数据成员的先后顺序。如下代码所示。
typedef struct _student
{
double score; //8
double height; //8
char name[6]; //6
unsigned int sid; //4
char sex; //1
} Student;
我们按照先前的内存对齐的分析方法,可以知道,仅有char name[6]由于长度为6,按8字节对齐需要填充2个字节,这里重点关注的是int后面的部分,由于char类型是不参与任何内存对齐操作的,因此它会紧挨着sid整形变量的内存块之后,此时从sid变量的内存地址st+24算起, 只需再填充3个字节,就能按8的倍数对齐了。
即调整数据成员的声明顺序之后,在内存对齐操作的作用之下,为struct Student分配的内存尺寸是32个字节。相对前面的版本,这里的内存消耗降低了8个字节。
一般经验:在定义类接口时,按照基本数据类型的大小由大到小声明数据成员,能够最大限度地提供内存的利用率, 注意:我这里限定的前提是数据成员的类型是基本数据类型,如果存在类类型的数据成员,我们不妨定义该类类型的数据成员的引用。
类类型的数组的内存分配
我们结合前面的结构体的改进版本后的例子,来进一步讨论struct的数组的内存布局情况。其实很简单,如果按照上面32字节版本的的struct Student为例子。
#include <iostream>
#include <string>
using namespace std;
typedef struct _student
{
double score; //8
double height; //8
char name[6]; //6
unsigned int sid; //4
char sex; //1
} Student;
int main(int argc, char const *argv[])
{
Student *st = new Student[4];
cout << sizeof(*st) << endl;
cout << "sid:" << st->sid << endl;
cout << "name:" << st->name << endl;
cout << "score:" << st->score << endl;
cout << "height:" << st->height << endl;
return 0;
}
如果struct Student的成员在struct的接口中进行了显式初始化(例如,unsigned int sid = 113),或者该结构使用了组合,并且组合数据成员的类型定义了默认构造函数,则在结构的接口中进行初始化,并由组合数据成员的构造函数执行的初始化优先于0初始化。 这是一个例子:
typedef struct _student
{
_student();
double score; //8
double height; //8
char name[6]; //6
unsigned int sid; //4
char sex; //1
} Student;
Student::_student()
{
sid = 113;
strcpy(name, "None");
score = 65.33;
height = 165;
sex = 'f';
}
int main(int argc, char const *argv[])
{
Student *st = new Student[4];
cout << sizeof(*st) << endl;
for (int i = 0; i < 4; i++)
{
cout << "st[" << i << "] "
<< " sid:" << st[i].sid << endl;
cout << "st[" << i << "] "
<< "name:" << st[i].name << endl;
cout << "st[" << i << "] "
<< "score:" << st[i].score << endl;
cout << "st[" << i << "] "
<< "height:" << st[i].height << endl;
}
return 0;
}
当操作符new []用于分配定义了默认构造函数的类类型的对象数组时,将自动使用这些构造函数。 因此,new Student[4]会产生4个分别调用该类的默认构造函数的对象的块。 但new []操作府不能调用非默认构造函数,但通常可以解决该问题,下文会继续介绍。
new [] 操作符创建的struct Student的数组的内存布局如下图,有些人认为随着新的内存访问技术不断革新,关注内存对齐不再必要,其实当你看到这种言论的时候,我觉得很可笑。那我反问一句为何那么多种类的C++编译器在编译程序的时候,还保留内存对齐这个机制?内存对齐不仅是兼顾老一批硬件的原有内存访问机制,而且作为C/C++程序员要理解这些编译器这些工作原理,并在定义接口的时候,作出空间成本消耗最低的优化.
注意:
-
操作符new []中括号之间的表达式表示要分配的数组元素数。 C ++标准允许分配大小为0的数组。 语句new int [0]这样的语句对于C ++是正确。 但这样做是毫无意义,应该避免。因为它根本没有引用任何元素,因为返回的指针具有无用的非0值,因此令人困惑。
-
其实,都这里我们应该要认识,在不使用运算符new []的情况下,可变大小的数组也可以构造为局部数组。 这样的数组不是动态数组,其生存期仅限于定义它们的块的生存期。
-
一经new []分配后,所有数组的大小均固定的。C++ 没有简单的方法来扩大或缩小数组。所谓的可扩容数组实质上是就是malloc向系统底层开辟一个比原来数据尺寸更大的新内存块区域,然后将旧的数组上的元素全部拷贝到新的内存区域,最后释放原来旧数组所占的内存区域。我之前写的本文有了动态数组的实现《C++ 数据结构--动态顺序表的实现》
下篇待续 .....