c++中的虚函数到底有什么用?需要注意什么?

前言

作为一个c++的初学者,这篇文章我想讲讲我对虚函数的看法和理解,也希望对其他朋友有所帮助,如果文章中有纰漏或者不足之处也欢迎各位指出。

虚函数的定义

虚函数是面向对象编程(特别是在 C++ 等语言中)中的一个核心概念。它允许你在子类中重新定义父类的方法,并且确保在程序运行时,系统能够根据对象的实际类型(而不是定义类型)来决定调用哪个函数。这种行为被称为动态绑定或多态。

首先为什么会需要虚函数?

从上面解释可以看出来虚函数的作用是重新定义父类的方法,然后程序运行后可以根据实际对象来决定调用哪个函数(也就是说如果不使用虚函数会出现实际调用的对象函数并不是自己原本想调用的)。

出现这种情况的原因在于编译器的默认行为,C++的设计原则是“静态类型检查”和“高性能”。在编译阶段时,编译器看到一个类型的指针变量后(比如说Shape*指针),它的逻辑是:

1.这个指针将来可能指向一个Shape对象。

2.这个指针占有8个字节(64位系统),里面存储一个地址。

这个时候编译器就会去Shape类里面找对应的函数(比如说draw函数),然后把调用指令编译进去,而这个时候如果你把Shape的子类Circle对象的地址赋值给这个指针,编译器在编译过程中是并不知道这一点的(因为赋值是发生在运行期间)。所以说编译出来的指令依旧是调用Shape::draw的命令。这种在编译阶段就决定调用哪个函数的方式,叫做静态绑定(Static Binding)或早绑定,而虚函数就是为了解决这个问题设计的。

这里我们可以来看一下结果

#include <iostream> using namespace std; // 基类:形状 class Shape { public: void draw() { cout << "画一个通用的形状" << endl; } }; // 派生类:圆形 class Circle : public Shape { public: void draw(){ cout << "画一个圆形" << endl; } }; // 派生类:矩形 class Rectangle : public Shape { public: void draw() { cout << "画一个矩形" << endl; } }; int main() { Shape* s; // 一个基类指针 Circle c; // 圆形对象 Rectangle r; // 矩形对象 // 指向圆形 s = &c; s->draw(); // 输出: 画一个通用的形状 // 指向矩形 s = &r; s->draw(); // 输出: 画一个通用的形状 return 0; }

可以看出来,当没有标注虚函数时,shape* 指针在调用函数时依旧使用的是Shape类的函数,而不是指针指向的实际对象,而在使用虚函数后

#include <iostream> using namespace std; // 基类:形状 class Shape { public: virtual void draw() { cout << "画一个通用的形状" << endl; } }; // 派生类:圆形 class Circle : public Shape { public: // 重写(Override)基类的 draw 函数 void draw() override { cout << "画一个圆形" << endl; } }; // 派生类:矩形 class Rectangle : public Shape { public: void draw() override { cout << "画一个矩形" << endl; } }; int main() { Shape* s; // 一个基类指针 Circle c; // 圆形对象 Rectangle r; // 矩形对象 // 指向圆形 s = &c; s->draw(); // 输出: 画一个圆形 // 指向矩形 s = &r; s->draw(); // 输出: 画一个矩形 return 0; }

实际上,虚函数的作用就是通过virtual 关键字告诉编译器:"这个函数比较特殊,在程序运行时再去看实际指向对象,然后再调用方法。”由此来解决前面提到的问题。

既然编译器的静态绑定这么麻烦,为什么不直接都用动态绑定(默认都是虚函数)来解决呢?

这边涉及到了一个性能代价的问题。

1.虚函数的调用开销是很大的,在c++的设计中:

  • 非虚函数:调用开销 ≈ 1-2个CPU指令,通常可以内联
  • 虚函数:调用开销 ≈ 5-10个CPU指令,不能内联,阻碍编译器优化
// 假设我们有1000万个这样的调用 class Point { public: void getX() const { return x; } // 非虚函数 virtual void getY() const { return y; } // 虚函数 private: float x, y; }; void processPoints(Point* points, int count) { for(int i = 0; i < count; i++) { // 非虚调用:直接内联,可能就1条CPU指令 float x = points[i].getX(); // 虚函数调用:至少需要3步 // 1. 从对象取出vptr(内存访问) // 2. 从vtable取出函数地址(内存访问) // 3. 间接调用(不能内联,可能影响CPU分支预测) float y = points[i].getY(); } }

2.内存布局的影响

设计都是虚函数的框架的话,每个对象都会多一个指针

class Empty { }; // sizeof(Empty) = 1字节(C++要求每个对象有唯一地址) class WithVirtual { virtual void foo() {} }; // sizeof(WithVirtual) = 8字节(64位系统的vptr大小) // 想象你有一个包含1000万个点的数组 Empty points1[10000000]; // 占用 10 MB WithVirtual points2[10000000]; // 占用 80 MB

这在大规模项目下是非常致命的设计,会占用宝贵的缓存空间和内存空间等等。

3.C++的核心理念:"零开销原则"

