类的动态内存分配和释放
C++能够在程序运行时决定内存的分配,而不是只在编译阶段,因此,就可以根据程序的需要,而不是根据一系列严格的存储类型规则来使用内存,C++使用new和delete运算符来动态控制内存,但是,在类中使用这些运算符会导致许多新的问题,在这种情况下,析构函数就是必不可少的,下面我们通过一个小程序来看一看这些问题,这个程序是在类中使用new和delete来存储释放字符串
我们将函数声明写在一个头文件,即stringbad.h
在头文件里定义了一个类,名为Stringbad
cpp#ifndef STRINGBAD_ #define STRINGBAD_ #include<iostream> using namespace std; class Stringbad { private: char *str;//字符串地址 int len; //长度 static int num_strings; //字符串个数 public: Stringbad(const char *s); Stringbad(); ~Stringbad(); friend ostream & operator<<(ostream &os, Stringbad & t); }; #endif ~
在类函数定义文件中,即stringbad.cpp
cpp#include"stringbad.h" #include<cstring> int Stringbad::num_strings = 0; Stringbad::Stringbad(const char *s) { len = strlen(s); str = new char[len+1]; strcpy(str,s); num_strings++; cout<<" "<<num_strings<<": \""<<str<<".\""<<endl; } Stringbad::Stringbad() { len = 4 ; str = new char[4]; strcpy(str,"C++"); num_strings++; } Stringbad::~Stringbad() { cout<<" \""<<str<<"\"object deleted."<<endl; --num_strings; cout<<" "<<num_strings<<" left. "<<endl; delete [] str; } ostream &operator<<(ostream & os, Stringbad & t) { os<<t.str; return os; }
注意:
在我们stringbad.h文件中,我们有一个静态变量num_strings用来存储字符串的个数,但是我们确是在stringbad,cpp文件中才对这个变量进行了初始化,这是因为不能在类声明中初始化静态成员变量,因为声明描述的只是如何分配内存,但是实际上并没有进行分配内存,我们可以使用这种格式来创造对象,从而分配和初始化内存
但是对于静态的类成员,我们可以在类声明之外使用单独的语句来进行初始化,因为静态类成员是单独存储的,不是类对象的组成部分,所有的类对象共用一个num_strings,并且,在我们stringbad,cpp文件中的初始化,并没有使用static关键字,只是指出了类型及使用了作用域解析运算符
初始化是在类方法的文件中,而不是在类声明的文件里,因为类声明位于stringbad.h头文件中,程序可能将头文件包含在其他几个文件里,如果在头文件中对其进行初始化,可能会出现多个初始化的副本,从而引发错误
我们在主函数文件main.cpp中,测试函数传递类的引用以及类的值
cpp#include"stringbad.h" #include<iostream> void callme1(Stringbad & st); void callme2(Stringbad st); int main(void) { Stringbad a1("Hello world"); Stringbad a2("Good morning"); Stringbad a3; cout<<a1<<a2<<a3; callme1(a1); callme2(a2); } void callme1(Stringbad & st) { cout<<"String passed by reference: "<<st<<endl; } void callme2(Stringbad st) { cout<<"String passed by value: "<<st<<endl; }
当我们只调用callme1时,即按引用传递
程序能够正常运行
但是,当我们调用按值传递时,程序却出现了错误
此时却出现了问题,在这次运行中,我们同时调用了callme1和callme2函数,即按引用传递类和直接传递类来进行输出,我们按引用进行传递时,并没有发生任何的问题,但是按值进行传递时,却出现了问题,在我们调用callme2时,我们将a2传递给了我们的callme2,在我们callme2中就使用了形参st去接收它,这就出现了我们类进行赋值的一个操作,这就是相当于是
cppStringbad st = a2; //又等价于 Stringbad st = Stringbad(a2);
两者都是类的对象,这时候不知道会去调用哪个构造函数,因为我们只有一个默认构造函数以及一个参数为const char*的构造函数,而并没有参数是类的对象的构造函数,这时候我们使用一个类的对象去初始化另一个类的对象时,编译器就会生成一个复制的构造函数
cppStringbad(const Stringbad &);
因为我们使用了编译器自己创建的默认构造函数后,也会去调用析构函数,而我们的析构函数中会使用delete去释放内存,我们自己创建了3个类的对象,因此调用了3次构造函数(其中一次是默认构造函数),而我们进行值传递时,又调用了一次编译器创建的构造函数,但是,我们只有前面3次使用new来创建内存空间,而进行释放时会调用4次析构函数,即会使用4次delete来释放空间,这就导致了段错误,并且自动生成的复制构造函数并不会去更新num_strings,因此会搞乱计数方案
特殊成员函数
复制构造函数
我们上面所说的问题其实就是由特殊的成员函数引起的,这些成员函数都是编译器进行自动定义的,就Stringbad而言,这些函数的行为与我们类的设计不符,C++会自动提供下列的成员函数
1.默认构造函数,如果没有定义构造函数
2.默认析构函数,如果没有定义析构函数
3.复制构造函数
4.赋值运算符
5.地址运算符,隐式地址运算符返回调用对象的地址(this指针的值)
更准确的说,如果程序使用对象的方式要求我们这样做,编译器就是生成上述最后三个函数的定义,例如,如果我们要将一个对象赋值给另一个对象,编译器会提供赋值运算符的定义,可以看出Stringbad类中的问题是由隐式复制构造函数和隐式的赋值运算符引起的,因此,我们可以自己提供一个参数符合的复制构造函数,就不会出现这样的问题,也可以对赋值运算符进行显示的重载,防止其中的某个部分不能使用
在我们stringbad.h和stringbad.cpp中分别添加复制构造函数和赋值运算符重载的声明和定义
在我们进行自己添加复制构造函数和赋值运算符重载之后,编译器就不会再进行自动生成,这样我们在进行按值传递时结果也就不会出错,结果如下
对于复制构造函数的说明:
复制构造函数:
复制构造函数用于将一个对象复制到新创建的对象中,即用于初始化过程中,包括按值传递参数过程中,而不是常规的赋值过程中,原型通常如下
class_name(const class_name & ); //类名(const 类名 &);
复制构造函数接收一个指向类对象的常量作为参数
复制构造函数的调用时间:
即每当生成了对象的副本,编译器都会调用复制构造函数,即当函数按值传递参数时或者函数返回对象时,都会使用复制构造函数
由于按值传递对象会调用复制构造函数,这样会浪费调用构造函数的时间已经存储新对象的空间,因此应该按引用来传递
赋值运算符
对于赋值运算符重载的说明
在上面复制构造函数的时候我们说过,我们将一个类的对象,传递给我们的st
cppStringbad st = a2; //又等价于 Stringbad st = Stringbad(a2);
在这里可以理解为先使用复制构造函数创建了一个临时的对象,又将临时的对象通过赋值运算符再传递给我们的st,而赋值运算符C++能够自动给我们提供,但是可能会潜在出现问题,为了防止这些问题的发生,我们就需要在原有代码上加上重载的赋值运算符
设计的思路:
1.我们在编写代码时,可能会出现连续的赋值操作,例如 a1 = a2 = a3,因此,在这里,我们需要将函数的返回值也设置为一个类的对象的引用,而我们想要实现赋值操作,左侧为类的对象的引用,右侧也应该为类的对象的引用,因此参数就应该为一个类的对象的引用
2.在我们上述代码中,Stringbad st = Stringbad(a2),我们也可以理解为我们先创建了一个对象st,因此肯定会调用构造函数,而在我们上面所写的构造函数中,我们都使用new来分配了内存空间,因此在对象定义时就已经为其分配了内存空间,而我们想将我们产生的临时的类的对象通过重载的赋值运算符让该对象里的str指向临时的类的对象,因此我们在实现重载赋值运算符时,我们要先将我们产生的默认构造函数分配的空间进行释放,之后再计算str的长度来重新开辟内存空间并让str指向它,再将临时的类的对象拷贝到我们str中,最后返回调用对象的引用
注意:在这种情况下,我们不能传递自身,因为我们先使用了delete,为了防止这个问题,我们需要加上判断条件
应用实例
我们可以根据上面的知识将我们的所写的代码进行改进补充,重新体会类的各种用法,并完成一些字符串的基本操作
头文件string.h
cpp#include<iostream> using namespace std; class String { private: char *str; int len; static int num_strings; public: static const int CINLIM = 80; String(const char *s); String(); String(const String & s); ~String(); String & operator=(const String & t); String & operator=(const char * t); const char & operator[](int i) const ; int lengh() const //计算长度 { return len; } friend bool operator<(const String &str1, const String &str2); friend bool operator>(const String &str1, const String &str2); friend bool operator==(const String &str1,const String &str2); friend ostream & operator<<(ostream &os, String & t); friend istream & operator>>(istream &os, String &st); //计算对象个数 static int howmany(); }; #endif ~
函数定义文件string.cpp
cpp#include"string.h" #include<cstring> int String::num_strings = 0; int String::howmany() { return num_strings; } String::String(const char *s) { len = strlen(s); str = new char[len+1]; strcpy(str,s); num_strings++; } String::String() { len = 0 ; str = new char[1]; str[0] = '\0'; num_strings++; } String::String(const String & s) { len = s.len; str = new char[len + 1]; strcpy(str,s.str); num_strings++; } String & String::operator=(const String & t) { //防止自身传递 if(this == &t) return *this; delete [] str; len = t.len; str = new char[len + 1]; strcpy(str,t.str); return *this; } String & String::operator=(const char *st) { delete [] str; len = strlen(st); str = new char[len+1]; strcpy(str,st); return *this; } const char & String::operator[](int i)const { return str[i]; } String::~String() { --num_strings; delete [] str; } ostream &operator<<(ostream & os, String & t) { os<<t.str; return os; } bool operator<(const String &str1, const String &str2) { return (strcmp(str1.str, str2.str)<0); } bool operator>(const String &str1, const String &str2) { return str2 < str1; } bool operator==(const String &str1, const String &str2) { return (strcmp(str1.str, str2.str) == 0 ); } istream & operator>>(istream &is, String &str1) { char temp[String::CINLIM]; is.get(temp,String::CINLIM); if(is) str1 = temp; while(is && is.get() != '\n') continue; return is; }
测试类的文件main.cpp
cpp#include"string.h" #include<iostream> const int MAXLEN = 81; void callme1(String & st); void callme2(String st); int main(void) { cout<<"what is your name ?\n"; String name; cin>>name; cout<<name<<", please enter a string: "<<endl; String saying; char temp[MAXLEN]; cin.get(temp,MAXLEN); while(cin && cin.get() != '\n') continue; saying = temp; cout<<"here is you saying : "<<endl; cout<<saying<<" "<<saying[0]<<endl; String str = "Hello dad"; if(saying.lengh() < str.lengh()) cout<<"str is longer \n"; else cout<<"saying long is longer\n"; if(saying > str) cout<<"saying\n"; else if(saying < str) cout<<"saying\n"; else cout<<"=\n"; cout<<String::howmany()<<endl; return 0; }
在构造函数使用new的注意事项
在构造函数使用new和delete注意事项如下:
1.如果我们在构造函数中使用了new,则在析构函数中应该使用delete
2.new和delete必须兼容,new对应于delete,而new[]对应于delete[]
3.如果有多个构造函数,则必须以相同的方式去使用new,要么都带中括号,要么都不带,因为只有一个析构函数,所以的构造函数都必须与它兼容,但是,我们可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空,因为delete可以用于空指针,无论delete带不带中括号
(C++可以使用0,NULL,还有C++11的关键字nullptr来代表空指针)
4.我们在构造函数中使用new运算符时,应该定义一个复制构造函数,将一个对象初始化为另一个对象,应该要分配足够的内存空间来存储复制的数据,并且复制数据不仅仅是复制地址,还应该更新所有受影响的静态类成员
5.应该定义一个复制运算符,通过深度复制将一个对象传递给另一个对象,这个应该检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不是数据的地址,并返回一个调用指针的引用
有关返回对象的说明
当成员函数或者独立函数返回对象时,有几种返回方式可以选择,即可以返回对象的引用,指向对象的const引用或者const对象
返回指向const对象的引用
1.使用const引用的常见原因是为了提高效率,如果函数返回(通过调用对象的方法或者将对象作为参数)传递给它的对象,可以通过返回其引用提供效率
2.如果我们返回的是类的对象,那么返回对象会调用复制构造函,而类的引用不会
3.引用指向的对象应该在调用函数执行时存在,即我们返回引用时,不能返回临时变量的引用,因为当函数调用结束后,临时变量已经不存在了,此时返回引用没有意义
4.如果我们的调用的参数为const,返回的是参数中的一个,则返回也应该是const
返回指向非const对象的引用
有两种返回非const对象的引用的情况比较常见,一种是重载赋值运算符,另一种是重载与cout一起使用的<<运算符,返回非const对象的引用即返回值是允许被修改的
返回对象
1.如果被返回的对象是被调用函数中的局部变量,那么不能使用引用的方式去返回它,因为在被调函数执行完毕后,局部的对象会调用析构函数,因此,当控制权回到调用函数时,引用指向的对象将不再存在,在这种情况下,就应该返回对象而不是引用
提示:当进行算术运算符重载时,最后返回的是类的对象
总结
1.如果方法或者函数要返回局部对象,则应该返回对象,而不是对象的引用,在这种情况下,将使用复制构造函数来生成返回的对象
2.如果方法或者函数要返回一个没有公有复制构造函数的类的对象,它必须返回一个指向这种对象的引用,例如ostream类
3.如果有方法或函数可以返回对象,也可以返回对象的引用,则应该返回引用,因为效率高
使用指向对象的指针
我们可以使用一个指针来指向一个类的对象,当我们使用对象指针时,需要注意以下几点:
1.使用常规表示法指向对象的指针
String *p;
2.可以将指针初始化为指向已有的对象
cppString *p = &saying; //saying是一个类的对象
3.使用new来初始化指针,会创建一个新的对象
cppString *p = new String; //在这里,定义一个指向类对象的指针,开辟了String类大小的内存空间 //此时还会去调用相应的构造函数,因为没有参数,所以就会调用默认构造函数 String *p = new String(saying); //saying为类对象,所以就会去调用参数为类对象的构造函数
4.对类使用new将会调用相应的类构造函数来初始化新创建的对象
cppString *p = new String("AAA"); //在这里,定义一个指向类对象的指针,开辟了String类大小的内存空间 //此时还会去调用相应的构造函数,即调用参数为字符串的构造函数
根据我们上面所写的代码,使用new来创建,分析过程如下
C++prime plus分析:
注意:如果我们使用new来创建类的对象时,会调用类构造函数,而当我们显示使用delete对我们开辟的存储类对象空间进行删除时,会去调用析构函数
定位new运算符
定位new运算符能够能够让我们在分配内存时指定内存的位置,但是,将定位new运算符用于类的情况却有所不同,我们下面通过一个测试的例子来看看
cpp#include<iostream> #include<string> #include<new> using namespace std; const int BUF = 512; class A { private: string words; int number; public: A(const string &str = "A", int n = 0) { words = str; number = n; cout<<words<<endl; } //测试析构函数是否被调用 ~A() { cout<<words<<" destroyed\n"; } void show() const { cout<<words<<" , "<<number<<endl; } }; int main() { //先进行了分配一个内存块 char *p = new char[BUF]; A *p1 = nullptr; A *p2 = nullptr; //一个类对象使用定位new运算符指定存储的的位置为p //调用默认构造函数 p1 = new(p) A; //一个类对象使用new运算符为类A开辟内存并进行初始化 p2 = new A("B",2); p1->show(); p2->show(); //让一个新的类对象也使用类定位运算符指向p A *p3 = new(p) A("C",6); A *p4 = new A("D",8); p3.show(); p4.show(); //测试例子 delete p1; delete p3; return 0; }
我们在这个程序中使用new定位运算符已经普通的new运算符来开辟内存空间,并且测试它们调用构造函数和析构函数的情况,运行代码结果如下:
我们可以看出,在这个例子中,我们的构造函数被调用了四次,而析构函数只被调用了一次,并且还出现了段错误,这个原因是什么呢?并且如何进行修改?
原因如下:
delete可以与常规的new运算符配合使用,但是不能与new运算符配合使用,因为常规new运算符开辟了内存空间,而定位new运算符是在别处开辟的内存空间
在这里例子里,我们使用了两次定位new运算符,并且让它们指向了同一个内存块(这样做不好),而在最后,我们对这个内存块进行了两次的delete,即我们释放了已经释放过的内存空间,我们在delete p1时,就已经释放了一次,而我们调用delete p2时,又释放了一次,因此就出现了段错误,并且,我们在分配内存空间时,我们使用的是new[],因此,我们释放内存空间时也需要对整个内存块进行释放
我们在使用类对象的指针时说过,当我们使用new去创建类的对象时,会去调用构造函数,只有当我们显式的去使用delete时,程序才会去调用析构函数,因为我们要修改后的代码为
cpp
delete []p;
delete p2;
delete p4;
程序运行后的结果为
此时我们可以看出,析构函数被调用了两次,即p2和p4调用了析构函数,因为delete [] p释放使用常规new运算符分配的整个内存块,但它并没有为定位new运算符在该内存块中创造的对象调用析构函数,因此,我们需要显式地为定位new运算符创建的对象调用析构函数,显示调用析构函数时,必须知道要销毁的对象,在我们添加上之后
cpp
delete p2;
delete p4;
p3->~A();
p1->~A();
delete [] p;
程序的运行结果如下所示:
在这里,我们可以看出,p3的C将原来p1里的A给覆盖掉了,这是因为我们两次使用new定位运算符时指向的内存空间的起始位置是一样的,所以后面p3调用时就会将原本的内容进行覆盖,要解决这个问题,我们只需要在p3使用new定位运算符时,让其进行偏移一个类的大小就可以解决
cpp
A *p3 = new(p + sizeof(A)) A("C",6);
提示:
对于我们使用定位new运算符创建的对象,应该使用与创建顺序相反的顺序进行删除,因为晚创建的对象可能依赖于早创建的对象,并且需要在所有的对象都被销毁后,才能释放用于存储这些对象的缓冲区
成员初始化列表
当我们在类的私有成员里设置了一个const常量,那么我们就必须在构造函数的初始化列表中对其进行初始化,否则就会出错
例如我们有如下的代码:
cppclass A { private: int a; const int num; public: A(int anum = 0); friend ostream &operator<<(ostream & os, A & t); }; A::A(int anum) { a = 0; num = anum; }
我们定义了一个类A,当我们尝试去使用这个类去创造一个对象时,编辑就会出错,因为我们在
A
类的构造函数中,没有对常量成员变量num
进行初始化,并且在构造函数中,我们尝试去修改num的值,而num是一个常量
对于我们的代码而言,num是一个常量,所以我们可以对其进行初始化,但是不能给它赋值,当我们调用构造函数时,对象会将在括号中的代码执行前就被建立,即我们没有进行a = anum;和num = anum + 1;操作之前,我们的程序就已经为a和num分配了内存空间,这个分配内存空间的过程实际就是相当于我们对a和num进行初始化的过程,只是我们没有给它们一个确定的值,而当我们开始执行a = anum;和num = anum + 1;时,我们进行的是常规的赋值操作,而不是初始化,因此,对于const数据成员,必须在执行到构造函数之前就对其进行初始化
C++提供了一种特殊的语法来完成这个过程,即成员列表初始化
成员列表初始化由逗号分隔的初始化列表列表组成,前面要加冒号,它位于参数列表的右括号之后,函数体的左括号之前,因此,我们的代码应该修改为
cppA::A(int anum) :num(anum) { a = 0; }
成员初始化列表的要求如下
1.对于我们上面所修改的代码,这种写法只能在构造函数中出现
2.在C++11之前必须用这种格式来初始化非静态const数据成员
3.必须用这种格式来初始化引用数据成员