C++面向对象基础

C++面向对象基础

面向对象三大特性

  • 封装性:数据和代码捆绑在一起,避免外界干扰和不确定性访问。封装可以使得代码模块化。

    优点:

    • 确保用户代码不会无意间破坏封装对象的状态

    • 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码

  • 继承性:让某种类型对象获得另一个类型对象的属性和方法。继承可以扩展已存在的代码

  • 多态性:同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)。多态的目的则是为了接口重用

对多态的理解

多态

​ 多态性可以简单地概括为“一个接口,多种方法”,程序在运行时才决定调用的函数,它是面向对象编程领域的核心概念。

​ C++多态性是通过虚函数来实现的,虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖(override),或者称为重写。

​ 多态可分为静态多态和动态多态。静态多态是指在编译期间就可以确定函数的调用地址,并生产代码,这就是静态的,也就是说地址是早早绑定的,静态多态也往往被叫做静态联编。 动态多态则是指函数调用的地址不能在编译器期间确定,必须需要在运行时才确定,这就属于晚绑定,动态多态也往往被叫做动态联编。 ​ 静态多态往往通过函数重载(运算符重载)和模版(泛型编程)来实现

动态绑定

​ 当使用基类的引用或指针调用虚成员函数时会执行动态绑定。动态绑定直到运行的时候才知道到底调用哪个版本的虚函数,所以必为每一个虚函数都提供定义,而不管它是否被用到,这是因为连编译器都无法确定到底会使用哪个虚函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。

构造/析构函数

拷贝构造函数
  • 概念

    ​ 如果一个构造函数的第一个参数是自身类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。

  
  class Foo{
  public:
      Foo();           //构造函数
      Foo(const Foo&);  //拷贝构造函数   
  }
  • 为什么参数为引用类型

    ​ 简单的回答是为了防止递归引用。​ 具体一些可以这么讲:当 一个对象需要以值方式传递时,编译器会生成代码调用它的拷贝构造函数以生成一个复本。如果类A的拷贝构造函数是以值方式传递一个类A对象作为参数的话,当需要调用类A的拷贝构造函数时,需要以值方式传进一个A的对象作为实参; 而以值方式传递需要调用类A的拷贝构造函数;结果就是调用类A的拷贝构造函数导致又一次调用类A的拷贝构造函数,这就是一个无限递归。

  • 调用时机

  1. 使用=定义变量的时候

  2. 将一个队形作为形参传递给非引用类型的形参

  3. 从一个返回为非引用类型的函数返回一个对象

  4. 用花括号初始化一个数组中的元素或一个聚合类中的成员

深拷贝和浅拷贝
  • 深拷贝时,当被拷贝对象存在动态分配的存储空间时,需要先动态申请一块存储空间,然后逐字节拷贝内容。

  • 浅拷贝仅仅是拷贝指针字面值。

  • 当使用浅拷贝时,如果原来的对象调用析构函数释放掉指针所指向的数据,则会产生空悬指针。因为所指向的内存空间已经被释放了。

例子

  
  深拷贝:当对象中含有指针域的时候,在进行对象之间初始化(也就是调用拷贝构造函数)或者是=操作的时候(注:浅两者是不同的情况),将指针所包含的内存空间中的内容也进行拷贝
  
  浅拷贝:当对象中含有指针域的时候,在进行对象之间初始化(也就是调用拷贝构造函数)或者是=操作的时候(注:浅两者是不同的情况),单纯将指针的值(也就是所指内存空间的首地址)拷贝,这就导致两个对象的指针域是同一块内存,所以在对象生存周期完毕时,调用析构函数,释放内存的时候出现core down的情况!
  
  原因分析:因为C++提供的默认拷贝构造函数和=操作都是浅拷贝操作,即只是将指针域进行值复制。
  
  解决方法:重写默认拷贝构造函数  重载=操作符