C++的设计哲学是著名的"你不用为不需要的东西付出代价"。

// 如果所有函数都是虚函数,那么即使是这个简单的类 class IntWrapper { int value; public: int getValue() const { return value; } // 被迫成为虚函数 void setValue(int v) { value = v; } // 被迫成为虚函数 };

这就会造成:

  • 每个IntWrapper对象都多了8字节
  • 每次get/set都要查虚函数表
  • 完全违背了"简单包装int"的初衷

4. 设计清晰性:接口 vs 实现

虚函数是一种设计声明,它告诉其他程序员:"这个函数是设计用来被重写的"。

class DatabaseConnection { public: void connect(string url); // 非虚:具体的实现步骤 void executeQuery(string sql); // 非虚:固定的流程 virtual void log(string message); // 虚:可以自定义日志 virtual void onConnectionLost(); // 虚:可以自定义处理 };

意图清晰:

  • 非虚函数:"这就是我的实现方式,请直接使用"
  • 虚函数:"这是一个扩展点,你可以根据需求定制"

如果所有函数都是虚的,这个重要的设计意图就丢失了。

使用虚函数需要注意哪些问题?

1.构造函数不写为虚函数

从存储空间角度:

虚函数需要用一个叫vtable的结构(虚函数表)来实现,它实际上是存储在对象内存(一般存储在对象的内存头部)中的一块指针或者表,用于动态绑定虚函数。构造函数是在对象创建(实例化)时调用的,在对象还没有创建好时,内存空间还没有分配好,这时候如果调用虚函数会发现无法找到vtable所在的地址(因为空间还没分配),所以构造函数不能是虚函数。

从使用角度:

虚函数的作用是通过父类的指针或者引用调用来动态绑定到子类的对应函数,而构造函数在对象创建时会自动调用,调用时还没有父类指针或者引用调用来指向字类对象。

实例1:虚函数调用与构造函数调用顺序

#include <iostream> using namespace std; class Base { public: Base() { cout << "Base 构造函数调用" << endl; // 这里调用虚函数 print(); } virtual void print() { cout << "Base::print()" << endl; } virtual ~Base() {} }; class Derived : public Base { public: Derived() { cout << "Derived 构造函数调用" << endl; } void print() override { cout << "Derived::print()" << endl; } }; int main() { Derived d; return 0; }

运行结果:

Base 构造函数调用

Base::print()

Derived 构造函数调用

说明:

  • 在构造Base时,print()被调用,但此时对象还没有成为完整的Derived对象,虚表指针还指向Base版本。
  • 所以print()调用的是Base版本,而不是Derived版本。
  • 这是因为构造函数调用时,对象还处于基类构造阶段,因此虚函数不表现多态。

解释: 如果构造函数是虚函数,调用时需要子类版本,但对象还没有构造完成,子类信息不可用,冲突出现。

2.析构函数为什么一般写为虚函数?

通过基类指针删除子类对象时,如果析构函数不是虚函数,程序只会调用基类的析构函数,导致子类独有的资源无法释放,造成内存泄漏。

先来看看是否为虚函数的区别:

  • 静态绑定(无virtual):编译器根据指针的类型决定调用哪个函数
  • 动态绑定(有virtual):程序根据对象的实际类型决定调用哪个函数
class Animal { public: ~Animal() { cout << "Animal析构" << endl; } // 非虚析构 }; class Dog : public Animal { public: Dog() { food = new string("骨头"); } // Dog特有资源 ~Dog() { delete food; // 释放Dog的资源 cout << "Dog析构" << endl; } private: string* food; }; int main() { Animal* pet = new Dog(); // 基类指针指向子类对象 delete pet; // 这里发生了什么? return 0; } //输出结果:Animal析构

可以看得出来,Dog的析构函数没有被调用,food指针指向的内存永远无法被释放。

可以来看一下运行这段程序发生了什么

对象创建时(new Dog()): [Animal部分的数据] [Dog部分的vptr] [Dog特有的数据(包括food指针)] ^ ^ | | pet指向这里 Dog自己也需要这部分 对象析构时(delete pet): 情况A:析构函数不是虚函数 pet(Animal*)→ 编译器:调用Animal的析构函数 → 只清理了Animal部分 Dog的food指针 → 永远丢失了! 情况B:析构函数是虚函数 pet(Animal*)→ 运行时:通过vptr找到Dog的虚函数表 → 调用Dog::~Dog() Dog::~Dog()执行完后,自动调用Animal::~Animal() → 所有资源都被正确释放

而加上virtual后:

class Animal { public: virtual ~Animal() { cout << "Animal析构" << endl; } // 虚析构! }; class Dog : public Animal { public: Dog() { food = new string("骨头"); } virtual ~Dog() { // 重写基类的虚析构函数 delete food; cout << "Dog析构" << endl; } private: string* food; }; int main() { Animal* pet = new Dog(); delete pet; return 0; }

输出如下:

Dog析构

Animal析构

可以看出来,这里所有的资源都被正确释放。

析构函数的设计就像拆房子一样(从顶而下):

先清理子类:因为子类可能需要依赖父类的部分。

再清理父类:父类为基础部分。

3.什么时候析构函数不需要使用虚函数?

可以看看以下部分:

// 情况1:不需要(这是大多数情况) class Point { int x, y; public: ~Point() { } // 非虚析构就够用了 }; // 没人继承这个类,或者没人会通过基类指针删除它 // 情况2:需要(多态基类) class Shape { // 这个类设计出来就是为了被继承的 public: virtual void draw() = 0; virtual ~Shape() { } // 必须虚! }; // 情况3:特殊规则 class NoVirtualDtor { public: ~NoVirtualDtor() { } // 虽然没有虚函数,但有人可能继承它并通过基类指针删除 // 这很危险!应该避免这样使用 };

总结经验就是:

