类和动态分配(一)设计一个String
类
对类成员使用动态内存分配会产生一系列问题。我们需要对原有类体系进行扩充。
1. 实例:StringBad
类
|
|
StringBad
是个不太完整的类。这个类并没有什么错误,但是忽略了一些不明显却必不可少的东西。
首先这个类中的str
成员是一个指针,它指向一个内存块,这就意味著这个类本身根本没有存储字符串,而是在构造函数中使用new
分配了一块内存,类本身保持的只是这块内存的地址和字符串的其他信息。
这里的num_strings
成员是静态类成员
静态类成员无了创建了多少个对象,程序都不会创建静态变量副本,类的所有对象共享一个静态成员。
我们发现,在类方法定义的文件中,可以对私有成员初始化,这是不可思议的,因为私有成员不能直接访问。
int StringBad::num_strings = 0;
我们在类声明中是无法初始化静态成员变量,(ISO C++ forbids in-class initialization of non-const static member 'StringBad::num_strings'static int num_strings=0;
),对于静态类成员,我们可以再类声明外进行初始化,而且我们发现初始化语句并没有static
关键词。
C++允许我们在类声明中初始化成员变量,但是静态非const成员除外静态数据成员在类声明中声明,在类方法中初始化。如果静态成员是const或者枚举类,则可以在类声明中初始化,(因为这种数据相当于是宏)。
strlen()
是计算字符串长度的,但是不会加上字符串末尾的空字符,所以分配内存时,要+1。
在构造函数中,我们使用了动态内存分配,而隐式默认析构函数,是不会做任何操作的,所以我们必须自己写一个析构函数释放动态内存。
在构造函数中使用new
分配内存时,必须在相应的析构函数中使用delete
来释放内存。
下面我们看看这个StringBad
类的缺陷
|
这段代码是有问题的,不同编译器编译后,运行的结果不太一样。
具体有问题的语句是
callme2(headline2);
StringBad sailor = sports;
knot = headline1;
为什么会出错?实际上,类中不止有构造函数、析构函数,还有很多编译器自动写的默认成员函数,出错的地方就是编译器给的默认成员函数对动态内存分配的不配合。
2. 特殊成员函数
C++自动提供了下面这些成员函数
- 默认构造函数,如果没有定义构造函数
- 默认析构函数,如果没有定义
- 复制构造函数,如果没有定义
- 赋值运算符,如果没有定义
- 地址运算符,如果没有定义
- 移动构造函数,如果没有定义
- 移动赋值运算符,如果没有定义
默认构造函数和默认析构函数之前都说过,他们两啥事都不会干。重载地址运算符,就是对象的值就是这个对象的
this
指针的值,也很简单。移动构造函数和移动赋值运算符也不说了,这涉及移动语义语法,以后再说。
2.1 复制构造函数
复制构造函数用于初始化和按照传递参数,它的原型是:
Class_name(const Class_name&);
它接受一个const对象的引用参数,例如StringBad
类的赋值函数原型是:
StringBad(const StringBad & );
-
何时调用复制构造函数?
StringBad ditto(motto);
StringBad metto=motto;
StringBad also =StringBad(metto);
StringBad *pStringBad=new StringBad(motto);
当将新对象显式初始化成现有的对象时会直接调用复制构造函数,上面这4个声明都会使用复制构造函数之间创建对象。早期的编译器,可能对中间两种声明做一些复杂的操作,但是也是会使用复制构造函数的
当程序生成了对象副本时,编译器都会使用复制构造函数。具体的,函数按值传递对象(例如callme2()
)或者按值返回对象时,都会使用复制构造函数。按值传递意味著创建原始变量的一个副本。
编译器生成临时变量时,也会使用复制构造函数,例如将三个对象相加,编译器会生成临时变量保存中间结果。不过现在编译器很智能,临时变量的生成时越来越少了。
总之,复制构造函数的调用会出现在初始化、按值传递、对象返回、生成临时对象时
- 默认复制构造函数的功能
默认的复制构造函数只会进行潜复制,即逐个复制非静态成员,复制的时成员的值。
StringBad sailor=sports;
相当于
StringBad sailor;
sailor.str=sports.str;
sailor.len=sports,len;
当然了私有成员是无法直接访问的,上面只是演示。
静态成员是不会复制的,因为静态成员都是所有对象共享的,所以不需要复制。
StringBad
类中默认复制构造函数出错了!
callme2(headline2);
调用时,默认复制构造函数会用来创建一个原始对象的副本,而且默认构造函数不会更新num_strings
的值。
如果类中需要一个静态成员需要在创建对象时更新,那么一定要提供一个显式复制构造函数用来处理计数问题。
还有一个更致命的问题,当callme2()
调用结束后,原始变量的副本就会被删除,即调用析构函数。但是副本对象的str
成员和原始对象的str
成员是一样的,那么如果调用析构函数删除副本,那么原始对象的动态内存也会被释放,从而导致乱码。
StringBad sailor = sports;
这段代码也会直接让sailor.str
和sports.str
完全相同,从而调用析构函数释放sports
时也会把,sailor
的那块动态内存释放掉,等到退出代码块后,sailor
和sports
都会调用析构函数,相当于把同一块地址delete[]
两次,从而出错。
- 定义一个显式复制构造函数解决问题
解决这一问题的关键是**深复制(deep copy)**应该定义一个复制构造函数,用来复制指向的数据,而不是指针。
可以这样编写显式复制构造函数:
StringBad::StringBad(const StringBad & st) |
总之,如果类中包含了使用
new
初始化的指针成员,应该定义一个复制构造函数,用来复制指向的数据,而不是指针,这就是深复制。浅复制仅复制指针信息,而不会深入挖掘指针指向的数据
2.2 赋值运算符
C++会自动给类写一个重载
=
的函数,默认赋值运算符。
它的原型是:
Class_name & Class_name::operator=(const Class_name&);
例如,StringBad
的默认赋值运算符函数原型是:
StringBad & StringBad::operator=(const StringBad &);
这里返回类型是引用,因为返回的就是*this
或者说允许连续赋值操作。(一般来说,能用引用传参或返回,优先考虑引用传参或返回,尤其是允许把传入的引用变量返回的函数。主要是,引用不能指向局部变量,所以我们有时候不得不使用按值传递。)
- 赋值运算什么时候使用?
把已有的对象赋给另一个对象时,会使用赋值运算符。但是,一般来说,初始化对象时,不会调用赋值运算符,例如:
StringBad sailor = sports;
优秀的编译器,执行上面这一步会直接调用复制构造函数。
而落后的编译器它会这样做:
调用复制构造函数,创建一个临时对象(sports
的副本),然后调用赋值运算符,把临时对象赋值给调用对象(即把sports
的副本赋值给sailor
)。
- 默认赋值运算符的功能
默认赋值符的功能是,把传入的对象的逐个成员浅复制到调用对象的逐个成员。
a=b;
相当于
a.len=b.len;
a.str=b.str;
当然了,它也不会复制静态成员。
- 默认赋值运算符出错了!
knot = headline1;
会使得knot.str
和headline.str
相同,则调用析构函数时,同一块动态内存会被释放两次。
- 解决赋值问题
解决问题的关键还是:深复制
但是赋值运算符和复制构造函数有区别:
- 调用对象得将原来分配的数据
delete
掉,防止内存泄漏。 - 应该避免把自己赋值给自己;否则你已经把自己数据删除掉了,还怎么赋值。
- 返回值是指向调用对象的引用。
通过返回引用就可以使用连续赋值,(按值传递的其实也可以完成连续赋值,只是引用传递更高效)
s0=s1=s2;
相当于s0.operator=(s1.operator=(s2));
StringBad & StringBad::operator=(const StringBad & st) |
注意这里
if(this==&st)
千万不能写成if(*this==st)
因为地址相同和内容相同概念完全不一样。
赋值操作不会创造新的对象,所以num_strings
不需要调整。
3. 改进后的新String
类
3.1 一些新的想法
我们在
StringBad
的基础上,添加一些功能,使他更像C++中的string
类
如下:
//string1.h |
//string1.cpp |
- 默认构造函数
我们看默认构造函数:
String::String()//默认构造函数 |
构造函数的关键就在于,它能和析构函数匹配,这里
str=nullptr;
就是把指针置空,delete[]
是可以对空指针操作的。你可以可以写成这样子str=new char[1]{'\0'};
,相当于是给个空字符.
- 比较成员函数
我们使用
strcmp()
函数对String
对象做比较,strcmp(char*s1,char*s2)
函数的返回值是,如果s1>s2
即第一个参数在第二个参数后面,则返回正数;若s1<s2
,则返回负数;如果s1==s2
两个字符串相同,则返回0.
bool operator<(const String & st1,const String & st2) |
而且我们把比较成员函数作为友元函数,是因为友元函数重载运算符更适应类类型自动转换,我们的
String
类的构造函数String::String (const char*s)
它只接受一个字符串常量的参数,那么对于这个String
类,它可以将字符串常量自动类型转换成String
类类型.而使用友元函数重载运算符就是方便String
对象和C中字符串常量做比较.
具体来说,如果使用类成员函数来实现<
重载,则"name"<object;
这句话就无法执行。因为"name"
不是对象它无法调用类成员函数;
如果使用友元函数实现<
重载,那么"name"<object;
就可以执行,因为bool operator<(const String & st1,const String & st2)
的第一个参数是String
类,而"name"
是字符串常量,他会调用构造函数自动转换成
String
对象以匹配这个友元函数.
- 重载
[]
运算符
[]
也是一个运算符,它是一个二元运算符.a[n]
中a
是第一个操作数,n
是第二个操作数.
我们希望,String
对象能够像数组一样使用[]
运算符,但是我们无法直接访问私有成员,那么得重载运算符了.我们采用的是类成员函数方式:
char& String::operator[](int n)//中括号运算符的重载 |
可以看懂我们写了两个重载函数,这是因为我们调用
[]
时,如果是const
对象我们不能对它的私有数据做修改,所以返回值时是const
引用(或者采用按值传递);如果我们期待修改str[n]
数据,我们就得返回它的引用,以确保object[0]='a';
这样的赋值语句可以执行.
在重载解析时,会区别const
和非const
,以确保调用正确的成员函数.
- 静态成员函数
我们可以将成员函数声明成静态的
静态成员函数不能使用对象调用,这也就意味着它没有this
指针.
正因它和对象无关,静态成员函数只能使用静态数据成员.静态成员函数的作用: 一般来说,我们只能通过对象才能调用成员函数,但是静态成员函数是个例外,
它放在公有部分,那么可以直接用作用域解析符访问它.
static int HowMany();//函数原型 |
可以看出来,静态成员函数的一种用法:私有静态数据
num_strings
无法直接访问,那么就用公有的静态成员函数的方式来访问.还有一种用法是,使用静态成员函数设置类级标记,以控制某些接口的行为.
- 进一步重载
=
运算符
String& operator=(const String&);//赋值运算符 |
可以看出来,我们使用了两个函数来重载赋值运算符,这是因为我们经常会用C风格的字符串常量来给
String
类赋值.如果只有第一个函数,那么字符串常量就会先自动类型转换成String
类对象,然后再调用赋值运算符.
但是这样做的开销是:
首先,调用构造函数创建一个String
临时对象.
其次,对临时对象使用重载赋值运算
最后,调用析构函数删除临时对象.所以为了减少开销,那就再写一个专门给字符串常量设计的赋值运算.
- 重载
>>
运算符
我们希望能够使用
cin>>s;
的方式直接给String
类赋值.
istream & operator>>(istream &is,String& st) |
首先说明一点
String::CINLIM
,这种方式直接访问私有静态数据,友元函数和成员函数有相同的访问权限,可以用作用域解析符来直接访问私有静态数据,st.CINLIM
的使用对象调用的方式访问静态数据也是可行的,总之静态数据由于是所有对象共享的数据,可以用作用域解析符来访问,但是私有静态数据只允许成员函数和友元访问.
关于cin.get(array_name, ArSize);
,从缓冲区读取数据,达到行尾或者读取了 ArSize - 1 个字符为止,且超过规定的字符数不会出现错误,会直接截断,并且不对此换行符进行处理,并将其留在缓冲区。如果直接输入\n
,那么array_name[0]
就会变成\0
,并且换行符仍留在缓冲区.
cin.get();
读入一个字符后结束读取,而且它会读取换行符.cin.get();
多是用来处理换行符的.所以上面那段代码,首先读取最多
CINLIM-1
个字符,然后if(is)
是为了判断是否直接输入空行,如果不是空行就赋值,如果是空行的话直接返回;while (is && is.get()!='\n')
是用is.get()
吸收掉多余字符,并且它会吸收换行符.
或者这么写也行
istream & operator>>(istream &is,String& st) |
直接使用
is.ignore();
清除缓冲区所有字符.
3.2 一个例子
//类_sayings1.cpp |
PS D:\study\c++\path_to_c++> g++ -I .\include\ -o 类_sayings1 .\类_sayings1.cpp .\string1.cpp |