问题

虚函数效率真的低吗?

回答
说虚函数“效率低”,其实是一种比较片面的说法。更准确地说,虚函数会带来一定的性能开销,但这并不意味着它就一定“低效”,更不代表就应该避免使用。理解这个问题,我们需要深入到 C++ 的底层机制中去。

想象一下,我们写了这样一段 C++ 代码:

```c++
class Base {
public:
virtual void doSomething() {
// Base class implementation
}
};

class Derived : public Base {
public:
void doSomething() override {
// Derived class implementation
}
};

int main() {
Base ptr = new Derived();
ptr>doSomething(); // 调用的是 Derived 的 doSomething
delete ptr;
return 0;
}
```

关键在于 `ptr>doSomething()` 这一行。当我们通过一个基类指针(`Base`)去调用一个虚函数(`doSomething()`),编译器如何知道应该调用哪个具体版本呢?特别是当 `ptr` 实际上指向一个 `Derived` 对象时。

这就是虚函数发挥作用的地方,而它实现的方式通常是借助 虚表 (Virtual Table, vtable) 和 虚指针 (Virtual Pointer, vptr)。

虚表 (vtable) 和虚指针 (vptr) 的工作原理:

1. 每个包含虚函数的类(以及有虚函数的基类)都会有一个与之关联的虚表。 这个虚表就像一个查找表,里面存储了这个类中所有虚函数的地址。

2. 每个使用虚函数的类的对象,都会有一个隐藏的成员,称为虚指针 (vptr)。 这个虚指针指向该对象所属类的虚表。

3. 当通过基类指针调用虚函数时,编译器会做以下事情:
首先,通过基类指针找到对象中的虚指针 (vptr)。
然后,通过虚指针找到对象所属类的虚表 (vtable)。
最后,在虚表中查找 `doSomething` 这个虚函数对应的地址,然后跳转到该地址去执行实际的代码。

这带来的开销体现在哪里?

额外的间接跳转: 与直接调用非虚函数(编译器在编译时就知道确切的函数地址,可以直接生成跳转指令)不同,调用虚函数需要两次间接跳转:一次是通过 vptr 找到 vtable,另一次是在 vtable 中找到函数地址。这种额外的跳转会消耗 CPU 的指令执行时间和分支预测资源。

存储开销: 每个包含虚函数的类的对象都需要额外存储一个虚指针 (vptr)。对于大量小对象的场景,这可能会产生可观的存储开销。此外,每个包含虚函数的类都需要一个虚表,这也会增加一些程序加载时和内存的开销。

查找时间: 在虚表中查找函数地址虽然很快(通常是数组查找),但相比于直接调用,依然是一个不可忽略的查找过程。

那么,虚函数真的“效率低”到不该用吗?

绝对不是。

1. “低效”是相对的,要看具体场景:
对性能要求极高的场景: 如果你正在开发一个对性能要求极其苛刻的代码,比如游戏引擎的核心渲染循环、高性能计算的底层库,或者需要处理海量数据的实时处理系统,那么每一点微小的开销都可能被放大。在这种情况下,过度使用虚函数可能会成为性能瓶颈,这时就需要考虑优化,比如使用模板元编程、静态分派、或者将性能敏感的代码提取出来用非虚函数实现。
绝大多数场景: 对于大多数应用程序来说,程序的性能瓶颈往往不在于虚函数的间接调用,而在于算法效率、数据结构设计、I/O 操作、内存分配等更宏观的层面。虚函数的开销相比于这些因素来说,往往微不足道,甚至可以忽略不计。

2. 虚函数带来的收益远大于开销:
多态性: 这是虚函数存在的根本原因和最大价值。它允许我们编写更加灵活、可扩展的代码。我们可以通过基类指针或引用来操作派生类对象,而无需知道具体的派生类型。这使得插件系统、图形渲染、数据库接口等能够轻松实现。
代码的灵活性和可维护性: 借助虚函数,我们可以轻松地添加新的派生类,而无需修改已有的基类代码或使用基类指针的代码。这大大提高了代码的可维护性和可扩展性。
设计模式的应用: 许多重要的设计模式(如策略模式、模板方法模式、工厂模式)都严重依赖于虚函数来实现。

