Generic Callbacks
译自<<Exceptional C++ style>>的开放章节
JG问题
1. 设计、编写泛型设施时,预期什么样的质量?请解释。
Guru问题
2. 以下代码代表了一个有趣的、有用的惯用法来包装回调函数。请参看原文章来获得更详细的解释。[Kalev01]
指正这些代码并标出:
a) 可以改善风格的选择,以使设计满足更多的C++惯用法。
b) 限制了设施的可用性。
template < class T, void (T::*F)() >
class callback
{
public:
callback(T& t) : object(t) {} // assign actual object to T
void execute() {(object.*F)();} // launch callback function
private:
T& object;
};
解答
泛型质量
1. 设计、编写泛型设施时,预期什么样的质量?请解释。
泛型代码首先应该是可用的。那并不意味着它必须包含所有的操作。它的意思是泛型代码应改作合理的、平衡的努力以避免至少三件事:
1. 避免不适当的类型限制。例如,如果你想写一个泛型容器。很合理的要求是容器中的元素类型有一个拷贝构造函数,一个不会抛出异常的析构函数。但是缺省的构造函数呢?赋值运算符呢?用户想放入容器中的很多类型并不包含缺省的构造函数,并且如果你的容器使用它的话,那么这种类型将不能使用你编写的容器。所以你的容器并不是泛型的。(请参考Exceptional C++[Sutter00]来获得一个完全的例子。)
2. 避免不适合的功能限制。如果你编写一个设施来执行X和Y,但用户想执行Z,那会怎样?Z和Y的差距并不大。有时你会想让你的泛型设施更灵活,让它能够支持Z。有时你又不想那样。好的泛型设计的一部分是提供选择方式,意思是通过它,你的泛型设施可以被定制或扩展。这在泛型设计中是很重要的,并且你也不应该惊讶,因为这个原则在面向对象的类的设计中也是相同的。
基于策略的设计是众多重要技术中的一个,它允许在泛型代码中的“可插入”行为。基于策略的设计的例子请参看[Alexandrescu01]的部分章节。StartPtr和Singleton是很好的开始点。这导致了一个相关的问题:
3. 避免不合适的[monolithic]设计。这个重要的问题并不会在下面要考虑的例子中出现,应该对它作进一个步的考虑,因此它不仅出现在本条款中,也出现在以后的条款中:请查看条款37到40。
在这三点中,你可能注意到重复出现的词是“不合适的”。它的意思是说:当在缺乏足够的泛型和过度工程之间做出平衡时,应该好好的判断。
剖析泛型回调
2. 以下代码代表了一个有趣的、有用的惯用法来包装回调函数。请参看原文章来获得更详细的解释。[Kalev01]
再看一下代码:
template < class T, void (T::*F)() >
class callback
{
public:
callback(T& t) : object(t) {} // assign actual object to T
void execute() {(object.*F)();} // launch callback function
private:
T& object;
};
现在,实际来讲,在这个有两个成员函数,每个成员函数都是一行的简单类中会有多少错误?结果是,这种极端的简单性就是问题的一部分。这个模板类不需要如此的重量级,根本不需要,但是可以用一个稍微轻量级的来代表它。
改善的风格
指正这些代码并标出:
a) 可以改善风格的选择,以使设计满足更多的C++惯用法。
你发现多少瑕疵?这是我总结的:
4. 构造函数应该是explict的。作者可能并不想提供一个隐式的从T到callback<T>的转换。行为良好的类要避免给它的使用者产生潜在的错误。我们确切想要提供的如下:
explicit callback(T& t) : object(t){}//assign actual object to T
尽管我们已经分析了这个特定行,还有一个不是关于设计,而是关于描述的风格问题:
原则:尽量使构造函数是explicit,除非你确实想提供类型转换。
注释是错误的。注释中的“assign”是不正确的,所以导致某些误解。在构造函数中,更正确地是我们绑定T对象的一个引用,并扩展成回调对象。同样,经过了多次重读注释,我还不知道“to T”部分的意思。所以更好的注释将是“绑定实际的对象”
explicit callback(T& t) : object(t) {} // bind actual object
但是,所有注释所表达的正是代码已经表达的,确实非常可笑,它是无用注释的一个很好的例子,所以最好写成如下形式:
explicit callback(T& t) : object(t) {}
5. execute函数因该是const的。毕竟,execute函数并没有对callback<T>对象的状态作任何改动。这有回到了基本的问题:Const正确性可能有点老,但是确实很好。至少自从1980年早期,在C,C++中就已经知道Const正确性的价值。并且它的价值并不会因为我们到了新的千年而消失,也不会应为我们编写大量的模板而消失。
void execute()const{ (object.*F)(); }// launch callback function
原则:正确的使用const。
尽管我们已经分析了execute函数,仍有一个更严重的惯用法问题:
6. execute函数应该被写为operator()。在C++中,惯用法是用函数调用操作符来执行函数风格的操作。因此注释也变得无用了,可以安全的将其删除,因为我们现在的代码已经通过惯用法表达了它自己。
void operator()()const{(object.*F)();}//launch callback function
你可能会吃惊的说“如果我们提供了函数运算符,那它不就是一个函数对象了?”很好的问题,我们继续观察,作为一个函数对象,或许回调的实例应该是可配接的。
原则:提供operator()来代表惯用的函数对象,而不是提供一个名为execute的方法。
陷阱:(惯用法)难道这个回调不应该从std::unary_function继承吗?查看[Meyers01]的条款36来了解关于可配接的更多的讨论,以及为什么通常情况下它一个好的设计。但是,在这里,有两个很好的理由导致callback不应该从std::unary_function继承:
l 它不是单参函数。它不接收参数,单参函数接收一个参数。(void不算)
l 从std::unary_function继承无论如何并不能使之可扩展。下面我们将要看回调或许应该能处理其他不同签名的函数;根据于参数的个数,或许根本没有合适的基类可以继承。例如,如果我们支持三个参数的callback,我们没有std::ternary_function来继承。
派生自std::unary_function或std::binary_function可以提供几个方便的、它所绑定的类型定义,它所依赖的相似的设施,但是这些仅在你准备使用函数对象的这些设施时它才有效。因为回调的自然属性以及它们会被如何使用,不太可能需要这些设施。(对于普通的单参,双参情况,如果将来它们应该可以这么使用,我们后来提到的单参,双参版本应该分别派生自std::unary_function和std::binary_function。)
修正机制错误及限制
b) 限制了设施的可用性。
7. 考虑使回调函数接收一个正常的参数,而不是一个模板参数。非类型模板参数很少使用,因为在编译时如此固定一个类型,能够获得的好处很少。因此我们替换为:
template < class T >
class callback {
public:
typedef void (T::*Func)();
callback(T& t, Func func) : object(t), f(func) {} // bind actual object
void operator()() const { (object.*f)(); } // launch callback function
private:
T& object;
Func f;
};
现在函数可以在运行时被改变,很容易添加一个成员函数以允许用户改变已存回调对象的函数,这在以前的版本中是不可能实现的。
原则:优先使用正常的函数参数,然后是非类型参数。除非它们确实是非类型模板参数。
8. 可以被容器化。如果程序想保存一个回调对象为后来使用,那么它也可能会保存多个对象。如果它想把回调对象放入容器中,比如vector或list中会怎样?现在那是不可能的,因为回调对象不支持operator=,就是说它不能被赋值。为什么不能?因为它包含一个引用,在构造函数中,一旦绑定引用,它就不能再被修改了。
但是指针没有这样的限制,它可以被随意改变。在这种情况下,使用指向对象的指针而不是引用是安全的,并且可以使用编译器产生的拷贝构造函数和赋值运算符。
template < class T >
class callback {
public:
typedef void (T::*Func)();
callback(T& t, Func func) : object(&t), f(func) {} // bind actual object
void operator()() const { (object->*f)(); } // launch callback function
private:
T* object;
Func f;
};
现在,可能有形如list< callback< Widget, &Widget::Somefun > >这样的类型了。
原则:优先是你的对象和容器相兼容。特别的,为了将对象放入容器中,对象必须是可赋值的。
“但是,等一下”,你此时很想知道,“如果我有那样的一个list,为什么我不能有一个任意类型的回调呢?以便我可以记住它们并且当我想执行回调时,我可以随意执行”。实际上,如果你添加一个基类,你就可以那么做了。
9. 允许多态:为回调类型提供一个共通的基类。如果我们想让用户拥有list< callbackbase* >(或者更好list< shared_ptr<callbackbase> >),我们可以通过提供这样的一个基类来实现,基类中的运算符()什么也不做。
class callbackbase {
public:
virtual void operator()() const { };
virtual ~callbackbase() = 0;
};
callbackbase::~callbackbase() { }
template < class T >
class callback : public callbackbase {
public:
typedef void (T::*Func)();
callback(T& t, Func func) : object(&t), f(func) {} // bind actual object
void operator()() const { (object->*f)(); } // launch callback function
private:
T* object;
Func f;
};
现在,任何人可以保存list< callbackbase* > 并且可以多态的调用元素的运算符()。当然list< shared_ptr<callback> >将更好些;请参见[Sutter02b]。
注意到,添加一个基类是一个权衡,但是却是很小的一个:当通过基类触发回调时,我们增加了两级间接调用的负荷,也就是说一个虚函数调用。但是这个负荷仅仅是通过使用基类时才有。不使用基类接口的代码并不需要为此付出。
原则:如果对你的模板类起作用的话,考虑允许多态以便你的类的不同的实例可以互交换的使用。如果的确起作用,那么为你所有的模板类提供一个共通的基类。
10.(惯用法,权衡)应该有一个辅助函数make_callback来进行类型推导。经过了一段时间使用之后,用户会对为临时对象提供显式模板参数感到厌倦。
list< callback< Widget > > l;
l.push_back( callback<Widget>( w, &Widget::SomeFunc ) );
为什么要写两次Widget?难道编译器不应该知道吗?可是,它不知道,但我们可以帮助它,让它知道此时仅仅需要临时变量。因此我们提供了一个辅助函数,它仅需要一个类型:
list< callback< Widget > > l;
l.push_back( make_callback( w, &Widget::SomeFunc ) );
make_callback和std::make_pair的工作原理相同。make_callback函数应该是一个模板函数,因为这是编译器唯一可以推导的模板参数类型。如下是辅助函数:
template<typename T >
callback<T> make_callback( T& t, void (T::*f) () ) {
return callback<T>( t, f );
}
11.(权衡)添加其他函数签名的支持。我留下这个最大的工作。因为吟游诗人会说“会有更多的函数签名,而不仅仅是void ( T::*F)()!”
原则:避免限制你的模板;避免为具体的类型或为更少的通用类型而硬编码。
如果限制的回调函数签名足够的话,无论如何要在这里结束了。如果我们不需要它,我们就不必将设计引向更复杂。如果我们确实需要它,我们将不得不将设计引向更复杂。
我不会写出所有的代码,因为那非常乏味。我所做的只是概要的描述你必须做的工作,以及如何做。
首先,常成员函数怎样?处理它的最容易的方式是提供一个同等的使用const签名的回调,在那个版本中,注意要保存一个执行const对象的指针或引用。
其次,如果函数的返回类型不为void会怎样?最简单的方式是添加另外一个模板参数表示返回类型。
最后,如果回调函数需要接收参数会怎样?再次,添加模板参数,注意也给操作符()添加函数参数。
记着添加新的模版来处理每种潜在数量的回调参数。
探索完毕所有的代码,你不得不限制回调支持的函数参数的个数。或许在将来的C++0x中,我们提供一个想varargs的模版来帮助你处理这些事情,但是现在没有。
总结
把所有的东西放在一起,做些纯粹的风格调整,使用typename,命名规则,以及我比较喜欢的空格规则,这就是我们得到的最终结果:
class CallbackBase {
public:
virtual void operator()() const { };
virtual ~CallbackBase() = 0;
};
CallbackBase::~CallbackBase() { }
template<typename T>
class Callback : public CallbackBase {
public:
typedef void (T::*F)();
Callback( T& t, F f ) : t_(&t), f_(f) { }
void operator()() const { (t_->*f_)(); }
private:
T* t_;
F f_;
};
template<typename T>
Callback<T> make_callback( T& t, void (T::*f) () ) {
return Callback<T>( t, f );
}