分类: C/C++
2014-09-09 23:16:25
Designing Qt-Style C++ APIs |
by Matthias Ettrich |
We have done substantial research at Trolltech into improving the Qt development experience. In this article, I want to share some of our findings and present the principles we've been using when designing Qt 4, and show you how to apply them to your code.
Designing application programmer interfaces, APIs, is hard. It is an art as difficult as designing programming languages. There are many different principles to choose from, many of which tend to contradict each other.
Computer science education today puts a lot of emphasis on algorithms and data structures, with less focus on the principles behind designing programming languages and frameworks. This leaves application programmers unprepared for an increasingly important task: the creation of reusable components.
Before the rise of object-oriented languages, reusable generic code was mostly written by library vendors rather than by application developers. In the Qt world, this situation has changed significantly. Programming with Qt is writing new components all the time. A typical Qt application has at least some customized components that are reused throughout the application. Often the same components are deployed as part of other applications. KDE, the K Desktop Environment, goes even further and extends Qt with many add-on libraries that implement hundreds of additional classes.
But what constitutes a good, efficient C++ API? What is good or bad depends on many factors -- for example, the task at hand and the specific target group. A good API has a number of features, some of which are generally desirable, and some of which are more specific to certain problem domains.
Six Characteristics of Good APIs |
An API is to the programmer what a GUI is to the end-user. The 'P' in API stands for "Programmer", not "Program", to highlight the fact that APIs are used by programmers, who are humans.
We believe APIs should be minimal and complete, have clear and simple semantics, be intuitive, be easy to memorize, and lead to readable code.
The Convenience Trap |
It is a common misconception that the less code you need to achieve something, the better the API. Keep in mind that code is written more than once but has to be understood over and over again. For example,
*slider = new (12, 18, 3, 13, Qt::Vertical, 0, "volume");
is much harder to read (and even to write) than
*slider = new (Qt::Vertical); slider->setRange(12, 18); slider->setPageStep(3); slider->setValue(13); slider->setObjectName("volume");
The Boolean Parameter Trap |
Boolean parameters often lead to unreadable code. In particular, it's almost invariably a mistake to add a bool parameter to an existing function. In Qt, the traditional example is repaint(), which takes an optional bool parameter specifying whether the background should be erased (the default) or not. This leads to code such as
widget->repaint(false);
which beginners might read as meaning, "Don't repaint!"
The thinking is apparently that the bool parameter saves one function, thus helping reducing the bloat. In truth, it adds bloat; how many Qt users know by heart what each of the next three lines does?
widget->repaint(); widget->repaint(true); widget->repaint(false);
A somewhat better API might have been
widget->repaint(); widget->repaintWithoutErasing();
In Qt 4, we solved the problem by simply removing the possibility of repainting without erasing the widget. Qt 4's native support for double buffering made this feature obsolete.
Here come a few more examples:
widget->setSizePolicy(::Fixed, ::Expanding, true); textEdit->insert("Where's Waldo?", true, true, false); rx("moc_*.c??", false, true);
An obvious solution is to replace the bool parameters with enum types. This is what we've done in Qt 4 with case sensitivity in . Compare:
str.replace("%USER%", user, false); // Qt 3 str.replace("%USER%", user, Qt::CaseInsensitive); // Qt 4
Static Polymorphism |
Similar classes should have a similar API. This can be done using inheritance where it makes sense -- that is, when run-time polymorphism is used. But polymorphism also happens at design time. For example, if you exchange a with a , or a with a , you'll find that the similarity of APIs makes this replacement very easy. This is what we call "static polymorphism".
Static polymorphism also makes it easier to memorize APIs and programming patterns. As a consequence, a similar API for a set of related classes is sometimes better than perfect individual APIs for each class.
The Art of Naming |
Naming is probably the single most important issue when designing an API. What should the classes be called? What should the member functions be called?
General Naming Rules |
A few rules apply equally well to all kinds of names. First, as I mentioned earlier, do not abbreviate. Even obvious abbreviations such as "prev" for "previous" don't pay off in the long run, because the user must remember which words are abbreviated.
Things naturally get worse if the API itself is inconsistent; for example, Qt 3 has activatePreviousWindow() and fetchPrev(). Sticking to the "no abbreviation" rule makes it simpler to create consistent APIs.
Another important but more subtle rule when designing classes is that you should try to keep the namespace for subclasses clean. In Qt 3, this principle wasn't always followed. To illustrate this, we will take the example of a . If you call name(), caption(), text(), or textLabel() on a in Qt 3, what do you expect? Just try playing around with a in Qt Designer:
Naming Classes |
Identify groups of classes instead of finding the perfect name for each individual class. For example, All the Qt 4 model-aware item view classes are suffixed with View (, , and ), and the corresponding item-based classes are suffixed with Widget instead (, , and ).
Naming Enum Types and Values |
When declaring enums, we must keep in mind that in C++ (unlike in Java or C#), the enum values are used without the type. The following example shows illustrates the dangers of giving too general names to the enum values:
namespace Qt { enum Corner { TopLeft, BottomRight, ... }; enum CaseSensitivity { Insensitive, Sensitive }; ... }; tabWidget->setCornerWidget(widget, Qt::TopLeft); str.indexOf("$(QTDIR)", Qt::Insensitive);
In the last line, what does Insensitive mean? One guideline for naming enum types is to repeat at least one element of the enum type name in each of the enum values:
namespace Qt { enum Corner { TopLeftCorner, BottomRightCorner, ... }; enum CaseSensitivity { CaseInsensitive, CaseSensitive }; ... }; tabWidget->setCornerWidget(widget, Qt::TopLeftCorner); str.indexOf("$(QTDIR)", Qt::CaseInsensitive);
When enumerator values can be OR'd together and be used as flags, the traditional solution is to store the result of the OR in an int, which isn't type-safe. Qt 4 offers a template class
By convention, we give the enum type a singular name (since it can only hold one flag at a time) and the "flags" type a plural name. For example:
enum RectangleEdge { LeftEdge, RightEdge, ... }; typedefRectangleEdges;
In some cases, the "flags" type has a singular name. In that case, the enum type is suffixed with Flag:
enum AlignmentFlag { AlignLeft, AlignTop, ... }; typedefAlignment;
Naming Functions and Parameters |
The number one rule of function naming is that it should be clear from the name whether the function has side-effects or not. In Qt 3, the const function ::simplifyWhiteSpace() violated this rule, since it returned a instead of modifying the string on which it is called, as the name suggests. In Qt 4, the function has been renamed ::simplified().
Parameter names are an important source of information to the programmer, even though they don't show up in the code that uses the API. Since modern IDEs show them while the programmer is writing code, it's worthwhile to give decent names to parameters in the header files and to use the same names in the documentation.
Naming Boolean Getters, Setters, and Properties |
Finding good names for the getter and setter of a bool property is always a special pain. Should the getter be called checked() or isChecked()? scrollBarsEnabled() or areScrollBarEnabled()?
In Qt 4, we used the following guidelines for naming the getter function:
Pointers or References? |
Which is best for out-parameters, pointers or references?
void getHsv(int *h, int *s, int *v) const void getHsv(int &h, int &s, int &v) const
Most C++ books recommend references whenever possible, according to the general perception that references are "safer and nicer" than pointers. In contrast, at Trolltech, we tend to prefer pointers because they make the user code more readable. Compare:
color.getHsv(&h, &s, &v); color.getHsv(h, s, v);
Only the first line makes it clear that there's a high probability that h, s, and v will be modified by the function call.
Case Study: QProgressBar |
To show some of these concepts in practice, we'll study the API of Qt 3 and compare it to the Qt 4 API. In Qt 3:
class : public { ... public: int totalSteps() const; int progress() const; const &progressString() const; bool percentageVisible() const; void setPercentageVisible(bool); void setCenterIndicator(bool on); bool centerIndicator() const; void setIndicatorFollowsStyle(bool); bool indicatorFollowsStyle() const; public slots: void reset(); virtual void setTotalSteps(int totalSteps); virtual void setProgress(int progress); void setProgress(int progress, int totalSteps); protected: virtual bool setIndicator( &progressStr, int progress, int totalSteps); ... };
The API is quite complex and inconsistent; for example, it's not clear from the naming that reset(), setTotalSteps(), and setProgress() are tightly related.
The key to improve the API is to notice that is similar to Qt 4's class and its subclasses, , and . The solution? Replace progress andtotalSteps with minimum, maximum and value. Add a valueChanged() signal. Add a setRange() convenience function.
The next observation is that progressString, percentage and indicator really refer to one thing: the text that is shown on the progress bar. Usually the text is a percentage, but it can be set to anything using the setIndicator() function. Here's the new API:
virtual text() const; void setTextVisible(bool visible); bool isTextVisible() const;
By default, the text is a percentage indicator. This can be changed by reimplementing text().
The setCenterIndicator() and setIndicatorFollowsStyle() functions in the Qt 3 API are two functions that influence alignment. They can advantageously be replaced by one function,setAlignment():
void setAlignment(Qt::Alignment alignment);
If the programmer doesn't call setAlignment(), the alignment is chosen based on the style. For Motif-based styles, the text is shown centered; for other styles, it is shown on the right hand side.
Here's the improved API:
class : public { ... public: void setMinimum(int minimum); int minimum() const; void setMaximum(int maximum); int maximum() const; void setRange(int minimum, int maximum); int value() const; virtual text() const; void setTextVisible(bool visible); bool isTextVisible() const; Qt::Alignment alignment() const; void setAlignment(Qt::Alignment alignment); public slots: void reset(); void setValue(int value); signals: void valueChanged(int value); ... };
How to Get APIs Right |
APIs need quality assurance. The first revision is never right; you must test it. Make use cases by looking at code which uses this API and verify that the code is readable.
Other tricks include having somebody else use the API with or without documentation and documenting the class (both the class overview and the individual functions).
Documenting is also a good way of finding good names when you get stuck: just try to document the item (class, function, enum value, etc.) and use your first sentence as inspiration. If you cannot find a precise name, this is often a sign that the item shouldn't exist. If everything else fails and you are convinced that the concept makes sense, invent a new name. This is, after all, how "widget", "event", "focus", and "buddy" came to be.
Automatic Dialogs |
by |
This article shows how to maintain sets of "attributes" (QVariant values), and how to allow users to view and edit them using dialogs that are created dynamically based on the attributes and their types.
The Attributes class described in this article holds a set of s, and can create a dialog to present the s to the user in an appropriate way. For example, if a value is an integer, the dialog will represent that integer using a . For colors, fonts and file names, the current color, font or file name is shown, and an ellipsis button (...) is provided that invokes a suitable dialog through which the user can choose a value.
The dialog is not specified or laid out in code; instead we simply call an Attributes object's setAttribute() function for each attribute we want to hold, giving each one a name and an initial value. When we want the user to be able to edit the attributes, we just call dialog(), and if it returns true we know that the user clicked OK and that the attributes have been updated.
If you're an object-oriented purist, or performance obsessed, you may want to bail out now. We use explicit type checking and create dynamic arbitrary data structures using and; this gives us great flexibility, but at the cost of some efficiency and a certain amount of type safety.
Here's how to create an Attributes object on the stack with some default values, and present a dialog based on the object to the user:
Attributes atts; atts.setAttribute("Text", "Qt Conference"); atts.setAttribute("Type", () << "One-Off" << "TODO"); atts.setAttribute("Start", (2004, 5, 10)); if (atts.dialog(this, "Event")) { text = atts.value("Text").toString(); type = atts.value("Type").toString(); start = atts.value("Start").toDate(); ... }
Each attribute has a name, a value, and some optional "extra" data. The name is used as the label in the dialog (with accelerators automatically added where possible inside the dialog() function). The value's type determines what sort of widget is used for presenting the value; we'll see what the extra data is in a moment. If the user clicks OK, the attribute data is updated from the dialog's values; if the user clicks Cancel the data is not changed.
Now lets look at a more sophisticated example. This time we'll suppose that we have a data structure populated with pointers to a number of Attributes objects. We'll also exercise more control over the dialog that's generated.
Attributes *atts = new Attributes; atts->setAttribute("Alias", "Administrator"); list; list << "Active" << "Completed" << "Suspended"; <, > extra; extra["selected"] = 1; atts->setAttribute("Status", list, extra); extra.clear(); extra["minimum"] = 0; extra["maximum"] = 100; extra["suffix"] = "%"; atts->setAttribute("Rating", 50, extra); extra.clear(); extra["hidden"] = true; atts->setAttribute("Internal ID", "WX257C", extra); atts->setAttribute("Background", cyan); atts->setAttribute("Font", font()); extra.clear(); extra["fileFilter"] = ("Executables (*;*.exe)"); const empty; atts->setAttribute("Email Client", empty, extra); atts->setAttribute("Web Browser", empty, extra);
The screenshots show the dialogs after some user interaction.
The extra parameter is a map of names to values. We've reused the same extra variable for the sake of convenience, which is why we clear() it before giving it fresh values. For a attribute, like "Status", there is one extra defined: "selected". The "selected" extra's integer value is the index of the selected item in the that presents the 's strings to the user. For integers like "Rating", we can set "minimum", "maximum", "prefix", and "suffix" extras if we wish.
Sometimes it is convenient to have attributes that we use within our code, but which we don't want to make available to users. This is achieved by setting "hidden" in the extra parameter to true. If we don't want a default value, we must provide an empty value of the right type so that Attributes knows what type of value it can accept. If we set "fileFilter" in the extra parameter, the value is taken to be a file name of type , and an ellipsis button that invokes a file dialog is provided. Similarly, a color value has an ellipsis button that invokes a color dialog, and a font value has one that invokes a font dialog.
Implementing Attributes |
The implementation of Attributes is easy except for the dialog() function. Here, we'll confine ourselves to reviewing some of the key features of the code.
Each attribute is stored in a PrivateAttribute object; this object is designed to be accessed from Attributes objects, and should never be directly used in your own code.
typedef <, > StringVariantMap; class PrivateAttribute { public: PrivateAttribute() : name(""), index(-1) {} PrivateAttribute(const &name, int index, value, StringVariantMap extra = StringVariantMap()) : name(name), index(index), value(value), extra(extra) {} name; int index; value; StringVariantMap extra; *widget; };
The index member stores the position of the attribute. Attributes are displayed in the dialog() in index order, and this order is determined by the order in which attributes are created by calls toAttributes::setAttribute(). The widget pointer is used by the dialog() function.
The Attributes class definition follows:
class Attributes : public { Q_OBJECT public: Attributes(); Attributes(const Attributes &other); Attributes &operator=(const Attributes &other); int count() const { return attributes.count(); } names(bool indexOrder = false) const; bool dialog( *parent, const &caption, bool autoMnemonics = true); value(const &name) const; int index(const &name) const; StringVariantMap extra(const &name) const; public slots: void setAttribute(const &name, value, StringVariantMap extra = StringVariantMap()); void removeAttribute(const &name) { attributes.remove(name); } void clear() { attributes.clear(); } private slots: void mapped(const &name); private: insertMnemonic(const &text, *allowed); int nextIndex; pixmapPath; filePath; <, > fonts; *dlg; *mapper; <, PrivateAttribute> attributes; };
We will omit the code for the copy constructor and the assignment function since they do little more than memberwise copying. The names() function could be implemented with the single statement
return attributes.keys();
but our implementation (not shown) provides the ability to order the names.
We'll also skip the index() and extra() functions, since the coverage of value() is sufficient to understand them.
Attributes::Attributes() : nextIndex(0), pixmapPath("."), filePath("."), dlg(0), mapper(0) { }
We use the pixmapPath and filePath strings, and the fonts map to record temporary transient data. This is useful if dialog() is invoked repeatedly for the same Attributes object; e.g., maintaining the last path used for a file dialog. nextIndex is an internal counter that ensures that each time an attribute is added (using setAttribute()), it is ordered after those that were added previously.
Attributes::value(const &name) const { if (!attributes.contains(name)) return (); const PrivateAttribute &attr = attributes[name]; if (attr.value.type() == ::StringList) return attr.value.toStringList()[ attr.extra["selected"].toInt()]; return attr.value; }
We return an invalid if the attribute doesn't exist. If the attribute's value is a string list, we return the selected string.
void Attributes::setAttribute(const &name, value, StringVariantMap extra) { if (value.type() == ::CString) value.cast(::String); if (!attributes.contains(name)) { if (value.type() == ::StringList) { if (!extra.contains("selected")) extra["selected"] = 0; } else if (value.type() == ::UInt) { if (!extra.contains("minimum")) extra["minimum"] = uint(0); } attributes[name] = PrivateAttribute(name, nextIndex++, value, extra); } else { PrivateAttribute &attr = attributes[name]; attr.value = value; if (extra.count()) { StringVariantMap::const_iterator i = extra.constBegin(); for (; i != extra.constEnd(); ++i) attr.extra[i.key()] = i.data(); } } }
The setAttribute() function has two modes of operation. If the attribute name doesn't exist, we create a new attribute with the given value and extra data, providing defaults for the extra data where necessary; otherwise we set the attribute's value to value, and update its extra data with the new extra data.
The dialog() function is quite long; so we'll just quote and explain some extracts from the code. There are blocks of similar code for each type we handle, so we only need to show snippets from a sample type to convey the key ideas.
bool Attributes::dialog( *parent, const &caption, bool autoMnemonics) { dlg = new (parent); dlg->setCaption(caption); mapper = new (dlg); *vbox = new (dlg, 5, 5); *hbox = 0;
The dialog uses a signal mapper to capture ellipsis button clicks and respond appropriately to them. The whole dialog is laid out vertically, with each attribute occupying a successive horizontal layout within the vertical layout.
After the initial declarations we iterate over each attribute. This serves two purposes: firstly we need to know the widest label so that we can make all the labels the same width, and secondly we want to create an integer-to-string map that maps each attribute's index position to its name---this is so that we can lay out each attribute in index order.
order; ... ::const_iterator j = order.constBegin(); for (; j != order.constEnd(); ++j) { PrivateAttribute &attr = attributes[j.data()]; if (attr.extra.contains("hidden") && attr.extra["hidden"].toBool()) continue; value = attr.value;
The function's main loop iterates over the names of the attributes; any that are hidden, or of a type that we cannot handle, are ignored.
For the rest, we copy their name and try to insert an ampersand ('&') to create a mnemonic if this is possible. We use a simple and imperfect algorithm (not shown) that works as follows. We hold a string containing the letters A to Z. For each name, we see if its first letter is in the string; if it is, we insert the ampersand before the letter in the name and delete that letter from the string. Otherwise, we do the same for any letter in the name that is preceded by a space; if that doesn't work, we do the same for any letter in the name. If no strategy works, we don't add a mnemonic.
*label = new (text, dlg); label->setFixedWidth(labelWidth); hbox = new (vbox); hbox->addWidget(label);
We create a for each attribute, using its name (possibly with an ampersand) for its text. We make it fixed width (based on the widest label) and add it to a new horizontal layout.
switch (type) { case ::String: lineEdit = new (value.toString(), dlg); if (attr.extra.contains("maximum")) lineEdit->setMaxLength( attr.extra["maximum"].toInt()); attr.widget = lineEdit; label->setBuddy(lineEdit); hbox->addWidget(lineEdit, 2); if (attr.extra.contains("fileFilter")) { button = new (tr("..."), dlg); button->setFixedWidth(ellipsisWidth); connect(button, SIGNAL(clicked()), mapper, SLOT(map())); mapper->setMapping(button, attr.name); hbox->addWidget(button); } break; ...
What we do next depends on the attribute's type. In the case of a string, we create a line edit with the string's value and set the maximum length if that's been given in the extra data. We remember the widget used (attr.widget = lineEdit), and add it to the horizontal layout. If the string is holding a file name (indicated by an extra "fileFilter" data item), we create an ellipsis button and connect it to the signal mapper. We also add the button to the horizontal layout.
Once all the widgets for the attributes have been added, we create another horizontal layout and add a stretch followed by an OK and a Cancel button, suitably connected.
bool result = false; if (dlg->exec()) { <, PrivateAttribute>::iterator i = attributes.begin(); for (; i != attributes.end(); ++i) { if (i.data().extra.contains("hidden") && i.data().extra["hidden"].toBool()) continue;
Next we show the dialog to the user. If they click OK we iterate over the attributes, again skipping any that are hidden.
switch (type) { case ::String: lineEdit = ( *)i.data().widget; i.data().value = lineEdit->text(); break; ... }
We retrieve the data from the remembered widgets (which vary depending on the attributes' types), updating the Attributes object with the updated values. Finally, we delete the dialog.
If any of the attributes has an ellipsis button which the user clicked, the mapped() function is called. Here's an extract from it, to show what happens in the case of a file name string.
void Attributes::mapped(const &name) { PrivateAttribute &attr = attributes[name]; ... fileName; switch (attr.value.type()) { case ::String: if (attr.extra.contains("fileFilter")) { fileName = ::getSaveFileName( filePath, attr.extra["fileFilter"].toString(), dlg, "", tr("Choose a file")); if (!fileName.isEmpty()) { (( *)attr.widget)-> setText(fileName); filePath = (fileName).absPath(); } } break; ... }
If a file name ellipsis button is pressed, we present the user with a file dialog. If the user chooses a file, we set the file name line edit in the dialog to show it. We also record the path so that the next time the dialog is invoked by this Attributes object, the path will be the one the user last used. Notice that we do not update the attributes object here. That is done in dialog() if the user closed the dialog by clicking OK.
Conclusion |
Attributes could be implemented in other ways, for example, using a . If there are lots of attributes, using a , or a might be necessary; or each attribute could have a "group" name (defaulting to, say, "General"), and we could create a tabbed dialog, with groups corresponding to tabs.