Musings On - "Good Component Software"

How do you make a good piece of C++ software? More specifically how do you make a component software that can be utilized by different developers in different contexts? Here are a few things that you need to consider:

Memory Management - how to allow the user to control where objects are created.
Error Handling - how to notify the user when something has gone wrong.
Scope - what things does it handle and what things does it not handle.
Generality - how reusable is your software.
Consistency - are things done in a way such that things are predictable and consistent throughout the software.
Miscellaneous - documentation, portability, maintainability, etc.
 

Memory Management

Here are a couple of things that we do for memory management. First we force all of our heap based allocation of new objects to utilize a context. The context is something that can be used to specify the placement of an object into a specific pool or memory location. This allows the users of your component software to integrate it into their memory management scheme.

Here is an example of the usage:

Foo* make_foo(const IwContext & crContext)
{ return new (crContext) Foo(); }

/************************************************************
PURPOSE --- The context object is used to pass around 
     contextual information. Currently the only contextual 
     information it has is the memory pool of the object. 

USAGE NOTES --- 
************************************************************/
class IwContext : public IwObject
{
public:
    IwContext() { m_pMemPool = NULL; }
    IwContext(IwMemPool * pMemPool) {m_pMemPool = pMemPool; }
    virtual ~IwContext() {}
    IwMemPool* GetMemPool() const {return m_pMemPool;}
    IW_COMMON(IwContext,IwObject,IwContext_TYPE);
protected:
    IwMemPool* m_pMemPool;
};

/************************************************************
PURPOSE - Overrides the new operator for IwObject subclasses
  which take a reference to a context as the argument. 

USAGE NOTES --- IwObject *a = new (crContext) IwObject();
************************************************************/
void *IwObject::operator new(size_t size, 
                             const IwContext & crContext)
{ 
    IwMemPool*pMemPool = NULL; 
    pMemPool = crContext.GetMemPool(); 
    IwObject *pRet=(IwObject*)iwos_Calloc(size,1,pMemPool); 
    IwObject::sm_pContext = &crContext; return (void*)pRet;
} 

The other thing that we do in terms of memory management is to use a stack based object to free memory allocated on heap. Whenever an object is allocated, it should be immediately put into a stack based clean up object. Upon exit of the current scope the object will automatically be deleted. This will prevent you from having to worry about putting in a bunch of "delete" methods any time have to "return". The only thing you have to be careful of is to make sure you "Clear" the stack based object if the objects need to be passed back. Here are a couple of objects that we use to do individual object clean up and array clean up.

/************************************************************
PURPOSE --- This object is a stack based deletion object.  
   It will automatically call a delete on the object  
   it has in it when it goes out of scope.  
   This method only works on IwObject and its subclasses.

USAGE NOTES --- 
- {
-     IwObject *a = new IwObject();
-     IwObjDelete sCleanup(a);
- }  // automatically deletes a on exit of scope
- or 
- {
-     IwObject *a = new IwObject();
-     IwObjDelete sCleanup(a);
-     sCleanup.Clear(); 
     // allows a to exist outside of current scope
- } // a will now exist outside of this scope
************************************************************/
class IwObjDelete {
public:
    IwObjDelete() { m_pObj = NULL; }
    IwObjDelete(IwObject *pObj) { m_pObj = pObj; }
    ~IwObjDelete() { if (m_pObj) delete m_pObj; }
    void Clear();
    void SetObj(IwObject *pObj);
protected:
    IwObject *m_pObj;
}; 
/************************************************************
PURPOSE --- This is a stack based deletion object.  It will 
   automatically delete an array of objects when it goes 
   out of scope.  

USAGE NOTES --- 
- {
- IwTArray<IwObject*> sObjs;
- IwObjsDelete<IwObject*> sCleanupObjs(&sObjs);
- sObjs.Add(new IwObject());
- ...
- } // Going out of scope deletes all objects which 
    //   have pointers in sObjs
************************************************************/
template<class TYPE>
class IwObjsDelete {
public:
    IwObjsDelete() { m_cpArray = NULL; }
    IwObjsDelete(const IwTArray<TYPE> * cpArray) 
                  { m_cpArray = cpArray; }
    ~IwObjsDelete() {
        if (m_cpArray) {
            for (ULONG i=0; i<m_cpArray->GetSize(); i++) {
                TYPE pObj = m_cpArray->GetAt(i); 
                delete pObj; }} }
    void Clear() { m_cpArray = NULL; }
    void SetArray(const IwTArray<TYPE> * cpArray) 
                 { m_cpArray = cpArray; }
protected:
    const IwTArray<TYPE> * m_cpArray;
}; 

 

Error Handling

Error handling is a critical aspect of good component software. There are basically two different formats of error handling: exception based and return based. In exception based error handling you have to "THROW" an error when it occurs and somewhere in the call stack you need to "CATCH" that error. The other format for error handling requires returning an error status code. The use of the stack based memory clean-up objects make both error handling easy to implement and utilize. Without it, each routine that allocated memory would have to "CATCH" or implement an "if (error) {" to delete its allocated memory.

In good component software, every routine that has an internal condition that may fail should either "return" or "THROW" an error. In IntegrityWare's libraries we have opted for a technique which implements "return" based error handling but is structured such that it could be easily converted to utilize exception based error handling. The following macros are utilized to hide error handling details from the source code.:

// Catch all errors and do something
#define CATCH_ALL(a) if ((a) != IW_SUCCESS)

// Catch an error of a specific type and do something
#define CATCH_ONE(a,err) if ((a) == (err))

