C++:类和动态内存分配

222 阅读16分钟

动态内存分配的优点:在程序运行时能够精确知晓需要分配多少内存。用多少就分配多少,这是静态(编译时)内存分配无法做到的。

C++ 使用 newdelete 运算符来动态控制内存。用的好,皆大欢喜;用不好,signal 就来咯~

1、动态内存和类

1.1 例子分析

image.png image.png image.png

  1. 类声明没有为字符串本身分配存储空间,而是在构造函数中使用 new 来为字符串分配空间。
  2. 静态类成员有一个特点:类的所有对象共享同一个静态成员。

Note:

  • 不能在类声明中初始化静态成员变量。因为声明只描述了如何分配内存,但并不会分配内存。
  • 静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类,无需再使用关键字 static。例外情况是如果静态成员是整型或者枚举型 const,则可以在类声明中初始化。

除了例外情况,初始化是在方法实现文件中,而不是在类声明文件中。Why ❓

答:因为类声明为头文件中,而头文件可能被包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。

image.png image.png

3、在构造函数中使用 new 来分配内存时,必须在相应的析构函数中使用 delete 来释放内存。如果使用 new[] 来分配内存,则应使用 delete[] 来释放内存。

image.png image.png

linux g++ 中,一般会提示“double free”等类似的错误。

错误分析: callme2() 按值(而不是引用)传递 headline2,结果出现了严重的问题,乱码——读取非法内存了。虽然按值传递时通过浅拷贝一个临时对象,可以防止实参被修改,但这个实参里包含了一个指针成员,这个指针成员指向了一个堆上内存。浅拷贝并不会创建该堆内存的副本。于是,实参对象和临时对象中的指针成员指向了同一个堆内存。当 callme2() 执行结束后并析构掉这个临时对象时,堆内存被释放,然而实参对象,也即本例中的 headline2 对象仍然持有指向该堆内存的指针,不同地是,堆内存对于它而言,已经成为非法访问区域了。果然,接下来打印 headline2 时,需要访问这个堆内存(已经非法了),(多数情况下)程序崩溃😫。

错误分析: 退出作用域时,开始逐个析构 StringBad 对象。前两个正常,后面开始不正常了,输出字符串异常,计数也异常。why ❓ 因为这其中存在编译器自动创建的复制构造函数,里面并不会对 num_string 这个变量进行计数维护。同样地,这种自动创建的复制构造函数,只是一种浅拷贝,导致多个对象指向同一个堆内存。当其中一个对象析构时,另外指向相同堆内存的对象析构时,就出现非法访问内存的问题了。

1.2 特殊成员函数~

C++ 自动提供了下面这些成员函数:

  • 若未定义 Constructor,则自动创建 Default Constructor(无参,不执行任何操作).
  • 若未定义 Destructor,则自动创建 Default Destructor(不执行任何操作).
  • 若未定义 Copy Constructor 且使用了,则自动创建 Copy Constructor.
  • 若未定义 Assignment operator 且使用了,则自动创建.
  • 若未定义 Address operator 且使用了,则自动创建. C++11 提供了两个特殊成员函数:
  • 移动构造函数(move constructor)
  • 移动赋值运算符(move assignment operator)

结论:StringBad 类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。

1.2.1 默认构造函数

1、首先明确什么是“默认构造函数”❓ 2 种但同时只能存在 1 个,否则二义性!
  • 无参构造函数;
  • 所有参数都有默认值的带参数的构造函数。即,在创建对象时,无须显式提供参数。如下所示。
MyClass::MyClass() { cout << "MyClass"; }
MyClass::MyClass(int n = 2, int l = 10) { cout << n * l; }

MyClass my1(3, 12); // 调用带参的默认构造函数。
MyClass my2();     // 不知道调用哪一个默认构造函数了,代码存在二义性。
2、when 编译器自动生成 默认构造函数❓

答:当不存在任何构造函数时,编译器才会提供一个无参、不执行任何操作的默认构造函数(称为:默认的默认构造函数)。通过其创建的对象,类似于一个常规的自动变量,因为该构造函数不接受任何参数,不执行任何操作,其内部成员变量初始化时的值是未知的

