如何自我记录由模板库类调用的回调函数?

19

我有一个User::func()(回调)函数,会被一个模板类(Library<T>)调用。

在开发的第一轮中,每个人都知道func()只为这个单一目的服务。
几个月后,大多数成员忘记了func()是干什么的。
经过一些重构,func()有时会被某些程序员删除。

起初,我认为这根本不是问题。
然而,在我多次重新遇到这个模式之后,我认为我需要一些对策。

问题

如何优雅地记录它?(可爱&&简洁&&没有额外的CPU成本)

例子

这是一个简化的代码:-
(真实世界的问题分散在10多个库文件&20多个用户文件&40多个函数中。)

Library.h

template<class T> class Library{
    public: T* node=nullptr;
    public: void utility(){
        node->func();  //#1
    }
};

User.h

class User{
    public: void func(){/** some code*/} //#1
    //... a lot of other functions  ...
    // some of them are also callback of other libraries
};

main.cpp

int main(){
    Library<User> li; .... ;  li.utility();
}

我的不太好的解决方案

1. 注释/文档

作为第一个解决办法,我倾向于添加这样的注释:

class User{ 
    /** This function is for "Library" callback */
    public: void func(){/** some code*/}
};

但是它很快就会变得混乱 - 我必须在每个类的每个“func”中都添加它。

2. 重命名“func()”

在实际情况中,我倾向于像这样给函数名称加前缀:

class User{ 
    public: void LIBRARY_func(){/** some code*/}
};

很明显,但现在函数名非常长。
(特别是当Library-class有更长的类名时)

3. 带有“func()=0”的虚拟类

我考虑创建一个抽象类作为回调的接口。

class LibraryCallback{ 
    public: virtual void func()=0;
};
class User : public LibraryCallback{ 
    public: virtual void func(){/** some code*/}
};

它给人一种func()是用于某些相当外部的事情的感觉。:)
然而,我必须牺牲虚拟调用成本(v-table)。在性能关键的情况下,我无法承受。

4. 静态函数

(来自评论中的Daniel Jour的想法,谢谢!)

将近一个月后,这是我如何使用的:

Library.h

template<class T> class Library{
    public: T* node=nullptr;
    public: void utility(){
        T::func(node);  //#1
    }
};

User.h

class User{
    public: static void func(Callback*){/** some code*/} 
};

main.cpp

int main(){
    Library<User> li;
}

它可能更加清晰,但仍缺乏自我说明。


@Code-Apprentice 谢谢,很有用。但在这种情况下,我不需要它。我可以在class Library{}内部使用T* t的方式来实现。我承认,在编写Library时,虚拟类提供了很大的便利。 - javaLover
2
有一件事我真的不太明白:删除成员函数应该会导致编译器错误……这难道不已经是足够的“文档”了吗?如果库的成员函数没有从你的库代码中实例化,你可以添加一个单元测试来实现。 - Daniel Jour
@Daniel Jour 是的,它已经足够了。然而,如果代码本身有一个明显的形式/形状,比如在编辑器中滚动时没有任何(可能很长)编译,那就更好了。 - javaLover
1
可以有多个不同的 User 类(具有不同的 func 实现),对吗?如果 func "没有任何用处,因此被删除" 不是显而易见的话......那么也许最好不要将 func 作为 User 的成员函数,而是作为自由函数或某个“辅助”类的成员。 - Daniel Jour
我有各种情况。然而,大多数情况下(70%),是不行的。即使在更困难的情况下,我认为我可以通过使用“friend”关键字来绕过限制,代码仍然看起来合理。 - javaLover
显示剩余3条评论
9个回答

