【C++】多态

在这里插入图片描述

欢迎来到Cefler的博客
博客主页:那个传说中的man的主页
个人专栏:题目解析
推荐文章:题目大解析(3)


目录

  • 多态
    • 概念
    • 实现多态条件
    • 虚函数实现的条件
      • 虚函数重写的两个例外
  • 基类和子类的赋值兼容规则
  • 重写和重定义
  • 纯虚函数
    • final和override
  • 静态联编和动态联编
  • 抽象类
  • ✍继承与多态的一些选择题

多态

概念

C++的多态是指,通过基类指针或引用来调用派生类的函数,实现运行时动态绑定的特性。具体来说,多态有两种形式:虚函数和模板函数。

  1. 虚函数

在C++中,通过在基类中声明虚函数,然后在派生类中覆盖该虚函数,就实现了动态绑定的特性。假设我们有一个Shape基类和两个派生类Circle和Rectangle,其中Shape中有一个纯虚函数getArea(),我们可以这样实现多态:

class Shape {
public:
    virtual double getArea() = 0;
};

class Circle : public Shape {
public:
    double radius;
    Circle(double r) : radius(r) {}

    double getArea() {
        return radius * radius * 3.14;
    }
};

class Rectangle : public Shape {
public:
    double width;
    double height;
    Rectangle(double w, double h) : width(w), height(h) {}

    double getArea() {
        return width * height;
    }
};

int main()
{
    Circle c(5);
    Rectangle r(3, 4);

    Shape* pShape1 = &c;
    Shape* pShape2 = &r;

    std::cout << "Circle area: " << pShape1->getArea() << std::endl;
    std::cout << "Rectangle area: " << pShape2->getArea() << std::endl;

    return 0;
}

在上面的代码中,我们使用了基类指针pShape1和pShape2来分别指向派生类对象c和r,并通过调用pShape1->getArea()和pShape2->getArea()来实现多态。由于getArea()是虚函数,因此在代码运行时会动态绑定到对应的派生类函数。

  1. 模板函数

C++的模板机制也可以实现多态。例如,我们可以定义一个print()模板函数,通过在运行时根据不同类型进行特化,实现调用不同的函数:

template <typename T>
void print(T arg) {
    std::cout << arg << std::endl;
}

void print(std::string str) {
    std::cout << "String: " << str << std::endl;
}

void print(double num) {
    std::cout << "Double: " << num << std::endl;
}

int main()
{
    int n = 10;
    std::string str = "hello";
    double d = 3.14;

    print(n); // 调用模板函数
    print(str); // 调用重载函数
    print(d); // 调用重载函数

    return 0;
}

实现多态条件

实现多态的条件主要有两个:

  1. 基类中需要声明虚函数

在C++中,如果希望派生类能够覆盖基类的成员函数,就必须将基类中对应的成员函数声明为虚函数。在派生类中重新定义该函数时,要使用virtual关键字标识。

  1. 使用基类指针或引用调用派生类的虚函数

实现运行时动态绑定的核心就是通过基类指针引用来调用派生类的虚函数!!!。具体来说,当我们将一个派生类对象赋值给一个基类指针或引用时,编译器会根据对象的实际类型在运行时进行动态绑定,即将对虚函数的调用映射到对应的派生类函数。从而实现了多态的特性。

总之,实现多态的前提是需要在基类中声明虚函数,并通过基类指针或引用调用派生类的虚函数
句举例进行再次说明

当我们定义一个基类和它的一个或多个派生类时,通常会在基类中定义一个或多个虚函数。虚函数是一种特殊的函数,可以使得派生类对象在调用基类指针或引用时表现出不同的行为。具体地说,在基类声明一个虚函数时,派生类可以覆盖这个函数并提供自己的实现。

在使用派生类对象时,我们通常会创建基类指针或引用来引用这个对象。例如:

class Animal {
public:
    virtual void makeSound() const {
        std::cout << "动物正在发出声音" << std::endl;
    }
};

class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "汪汪汪!" << std::endl;
    }
};

int main() {
    Animal* animalPtr = new Dog();
    animalPtr->makeSound();
    delete animalPtr;

    return 0;
}

在上面的代码中,我们定义了一个 Animal 类和一个 Dog 类,其中 Dog 是从 Animal 派生而来的。我们创建了一个指向 Dog 对象的 Animal 指针,然后通过调用 makeSound 函数来输出这只狗发出的声音。由于 makeSound 函数是虚函数,因此将在运行时动态决定调用哪个版本的函数。

