导图社区 类和动态内存分配
介绍类的注意事项,主要包括类与内存动态分配问题。同时梳理了一些前面的技术,巩固学习。
编辑于2021-01-02 19:38:55第12章 类和动态内存分配
12.1 动态内存和类
12.1.1 复习示例和静态类成员
1. 示例
"strngbad.h" #include<iostream> #ifndef STRNGBAD_H #define STRNGBAD_H class StringBad { private: char* str; int len; static int num_strings; public: StringBad(); StringBad(const char* s); ~StringBad(); // firend function friend std::ostream& operator<<(std::ostream& os, const StringBad& st); }; #endif
vegnews.cpp #include<iostream> using std::cout; #include"strngbad.h" void callme1(StringBad&); // pass by reference void callme2(StringBad); // pass by value int main() { ... StringBad headline1, headline2, sports; ... StringBad sailor = sports; ... } void callme1(StringBad&) { ... } void callme2(StringBad) { ... }
strngbad.cpp #include<cstring> #include"strngbad.h" using std::cout; int StringBad::num_strings = 0; StringBad::StringBad() { len = 4; str = new char[4]; std::strcpy(str, "C++"); num_strings++; cout << num_strings << ": \"" << str << "\" default object created\n"; } StringBad::StringBad(const char* s) { len = std::strlen(s); str = new char[len + 1]; std::strcpy(str, s); num_strings++; cout << num_strings << ": \"" << str << "\" object created\n"; } StringBad::~StringBad() { cout <<"\"" << str << "\" object deleted, " --num_strings; cout << num_strings << " left\n"; delete[] str; } std::ostream& operator<<(std::ostream& os, const StringBad& st) { os << st.str; return os; }
2. 注意
(1)StringBad类声明使用char指针(而不是char数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声明中预先定义字符串的长度
(2)num_strings成员声明为静态储存类。意味着无论创建了多少对象,程序都只创建一个静态变量num_strings副本,所有的类成员共享这一个静态类成员。
(3)字符串并不保存在对象中。字符串单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息
3. 静态成员
(1)不能在类声明中初始化静态成员变量。因为类声明描述了如何分配内存,但并不分配内存。
(2)可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。
(3)但如果静态成员是const整数类型或枚举型,则可以在类声明中初始化
4. 问题引出
callme2(headline2); cout << "headline2: " << headline2 << endl; 这里,callme2()按值传递(而不是引用)headline2,结果表明是个很严重的问题! String passed by value: "Lettuce Prey" "Lettuce Prey" object deleted, 2 left headline2: Dû°
5. 解释
首先,将headline2作为函数参数来传递从而导致析构函数被调用。其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符。 因为是按值传递,所以在传递给函数对象时,程序会先对headline2进行复制一遍,但是由于没有提供复制构造函数,所以编译器会自动生成复制构造函数——这就乱套了
再请看以下代码: StringBad sailor = sports; 这使用的是那个构造函数呢?不是默认构造函数也不是参数为const char*的构造函数。记住,这种形式的初始化等效于下面的语句: StringBad sailor = StringBad(sports); 因为sports的类型为StringBad,因此相应的构造函数原型应该如下: StringBad(const StringBad&);
正是因为程序会自动生成复制构造函数,而这个自动生成的复制构造函数并不知道需要具体按照我们的意愿做什么,因此会造成这样的错误
12.1.2 特殊成员函数
概述
C++会自动提供下面这些成员函数
· 默认构造函数
· 默认析构函数
· 复制构造函数
· 赋值运算符
· 地址运算符
简单的特殊成员函数讨论
· 隐式地址运算符
返回调用对象的地址(即this指针的值)
· 默认析构函数
啥也不做
1. 默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。例如,假如定义了一个Klunk类,但没有提供任何构造函数,则编译器将提供下述默认构造函数: Klunk::Klunk() { } // implicit default constructor 也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用构造函数: Klunk lunk; // invokes default constructor
默认构造函数使lunk类似于一个常规的自动变量,也就是说,它的值在初始化时是未知的
2. 复制构造函数
它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。它的原型如下: Class_name(const Class_name&); 它接受一个指向类对象的常量引用作为参数。例如,StringBad类的复制构造函数原型如下: StringBad(const StringBad&);
3. 何时调用复制构造函数
· 新建一个对象并将其初始化为同类现有对象时
StringBad ditto(motto); StringBad metoo = motto; StringBad also = StringBad(motto); StringBad* pStringBad = new StringBad(motto);
· 每当程序生成了对象副本时
callme2(headline2);
4. 默认的复制构造函数的功能
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值
12.1.3 回到Stringbad:复制构造函数的哪里出了问题
1. 问题
(1)编译器提供的默认复制构造函数没有更新静态成员num_strings
(2)由于默认复制构造函数是按值复制,因此字符串指针str的指向内容没有被复制
2. 解决方法
定义一个显式复制构造函数: StringBad::StringBad(const StringBad& st) { num_strings++; len = st.len; str = new char[len + 1]; std::strcpy(str, st.str); cout << num_strings << ": \"" << str << "\" object created\n"; }
3. 注意事项
如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制
12.1.4 Stringbad的其他问题:赋值运算符
概述
赋值运算符的原型如下: Class_name& Class_name::operator=(const Class_name&);
1. 赋值运算符的功能以及如何使用
将已有的对象赋给另一个对象时,将使用重载的赋值运算符
初始化对象时,不一定会使用赋值运算符: StringBad metoo = knot; // use copy constructor, possibly assignment, too 这里,metoo是一个新创建的对象,被初始化为knot的值,因此使用复制构造函数
初始化总是会调用复制构造函数,而使用=运算符时也允许调用赋值运算符
2. 赋值的问题出在哪里
与上述隐式复制构造函数的问题相同
3. 解决赋值的问题
(1)解决方法
提供赋值运算符(深度复制)的定义
(2)与复制构造函数的差别
· 由于目标对象可能引用了以前分配的数据,所以函数应使用delete[]来释放这些数据
· 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容
· 函数返回一个指向调用对象的引用,这样可以像常规变量一样进行连续赋值
(3)修改后的赋值运算符定义
StringBad& StringBad::operator=(const StringBad& st) { if (this == &st) return *this; delete[] str; len = st.len; str = new char[len + 1]; std::strcpy(str, st.str); return *this; }
12.2 改进后的新String类
概述
添加一些新功能
int lenth() const { return len; } friend bool operator<(const String& st1, const String& st2); friend bool operator>(const String& st1, const String& st2); friend bool operator==(const String& st1, const String& st2); friend operator>>(istream& is, String& st); char& operator[](int i); const char& operator[](int i) const; static int HowMany();
12.2.1 修订后的默认构造函数
1. 新的默认构造函数
String::String() { len = 0; str = new char[1]; str[0] = '\0'; }
2. 为何不用str = new char?
使用下述代码: str = new char; 从函数角度来说没有问题,但是会与后面的析构函数不兼容: delete[] str;
3. C++空指针
在C++98中,字面值0有两个含义:可以表示数字0,也可以表示空指针。但这使得阅读程序的人和编译器难以区分。有些程序员使用(void*)0来表示空指针(空指针本身的内部表示可能不是0),还有一些程序员使用C语言中的宏:NULL来表示。
C++11提供了关键字:nullptr,用于表示空指针: str = nullptr; // C++ null pointer notation
12.2.2 比较成员函数
1. 成员函数的定义
bool operator<(const String& st1, const String& st2) { return (std::strcmp(st1.str, str2.str) < 0); }
bool operator>(const String& st1, const String& st2) { return (str2 < str1); }
bool operator==(const String& st1, const String& st2) { return (std::strcmp(st1.str, str2.str) == 0); }
2. 将比较函数作为友元函数,有助于将String对象与常规的C字符串进行比较
12.2.3 使用中括号表示法访问字符
1. 成员函数的定义
char& String::operator[](int i) { return str[i]; }
const char& String::operator[](int i) const { return str[i]; }
2. 说明
假设有下面的常量对象: const String answer("futile"); 如果只有上述operator[]()定义,则下面的代码将出错: cout << answer[1]; 原因是answer是常量,上述方法无法保证不修改数据。(实际上,有时该方法的工作就是修改数据)。但在重载时,C++将区分常量和非常量函数的特征标,因此可以提供另一个仅供const String对象使用的operator[]()版本。
12.2.4 静态类成员函数
1. 不能通过对象调用静态成员函数
实际上,静态成员函数都不能使用this指针。如果静态成员函数是在共有部分声明的,则可以使用类名和作用域解析符来调用它。例如: int count = String::HowMany();
2. 静态成员函数只能使用静态成员数据
因为静态成员函数不与特定的对象关联,所以无法访问类的成员数据
12.2.5 进一步重载赋值运算符
String& String::operator=(const char* s) { delete[] str; len = std::strlen(s); str = new char[len + 1]; std::strcpy(ste, s); return *this; } istream& operator>>(istream& is, String& st) { char temp[100]; is.get(temp, 100); if (is) st = temp; while (is && is.get() != '\n') continue; return is; }
12.3 在构造函数中使用new时应注意的事项
概述
· 如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete
· new和delete必须相互兼容。new对应于delete,new[]对应于delete[]
· 如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带,因为只有一个析构函数,所有的构造函数都要与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空(0或C++中的nullptr)。这是因为delete(无论是带中括号还是不带中括号)可以用于空指针
· 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。
· 应当定义一个复制运算符,通过深度复制将一个对象赋值给另一个对象
12.3.1 应该和不应该
1. 示例
String::String() { str = "default string"; // wrong len = std::strlen(str); } String::String(const char* s) { len = std::strlen(s); str = new char; // wrong std::strcpy(str, s); } String::String(const String& st) { len = st.len; str = new char[len + 1]; std::strcpy(str, st.str); }
2. 修改
(1)第一个构造函数没有使用new来初始化str,可用以下这些来替换: String::String() { len = 0; str = new char[1]; str[0] = '\0'; } String::String() { len = str =0; // or str = nullptr; } String::String() { static const char* s = "C++"; len = std::strlen(s); str = new char[len + 1]; std::strcpy(str, s); }
(2)第二个构造函数的new没有带中括号,与第三个构造函数的格式不一致
(3)第三个构造函数是正确的
12.3.2 包含类成员的类的逐成员复制
假设类成员的类型为String类或标准string类: class Magazine { private: String title; string publisher; ... } String和string都使用动态内存分配,这是否意味着要为Magazine类编写复制构造函数和赋值运算符?不,至少对这个类本身来说不需要。默认的逐成员复制和赋值行为有一定的智能。复制title成员时,将使用String类的复制构造函数,而将 title赋给另一个Magazine对象时,将使用String的赋值运算符,以此类推。 然而,如果Magazine因其他成员需要定义复制构造函数和赋值运算符时,情况将更加复杂;在这种情况下,这些函数必须显式地调用String和string的复制构造函数和赋值运算符。
12.7 队列模拟
12.7.1 队列类
1. 嵌套结构和类
· 如果声明是在类的私有部分进行的,则只能在这个类中使用被声明的类型
· 如果声明是在类的共有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类。 例如,如果Node是在Queue类的共有部分声明的,则可以在类的外面声明Queue::Node类型的变量
2. 成员初始化列表的语法
如果Classy是一个类,而mem1、men2和mem3都是这个类的数据成员,则类构造函数可以使用如下的语法来初始化数据成员: Classy::Classy(int n, int m) :mem1(n), mem2(0), mem3(n*m + 2) { // ... } 这些初始化工作时在对象创建时完成的,此时还未执行括号中的任何代码。
注意: · 这种格式只能用于构造函数 · 必须用这种格式来初始化非静态const数据成员(至少在C++11之前是这样的) · 必须用这种格式来初始化引用数据成员(引用于const数据类似,只能在被创建时初始化)
3. C++11的内类初始化
C++11允许以更直观的方式进行初始化: class Classy { int mem1 = 10; // in-class initialization const int mem2 = 20; // in-class initialization // ... }; 这与在构造函数中使用成员初始化列表等价: Classy::Classy() :men1(10), mem2(20) {...} 成员mem1和mem2将分别被初始化为10和20,除非调用了使用成员初始化列表的构造函数,在这种情况下,实际列表将覆盖这些默认初始值: Classy::Classy(int n) :men1(n) {...} 在这里,构造函数将使用n来初始化mem1,但mem2仍被设置为20
4. 避免复制构造函数和赋值运算符的未定义出错
(1)方法
将所需的方法定义为私有方法: class Queue { private: Queue(const Queue& q) : qsize(0) { } // preemptive definition Queue& operator=(const Queue& q) { return *this; } // ... };
(2)作用
· 避免了本来将自动生成的默认方法定义
· 因为这些方法是私有的,所以不能被广泛应用
12.7.2 Customer类(*)
12.7.3 ATM模拟(*)
12.6 复习各种技术
12.6.1 重载<<运算符
ostream& operator<<(ostream& os, const c_name& obj) { os << ... ; // display object contents return os; } 其中c_name是类名。
12.6.2 转换函数
1. 单个值 -> 类类型
c_name(type_name value); 其中c_name是类名,type_name是要转换的类型名称
2. 类 -> 其他类型
operator type_name(); 该函数没有声明返回类型,但应返回所需类型的值
3. 可以用关键字explicit,以防止隐式转换
12.6.3 其构造函数使用new的类
1. 对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete
2. 如果析构函数通过对指针类成员使用delete来释放内存,则每个构造函数都应当使用new来初始化指针,或将它设置为空指针(nullptr)
3. 构造函数中要么使用new[],要么使用new,而不能混用,应和析构函数的delete[]和delete相匹配
4. 应定义一个分配内存(而不是将指针指向已有内存)的复制构造函数,进行深度复制
5. 应定义一个重载赋值运算符的类成员函数,其函数定义如下: c_name& c_name::operator==(const c_name& cn) { if (this == &cn) return *this; delete[] c_pointer; c_pointer = new type_name[size]; ... return *this; }
12.5 使用指向对象的指针
12.5.1 再谈new和delete(*)
没啥好谈的
12.5.2 指针和对象小结
1. 使用常规表示法来声明指向对象的指针: String* glamour;
2. 可以将指针初始化为指向已有的对象: String* first = &sayings[0];
3. 可以使用new来初始化指针,这将创建一个新的对象: String* favorite = new String(sayings[choice]);
4. 对类使用new将调用相应的类构造函数来初始化新创建的对象: String* gleep = new String; String* gleep = new String("my my my"); String* favorite = new String(sayings[choice]);
5. 可以使用->运算符通过指针访问类方法: if (sayings[i].length() < shortest -> length()): ...
6. 可以对对象指针应用解除引用运算符(*)来获得对象: if (sayings[i] < *first) first = &sayings[i];
12.5.3 再谈定位new运算符
1. 示例
#include<iostream> #include<string> #include<new> using namespace std; const int BUF = 512; class JustTesting { private: ... public: ... }; int main() { char* buffer = new char[BUF]; JustTesting *pc1, *pc2; pc1 = new (buffer) JustTesting;// place object in buffer pc2 = new JustTesting("Heap1", 20); ... JustTesting *pc3, *pc4; pc3 = new (buffer) JustTesting("Bad idea", 6); pc4 = new JustTesting("Heap2", 20); ... }
2. 两个问题
(1)在创建pc3时,定位new运算符使用一个新对象来覆盖了pc1的内存单元。显然,如果类动态地为其成员分配内存,这将引发问题
(2)将delete用于pc2和pc4时,将自动为pc2和pc4指向的对象调用析构函数,然而,将delete[]用于buffer时,不会为使用定位new运算符创建的对象调用析构函数
3. 相关说明
· 程序员必须负责管用定位new运算符用从中使用的缓冲区内存单元。要使用不同的内存单元,程序员需要提供两个位于缓冲区的不同地址,并确保这两个内存单元不重叠
· 如果使用定位new运算符来为对象分配内存,必须确保其起构函数被调用。对于在堆中创建的对象,可以这样做: delete pc2; 但不能这样做: delete pc1; // NO! delete pc3; // NO! 原因在于delete可与常规new运算符配合使用,但不能与定位new运算符配合使用。例如,指针pc3没有收到new运算符返回的地址,因此delete pc3将导致运行阶段错误。在另一方面,指针pc1指向的地址与buffer相同,但是buffer是用new[]初始化的,因此必须使用delete[]而不是delete来释放。即使buffer是使用new初始化的,delete pc1也将释放buffer,而不是pc1。因为new/delete系统知道已分配的512字节块buffer,但对定位new运算符对该内存块做了何种处理一无所知
4. 解决方案
(1)将相应代码替换如下: pc1 = new (buffer) JustTesting; pc3 = new (buffer + sizeof(JustTesting)) JustTesting("Better idea", 6);
(2)显示调用析构函数,指定销毁的对象: pc3 -> ~JustTesting(); pc1 -> ~JustTesting(); delete[] buffet; 这里注意,使用定位new运算符创建的对象,应以与创建顺序相反的顺序进行删除。因为晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区
12.4 有关返回对象的说明
12.4.1 返回指向const对象的引用
1. 使用const引用的常见原因是旨在提高效率
2. 假设要编写Max(),它返回两个Vector对象中较大的一个,函数将以下面的方式被调用: Vector forcel1(50, 60); Vector forcel2(10, 70); Vector max; max = Max(forcel1, forcel2);
两种可行的实现: Vector Max(const Vector&v1, const Vector& v2) { if (v1.magval() > v2.madval()) return v1; else return v2; } const Vector& Max(const Vector&v1, const Vector& v2) { if (v1.magval() > v2.madval()) return v1; else return v2; }
3. 说明
(1)返回对象将调用复制构造函数,而返回引用不会
(2)引用指向的对象应该在调用函数执行时存在
(3)v1和v2都被声明为const引用,因此返回类型必须为const,这样才匹配
12.4.2 返回指向非const对象的引用
1. 重载赋值运算符
operator=()的返回值用于连续赋值: String s1("Good stuff"); String s2, s3; s3 = s2 = s1; 在上述代码中,s2.operator=()的返回值被赋给s3。为此,返回String对象或String对象的引用都是可以的,只是效率的问题。在这个例子中,返回类型不能是const,因为方法operator=()返回一个指向s2的引用,可以对其进行修改
2. 重载<<运算符
operator<<()的返回值用于接串输出: String s1("Good stuff"); cout << s1 << " is coming!"; 在上述代码中,operator<<(cout, s1)的返回值成为一个用于显示字符串" is coming!"的对象。返回类型必须是ostream&,而不能仅仅是ostream。因为ostream没有公有的复制构造函数。
12.4.3 返回对象
通常,被重载的算术运算符的返回类型不能是引用。因为这类函数通常需要在函数内部创建一个新的对象来进行返回。而当我们引用这个对象时,由于其作用域为函数代码块,因此在返回时该对象就不再存在。因此,在这种情况下,我们选择调用复制构造函数来创建被返回对象的副本,这种开销是不可避免的
12.4.4 返回const对象
1. 问题提出
我们发现,下面的代码也是可行的: net = force1 + force2; // 1: three Vector objects force1 + force2 = net; // 2: dyslectic programming cout << (force1 + force2 = net).magval() << endl; // 3
这种代码之所以可行,是因为复制构造函数将创建一个临时对象来表示返回值。因此,在前面的代码中,表达式force1 + force2的结果为一个临时对象。在语句1中,该临时对象的值被赋给net;在语句2和语句3中,net被赋给该临时对象;最后,使用完之后,又把临时对象给丢弃,这显然是没有意义的
2. 解决方案
为了防止这样没有意义的代码通过编译,我们可以将返回类型声明为const Vector。