5
func 不是 User 的一个功能。它是 User-Library<T> 耦合的一个特征。
如果它在 Library<T> 外没有明确的语义,将其放在 User 中是一个不好的想法。如果它确实具有明确的语义,则应该说明它的作用,删除它应该是一个显然的不好的主意。
将它放在 Library<T> 中是行不通的,因为它的行为是由 Library<T> 中的 T 函数决定的。
答案是把它放在两个地方都不是。
template<class T> struct tag_t{ using type=T; constexpr tag_t(){} };
template<class T> constexpr tag_t<T> tag{};

现在在Library.h中:
struct ForLibrary;
template<class T> class Library{
  public: T* node=nullptr;
  public: void utility(){
    func( tag<ForLibrary>, node ); // #1
  }
};

User.h 文件中:
struct ForLibrary;
class User{ 
/** This function is for "Library" callback */
public:
  friend void func( tag_t<ForLibrary>, User* self ) {
    // code
  }
};

或者将这个放到与User相同的命名空间中, 或者放到与ForLibrary相同的命名空间中:

friend func( tag_t<ForLibrary>, User* self );

在删除func之前,您需要追踪ForLibrary
它不再是User的“公共接口”一部分,因此不会使其混乱。它可以是一个友元(辅助函数),或者是在UserLibrary的相同命名空间中的自由函数。
如果只使用User的公共接口,您可以在需要Library<User>的地方实现它,而不是在User.hLibrary.h中。
这里使用的技术包括“标签分派”,“参数相关查找”,“友元函数”和优先使用自由函数而非方法。

这个技巧(标签)对我来说比较高级。我真的可以将 tag_t<ForLibrary> 作为一种类型传递吗?它不是对象的实例!(tag_t<ForLibrary>()) ...... 我需要一些时间去找到并阅读相关资料,谢谢。 - javaLover
1
@javaLover tag_t<ForLibrary> 是一种类型。tag<ForLibrary>tag_t<ForLibrary> 类型的值。您可以使用 tag_t<ForLibrary>{} 来创建一个值。 - Yakk - Adam Nevraumont
哦,我的错。我现在明白了。顺便说一下,这个解决方案需要额外的参数传递,对吧?我知道即使有一些成本,它也会非常小,但我想知道。 - javaLover
1
@javaLover 在 C++ 中,参数传递发生在抽象机器中;在运行时,如果传递涉及到可平凡复制的对象(指针、标签、整数等)、相同结构体或者编译器可以消除内联的非平凡移动/复制,则它可以蒸发。我认为在任何非调试优化级别下都不会有任何开销。 - Yakk - Adam Nevraumont
请注意,virtual 调用无法进行优化。但传递指针和一个 1 字节标记的开销将很难被检测到。 - Yakk - Adam Nevraumont
显示剩余2条评论

4

从用户角度来看,我会使用 CRTP 创建一个回调接口,并强制用户使用它。例如:

template <typename T>
struct ICallbacks
{
    void foo() 
    {
        static_cast<T*>(this)->foo();
    }
};

用户应该继承这个接口并实现foo()回调函数

struct User : public ICallbacks<User>
{
    void foo() {std::cout << "User call back" << std::endl;}
};

这个技术的好处是,如果Library使用ICallback接口,而User忘记实现foo(),则会收到一个很好的编译器错误消息。

请注意,这里没有虚函数,因此没有性能损失。

从库的角度来看,我只会通过其接口(在本例中为ICallback)调用这些回调。遵循使用指针的OP,我会这样做:

template <typename T>
struct Library
{
    ICallbacks<T> *node = 0;

    void utility()
    {
       assert(node != nullptr);
       node->foo();
    }
};

请注意,使用此方法自动记录下的内容。很明确地表明您正在使用回调接口,并且node是具有这些函数的对象。
以下是一个完整的工作示例:
#include <iostream>
#include <cassert>

template <typename T>
struct ICallbacks
{
    void foo() 
   {
       static_cast<T*>(this)->foo();
   }
};

struct User : public ICallbacks<User>
{
     void foo() {std::cout << "User call back" << std::endl;}
};

template <typename T>
struct Library
{
    ICallbacks<T> *node = 0;