假设我们创建了两种派生类,分别是 PoodlePitbull。如果我们使用基类指针或引用来调用 makeSound 函数,会根据实际类型来调用适当的派生类函数。

int main() {
    Animal* animal1 = new Poodle();
    Animal* animal2 = new Pitbull();

    animal1->makeSound(); // 调用派生类 Poodle 的 makeSound 函数
    animal2->makeSound(); // 调用派生类 Pitbull 的 makeSound 函数

    delete animal1;
    delete animal2;

    return 0;
}

在上面的代码中,我们创建了两个 Animal 指针,分别指向两个不同的派生类对象。通过调用 makeSound 函数,会根据实际类型分别调用 Poodle 类和 Pitbull 类中的函数。这就是动态联编的机制,它允许使用基类指针或引用来调用派生类函数,从而实现多态性

虚函数实现的条件

在C++中,虚函数的重写条件包括以下几点:

  1. 基类中的虚函数必须使用 virtual 关键字进行声明

在基类中,如果希望派生类能够重写(覆盖)该函数,就需要在基类中使用 virtual 关键字对该函数进行声明。只有被声明为虚函数的成员函数才能在派生类中被重写。

  1. 派生类中的函数必须与基类中的虚函数有相同的名称和签名(三同(函数名/参数/返回))

在派生类中重写虚函数时,函数的名称参数列表返回类型必须与基类中的虚函数相匹配。也就是说,它们应该有相同的函数名,并且参数类型、参数个数、参数顺序以及返回类型都要一致。

  1. 重写的函数不能使用 virtualstatic 关键字修饰

在派生类中重写虚函数时,不需要再使用 virtual 关键字进行声明,因为它已经在基类中声明为虚函数了。此外,重写的函数也不能使用 static 关键字修饰,因为 static 成员函数是属于类本身而不是对象的,而虚函数则是用于多态的。

  1. 虚函数的访问权限可以发生变化,但不能更改为私有(private)

派生类中重写虚函数时,可以改变虚函数的访问权限。例如,如果基类中的虚函数是公有的,派生类可以将其重写为公有、保护或私有的。但是,不能将基类中的虚函数改变为私有,否则在派生类中无法访问该函数。

需要注意的是,重写虚函数的时候还应该遵循其他的函数重写规则,比如派生类中的函数不能改变基类函数的常量性(const)等。以上是虚函数重写的一般条件和规则,遵循这些条件可以正确地实现虚函数的重写和多态特性。

虚函数重写的两个例外

  1. 协变(基类与派生类虚函数返回值类型不同)
    派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
    针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class A{};
class B : public A {};
class Person {
public:
 virtual A* f() {return new A;}
};
class Student : public Person {
public:
 virtual B* f() {return new B;}
};
  1. 析构函数的重写(基类与派生类析构函数的名字不同)
    如果基类的析构函数为虚函数此时派生类析构函数只要定义,无论是否加virtual关键字,
    都与基类的析构函数构成重写
    ,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
    看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
    理,编译后析构函数的名称统一处理成destructor
class Person {
public:
 virtual ~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
 virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
 Person* p1 = new Person;
 Person* p2 = new Student;
 delete p1;
 delete p2;
 return 0;
}

所以,析构是虚函数,才能正确调用析构函数

基类和子类的赋值兼容规则

在面向对象编程中,基类和子类之间存在赋值兼容规则,即可以使用子类对象赋值给基类对象或基类指针。这个规则称为向上转型

具体来说,基类和子类之间的赋值兼容规则如下:

  1. 子类对象可以赋值给基类对象:可以将子类的对象直接赋值给基类的对象,即将子类对象的值复制给基类对象。这种情况下,只会复制从基类继承的成员,而子类特有的成员将被忽略。
BaseClass baseObj;
DerivedClass derivedObj;
baseObj = derivedObj;  // 子类对象赋值给基类对象
  1. 子类指针可以赋值给基类指针:可以将子类对象的地址赋值给基类指针,实现指向子类对象的基类指针。通过这个指针,可以访问基类中的成员和子类中重写的虚函数。这种情况下,子类对象中的成员仍然存在,但只能通过基类指针进行访问。
BaseClass* basePtr;
DerivedClass* derivedPtr = new DerivedClass();
basePtr = derivedPtr;  // 子类指针赋值给基类指针
  1. 子类引用可以绑定到基类对象:可以使用子类引用绑定到基类对象,成为基类对象的别名。通过这个引用,可以访问基类中的成员和子类中重写的虚函数。
