Qt 5 中信号槽新语法的实现

原文地址:http://woboq.com/blog/how-qt-signals-slots-work-part2-qt5.html

这是解释 Qt 信号槽机制的上一篇文章的后续。在上一篇文章中,我们了解到旧的信号槽语法的一般原理以及如何实现的。在本章,我们将看看 Qt5 基于函数指针的新语法是如何实现的。

Qt5 的新语法

新语法看起来是这样的:

QObject::connect(&a, &Counter::valueChanged,
                 &b, &Counter::setValue);

为什么要有新语法?

我们已经在介绍新语法的文章中解释过新语法的好处。简单来说,新语法允许信号槽的编译时检查,也能够自动转换不同类型的参数。另外,它还提供了对 lambda 表达式的支持。

新的重载

是要很少的修改就能支持上述语法。其主要思路是,为QObject::connect()添加新的重载,用函数指针替换那些char *。现在有三个新的QObject::connect()的静态重载(实际代码并不是这个样子的):

QObject::connect(const QObject *sender, PointerToMemberFunction signal,
                 const QObject *receiver, PointerToMemberFunction slot,
                 Qt::ConnectionType type)

QObject::connect(const QObject *sender, PointerToMemberFunction signal,
                 const QObject *receiver, PointerToFunction method)

QObject::connect(const QObject *sender, PointerToMemberFunction signal,
                 const QObject *receiver, Functor method)

第一个同旧语法很相似:将来自发送者的信号连接到接受者的槽。其它两个则是将一个信号连接到静态函数或没有接受者的函数对象。

这几个重载都非常相似,在本文我们只分析第一个重载。

成员函数指针

在我们开始讲解之前,首先我们需要插入一段有关成员函数指针的内容。

下面是一个声明成员函数指针并且调用的示例:

void (QPoint::*myFunctionPtr)(int); // 声明指向一个成员函数的指针 myFunctionPtr,
                                    // 该函数返回 void,参数是一个 int
myFunctionPtr = &QPoint::setX;
QPoint p;
QPoint *pp = &p;
(p.*myFunctionPtr)(5);   // 等同于 p.setX(5);
(pp->*myFunctionPtr)(5); // 等同于 pp->setX(5);

成员指针和成员函数指针都是 C++ 中那些较少使用的部分,因而也不被大多数人所知。好消息是,使用 Qt 及其新语法,你仍然不需要了解很多。你所要知道的是,记得在connect()函数调用的时候,在信号的名字前面添加一个 &。你也不需要真正去面对 ::*、.* 或者 ->* 这些神秘的运算符。

这些神秘的运算符允许你声明一个指向成员的指针并且访问它。这些指针的类型包括返回值类型、拥有该成员的类、每一个参数的类型以及函数的 const 修饰符。

你不能真正地将成员函数指针转换成其它什么东西,特别是不能转成void *,因为他们的sizeof运算结果是不同的。如果函数声明有任何细微的变化,你都不能将其转换成其它的。例如,从void (MyClass::*)(int) const转换到void (MyClass::*)(int)都是不允许的。(你可以通过reinterpret_cast完成转换;但是根据 C++ 标准,这种行为是未定义的)

成员函数指针不同于普通的函数指针,普通的函数指针仅仅是一个指向某个地址的普通指针,这个地址就是函数代码所在的起始地址。但是,成员函数指针需要存储更多的信息:成员函数可能是虚的;在多继承情况下可能有隐藏的 this 偏移。甚至成员函数指针的sizeof运算结果都会严重依赖于这个类。这就是在操作成员函数指针时需要特殊处理的原因。

类型特征:QtPrivate::FunctionPointer

现在我们引入一个类型特征:QtPrivate::FunctionPointer

特征类是一种用于给出给定类型的辅助类。Qt 中另外一个特征类例子是 QTypeInfo

为实现新语法,我们需要知道函数指针的信息。

template<typename T> FunctionPointer可以通过其成员告诉我们有关 T 的信息。

  • ArgumentCount:用于表述函数的参数个数的整型。
  • Object:该数据值仅适用于指针是成员函数指针时。这是成员函数所在类的typedef
  • Arguments:表示参数列表。这是元编程列表的typedef
  • call(T &function, QObject *receiver, void **args):对给定的对象,传递给定的参数,调用某个函数的静态函数。

