Item 18: Make interfaces easy to use correctly and hard to use incorrectly


C++ is awash in interfaces. Function interfaces. Class interfaces. Template interfaces. Each interface is a means by which clients interact with your code. Assuming you're dealing with reasonable people, those clients are trying to do a good job. They want to use your interfaces correctly. That being the case, if they use one incorrectly, your interface is at least partially to blame. Ideally, if an attempted use of an interface won't do what the client expects, the code won't compile; and if the code does compile, it will do what the client wants.

Developing interfaces that are easy to use correctly and hard to use incorrectly requires that you consider the kinds of mistakes that clients might make. For example, suppose you're designing the constructor for a class representing dates in time:

 class Date { public:   Date(int month, int day, int year);   ... }; 

At first glance, this interface may seem reasonable (at least in the USA), but there are at least two errors that clients might easily make. First, they might pass parameters in the wrong order:

 Date d(30, 3, 1995);              // Oops! Should be "3, 30" , not "30, 3" 

Second, they might pass an invalid month or day number:

 Date d(2, 20, 1995);              // Oops! Should be "3, 30" , not "2, 20" 

(This last example may look silly, but remember that on a keyboard, 2 is next to 3. Such "off by one" typing errors are not uncommon.)

Many client errors can be prevented by the introduction of new types. Indeed, the type system is your primary ally in preventing undesirable code from compiling. In this case, we can introduce simple wrapper types to distinguish days, months, and years, then use these types in the Date constructor:

 struct Day {            struct Month {                struct Year {   explicit Day(int d)     explicit Month(int m)         explicit Year(int y)   :val(d) {}              :val(m) {}                    :val(y){}   int val;                int val;                      int val; };                      };                            }; class Date { public:  Date(const Month& m, const Day& d, const Year& y);  ... }; Date d(30, 3, 1995);                      // error! wrong types Date d(Day(30), Month(3), Year(1995));    // error! wrong types Date d(Month(3), Day(30), Year(1995));    // okay, types are correct 

Making Day, Month, and Year full-fledged classes with encapsulated data would be better than the simple use of structs above (see Item 22), but even structs suffice to demonstrate that the judicious introduction of new types can work wonders for the prevention of interface usage errors.

Once the right types are in place, it can sometimes be reasonable to restrict the values of those types. For example, there are only 12 valid month values, so the Month type should reflect that. One way to do this would be to use an enum to represent the month, but enums are not as type-safe as we might like. For example, enums can be used like ints (see Item 2). A safer solution is to predefine the set of all valid Months:

 class Month { public:   static Month Jan() { return Month(1); }   // functions returning all valid   static Month Feb() { return Month(2); }   // Month values; see below for   ...                                       // why these are functions, not   static Month Dec() { return Month(12); }  // objects      ...                                       // other member functions private:   explicit Month(int m);                    // prevent creation of new                                             // Month values   ...                                       // month-specific data }; Date d(Month::Mar(), Day(30), Year(1995)); 

If the idea of using functions instead of objects to represent specific months strikes you as odd, it may be because you have forgotten that reliable initialization of non-local static objects can be problematic. Item 4 can refresh your memory.

Another way to prevent likely client errors is to restrict what can be done with a type. A common way to impose restrictions is to add const. For example, Item 3 explains how const-qualifying the return type from operator* can prevent clients from making this error for user-defined types:

 if (a * b = c) ...                    // oops, meant to do a comparison! 

In fact, this is just a manifestation of another general guideline for making types easy to use correctly and hard to use incorrectly: unless there's a good reason not to, have your types behave consistently with the built-in types. Clients already know how types like int behave, so you should strive to have your types behave the same way whenever reasonable. For example, assignment to a*b isn't legal if a and b are ints, so unless there's a good reason to diverge from this behavior, it should be illegal for your types, too. When in doubt, do as the ints do.

The real reason for avoiding gratuitous incompatibilities with the built-in types is to offer interfaces that behave consistently. Few characteristics lead to interfaces that are easy to use correctly as much as consistency, and few characteristics lead to aggravating interfaces as much as inconsistency. The interfaces to STL containers are largely (though not perfectly) consistent, and this helps make them fairly easy to use. For example, every STL container has a member function named size that tells how many objects are in the container. Contrast this with Java, where you use the length property for arrays, the length method for Strings, and the size method for Lists; and with .NET, where Arrays have a property named Length, while ArrayLists have a property named Count. Some developers think that integrated development environments (IDEs) render such inconsistencies unimportant, but they are mistaken. Inconsistency imposes mental friction into a developer's work that no IDE can fully remove.

Any interface that requires that clients remember to do something is prone to incorrect use, because clients can forget to do it. For example, Item 13 introduces a factory function that returns pointers to dynamically allocated objects in an Investment hierarchy:

 Investment* createInvestment();   // from Item 13; parameters omitted                                   // for simplicity 

To avoid resource leaks, the pointers returned from createInvestment must eventually be deleted, but that creates an opportunity for at least two types of client errors: failure to delete a pointer, and deletion of the same pointer more than once.

Item 13 shows how clients can store createInvestment's return value in a smart pointer like auto_ptr or tr1::shared_ptr, thus turning over to the smart pointer the responsibility for using delete. But what if clients forget to use the smart pointer? In many cases, a better interface decision would be to preempt the problem by having the factory function return a smart pointer in the first place:

 std::tr1::shared_ptr<Investment> createInvestment(); 

This essentially forces clients to store the return value in a TR1::shared_ptr, all but eliminating the possibility of forgetting to delete the underlying Investment object when it's no longer being used.

In fact, returning a TR1::shared_ptr makes it possible for an interface designer to prevent a host of other client errors regarding resource release, because, as Item 14 explains, TR1::shared_ptr allows a resource-release function a "deleter" to be bound to the smart pointer when the smart pointer is created. (auto_ptr has no such capability.)

Suppose clients who get an Investment* pointer from createInvestment are expected to pass that pointer to a function called geTRidOfInvestment instead of using delete on it. Such an interface would open the door to a new kind of client error, one where clients use the wrong resource-destruction mechanism (i.e., delete instead of getridOfInvestment). The implementer of createInvestment can forestall such problems by returning a TR1::shared_ptr with geTRidOfInvestment bound to it as its deleter.

tr1::shared_ptr offers a constructor taking two arguments: the pointer to be managed and the deleter to be called when the reference count goes to zero. This suggests that the way to create a null tr1::shared_ptr with getridOfInvestment as its deleter is this:

 std::tr1::shared_ptr<Investment>      // attempt to create a null   pInv(0, getRidOfInvestment);        // shared_ptr with a custom deleter;                                       // this won't compile 

Alas, this isn't valid C++. The TR1::shared_ptr constructor insists on its first parameter being a pointer, and 0 isn't a pointer, it's an int. Yes, it's convertible to a pointer, but that's not good enough in this case; tr1::shared_ptr insists on an actual pointer. A cast solves the problem:

 std::tr1::shared_ptr<Investment>      // create a null shared_ptr with   pInv(static_cast<Investment*>(0),   // getRidOfInvestment as its         getRidOfInvestment);          // deleter; see Item 27 for info on                                       // static_cast 

This means that the code for implementing createInvestment to return a tr1::shared_ptr with geTRidOfInvestment as its deleter would look something like this:

 std::tr1::shared_ptr<Investment> createInvestment() {   std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0),                                           getRidOfInvestment);   retVal = ... ;                         // make retVal point to the                                          // correct object   return retVal; } 

