C++ day20 用动态内存开发类 (一)自己写一个string类,浅复制遇到指针成员会出事(复制构造函数,赋值运算符函数,类的静态成员)

本文为了学习类的更多知识,自己写了一个string类,期间编译器通过隐式定义的一些特殊成员函数作妖多次,主要是复制构造函数和赋值运算符函数,但是二者都是通过浅复制酿成大祸,所以只要类有指针成员就必须自己用深复制显式定义这两个特殊成员函数,深刻体会到要想写一个真正友好健壮的类并不简单。

文章目录

在构造函数中使用new,在运行时分配内存

前面以字符串数组的长度为例说过很多次,数组的长度很棘手,最好的也是通常的办法就是动态分配内存,在运行时根据需要分配适当的大小。当然了,C++已经提供了string类,字符串的问题已经解决了,但是我们简单地使用string类,就会因为OOP的封装和隐藏特性,我们接触不到任何内存管理的细节,所以为了学习动态管理内存,必然要自己动手。

既然要用到new和delete,那必然就要自己动手管理内存。

你每一天的三餐吃什么,吃多少,肯定是吃的时候才决定,不是提前一周就规划好的,内存的使用也是这样,最好是临时决定分配多少,分配在哪里,这样才更加方便,但是方便的同时,也需要我们更加用心的管理。

示例 不再可有可无的析构函数;对象存在栈中;类声明中只能初始化const枚举量一种静态类成员;静态类成员属于整个类,不属于单个对象

前面的类设计示例都没有使用new和delete,所以析构函数写不写都一样,可有可无,因为不写,编译器也会自己加,写了,也是写个没有函数体的空壳子。

但是现在必须认真写析构函数。

这个示例写了一个初步的string类,省略了很多好的方法接口,只写了最简要的东西,作为示例看看构造函数,析构函数中new和delete的使用,还看了看静态类成员的使用。

为了看清构造函数和析构函数何时被调用,我在二者中输出了一些消息

//StringBad.h
#include <iostream>
#ifndef STRINGBAD_H_
#define STRINGBAD_H_
typedef unsigned int uint;

class StringBad{
private:
	char * str;//指针成员,不再用char数组,而用char指针,只存储地址,用new分配
	uint strLen;
	static uint numStrings;//静态类成员示例,仅作示例,实际string类无需这个成员
public:
	StringBad();
	StringBad(const char * st);//不要把构造函数声明为const成员函数
	~StringBad();
	friend std::ostream & operator<<(std::ostream & os, const StringBad & st);
};

#endif
//stringBad.cpp
#include <iostream>
#include <cstring>
#include "StringBad.h"
typedef unsigned int uint;

//在方法文件初始化静态类成员,不可在头文件的类声明中初始化,因为:
//第一,类声明只说明如何分配内存,并不实际分配内存
//第二,如果写在头文件,如果程序的多个源文件都包含了该头文件,则静态类成员的初始化语句将有多条,会引发错误
//且这里无需使用static
//静态类成员不是对象的组成部分,不存储在对象里,而是存在静态内存中,所以也无需在类作用域中初始化
uint StringBad::numStrings = 0;//必须用类名限定,否则后面方法中用numStrings报错,因为方法们不知道他是类成员,真心好奇怪

StringBad::StringBad()
{
	strLen = 3;//std::strlen("C++");
	str = new char[strLen + 1];
	std::strcpy(str, "C++");
	++numStrings;
	std::cout << numStrings << ": default object created!\n";
}

StringBad::StringBad(const char * st)
{
	strLen = std::strlen(st);
	str = new char[strLen + 1];
	std::strcpy(str, st);//由于是分配的刚好的内存,不用担心内存覆盖的安全问题
	++numStrings;
	std::cout << numStrings << ": \"" << str << "\" object created!\n";
}

//当StringBad类对象过期时,str指针也会过期,所以他指向的内存必须在析构函数中释放掉,否则就会内存泄漏
//删除对象只会删除对象本身占用的内存,并不会释放指针成员指向的内存
StringBad::~StringBad()
{
	--numStrings;
	std::cout << "\"" << str << "\" object deleted! " << numStrings << " objects left!\n";
	delete [] str;//用new时用了中括号,则delete就也要用中括号
}