Qt 同样支持 C++98 编译器。这意味着我们不能要求支持可变参数模板。因此我们必须为每一个不同的参数数指定我们的特征函数。我们有四种类型的特化:普通函数指针,成员函数指针,const成员函数指针和仿函数(functors)。每一种类型,我们都需要为每一个参数数目指定特征函数。我们支持多达六个参数。如果编译器支持可变参数模板,我们还提供了利用可变参数模板实现的特化,由此支持任意的参数数。

FunctionPointer的实现位于 qobjectdefs_impl.h

QObject::connect

QObject::connect()的实现依赖于大量模板代码。这里我们不打算全部解释。

下面是选自 qobject.h 的第一个新的重载版本:

template <typename Func1, typename Func2>
static inline QMetaObject::Connection connect(
    const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal,
    const typename QtPrivate::FunctionPointer<Func2>::Object *receiver, Func2 slot,
    Qt::ConnectionType type = Qt::AutoConnection)
{
  typedef QtPrivate::FunctionPointer<Func1> SignalType;
  typedef QtPrivate::FunctionPointer<Func2> SlotType;

  //compilation error if the arguments does not match.
  Q_STATIC_ASSERT_X(int(SignalType::ArgumentCount) >= int(SlotType::ArgumentCount),
                    ""The slot requires more arguments than the signal provides."");
  Q_STATIC_ASSERT_X((QtPrivate::CheckCompatibleArguments<typename SignalType::Arguments,
                                                         typename SlotType::Arguments>::value),
                    ""Signal and slot arguments are not compatible."");
  Q_STATIC_ASSERT_X((QtPrivate::AreArgumentsCompatible<typename SlotType::ReturnType,
                                                       typename SignalType::ReturnType>::value),
                    ""Return type of the slot is not compatible with the return type of the signal."");

  const int *types;
  /* ... Skipped initialization of types, used for QueuedConnection ...*/

  QtPrivate::QSlotObjectBase *slotObj = new QtPrivate::QSlotObject<Func2,
        typename QtPrivate::List_Left<typename SignalType::Arguments, SlotType::ArgumentCount>::Value,
        typename SignalType::ReturnType>(slot);

  return connectImpl(sender, reinterpret_cast<void **>(&signal),
                     receiver, reinterpret_cast<void **>(&slot), slotObj,
                     type, types, &SignalType::Object::staticMetaObject);
}

注意,函数签名中的senderreceiver并不像文档中指出的那样,是QObject *类型的,而是typename FunctionPointer::Object的指针。这使用了 SFINAE 机制,确保该重载版本仅适用于成员函数指针,因为FunctionPointer中的Object仅存在于其类型是成员函数指针的情况下。

我们从 Q_STATIC_ASSERT 部分来理解这段代码。这部分会在开发人员出错时生成合理的编译错误。如果开发人员出了错,他应该在这里就看到错误信息,而不是到 _impl.h 中定义的一大堆模板中去寻找。我们希望对那些不需要知道底层信息的上层开发人员隐藏这些底层实现。这意味着,如果你看到了有关实现细节的那些令人奇怪的错误信息,你应该把它当成是一个 bug 并上报

然后我们创建了一个QSlotObject对象,该对象将被传递给connectImpl()QSlotObject是槽函数的包装器,用于辅助调用该槽函数。同时,它也会知道信号的参数类型,所以,它能进行合理的类型转换。我们使用List_Left指出,仅传递与槽函数相同数目的参数,这允许哦我们将一个信号与参数数少于该信号的槽连接起来。

QObject::connectImpl是用于执行连接的私有内部函数。它与旧语法相似,不同之处在于,旧的实现在QObjectPrivate::Connection结构体中存储函数索引,而新的实现则是在QSlotObjectBase存储函数指针。

我们之所以将&slot作为void**传递,只是为了在连接类型是Qt::UniqueConnection时可以与其进行对比。

我们还将&signal作为void**传递。这是一个指向成员函数指针的指针(是的,就是指针的指针)。

信号索引

我们需要建立信号指针同信号索引之间的关系。我们使用 MOC 达到这个目的。是的,这意味着新语法仍然需要 MOC,并且也没有任何放弃的计划。