// Detect an error, print out some useful debugging 
// information,and propagate error returns up the 
// call stack.  This macro assumes that all memory
// clean-up occurs automatically on return.
#define SER(a) \
{ \
    IwStatus sErr = (a);    \
    if (sErr != IW_SUCCESS) { \
        iwos_ErrorMessage(sErr,__FILE__,__LINE__,NULL); \
        return sErr; \
    } \
} 
// If "a" is NULL return an error up the call stack.
#define NER(a) \
{ \
    const void* vVoid = (a); \
    if (vVoid == NULL) { \
        iwos_ErrorMessage(IW_ERR_NULL_POINTER,__FILE__,\
                         __LINE__,NULL); \
        return IW_ERR_NULL_POINTER; \
    } \
} 
// Perform an assertion on a Boolean value.
// If the assertion fails it is a somewhat fatal problem.
#define IW_ASSERT(a) \
{  \
    BOOL bBool = (a); \
    if (!bBool) { \
        iwos_ErrorMessage(IW_ERR_ASSERT_FAILURE,__FILE__, \
        __LINE__,\
        "Assert Failure - Unrecoverable - Exit now"); \
    } \
} 

The elegant part about utilizing macros like these is that they allow you to write very clean software. You no longer have to clutter your software with "if (error)" statements. In the following example you can clearly see how error handling and the stack based cleanup objects work together:

IwStatus Foo(IwObject *& rpNewObject)
{
    IwObject *pObject = new IwObject();  
    NER(pObject); // Return error if allocation fails
    // Put pObject into stack based clean-up object
    IwObjDelete sCleanUpObject(pObject);  

    // Call some function which has error return
    SER(Foo2(pObject));

    // Invoke a method of pObject which has error return
    SER(pObject->FooMethod());

    // Check validity of object or data somehow and return
    // an error if something goes wrong.
    if (!pObject->IsValidObject) {
        return IW_ERR_INVALID_OBJECT;
    }

    // Now we have completed working with pObject - remove
    // it from the clean up object and set the output.
    sCleanUpObject.Clear(); 
    rpNewObject = pObject;
    return IW_SUCCESS;  // Successful completion 
}

Scope

Scope of a software is defined as the set of data on which it operates. Scope can be defined for both a library and for an individual function. Scope needs to be sufficiently broad to be of use to a large variety of users and sufficiently narrow to enable you to implement it in a robust and efficient manner. The difference between good software and bad software is sometimes an issue of scope. In most cases the scope is too narrow and the functionality is too broad. It is always better to write several simpler modules (functions or methods) that each handle a broad range of input values instead of a single module which dies a lot of things but does not do any of them well. A good indicators of scope are: the number of arguments; the size of a module; and the complexity of a description of what the function does.

In IntegrityWare's libraries we have defined the scope to be geometry as defined by NURBS. We have further limited the scope to define the specific continuities, order, and dimensionality for NURBS curves and surfaces. Some modules such as Surface/Surface intersection are further restricted to surfaces which have at least G1 internal continuity.

Having good scope is often a matter of experience. Too much or too little scope will lead to failure in the development of component software. I would suggest that you do not try to develop component software for anything unless you have implemented a non-component version of the software at least one time first. Writing a graphics toolkit prior to having implemented a graphics system would be a foolish adventure unless you had an existing API such as OpenGL or PHIGS to define your scope. I would imagine that the same is true of many other application areas.

 

Generality

Scope and generality are very closely related. Good scope often produces reusable software. In C++, generality can often be deduced by the objects and how they are layered in the architecture. By an architectural layer is a set of objects and functions which only have dependencies on the current or lower level layers. Lets take an example of two versions of a bounding box. In one case the bounding box only has dependencies on points. The second bounding box also has methods which have dependencies on NURBS curves and surfaces. In the first implementation the bounding box can be placed on a lower layer in the architecture than the NURBS curves and surfaces. In the second case, the bounding box must be on the same layer or higher layer. In the first case the bounding box software could be removed and used in another package which did not have NURBS. The second case would require substantial editing of the bounding box to accomplish the same thing. We can easily make the statement that the fist bounding box has better "Generality" than the second.

In IntegrityWare's products there are nine distinct layers as follows:

  1. OS - Operating System Layer
  2. PVXB - Points, Vectors, Transformations, Bounding Box, etc.
  3. CONT - Containers - Arrays, Lists, etc.
  4. GFX - Graphics Interface
  5. SOLV - Global Solvers, Local Solvers, Integrator
  6. CURV - Curves Layer
  7. SURF - Surfaces Layer
  8. ASPIN - Advanced Surfacing Plug-in Layer
  9. TOPO - Topology Layer

We can and do create products simply by choosing the appropriate layers. We were able to create a curve library for one customer in a matter of only a few hours by choosing only those files in the CURV Layer and below. The only disadvantage to layering is that sometimes you may be able to do something more efficiently or easier without layers. However, over the long haul good generality and good layering always pays more dividends then they costs.

 

Consistency

When software is implemented in a consistent manner it is easier to use. The user has enough to concentrate on just trying to figure out how to use the software and how to solve his problem. Don't complicate his problem by making thing unpredictable. We have encapsulated some of the things we do for consistency into a set of coding standards shown below:

Documentation and Coding Conventions

Most of the documentation of this software is in this file and in the actual source code. We try to make things self documenting. Once you figure out what is going on, you should be able to print out a copy of the include files and use that as your hard copy reference manual. Occasionally you may have to look at the source code for clarification.

Here are some of the rules we follow to keep things consistent and unambiguous:

 

Miscellaneous

Writing good component software is not for the lazy and faint hearted. It really requires a lot of thinking and hard work. It is an order of magnitude more work than writing software for a specific application. It also requires you to humbly consider the opinions of your customers and prospective customers. If you want your software to last you also have to consider documentation, portability, maintainability and many other things. The real measurement of the quality of the job you have done is your customers. Do they like it? Are they using it for doing a lot of different things? Are you getting regular enhancement requests?