polymorphism

静态联编和动态联编

多态性(polymorphism)提供接口与具体实现之间的另一层隔离,从而将”what”和”how”分离开来。多态性改善了代码的可读性和组织性,同时也使创建的程序具有可扩展性,项目不仅在最初创建时期可以扩展,而且当项目在需要有新的功能时也能扩展。

c++支持编译时多态(静态多态)和运行时多态(动态多态),运算符重载和函数重载就是编译时多态,而派生类和虚函数实现运行时多态。

静态多态和动态多态的区别就是函数地址是早绑定(静态联编)还是晚绑定(动态联编)。如果函数的调用,在编译阶段就可以确定函数的调用地址,并产生代码,就是静态多态(编译时多态),就是说地址是早绑定的。而如果函数的调用地址不能编译不能在编译期间确定,而需要在运行时才能决定,这这就属于晚绑定(动态多态,运行时多态)。

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。编译器必须查看函数参数以及函数名才能确定使用哪个函数。

指针和引用类型的兼容性以及向上类型转换

在C++里面动态联编与通过指针和引用的调用方法有关。通常c++不允许将一种一类的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。

double x = 2.5;
int * pi = &x; //类型不对不能这样定义
long & r1 = x; //问题同上

对象可以作为自己的类或者作为它的基类的对象来使用。还能通过基类的地址来操作它。取一个对象的地址(指针或引用),并将其作为基类的地址来处理,这种称为向上类型转换。

父类引用或指针可以指向子类对象,通过父类指针或引用来操作子类对象。

就是指将子类对象的引用赋给父类类型的引用变量的过程。在面向对象编程中,这种类型转换是安全的,因为子类对象可以被当做父类对象来对待。通过向上类型转换,可以实现多态性,即一个父类引用变量可以引用不同子类对象,并根据实际对象类型调用相应的方法。

class Base {
public :
	virtual void func() {
		cout << "class Base" << endl;
	}
};
class Derive_1 : public Base {
public:
	void func() {
		cout << "class Derive_1" << endl;
	}
};
class Derive_2 : public Base {
public:
	void func() {
		cout << "class Derive_2" << endl;
	}
};
void test() {
	Derive_1 d1;
	Derive_2 d2;
	//向上类型转换
	Base* b1 = &d1;
	Base* b2 = &d2;
//通过父类引用变量调用子类方法
	b1->func();
	b2->func();

}

image-20240302105606439

虽然父类调用func函数,但是父类的指针全部指向了子类的引用,并且可以完成隐式类型转换。再如下面这个代码

class Base {
public :
	void func() {
		cout << "class Base" << endl;
	}
};
class Derive_1 : public Base {
public:
	void func() {
		cout << "class Derive_1" << endl;
	}
};

void GetQuestion(Base& b) {
	b.func();
}

void test() {
	Derive_1 d1;
	
	GetQuestion(d1);


}

image-20240302110720280

参数定的是基类的引用,但是传参传的是子类,最后调用的依然是基类的方法。这个地方就引出了一个叫做捆绑的概念。把函数体与函数调用相联系称为绑定(捆绑,binding)

当绑定在程序运行之前(由编译器和连接器)完成时,称为早绑定(early binding).C语言中只有一种函数调用方式,就是早绑定。上面的问题就是由于早绑定引起的,因为编译器在只有Base地址时并不知道要调用的正确函数。编译是根据指向对象的指针或引用的类型来选择函数调用。

在代码里,GetQuestion函数的参数类型是Base&,编译器确定了应该调用的func函数是Base::func(),并不是传入的d1.解决方法就是迟绑定(迟捆绑,动态绑定,运行时绑定,late binding),意味着绑定要根据对象的实际类型,发生在运行。C++语言要实现这种动态绑定,必须有某种机制来确定运行时对象的类型并调用合适的成员函数。对于一种编译语言,编译器并不知道实际的对象类型(编译器并不知道Animal类型的指针或引用指向的实际的对象类型)。

虚函数