MOC 会生成qt_static_metacall中,用于对比参数并返回正确的索引的代码。connectImpl会将函数指针的指针作为参数来调用qt_static_metacall函数。

void Counter::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a)
{
    if (_c == QMetaObject::InvokeMetaMethod) {
        /* .... skipped ....*/
    } else if (_c == QMetaObject::IndexOfMethod) {
        int *result = reinterpret_cast<int *>(_a[0]);
        void **func = reinterpret_cast<void **>(_a[1]);
        {
            typedef void (Counter::*_t)(int );
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::valueChanged)) {
                *result = 0;
            }
        }
        {
            typedef QString (Counter::*_t)(const QString & );
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::someOtherSignal)) {
                *result = 1;
            }
        }
        {
            typedef void (Counter::*_t)();
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::anotherSignal)) {
                *result = 2;
            }
        }
    }
}

一旦我们有了信号索引,我们就能够像旧的语法一样进行处理。

QSlotObjectBase

QSlotObjectBase是传递给connectImpl,用来表示槽的对象。

在分析真正的代码之前,我们先来看看 Qt5 alpha 中QObject::QSlotObjectBase是怎样的:

struct QSlotObjectBase {
    QAtomicInt ref;
    QSlotObjectBase() : ref(1) {}
    virtual ~QSlotObjectBase();
    virtual void call(QObject *receiver, void **a) = 0;
    virtual bool compare(void **) { return false; }
};

这里仅提供一个基本的接口,意味着需要由实现了调用和函数指针对比的模板类来重新实现。

现在,它由QSlotObjectQStaticSlotObjectQFunctorSlotObject三个模板类实现。

仿虚表

这种实现的问题是,那些对象的实例可能需要创建虚表。虚表不仅包含指向虚函数的指针,同时还要有很多我们不需要的其它信息,比如 RTTI。这会导致大量冗余数据和二进制重定位。

为了避免这种情况,QSlotObjectBase作出了修改:不再是一个 C++ 多态类。虚函数由手工方式模拟。

class QSlotObjectBase {
  QAtomicInt m_ref;
  typedef void (*ImplFn)(int which, QSlotObjectBase* this_,
                         QObject *receiver, void **args, bool *ret);
  const ImplFn m_impl;
protected:
  enum Operation { Destroy, Call, Compare };
public:
  explicit QSlotObjectBase(ImplFn fn) : m_ref(1), m_impl(fn) {}
  inline int ref() Q_DECL_NOTHROW { return m_ref.ref(); }
  inline void destroyIfLastRef() Q_DECL_NOTHROW {
    if (!m_ref.deref()) m_impl(Destroy, this, 0, 0, 0);
  }

  inline bool compare(void **a) { bool ret; m_impl(Compare, this, 0, a, &ret); return ret; }
  inline void call(QObject *r, void **a) {  m_impl(Call,    this, r, a, 0); }
};

m_impl 是一个普通的函数指针,这个函数指针执行先前版本中的三个虚函数的操作。“重新实现(re-implementations)”在构造函数中将其设置为各自实际的实现。

尽管在这里你了解到这种技术,即使你感觉这很有用,但也不要将你自己的代码中的虚函数替换成这种实现。这仅在这种情况下才有效,因为几乎每一个连接的调用都要生成一个新的类型(因为QSlotObject具有因信号和槽而不同的模板参数)。

protected、public 以及 private 信号

在 Qt4 及更早的版本中,信号是 protected 的。这是由于考虑到,信号应该在对象在改变自己状态的时候被触发。它不应该被外部对象发出,并且在另外一个对象中调用某个信号不是什么好主意。

但是,对于新语法,你需要在建立连接的时候获取到信号的地址。在你需要访问信号的时候,编译器要求必须这么做。如果信号不是 public 的,&Counter::valueChanged这样的语句就会报出编译错误。

在 Qt 5 中,我们不得不将信号从 protected 改为 public。这可能会有问题,因为这允许任何人都能够发出信号。我们找不到任何方法改变它。我们试图对 emit 关键字做点手脚,但是不行。我相信,新语法带来的好处要远远大于信号变成 public 这一缺陷。