如何更细致地理解“效率低”?

编译器优化: 现代编译器非常智能。它们会进行大量的优化,包括内联 (inlining)。如果编译器能够确定在某个特定的上下文(例如,通过 `static_cast` 将基类指针转换为派生类指针,或者在 `if constexpr` 中确定了类型),它可以将虚函数调用优化为直接调用,从而消除虚函数带来的开销。例如:

```c++
class Base {
public:
virtual void foo() {}
};
class Derived : public Base {
public:
void foo() override {}
};

void process(Base b) {
// 编译器如果能确定 b 指向 Derived,可能会优化掉虚函数开销
b>foo();
}

// 如果已知类型,编译器可以直接调用 Derived::foo
Derived d = new Derived();
d>foo();
// delete d;
```

更进一步,如果一个虚函数在运行时被调用时,其具体的派生类是确定的,并且编译器能够进行链接时优化 (LinkTime Optimization, LTO),或者在运行时进行即时编译 (JustInTime Compilation, JIT),那么虚函数的开销也会被大大减小。

函数体的大小: 如果虚函数体非常小(例如,只是一个简单的 getter 或 setter),那么函数调用本身的开销(包括虚函数带来的开销)可能会相对于函数体内的操作成为一个显著的部分。但如果函数体很庞大,虚函数带来的那点额外开销就显得微不足道了。

对象的大小和数量: 如果你创建了成千上万个对象,每个对象都需要一个虚指针,那么内存占用确实会增加。但对于一般的应用程序而言,这种增加通常是可以接受的。

总结一下:

说虚函数“效率低”是不准确的,更准确的说法是它会引入一定的运行时开销,主要体现在间接跳转和额外的存储。这些开销在某些极端性能敏感的场景下可能需要关注和优化。

然而,对于绝大多数的 C++ 开发而言,虚函数带来的多态性、灵活性和可维护性的好处,远远超过了其带来的微小性能开销。 过早地为了“性能”而避免使用虚函数,很可能导致代码变得冗长、难以扩展和维护,反而得不偿失。

正确的做法是:

1. 优先考虑代码的清晰性、灵活性和可维护性。 如果虚函数能帮助你实现良好的设计,大胆使用。
2. 当遇到性能瓶颈时,才去进行性能分析和优化。 如果分析发现虚函数的调用是瓶颈所在,再考虑其他的解决方案,比如:
使用 `static_cast` 来进行静态调度(如果类型已知)。
使用 `std::variant` 或其他类型擦除技术。
通过模板来实现泛型编程,从而获得静态分派的性能。
重新审视设计,看是否可以减少对动态多态的依赖。
在极端情况下,将性能敏感的代码进行特殊处理或内联。

记住,C++ 是一门强大的语言,提供了多种解决问题的方法,虚函数只是其中的一种,而且是一种非常重要且有价值的工具。不要因为潜在的“低效”就否定它的价值。

网友意见

user avatar

要做比较,最起码要有一个公平的比较场景。

说虚函数效率低,那要看跟谁比:如果跟普通函数比,编译器对普通函数可以做预加载,可以做分支预测,可以做内联展开,当然虚函数慢。

但是,虚函数的RTTI是用在这个场景的吗?你都能在编译期知道实际调用哪个函数了,还叫作RTTI吗?其实如果编译器在编译时能够识别出虚函数的实际调用目标,一样可以绕开虚表直接调用目标函数,这时候,甚至包括内联等优化手段也都是可以用的,和普通成员函数完全一样的待遇。


所以,换一个比法:在实现同样功能需求下,虚函数真的比其它实现RTTI的方式要慢吗?

