Skip to content

API设计原则(中)

Published: at 02:08 PM | 21 min read

C++相关

值 vs. 对象

1. 指针 vs. 引用

指针(pointer)还是引用(reference)哪个是最好的输出参数(out-parameters)?

void getHsv(int *h, int *s, int *v) const;
void getHsv(int &h, int &s, int &v) const;

大多数C++书籍推荐尽可能使用引用,基于一个普遍的观点:引用比指针『更加安全和优雅』。与此相反,我们在开发Qt时倾向于指针,因为指针让用户代码可读性更好。比较下面例子:

color.getHsv(&h, &s, &v);
color.getHsv(h, s, v);

只有第一行代码清楚表达出h、s、v参数在函数调用中非常有可能会被修改。

这也就是说,编译器并不喜欢『出参』,所你应该在新的API中避免使用『出参』,而是返回一个结构体,如下所示:

struct Hsv { int hue, saturation, value };
Hsv getHsv() const;

【译注】:函数的『入参』和『出参』的混用会导致 API 接口语义的混乱,所以,使用指针,在调用的时候,实参需要加上“&”,这样在代码阅读的时候,可以看到是一个『出参』,有利于代码阅读。(但是这样做,在函数内就需要判断指针是否为空的情况,因为引用是不需要判断的,所以,这是一种 trade-off)

另外,如果这样的参数过多的话,最好使用一个结构体来把数据打包,一方面,为一组返回值取个名字,另一方面,这样有利用接口的简单。

2. 按常量引用传参 vs. 按值传参

如果类型大于16字节,按常量引用传参。

如果类型有重型的(non-trivial)拷贝构造函数(copy-constructor)或是重型的析构函数(destructor),按常量引用传参以避免执行这些函数。

对于其它的类型通常应该按值传参。

示例:

void setAge(int age);
void setCategory(QChar cat);
void setName(QLatin1String name);
 
// const-ref is much faster than running copy-constructor and destructor
void setAlarm(const QSharedPointer<Alarm> &alarm);
 
// QDate, QTime, QPoint, QPointF, QSize, QSizeF, QRect
// are good examples of other classes you should pass by value.

【译注】:这是传引用和传值的差别了,因为传值会有对像拷贝,传引用则不会。所以,如果对像的构造比较重的话(换句话说,就是对像里的成员变量需要的内存比较大),这就会影响很多性能。所以,为了提高性能,最好是传引用。但是如果传入引用的话,会导致这个对象可能会被改变。所以传入const reference。

虚函数

在C++中,当类的成员函数声明为virtual,主要是为了通过在子类重载此函数能够定制函数的行为。将函数声明为virtual的目的是为了让对这个函数已有的调用变成执行实际实例的代码路径。对于没有在类外部调用的函数声明成virtual,你应该事先非常慎重地思考过。

// QTextEdit in Qt 3: member functions that have no reason for being virtual
virtual void resetFormat();
virtual void setUndoDepth( int d );
virtual void setFormat( QTextFormat *f, int flags );
virtual void ensureCursorVisible();
virtual void placeCursor( const QPoint &pos;, QTextCursor **c = 0 );
virtual void moveCursor( CursorAction action, bool select );
virtual void doKeyboardAction( KeyboardAction action );
virtual void removeSelectedText( int selNum = 0 );
virtual void removeSelection( int selNum = 0 );
virtual void setCurrentFont( const QFont &f );
virtual void setOverwriteMode( bool b ) { overWrite = b; }

QTextEdit从Qt 3移植到Qt 4的时候,几乎所有的虚函数都被移除了。有趣的是(但在预料之中),并没有人对此有大的抱怨,为什么?因为Qt 3没用到QTextEdit的多态行为 —— 只有你会;简单地说,没有理由去继承QTextEdit并重写这些函数,除非你自己调用了这些方法。如果在Qt在外部你的应用程序你需要多态,你可以自己添加多态。

【译注】:『多态』的目的只不过是为了实践 —— 『依赖于接口而不是实现』,也就是说,接口是代码抽像的一个非常重要的方式(在Java/Go中都有专门的接口声明语法)。所以,如果没有接口抽像,使用『多态』的意义也就不大了,因为也就没有必要使用『虚函数』了。

1. 避免虚函数

在Qt中,我们有很多理由尽量减少虚函数的数量。每一次对虚函数的调用会在函数调用链路中插入一个未掌控的节点(某种程度上使结果更无法预测),使得bug修复变得更复杂。用户在重写的虚函数中可以做很多疯狂的事:

还有其他很多原因要避免过度使用虚函数:

经验告诉我们,没有虚函数的类一般bug更少、维护成本也更低。

一般的经验法则是,除非我们以这个类作为工具集提供而且有很多用户来调用某个类的虚函数,否则这个函数九成不应该设计成虚函数。

