虚方法或函数指针

33

在C++中实现多态行为时,可以使用纯虚函数或函数指针(或functor)。例如,可以通过以下方式实现异步回调:

方法1

class Callback
{
public:
    Callback();
    ~Callback();
    void go();
protected:
    virtual void doGo() = 0;  
};

//Constructor and Destructor

void Callback::go()
{
   doGo();
}

如果要在这里使用回调函数,您需要重写doGo()方法以调用您想要的任何函数。

方法二

typedef void (CallbackFunction*)(void*)

class Callback
{
public:
    Callback(CallbackFunction* func, void* param);
    ~Callback();
    void go();
private:
   CallbackFunction* iFunc;
   void* iParam;
};

Callback::Callback(CallbackFunction* func, void* param) :
    iFunc(func),
    iParam(param)
{}

//Destructor

void go()
{
    (*iFunc)(iParam);
}

要在此处使用回调方法,您需要创建一个函数指针,以便由Callback对象调用。

方法三

[这是我(安德烈亚斯)添加到问题中的;它不是原始发布者编写的]

template <typename T>
class Callback
{
public:
    Callback() {}
    ~Callback() {}
    void go() {
        T t; t();
    }
};

class CallbackTest
{
public:
    void operator()() { cout << "Test"; }
};

int main()
{
    Callback<CallbackTest> test;

    test.go();
}

每种实现方式都有什么优缺点?

1
或者 go() 可以是一个模板函数,可以接受 任一 函数指针或具有重载的 operator()(void*) 的类。 - Nicolás
2
方法2是编译器在幕后实现方法1的方式。你不应该自己去做,因为这样很危险且容易出错。 - Martin York
酷,感谢Andreas提供的第三种方法。 - doron
1
我不理解第三种方法...它只是一个函数。一个总是调用相同方法的“回调”有什么用处?你不能将其传递给提供不同功能的系统。 - Icebone1000
8个回答

14

方法一(虚函数)

  • "+" 在C++中正确的做法
  • "-" 每个回调都必须创建一个新类
  • "-" 性能上需要通过VF-Table进行额外的解引用,与函数指针相比。与Functor解决方案相比,需要两个间接引用。

方法二(带有函数指针的类)

  • "+" 可以为C++回调类包装C风格函数
  • "+" 回调函数可以在回调对象创建后更改
  • "-" 需要间接调用。对于可以在编译时静态计算的回调,可能比Functor方法慢。

方法三(调用T Functor的类)

  • "+" 可能是最快的方法。没有间接调用开销,可能完全内联。
  • "-" 需要定义一个额外的Functor类。
  • "-" 需要在编译时静态声明回调。

顺便说一下,函数指针和Functor不是相同的东西。Functor(在C++中)是用于提供通常是operator()的函数调用的类。

以下是一个示例Functor以及使用Functor参数的模板函数:

class TFunctor
{
public:
    void operator()(const char *charstring)
    {
        printf(charstring);
    }
};

template<class T> void CallFunctor(T& functor_arg,const char *charstring)
{
    functor_arg(charstring);
};

int main()
{
    TFunctor foo;
    CallFunctor(foo,"hello world\n");
}

从性能角度来看,虚函数和函数指针都会导致间接函数调用(即通过寄存器),但是虚函数在加载函数指针之前需要额外加载VFTABLE指针。使用Functors(非虚拟调用)作为回调是使用参数模板函数的最高性能方法,因为它们可以被内联,即使不被内联,也不会生成间接调用。


但是在我的第二种方法中,它们可以互换使用吗? - doron
从技术上讲,如果您将go()视为类提供的函数而不是operator(),则这三个类都可以被宽松地认为是functor。在#2中的函数指针与functor无关。然而,只有第3种方法遵循真正的C++ functor的“精神”,并且可以受益于通常被认为是用于C++中的functor的模板优化。 - Adisak

7

方法一

  • 更易于阅读和理解
  • 错误的可能性较小(iFunc 不能为NULL,不使用void *iParam等)
  • C++程序员会告诉你这是C++中“正确”的做法

方法二

  • 需要打字的量略少
  • 非常稍微快一点(调用虚方法有一些开销,通常与两个简单算术操作相同。所以它很可能无关紧要)
  • 这是在C中执行此操作的方式

方法三

如果可能的话,这可能是最好的方法。它将具有最佳性能,类型安全,并且易于理解(这是STL使用的方法)。


4
如果虚函数阻止小函数的内联、重复或不可能的分支的删除或者只使用寄存器变量存储,那么它们可能会慢40倍以上。 - Zan Lynx
@Zan:#1 也防止了内联优化,这就是我进行比较的原因。不过现在我已经添加了选项 #3。 - Andreas Bonini
1
这些都不适用于通过函数指针调用。 - struppi
2
方法3不是做这件事的最佳方式。因为您需要打破类的封装才能允许任意对象访问数据。 - Martin York
@Andreas,这不仅仅是一个算术加法;我甚至写了一篇关于虚函数调用的“文章”:http://coldattic.info/shvedsky/pro/blogs/a-foo-walks-into-a-bar/posts/3 - P Shved
显示剩余6条评论

