C++ Primer Plus (6th) Chap12 类和动态内存分配 摘录
通常,最好是在程序运行时(而不是编译时)确定诸如使用多少内存等问题。对于在对象中保存姓名来说,通常C++的做法是,在类构造函数使用new运算符在程序运行时分配所需的内存。
12.1 动态内存和类
C++分配内存时采取的部分策略与此相同,让程序在运行时决定内存分配,而不是在编译时决定。
12.1.1 复习示例和静态类成员
- // stringbad.h
- #include <iostream>
- #ifndef STRINGBAD_H_
- #define STRINGBAD_H
-
- class StringBad
- {
- private:
- char* str;
- int len;
- static int num_strings;
- public:
- StringBad(const char* s);
- StringBad();
- ~StringBad();
- // friend function
- friend std::ostreaming& operator <<(std::ostreaming os, const StringBad& st);
- };
-
- #endif
对于此声明,需要注意两点。首先,它使用char*指针(而不是char数组)来表示姓名。意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声明中预先定义字符串的长度。其次,将num_strings成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员。这对于所有类对象都具有相同值的类私有数据是非常方便的。
- // string.cpp -- StringBad class methods
- #include <cstring>
- #include "stringbad.h"
- using std::cout;
-
- // initializing static class member
- int StringBad::num_strings = 0;
-
- // class methods
- // construct StringBad from C string
- StringBad::StringBad(const char* s)
- {
- len = std::strlen(s);
- str = new char[len + 1]; // 类成员str是一个指针,因此构造函数必须提供内存来存储str。
- std::strcpy(str, s); // 若使用str = s,则只是将s地址赋给了str,而并没有保存s指向的内容。
- num_strings += 1;
- }
-
- StringBad::~StringBad()
- {
- --num_strings;
- delete[] str; // 构造函数使用了new char[]。
- }
请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。对于静态成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static。初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包含在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
注意,静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态类成员是const整数类型或枚举类型,则可以在类声明中初始化。
当StringBad对象过期时,str指针也将过期。但str指向的内存仍被分配,除非使用delete将其释放。删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。
在构造函数中使用new来分配的内存时,必须在相应的析构函数中使用delete来释放内存。如果使用new[](包括中括号)来分配内存,则应使用delete[]来释放内存。
12.1.2 特殊成员函数
StringBad类的问题是由特殊成员函数引起的。具体地说,C++自动提供了下面这些成员函数:
1. 默认构造函数, 如果没有定义构造函数;
2. 默认析构函数, 如果没有定义;
3. 复制构造函数, 如果没有定义;
4. 复制运算符, 如果没有定义;
5. 地址运算符, 如果没有定义;
StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。
隐式地址运算符返回调用对象的地址(即this指针的值)。
C++11 还提供了另外两个特殊成员函数:移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。
1. 默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数例如:
Klunk::Klunk(){}; // implicit default constructor
如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显示地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值:
- Klunk::Klunk()
- {
- klunt_ct = 0;
- ...
- }
带参数的构造函数也可以是默认构造函数,只要所有的参数都有默认值。
Klunk(int n = 0){ klunk_ct = n; }
但是默认构造函数只有有一个,不然引起二义性。
2. 复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数通常如下;
- Class_name(const Class_name& );
-
- String::String(const String& st)
- {
- num_strings++;
- len = st.len;
- str = new char[len + 1];
- std::strcpy(str, st.str);
- }
对于复制构造函数,需要知道两点:何时调用和有何功能
新建一个对象并将其初始化同类现有对象时,复制构造函数都将被调用。最常见的情况时将新对象显式地初始化为现有的对象。
- StringBad motto = {...};
-
- // 以下4种声明都调用复制构造函数
- StringBad ditto1(motto);
- StringBad ditto2 = motto;
- StringBad ditto3 = StringBad(motto);
- StringBad* ditto4 = new StringBad(motto);
中间两种声明可能会导致使用复制构造函数直接创建ditto2和ditto3,也可以使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给ditto2和ditto3,这取决于具体实现。最后一种声明使用motto初始化一个匿名对象,并将新对象的地址赋给ditto4指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象或函数返回临时对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。
4. 默认的复制构造函数功能
默认的复制构造函数逐个复制非静态成员(成员复制也叫浅复制),复制的是成员的值。静态成员是属于整个类的,而不是各个对象。
12.1.3 回到StringBad:复制构造函数的哪里出了问题
- StringBad sports = {...};
- StringBad sailor = sports;
一个问题是静态成员变量计数更新问题。如果类中包含这样的静态成员,即其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理静态成员更新问题。
另一个问题隐式构造函数是逐非静态成员复制。
当释放sports后,则sailor.str指向的内存也被释放了,这将导致不确定的,甚至有害的后果。因为试图释放内存两次可能导致程序异常终止。
解决类设计中这种问题的方法是进行深度复制(deep copy)。复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。
必须定义复制构造函数的原因在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。
如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针。这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
12.1.4 StringBad的其他问题:赋值运算符
Class_name& Class_name::operator =(const Class_name&);
1.赋值运算符的功能以及何时使用它
将已有的对象赋给另一个对象时,将使用重载的赋值运算符:
- StringBad headline(...);
- StringBad knot;
- knot = headline; // 赋值运算符
-
- StringBad knot1 = headline; // 复制构造函数
初始化对象时,并不一定调用赋值运算符。knot1初始化的实现可能分两步来实现:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。
与复制构造函数类似,赋值运算符的隐式实现也对成员进行逐个复制。
2. 赋值问题的出在哪里
问题与隐式复制构造函数相同:数据受损。
3. 解决赋值的问题
解决办法是提供赋值运算符(进行深度复制)定义,其实现与复制构造函数类似。但也有一些区别:
由于目标对象可能引用了以前分配的数据,所以函数应该先使用delete来释放这些数据;
函数应当避免将对象赋给自身;否则,给对象重新赋值,释放内存操作可能删除对象的内容。
函数应当返回一个指向调用对象的引用。
通过返回一个对象,函数可以像常规赋值操作那样,进行连续的赋值。
- StringBad& StringBad::operator =(const StringBad& st)
- {
- if (this == &st)
- return *this;
- delete[] str;
- len = str.len;
- str = new char[len + 1];
- std::strcpy(str, st.str);
- return *this;
- }
如果不是自我赋值,在不首先使用delete运算符,则上述字符串将保留在内存中。由于程序中不在包含该字符串的指针,因此这些内存将被浪费掉。
12.2 改进后的String类
12.2.1 修订后的默认构造函数
- String::String()
- {
- len = 0;
- str = new char[1];
- str[0] = ' ';
- }
您可能会问,会什么使用new char[1], 而不是new char? 上面两种方式分配的内存量相同,区别在于前者与类析构函数兼容,而后者不兼容。析构函数中包含以下代码delete[] str。
C++11 引入了新关键字nullptr,用于表示空指针。
12.2.2 比较成员函数
要实现字符串比较函数,最简单的方法是使用保准的strcmp()函数,如果依照字母顺序,第一个参数位于第二个参数之前,则该函数返回一个负值;如果两个字符串相同,则返回0;如果第一个参数位于之后,则返回一个正值。
- bool operator <(const String& st1, const String& st1)
- {
- return std::strcmp(st1.str, st2.str) < 0;
-
- }
12.2.3 使用中括号表示法访问
对于中括号运算符,一个操作数位于第一个中括号前面,另一个操作数位于两个中括号之间。
- const char& String::operator [](int i) cosnt
- return str[i];
在重载时,C++将区分常量和非常量函数的特征标。
12.2.4 静态类成员函数
可以将成员函数声明为静态(函数声明必须包括关键字static,但如果函数定义是独立的,则其定义不需包含static关键字)。
静态类成员函数有两个特点:首先,不能通过对象调用静态类成员函数;实际上,静态类成员函数不能使用this指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。例如
- // 在类中声明静态类成员函数HowMany
- static int HowMany() {return num_strings;}
-
- // 调用它的方式如下
- int count = String::HowMant();
由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。例如,静态类成员函数能访问静态类成员num_strings,但不能访问str和len。
12.3 在构造函数中使用new时应注意的事项
使用new初始化对象的指针成员时必须特别小心,具体地说:
如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete。
new和delete必须相互兼容,new对应delete,new[]对应delete[]。
如果有多个构造函数,则必须以相同地方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有构造函数都必须与它们兼容。
应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。具体地说,复制构造函数应该分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受到影响的静态类成员。
应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。具体地说,应检查自我赋值情况。
12.4 有关返回对象的说明
当成员函数或独立的函数返回对象时,有几种返回方式可供选择。可以返回指向对象的引用、指向对象的const引用或const对象。
12.4.1 返回值const对象的引用
如果函数返回(通过调用对象的方法或将对象作为参数)传递给它的对象,可以通过返回引用来提高其效率。
- Vector force1(20, 30);
- Vector force2(10, 20);
- Vector max;
- max = Max();
-
- const Vector& Max(const Vector& v1, const Vector& v2)
- {
- if(v1.magva;() > v2.magva())
- return v1;
- else
- return v2;
- }
这里有三点需要说明:首先,返回对象将调用复制构造函数,而返回引用不会。其次,引用指向的对象应该在调用函数执行时存在。最后,v1与v2都被声明为const引用,因此返回类型必须为const。
12.4.2 返回指向非const对象的引用
两种常见的返回非const对象的情形是,重载赋值运算符=以及重载与cout一起使用的<<运算符。前者是为了提高效率,而后者是必须这样做。因为使用返回类型ostream,将调用ostream类的赋值构造函数,而ostream没有公有的复制构造函数。
12.4.3 返回对象
如果返回对象是被调用函数的局部变量,则不应按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。因此,当控制权回到调用函数时,引用指向的对象将不再存在。通常,被重载的算术运算符属于此类。在这情况下,存在调用复制构造函数创建被返回的对象的开销,然而,这是无法避免的。
12.4.4 返回const对象
避免程序员的某些误操作,将返回类型声明为const对象。
总之,如果方法或函数返回局部对象,则应返回对象,而不是指向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对象。如果方法要返回一个没有公有复制构造函数的类的对象时,它必须返回一个指向这种对象的引用、最后,若即可返回对象or指向对象的引用时,优先返回引用。
12.5 使用指向对象的指针
通常,如果Class_name是类,value的类型为Type_name。则下面的语句:
Class_name* pclass = new Class_name(value);
将调用如下构造函数Class_name(Type_name);这里可能还有一些琐碎的转换,例如:
Class_name(const Type_name&)。另外,如果不存在二义性,则将发生由原型匹配导致的转换。
12.5.1 在谈new和delete
在以下情况下,析构函数将被调用:
如果对象是动态变量,则当执行完定义该对象的程序块时,将调用该对象的析构函数。
如果对象时静态变量(外部、静态、静态外部或来自名称空间),则在程序结束时将调用对象的析构函数、
如果对象是用new创建的,则仅当您显式使用delete删除对象时,其析构函数才会被调用。
15.5.2 指针和对象小结
使用对象指针时,需要注意几点:
- // 使用常规表示法声明指向对象的指针
- String* glamour;
-
- // 可以将指针指向已有的对象
- String* first = &sayings[0];
-
- // 可以使用new来初始化指针
- String* favorite = new String(sayings[chioce]);
-
- // 使用默认构造函数
- String* gleep = new String;
-
- // 使用复制构造函数
- String* glop = new String("my");
-
- // 使用->运算符通过指针访问类方法
- shorter->length();
12.5.3 再谈定位new运算符
- // 下面的代码有问题!!!!
- JustTesting*, pc1, * pc2, * pc3, * pc4;
- pc1 = new (buffer) JsutTesting;
- pc2 = new JustTesting("Heap1", 20);
- pc3 = new (buffer) JustTesting("Bad Idea", 6);
- pc4 = new JustTesting("Heap2", 10);
使用定位new运算符存在两个问题。首先,在创建第二个对象时,定位new运算符使用一个新对象来覆盖第一个对象的内存单元。显然,如果类动态地为其成员分配内存,这将引发问题。其次,及那个delete应用于pc2和pc4时,将自动调用pc2和pc4指向的对象调用析构函数;然而,将delete[]应用于buffer时,不会为使用new运算符创建的对象调用析构函数。
程序员必须负责管用定位new运算符用从中使用的缓冲区内存单元。要使用不同的内存单元,程序员需要提供两个位于缓冲区的不同地址,并确保这两个内存单元不重叠。例如,可以这样做;
- pc1 = new (buffer) JustTesing;
- pc3 = new (buffer + sizeof(JustTesing)) JustTesing("Better Idera", 6);
第二个教训是,如果使用定位new运算符来为对象分配内存,必须确保其析构函数被调用。对于堆中的数据:可以按以下方式处理:
delete pc2;
delete不能与定位new匹配。这种解决方案是显式地为使用定位new运算符创建的对象调用析构函数。显式地调用析构函数时,必须指定要销毁的对象。
- delete pc1; // No!
- delete pc3; // No!
-
- pc1->~JustTesting(); // OK!
- pc3->~JustTesting(); // OK!
-
- delete[] buffer;
对于使用定位运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区。
12.6 复习各种技术
12.6.1 重载<<运算符
请定义下面的友元运算符函数:
- ostream& operator<<(ostreeam& os, const c_name& obj)
- {
- os << ...
- return os;
- }
12.6.2 转换函数
要将单个值转换为类类型,需要创建原型如下的类构造函数:
c_name(type_name value);
其中c_name是类名,type_name是要转换的类型的名称。
要将类转换为其他类型,需要创建原型如下的类成员函数:
operator type_name();
12.6.3 其构造函数使用new的类
对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete。
如果析构函数通过对指针类成员使用delete来释放内存,则没够构造函数都应当使用new来初始化指针,或将其设置为空指针。
构造函数要么使用new[], 要么使用new,而不能混用。因为析构函数只有一个。
应定义一个分配内存(而不是将指针指向已有内存)的复制构造函数。这样程序将类对象初始化为另一个类对象。其函数原型如下:
className(const className&);
应定义一个重载赋值运算符的类成员函数吗其定义如下:
- c_name& c_name::operator =(const c_name& cn)
- {
- if (this == &cn)
- return *this;
- delete[] c_pointer;
- // calculate size
- c_pointer = new type_name[size];
- // copy cn.data to c_pointer point to data
- return *this;
- }
12.7 设计类
先列出类的特征;
设计好数据的表示方法后,编写类方法;
确定接口后,实现它;
在类中声明的结构,类或枚举量是被嵌套在类中,其作用域为整个类。这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。如果声明是在类的私有部分进行的,则只能在这个类使用被声明的类型;如果是在公有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类型。
构造函数首先给成员数据分配内存。然后,程序流程进入括号中,使用常规的赋值方法将值存储到内存中。但对于const成员数据而言,必须在执行到构造函数体之前,即创建对象时初始化。C++提供了成员初始化列表。成员初始化列表由逗号分隔的初始化列表组成,前面带冒号。通常,初值可以是常量或构造函数的参数列表中的值。
只有构造函数可以使用这种初始化列表语法。对于const类成员和呗声明为引用的类成员,必须使用初始化列表语法。因为const数据和引用都必须在创建时初始化。
- Classy::Classy(int n, int m) : mem1(n), mem2(0), mem3(n * m + 2)
- {
- ...
- }
C++11 类内初始化:
- class Classy
- {
- int mem1 = 10;
- const int mem2 = 20;
- ...
- }
在某些情况下,将构造函数声明为私有。
- class ClassName
- {
- private:
- ClassName();
- ...
-
- }
这样做有两个作用。第一,它避免了本来将自动生成的默认方法定义。第二,因为方法是私有的,所以不能被广泛使用。另外,在定义其对象不允许被复制的类时,也能用的上。
C++11提供了另一个禁用方法的方式--使用delete关键字。
12.8 总结
在类构造函数中,可以使用new为数据分配内存,然后将内存地址赋给类成员。
如果对象包含指针,同时它指向的内存是由new分配的,则释放用于保存对象的内存并不会自动释放对象成员指针指向的内存。因此,在类构造函数使用new来分配内存时,应在类析构函数中使用delete来释放分配的内存。这样,当对象过期时,将自动释放其指针成员指向的内存。
在默认情况下,C++逐个对成员进行初始化和赋值,这意味着被初始化或被赋值的对象的成员将于原始对象完全相同。即两个指针指向同一块内存。当对象都过期后,类的析构函数试图删除同一块内存两次,这是错误的。解决方法是:定义一个特殊的复制构造函数来重新定义初始化,并重载赋值运算符。
对象的存储持续性为自动或外部时,在它不在存在时将自动调用其析构函数。mew和delete应该匹配使用。
C++允许在类中包含结构、类和枚举定义。这些嵌套类型的作用域是整个类。
C++为构造函数提供初始化成员列表。这种语法包括冒号和逗号分隔的初始化列表,被放在构造函数参数的右括号边,函数体的左括号之前。如果数据成员是非静态const成员或引用,则必须使用初始化成员列表方式。C++11 允许新增的类内初始化用于非静态const类型。