MyClass::MyClass() {}

自动生成的默认构造函数的功能是什么?

  1. 调用基类的默认构造函数以及调用本身是对象的成员所属类的默认构造函数
  2. 如果派生类构造函数的成员初始化列表中没有显式调用基类构造函数,则编译器将使用基类的默认构造函数来构造派生类对象的基类部分。
    • 在这种情况下,如果基类没有构造函数,将导致编译阶段错误。
    • 若已经定义其它构造函数,由于编译器不会生成默认构造函数,在这种情况下,开发者就必须提供默认构造函数。

1.2.2 复制构造函数

1、类的复制构造函数原型

它接受一个指向类对象的常量引用作为参数。

ClassName(const ClassName &);
2、复制构造函数是干什么的❓何时调用❓

答:用于将一个对象复制到新创建的对象中。它用于新创建对象的初始化过程中,而不是常规的赋值过程中,如下👇:

StringBad sailor = sports; // 这个叫对新创建对象的初始化
StringBad knot;
knot = headline1; // 这个叫赋值.因为knot已经通过默认构造函数初始化过了.

下述情况,将使用复制构造函数:

  • 将新对象初始化为一个同类对象
  • 函数传参:按值传递对象给函数;
  • 函数返回:按值返回对象;
  • 编译器生成临时对象时。
3、默认的复制构造函数

默认的复制构造函数逐个复制非静态成员的值(浅拷贝),仅复制非静态成员变量的值

image.png

💣💥浅拷贝的危险💥

在🌰中,复制后,两个 StringBad 对象的指针成员 str 指向同一个堆内存。若被复制的对象仅是一个临时对象,当脱离作用域时,临时对象一旦被析构,则堆内存将被释放。等到第二个对象再次访问堆内存(访问非法内存)或者被析构时(重复 delete,前提是析构函数内有对其进行 delete 的操作),则会报错!

4、显式定义复制构造函数

如果类中包含了使用 new 初始化的指针成员,应当显式定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制

image.png

5、何时调用复制构造函数❓

答:新建一个对象并将其初始化为同类现有对象时(显式),或每当程序生成了对象副本时(隐式),复制构造函数都将被调用。

  • 显式触发:下面 sb1、sb2、sb3、psb 初始化时,都将调用复制构造函数。

    ① sb2、sb3 的初始化过程中:可能使用复制构造函数直接创建;也可能先使用复制构造函数生成一个临时对象,然后再把临时对象的内容赋值给 sb2、sb3。(两种可能取决于编译器实现)
    ② psb 是一个类对象指针,与 sb1 使用 momo 初始化相同,这里使用 momo 初始化一个匿名对象,并将该匿名对象的地址赋值给 psb。

Stringbad momo;
StringBad sb1(momo);             // call StringBad(const StringBad &)
StringBad sb2 = momo;            // call StringBad(const StringBad &)
StringBad sb3 = StringBad(momo); // call StringBad(const StringBad &)
StringBad * psb = new StringBad(momo); // call StringBad(const StringBad &)

sb1(momo) 的初始化,会引起一个问题,解决这个问题的方法是引入了一个新的关键字:explicit

  • 隐式触发: ① 函数按值传递对象时;(这也说明了:引用传递对象时,可以减少开销和避免一些错误) ② 函数返回对象时。
6、包含类成员的类的逐成员复制

如果一个类 A 包含有另一个类 B 对象的成员变量,则将一个 A 对象复制(或赋值)给另一个 A 对象,在复制(或赋值)类 B 对象的这一成员变量时,将会使用类 B 定义的复制构造函数(或赋值运算符)。依次类推。

1.2.3 重载的赋值运算符

1、功能以及何时使用❓

将已有的对象赋给另一个==已存在的==对象时,将使用重载的赋值运算符。不要讲赋值和初始化混淆!

StringBad headline1("AAAAAAA");
StringBad h = headLine1; // 这是初始化
StringBad knot;
knot = headline1;  // 赋值运算符会被调用