std::ostream & operator<<(std::ostream & os, const StringBad & st)
{
	os << st.str;//我竟然没写.str。。。。
	return os;
}

在方法文件初始化静态类成员,不可在头文件的类声明中初始化,因为:
第一,类声明只说明如何分配内存,并不实际分配内存
第二,如果写在头文件,如果程序的多个源文件都包含了该头文件,则静态类成员的初始化语句将有多条,会引发错误

在方法文件初始化静态类成员无需使用static

静态类成员虽然是私有数据成员,但却并不是对象的组成部分,不存储在对象里,而是存在静态内存中,如果给静态类成员赋值时不用类名限定,则方法文件中的方法们就不认识这个静态类成员!!!虽然他确实是私有数据成员!!可见对象真的不把静态类成员当做自己的一部分,他是类的一部分,但不是对象的一部分,方法都是对对象在操作,所以不认识静态类成员。

//main.cpp
#include <iostream>
#include "StringBad.h"

int main()
{
	{//starting an inner block
		StringBad morning = StringBad("I ");
		StringBad afternoon("love ");
		StringBad evening;//我写成StringBad evening();错误!!这样不会调用任何构造函数

	    std::cout << morning << afternoon << evening << "!\n";//使用重载运算符输出
	}
	return 0;
}

输出

1: "I " object created!
2: "love " object created!
3: default object created!
I love C++!
"C++" object deleted! 2 objects left!
"love " object deleted! 1 objects left!
"I " object deleted! 0 objects left!

用我自己写的这个测试程序来看,结果蛮好的,没什么错误,那是不是这个类就设计的很好呢,我用书上的测试程序跑了一下,果然隐藏的问题不少

用另一个测试程序让这个类露出马脚(类设计和编译器自动定义的特殊成员函数行为不符的后果)

这个测试程序大有文章,尽显大牛风采

它所发现的问题实际上都是由编译器自动生成的,即自动定义的特殊成员函数造成的,比如复制构造函数。在这个类中之所以会出现问题,是因为StringBad类的设计和这些特殊函数的行为不符。

可见,编译器硬气不好惹,我们在开发类时必须要迎合它生成的成员函数的行为,稍有不符就出错。

//main.cpp
#include <iostream>
#include "StringBad.h"

using std::cout;
void callme1(StringBad & st);//按引用传递
void callme2(StringBad st);//按值传递

int main()
{
	{
		cout << "starting an inner block\n";

		StringBad headline1("Celery Stalks at Midnight");
		StringBad headline2("Lettuce Prey");
		StringBad sports("Spinach Leaves Bowl for Dollars");

		cout << "headline1: " << headline1 << '\n';
		cout << "headline2: " << headline2 << '\n';
		cout << "sports: " << sports << '\n';

		cout << std::endl;
		callme1(headline1);
		cout << "headline1: " << headline1 << '\n';
		callme2(headline2);
		cout << "headline2: " << headline2 << '\n';

		cout << std::endl;
		cout << "Initialize one object to another:\n";
		StringBad sailor = sports;//使用的构造函数原型:StringBad(const StringBad &);当使用一个对象初始化另一个对象时,编译器会自动生成这个构造函数,这就是复制构造函数
		//除此之外,上句代码还使用了编译器自动定义的赋值运算符成员函数
		cout << "sailor: " << sailor << '\n';

		cout << std::endl;
		cout << "Assign one object to another:\n";
		StringBad knot;//调用默认构造函数
		knot = headline1;
		cout << "Knot: " << knot << '\n';
		cout << "Exiting the block.\n\n";
	}
	cout << "Exiting the main() function\n";
	return 0;
}

//这两个显示函数的代码一毛一样
void callme1(StringBad & st)
{
	cout << "String passed by reference:\n";
	cout << st << '\n';//使用重载运算符输出
}

void callme2(StringBad st)
{
	//按值传递对象,编译器会调用自动定义的复制构造函数初始化对象st,这样需要时间开销(调用)和内存开销(存储新对象),实在得不偿失,所以还是传对象的引用吧
	cout << "String passed by value:\n";
	cout << st << '\n';
}

输出

starting an inner block
1: "Celery Stalks at Midnight" object created!
2: "Lettuce Prey" object created!
3: "Spinach Leaves Bowl for Dollars" object created!
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Bowl for Dollars