其实在上面的代码里也能发现区别就是基类的函数是不是虚函数决定了这个绑定发生在什么时候。如果没有定义虚的,b.func();将根据引用类型调用函数,编译时已知类型之后,对于非虚方法就是用的是静态联编。

  1. 为创建一个需要动态绑定的虚成员函数,可以简单在这个函数声明前面加上virtual关键字,定义时候不需要.

  2. 如果一个函数在基类中被声明为virtual,那么在所有派生类中它都是virtual的.

  3. 在派生类中virtual函数的重定义称为重写(override).

  4. Virtual关键字只能修饰成员函数.

  5. 构造函数不能为虚函数

仅需要在基类中声明一个函数为virtual.调用所有匹配基类声明行为的派生类函数都将使用虚机制。虽然可以在派生类声明前使用关键字virtual(这也是无害的),但这个样会使得程序显得冗余和杂乱。

image-20240302111643528

用《C++Primers》里面的一个图解释一下虚函数的工作原理。通常编译器处理虚函数的方法是:给每个对象添加一个隐藏成员,这个隐藏成员中保存了一个指向函数地址数组的指针(图里的vptr),而这种地址数组就叫做虚函数表(vtbl)。虚函数表里存储了为类对象进行声明的虚函数的地址。比如基类对象Base包含一个指针,该指针指向基类中所有虚函数的地址表,派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新的地址;相反,该虚函数表将保存原始版本的地址。