📌 使用另一个对象对新对象进行初始化时,可能也会使用赋值运算符: 1^1先用复制构造创建一个匿名的临时对象,2^2然后再将临时对象赋值给新创建的对象。 也可能不使用赋值运算符。这取决于编译器的实现。

2、函数原型

C++ 允许类对象赋值,是通过自动为类重载赋值运算符实现的,原型如下: image.png

3、默认的重载赋值运算符

编译器自动为类生成的默认的重载赋值运算符,也是一种简单的按 bits 拷贝,也即非静态成员变量值的拷贝。不是深度复制,这与默认的复制构造函数类似。

4、显式定义重载的赋值运算符

我们需要深度赋值~由于赋值过程并不会创建新的对象,因此,在赋值前有两个前置步骤:
① 检查“赋值对象”和“被赋值对象”的地址是否相同,判断是否为同一个对象。 若既已是同一个对象,则多余!
② 将“被赋值对象”中的所有堆内存资源全部 delete 处理。 因为“被赋值对象”可能还引用了在这之前被分配到的数据,所以要使用 delete / delete[] 来释放这些数据。

前置步骤走完,下面就是和显式复制构造函数相同了,即:
③ 对每个成员进行逐个复制;若成员本身是非基本类型(自定义 class),则程序将使用“为这个类定义的”赋值运算符来复制该成员(递归思想),当然,类的静态数据成员不算在内,因为其所有类成员对象共享。
④ 最终,返回指向调用对象的引用。这可以实现“连续赋值:a=b=c=...”。

image.png

【总结一句话】 检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用

5、派生类对象可以赋值给基类对象

is-a 关系允许基类引用指向派生类对象,赋值运算符只处理基类成员。

6、基类对象可以赋值给派生类对象 ? Maybe

派生类引用不能自动引用基类对象。除非有转换构造函数:接受一个类型为基类的参数和其他参数,条件是其他参数有 默认值。因此,问题“是否可以将基类对象赋值给派生类对象 ?” 答案是“ Maybe ”。

1.3 StringBad 错误修改

将默认复制构造函数和默认重载的赋值构造函数产生的影响解决后:

image.png

编程技巧:重载了一些赋值运算符。

1、重载中括号表示法来访问字符

image.png

2、重载赋值运算符

String name;
char temp[40];
cin.getline(temp, 40);
name = temp;

👆 第四句赋值,将根据 temp 先构建一个临时 String 对象,赋值给 name 后,再将其删除。 【优化】重载赋值运算符,使其能直接接受一个 C 字符串,不用创建和删除临时对象,减少开销

image.png

1.4 静态类成员函数

1、静态类成员函数声明必须包含关键字 static。若函数定义是独立的(在另一个文件中)则不能再包含关键字 static;
2、静态类成员函数不与特定的对象相关联,因此不能访问类的非静态成员,只能访问属于类的静态成员变量。
3、静态类成员函数属于类,不属于类的实例对象,因此不能通过类的实例对象去调用。若其是公有的,则可以使用类名和作用域解析运算符来调用。

2、在构造函数中使用 new 时应注意的事项

1、成对出现,确保资源“有来有去无泄漏
在构造函数中使用 new 来初始化指针成员,则应在析构函数中使用 delete

2、要“门当户对”
newdelete 必须相互对应。newdeletenew[]delete[]

3、如果有多个构造函数,则必须以相同的方式使用 new 来初始化同一个指针成员,要么都带中括号,要么都不带。因为析构函数只有一个,所有构造函数都必须与其兼容

4、可以对空指针使用 deletedelete[]
构造函数中使用 new,通常意味着类存在指针成员变量,那么必须考虑“深度复制”

5、应该定义一个复制构造函数,并使用深度复制实现。
(1)深度复制,而不仅仅是数据的地址;
(2)应该更新所有受影响的静态类成员。

6、应该定义一个赋值构造函数,并使用深度复制实现。
(1)检查自我赋值情况,释放成员指针以前指向的内存,赋值数据而不仅仅是数据的地址;
(2)返回一个指向调用对象的引用。

3、有关返回对象的说明

当成员函数或独立的函数返回对象时,有 4 种返回方式:

  1. 返回指向 const 对象的引用;
  2. 返回指向非 const 对象的引用;
  3. 返回对象;
  4. 返回 const 对象;