String passed by reference:
Celery Stalks at Midnight
headline1: Celery Stalks at Midnight
String passed by value:
Lettuce Prey
"Lettuce Prey" object deleted! 2 objects left!
headline2:

Initialize one object to another:
sailor: Spinach Leaves Bowl for Dollars

Assign one object to another:
3: default object created!
Knot: Celery Stalks at Midnight
Exiting the block.

"Celery Stalks at Midnight" object deleted! 2 objects left!
"Spinach Leaves Bowl for Dollars" object deleted! 1 objects left!
"槮?

首先映入眼帘的就是有乱码,其次还有些东西没输出

  • 传对象本身会调用析构函数

使用了两个显示函数,一个把对象的引用作为参数,一个把对象本身作为参数,之前我们说过了使用对象传引用更多,但主要说的是传引用只需传地址,不需要复制对象的内容这一点好处,今天看到了另一点好处,这一点使得我们写程序必须传对象的引用!!!

通过上面的结果可以看出,传引用一点问题都没有。但是传对象本身却莫名其妙地调用了析构函数!!传对象本身是按值传递,函数中使用的实际是对象的副本,

  • 复制构造函数

StringBad sailor = sports;使用的构造函数原型:StringBad(const StringBad &);当使用一个对象初始化另一个对象时,编译器会自动生成这个构造函数

但是这个复制构造函数并不会像我自己写的两个构造函数那样更新静态类成员numStrings.所以从这里开始,对象数目就不对了

  • 析构函数的消息的乱码

程序中总共有4个对象,headline2在程序中被按值传递的显示函数调用析构函数删除了,还剩3个,由于对象存在栈中,所以先删除的是最后创建的。所以删除的第一个是knot,第二个是sports ,第三个本应该是headline1,但是由于headline2可能删除的有点问题,所以存储在headline2前面的headline1就找不到了,没有成功调用析构函数。

7个特殊成员函数(你没定义时,C++编译器自动提供定义的隐式成员函数)

1 默认构造函数

编译器生成的默认构造函数没有参数,也不执行任何操作,用编译器提供的默认构造函数创建的对象的数据成员的值是未知的。

如果你不想用编译器生成的这个啥也不做的默认构造函数,就自己定义一个做点事情的默认构造函数。只要你定义了,编译器便不会多事,自然会用你定义的那个。

一般把编译器生成的默认构造函数叫做隐式默认构造函数,而把我们自己定义的默认构造函数叫做显式默认构造函数。

二者没有相同点。

不同点1:显式的可以执行一些操作。

不同点2:显式的可以有参数,但必须全部是默认参数。
这时需要注意,如果你定义了一个参数全是默认参数的构造函数,就不能再定义一个没有参数的了,因为这会产生二义性,编译器不知道你到底要用哪一个构造函数,于是编译器翻脸,剧终。(二义性是编译器的天敌)

2 复制构造函数(copy constructor, 只要程序生成了对象的副本,就会使用)

他的浅复制很会找事儿

原型一般是:

ClassName(const ClassName &);//参数是指向类对象的常量引用

复制构造函数只用于初始化对象,不用于对象之间的常规赋值,即把一个已经创建的对象复制到一个新创建的对象中。

初始化对象一定会让程序生成对象的副本,就会调用复制构造函数。这三者是等价的,即:

初始化对象一定会让程序生成对象的副本
生成对象的副本一定会调用复制构造函数
初始化对象也一定会调用复制构造函数

那么哪些情况会生成对象的副本呢?

编译器生成临时对象时调用

最常见但不是唯一的场合:把新对象显式初始化为现有对象

//假设motto是一个已经创建的变量
//下面四种case均会调用原型StringBad(const StringBad &)
StringBad ditto(motto);
StringBad metoo = StringBad(motto);//可能直接创建metoo,也可能先创建临时对象再复制给metoo,取决于实现
StringBad also = motto;//可能直接创建also,也可能先创建临时对象再复制给also,取决于实现
StringBad * p = new StringBad(motto);//初始化一个匿名对象,并把其地址给指针p
函数按值传递参数时和函数返回对象时调用

活生生的例子就在上面,程序用了编译器生成的复制构造函数去初始化callme2函数的形参——对象st.