【译注】:使用虚函数时,你需要对编译器的内部行为非常清楚,否则,你会在使用虚函数时,觉得有好些『古怪』的问题发生。比如在创建数组对象的时候。 在C++中,会有一个基础类,这个基础类中已经实现好了很多功能,然后把其中的一些函数放给子类去修改和实现。这种方法在父类和子类都是一组开发人员维护时没有什么问题,但是如果这是两组开发人员,这就会带来很多问题了,就像Qt这样,子类完全无法控制,全世界的开发人员想干什么就干什么。所以,子类的代码和父类的代码在兼容上就会出现很多很多问题。所以,还是上面所说,其实,虚函数应该声明在接口的语义里(这就是设计模式的两个宗旨——依赖于接口,而不是实现;钟爱于组合,而不是继承。也是为什么Java和Go语言使用interface关键字的原因,C++在多态的语义上非常容易滥用)

2. 虚函数 vs. 拷贝

多态对象(polymorphic objects)和值类型的类(value-type classes)两者很难协作好。

包含虚函数的类必须把析构函数声明为虚函数,以防止父类析构时没有清理子类的数据,导致内存泄漏。

如果要使一个类能够拷贝、赋值或按值比较,往往需要拷贝构造函数、赋值操作符(operator =)和相等操作符(operator ==)。

class CopyClass {
public:
    CopyClass();
    CopyClass(const CopyClass &other);
    ~CopyClass();
    CopyClass &operator =(const CopyClass &other);
    bool operator ==(const CopyClass &other) const;
    bool operator !=(const CopyClass &other) const;
 
    virtual void setValue(int v);
};

如果继承CopyClass这个类,预料之外的事就已经在代码时酝酿了。一般情况下,如果没有虚成员函数和虚析构函数,就不能创建出可以多态的子类。然而,如果存在虚成员函数和虚析构函数,这突然变成了要有子类去继承的理由,而且开始变得复杂了。起初认为只要简单声明上虚操作符重载函数(virtual operators)。 但其实是走上了一条混乱和毁灭之路(破坏了代码的可读性)。看看下面的这个例子:

class OtherClass {
public:
    const CopyClass &instance() const; // 这个方法返回的是什么?可以赋值什么?
};

(这部份还未完成)

【译注】:因为原文上说,这部份并没有完成,所以,我也没有搞懂原文具体也是想表达什么。不过,就标题而言,原文是想说,在多态的情况下拷贝对象所带来的问题??

关于const

C++的关键词const表明了内容不会改变或是没有副作用。可以应用于简单的值、指针及指针所指的内容,也可以作为一个特别的属性应用于类的成员函数上,表示成员函数不能修改对象的状态。

然而,const本身并没有提供太大的价值 —— 很多编程语言甚至没有类似const的关键词,但是却并没有因此产生问题。实际上,如果你不用函数重载,并在C++源代码用搜索并删除所有的const,几乎总能编译通过并且正常运行。尽量让使用的const保持实用有效,这点很重要。

让我们看一下在Qt的API设计中与const相关的场景。

1. 输入参数:const指针

有输入指针参数的const成员函数,几乎总是const指针参数。

如果函数声明为const,意味着既没有副作用,也不会改变对象的可见状态。那为什么它需要一个没有const限定的输入参数呢?记住const类型的函数通常被其他const类型的函数调用,接收到的一般都是const指针(只要不主动const_cast,我们推荐尽量避免使用const_cast)

以前:

bool QWidget::isVisibleTo(QWidget *ancestor) const;
bool QWidget::isEnabledTo(QWidget *ancestor) const;
QPoint QWidget::mapFrom(QWidget *ancestor, const QPoint &pos) const;

QWidget声明了许多非const指针输入参数的const成员函数。注意,这些函数可以修改传入的参数,不能修改对象自己。使用这样的函数常常要借助const_cast转换。如果是const指针输入参数,就可以避免这样的转换了。

之后:

bool QWidget::isVisibleTo(const QWidget *ancestor) const;
bool QWidget::isEnabledTo(const QWidget *ancestor) const;
QPoint QWidget::mapFrom(const QWidget *ancestor, const QPoint &pos) const;

注意,我们在QGraphicsItem中对此做了修正,但是QWidget要等到Qt 5:

bool isVisibleTo(const QGraphicsItem *parent) const;
QPointF mapFromItem (const QGraphicsItem *item, const QPointF &point) const;

2. 返回值:const值

调用函数返回的非引用类型的结果,称之为右值(R-value)。

非类(non-class)的右值总是无cv限定类型(cv-unqualified type)。虽然从语法上讲,加上const也可以,但是没什么意义,因为鉴于访问权限这些值是不能改变的。多数现代编译器在编译这样的代码时会提示警告信息。

【译注】:cv-qualified的类型(与cv-unqualified相反)是由const或者volatile或者volatile const限定的类型。详见cv (const and volatile) type qualifiers – C++语言参考

当在类类型(class type)右值上添加const关键字,则禁止访问非const成员函数以及对成员的直接操作。

不加const则没有以上的限制,但几乎没有必要加上const,因为右值对象生存时间(life time)的结束一般在C++清理的时候(通俗的说,下一个分号地方),而对右值对象的修改随着右值对象的生存时间也一起结束了(也就是本条语句的执行完成的时候)。

示例:

struct Foo {
    void setValue(int v) { value = v; }
    int value;
};
 