拷贝赋值运算符
  • 概念:

    • 与类控制其对象如何初始化一样,类也可以控制器对象如何赋值:

        
        Sales_data trans, accum;
        trans = accum;  //使用Sales_data的拷贝赋值运算符

      与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器也会为它合成一个。

    • 重载赋值运算符

      • 重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。

      • 如果是一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

      • 拷贝赋值运算符接受一个与其类相同类型的参数:

          
          class Foo{
          public:
              Foo& operator=(const Foo&);  //赋值运算符
              //...
          };

        为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。注意,标准库通常要求保存在容器中的类型要有其赋值运算符,且其返回值是左侧运算对象的引用。

    • 合成拷贝赋值运算符

      ​ 如果一个类未定义自己的拷贝赋值运算符,编译器会给它生成一个合成拷贝赋值运算符。

        
        //eg:
        Sales_data&
        Sales_data::operator=(const Sales_data &rhs)
        {
            bookNo=rhs.bookNo;
            units_sold=rhs.units_sold;
            revenue=rhs.revenue;
            return *this;
        }

  • 与拷贝构造函数区别:

    ​ 拷贝构造函数和赋值运算符的行为比较相似,都是将一个对象的值复制给另一个对象;但是其结果却有些不同,拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例。这种区别从两者的名字也可以很轻易的分辨出来,拷贝构造函数也是一种构造函数,那么它的功能就是创建一个新的对象实例;赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。调用的是拷贝构造函数还是赋值运算符,主要是看是否有新的对象实例产生。如果产生了新的对象实例,那调用的就是拷贝构造函数;如果没有,那就是对已有的对象赋值,调用的是赋值运算符。

阻止拷贝
  1. 定义删除的函数

    ​ 在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除函数来阻止拷贝。删除函数:虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:

      
      struct NoCopy{
          NoCopy()=default;         //使用合成的默认构造函数
          NoCopy(const NoCopy&)=delete;                      //阻止拷贝
          NoCopy& operator=(const NoCopy&)=delete;           //阻止赋值
          ~NoCopy()=default;        //使用合成的析构函数
      }

    注:析构函数不能是删除的函数;

  2. private拷贝控制

    ​ 在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝:

      
      class PrivateCopy{
      private:
          PrivateCopy(const PrivateCopy&);
          PrivateCopy &operator=(const PrivateCopy&);
      public:
          PrivateCopy()=default;
          ~PrivateCopy();     //用户可以定义此类型的对象,但无法拷贝它们
      }
析构函数
  • 析构函是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:

      
      class Foo{
      public:
          ~Foo();   //析构函数
          //...
      };

    由于析构函数不接受参数,因此它不能被重载。对于一个给定类,只会由唯一一个析构函数。

  • 在一个构造函数中,成员的初始化时在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序进行销毁。

  • 无论何时一个对象被销毁,就会自动调用其析构函数:

      
      1.变量在离开其作用域时被销毁
      2.当一个对象被销毁时,其成员被销毁
      3.容器(无论是标准容器还是数组)被销毁时,其元素被销毁
      4.对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
      5.对于临时对象,当创建它的完整表达式结束时被销毁
左值引用&右值引用

左值引用符:&

右值引用符:&&

左值&右值:

  • 左值:一般指的是一个对象,或者说是一个持久的值,例如赋值的返回值、下标操作、解引用以及前置递增等。

  • 右值:一个短暂的值,比如一个表达式的求值结果、函数返回值以及一个字面值等。

右值引用作用:

​ 为了支持移动操作(包括移动构造函数和移动赋值函数),C++才引入了一种新的引用类型——右值引用,可以自由接管右值引用的对象内容。

区别:

  1. 绑定的对象(引用的对象)不同,左值引用绑定的是返回左值引用的函数、赋值、下标、解引用、前置递增递减

  2. 左值持久,右值短暂,右值只能绑定到临时对象,所引用的对象将要销毁或该对象没有其他用户

  3. 使用右值引用的代码可以自由的接管所引用对象的内容

others:

左值引用不能绑定到右值对象上,右值引用也不能绑定到左值对象上。

由于右值引用只能绑定到右值对象上,而右值对象又是短暂的、即将销毁的。也就是说右值引用有一个重要性质:只能绑定到即将销毁的对象上。

左值、右值引用的几个例子:

  
  int i = 42;//如前所述,i是一个左值对象  
  int &r = i;//正确,左值引用绑定到左值对象i  
  int &&rr = i;//错误,右值引用绑定左值对象  
  int &r2 = i * 42;//错误,如前所述i*42是临时变量,是右值,而&r2是左值引用  
  int &&rr2 = i * 42;//正确,右值引用绑定右值对象  

注意:以上绑定规则有一个例外,如果左值引用是const类型的,则其可以绑定到右值对象上。

  
  const int &r3 = i * 42;//正确,我们可以将一个const的引用绑定到一个右值对象上  

对于一个左值,若想使用其右值引用,我们可以用move函数:

  
  int &&rr3 = std::move(rr1);//正确,显式使用rr1的右值引用  
对象移动