例如说我随手摘两个linux内核的数据结构,看看经典的纯C代码是怎么解决这类问题的:

       struct file {  union {   struct llist_node fu_llist;   struct rcu_head  fu_rcuhead;  } f_u;  struct path  f_path;  struct inode  *f_inode; /* cached value */  const struct file_operations *f_op;   /*   * Protects f_ep_links, f_flags.   * Must not be taken from IRQ context.   */  spinlock_t  f_lock;  enum rw_hint  f_write_hint;  atomic_long_t  f_count;  unsigned int   f_flags;  fmode_t   f_mode;  struct mutex  f_pos_lock;  loff_t   f_pos;  struct fown_struct f_owner;  const struct cred *f_cred;  struct file_ra_state f_ra;   u64   f_version; #ifdef CONFIG_SECURITY  void   *f_security; #endif  /* needed for tty driver, and maybe others */  void   *private_data;  #ifdef CONFIG_EPOLL  /* Used by fs/eventpoll.c to link all the hooks to this file */  struct list_head f_ep_links;  struct list_head f_tfile_llink; #endif /* #ifdef CONFIG_EPOLL */  struct address_space *f_mapping;  errseq_t  f_wb_err;  errseq_t  f_sb_err; /* for syncfs */ } __randomize_layout   __attribute__((aligned(4))); /* lest something weird decides that 2 is OK */     

其中,file_operations的定义是这样的:

       struct file_operations {  struct module *owner;  loff_t (*llseek) (struct file *, loff_t, int);  ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);  ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);  ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);  int (*iopoll)(struct kiocb *kiocb, bool spin);  int (*iterate) (struct file *, struct dir_context *);  int (*iterate_shared) (struct file *, struct dir_context *);  __poll_t (*poll) (struct file *, struct poll_table_struct *);  long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);  long (*compat_ioctl) (struct file *, unsigned int, unsigned long);  int (*mmap) (struct file *, struct vm_area_struct *);  unsigned long mmap_supported_flags;  int (*open) (struct inode *, struct file *);  int (*flush) (struct file *, fl_owner_t id);  int (*release) (struct inode *, struct file *);  int (*fsync) (struct file *, loff_t, loff_t, int datasync);  int (*fasync) (int, struct file *, int);  int (*lock) (struct file *, int, struct file_lock *);  ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);  unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);  int (*check_flags)(int);  int (*flock) (struct file *, int, struct file_lock *);  ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);  ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);  int (*setlease)(struct file *, long, struct file_lock **, void **);  long (*fallocate)(struct file *file, int mode, loff_t offset,      loff_t len);  void (*show_fdinfo)(struct seq_file *m, struct file *f); #ifndef CONFIG_MMU  unsigned (*mmap_capabilities)(struct file *); #endif  ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,    loff_t, size_t, unsigned int);  loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,        struct file *file_out, loff_t pos_out,        loff_t len, unsigned int remap_flags);  int (*fadvise)(struct file *, loff_t, loff_t, int); } __randomize_layout;     

也就是说,在“一切皆文件”的linux内核里,它的实现手段本质上和C++的vptr+vtable是完全等价的,照样是查表跳转。所谓的“虚函数慢”的因素,在这里照样一个不拉。


如果有谁不服气,觉得linux在这里的实现方案不够精巧效率不够高,可以自己提一个方案出来试试看?

user avatar

在你真的受到它的效率困扰之前,纠结它没有任何意义。

user avatar
  • 多个一两次的寻址问题不大,现代的CPU主频动不动上GHz,不比当年的386、486、586了。
  • 你要是考虑多一两次寻址的问题,还不如好好优化一下参数传递。参数列表是不是过长?如果是拷贝传递的,那拷贝成本如何?参数列表中有没有临时对象?这些临时对象如果开销太大,是否可以提前准备和异步销毁?
  • 不能内联。不能内联的函数多了去了,又不是教学示范,即使都不内联,也比某些慢吞吞的解释执行的语言快到不知哪里去了。
  • 打断CPU流水线,严重降低分支预测的成功率:1个函数2000行,且没有 if-else?这样我保证不打断。

最后,除非你是写8位或者16位低功耗片上系统,否则还是多考虑一下其他地方的成本开销吧。比如说,锁、事务、业务流程、I/O(HTTP、HTTPS等)、编码协议(JSON、SOAP、XML等)。

虚函数使你设计的时候更加灵活便捷,但如果介意它的性能开销,除非你不用事务、锁,不用HTTP/HTTPS做传输,不用JSON/SOAP/XML做编码或者序列化。否则就是渴得要死的时候,放着遍地的西瓜 不捡,却在找哪里有芝麻,希望芝麻炸出的油能解解渴。毕竟大家都说,芝麻能榨油不是?