Foo foo() {
    return Foo();
}
 
const Foo cfoo() {
    return Foo();
}
 
int main() {
    // The following does compile, foo() is non-const R-value which
    // can't be assigned to (this generally requires an L-value)
    // but member access leads to a L-value:
    foo().value = 1; // Ok, but temporary will be thrown away at the end of the full-expression.
 
    // The following does compile, foo() is non-const R-value which
    // can't be assigned to, but calling (even non-const) member
    // function is fine:
    foo().setValue(1); // Ok, but temporary will be thrown away at the end of the full-expression.
 
    // The following does _not_compile, foo() is ''const'' R-value
    // with const member which member access can't be assigned to:
    cfoo().value = 1; // Not ok.
 
    // The following does _not_compile, foo() is ''const'' R-value,
    // one cannot call non-const member functions:
    cfoo().setValue(1); // Not ok
}

【译注】:上述的代码说明,如果返回值不是const的,代码可以顺利编译通过,然而并没有什么卵用,因为那个临时对像马上就被抛弃了。所以,这样的无用的代码最好还是在编译时报个错,以免当时头脑发热想错了,写了一段没用但还以为有用的代码。

3. 返回值:非const的指针还是有const的指针

谈到const函数应该返回非const的指针还是const指针这个话题时,多数人发现在C++中关于『const正确性』(const correctness)在概念上产生了分歧。 问题起源是:const函数本身不能修改对象自身的状态,却可以返回成员的非const指针。返回指针这个简单动作本身既不会影响整个对象的可见状态,当然也不会改变这个函数职责范围内涉及的状态。但是,这却使得程序员可以间接访问并修改对象的状态。

下面的例子演示了通过返回非const指针的const函数绕开const约定(constness)的诸多方式中的一种:

QVariant CustomWidget::inputMethodQuery(Qt::InputMethodQuery query) const {
    moveBy(10, 10); // doesn't compile!
    window()->childAt(mapTo(window(), rect().center()))->moveBy(10, 10); // compiles!
}

返回const指针的函数正是保护以避免这些(可能是不期望的/没有预料到的)副作用,至少是在一定程度上。但哪个函数你会觉得更想返回const指针,或是不止一个函数?

若采用const正确(const-correct)的方法,每个返回某个成员的指针(或多个指向成员的指针)的const函数必须返回const指针。在实践中,很不幸这样的做法将导致无法使用的API:

QGraphicsScene scene;
// … populate scene
 
foreach (const QGraphicsItem *item, scene.items()) {
    item->setPos(qrand() % 500, qrand() % 500); // doesn't compile! item is a const pointer
}

QGraphicsScene::items()是一个const函数,顺着思考看起来这个函数只应该返回const指针。

在Qt中,我们几乎只有非const的使用模式。我们选择的是实用路子: 相比滥用非const指针返回类型带来的问题,返回const指针更可能招致过分使用const_cast的问题。

4. 返回值:按值返回 还是 按const引用返回?

若返回的是对象的拷贝,那么返回const引用是更直接的方案; 然而,这样的做法限制了后面想要对这个类的重构(refactor)。 (以d-point的典型做法(idiom)为例,我们可以在任何时候改变Qt类在内存表示(memory representation);但却不能在不破坏二进制兼容性的情况下把改变函数的签名,返回值从const QFoo &变为QFoo。) 基于这个原因,除去对运行速度敏感(speed is critical)而重构不是问题的个别情形(例如,QList::at()),我们一般返回QFoo而不是const QFoo &。

【译注】:参看《Effective C++》中条款23:Don’t try to return a reference when you must return an object

5. const vs. 对象的状态

const正确性(Const correctness)的问题就像C圈子中vi与emacs的讨论,因为这个话题在很多地方都存在分歧(比如基于指针的函数)。

但通用准则是const函数不能改变类的可见状态。『状态』的意思是『自身以及涉及的职责』。这并不是指非const函数能够改变自身的私有成员,也不是指const函数改变不了。而是指函数是活跃的并存在可见的副作用(visible side effects)。const函数一般没有任何可见的副作用,比如:

QSize size = widget->sizeHint(); // const
widget->move(10, 10); // not const

代理(delegate)负责在其它对象上绘制内容。 它的状态包括它的职责,因此包括在哪个对象做绘制这样的状态。 调用它的绘画行为必然会有副作用; 它改变了它绘制所在设备的外观(及其所关联的状态)。鉴于这些,paint()作为const函数并不合理。 进一步说,任何paint()或QIcon的paint()的视图函数是const函数也不合理。 没有人会从内部的const函数去调用QIcon::paint(),除非他想显式地绕开const这个特性。 如果是这种情况,使用const_cast会更好。

// QAbstractItemDelegate::paint is const
void QAbstractItemDelegate::paint(QPainter **painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
 
// QGraphicsItem::paint is not const
void QGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem option, QWidget *widget)

API设计原则(上)
API设计原则(中)
API设计原则(下)

原文链接:API Design Principles – Qt Wiki 基于Gary的影响力上 Gary Gao 的译文稿:C++的API设计指导