5

方法二的主要问题在于它根本不可扩展。考虑相当于100个函数的等效情况:

class MahClass {
    // 100 pointers of various types
public:
    MahClass() { // set all 100 pointers }
    MahClass(const MahClass& other) {
        // copy all 100 function pointers
    }
};

MahClass的大小已经膨胀了,构造时间也显著增加。然而,虚函数的类大小和构造时间只增加O(1)-更不用说你,作为用户,必须手动编写所有派生类的回调函数,这些函数将调整指针以成为指向派生类的指针,并且必须指定函数指针类型,这会弄得一团糟。更不要忘记其中一个,或者将其设置为NULL或其他同样愚蠢的事情,因为您正在以这种方式编写30个类,并像寄生性黄蜂侵害毛毛虫一样违反DRY原则。

当所需的回调是静态可知的时,只有方法3才能使用。

这使得方法1成为唯一可用的方法,当需要动态方法调用时。


1
如果复制函数指针成为问题,函数指针可以被抽象成一个单独的静态表,这通常是虚函数表的操作方式。 - doron

3
从您的示例中不清楚您是创建实用程序类还是其他类型的类。您的“回调”类是否旨在实现闭包或更实质性的对象,只是您没有详细说明?
第一种形式:
- 更易于阅读和理解, - 更容易扩展:尝试添加方法pause、resume和stop。 - 更擅长处理封装(假设doGo在类中定义)。 - 可能是更好的抽象,因此更易于维护。
第二种形式:
- 可以与不同的doGo方法一起使用,因此不仅具有多态性。 - 可以允许(通过附加方法)在运行时更改doGo方法,从而允许对象实例在创建后改变其功能。 - 最终,在我看来,第一种形式对于所有普通情况都更好。第二种形式具有一些有趣的功能,但您并不经常需要这些功能。

1

第一种方法的一个主要优点是它具有更多的类型安全性。第二种方法使用void *作为iParam,因此编译器将无法诊断类型问题。

第二种方法的一个小优点是它与C集成所需的工作量较少。但如果您的代码库仅限于C ++,则此优点无关紧要。


0
例如,让我们看一个为类添加“读取”功能的接口:
struct Read_Via_Inheritance
{
   virtual void  read_members(void) = 0;
};

每当我想添加另一个阅读源时,我都必须继承该类并添加特定的方法:
struct Read_Inherited_From_Cin
  : public Read_Via_Inheritance
{
  void read_members(void)
  {
    cin >> member;
  }
};

如果我想要从文件、数据库或 USB 中读取数据,这就需要三个不同的类。当涉及到多个对象和多个来源时,组合变得非常复杂。

如果我使用一个类似于 访问者 设计模式的 函数对象

struct Reader_Visitor_Interface
{
  virtual void read(unsigned int& member) = 0;
  virtual void read(std::string& member) = 0;
};

struct Read_Client
{
   void read_members(Reader_Interface & reader)
   {
     reader.read(x);
     reader.read(text);
     return;
   }
   unsigned int x;
   std::string& text;
};

有了上述基础,对象只需向read_members方法提供不同的读取器,就可以从不同的来源读取:

struct Read_From_Cin
  : Reader_Visitor_Interface
{
  void read(unsigned int& value)
  {
     cin>>value;
  }
  void read(std::string& value)
  {
     getline(cin, value);
  }
};

我不需要更改任何对象的代码(这是一件好事,因为它已经在工作中)。我还可以将阅读器应用于其他对象。

通常,我在执行通用编程时使用继承。例如,如果我有一个Field类,那么我可以创建Field_BooleanField_TextField_Integer。我可以将它们的实例指针放入vector<Field *>中,并称之为记录。该记录可以对字段执行通用操作,并且不关心或知道处理的字段的类型


0

我认为函数指针更适合C语言风格。主要是因为为了使用它们,通常必须定义一个与指针定义完全相同签名的平面函数。

当我编写C++代码时,我唯一编写的平面函数是int main()。其他所有内容都是类对象。在这两个选择中,我会选择定义一个类并覆盖您的虚拟函数,但如果您只想通知某些代码发生了类中的某些操作,则这两个选择都不是最佳解决方案。

我不知道您的确切情况,但您可能需要查阅设计模式

我建议使用观察者模式。当我需要监视类或等待某种通知时,这就是我使用的模式。


此外,Adisak提到的函数对象是一个好主意,尽管我并没有多少使用过它们。 - Charles

0
  1. 首先将其更改为纯虚函数,然后再将其内联。只要内联不失败(如果您强制执行它,则不会失败),这应该可以消除任何方法调用开销。
  2. 既然你总是会调用方法并且无法内联,那么还不如使用C语言,因为这是C++相对于C的唯一真正有用的主要功能。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接