类似的话题

  • 回答
    说虚函数“效率低”,其实是一种比较片面的说法。更准确地说,虚函数会带来一定的性能开销,但这并不意味着它就一定“低效”,更不代表就应该避免使用。理解这个问题,我们需要深入到 C++ 的底层机制中去。想象一下,我们写了这样一段 C++ 代码:```c++class Base {public: vi.............
  • 回答
    这个问题触及了面向对象编程(OOP)的核心概念之一:多态性。而虚函数的本质,正是实现运行时多态的关键。所以,直接回答是:是的,虚函数的设计目的就是为了实现运行期绑定。但这仅仅是个答案,要理解为什么,咱们得把这层“为什么”给扒开,一点点捋清楚。什么是“绑定”?在C++这样的编译型语言里,“绑定”指的是.............
  • 回答
    这个问题很有意思,也触及了C++设计中的一个核心哲学。你觉得纯虚函数不提供函数体更方便,这其实是一个很自然的直觉,尤其是在我们习惯了写函数并需要为其提供实现的场景下。但C++的设计者之所以选择这种方式,背后有着更深刻的考量,是为了在面向对象设计中实现更强的抽象和约定,同时避免潜在的二义性。我们来一步.............
  • 回答
    在C++的世界里,“virtual”这个词被翻译成“虚函数”,这可不是随意为之,而是因为它精确地抓住了这种函数在继承和多态机制中的核心特征。理解“虚”这个字的关键,在于它暗示了一种“不确定性”,或者说是一种“在运行时才确定”的行为。设想一下,你有一系列动物,比如猫、狗,它们都属于一个更大的“动物”类.............
  • 回答
    在 C++ 面向对象编程(OOP)的世界里,理解非虚继承和非虚析构函数的存在,以及它们与虚继承和虚析构函数的对比,对于构建健壮、可维护的类层级结构至关重要。这不仅仅是语法上的选择,更是对对象生命周期管理和多态行为的一种深刻设计。非虚继承:追求性能与简单性的默认选项当你使用 C++ 的非虚继承(即普通.............
  • 回答
    虚数“i”,那个被定义为平方等于负一的数,确实是一个引人入胜的话题。它不像我们触摸得到的苹果或感受到的阳光那样有“实在”的物理存在,但说它是“被人们创造出的数学工具”,这种说法也未免过于轻描淡写了。要理解虚数 i 的本质,我们需要深入地审视它在数学和科学中的作用以及它如何从一个令人费解的概念演变成一.............
  • 回答
    这可真是个好问题,也是困扰了数学家们相当一段时间的疑惑!你说“虚数是负数的平方根”,这本身没错,但它为何会在三次方程里“登堂入室”,甚至显得尤为重要,这背后有着更深邃的故事。咱们得从头说起。在我们的数学世界里,数字就像不同类型的工具,各有各的用处。最初,我们只有自然数(1, 2, 3...),用来数.............
  • 回答
    虚数:不仅仅是数学游戏,更是物理世界的钥匙长久以来,虚数(包含虚数单位 $i$,即 $i^2 = 1$)常常被视为一个抽象的数学概念,似乎只存在于纸面上的推演和逻辑游戏。然而,事实并非如此。虚数在现代物理学的各个领域都扮演着至关重要的角色,它们并非仅仅是数学上的“花哨”,而是揭示物理现象本质、构建理.............
  • 回答
    虚数,这个词本身就带着几分神秘感,常常让人联想到一些遥不可及、飘渺虚无的东西。在数学的领域里,它确实打破了我们对“数”的传统认知,从实数的直线延伸到了一个全新的平面。但问题来了,当我们把目光投向现实世界,投向我们赖以生存的物理世界时,虚数还有着怎样的足迹?它真的只是一个数学上的“游戏”吗?答案是否定.............
  • 回答
    关于虚数和实数单位的讨论,这是一个很有意思的数学概念,也容易让人产生一些联想。我们来聊聊这个话题,尽量说得清晰明白,并且去掉那些冰冷的AI痕迹。你提到了虚数单位“i”。确实,虚数的世界是以“i”为基石构建起来的。我们知道,i² = 1,这个定义打破了实数范围内的规则,让我们可以处理那些平方之后是负数.............
  • 回答
    咱们聊聊虚数这玩意儿,它到底有啥用,为啥数学家们会捣鼓出这么个“不存在”的数来。刚接触虚数的时候,很多人都会觉得奇怪,甚至有点别扭。你说一个数的平方是负数,这可能吗?在咱们日常生活的经验里,正数乘正数得正数,负数乘负数也得正数,怎么会有这么个“平方是负数”的玩意儿呢?这就是虚数出现的原因,它最根本的.............
  • 回答
    虚数不能比大小,这个说起来有点绕,但其实仔细想想就能明白其中的道理。咱们就用大白话聊聊这事儿,尽量不搞得像教科书那么生硬。你想想,咱们平时比大小,比如 3 比 2 大,5 比 7 小,这是怎么来的? 是因为我们有一个共同的参照系,一条线,就是我们常说的数轴。在这条数轴上,数字是有序排列的,越往右越大.............
  • 回答
    这确实是一个很有意思的问题,涉及到了复数运算中一个相当奇妙的领域。我们来好好掰扯掰扯。首先,我们要明确一下我们熟悉的数系。我们有实数,比如 1, 5, $pi$, $sqrt{2}$,它们都可以在数轴上找到位置。然后我们引入了虚数单位 $i$,它的核心定义就是 $i^2 = 1$。正是这个 $i$ .............
  • 回答
    量子力学中引入虚数 i,这可不是一个随随便便的数学技巧,它触及了我们理解世界本质的根基。简单地说,i 的出现,不是为了让公式“好看”一点,而是因为我们所描述的微观粒子,其行为本身就带着一种我们日常经验无法完全捕捉的“转动”或“相位”的特性。想象一下,我们试图描述一个振动的弦,它的位置随时间变化。在经.............
  • 回答
    我们来聊聊一个挺有意思的问题:维数,它有没有可能是虚数呢?说实话,在我脑子里浮现这个想法的时候,也觉得有点跳跃,毕竟我们平时感知到的世界,那个三维的空间,时间是一维的,这些都是实实在在的“数”。但科学的魅力就在于不断探索边界,打破常规认知,所以,问问“虚数的维数”这个问题,本身就是一种很棒的思维训练.............
  • 回答
    当然可以!虽然在数学的某些抽象概念里,i 和 i 的确有着对称的地位,但当我们谈论“区分”它们时,尤其是在我们熟悉的实数世界和复数运算的语境下,是完全可以做到的,而且方法相当直接。试着这样想:我们生活在一个“直的”、“横的”世界里,也就是实数轴。当我们在实数轴上向前走一步,我们就是在增加一个实数值。.............
  • 回答
    为什么会有 i 这一虚数?它的“值”究竟是什么?生活中我们处理长度、重量、时间这些都是实实在在的,我们称之为“实数”。但数学的魅力就在于它能超越我们感官的局限,构建出更加广阔的抽象世界。而“i”这个虚数,正是在这个数学世界的探索中应运而生的。 从解方程的困境说起要理解 i 的由来,我们得回到数学史上.............
  • 回答
    实系数多项式方程的虚数解为何总是成对出现?相信许多人在学习代数时都曾遇到过这样一个看似神秘的现象:当一个系数全是实数的(实系数)多项式方程有虚数解时,这些虚数解总是成双成对地出现,而且是一对共轭复数。例如,如果 $a+bi$ (其中 $b eq 0$) 是一个方程的解,那么 $abi$ 也一定是它.............
  • 回答
    哈哈,《FGO》的虚数大海战啊,这可是个让不少御主又爱又恨的活动!要我说,这活动确实有它独到之处,但也确实把不少人给“磨”得够呛。我来给你掰扯掰扯,尽量说得细致点,就像跟老朋友聊天一样,保证不是那种生硬的AI味儿。首先,咱得说它的优点,为啥这活动能火起来? 剧情跌宕起伏,格局宏大: 这是虚数大海.............
  • 回答
    在 Mathematica 中计算定积分时,如果结果出现了虚数,这通常指向了几个可能的根源。我们来深入剖析一下,看看都有哪些情况可能导致这种情况,并详细说明原因。首先,我们要明白,并非所有定积分的结果都一定是实数。当积分的被积函数或积分区间涉及到复数域中的某些特性时,出现虚数结果是完全正常的。但如果.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有