​ 很多情况下都会发生对象拷贝,在某些情况下,对象拷贝后就立即被销毁,在这些情况下,移动而非拷贝对象会大幅提升性能。

​ 移动构造函数第一个参数是该类类型的一个引用,不同于拷贝构造函数的是,这个引用是一个右值引用。与拷贝构造函数一样,任何额外参数都必须有默认实参。

​ 下面实现一个StrVec到另一个StrVec的元素移动而非拷贝:

  
  StrVec::StrVec(Strvec &&s) noexcept  //移动操作不应抛出任何异常
      //成员初始化器接管s中的资源
      :elements(s.elements),first_free(s.first_free),cap(s.cap)
  {
      //令s进入这样的状态——对其运行析构函数是安全的
      s.elements=s.first_free=s.cap=nullptr;//stt:使移动源对象指向null,避免之后内存两次释放
  }

​ 与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的StrVec中的内存。在接管内存后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象继续存在。最终,移动源对象会被销毁,意味着将在其上运行析构函数。

运算符重载

概念

​ C++中预定义的运算符的操作对象只能是基本数据类型。但实际上,对于许多用户自定义类型(例如类),也需要类似的运算操作。这时就必须在C++中重新定义这些运算符,赋予已有运算符新的功能,使它能够用于特定类型执行特定的操作。运算符重载的实质是函数重载,它提供了C++的可扩展性,也是C++最吸引人的特性之一。

遵循规则

  1. 除了类属关系运算符"."、成员指针运算符".*"、作用域运算符"::"、sizeof运算符和三目运算符"?:"以外,C++中的所有运算符都可以重载。

  2. 重载运算符限制在C++语言中已有的运算符范围内的允许重载的运算符之中,不能创建新的运算符。

  3. 运算符重载实质上是函数重载,因此编译程序对运算符重载的选择,遵循函数重载的选择原则。

  4. 重载之后的运算符不能改变运算符的优先级和结合性,也不能改变运算符操作数的个数及语法结构。

  5. 运算符重载不能改变该运算符用于内部类型对象的含义。它只能和用户自定义类型的对象一起使用,或者用于用户自定义类型的对象和内部类型的对象混合使用时。

  6. 运算符重载是针对新类型数据的实际需要对原有运算符进行的适当的改造,重载的功能应当与原有功能相类似,避免没有目的地使用重载运算符。

例子:

  
  #include<iostream>
  using namespace std;
  
  class test
  {
  private:
      int a, b;
  public:
      test(int m,int n) {
          a = m;
          b = n;
      }
      void show(){
          cout << "a:" << a <<"   "<< "b:" << b << endl;
      }
      test test::operator+(const test &A) const{
          test tmp(0, 0);
          tmp.a = a + A.a;
          tmp.b = b + A.b;
          return tmp;
      }
      test test::operator*(const test &A)const{
          test tmp(0, 0);
          tmp.a = a * A.a;
          tmp.b = b * A.b;
          return tmp;
      }
  };
  
  int main(){
      test h(2,3);
      test H(8, 4);
      test k = h + H;
      k.show();
      k = h*H;
      k.show();
      return 0;
  }

相等运算符重载

  
  bool operator==(const Sales_data &lhs,const Sales_data &rhs)
  {
      return lhs.isbn()==rhs.isbn()&&lhs.units_sold==rhs.units_sold&&lhs.revenue==rhs.revenue;
  }
  
  bool operator!=(const Sales_data &lhs,const Sales_data &rhs)
  {
      return !(lhs==rhs);
  }

访问控制与继承

访问权限publicprotectedprivate
对本类可见可见可见
对子类可见可见不可见
对外部(调用方)可见不可见不可见

友元与继承

​ 类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。像友元关系不能传递一样,友元关系同样不能继承,基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。

虚函数相关

虚函数

virtual在函数中的使用限制:

  • 普通函数不能是虚函数,也就是说这个函数必须是某一个类的成员函数,不可以是一个全局函数,否则会导致编译错误。

  • 静态成员函数不能是虚函数,static成员函数是和类同生共处的,他不属于任何对象,使用virtual也将导致错误。

  • 内联函数不能是虚函数,如果修饰内联函数 如果内联函数被virtual修饰,编译器会忽略inline使它变成存粹的虚函数。

  • 构造函数不能是虚函数,否则会出现编译错误。

派生类中的虚函数:

  • 当派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。然而并非必须,因为一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。

  • 一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数一致。

虚函数表