BaseClass baseObj;
DerivedClass derivedObj;
BaseClass& baseRef = derivedObj;  // 子类引用绑定到基类对象

需要注意的是,通过基类指针或基类引用访问子类对象时,只能访问到基类中定义的成员和虚函数,而不能直接访问子类特有的成员和非虚函数。

这种赋值兼容规则的存在使得面向对象编程中的多态性得以实现。通过将子类对象赋值给基类对象或基类指针,可以以统一的方式处理不同的子类对象,并在运行时根据对象的实际类型调用正确的虚函数实现多态行为。

在【C++】继承中也已提过赋值兼容规则。

重写和重定义

重写(Override)和重定义(Redeclaration)是面向对象编程中两个相关的概念,用于描述在派生类中重新定义基类中的成员函数。

重写(Override)指的是在派生类中重新定义(覆盖)基类中已存在的虚函数。具体来说,当派生类中定义了一个与基类中虚函数具有相同名称、参数列表和返回类型的函数时,该函数将被视为重写了基类中的虚函数。通过重写,派生类可以提供特定于自身的实现,从而实现多态性。

重写要满足以下几个条件:

  1. 基类中虚函数必须以 virtual 关键字声明。
  2. 派生类中的函数必须与基类虚函数具有相同的名称、参数列表和返回类型(三同)。
  3. 基类和派生类的函数必须是成员函数(可以是公有、私有或受保护的)。
  4. 重写函数不能改变基类函数的访问权限

示例代码如下:

class Base {
public:
    virtual void foo() {
        cout << "Base::foo()" << endl;
    }
};

class Derived : public Base {
public:
    void foo() override {
        cout << "Derived::foo()" << endl;
    }
};

在上述示例中,Base 类有一个虚函数 foo()Derived 类通过重写 foo() 函数提供了特定于自身的实现。

另一方面,重定义(Redeclaration)是指在派生类中重新声明一个与基类中的非虚函数具有相同名称的函数。在这种情况下,并没有使用 virtual 关键字,也不会涉及到多态性。派生类中重新声明的函数将会隐藏基类中相同名称的函数

重定义要满足以下几个条件:

  1. 基类和派生类的函数必须具有相同的名称和参数列表。
  2. 重定义函数可以具有与基类函数不同的返回类型,但在 C++ 中这被视为不好的实践,因为可能会导致编译错误或未定义行为。
  3. 重定义函数可以具有不同的访问权限(例如,基类函数是公有的而派生类函数是私有的)。

示例代码如下:

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

class Derived : public Base {
public:
    void bar() {
        cout << "Derived::bar()" << endl;
    }
};

在上述示例中,Derived 类重新定义了 Base 类中的函数 bar(),从而隐藏了基类中的同名函数。

总结起来,重写(Override)用于在派生类中重新定义基类中的虚函数,以实现多态性,而重定义(Redeclaration)用于在派生类中重新声明非虚函数,隐藏基类中的同名函数。

重载、重写、重定义三概念
【C++】多态_第1张图片

纯虚函数

“纯虚函数”(pure virtual function)又称为“纯虚函数接口”(pure virtual function interface),是一个在 C++ 中的概念,它是一个没有实现的虚函数。

纯虚函数的语法如下:

virtual [return_type] function_name([parameters]) = 0;

其中,= 0 的语法表示该函数是一个纯虚函数。纯虚函数只有声明没有定义,也就是说,需要由派生类实现该函数,这样才能实例化对象。

使用纯虚函数可以实现接口与抽象类的功能,它们都是让其派生类实现它们的功能。

接口是一个类或结构体的抽象的公共接口,它只包含纯虚函数,没有数据成员。接口是一个基类,它不能被实例化,它只能被用作其他类的基类。派生类必须实现接口中的所有纯虚函数。

抽象类是包含至少一个纯虚函数的类,通常它还包含一些非纯虚函数和数据成员。抽象类也不能被实例化,只能被用作其他类的基类。派生类必须实现抽象类中的所有纯虚函数。

下面是一个示例,演示了如何定义和使用纯虚函数:

#include 

class Shape {
public:
    virtual double area() const = 0; // 纯虚函数
};

class Rectangle : public Shape {
public:
    Rectangle(double w, double h) : width(w), height(h) {}

    double area() const override { // 必须覆盖父类的纯虚函数
        return width * height;
    }

private:
    double width;
    double height;
};