为啥用❓啥时候用❓
【总结】
1、如果方法或函数要返回局部对象,则应返回对象,而不是指向的对象引用。返回对象将使用复制构造函数来生成返回的对象。
2、如果方法或函数要返回一个没有公有复制构造函数的类(如 ostream 类)的对象,它必须返回一个指向这种对象的引用。
3、有些方法和函数可以返回对象,也可以返回指向对象的引用。此时,首选引用,因为效果更高!

3.1 返回指向 const 对象的引用

image.png

3.2 返回指向非 const 对象的引用

返回指向非 const 对象的引用,有两种常见的场景:

3.2.1 重载赋值运算符

operator=() 的返回值用于连续赋值。

String s1("AAAAA");
String s2, s3;
s3 = s2 = s1;

这里,返回 String 对象或 String 对象的引用都是可行的。当然,返回 String 对象的引用,不会调用复制构造函数,效率更高

3.2.2 重载与 cout 一起使用的 << 运算符

operator<<() 的返回值用于串接输出。这是必须的,因为 ostream 没有公有复制构造函数。

String s1("AAAAA");
cout << s1 << "hai~hai~";

3.3 返回对象

如果方法或函数要返回局部对象,则应返回对象,而不是返回对象的引用

  • 绝对不要 !!! 返回 pointer 或 reference 指向的 local stack 对象,因为局部对象会被释放!
  • 绝对不要 !!! 返回 reference 指向的一个 local heap-allocated 对象,因为你无法去 delete 它!
  • 绝对不要 !!! 返回 pointer 或 reference 指向的 local static 对象,因为有可能同时需要多个这样的对象,而 static 对象永远只有一个。同时,还有多线程安全问题!
  • 不要妄想使用 local static array 来解决 local static 对象只有一个的问题,因为你要解决数组大小以及如何将值放入数组的问题,这一过程中:
    • 一方面没有解决我们最开始想避免的 pass-by-value 导致的一次构造+析构的开销;
    • 另一方面,多了很多其他的工作。 你该为有此想法而脸红 🤡🤡🤡

3.4 返回 const 对象

加上 const 能够帮忙程序员避免一些“很有创意的错误🙄”,例如:

image.png

4、使用指向对象的指针

4.1 指针和对象

1、声明指向对象的指针:

String * glamp;

2、声明定义并初始化为指向已有的对象:

String sayings[2];
String* first = &sayings[0];

3、声明定义并使用 new 来初始化指针,这将创建一个新的对象:

String* favorite = new String(sayings[0]);

4、对类使用 new 将调用相应的类构造函数来初始化新创建的对象:

String* gleep = new String;             // 调用默认构造函数
String* creen = new String("AAA");      // 调用 String(const char*) 构造函数
String* aplay = new String(sayings[0]); // 调用 String(const String&) 构造函数

5、使用 -> 运算符通过指针访问类方法; 6、对对象指针应用解除引用运算符(*)来获得对象。

4.2 再讨论 new 和 delete

image.png

String* str = new String();

这将调用构造函数。当程序不再需要该对象时,使用 delete 删除它。这将只释放用于保存 str 指针和 len 成员的空间,并不释放 str 指向的内存。str 指向的内存将由 String 的析构函数来完成。

4.3 再谈定位(placement) new 运算符

基本用法详见:定位 new 运算符

使用定位 new,可以在指定的位置存放数据。但这也意味着,这部分内存需要自行管理,即:
(1)要确保多个对象所占内存区域不会重叠;
(2)用完要主动显式地调用对象的析构函数
① 必须指定要销毁的对象。
② 必须按创建先后顺序的相反顺序进行删除。因此,仅当所有对象销毁后,才能释放用于存储这些对象的。

image.png

不能像下面这样直接对 pc1、pc3 使用 delete。

delete pc3;
delete pc1;

原因在于 delete 只与 常规 new 运算符配合使用,但不能与 定位 new 运算符配合使用。因为 new/delete 系统知道已分配的 512 字节块 buffer,但对于 定位 new 运算符对该内存块做了哪些处理一无所知。