面向对象设计


持续更新,补充C++新增feature,目录结构遵循《C++ Primer》

定义基类

class Quote{
public:
	Quote() = default;
	Quote(const string& book, double price):bookNo(book),price(price){};
	string isbn() const{
		return bookNo;
	}
	virtual double net_price(size_t n) const {
		return n* price;
	}
	virtual ~Quote() = default; //对析构函数进行动态绑定
private:
	string bookNo;
protected:
	double price=0.0;
};

上面我们定义了一个基类,有下面几点需要注意:

  1. 基类通常都应该定义一个虚的析构函数,即是它不执行任何操作
  2. 区分两种成员函数,一种是希望子类继承调用的,编译期即可确定函数地址;一种是希望子类重写的,用virtual声明,函数地址在运行时进行动态绑定
  3. virtual只用于成员函数声明,不能出现在定义中
  4. 访问范围说明符
    • 基类的private成员:可以被下列函数访问:
      • 基类的成员函数
      • 基类的友员函数
    • 基类的public成员:可以被下列函数范根:
      • 基类的成员函数
      • 基类的友员函数
      • 派生类的成员函数
      • 派生类的友员函数
      • 其他函数
    • 基类的protected成员:可以被下列函数访问:
      • 基类的成员函数
      • 基类的友员函数
      • 子类内部的成员函数可以访问父类protected成员

定义子类

class Bulk_Quote: public Quote{
public:
	Bulk_Quote() = default;
	Bulk_Quote( const string&, double, size_t, double);
	//override虚函数
	double net_price(size_t ) const override;
private:
	size_t min_qty=0; 
	double discount = 0.0;
};
  • 子类拥有基类全部的成员函数和成员变量,不论是private, protected 还是 public,在子类的各个成员函数中,不能访问父类的private成员
  • C++ 11可以允许子类使用override关键字显式声明override父类的成员函数
  • 子类对象的内存空间
    • 等于父类对象的内存空间 + 子类对象自己的成员变量的体积
    • 在子类对象中,包含着父类对象,而且父类对象的存储位置位于派生类对象新增的成员变量之前
           Quote  ----------- 
                  | bookNo  |
                  | price   |
                  |---------|
      Bulk_Quote  | min_qty |
                  | discount|
                  -----------
    
  • 子类到父类的类型转换
    • 从子类向基类的类型转换只对指针或者引用有效
    • 不存在从基类向子类的隐式转换
      Quote item; //父类对象
      Quote_Bulk bulk; //子类对象
      Quote* p = &item; //基类指针,p指向Quote对象
      p = &bulk; //基类指针隐式绑定到子类对象
      Quote &r = bulk; //基类引用隐式绑定到子类对象
    
      //不能将父类指针隐式转为子类指针
      Quote base;
      Bulk_Quote* bulkp = &base; //wrong!
      Bulk_Quote& bulkRef = base; //wrong!
    
  • 子类的构造与析构
    • 子类构造过程
      1. 先执行基类的构造函数,初始化父类的成员变量
      2. 调用成员对象类的构造函数,初始化成员对象
      3. 调用自身构造函数
    • 子类析构过程
      1. 调用成员对象类的析构函数
      2. 调用父类的析构函数
      3. 调用自身析构函数
      Bulk_Quote( const string& book, double p, 
      size_t qty, double disc):
      Quote(book,p),min_qty(qty),discount(disc){
          //使用统一构造函数初始化基类成员
      };
    

    上述代码我们显式的初始化了基类成员,如果不调父类的构造函数,则基类成员将被默认初始化。另外需要注意一点,对基类成员的初始化应该尽量遵循接口调用,即使用基类的构造函数初始化基类成员,而不是在子类中直接修改基类成员的值。

  • 子类调用父类的成员
    • 如果子类override了父类同名的成员变量,访问父类的成员变量时需要使用父类类名::成员名;
    • 成员函数同理:父类类名::成员函数名;
      struct Base{
          Base():mem(0){}
          int get_mem(){
              return Base::mem; //显式声明返回自己的mem
          }
          int memfcn();
          int mem;
      }
      struct Derived:public Base{
          Derived(int i):mem(i){} //i初始化Derived::mem, 父类的mem进行默认初始化
          int get_mem(){
              return mem; //返回的是Derived::mem
          }
          //这个会覆盖掉基类的同名方法
          int memfcn(int);
          //覆盖父类成员
          int mem;
      }
    	
      Derived d(42);
      d.get_mem(); //42;
    
      Derived d; Base b;
      b.memfcn(); //调用Base::memfcn
      d.memfcn(19); //调用Derived::memfcn
      d.memfcn(); //wrong!,此时编译器无法确定是调用自己的memfcn忘了传参,还是要调用父类的memfcn
      d.Base::memfcn(); //正确,需要显式调用
    

    派生类除了override虚函数外,尽量不要覆盖基类的同名的成员或者方法

    • 如果父类不希望子类覆写自己的共有成员函数,则需要加上final关键字
      struct B{
          int f1(int) const final;
      }
      struct C:public B{
          int f1(int) const; //wrong!
      }
    
  • 使用静态成员变量

    对于基类的静态成员,不管存在多少子类,都只存在唯一实例,属于类本身,可以通过命名空间访问,也可以通过对象访问。

      class Base{
      public:
          static void statmem();
      };
      class Derived:public Base{
      public:
          void f(const Derived& other){
              Base::statmem(); //通过命名空间访问
              Derived::statmem(); //通过命名空间访问
              other.statmem(); //通过对象访问
              statmem(); //使用this访问
          }
      }
    