有时将信号作为 private 的也是可以的。这是QAbstractItemModel中的例子:其 API 并不希望开发人员在子类中发出信号。曾经使用预处理机制来将信号作为私有的,但是这打破了新的连接语法。

我们还引入了新的技巧。在Q_OBJECT宏中申明了 private 的QPrivateSignal空结构。它可以作为信号的最后一个参数。由于它是私有的,只有当前对象才能在调用信号时构建它。MOC 会在生成签名信息时忽略最后一个QPrivateSignal参数。详见 qabstractitemmodel.h

更多模板代码

qobjectdefs_impl.hqobject_impl.h 中剩余的代码大多是带有技巧性的模板代码。

在本文中,我们对这些细节作过多解释,只是简单浏览下值得一提的东西。

元编程列表

前面提到过,FunctionPointer::Arguments是参数的列表。我们需要有对其操作的代码:遍历每一个元素、取其中某一部分或者选出指定的元素。

这就是为什么 QtPrivate::List 可以表示类型的列表。一些对其操作的辅助类是 QtPrivate::List_SelectQtPrivate::List_Left,前者取出列表中第 N 个元素,后者取出前 N 个元素的子列表。

List 的实现因编译器支持可变参数模板与否而有不同。

如果编译器支持可变参数模板,它就是template<typename... T> struct List;类型。参数列表仅仅被封装在模板参数中。例如,包含了参数(int, QString, QObject*)的列表类型应该是:

List<int, QString, QObject *>

如果编译器不支持可变参数模板, 它就是一个 LISP 风格的列表:template<typename Head, typename Tail> struct List;,其中,Tail可以是另外一个列表,也可以是void,如果代表列表结束的话。下面是一个简单的例子:

List<int, List<QString, List<QObject *, void> > >

ApplyReturnValue技巧

FunctionPointer::call函数中,args[0]意味着接收到的槽函数的返回值。如果信号有返回值,它是指向信号返回值类型的对象的指针,否则就是 0。如果槽有返回值,我们需要将其拷贝到arg[0];如果没有则什么都不做。

豆子注:如果你觉得上文中“信号有返回值”这句有错误的话,这里需要解释一下。豆子已经同原作者讨论过这个问题,原作者的回复是,从 Qt4 开始,信号就允许有返回值,只是并没有在文档中写明,因为关于这个特性,即使是 Qt 开发组内部人员也没有就该行为需要如何定义达成一致。因此,尽管现在你知道有这么回事,但是也不应该去使用它,就继续当不知道吧!

问题是,使用返回 void 的函数的返回值在语法上是错误的。我应该考虑那些重复的代码,一份拷贝专门用于处理返回 void 的函数,另一份处理非 void 的?不!多亏了逗号运算符。

在 C++ 中,你可以这么写:

functionThatReturnsVoid(), somethingElse();

你可以将逗号换成分号,这也是正确的。

真正有趣的是,当你将其在非 void 调用中时:

functionThatReturnsInt(), somethingElse();

此时,逗号实际会调用一个运算符,而这个运算符是可重写的。这正是我们在 qobjectdefs_impl.h 中所做的。

template <typename T>
struct ApplyReturnValue {
    void *data;
    ApplyReturnValue(void *data_) : data(data_) {}
};

template<typename T, typename U>
void operator,(const T &value, const ApplyReturnValue<U> &container) {
    if (container.data)
        *reinterpret_cast<U*>(container.data) = value;
}
template<typename T>
void operator,(T, const ApplyReturnValue<void> &) {}

ApplyReturnValue 仅仅是包装了 void* 的包装器。它可以被用于其它辅助类。下面是没有参数的伪函数的例子:

static void call(Function &f, void *, void **arg) {
    f(), ApplyReturnValue<SignalReturnType>(arg[0]);
}

这段代码是内联的,所以不会有任何运行时消耗。

结论

这是我们这篇文章的全部。当然还有很多值得讨论的问题(例如我们还没有提到QueuedConnection以及线程安全),但是我希望你能够对其感兴趣。希望本文能够对你的编程有所帮助。

Comments (4)

  1. 渡世白玉 2014年1月17日
  2. Sakura 2016年2月19日

Leave a Reply