Of course, if the raw pointer to be managed by pInv could be determined prior to creating pInv, it would be better to pass the raw pointer to pInv's constructor instead of initializing pInv to null and then making an assignment to it. For details on why, consult Item 26.

An especially nice feature of tr1::shared_ptr is that it automatically uses its per-pointer deleter to eliminate another potential client error, the "cross-DLL problem." This problem crops up when an object is created using new in one dynamically linked library (DLL) but is deleted in a different DLL. On many platforms, such cross-DLL new/delete pairs lead to runtime errors. tr1::shared_ptr avoids the problem, because its default deleter uses delete from the same DLL where the tr1::shared_ptr is created. This means, for example, that if Stock is a class derived from Investment and createInvestment is implemented like this,

 std::tr1::shared_ptr<Investment> createInvestment() {   return std::tr1::shared_ptr<Investment>(new Stock); } 

the returned tr1::shared_ptr can be passed among DLLs without concern for the cross-DLL problem. The tr1::shared_ptrs pointing to the Stock keep track of which DLL's delete should be used when the reference count for the Stock becomes zero.

This Item isn't about tr1::shared_ptr it's about making interfaces easy to use correctly and hard to use incorrectly but tr1::shared_ptr is such an easy way to eliminate some client errors, it's worth an overview of the cost of using it. The most common implementation of tr1::shared_ptr comes from Boost (see Item 55). Boost's shared_ptr is twice the size of a raw pointer, uses dynamically allocated memory for bookkeeping and deleter-specific data, uses a virtual function call when invoking its deleter, and incurs thread synchronization overhead when modifying the reference count in an application it believes is multithreaded. (You can disable multithreading support by defining a preprocessor symbol.) In short, it's bigger than a raw pointer, slower than a raw pointer, and uses auxiliary dynamic memory. In many applications, these additional runtime costs will be unnoticeable, but the reduction in client errors will be apparent to everyone.

Things to Remember

  • Good interfaces are easy to use correctly and hard to use incorrectly. Your should strive for these characteristics in all your interfaces.

  • Ways to facilitate correct use include consistency in interfaces and behavioral compatibility with built-in types.

  • Ways to prevent errors include creating new types, restricting operations on types, constraining object values, and eliminating client resource management responsibilities.

  • TR1::shared_ptr supports custom deleters. This prevents the cross-DLL problem, can be used to automatically unlock mutexes (see Item 14), etc.




Effective C++ Third Edition 55 Specific Ways to Improve Your Programs and Designs
Effective C++ Third Edition 55 Specific Ways to Improve Your Programs and Designs
ISBN: 321334876
EAN: N/A
Year: 2006
Pages: 102

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net