很好理解,毕竟按值传递是副本,调用时传参和返回时传返回值,都是通过复制机制实现的,现在虽然处理的是对象,但按值传递还是复制机制,复制对象是通过复制构造函数新建对象实现的。

默认复制构造函数的底层原理:浅复制(shallow copy, 即逐个复制非静态成员的,源对象和新对象的char指针成员都指向源对象的字符串,即两个对象拥有同一个字符串!!!)

复制构造函数复制对象是怎么实现的?

是通过逐个复制非静态成员(因为前面我总结的特别好的那点:静态成员不属于类的对象,他们属于类!人家很高级的)的值来实现的。

这种成员复制也被称为浅复制。因为复制的是。浅复制在类对象有指针成员时,会出问题。

StringBad sailor = sports;

等价于

StringBad sailor;
sailor.str = sports.str;//复制的并非字符串,而是指向字符串的指针,显然这会导致问题
sailor.strLen = sports.strLen;

当然啦,上面的后者是不可以通过编译的,因为私有数据的访问限制,数据隐藏等。

如果成员是其他类比如A类的一个对象,那么复制构造函数还会调用A类的复制构造函数来逐个复制这个对象的成员值。有点嵌套调用的意味。

所以编译器自动生成的隐式的复制构造函数明显是不够可靠的,明确的说,在类成员有指向数据的指针时,默认复制构造函数的浅复制是会引发错误的,所以当类中有指针成员时,我们必须自己显式地定义一个复制构造函数,并在其中采用深复制

自己写一个显式的复制构造函数(深度复制,deep copy,源对象和新对象各有一个物理上独立的字符串)

深度复制会把字符串复制一个副本,并把副本的地址给新对象的成员(两个对象都有自己的字符串,俩字符串存在不同位置,物理上独立),而不是直接把源对象的字符串地址复制给新对象的成员(这样两个对象的指针成员指向的是同一个内存处的字符串,当然要出事)。

类声明中增加

//显式的自定义复制构造函数
StringBad(const StringBad & st);

方法文件中增加

StringBad::StringBad(const StringBad & st)
{
	++numStrings;
	//深复制
	strLen = std::strlen(st.str);
	str = new char[strLen + 1];
	std::strcpy(str, st.str);
	std::cout << "explicit copy constructor called.\n";
	std::cout << numStrings << ": " << str << " object created!\n";
}

输出

可以看到对象的计数已经完全正确了,有两次调用了显式复制构造函数,分别是初始化sailor和调用callme2函数按值传递,但是由于显式复制构造函数用的是自己写的深复制,所以没出问题,headline2成功显示。

剩余的唯一问题是headline1在最后调用析构函数时显示了乱码。所以这个示例的问题不能完全归咎于编译器的隐式复制构造函数,背后还有别的始作俑者,那就是编译器自动定义的另一个成员函数——赋值运算符:knot = headline1;这句代码破坏了headline1的数据。所以为knot调用析构没问题 ,为headline1调用析构就成了乱码。实际上也是由于成员复制,即浅复制

starting an inner block
1: "Celery Stalks at Midnight" object created!
2: "Lettuce Prey" object created!
3: "Spinach Leaves Bowl for Dollars" object created!
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Bowl for Dollars

String passed by reference:
Celery Stalks at Midnight
headline1: Celery Stalks at Midnight
explicit copy constructor called.
4: Lettuce Prey object created!
String passed by value:
Lettuce Prey
"Lettuce Prey" object deleted! 3 objects left!
headline2: Lettuce Prey

Initialize one object to another:
explicit copy constructor called.
4: Spinach Leaves Bowl for Dollars object created!
sailor: Spinach Leaves Bowl for Dollars

Assign one object to another:
5: default object created!
Knot: Celery Stalks at Midnight
Exiting the block.

"Celery Stalks at Midnight" object deleted! 4 objects left!
"Spinach Leaves Bowl for Dollars" object deleted! 3 objects left!
"Spinach Leaves Bowl for Dollars" object deleted! 2 objects left!
"Lettuce Prey" object deleted! 1 objects left!
"丐?
总结

在这里插入图片描述

3 移动构造函数 move constructor,以后说

4 默认析构函数,不做任何操作,无甚内容需表