虚函数

C++中当使用一个基类型的引用或者指针调用一个虚函数时,会执行动态绑定(即在运行时根据调用者的具体类型来决定执行哪个虚函数),由于编译器在编译的时候无法确定哪个虚函数被执行,因此虚函数必须有定义。同样,这里有一个概念需要特别明确:动态绑定只发生在指针或者引用调用虚函数。这里有两个条件,一是调用方为指针或者引用类型,二是调用的函数为虚函数。

class Base{
public:
	virtual void print1(){
		cout<<"base print1"<<endl;
	};
	void print2(){
		cout<<"base print2"<<endl;
	}
};
class Derived:public Base{
public:
	void print1() override{
		cout<<"child print1"<<endl;
	}
	void print2(){
		cout<<"base print2"<<endl;
	}
}

Derived child;
Base& parent = child;
parent.print1(); //会发生动态绑定,因为parent的实际类型为child,因此会调用child的print1
parent.print2(); //不会发生动态绑定,print2不是虚函数,调用parent的print2

Base parent2 = child;
parent2.print1(); //不会发生动态绑定,调用parent的print1,因为parent2不是指针或引用类型

类比Objective-C可以发现,在OC中类的成员方法都是”虚的”,不区分方法是不是virtual,C++相对来说限制了一定的动态性。

多态

所谓多态是指指针或者引用对象的静态类型和它们在运行时的动态类型不相同,则我们可以说这个指针或者引用有多种类型。上面例子中,parent.print1()就属于多态调用,虽然parent被声明成了一个基类型(Base)的引用,但在运行时它的实际类型为Derived,因此将会调用子类的print1方法。为了加深对动态绑定的理解,我们再来看一个例子

class Base {  
public:
    virtual void vf1() { cout<<"Base::vf1"<<endl; }
    virtual void vf2() { cout<<"Base::vf2"<<endl; }
    void f1() { cout<<"Base::f1"<<endl; }
    void f2() { cout<<__func__<<endl; } 
private:
    int var1;
    int var2;
};
class Derived: public Base {
public:
    virtual void vf1() { cout<<"Derived::vf1"<<endl; }
    void f1() { cout<<"Derived::f1"<<endl; }
    void f3() { cout<<"Derived::f3"<<endl; }
private:
    int var3;
};
// experiment vtable and vptr
int main(){
    Derived d;
    Base& b = d;
    b.vf1(); //Derived::vf1
    b.f1(); //Base::f1
    b.vf2(); ////Base::vf2
    b.Base::vf1(); //Base::vf1
    return 0;
}

这个例子和前面的例子类似,由于b的类型是引用,且vf1是虚函数,因此在调用该函数的时候发生了动态绑定,而f1是普通成员函数,编译器在编译的时候即可根据b的静态类型来找到f1,因此会调用基类的f1函数。如果想要调用基类的vf1,则需要显式的调用。

C++是如何实现动态绑定的呢?答案是使用虚表。如果一个类中有虚函数,那么这个类的对象中会有一个指向虚表的指针:

可以把虚表理解为一个指针数组,数组中的每个元素是一个指针变量,指向某个函数地址。运行时如果发生动态绑定则通过对指针数组进行寻址:(*(p->vptr)[n])(p)来得到具体调用的函数,如上图所示。

值得注意的是,如果一个类的构造函数中有虚函数调用,则不会发生多态,因为构造对象时需要明确对象的类型。此外,类的析构函数尽量定义为虚函数,这样会确保在发生多态时,子类的析构函数会被调用。比如下面代码:

class CSon{
public: 
	~CSon(){};
};

class CGrandson: CSon{
public: 
	~CGrandson(){};
}; 

int main(){
	CSon* p = new CGrandson();
	delete p;
	return 0;
}

上面代码中,当delete p时,由于析构函数不是虚函数,因此编译器会调用CSon的析构函数,而不会调用CGrandson的析构函数。这显然不是我们希望的结果,我们希望当p释放时,先调用~CGrandson()而后调用~CSon()。因此,解决办法将让父类的析构函数变为虚函数:

class CSon{
public: 
	virtual ~CSon(){};
};

接口与抽象类

使用多态可以让某个基类具有类似“接口”的能力,对于某些场景,我们只需要调用具有虚函数的基类对象即可,也就是所谓的面向接口编程。

可将类中的虚函数类比为Java中的Interface或者Objective-C中的protocol

在C++中定义接口的一种方式是使用纯虚函数(用=0表示),定义了纯虚函数的类称为抽象基类,抽象基类不能被实例化,就好比接口不能被实例化一样,它需要某个对象实现这个接口,对应到C++则是需要某个子类继承抽象类,并实现接口(纯虚函数)。

class Disc_Quote:public Quote{
public:
	Disc_Quote( const string& book, double p,size_t qty, double disc):
	Quote(book,p),quantity(qty),discount(disc){};

	//pure virtual function
	double net_price(size_t price) const = 0;
private:
	size_t quantity=0; 
	double discount = 0.0;
};
Disc_Quote dq; //wrong! 抽象类不能被实例化

Resources