调用虚函数时,程序将查看存储在对象里的vtbl地址,然后转向相应的函数地址表,如果使用类声明中的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。总之使用虚函数时,在内存和执行速度方面有一定成本:

  1. 每个对象都会被增大其存储空间(和虚基类一样
  2. 每个类编译器都会有一个虚函数地址表
  3. 每个函数的调用都需要执行一项额外的操作就是到表里查找。

实现动态绑定细节过程

当子类无重写基类虚函数时:

image-20240302114920328

image-20240302114947419

子类完全继承基类的函数,他们拥有各自的虚函数表image-20240302115023473

当程序执行到这里,会去animal指向的空间中寻找vptr指针,通过vptr指针找到func1函数,此时由于子类并没有重写也就是覆盖基类的func1函数,所以调用func1时,仍然调用的是基类的func1.

当子类重写基类虚函数时

image-20240302115324609

子类重写了基类的func1,但是没有写func2,所以对应的地址表应该是image-20240302115400573

当程序执行到这里,会去animal指向的空间中寻找vptr指针,通过vptr指针找到func1函数,由于子类重写基类的func1函数,所以调用func1时,调用的是子类的func1.

抽象基类和纯虚函数

在设计时,常常希望基类仅仅作为其派生类的一个接口。这就是说,仅想对基类进行向上类型转换,使用它的接口,而不希望用户实际的创建一个基类的对象。同时创建一个纯虚函数允许接口中放置成员原函数,而不一定要提供一段可能对这个函数毫无意义的代码。

做到这点,可以在基类中加入至少一个纯虚函数(pure virtual function),使得基类称为抽象类(abstract class).

  1. 纯虚函数使用关键字virtual,并在其后面加上=0。如果试图去实例化一个抽象类,编译器则会阻止这种操作。

  2. 当继承一个抽象类的时候,必须实现所有的纯虚函数,否则由抽象类派生的类也是一个抽象类。

  3. Virtual void fun() = 0;告诉编译器在vtable中为函数保留一个位置,但在这个特定位置不放地址。

建立公共接口目的是为了将子类公共的操作抽象出来,可以通过一个公共接口来操纵一组类,且这个公共接口不需要事先(或者不需要完全实现)。可以创建一个公共类.

class AbstractClass {
public:
	virtual void sleep() = 0;
	virtual void dolove() = 0;
	virtual void cook() = 0;
	void func() {
		cook();
		dolove();
		sleep();
	}

};
class Regina : public AbstractClass {
public:
	virtual void sleep() {
		cout << "Regina::sleep()" << endl;
	}
	virtual void dolove() {
		cout << "Regina::dolove()" << endl;
	}
	virtual void cook() {
		cout << "Regina::cook()" << endl;
	}
	
};
class Ivanlee : public AbstractClass {
public:
	virtual void sleep() {
		cout << "Ivanlee::sleep()" << endl;
	}
	virtual void dolove() {
		cout << "Ivanlee::dolove()" << endl;
	}
	virtual void cook() {
		cout << "Ivanlee::cook()" << endl;
	}
};
void home(AbstractClass* a) {
	a->func();
	delete a;
}
void home(AbstractClass& a) {
	a.func();
}
void test() {
	home(new Regina);
	Ivanlee ivan;
	home(ivan);
}

image-20240304155721828

纯虚函数和虚函数是 C++ 中的重要概念,它们都与多态性(polymorphism)和继承相关。它们之间的主要区别在于以下几点:

  1. 虚函数:
    • 虚函数是在基类中声明为虚函数的成员函数,它可以在派生类中被重写(覆盖)。
    • 虚函数可以有默认的实现,如果派生类没有重写虚函数,则会调用基类的实现。
    • 虚函数通过基类指针或引用调用时,可以根据指针或引用所指向的对象的实际类型来动态地决定调用哪个版本的函数(动态联编)。
  2. 纯虚函数:
    • 纯虚函数是在基类中声明并且没有给出实现的虚函数,它只是一个接口,要求任何派生类都必须提供实现。
    • 在 C++ 中,通过在虚函数声明后面加上 = 0 来将其声明为纯虚函数,例如:virtual void myFunction() = 0;
    • 含有纯虚函数的类称为抽象类,无法实例化对象,只能作为基类来派生出其他类。派生类必须提供纯虚函数的实现,否则它们也会变成抽象类。

虚析构函数

虚析构函数是为了解决基类指针指向派生类对象,并用基类的指针删除派生类对象。当通过基类指针删除指向派生类对象的实例时,如果析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致派生类资源得不到正确释放,从而产生内存泄漏或未定义的行为。

class Base {
public:
    virtual ~Base() {
        // 虚析构函数
    }
};

class Derived : public Base {
public:
    ~Derived() {
        // 派生类的析构函数
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;  // 通过基类指针删除派生类对象
    return 0;
}

重写 重载 重定义

class Shape {
public:
    virtual double calculateArea() {
        return 0.0;
    }
};
  1. 重写(Override):

    • 重写是指派生类重新定义(覆盖)基类中已经存在的虚函数的行为。

    • 当派生类定义一个与基类中的虚函数具有相同名称和签名的函数时,它就会覆盖(重写)基类中的虚函数。

    • 通过使用重写,可以在派生类中改变虚函数的行为,实现多态性,即在运行时根据对象的实际类型来确定调用哪个版本的函数。

      class Rectangle : public Shape {
      public:
          double calculateArea() override {
              // 重写基类的虚函数
              return width * height;
          }
      private:
          double width, height;
      };
      
  2. 重载(Overload):

    • 重载是指在同一个作用域内允许存在多个同名函数,但它们的参数列表不同(参数类型、参数个数或参数顺序不同)。

    • 重载函数可以具有相同的名称,但是由于参数列表不同,编译器可以根据调用时提供的参数类型来确定应该调用哪个版本的函数。

      class Shape {
      public:
          virtual double calculateArea() {
              return 0.0;
          }
      
          double calculateArea(int a, int b) {
              // 重载的函数
              return a * b;
          }
      };
      
  3. 重新定义(Redefine):

    • 重新定义通常用于描述对于非虚函数的重新定义。在基类和派生类中,如果存在同名但参数列表不同的函数,这种情况称为函数的重新定义。

    • 在重新定义中,基类和派生类中的函数并不构成多态性,调用哪个版本的函数取决于编译器能够静态确定的最匹配的函数。

      class Circle : public Shape {
      public:
          void draw(int radius) {
              // 派生类中重新定义的函数
              cout << "Drawing a circle with radius " << radius << endl;
          }
      };