5 赋值运算符

C允许结构之间的赋值(但不允许数组之间的赋值),C++允许同一个类的对象之间的赋值。这两种允许的背后,都是使用了重载运算符。

原型:

//参数是指向类对象的常量引用,返回值是指向类对象的引用
ClassName & ClassName::operator=(const ClassName &); 
编译器自定义的赋值运算符也用浅复制(浅复制:本文示例所有错误真正的罪魁祸首)

原来编译器只能算帮凶或者二把手,浅复制才是真正的始作俑者。

knot = headline1;会调用编译器自动生成的隐式赋值运算符成员函数,进行逐个的成员复制,即浅复制,只复制值,不考虑成员的真正结构,于是knot.str和headline.str这两个指针的值一样,即两个指针指向的同一个字符串,于是knot被析构时这个字符串还在,正常,但是knot析构后这个字符串就没了,被释放了,所以再析构headline1,就出错了。

这和编译器的隐式复制构造函数一毛一样,面对指针成员就出错了。

所以办法是自己显式定义一个赋值运算符成员函数,使用深复制。

自己用深度复制定义赋值运算符

先想想这个函数要做啥,返回啥

  • 应该返回一个指向调用对象的引用
  • 这点很难想到,很容易忽略从而造成内存泄漏:调用对象,即目标对象,有可能引用了以前分配的数据,所以应该在赋值运算符函数中用delete或delete []释放这些数据。
  • 更难想到:应该禁止调用对象和源对象相同的情况。即赋值运算符两边的对象是同一个对象。因为按照第二点,我会先把=左边的对象的成员都delete,然后再把=右边的对象的成员复制给=左边的对象的成员,当然是深复制。但是很明显,如果左右是同一个对象,显然然后不了的。
  • OOP目标是让类尽量相似于简单内置类型,所以应该允许多重赋值,即连续赋值。

s0 = s1 = s2;
//等价于(结合性从右到左)
s0.operator=((s1.operator=(s2));
//不等价于
(s0.operator=(s1)).operator=(s2);
终于得到了完全正确的结果

原型

//显式的自定义赋值运算符函数
StringBad & operator=(const StringBad & st);

定义

StringBad & StringBad::operator=(const StringBad & st)
{
	//首先禁止源和目标对象是同一个对象的情况,我没想到用地址判断
	if (this == &st)
		return *this;
	strLen = st.strLen;
	delete [] str;//我忘了!!!!!
	str = new char[strLen + 1];
	std::strcpy(str, st.str);
	std::cout << "explicit assignment operator called (using deep copy)!\n";
	return *this;//我忘了。。
}

输出

starting an inner block
1: "Celery Stalks at Midnight" object created!
2: "Lettuce Prey" object created!
3: "Spinach Leaves Bowl for Dollars" object created!
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Bowl for Dollars

String passed by reference:
Celery Stalks at Midnight
headline1: Celery Stalks at Midnight
explicit copy constructor called.
4: Lettuce Prey object created!
String passed by value:
Lettuce Prey
"Lettuce Prey" object deleted! 3 objects left!
headline2: Lettuce Prey

Initialize one object to another:
explicit copy constructor called.
4: Spinach Leaves Bowl for Dollars object created!
sailor: Spinach Leaves Bowl for Dollars

Assign one object to another:
5: default object created!
explicit assignment operator called (using deep copy)!
Knot: Celery Stalks at Midnight
Exiting the block.

"Celery Stalks at Midnight" object deleted! 4 objects left!
"Spinach Leaves Bowl for Dollars" object deleted! 3 objects left!
"Spinach Leaves Bowl for Dollars" object deleted! 2 objects left!
"Lettuce Prey" object deleted! 1 objects left!
"Celery Stalks at Midnight" object deleted! 0 objects left!
Exiting the main() function

6 移动赋值运算符, 以后说

7 地址运算符,返回调用对象的地址,即this指针的值,简单不表

总结

如果有指针成员的话

  • 1 一定要自己用深复制定义一个复制构造函数,防止编译器拿浅复制来忽悠你导致出错。
    在这里插入图片描述
  • 2 一定要自己用深复制重载一个赋值运算符成员函数,也是为了避免编译器简单浅复制。
    在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值