class Circle : public Shape {
public:
    Circle(double r) : radius(r) {}

    double area() const override { // 必须覆盖父类的纯虚函数
        return 3.14 * radius * radius;
    }

private:
    double radius;
};

int main() {
    // 创建 Rectangle 和 Circle 对象,并计算它们的面积
    Rectangle rectangle(10, 5);
    Circle circle(3);

    std::cout << "矩形的面积为: " << rectangle.area() << std::endl;
    std::cout << "圆的面积为: " << circle.area() << std::endl;

    return 0;
}

在上述示例中,我们有一个抽象基类 Shape,其中包含一个纯虚函数 area。该函数没有实现,而是由派生类实现。我们定义了两个派生类 RectangleCircle,它们都继承自 Shape 类,并实现了 area 函数。

main 函数中,我们创建了 RectangleCircle 对象,并分别调用它们的 area 函数来计算它们的面积。注意,在调用 area 函数时,实际调用的是 RectangleCircle 类中的版本,而不是基类 Shape 中的版本。

final和override

overridefinal 是 C++11 引入的两个修饰符,用于增强代码的可读性、安全性和可维护性。

override 用于显式地标记派生类中的虚函数覆盖了基类中的虚函数。它可以确保派生类中的函数与基类中的同名函数具有相同的签名,避免出现意外的错误。如果在派生类中声明了一个虚函数,并且使用 override 关键字修饰,但是这个函数与基类中的同名函数的签名不匹配,编译器会给出错误提示。

final 用于防止一个虚函数被覆盖。如果在基类中声明了一个虚函数,并使用 final 关键字修饰,那么派生类将无法覆盖这个函数。这可以确保该函数在整个继承层次结构中始终具有相同的行为。

总结来说就是

  • override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
  • final:修饰虚函数,表示该虚函数不能再被重写

静态联编和动态联编

C++ 中的静态联编(Static Binding)和动态联编(Dynamic Binding)是与函数调用相关的两个概念。

静态联编是在编译时进行的绑定,也被称为早期绑定。在静态联编中,编译器根据函数调用的静态类型(即声明类型)来确定将要调用的函数。这是因为编译器在编译期间已经知道函数的调用方和被调用方的类型,因此可以根据静态类型进行绑定。静态联编的优点是速度快,开销低,但它的缺点是无法在运行时根据对象的实际类型来决定执行哪个函数。

动态联编是在运行时进行的绑定,也被称为晚期绑定。在动态联编中,函数的调用是根据对象的实际类型来确定的,而不是根据静态类型。通过使用虚函数和指针/引用来实现多态性,可以在运行时动态地确定调用哪个实际函数。动态联编的优点是具有灵活性和可扩展性,但它的缺点是相对于静态联编来说,会带来额外的运行时开销。

静态联编主要适用于非虚函数、全局函数和静态成员函数等情况,而动态联编主要用于虚函数的调用。在 C++ 中,通过在基类中将函数声明为虚函数,可以实现动态联编。当基类指针或引用指向派生类对象时,通过该指针或引用调用虚函数时,会根据实际类型来调用适当的派生类函数。

总结一下,静态联编是在编译时根据静态类型确定函数调用,而动态联编是在运行时根据对象的实际类型确定函数调用。两者各有优缺点,在不同的情况下选择合适的绑定方式可以提高代码的可读性、扩展性和性能效果。

抽象类

抽象类是无法实例化的类,它仅用作其他类的基类或接口。抽象类的目的是为了定义一组通用的特征或行为,并要求派生类提供相应的实现细节。

在C++中,通过在类声明中使用纯虚函数(pure virtual function)来创建抽象类。纯虚函数是一种在基类中声明但没有提供实际实现的虚函数。一个类包含纯虚函数时,这个类就成为了抽象类抽象类不能被实例化,而只能被用作其他类的基类

抽象类不能实例化的原因是因为它包含了一个或多个纯虚函数(pure virtual function)。纯虚函数是在基类中声明但没有提供实际实现的虚函数。

抽象类可以包含非纯虚函数和数据成员,这些成员对于派生类来说是继承的。但是,由于存在纯虚函数,无法直接创建抽象类的对象。

下面是一个简单的示例:

class Shape {
public:
    virtual double area() const = 0; // 纯虚函数
};

class Rectangle : public Shape {
private:
    double length;
    double width;
public:
    Rectangle(double l, double w) : length(l), width(w) {}
    double area() const override {
        return length * width;
    }
};

