本篇笔记主要分为三个部分,第一部分是以String类为例的基于对象的编程,重点在于构造与析构、拷贝构造函数、拷贝赋值函数三个重要函数。这一部分与笔记(1)中的内容结合起来就是基于对象编程的主要内容。第二部分是在掌握了基于对象编程的基础上的面向对象编程(OOP)学习,讲解了类之间的组合、继承、委托关系。最后一部分则是一些关于面向对象编程的一点补充,包括内存空间、生命周期、new和delete等,以及几种综合利用组合、继承、委托的设计模式简介。
第一部分、以String类(有指针类)为例讲解关键函数“Big Three”
1 class String 2 { 3 public: 4 String(const char* cstr=0); 5 String(const String& str); 6 String& operator=(const String& str); 7 ~String(); 8 char* get_c_str() const { return m_data; } 9 private: 10 char* m_data; 11 };
- 构造函数与析构函数
如笔记(1)中描述的,构造函数是在对象生成时被调用的特殊函数;相应的,析构函数是变量的生命结束时被调用的特殊函数。对于Complex类的对象来说,析构函数不需要进行特殊的操作;这时,编译器会自动提供一个默认的析构函数,其函数体为空。
为何String类需要特殊的析构函数?
首先看String类在产生时候进行了什么样的操作:
inline String::String(const char* cstr) { if (cstr) { m_data = new char[strlen(cstr)+1]; strcpy(m_data, cstr); } else { m_data = new char[1]; *m_data = '\0'; } }
可以看到,在构造String类的对象时,依靠指针,运用new方法在堆中申请了一块空间。如果不采用特殊的析构函数的话,成员指针变量的生命周期已经结束,但是指针变量所申请的内存并没有被归还,从而导致内存溢出。因此,必须有相应的析构函数。析构函数的操作很简单,只需要delete [] m_data;一个操作即可。
需要注意的是,构造函数是先进行分配成员内存和赋初值的工作再进入函数体;而析构函数是先进入函数体执行操作,退出函数体后编译器自动归还成员变量的内存。
有此例可知,在构造函数中存在申请堆空间的new操作时,必须要在析构时进行delete操作。
2. 默认拷贝构造函数和默认拷贝赋值函数
所谓拷贝构造函数,是指当使用一个对象去初始化同类对象时会进行调用的函数。
例如:
1 String A; 2 String B(A); 3 String C = A;
String对象B和C产生时就调用了复制构造函数。其函数声明为
String(const String& str);
所谓拷贝赋值函数,就是用一个对象去给另一个对象赋值时所用到的函数,也就是‘=’运算符的运算符重载。
默认情况下,C++所提供的拷贝构造函数和拷贝赋值函数就是简单的位复制,将成员变量一位一位的进行复制,所有数据完全相同,称这种复制方式叫做浅复制。对于纯数据类,并不需要设计者提供拷贝构造函数和拷贝赋值函数即可,然而对于有指针的类来说,浅复制有这样的问题:
经过浅复制a=b,指针的地址进行了位拷贝,就会变成
这种情况下,堆空间下的“WORLD\0”字符串将永远不会被delete,产生内存泄露。对于拷贝构造的操作(即String b = a);位复制不会给b多分配一个内存,但是它会导致“HELLO\0”字符串被两个指针同时指向,当其中一个析构时,指针所指向的区域被delete;剩下的指针成为了野指针。此时如果另一个也发生了析构,则此在野指针上进行delete操作,会发生内存溢出。
这些问题的核心就在于浅复制上,将浅复制转化为深复制,即复制时为b中的指针分配一段堆内存,然后在这段堆内存里面写上a对象的对应字符串即可。
3.拷贝赋值与拷贝构造的处理逻辑
拷贝赋值:
-
- 检查是否为自我赋值,如果是,直接返回。
- 归还原内存
- 申请新内存
- 深度复制内容
注意第一步检查是否为自我赋值的工作不可省略。因为如果是自我赋值,即产生了"a=a"这样的语句时,如果不进行自我赋值检查,第一步将直接归还a的内存造成错误。
1 inline 2 String& String::operator=(const String& str) 3 { 4 if (this == &str)//自我赋值检测 5 return *this; 6 7 delete[] m_data;//归还原内存 8 m_data = new char[ strlen(str.m_data) + 1 ];//申请新内存 9 strcpy(m_data, str.m_data);//深度复制内容 10 return *this;//返回对象本身 11 }
拷贝构造:
-
- 申请内存
- 深度复制内容
拷贝构造的任务比较简单,只需申请内存并深度复制即可。在拷贝构造函数中也可以使用拷贝赋值函数。
1 inline 2 String::String(const String& str) 3 { 4 m_data = new char[ strlen(str.m_data) + 1 ]; 5 strcpy(m_data, str.m_data); 6 }
第二部分、面向对象的设计(OOP)
这一部分主要关注类和类之间的三种关系:组合、继承、委托;
1.组合(has-a关系)
组合关系描述的是一种“包含关系”;Container由Component组成,但Container不是Component,如图所示。
例如,人体和四肢、森林和树木、职员信息和工资账单……使用类的表示法描述组合关系时如下图。
一个具体的例子如下:
queue 模板类是由deque模板类(deque:双向队列)派生出来的单向队列。queue的所有功能都是基于deque的功能的特化,queue本身并不提供其他的功能。一般管这种程序设计模式叫做Adapter(适配),是功能由完全到特化的一种设计模式。
组合关系下的构造与析构
- 构造由内而外
- 编译器先执行Component的无参构造,然后才执行自己的构造函数。Component的构造函数由编译器自动执行,无需显式执行。
- 如果不想使用无参构造函数,则应采取列表初始化语法。e.g. Container():Component(1,2){......}
- 析构由外而内
- 先析构自己,再析构自己的Component。
2.委托关系(Composition by reference)
所谓委托关系,就是在类中包含了指向一个类的对象的指针,用类的关系图描述如下:
这种特殊的String中,包含了一个指向StringRep的指针,StringRep包含是字符串的实体,关系如下图所示:
这种手法叫做“编译防火墙”,在普通的字符串上又加了一层API。StringRep提供基础功能,并且将String声明为友元。在编译时,StringRep永远不需要被重新编译。String的功能是在StringRep的功能之上的拓展。
(注:pImpl:pointer to implementation)
StringRep会对指向自己的String数目进行计数。事实上,这就是在Python中的垃圾回收机制。
对于相等的元素,Python只保留一个实体,另外的都以引用的方式存在着。采用“写时复制”的方法,保证了各个元素之间不会相互影响,同时又能尽量节省内存空间并提高速度。当引用数目为0时,实体将自动被释放,使程序员从内存分配的难题中解放出来。
3.继承关系(is-a关系)
继承关系是一种is-a关系,A继承于B表示A是一种B,例如:苹果是水果,香蕉是水果,水果又是物品……,采用类的关系图表示如下图左:
一个简单的例子如下:
这里需要说明的是虽然例子中使用的是Struct,但是在C++中,与C不同,Struct中也可以含有函数。在这种情况下,Struct的功能和Class几乎是一致的,唯一的区别就是Struct默认是Public,而class默认是private。在例子的关系图中,_List_node右上角的T表示这是一个函数模板。
最常用的继承关系是公有继承关系,_List_node是由_List_node_base派生得到的。在公有继承的关系中,基类的public成员变为派生类的public成员、基类的protected成员变成派生类的private成员、基类的private成员不可见。也就是说,在_List_node中,存在着_M_next这个public成员。
除了public继承之外,还存在着private继承和protected继承。
继承关系下的指针、重写、虚函数
由于继承表现的是一种“is-a”关系,在指针的使用上就应该有以下的特点:
- 由于“苹果是水果”,所以指向水果的指针应该可以指向苹果。
- 由于“水果并不是苹果”,所以指向苹果的指针不能指向水果。
也就是说,基类指针可以指向派生类;而派生类指针不可以指向基类。
当我们使用基类指针指向派生类对象并对其进行操作时,我们只能采用基类对象定义的方法进行操作,因为基类指针无法知道派生类新添加了什么样的方法。另外一种情况是,基类已经定义了一种操作,比如“水果”定义了一种操作“腐烂”。任何水果都会腐烂,但是腐烂的时间函数又各不相同,因此对每一种具体的水果,都要有一个具体的“腐烂”操作。这种在派生类中改写基类函数的行为就叫做重写。在这种情况下,如果用“水果”指针指向“苹果”并调用“腐烂”函数,C++会自动选取最为直接的“腐烂”函数,也就是“水果”的“腐烂”函数,而不是在“苹果”类中改写的新函数,这显然不符合我们的需要。
为了解决上面的两个问题,引入虚函数——virtual关键字。
- 非虚函数,默认的函数类型。你不希望子类覆写他。 int func(){......}
- 虚函数。你希望子类覆写他,并且在使用基类指针指向派生类时,你希望先检测是否发生了重写,如果有重写,则使用重写后的新函数。 virtual int func(){.......}
- 纯虚函数。这个功能应该在派生类中存在,然而作为基类无法得知具体的实现方法。因此,你要求派生类必须重写,并且在基类里不进行定义。 virtual int func()=0;
注1:使用纯虚函数的设计模式称为Template Method
将设计分为Framework和Application两个部分,在Framework层中构思好全部的功能,但不考虑如何去实现,也就是使用纯虚函数;在第二层使用继承,去进行具体的实现。
注2:即使函数被重写,也可以显式的调用重写前的函数(如果有权限)。例如class A:public B{...} A a;
在A中,对B中的func()方法进行了重写。则a.func()调用的是新的func方法。如果想调用原来的func()方法,则可以采用a.B::func()的方法去调用原来的函数。
注3:含有纯虚函数的类是虚基类,虚基类不能声明出对象(因为他还有一部分成员没有实现),只能是由实现了那部分函数的虚基类的派生类创建对象。
继承关系下的构造与析构
- 与组合关系一样,构造由内而外,析构由外而内;构造和析构函数中不需要显式对基类进行操作,编译器自动执行。
- 基类的析构函数必须是virtual。其目的在于,如果用父类指针指向子类对象,若通过父类指针对其进行delete操作,则会使用父类的析构函数导致析构不完全。因此在一般的设计中,都要把基类设计为虚函数。
- 同时存在组合和继承关系似的构造顺序:基类(继承)->成分(组合)->自己;析构顺序与此正好相反。
继承、组合下的复制构造函数与拷贝赋值函数:
- 拷贝构造函数和拷贝赋值函数不像基类的构造函数可以自动被调用,必须要显式地进行调用。
- 显示调用拷贝构造函数的方法是,使用初始化列表。Derived::Derived(const Derived& other):Base(other)。
- 显式调用拷贝赋值函数的方法是,直接在函数中使用Base::operator=(other)。
- 如果没有显示调用拷贝构造函数,则其会自动调用无参构造函数。
第三部分、一些补充
一、变量的生命周期
1)stack object
即默认的对象类型,在作用域(一个{...}成为一个作用域;特别地,对于临时对象,当前行就是它的作用域)结束时,object的生命周期就结束
2)static object
在程序结束时,变量的生命周期才结束。但是static变量的调用时牢牢限制在作用域之中的,你无法在{...}之外的地方调用大括号内定义的static变量,尽管他们实际上是存在的。
3)global object
在所有的{}之外定义的变量,成为全局变量,在任何地方都可以调用它。如果想使其只能够在当前文件中使用,可以加static关键字,使其变为静态全局变量,作用域为当前的文件。
4)heap object
在变量被delete或程序结束时结束,有new必须要delete。
二、类和对象的内存空间
当定义了一个类时,我们为类分配了一块存储空间,里边含有的是:静态数据成员、非静态函数、静态函数。
当我们再定义属于某一个类的一个对象时,我们为对象有分配了一些空间,里边仅包括非静态的数据成员。
也就是说,静态数据和各种函数在整个类中是共用一个的。对于非静态函数,其隐藏的第一参数为this指针,因此编译器才能够找到相应对象的数据成员的位置并进行正确的操作。例如,对于Complex类,若有以下语句:
complex C1;
cout<<C1.real()
它的本质实际上是 cout<<complex::real(&C1);
不过,编程时并不能这么写,这个指针的参数是自动加上的。
而对于静态函数,并不包含隐藏的this指针,因此静态函数就是用来操作静态成员的(静态函数没有this指针无法找到对象的位置,也就无法对对象的非晶态成员进行任何操作)
对于静态的数据成员,必须在类的外面进行显示的创建。因为在类中仅仅是声明,并没有真的为它分配内存。
即使m_rate是private类型的变量,也仍然是采用这种方式去进行初始化;注意到其前面存在double的类型名,也就是说这里实际上才是真正为m_rate分配内存的地方,在分配内存之后,它才表现出private的特点。
静态变量可以通过两种方法去调用,既可以通过类的方式,直接调用Account::m_rate;也可以通过对象的方式,Account a;a.m_rate
三、new、delete与内存分配
编译器眼中的new和delete:
new
Complex *pc = new Complex(1,2);
//自动转化为(注:自己这样写是无法通过变异的)
Complex *pc;
void * mem = operator new(sizeof(Complex));//用万能指针分配内存
pc = static_cast<Complex*>(mem);//万能指针特化
pc->Complex::Complex(1,2);//进行定位构造(自己写的话,就是定位new方法,以后会提到)
delete
1 String * ps = new String ("hello"); 2 delete ps; 3 //转化为 4 String::~String(ps);//先对自身进行析构; 5 operator delete(ps);/再归还申请的内存;
new的内存分配(以VC 32位、complex类为例)
1)单个变量
左侧为Debug模式下编译时对new分配的内存空间,含有一些调试信息;右侧为Release模式下分配的内存空间。在VC中,要求分配的内存都是16的倍数,因此左侧存在着12Byte的占位符;图中的红色部分为Cookie,他表示着分配的内存块的大小。0x41表示分配的内存是0x40也就是64Bytes,0x11表示分配的内存是)x10也就是16Bytes。
2)变量数组
new产生的变量数组,含有一个参数存储着有多少个变量;例子中就为3。
在此可以解释一下delete p和delete [] p 的区别。如前面所说,delete分为两个操作,析构和释放内存。析构的次数是由变量数这一参数决定的。delete [] p在这里就会连续三次调用析构函数,而delete则跳过这一检查只调用一次。在析构之后,根据cookie中所写的内存块大小,归还所申请的内存空间。
对于complex类这种析构函数为空的类来说,调用多少次析构函数都无所谓,只要内存全部被归还了就可以了;因此delete p和delete [] p没有什么实质上的区别;
但对于string类这种析构函数中进行了归还内存空间的操作的类来说,使用delete p会导致后面的几个指针所申请的空间没有归还(因为只调用了一次析构函数),但是指针本身却被释放掉了,从而那部分空间就再也没有被回收的可能了,导致内存泄漏。
四、综合OOP的各种方法的几种设计模式简介
1)Observer(观察者)
结合委托和继承
Subject中的attach操作是用来“注册”的,就是将所有的想要查看m_value的数值的对象登记;在notify()函数中,则是对所有对象进行更新数值的操作。所有的对象都要从Observer中派生出去。
2) Composite(广泛应用于文件系统)
委托+继承
Primitive——纯净物,在文件系统中就表示文件;Composite——混合物,在文件系统中表示文件夹。
图中类的表示法,注意,成员变量的类型都写在冒号之后,-号表示这个成员变量是private的。
Component——成分,是可以组成文件夹的内容,这些内容既可以是文件,也可以是文件夹。文件和文件夹都由这个类派生出去。如果文件夹想要包含一个问价你,只需要让文件夹中的指针指向这一文件,这就是委托的方式。通过这种继承的关系,文件夹的这一指针既可以指向文件夹,也可以指向单独的文件。
3) Prototype
有这样的一个需求:在基类中定义一个函数,能够创建它的子类对象。
然而,由于未来的子类还没有被定义,所以按照一般的方式是不可能做到的,因此采用添加原型—克隆原型的方法去进行“创建对象”。
图中的表示法,#表示private;下划线表示static。
实现这一功能的方法是,每个派生类要包含一个静态的他本身,在静态函数的构造函数中,将其“注册”到基类的数组中。当基类想要创建一个新的派生类对象时,通过这个静态的成员,调用一个clone函数(注:clone在基类中是作为纯虚函数而存在的),clone函数将返回一个新的对象变量,用这种方式实现了变量的复制。