    void utility()
    {
        assert(node != nullptr);
        node->foo();
    }
};

int main()
{
    User user;

    Library<User> l;
    l.node = &user;
    l.utility();
}

我会收到关于 User 试图从父类 (ICallbacks) 中隐藏 foo() 的丑陋警告。有没有避免警告的方法(除了使用 #pragma disable warning)? - javaLover
@javaLover,我对警告信息太粗心了,很抱歉。在尝试保持原始上下文的同时,我编辑了我的答案以消除那些警告。 - Amadeus
如果User没有实现foo(),这将导致无限递归(在运行时)! - chtz

3
虽然我知道我没有回答你具体的问题(如何记录不应删除的功能),但我可以解决你的问题(保留代码中似乎未使用的回调函数),方法是实例化 Library<User> 并在单元测试中调用 utility() 函数(或者可能更应该称为 API 测试......)。只要你不必检查每个可能的库类和回调函数组合,这种解决方案可能也适用于你的实际情况。
如果你有幸在一个需要成功的单元测试和代码审查才能将更改合并到代码库的组织中工作,那么在任何人删除 User::func() 函数之前,这将需要对单元测试进行更改,并且这样的更改可能会引起审核员的注意。
另一方面,你了解自己的环境,而我不了解,我知道这种解决方案并不适用于所有情况。

哦,算了吧,我刚刚看到了丹尼尔·乔的评论。不过我会点赞他的建议的 ^_^ - psyill
至少你的解决方案让我更加思考单元测试,谢谢。 - javaLover

3

Test.h

#ifndef TEST_H
#define TEST_H

// User Class Prototype Declarations
class User;

// Templated Wrapper Class To Contain Callback Functions
// User Will Inherit From This Using Their Own Class As This
// Class's Template Parameter
template <class T>
class Wrapper {
public:
    // Function Template For Callback Methods. 
    template<class U>
    auto Callback(...) {};
};

// Templated Library Class Defaulted To User With The Utility Function
// That Provides The Invoking Of The Call Back Method
template<class T = User>
class Library {
public:
    T* node = nullptr;
    void utility() {
        T::Callback(node);
    }
};

// User Class Inherited From Wrapper Class Using Itself As Wrapper's Template Parameter.
// Call Back Method In User Is A Static Method And Takes A class Wrapper* Declaration As 
// Its Parameter
class User : public Wrapper<User> {
public:
    static void Callback( class Wrapper* ) { std::cout << "Callback was called.\n";  }
};

#endif // TEST_H

main.cpp

#include "Test.h"

int main() {

    Library<User> l;
    l.utility();

    return 0;
}

输出

Callback was called.

我能够在Windows 7上的64位Intel Core 2 Quad Extreme中使用VS2017 CE编译、构建和运行此代码,没有错误。

有什么想法吗?

我建议适当命名封装类,然后针对每个具有独特目的的回调函数,在封装类内相应地进行命名。


编辑

在尝试了一些“模板魔法”之后,我发现这并不存在……我将Wrapper类中的函数模板注释掉,发现它是不必要的。然后我又注释掉了User中作为Callback()参数列表的class Wrapper*。这导致编译器错误,指出User::Callback()不接受0个参数。于是我回顾了一下Wrapper,因为User继承自它。好吧,在这点上,Wrapper是一个空的类模板。

这让我去看了Library。Library有一个指向User的指针作为公共成员,并且有一个调用User的静态Callback方法的utility()函数。这里的调用方法取一个指向User对象的指针作为参数。所以我尝试了这个:

class User; // Prototype
class A{}; // Empty Class

template<class T = User>
class Library {
public: 
    T* node = nullptr;
    void utility() {
        T::Callback(node);
    }
};

class User : public A {
public:
    static void Callback( A* ) { std::cout << "Callback was called.\n"; }
};

这个简化版本编译和构建都是正确的。然而,当我思考它时,模板版本更好,因为它在编译时被推导出来,而不是在运行时。所以当我们回到使用模板时,javaLover问我Callback方法中User类的参数列表中的class Wrapper*是什么意思或代表什么。
我将尽力清楚地解释一下,但首先,包装器类只是一个空的模板外壳,User将从中继承,并且它什么也不做,只是作为一个基类存在,现在看起来像这样:
template<class T>
class Wrapper {   // Could Be Changed To A More Suitable Name Such As Shell or BaseShell
};

当我们查看User类时:

class User : public Wrapper<User> {
public:
    static void Callback( class Wrapper* ) { // print statement }
};

我们可以看到,User是一个非模板类,它继承自一个模板类,但使用自身作为模板的参数。它包含一个公共静态方法,这个方法不返回任何内容,但它需要一个参数;这在Library类中也很明显,因为它的模板参数是一个User类。当Libraryutility()方法调用UserCallback()方法时,图书馆期望的参数是指向User对象的指针。所以当我们回到User类时,我没有直接在其声明中声明User*指针,而是使用它继承的空类模板。然而,如果你尝试这样做:
class User : public Wrapper<User> {
public:
    static void Callback( Wrapper* ) { // print statement }
};

你应该会收到一个关于Wrapper*缺少参数列表的消息。我们可以在这里使用Wrapper<User>*,但这是多余的,因为我们已经看到User继承自Wrapper并带有自己的参数列表。所以我们可以通过在Wrapper*前加上class关键字来修复它,并使其更加清晰。由此产生了模板魔法......其实这里没有魔法......只有编译器内在和优化。


template<class T> auto Callback(...) {};中的T更改为T2?(它目前几乎可以编译http://ideone.com/7Ueuul)....顺便问一下,`Callback(class Wrapper*)是什么?也就是说,你怎么能把class Wrapper*`用作函数参数?这对我来说是新的。为什么要在这个解决方案中使用它的优势是什么? - javaLover
@javaLover...我认为这是因为User继承自Wrapper,而Wrapper的模板参数是User。属于User的回调方法是一个静态方法,它的参数声明为类指针,因为Wrapper是一个类模板。将Wrapper<User>作为其参数是多余的。Wrapper类没有任何类实例的实例化,但它具有公共函数模板。 - Francis Cugler
@javaLover 我将包装类中的函数模板注释掉了,但它仍然可以编译、构建、运行并调用User::Callback()。 - Francis Cugler
@javaLover 我还注释掉了User中作为回调方法参数的class Wrapper*,当我这样做时,它说User::Callback不接受0个参数。所以我追溯到查看Wrapper,发现它只是一个空的类体。然后我看了一下Library类,它的成员是一个T*,将是一个User*对象。这会调用User来调用其回调方法,并传递一个User*。由于User继承自Wrapper,因此它将接受类声明指针作为其参数。 - Francis Cugler
  1. 是的,在编辑后,您的代码在gcc中也能正常运行(http://ideone.com/GFjfHv)。我认为您的技巧与Yakk的标签解决方案类似(https://dev59.com/wVcQ5IYBdhLWcg3wCfSS#43919854)。这可能是一种符合标准的将类型作为参数传递的方法。(!?)
  2. 您应该在auto Callback(...) {};中删除;。我在coliru中遇到了编译错误。
  3. 我能否在static void Callback( class Wrapper* )中删除class一词?它仍然可以编译。(http://coliru.stacked-crooked.com/a/1beafc04731d0aee)
- javaLover
显示剩余4条评论

2

这非常类似于一个叫做基于策略的设计的好东西,但在你的例子中,你没有从User类中继承Library类。好的名称是任何API最好的朋友。结合这个以及基于策略的设计的已知模式(已知非常重要,因为带有“Policy”一词的类名将立即引起代码许多读者的共鸣),我认为可以得到一些能够自我记录的良好代码。

  • 继承不会给你任何性能开销,但会使Callback成为受保护方法,这会给出一些暗示,表明它应该被继承并在某处使用。

  • 在多个类似于User的类中具有明显突出且一致的命名方式(例如采用前述基于策略的设计方式的SomePolicyOfSomething),以及Library的模板参数(例如SomePolicy或我会将其称为TSomePolicy)。

  • Library类中使用using声明Callback可能会提供更清晰和更早的错误提示(例如来自IDE、现代clang、visual studio语法解析器的错误提示)。

另一个有争议的选择可能是如果你使用C++>=11,则可以使用static_assert。但在这种情况下,它必须在每个类似于User的类中使用。


我阅读了你的维基链接。我从未知道我可以这样做。这很有趣。谢谢。 - javaLover

2

这里有一个使用Traits类的解决方案:

// Library.h:
template<class T> struct LibraryTraits; // must be implemented for every User-class

template<class T> class Library {
public:
    T* node=nullptr;
    void utility() {
        LibraryTraits<T>::func(node);
    }
};

// User.h:
class User { };

// must only be implemented if User is to be used by Library (and can be implemented somewhere else)
template<> struct LibraryTraits<User> {
    static void func(User* node) { std::cout << "LibraryTraits<User>::func(" << node << ")\n"; }
};

// main.cpp:

int main() {
    Library<User> li; li.utility();
}

优点:

  • 从命名上可以明显看出,LibraryTraits<User> 仅用于通过 Library 接口与 User 进行交互(一旦删除了 LibraryUser,可以将其删除)。
  • LibraryTraits 可以独立于 LibraryUser 进行特化。

缺点:

  • 无法轻松访问 User 的私有成员(将 LibraryTraits 设为 User 的友元会使其失去独立性)。
  • 如果需要将相同的 func 用于不同的 Library 类,则需要实现多个 Trait 类(可以通过继承其他 Trait 类的默认实现来解决)。

即使已经有很多解决方案,你仍然可以提供另一个独特的解决方案,具有自己的优势,这真是令人惊叹。谢谢。顺便说一下,我不认为你列出的缺点真的是缺点。 - javaLover

1

虽然这不是关于如何记录它的直接答案,但有些事情需要考虑:

如果您的库模板要求每个类都实现someFunction(),我建议将其添加为模板参数。

#include <functional>
template<class Type, std::function<void(Type*)> callback>
class Library {
    // Some Stuff...
    Type* node = nullptr;
public:
    void utility() {
         callback(this->node);
    }
};

可能需要更明确地说明,以便其他开发人员知道它是必需的。


遗憾的是,有问题的开发人员往往盲目编写User.h,并且没有时间、错过或没有动力去查看Library.h。他们仍然无法得到任何线索。 - javaLover
没错,但这只是对未来的建议 :) 如果他们确实删除了User中的函数,你仍然可以传递一个函数,而不管类中是否有一个。 - TinfoilPancakes

0

抽象类是强制函数不被删除的最佳方式。因此,我建议使用纯虚函数实现基类,以便派生类必须定义该函数。 或者第二种解决方案是使用函数指针,这样可以通过避免额外的V表创建和调用来节省性能开销。


你的第一种解决方案与“具有func()=0的虚拟类”是相同的,对吗?你的第二种解决方案如何减少人为错误?它如何促进更好的可读性? - javaLover
这个解决方案似乎有潜力(可能会引入具有独特优缺点的新解决方案)。然而,仍需要一些片段或更多细节。 - javaLover

0
如果在 User 中不明显需要 func(),那么我认为你违反了单一职责原则。相反,创建一个适配器类,其中包含User作为成员。
class UserCallback {
  public:
    void func();

  private:
    User m_user;
}

这样,UserCallback 的存在表明 func() 是一个外部回调,并将 Library 对回调的需求与 User 的实际职责分开。


遗憾的是,UserCallback.h仍然没有任何文档说明它与Library有关。 - javaLover

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