有同学说不知道怎么画内存模型图,我这里附几个教程
UML类图小结
UML类图与类的关系详解
类似的教程笔记网上有不少的,大家自己搜来看看把习题的类图实现一下,鉴于文章篇幅我这里就不做画图教程了。
首先我们要明确下规范,写函数内容的时候,最好使用this指针
this->width = width;
this->height = height;
而非:
width = width;
height = height;
为什么要这样写?这样写的好处是什么呢?
我们应该知道当局部变量名与全局变量同名时,全局变量会被覆盖,我们自己写构造函数的时候很可能会选择用不同名的变量名来进行赋值操作,像这样的:
width_d = width;
height_d = height;
但最好是写成这样的:
this->width = width;
this->height = height;
这里的this非常明确地指出了左边的width是当前类的成员,而不至于令别人看的时候摸不着头脑,不清楚这里的width到底哪里的东西。
我最后是这样写的:
this->width = width?width:0;
this->height = height?height:0;
附带对width,height的判断检查。
从简单的构造函数谈起
刚拿到题的时候我是呵呵笑的,脑袋中已经有了初始模型,直到我下笔写的时候……
说来惭愧,我写第一个构造函数的就卡住了,磨磨蹭蹭十来分钟过去了还是想不出怎么调用Point类里的数据成员(x, y)比较好。
原题中Rectangle类定义了一个指向Point的指针leftUp。我们可以利用这个指针来对(x, y)进行构造赋值。
起初我是这样写的:
x = leftUp->x;
y = leftUp->y;
……编译器啪啪啪打脸,简直不忍直视,大家帮忙分析下这样赋值错在哪里?
左边的x,y到底是谁?代表的是Point的成员还是另外创建的数据?leftUp之前只是定义了下,这个指针并未在堆中被创建,leftUp还没有被实例化哪里来的成员(x,y)?
很多时候就是这样的,感觉自己挺懂的,下笔写出来的往编译器来一丢立马报一堆错。
你说我没有实例化,那么我来搞一个
this->leftUp = Point* ptr;
this->leftUp->x = ptr->x;
this->leftUp->y = ptr->y;
这样乍看之下似乎无错,但大多数负责任的编译器仍会报错,这是为什么呢?
这里涉及到设计C++类、函数要考虑到的一些东西。当我们编写一个函数的时候通常会默认我们调用的外部的数据是安全的,是可用的,同时为了保证自己的鲁棒性我们要防止外部改动导致本函数的失效或者更为恶劣的程序崩溃。所以我们在对指针指向的类成员变量赋值时最好是这样做:
this->leftUp = new Point(x, y);
直接在堆中new一个,同时调用Point的默认构造函数传进(x, y)值;
你真的会拷贝构造吗?
老师讲完拷贝构造的时候我问了老刁,你拷贝类Shape里的no了吗?我们开始都没注意到这个值,不过细心的网友应该记着Rectangle是继承Shape而来的。所以他默认的数据里还是有no这个成员的,你怎么能抛弃他呢?但是我们要怎么给他拷贝复制呢?
我们使用Shape(other),直接来调用父类Shape的默认构造函数。为啥可以这样写呢?直接调用父类不会出错吗?
不会的,如果你认真看了侯捷老师的视频,应该已经知道子、父类之间会为友元。
完整的代码如下
inline
Rectangle::Rectangle(const Rectangle& other)
: Shape(other), width(other.width), height(other.height)
{
if(other.leftUp != nullptr)
{
this->leftUp = new Point(*other.leftUp);
}
else
{
this->leftUp = nullptr;
}
}
有的人可能会将函数名后紧跟的初始化操作写成这样的顺序:
width(other.width), height(other.height), Shape(other)
这时候李老师问了一个问题:你认为拷贝构造时的顺序是怎样的?是按照你写的初始化的顺序吗?
几乎所有人都回答“是”。
然而事实是有点坑爹的,构造函数开头的
inline
Rectangle::Rectangle(const Rectangle& other)
: Shape(other), width(other.width), height(other.height)
并不是按照你写的顺序来的,而是按照编译器定义的优先级来的,先是拷贝构造父类的数据,然后是原类里对数据定义的顺序,所以你在开头考虑写成什么顺序并没有什么卵用,无论你写成什么顺序,他内部都已经有约定好的顺序了,但是为了代码阅读方便,让人一看便知拷贝构造的顺序,你这里只要按照他内部约定好的顺序来写,先父类,后定义顺序,权当做个顺序说明好了。
最后我们要面对的是leftUp这个指针成员,很多人可能会直接这样写:
this->leftUp = new Point(*other.leftUp);
如果你拷贝的other里的leftUp是个空指针呢?我们还在堆里创建他干嘛?
所以这里要加个if判断。
if(other.leftUp != nullptr)
{
this->leftUp = new Point(*other.leftUp);
}
else
{
this->leftUp = nullptr;
}
为什么我用的是nullptr而不是NULL,null或者0?你可以参考下我下面给的连接,相信你看完会总结出一个属于自己的答案。
NULL,0,ptrnull全解析
C/C++ 中 0 与 NULL 区别是什么?
NULL VS ptrnull
赋值操作符-你不造的那些事儿
只有构造函数可以这样
Rectangle::Rectangle(const Rectangle& other)
: Shape(other), width(other.width), height(other.height)
{
···
}
在花括号之前这样直接初始化,赋值操作符是不可以的,这是构造函数才拥有的特例。
所以我们还是老老实实用this指针吧。不过我相信很多人会忘了在开头写这句判断:
if(this == &other)
{
return *this;
}
如果他赋值操作的就是他本身,我们不加判断的直接进行操作,在处理leftUp的时候,
this->leftUp = new Point(*other.leftUp);
已有的other.leftUp被再次指向了一个新的Point对象,但你原来的的other.left并没有被销毁,原来的数据不再被记录在案,换句话说你搞丢他了,这样便出现了内存泄露。
不过不用惊讶,李老师说“大部分程序员的c++程序里必然会出现内存泄露的问题”,所以要想成为那少数的“大牛”,就从现在开始养成良好的习惯,学习画你的数据内存模型图,搞清楚每一个数据的动向、联系。确保你写的程序万无一失。
接下来我们要思考父类Shape要怎么写呢?
说实话,我对处理继承的父类的东西是一窍不通的,连上面那个构造函数Shape(other)都是看的别人的,看到这个直接歇菜了。我开始写了个这样的
Shape(other.no);
连我自己都不知道这是什么鬼,一提笔便暴漏出很多问题来。我们几个C++都不晓得该怎么处理,然后有人从网上找了个答案。写成了酱紫:
Shape::operator=(other);
李老师后来过来看到我们这样写还以为我们是懂的,说这是对父类操作符重载的标准写法。
这里我们把“operator=”看做一个整体,即Shape的成员函数,然后我们直接传入参数other,这样便调用了shape的默认构造函数,对no进行的赋值操作,这样做的好处是我们完全不必管Shape内部是如何实现的,以及是否发生改动,我们只管做我们的赋值操作就OK了。
下面我要给大家当下反面教材。当时我问了句有点蠢的话:李老师,你怎么确定那个Shape里“operator=”一定存在呢?他不需要定义一下吗?
李老师笑着对我说到:看,这就是没有好好看侯捷老师视频的典型。
四大函数,构造、拷贝、操作符赋值、析构,一旦一个对象被创建这四个函数便被编译器自动生成默认结构了,so……
今天李老师讲的时候,提到了过了很多次解耦思想,一个函数定义的什么功能就只做什么事情,不要直接"left->x = x",搞得你很懂外面那个类是怎么实现的一样,非要去操作底层。很多时候我们在进行团队合作的时候,尤其是大公司,你调用的东西很可能不是你写的,你不知道你用的那个数据何时会发生改动,所以你要保证你的通用性、稳定性。写某个函数的时候,假设其他函数都是正确的,并且你不知道他们的内部实现,你只管调用他们的接口然后完成你当前函数要做的工作。所以说尽量用下面这种写法,否则后患无穷。
this->leftUp = new Point(x, y);
this->leftUp = new Point(*other.leftUp);
Shape::operator = (other);
赋值操作的最后我们仍要谈到喜欢逗你玩的类成员指针变量leftUp。
if(other.leftUp != nullptr)
{
if(leftUp != nullptr ) {
*leftUp = *other.leftUp;
}
else
{
leftUp = new Point(*other.leftUp);
}
}
else
{
delete leftUp;
this->leftUp = nullptr;
}
首先我们要判断other.leftUp是否为空,如果不为空我们就准备进行赋值操作,继续判断当前类成员leftUp是否为空,若为空就new一个直接在堆中初始化,否则直接改变leftUp指向的内容(原来指向的会被析构函数释放掉)。最后,若要赋值的other.leftUp为空,我们就先delete当前类中的leftUp,然后将他指向nullptr。
inline
Rectangle& Rectangle::operator=(const Rectangle& other)
{
if(this == &other)
{
return *this;
}
Shape::operator=(other);
this->width = other.width;
this->height = other.height;
if(other.leftUp != nullptr)
{
if(leftUp != nullptr) {
*leftUp = *other.leftUp;
}
else
{
leftUp = new Point(*other.leftUp);
}
}
else
{
delete leftUp;
this->leftUp = nullptr;
}
return *this;
}
析构函数
下面我们来看程序
inline
Rectangle::~Rectangle()
{
delete leftUp;
leftup = nullptr;
}
}
估计很多人只写了个delete leftUp就完事儿了,以为这样leftUp就被释放指向NULL了,事实真的是这样的?
delete只是对指针的指向空间的释放,并不会改变指针的值,即指针不为NULL,把指针指向的空间释放掉,但是指针的本身内容,即指向空间的地址,是不会改变的;指针为NULL时,没有空间可释放,也就不去释放了,而指针依然有效,指针的内容依然是NULL,在指针的有效域结束时,指针本身所占内存自动被释放。
Is it good practice to NULL a pointer after deleting it?
而有人的喜欢在delete之前判断是否为空,stackoverflow有不少这类问题
Is it safe to delete a NULL pointer?
Is there any reason to check for a NULL pointer before deleting?
csdn也有类似的讨论,看着还挺激烈的:)
我真是孤陋寡闻了,今天才知道NULL指针是可以直接delete的
摘一段给你们看看:
需要判断NULL指针,不是因为要delete,而是因为要访问该指针的内容,确保指针有效。
但是这么做还是没办法处理野指针,因为野指针只有在访问时才能知道是否有问题。所以在delete之后应该立即赋值为NULL。这样既方便以后检查指针是否有效,也可以防止二次delete无效的指针或者栈上的地址,引起的段错误。
总结
- 测试能反映出很多问题,往往你并不能将你心里所想完美无误的实现出来。
- 画内存模型图,李老师在以前的先下课提到过,大部分时候你感觉你的程序没问题,编译器也没报错,但是仔细一分析,其实错误百出。有些问题只有到达了一定量级才会被你发现,但是画内存模型分析图可以避免这种尴尬的事情。
- 眼高手低要不得,看几十遍视频也不一定有亲自实现一遍程序体会的深刻。
- 其实感觉很有问题没写出来,C++的东西深究起来不得了,很多事情要考虑清楚才能写出完美无误的代码,而这一直是我追求的目标,我先反省下自己。
- 今天听老师单独给我们分析,谈到一些问题的时候要自己思考没空做笔记,今天写的这些都是记在脑子里的,估计会有些遗漏,还望跟我一起接受指导的同学们批评指出。