int main() {
    // Shape shape; // 错误!无法实例化抽象类

    Rectangle rect(3.0, 4.0);
    double rectArea = rect.area(); // 调用派生类 Rectangle 的 area 函数
    return 0;
}

在上面的示例中,Shape 是一个抽象类,其定义中包含了一个纯虚函数 area()。由于存在纯虚函数,我们无法直接创建 Shape 类的实例。

然后,我们定义了一个派生类 Rectangle,它重写了 area() 函数来计算矩形的面积。

main() 函数中,我们创建了一个矩形对象 rect,并通过调用 rect.area() 来计算矩形的面积。由于 Rectangle 类继承自 Shape 类,它必须提供对纯虚函数 area() 的具体实现。

通过使用抽象类,我们可以定义一组通用的特征和行为,并要求派生类提供符合自身特性的实现。这种机制可以帮助我们实现多态性和灵活的代码结构。

✍继承与多态的一些选择题

一、
关于基类与派生类对象模型说法正确的是(E)
A.基类对象中包含了所有基类的成员变量
B.子类对象中不仅包含了所有基类成员变量,也包含了所有子类成员变量
C.子类对象中没有包含基类的私有成员
D.基类的静态成员可以不包含在子类对象中
E.以上说法都不对

解析
A.静态变量就不被包含

B.同理,静态变量就不被包含

C.父类所有成员都要被继承,因此包含了

D.静态成员一定是不被包含在对象中的

E.很显然,以上说法都不正确

二、
关于基类与子类对象之间赋值说法不正确的是( B)
A.基类指针可以直接指向子类对象
B.基类对象可以直接赋值给子类对象
C.子类对象的引用不能引用基类的对象
D.子类对象可以直接赋值给基类对象

解析
A.这是赋值兼容规则的其中一条,正确

B.基类不能给子类对象直接赋值,因为父类类型对于子类类型来说类型不完全,故错误

C.不能用父类初始化子类引用

D.这也是赋值兼容规则的其中一条

三、

下面哪项结果是正确的( C)

class Base1 { public: int _b1; };

class Base2 { public: int _b2; };

class Derive : public Base1, public Base2 

{ public: int _d; };



int main(){



Derive d;

Base1* p1 = &d;

Base2* p2 = &d;

Derive* p3 = &d;

return 0;

}

A.p1 == p2 == p3
B.p1 < p2 < p3
C.p1 == p3 != p2
D.p1 != p2 != p3
解析
【C++】多态_第2张图片

p1和p2虽然都是其父类,但在子类内存模型中,其位置不同,所以p1和p2所指子类的位置也不相同,因此p1!=p2,

由于p1对象是第一个被继承的父类类型,所有其地址与子类对象的地址p3所指位置都为子类对象的起始位置,因此p1==p3,所以C正确

四、
关于虚函数说法正确的是( B)
A.被virtual修饰的函数称为虚函数
B.虚函数的作用是用来实现多态
C.虚函数在类中声明和类外定义时候,都必须加虚拟关键字
D.静态虚成员函数没有this指针

解析
A.被virtual修饰的成员函数称为虚函数

B.正确

C.virtual关键字只在声明时加上,在类外实现时不能加

D.static和virtual是不能同时使用的

五、

要实现多态类型的调用,必须(D )
A.基类和派生类原型相同的函数至少有一个是虚函数即可
B.假设重写成功,通过指针或者引用调用虚函数就可以实现多态
C.在编译期间,通过传递不同类的对象,编译器选择调用不同类的虚函数
D.只有在需要实现多态时,才需要将成员函数设置成虚函数,否则没有必要

解析
A.必须是父类的函数设置为虚函数,子类想设置就设置,不设置也没关系

B.必须通过父类的指针或者引用才可以,子类的不行

C.不是在编译期,而应该在运行期间,编译期间,编译器主要检测代码是否违反语法规则,此时无法知道基类的指针或者引用到底引用那个类的对象,也就无法知道调用那个类的虚函数。在程序运行时,才知道具体指向那个类的对象,然后通过虚表调用对应的虚函数,从而实现多态。

D.正确,实现多态是要付出代价的,如虚表,虚表指针等,所以不实现多态就不要有虚函数了


如上便是本期的所有内容了,如果喜欢并觉得有帮助的话,希望可以博个点赞+收藏+关注❤️ ,学海无涯苦作舟,愿与君一起共勉成长
【C++】多态_第3张图片
在这里插入图片描述

你可能感兴趣的:(C++,c++,多态)