• 学习c++的第十二天


    目录

    多态性

    多态性

    虚函数

    虚析构函数

    纯虚函数

    数据抽象和封装

    设计策略

    接口(抽象类)

    设计策略


    多态性

    多态性

    多态性的实现依赖于继承和虚函数。在面向对象编程中,通过基类的指针或引用来调用派生类的成员函数时,可以根据实际的对象类型决定调用哪个类的成员函数,从而实现多态性。

    • 在静态联编中,编译器在编译阶段就能确定调用的具体函数,这种情况下多态性的效果并不明显。而在动态联编中,编译器只能确定调用的是基类的成员函数,具体调用哪个派生类的函数则在运行时决定,这就发挥了多态性的优势。
    • 动态联编和多态性的典型应用是通过虚函数实现的。虚函数是在基类中声明的带有 virtual 关键字的成员函数,它可以被派生类重写(Override)。当使用基类的指针或引用调用虚函数时,实际调用的是派生类中重写的函数。

    下面,我们围绕静态联编,给大家举一个简单例子:

    1. #include
    2. class Shape {
    3. public:
    4. void draw() {
    5. std::cout << "绘制一个形状" << std::endl;
    6. }
    7. };
    8. class Circle : public Shape {
    9. public:
    10. void draw() {
    11. std::cout << "绘制一个圆形" << std::endl;
    12. }
    13. };
    14. class Rectangle : public Shape {
    15. public:
    16. void draw() {
    17. std::cout << "绘制一个矩形" << std::endl;
    18. }
    19. };
    20. int main() {
    21. Shape shape;
    22. Circle circle;
    23. Rectangle rectangle;
    24. // 基类指针指向派生类对象
    25. Shape* shapePtr1 = &circle;
    26. Shape* shapePtr2 = &rectangle;
    27. // 调用基类的成员函数,由于静态联编,在编译时就确定了调用的函数
    28. shape.draw(); // 绘制一个形状
    29. shapePtr1->draw(); // 绘制一个形状
    30. shapePtr2->draw(); // 绘制一个形状
    31. return 0;
    32. }

    在上述示例中,我们有一个基类 Shape 和两个派生类 Circle 和 Rectangle。基类和派生类都有一个名为 draw 的成员函数。

    在 main 函数中,我们创建了一个基类对象 shape 和两个派生类对象 circle 和 rectangle。然后,我们将派生类对象的地址赋给基类指针 shapePtr1 和 shapePtr2。

    在调用成员函数时,基类对象 shape 和派生类对象的指针 shapePtr1 和 shapePtr2 都调用了 draw 函数。由于静态联编,在编译时就确定了调用的函数,因此无论是通过基类对象还是派生类对象的指针调用,都会执行基类的 draw 函数,输出 "绘制一个形状"。

    这个示例展示了静态联编的特点,即在编译时就确定了调用哪个函数,不会根据对象的实际类型来决定调用哪个函数。

    通过多态性,我们可以以一种统一的方式处理不同类型的对象。这样做的好处是,我们可以将对象视为其基类类型,而不需要关注具体是哪个派生类的实例。这使得我们可以设计出更加灵活和可扩展的代码,同时提高了代码的可读性和可维护性。

    总结来说,多态性是面向对象编程的重要特性,它通过继承和虚函数机制实现。它允许不同类型的对象对同一个消息做出不同的响应,提高了代码的灵活性和可扩展性。静态联编和动态联编是多态性的两种实现方式,静态联编由于编译时候就已经确定好怎么执行,因此执行起来效率高;而动态联编想必虽然慢一些,但优点是灵活。两者各有千秋,有各自不同的使用场景。

    虚函数

    虚函数是用于实现动态联编和多态性的一种机制。通过将父类中的成员函数声明为虚函数,可以在子类中重写该函数并根据对象的实际类型选择调用哪个函数,从而实现多态性。

    在 C++ 中,将函数声明为虚函数需要在函数名前加上关键字 virtual。当一个函数被声明为虚函数时,它的子类中的同名函数也会自动成为虚函数。当使用一个指向子类对象的基类指针或引用调用虚函数时,程序会在运行时动态地确定调用哪个函数,这就是动态联编。

    一般形式如下:

    1. virtual 函数返回值 函数名(形参)
    2. {
    3. 函数体
    4. }

    需要注意的是:

    1. 虚函数不能是静态成员函数或友元函数:虚函数依赖于对象的实际类型来确定调用哪个函数,而静态成员函数和友元函数不属于特定的对象,因此无法实现动态多态性,所以它们不能被声明为虚函数。
    2. 内联函数不能是虚函数:内联函数是在编译时插入代码的,而虚函数的调用是在运行时动态决定的。即使虚函数在类的定义中被标记为 inline,编译器仍然会将其视为非内联函数,因为虚函数的调用需要在运行时根据对象的实际类型来确定。
    3. 构造函数不能是虚函数,析构函数可以是虚函数:构造函数在创建对象时被调用,并负责初始化对象的成员变量等工作。由于在调用构造函数时对象尚未完全构建,因此无法实现动态多态性,所以构造函数不能声明为虚函数。而析构函数在销毁对象时被调用,可以实现动态多态性,通常情况下我们将析构函数声明为虚函数,以确保在删除基类指针时正确调用派生类的析构函数,防止内存泄漏。

    以下是一个简单的使用虚函数的示例:

    1. #include
    2. using namespace std;
    3. class Shape {
    4. public:
    5. virtual void draw() {
    6. cout << "绘制一个形状" << endl;
    7. }
    8. };
    9. class Circle : public Shape {
    10. public:
    11. void draw() {
    12. cout << "绘制一个圆形" << endl;
    13. }
    14. };
    15. class Rectangle : public Shape {
    16. public:
    17. void draw() {
    18. cout << "绘制一个矩形" << endl;
    19. }
    20. };
    21. int main() {
    22. Shape shape;
    23. Circle circle;
    24. Rectangle rectangle;
    25. Shape* shapePtr1 = &circle;
    26. Shape* shapePtr2 = &rectangle;
    27. // 通过基类指针调用虚函数,程序会在运行时动态地确定调用哪个函数
    28. shape.draw(); // 绘制一个形状
    29. shapePtr1->draw(); // 绘制一个圆形
    30. shapePtr2->draw(); // 绘制一个矩形
    31. return 0;
    32. }

    在上述示例中,我们将 Shape 类的 draw 函数声明为虚函数,并在其派生类 Circle 和 Rectangle 中重写了该函数。在 main 函数中,我们创建了一个基类对象 shape 和两个派生类对象 circle 和 rectangle,并将派生类对象的地址赋给基类指针。

    通过基类指针调用虚函数时,程序会在运行时动态地确定调用哪个函数。因此,通过基类对象 shape 调用 draw 函数时,输出 "绘制一个形状";通过派生类对象的指针 shapePtr1 和 shapePtr2 调用 draw 函数时,分别输出 "绘制一个圆形" 和 "绘制一个矩形",实现了多态性。

    虚析构函数

    在C++中,我们不能将构造函数声明为虚函数,因为构造函数的调用是在实例化对象时发生的,而虚函数的实现是通过虚函数表来实现的。在实例化对象之前,对象还没有被创建,也没有内存空间可供调用虚函数,所以将构造函数声明为虚函数是没有意义的。

    然而,析构函数可以被声明为虚函数,并且通常情况下我们将其声明为虚析构函数。通过将析构函数声明为虚函数,可以在删除指向派生类对象的基类指针时,根据实际所指向的对象类型动态绑定调用相应的析构函数,从而实现正确的对象内存释放。

    当使用基类指针指向派生类对象时,如果基类的析构函数不是虚函数,那么在删除指针时只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类对象中动态分配的资源没有得到正确释放,从而造成内存泄漏。

    通过声明析构函数为虚函数,我们可以保证在删除基类指针时调用正确的析构函数,确保对象的内存得到正确释放,避免内存泄漏问题。

    下面是一个使用虚析构函数的示例:

    1. #include
    2. using namespace std;
    3. class Base {
    4. public:
    5. virtual ~Base() {
    6. cout << "调用了基类的析构函数" << endl;
    7. }
    8. };
    9. class Derived : public Base {
    10. public:
    11. ~Derived() {
    12. cout << "调用了派生类的析构函数" << endl;
    13. }
    14. };
    15. int main() {
    16. Base* ptr = new Derived();
    17. delete ptr;
    18. return 0;
    19. }

    在上述示例中,我们定义了一个基类 Base 和一个派生类 Derived。基类 Base 的析构函数被声明为虚函数(使用 virtual 关键字),而派生类 Derived 的析构函数没有显式声明为虚函数。

    在 main 函数中,我们创建了一个派生类对象的指针 ptr,并将其赋给基类指针。接着,我们使用 delete 关键字删除基类指针,这会触发析构函数的调用。

    由于基类的析构函数是虚函数,因此在删除指针时,会调用正确的析构函数。输出结果为:

    1. 调用了派生类的析构函数
    2. 调用了基类的析构函数

    可以看到,通过将基类的析构函数声明为虚函数,我们确保了派生类的析构函数也被正确地调用,从而释放了派生类对象中的资源。

    纯虚函数

    在C++中,纯虚函数是一种在基类中声明但没有实现的虚函数。纯虚函数的声明形式是在函数原型的末尾使用 "= 0" 进行标记。纯虚函数用于定义一个接口,要求派生类必须实现该函数。

    virtual 返回值 函数名(形参)=0;

    纯虚函数的作用是定义一个基类的接口,这个接口可以被派生类继承并实现。通过在基类中声明纯虚函数,我们可以强制派生类提供相应的实现,以确保派生类具有某些特定的行为或功能。

    下面是一个使用纯虚函数的示例:

    1. #include
    2. using namespace std;
    3. class Shape {
    4. public:
    5. virtual double getArea() const = 0; // 纯虚函数
    6. };
    7. class Rectangle : public Shape {
    8. private:
    9. double length;
    10. double width;
    11. public:
    12. Rectangle(double l, double w) : length(l), width(w) {}
    13. double getArea() const override { // 派生类必须实现纯虚函数
    14. return length * width;
    15. }
    16. };
    17. class Circle : public Shape {
    18. private:
    19. double radius;
    20. public:
    21. Circle(double r) : radius(r) {}
    22. double getArea() const override { // 派生类必须实现纯虚函数
    23. return 3.14159 * radius * radius;
    24. }
    25. };
    26. int main() {
    27. Shape* shape1 = new Rectangle(5, 6);
    28. Shape* shape2 = new Circle(3);
    29. cout << "矩形的面积: " << shape1->getArea() << endl;
    30. cout << "圆形的面积: " << shape2->getArea() << endl;
    31. delete shape1;
    32. delete shape2;
    33. return 0;
    34. }

    在上述示例中,我们定义了一个基类 Shape,其中声明了一个纯虚函数 getArea()。这个函数没有提供实现,只是要求派生类必须实现它。派生类 Rectangle 和 Circle 都继承自基类 Shape,并实现了 getArea() 函数。

    在 main 函数中,我们创建了基类指针 shape1 和 shape2,分别指向派生类对象 Rectangle 和 Circle。通过调用 getArea() 函数,我们可以获得矩形和圆形的面积。

    注意到,基类 Shape 是抽象类,因为它包含一个纯虚函数。抽象类不能被实例化,只能用作其他类的基类。而派生类必须实现纯虚函数,否则它们也会成为抽象类。

    数据抽象和封装

    在C++中,数据抽象是一种面向对象编程的概念,它允许将数据和对数据的操作分离开来,并隐藏了数据的内部细节。数据抽象通过定义类来实现,类将数据成员和成员函数封装在一起,外部只能访问类的公有接口,而不能直接访问类的私有成员。

    数据抽象的目的是为了实现信息隐藏和封装,以提高程序的可维护性和安全性。通过隐藏数据的内部表示和实现细节,我们可以防止外部代码直接修改数据,从而确保数据的有效性和一致性。

    访问标签通过定义成员变量和成员函数的访问级别,实现了数据抽象和封装。

    在C++中,访问标签包括public、private和protected。它们用于控制类的成员的访问权限。

    • public标签定义的成员可以被该程序的所有部分访问。公共成员通常用于定义类的接口,因为它们对外部代码是可见的。
    • private标签定义的成员只能在类内部访问,无法被使用类的代码直接访问。私有成员通常用于隐藏实现细节和保护数据的安全性。
    • protected标签定义的成员与私有成员类似,可以在类内部访问,但也允许派生类访问这些成员。

    访问标签可以多次使用,并且每个标签的作用范围会一直有效,直到遇到下一个访问标签或者类主体的结束右括号。

    1. class MyClass {
    2. public: // 公有标签
    3. void publicMethod() {
    4. // 这个方法可以被任何地方的代码访问
    5. }
    6. private: // 私有标签
    7. int privateVariable; // 私有成员变量,无法被外部代码直接访问
    8. protected: // 保护标签
    9. void protectedMethod() {
    10. // 这个方法可以在类内部和派生类中访问
    11. }
    12. };

    在这个示例中,publicMethod()是一个公共成员函数,可以被任何地方的代码访问。privateVariable是一个私有成员变量,只能在类内部访问。protectedMethod()是一个受保护的成员函数,可以在类内部和派生类中访问。

    通过使用访问标签,我们可以控制类成员的可访问性,达到数据抽象和封装的目的。这样可以隐藏实现细节,提高程序的安全性和可维护性。

    下面是一个简单的示例,展示了如何使用数据抽象和封装:

    1. #include
    2. using namespace std;
    3. class BankAccount {
    4. private:
    5. string accountNumber; // 账号
    6. double balance; // 余额
    7. public:
    8. BankAccount(string accNum) : accountNumber(accNum), balance(0) {}
    9. void deposit(double amount) { // 存款
    10. balance += amount;
    11. }
    12. void withdraw(double amount) { // 取款
    13. if (amount <= balance) {
    14. balance -= amount;
    15. } else {
    16. cout << "余额不足" << endl;
    17. }
    18. }
    19. double getBalance() const { // 获取余额
    20. return balance;
    21. }
    22. };
    23. int main() {
    24. BankAccount myAccount("123456789"); // 创建账户对象
    25. myAccount.deposit(1000); // 存款
    26. cout << "账户余额: " << myAccount.getBalance() << endl;
    27. myAccount.withdraw(500); // 取款
    28. cout << "账户余额: " << myAccount.getBalance() << endl;
    29. myAccount.withdraw(1000);
    30. cout << "账户余额: " << myAccount.getBalance() << endl;
    31. return 0;
    32. }

    这个示例代码中,BankAccount 类代表银行账户,其中的私有成员有 accountNumber(账号)和 balance(余额)。这些私有成员被封装在类的私有部分,外部无法直接访问。

    BankAccount 类提供了公共的成员函数 deposit()(存款)、withdraw()(取款)和 getBalance()(获取余额),用于对账户进行操作。这些函数是类的公有接口,外部代码通过调用它们来操作账户。

    在 main 函数中,我们创建了一个 BankAccount 对象 myAccount,并通过公有接口进行存款、取款和获取余额操作。由于私有成员被封装起来,外部无法直接修改账户余额,只能通过调用类的方法来进行操作。

    通过数据抽象和封装,我们可以隐藏内部数据细节,使得代码更加模块化和易于理解。

    设计策略

    数据抽象和封装是面向对象编程中的两个重要设计策略。

    数据抽象是指将数据和对数据的操作分离,只暴露必要的接口给外部使用,隐藏内部实现细节。它通过定义抽象数据类型(ADT)来实现,包括数据的定义和对数据进行操作的方法。数据抽象使得程序设计更加模块化,可以降低系统的复杂性,提高代码的可维护性和可复用性。

    封装是指将数据和操作数据的方法封装在一个单元(类)中,形成一个独立的模块。封装通过访问修饰符(如public、private、protected)控制成员的可见性,只允许通过公共接口访问和操作数据,隐藏了内部实现的细节。封装可以防止外部直接访问和修改对象的状态,提供了数据的安全性和一致性。

    数据抽象和封装的设计策略有以下几个优点:

    1. 隐藏实现细节:将内部实现细节隐藏起来,只暴露必要的接口,避免了外部直接访问和修改内部状态,保护了数据的完整性和安全性。
    2. 提高代码的可维护性:通过封装将相关的数据和方法组织在一起,使得代码更加模块化和可维护。
    3. 提高代码的可复用性:通过数据抽象定义抽象数据类型,可以在不同的程序中重复使用,减少代码的重写和修改。
    4. 简化接口:封装将复杂的内部实现封装起来,对外提供简明清晰的接口,降低了使用者的认知负担,使得代码更易于使用和理解。

    综上所述,数据抽象和封装是面向对象编程中的重要设计策略,可以提高代码的可维护性、可复用性和安全性。

    接口(抽象类)

    在面向对象编程中,接口(interface)是一种抽象数据类型的实现方式。接口定义了一个类或对象应该执行的操作,并指定了这些操作的输入和输出。它只包含函数原型和常量,没有任何实现代码。

    接口通常用于定义程序的公共接口,使得不同的类或对象可以通过实现相同的接口达到一致的行为。这有助于提高代码的模块化和可重用性,同时也提供了更好的代码隔离和安全性。

    在C++中,接口可以通过抽象类(abstract class)来实现。抽象类是一种不能被实例化的类,它只能作为其他类的基类使用。抽象类中至少有一个纯虚函数(pure virtual function),即没有实现的虚函数。

    纯虚函数通过在函数声明末尾添加=0来声明,表示没有函数体,这就是一个纯虚函数。包含纯虚函数的类就是抽象类,一个抽象类至少有一个纯虚函数。例如:

    1. class MyInterface {
    2. public:
    3. virtual void myFunction() = 0; // 纯虚函数
    4. };

    上面示例中,myFunction()是一个纯虚函数,没有实现代码。这个类成为抽象类,不能直接实例化。如果想要使用这个接口,需要创建一个子类,并实现纯虚函数。子类必须实现所有的纯虚函数,否则它自己也会成为抽象类。

    下面是一个示例代码,展示了如何使用抽象类来实现接口:

    1. #include
    2. using namespace std;
    3. // 定义接口
    4. class MyInterface {
    5. public:
    6. virtual void myFunction() = 0; // 纯虚函数
    7. };
    8. // 子类实现接口
    9. class MyClass : public MyInterface {
    10. public:
    11. void myFunction() override { // 实现纯虚函数
    12. cout << "MyClass::myFunction() called" << endl;
    13. }
    14. };
    15. int main() {
    16. MyClass obj; // 实例化子类
    17. obj.myFunction(); // 调用接口函数
    18. return 0;
    19. }

    在这个示例中,MyInterface是一个接口,它定义了一个纯虚函数myFunction()。MyClass是这个接口的一个实现,它通过继承MyInterface并实现myFunction()来实现接口。

    在main()函数中,我们创建了一个MyClass对象,并调用了它的myFunction()函数,这个函数实际上是实现了MyInterface接口的函数。

    抽象类的特点可以概括为以下几点:

    1. 抽象类无法实例化对象,只能作为基类供派生类继承并完善其中的纯虚函数后才能被实例化使用。
    2. 派生类可以继续保持抽象类的性质,即不完善基类中的纯虚函数,这样的派生类仍然是抽象类。只有当派生类给出了所有纯虚函数的具体实现,才能成为一个具体类,从而可以实例化对象。
    3. 由于抽象类是抽象的、无法具体化,因此不能直接作为参数类型、返回值或进行强制类型转换。
    4. 尽管不能直接实例化抽象类,但可以定义指针或引用类型,指向其派生类的对象,从而实现多态特性。

    通过使用抽象类和纯虚函数,我们可以定义类的接口,提高代码的模块化和可重用性,同时也避免了实现细节的暴露,提高了程序的安全性和可维护性。

    设计策略

    这是一个常见的设计策略,称为抽象基类/接口设计模式。它通过在抽象基类中定义公共函数作为纯虚函数(即没有具体实现的函数),来提供一个通用的接口。派生类继承抽象基类,并实现这些纯虚函数,从而为外部应用程序提供具体的功能。

    这种设计策略有以下优点:

    1. 标准化接口:通过抽象基类定义的接口,可以提供一个统一、标准化的接口给外部应用程序使用,使得不同的实现可以以一致的方式进行交互。

    2. 可扩展性:新的应用程序可以轻松地添加到系统中,只需要继承抽象基类并实现相应的函数即可,无需修改现有的代码。这样可以保持系统的稳定性和可扩展性。

    3. 代码复用:通过抽象基类定义公共的接口和功能,可以避免重复编写相同的代码,提高代码的复用性和开发效率。

    4. 松耦合:抽象基类将接口和实现分离,外部应用程序只需要依赖于抽象基类而不是具体的实现类。这样可以降低模块之间的耦合度,提高代码的灵活性和可维护性。

    然而,这种设计策略也有一些注意事项:

    1. 合理定义抽象基类:需要合理地定义抽象基类,将相关的操作进行归类,并定义合适的接口。如果抽象基类过于庞大或不符合单一职责原则,可能会导致设计上的问题。

    2. 考虑接口的稳定性:一旦抽象基类被定义并在系统中使用,其接口就应该保持稳定,以避免对现有代码的破坏性修改。因此,在设计抽象基类时应该考虑未来的需求和变化,并预留扩展空间。

    综上所述,抽象基类/接口设计模式是一种常见的设计策略,可以提供通用的、标准化的接口,同时保持代码的可扩展性和可维护性。通过合理定义抽象基类和接口,可以实现代码的模块化、复用和解耦。

  • 相关阅读:
    xss-labs/level9
    利用pytorch自定义CNN网络(三):构建CNN模型
    MATLAB算法实战应用案例精讲-【大模型】LLM算法(最终篇)
    Java面试题:Java中垃圾回收机制是如何工作的?请描述几种常见的垃圾回收算法
    数据库
    QT--对象模型(对象树)
    ctfshow SSRF
    Android Studio 的六种基本布局
    城市道路拥堵终结者,智能停车场系统组网解决方案
    JAVA JDBC训练之 CallableStatement 的案例
  • 原文地址:https://blog.csdn.net/m0_74293254/article/details/134228242