Fred is a principal of Advantage Software Technologies. He can be contacted at [email protected].
On a recent train trip, I overheard two men talking about the merits of various programming languages. When the debate turned to Java versus C++, I leaned back to listen. It didn't take long before the Java advocate crowed, "But C++ doesn't support interfaces." To my surprise, the C++ proponent didn't object. It wasn't my place to intrude, but it did get me thinking about C++ and interfaces. Yes, C++ can do interfaces, and in this article I'll explain how.
About Interfaces
Interfaces provide a convenient means of resolving the tension that sometimes occurs between what a class is and what it can do. When a given Java class "implements" some number of interfaces, the instances of that class can serve anywhere that an object implementing one of those interfaces is required. (The name of an interface can be used as a reference designator for an object, for example, in a formal argument to a function.) Thus, the interface name can be used as a reference to a heterogeneous set of objects that implement that interface. This may sound very much like inheritance-based polymorphism, but there is a key difference: Interfaces allow you to express situations that apply polymorphism to objects that do not share a common base class.
Implementing an "interfaces" design goal in C++ is approachable and direct. In fact, although the word "interface" is not a reserved word in C++, there are at least three good ways to express the design intent of interfaces in C++.
Interface Design Goals
The design goal of using interfaces is mostly concerned with expressing what a class can do, as opposed to what a class is. This may seem like a fine distinction, but it is an important one. If you don't make such a distinction, then your type systems end up looking much more like a taxonomy of capabilities, and less like a taxonomy of classes differentiated by their essential properties.
To clarify the difference between inheritance and interface, consider the structure of classes (rectangles) in Figure 1. At the top are Machine and Person. Think of these as good examples of two classes of objects that do not share a common superclass at any level of abstraction relevant to what we are doing.
Next, suppose certain subclasses of each of these superclasses implement a common capability -- namely, one that takes in a potato, and returns a bag of french fries. You call the interface providing such a capability, the MakeFries interface (the oval in Figure 1), and each subclass implements this interface (indicated by using the lollipop symbols attached to the subclasses).
Hopefully, you agree that the essential properties of what makes a french-fry maker a machine are different from the essential properties that make a chef a person. Despite the differences in the nature of these two classes, each is capable of making french fries. However, the implementation of the MakeFries capability in each subclass will need to take the specific nature of each of the subclasses into account. Thus, in this contrived example, there is no hope of sharing a common implementation. This situation is precisely where interfaces, instead of superclasses, are most properly applied.
The Java declaration for the MakeFries interface might look like Listing One. Java lets you use interfaces to declare that both a Chef and FrenchFryMaker supply the potato transformation function.
Comparing Interfaces to Abstract Classes
Unlike abstract base classes in C++, Java interfaces only make a promise about what methods (functions) a class supplies, and cannot provide a shared implementation of those functions. They are simply (as their name suggests) interfaces.
With C++, you have only a single mechanism (classes and inheritance) to serve the dual roles of interface and superclass. Thus, C++ developers often make the mistake of muddying concerns of interface and shared, type-assuming implementations. Nevertheless, this is an issue of practice, not one of language capability. The Java interface mechanism is more specific in this regard, providing an elegant escape from this invitation to err.
Using Java interfaces, you can say what functions a class must implement, without making commitments about the implementation type of the functions. The two worlds -- the lines of inherited implementation and provided interfaces -- can remain clear and distinct in Java because they are expressed using different mechanisms. What do you do to use shared interface implementations in Java? In Java, if you want to create an implementation of an interface to be shared by some number of subclasses, you can combine the use of interface and inheritance mechanisms. In other words, a given superclass can implement an interface, and its subclasses can inherit both the interface conformance and implementation aspects.
A C++ Equivalent to Java Interfaces
We've seen an example in Java. Now let's see what C++ developers can use to fulfill the same intent. Using multiple inheritance, C++ developers can create a "dual aspect inheritance scheme." In this scheme they express, as needed:
- A primary superclass that might provide some shared implementation.
- Additional lines of inheritance that mix in purely abstract classes.
The purely abstract classes contain only pure virtual functions, and their declaration effectively imposes an interface requirement. In Listing Two, several purely abstract C++ classes are used in this manner to create a one-for-one equivalent to Java interfaces.
When this test case is run, it creates the output called ImplementsBoth::getName. Although the class named ImplementsBoth inherits two pure virtual functions possessing the same signature from different superclasses, it is not a problem (there are several inherited pure virtual functions named getName; see Listing Two). The single implementation of getName within the class ImplementsBoth satisfies both of them. Furthermore, within the function main (Listing Two), you can change the type of the pointer that holds the address of the allocated ImplementsBoth class from HasNameAndAddress to the other abstract class HasName and all will be well.
There really is no substantive difference between the mechanism of applying purely abstract superclasses, and that of interfaces in Java. This practice is merely a convention for using the C++ pure virtual function mechanism to exact the interface idiom.
Noninheritance-Based Interfaces in C++
C++ developers have still other options for separating concerns of interface from the concerns of base types. Two of the most immediately identifiable ones are:
- Compile-time binding using templates.
- Application of the Adapter design pattern.
Dependencies upon Anonymous Interfaces from Templates
When an algorithm depends on certain interface elements being available in the classes it operates upon, it does not necessarily need to impose a declarative constraint on those classes. The algorithm can be encased in a function template. So, for example, if you create a function to delete all of the entries in an STL collection of pointers (see STL Tutorial and Reference Guide, by Saini Musser, Addison Wesley, 1996), you can do so without knowing the actual collection type, or even the type of the pointers it is storing; see Listing Three.
When the function template in Listing Three is used, the type of object that is passed in needs to provide:
- A type named iterator, which can be compared, dereferenced, and incremented.
- Functions named begin and end that each return an object of type iterator.
- Proper iterator behavior. Setting an iterator to begin() and successively incrementing it will eventually yield a value equal to end(), which is a sentinel value that signals the end of the sequence.
In essence, this function requires a certain interface to be implemented by the class of its argument. However, rather than name the interface, you simply assume the required elements are present. When you pass an object to the function, a version of the function matching the type of the argument is created by the C++ template instantiation mechanism. If the members of the class passed to the function don't match the function's actual interface requirements, compile-time errors (citing missing or type-mismatched elements) will result. An example use of this function template is:
deleteAll(thingList); // assume,
// for example, thingList is // of type "list<thing*>"
Function templates are arguably more convenient than formally implementing named interfaces. The only downside is that the level of cooperation needed to provide a certain set of public elements across all classes may be difficult to acquire. When named interfaces are used, the compiler helps a great deal more with the enforcement aspect of implementing interfaces. Templates are instantiated only for a specific type when that type is used with the template. Thus, detecting errors of interface compliance for a specific type may be arbitrarily delayed until the first situation is created pairing it with the template.
In a system in which you have complete control over the definitions of the classes that will work with your algorithms, interface consistency can be achieved. For example, the classes within STL were carefully designed to ensure that their public elements achieved complete consistency. By doing this, STL algorithms, which are predominantly encased in function templates, are able to work with any sequence of objects made available through appropriate iterators.
When you get classes from some other sources, you may not have control over their interfaces. This means that you may need to adapt the class for use in any of your software that requires certain interfaces.
Adapter Classes in C++
At times, you'll find the need to use third-party classes (that you cannot define or change) in a context that imposes certain interface requirements. Adding one level of indirection, you can introduce a proxy class called an "Adapter" (see Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma et al., Addison-Wesley, 1995) which resolves interface mismatches. First, an adapter meets interface requirements by providing its own compliant interface. It then serves as a mediator between its client and some target object, translating its own actions and properties into accesses of equivalent actions and properties in the target object.
The level of resolution supplied by an adapter can vary widely. At times an adapter serves as a simple pass-through mechanism, merely resolving differences in a few names and function signatures. At other times adapters make objects of one nature look like objects of a different nature.
For instance, suppose you have an application that uses an instance of the Microsoft MFC CList template to hold a list of pointers to CStrings. Next, suppose you want to use some of our templated algorithms with your CList-based pointer collection, but those algorithms use the STL style of iterators (such as the deleteAll function template in Listing Three). What you need is a way for an MFC CList to be adapted to look like an STL style container in terms of element iteration. Listing Four presents a template that is capable of making an adapter that our deleteAll function template will accept. (Also see main.cpp, available electronically; see "Resource Center," page 3.)
Using the adapter only requires the type of the instantiated CList and the type of its elements. Given these, you can instantiate an adapter to stand in as a proxy within an algorithm that accesses the list's elements assuming an STL style of iteration, again, as does the deleteAll function template. (Listing Five is an example application of this adapter to a list of pointers to strings.)
Conclusion
Separating concerns of interface aspects and inherited implementation aspects is a good practice. It keeps designs clean and fosters sensible paths of reuse. It's clear that both Java and C++ possess the ability to directly express designs that include this separation of concerns (in Java interfaces, and in C++ purely abstract classes). But compared to Java, C++ additionally affords you the ability to connect components and algorithms (using templates) without the need to impose an interface declaration upon the components. Lastly, it is true of both Java and C++, that adapter classes can be used as proxies for classes that bare a different interface than the one required.
As a final acknowledgment of Java, let me point out that the inclusion of the interface construct in Java is a good thing. Its presence highlights for Java developers the idea that interface compliance is a different concern than that of inheritance. C++ developers should also note this difference and use the mechanisms available to them in C++ to create the cleanest possible separation of concerns.
Acknowledgments
A note of thanks is due to my friends and colleagues David Swift and Tushar Pradhan for their helpful suggestions in improving the clarity of this article.
DDJ
Listing One
interface MakeFries { BagOfFries transformPotatoIntoFries(Potato spud); } class Chef extends Person implements MakeFries { ... BagOfFries transformPotatoIntoFries(Potato spud) { ... } } class FrenchFryMaker extends Machine implements MakeFries { ... BagOfFries transformPotatoIntoFries(Potato spud) { ... } }
Listing Two
class HasName { public: virtual string getName() = 0; }; class HasNameAndAddress { public: virtual string getName() = 0; virtual string getAddress() = 0; }; class ImplementsBoth : public HasName, public HasNameAndAddress { public: virtual string getName() { return "called ImplementsBoth::getName"; } virtual string getAddress() { return "called ImplementsBoth::getAddress"; } }; int main() { HasNameAndAddress *anObject = new ImplementsBoth; string hmmm = anObject->getName(); cout << hmmm << endl ; return 0; }
Listing Three
template <class PTR_CONTAINER>void deleteAll(PTR_CONTAINER &ptrs) { PTR_CONTAINER::iterator it = ptrs.begin(); while (it != ptrs.end()) { delete *it++; } }
Listing Four
template <class CLIST, class ITEMTYPE>class CListSTLFwdIterAdapter { CLIST &rList; public: CListSTLFwdIterAdapter(CLIST &aList) : rList(aList) { } class iterator { CLIST *pList ; POSITION p ; public: iterator(CLIST *aList) { pList = aList; p = (pList == NULL ? NULL : pList->GetHeadPosition()); } iterator(const iterator &it) { pList = it.pList; p = it.p; } // item de-ref ITEMTYPE operator*() { return pList->GetAt(p); } // pre increment iterator operator++() { pList->GetNext(p); return *this; } // post increment iterator operator++(int) { iterator it(*this) ; pList->GetNext(p); return it; } bool operator==(const iterator &rhs) { return this->p == rhs.p; } bool operator!=(const iterator &rhs) { return this->p != rhs.p; } }; iterator begin() { iterator it(&rList); return it; } iterator end() { iterator it(NULL); return it; } };
Listing Five
int main (){ typedef CList<CString*,CString*> CListOfStrPtrs ; CListOfStrPtrs nameList; nameList.AddTail(new CString("Simon")); nameList.AddTail(new CString("Peter")); nameList.AddTail(new CString("James")); nameList.AddTail(new CString("John")); nameList.AddTail(new CString("Andrew")); nameList.AddTail(new CString("Ivan")); </p> typedef CListSTLFwdIterAdapter<CListOfStrPtrs,CString*> StringPtrListAdapter ; StringPtrListAdapter nameListAdapter(nameList); </p> deleteAll(nameListAdapter); return 0; }NOTIFIERS
Copyright © 1998, Dr. Dobb's Journal