  • 如果类里有任何虚函数,析构函数也应该是虚函数
  • 如果类被设计为基类(即使没有虚函数),最好也加上虚析构函数
  • 如果类是final(不会再被继承),可以用非虚析构函数获得更好的性能

关于虚函数的部分到这里暂时就结束了,如果有需要补充后续会不定期更新。

Read more

SpringBoot+Vue 针对老年人景区订票系统平台完整项目源码+SQL脚本+接口文档【Java Web毕设】

SpringBoot+Vue 针对老年人景区订票系统平台完整项目源码+SQL脚本+接口文档【Java Web毕设】

摘要 随着老龄化社会的加速发展,老年人旅游需求日益增长,但传统景区订票系统往往操作复杂,对老年用户不够友好。针对这一问题,设计并实现了一款专为老年人优化的景区订票系统平台。该系统通过简化操作流程、提供大字体显示、语音引导等功能,降低老年人使用门槛,提升用户体验。同时,系统整合景区资源,实现门票在线预订、订单管理、个人信息维护等功能,为老年人提供便捷的旅游服务。关键词:老龄化社会、景区订票系统、用户体验、在线预订、旅游服务。 本系统基于SpringBoot和Vue技术栈开发,采用前后端分离架构,后端使用SpringBoot提供RESTful API接口,前端通过Vue.js实现动态交互。数据库采用MySQL,结合Redis缓存提升系统性能。系统功能涵盖用户注册登录、景区信息展示、门票预订、订单支付、个人中心等模块,并针对老年人需求优化了界面设计和交互逻辑。系统接口文档完整,便于后续扩展和维护。关键词:SpringBoot、Vue.js、MySQL、Redis、RESTful API、老年人优化。 数据表设计

By Ne0inhk
Axum: Rust 好用的 Web 框架

Axum: Rust 好用的 Web 框架

Axum 是 Rust 生态中基于 Tokio 异步运行时和 Tower 中间件体系打造的高性能 Web 框架,以“类型安全、无宏入侵、轻量高效”为核心优势,广泛应用于云原生、微服务、API 网关等场景。它摒弃了传统 Web 框架的宏魔法,完全依赖 Rust 的类型系统实现路由匹配、请求解析、响应处理,兼顾了开发效率与运行性能。 本文将从环境搭建、核心概念、路由设计、请求处理、中间件开发到生产级实战,全方位拆解 Axum 的使用技巧,每个知识点均配套可运行的示例代码,帮助开发者从入门到精通,快速构建高性能的 Rust Web 应用。 一、环境准备与项目初始化 1.1 前置条件 * 安装 Rust 环境:

By Ne0inhk
Web 毕设篇-适合练手的 Spring Boot Web 毕业设计项目:智驿AI系统(前后端源码 + 数据库 sql 脚本)

Web 毕设篇-适合练手的 Spring Boot Web 毕业设计项目:智驿AI系统(前后端源码 + 数据库 sql 脚本)

🔥博客主页: 【小扳_-ZEEKLOG博客】 ❤感谢大家点赞👍收藏⭐评论✍ 文章目录         AI系统具有许多优势         1.0 项目介绍         1.1 项目功能         1.2 用户端功能         2.0 用户登录         3.0 首页界面         4.0 物件管理功能         5.0 用户管理功能         6.0 区域管理功能         7.0 物件日志管理功能         8.0 操作日志         AI系统具有许多优势         1)自动化:AI 系统能够自动化执行任务,减少人力和时间成本。它们可以自动处理大量数据并执行复杂的计算,从而提高效率。         2)智能决策:AI 系统可以通过学习和分析数据来做出智能决策。

By Ne0inhk
前端知识点全解析

前端知识点全解析

作为一名前端高级开发人员,面试不仅考察知识点的记忆,更关注对原理的理解、工程化的思考以及解决复杂问题的能力。本文将从 HTML/CSS、JavaScript、浏览器与网络、框架、工程化、性能优化、算法与设计模式等多个维度,系统梳理前端面试中的核心知识点,并提供深入解析及案例,帮助你在面试中展现出真正的技术深度。 1. HTML & CSS 基础 1.1 语义化 HTML 讲解:语义化 HTML 是指使用具有明确含义的标签(如 <header>、<nav>、<article>、<section>)来描述网页结构,而不是单纯使用 <div> 和 <span&

By Ne0inhk