[GeekBand] C++学习笔记(2)——BigThree、OOP

 

本篇笔记主要分为三个部分,第一部分是以String类为例的基于对象的编程,重点在于构造与析构、拷贝构造函数、拷贝赋值函数三个重要函数。这一部分与笔记(1)中的内容结合起来就是基于对象编程的主要内容。第二部分是在掌握了基于对象编程的基础上的面向对象编程(OOP)学习,讲解了类之间的组合、继承、委托关系。最后一部分则是一些关于面向对象编程的一点补充,包括内存空间、生命周期、new和delete等,以及几种综合利用组合、继承、委托的设计模式简介。node

 

第一部分、以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. 构造函数与析构函数

  如笔记(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;一个操做便可。cookie

  须要注意的是,构造函数是先进行分配成员内存和赋初值的工做再进入函数体;而析构函数是先进入函数体执行操做,退出函数体后编译器自动归还成员变量的内存。函数

  有此例可知,在构造函数中存在申请堆空间的new操做时,必需要在析构时进行delete操做。学习

 

  2. 默认拷贝构造函数和默认拷贝赋值函数this

  所谓拷贝构造函数,是指当使用一个对象去初始化同类对象时会进行调用的函数。spa

  例如:

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.拷贝赋值与拷贝构造的处理逻辑

  拷贝赋值:

  1.  
    1. 检查是否为自我赋值,若是是,直接返回。
    2. 归还原内存
    3. 申请新内存
    4.       深度复制内容

  注意第一步检查是否为自我赋值的工做不可省略。由于若是是自我赋值,即产生了"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.  
    1. 申请内存
    2. 深度复制内容

  拷贝构造的任务比较简单,只需申请内存并深度复制便可。在拷贝构造函数中也可使用拷贝赋值函数。

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、变量的生命周期

  1)stack object

    即默认的对象类型,在做用域(一个{...}成为一个做用域;特别地,对于临时对象,当前行就是它的做用域)结束时,object的生命周期就结束

  2)static object

    在程序结束时,变量的生命周期才结束。可是static变量的调用时紧紧限制在做用域之中的,你没法在{...}以外的地方调用大括号内定义的static变量,尽管他们其实是存在的。

  3)global object

    在全部的{}以外定义的变量,成为全局变量,在任何地方均可以调用它。若是想使其只可以在当前文件中使用,能够加static关键字,使其变为静态全局变量,做用域为当前的文件。

  4)heap object

    在变量被delete或程序结束时结束,有new必需要delete。

2、类和对象的内存空间

  当定义了一个类时,咱们为类分配了一块存储空间,里边含有的是:静态数据成员、非静态函数、静态函数。

  当咱们再定义属于某一个类的一个对象时,咱们为对象有分配了一些空间,里边仅包括非静态的数据成员。

  也就是说,静态数据和各类函数在整个类中是共用一个的。对于非静态函数,其隐藏的第一参数为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

 

3、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会致使后面的几个指针所申请的空间没有归还(由于只调用了一次析构函数),可是指针自己却被释放掉了,从而那部分空间就再也没有被回收的可能了,致使内存泄漏。

 

4、综合OOP的各类方法的几种设计模式简介

1)Observer(观察者)

  结合委托和继承

  Subject中的attach操做是用来“注册”的,就是将全部的想要查看m_value的数值的对象登记;在notify()函数中,则是对全部对象进行更新数值的操做。全部的对象都要从Observer中派生出去。

2) Composite(普遍应用于文件系统)

委托+继承

Primitive——纯净物,在文件系统中就表示文件;Composite——混合物,在文件系统中表示文件夹。

图中类的表示法,注意,成员变量的类型都写在冒号以后,-号表示这个成员变量是private的。

Component——成分,是能够组成文件夹的内容,这些内容既能够是文件,也能够是文件夹。文件和文件夹都由这个类派生出去。若是文件夹想要包含一个问价你,只须要让文件夹中的指针指向这一文件,这就是委托的方式。经过这种继承的关系,文件夹的这一指针既能够指向文件夹,也能够指向单独的文件。

3) Prototype

有这样的一个需求:在基类中定义一个函数,可以建立它的子类对象。

然而,因为将来的子类尚未被定义,因此按照通常的方式是不可能作到的,所以采用添加原型—克隆原型的方法去进行“建立对象”。

图中的表示法,#表示private;下划线表示static。

实现这一功能的方法是,每一个派生类要包含一个静态的他自己,在静态函数的构造函数中,将其“注册”到基类的数组中。当基类想要建立一个新的派生类对象时,经过这个静态的成员,调用一个clone函数(注:clone在基类中是做为纯虚函数而存在的),clone函数将返回一个新的对象变量,用这种方式实现了变量的复制。

相关文章
相关标签/搜索