nonstatic数据成员被放置到对象内部,static数据成员、static和nonstatic函数成员军备放到对象之外。对于虚函数的支持则分两部分完成:

1、每一个class产生一堆指向虚函数的指针,并存放在虚函数表中(Virtual Table,vtbl);

2、每个对象被添加了一个指针,指向相关的虚函数表vtbl。通常这个指针被称为vptr。vptr的设定和重置都由每一个class的构造函数,析构函数和拷贝赋值运算符自动完成。

另外,虚函数表地址的前面设置了一个指向type_info的指针,RTTI(Run Time Type Identification)运行时类型识别是由编译器在编译时生成的特殊类型信息,包括对象继承关系,对象本身的描述。RTTI是为多态而生成的信息,所以只有具有虚函数的对象才会生成。

这个模型的优点在于它的空间和存取时间的效率;缺点如下:如果应用程序本身未改变,当所使用的类的nonstatic数据成员添加删除或修改时,需要重新编译。

纯虚函数

​ 通过在函数体的位置(即在声明语句的分号前)书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只出现在类内部的虚函数声明语句处。

​ 含有纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。不能创建一个抽象基类的对象。

虚析构函数

​ 如果基类的析构函数不为虚函数,则delete一个指向派生类对象的基类指针将产生未定义行为。

  
  #include <iostream.h>
  class Base 
  { 
  public: 
  Base() { mPtr = new int; } 
  ~Base() { delete mPtr; cout<<"Base::Destruction"<<endl;} 
  private: 
    int* mPtr; 
  } ;
  
  class Derived : public Base 
  { 
  public: 
    Derived() { mDerived = new long; } 
    ~Derived() { delete mDerived; cout<<"Derived::Destruction"<<endl;} 
  private: 
    long* mDerived; 
  } ;
  
  void main() 
  { 
    Base* p = new Derived; //父类指针指向子类对象,指向派生类的基类指针
    delete p; 
  }

输出结果只有:Base::Destruction

​ 以上代码会产生内存泄露,因为new出来的是Derived类资源,采用一个基类的指针来接收,析构的时候,编译器因为只是知道这个指针是基类的,所以只将基类部分的内存析构了,而不会析构子类的,就造成了内存泄露,如果将基类的析构函数改成虚函数,就可以避免这种情况,因为虚函数是后绑定,其实就是在虚函数列表中,析构函数将基类的析构函数用实际对象的一组析构函数替换掉了,也就是先执行子类的虚函数再执行父类的虚函数,这样子类的内存析构了,父类的内存也释放了,就不会产生内存泄露。

注:

1.析构函数其实是一个函数,不论子类还是父类,虽然可能看起来名字不一样。而且析构函数执行过程都是执行子类再到父类。

2.多态的时候一定要将析构函数写成虚函数,防止内存泄露,各个子类维护自己内部数据释放。

内联函数、构造函数、静态成员函数&虚函数
  • 内联函数、构造函数、静态成员函都可以为虚函数吗? NO

内联函数(inline)需要在编译阶段展开(在编译时就已经确定了),而虚函数是运行时动态绑定的,编译时无法展开,因此是矛盾的;

构造函数在进行调用时还不存在父类和子类的概念,父类只会调用父类的构造函数,子类调用子类的,因此不存在动态绑定的概念(先有父类才能有子类,构造父类的时候子类还不存在,子类都还没有怎么可能在父类里动态调用子类);

静态成员函数(static)是以类为单位的函数,与具体对象无关,虚函数是与对象动态绑定的,因此是两个矛盾的概念;

  • 构造函数中可以调用虚函数吗?

可以,但是没有意义,起不到动态绑定的效果。父类构造函数中调用的仍然是父类版本的函数,子类中调用的仍然是子类版本的函数。

虚继承

  • 虚继承和虚函数是完全无相关的两个概念:

    ​ 虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。

  • 虚继承可以解决多种继承前面提到的两个问题:

    ​ 虚继承底层实现原理与编译器相关,一般通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。

    (pic from inside the c++ object model pg86)

    ​ 在虚拟继承中,C++对象模型将Class分为两个区域,一个是不变区域,直接存储在对象中;一个是共享区域,存储的是virtual base class subobjects,它在内存中单独存储在某处,derived class object持有指向它的指针。在cfront编译器中,每一个derived class object中安插一些指针,每个指针指向一个virtual base class,为此需要付出相应的时间和空间成本。

  • 49
    点赞
  • 206
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值