Original URL: http://www.cs.wustl.edu/~schmidt/rules.html
Framework Design Rules
This document describes design rules to follow while developing and using object-oriented frameworks for communication systems.
Table of Contents
1. Introduction
The OO application framework design rules described below have been derived by re-engineering the design, implementation, and proper use of the
ACE C++ framework. ACE is an object-oriented (OO) framework that implements many core
design patterns for concurrent communication software. ACE provides a rich set of reusable C++ wrappers and framework components that perform common communication software tasks across a range of operating system platforms.
2. Programming Principles
This section describes coding practices that directly result from practical framework design issues, including maintainability and reusability.
#guard header files
Rule
-
Always guard against multiple inclusion of header files.
Example
-
Suppose we have files
a.h,
b.h, and
c.h which declare classes
A,
B and
C, respectively. Further suppose that
B and
C both inherit from
A, and that
C contains a reference to
B. Hence,
b.h includes
a.h and that
c.h includes both
a.h and
b.h.
Rationale
-
Without guarding against multiple inclusion,
c.h from the example above will contain two declarations for class
A.
Applicability
-
Any header file.
Consequences
-
Prevents multiple declarations. Requires additional code to be written. However, this can be "boiler-plated" by a suitable development environment or program editor.
Exceptions
-
There are no exceptions.
Known Uses
-
Applies to every header file.
Enforcement
-
Can be automated.
Avoid global functions
Rule
-
Avoid the use of global functions. If functions are required, place them within a
namespace or nested in a class.
Example
-
Integrating a framework like ACE with third party frameworks like
X
Windows and other class libraries like STL or Tools.h++ require callback functions to be defined and passed into them. For instance, thread creation functions require a C-style function:
void *thread_entrypoint (void *arg)
{
// ...
}
// ...
thr_create (0, 0, thread_entrypoint, param, 0, &tid);
However, defining functions like
my_callback
at global scope can pollute the namespace. Namespace pollution makes it hard to integrate the various frameworks and class libraries. Therefore, all global functions in a framework should be preceded with a unique prefix. One way to do this is as follows:
void *ace_thread_entrypoint (void *arg)
{
// ...
}
// ...
thr_create (0, 0, ace_thread_entrypoint, param, 0, &tid);
Often, a better way to structure this code is to scope it as a static method within a class, as follows:
class ACE
{
static void *thread_entrypoint (void *arg)
{
// ...
}
};
// ...
thr_create (0, 0, ACE::thread_entrypoint, param, 0, &tid);
This solution is more abstract, but may cause problems with certain C++ compilers (such as the IBM MVS C++ compiler) that don't allow static member functions to be used as parameters for functions that expect C functions (such as
thr_create
or
signal
).
If a compiler supports namespaces, an even better way to structure this code is to scope it within a namespace, as follows:
namespace ACE
{
void *thread_entrypoint (void *arg)
{
// ...
}
}
// ...
thr_create (0, 0, ACE::thread_entrypoint, param, 0, &tid);
However, many older C++ compilers don't support namespaces yet, so this approach is less portable.
Rationale
-
Protecting global functions with namespace or class scoping, minimizes the possibility of colliding with user code.
Applicability
-
This rule should be applied whenever a C++ function is required.
Consequences
-
If the compiler lacks support for
namespace, then scoping inside a class is acceptable but less convenient.
Exceptions
-
None
Known Uses
-
ACE precedes its handful of global functions with the
ace_
prefix. Moreover, it places most stand-alone functions as static methods within the
ACE
class.
Enforcement
-
Can be automated.
Respect user namespace
Rule
-
Avoid polluting the global namespace.
Example
-
Rationale
-
This rule is a generalization of the
Avoid global functions. It extends the application of the rule to global variables as well as functions.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
Use explicit destructor with placement new
Rule
-
All uses of the placement new operator must be accompanied by a corresponding explicit destructor call.
Example
-
Consider the following code fragment.
class A *arr = Allocator::malloc (MAX * sizeof (class A));
for (int i = 0; i < MAX; i++)
new (arr + i) A (i);
// ...
Allocator::free (arr);
Rationale
-
If class
A dynamically allocates memory for itself, the example above results in memory being leaked. In order to reclaim the memory, the destructor should be explicitly called.
for (int i = 0; i < MAX; i++)
(arr + i)->A::~A (); // arr[i].A::~A (); is equivalent,
Note that it is incorrect to attempt to use
delete on any part of
arr, since the the memory was not
allocated with
new.
Applicability
-
This still applies (but in reverse) if placement new is applied to an existing fully initialized object. That is, the destructor should be called explicitly first before placement new is applied.
Consequences
-
Faithfully applying this rule will lead to fewer problems with memory leaking. However, programmers may decide not to use general memory allocators to avoid the complexity of remembering to call destructors explicitly.
Exceptions
-
There are no exceptions.
Known Uses
-
In ACE, classes that allow for parameterizable allocators follow this rule.
Enforcement
-
Automatic enforcement imperfect without data-flow analysis.
Avoid long pointer dereferencing chains
Rule
-
Avoid long chains of pointer dereference operators.
Example
-
Avoid lines of code that resemble
a ()->b ()->c ()->d ()->e ();. Since actual objects names are usually longer than a single character, one can imagine such a chain being difficult to read.
In addition, if the line of code is executed in a performance-critical section of the program many C++ compilers will not optimize this.
Rationale
-
The primary concern is that of code readability and maintainability. When debugging, the programmer is faced with tracing through number methods to understand the behavior of the code fragment. Efficiency is a secondary, but relevant, issue.
Adherence to this rule can lead to framework designs that are more cohesive, and easier to understand.
Applicability
-
Everywhere.
Consequences
-
Makes method invocations easier to follow. A performance speedup may result by explicitly creating intermediate temporary pointers.
May result in programmers manually creating temporaries before derefencing the next method call. However, these can often be optimized away by the compiler.
Exceptions
-
None.
Known Uses
-
ACE, TAO and JAWS.
Enforcement
-
Can be automated.
Avoid making direct system calls
Rule
-
Framework users should always use wrappers instead of directly making system calls.
Example
-
Suppose a framework developer has need to create a temporary file with a name that can be passed to another process (
i.e., the Standard C function
tmpfile() is insufficient). In UNIX, the developer may be tempted to use the
mktemp() system call, which creates a temporary file name that thus far does not exist. However, this system call is not available on all platforms (
e.g., VxWorks and OSF1). Rather than making a direct system call, a wrapper should be used instead. For example,
namespace ACE_OS {
char *
mktemp (char *s) {
#if defined (ACE_LACKS_MKTEMP)
// ... framework provided emulation
#else
return ::mktemp (s);
#endif /* ACE_LACKS_MKTEMP */
}
}
Thus, a temporary file can now be created with a call to
ACE_OS::mktemp().
Rationale
-
This rule allows the framework to flexibly handle portability issues by emulating functionality when needed. This allows developers to program to a common API.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
Use consistent error-handling
Rule
-
The framework should employ and maintain a consistent error-handling interface.
Example
-
There are many styles of error-handling. Exceptions are beginning to become ubiquitous among C++ compiler implementations, but the quality of implementation across different vendors remains inconsistent. Another technique uses a error code return value, commonly used by system calls and synchronous method calls. Finally, classes that provide asynchronous methods may handle errors by providing a status method that reports the state of the asynchronous task.
If a framework design lacks consistent error-handling, then semantically similar objects (such as containers) may present the framework user with different mechanisms for detecting error conditions. Which may be a potential source of confusion.
Rationale
-
Employing and deploying a consistent error-handling interface in the design of a framework can reduce the cost of maintenance, and raise its readability, and usability.
Applicability
-
Any exported class with methods whose execution may result in an error.
Consequences
-
It is admittedly difficult to choose a single error-handling interface that is flexible enough to handle all possible problems that may arise.
Exceptions
-
None.
Known Uses
-
JAWS Filecache and IO.
Enforcement
-
Code review.
Implement dump() methods
Rule
-
Instrument a dump() method in every object.
Example
-
Suppose we are following the
Allow for run-time tracing rule. It is often the case that the state of the object needs to be made known to create meaningful trace output.
Rationale
-
Following this design principle allows for easy inspection of the state of any object.
Applicability
-
Every object which maintains state that may need to be inspected at run-time.
Consequences
-
Enables run-time tracing. However, it may violate encapsulation unless the programmer has diligently provided accessor methods to the object's state.
Exceptions
-
Stateless objects.
Known Uses
-
ACE.
Enforcement
-
Can be automated.
Use abstractions for file descriptors
Rule
-
Abstract away different representations of handles / file descriptors.
Example
-
Windows NT descriptors are represented as pointers, while in UNIX, they are represented as integers. If the framework is to be portable across both platforms, these differing representations need to be reconciled. In ACE, this reconcilation is achieved with
ACE_HANDLE.
#if defined (ACE_WIN32)
typedef HANDLE ACE_HANDLE;
#else
typedef int ACE_HANDLE;
#endif /* ACE_WIN32 */
Here,
HANDLE is the Win32 type name given for file descriptors, whose underlying representation is a
void *.
Rationale
-
By applying an abstraction such as
ACE_HANDLE to the representation of descriptors for both Windows NT and UNIX, framework developers can be more confident that the code can be ported to either platform with minimal disruption to application logic.
Applicability
-
Framework that performs operations on descriptors on multiple platforms.
Consequences
-
This technique may create a mandate that framework users program to the interface of the most exotic platform. For example, Windows NT uses a separate pointer type to represent network handles than from file handles, where as in UNIX they are both integers. In order to propogate this design rule, a new abstraction would be created that would become a network handle in Windows NT, and an integer in UNIX. This means framework users would be programming to the notion of separate handle types for file and network handles, even though they are the same type in UNIX.
Exceptions
-
Single platform environments.
Known Uses
-
ACE.
Enforcement
-
Can be automated.
Allow for run-time tracing
Rule
-
Instrument framework methods to allow for run-time tracing.
Example
-
When developing applications, programmers often find the need to annotate portions of their program with print statements so as to be able to determine what state a program is in at the point of a failure. However, bare print statements are inconvenient after debugging has been completed, since all the trace statements have to be removed manually. To solve this problem, many developers employ a macro wrapper that contains the print statement, and can be selectively removed by redefining the macro to be blank.
The ACE programming framework provides such tracing capabilities through its ACE_DEBUG macro. This flexible facility has the ability to send the trace messages to the screen, a file, or to a log server.
Rationale
-
Incorporating tracing mechanisms into the framework design provide aid to the debugging process of framework applications.
Applicability
-
Consequences
-
When using macros that may be selectively removed, care has to be taken to not include expressions with side-effects to be taken as arguments into the macro.
Exceptions
-
Known Uses
-
Enforcement
-
Code review.
Check all returned values
Rule
-
Either check or return all returned values from system functions and framework method calls.
Example
-
This rule is perhaps obvious, but it is quite an easy point to overlook. Developers often prototype code that they do not believe will be included in any actual product. This code can be, undestandably, sloppily written. However, when the prototype is shown to be useful, they often become included into the production code base. Perhaps the most common mistake is to not verify the result of a call to
new.
Rationale
-
This can be considered an extension to the
Use consistent error-handling rule. A program should consistently never assume all calls are successful.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
Qualify methods not in scope
Rule
-
Qualify references to base class methods and data. I.e., anything outside the scope of the class.
Example
-
Rationale
-
Without a qualifying references, it is often not obvious to the code reviewer which function is being invoked. Qualifying these references increases the maintainability of the code.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
Avoid data members in base classes
Rule
-
Base classes should not store information about derived classes.
Example
-
Consider in ACE the
Event_Handler. This abstract class is used as the base class for objects to be managed by a
Reactor. Since the
Reactor is a pattern based on the UNIX
select() system call, an
Event_Handler is associated with a
HANDLE. However, storing the
HANDLE associated with the
Event_Handler in the abstract base class would be a mistake. The
HANDLE is needed by the derived classes in order to perform their low-level actions, such as reading from and writing to the descriptor. The only way to ensure access would be to make the
HANDLE data member public, or to provide public accessors to the data. However, a simpler solution is to create a virtual accessor method in the abstract class.
Rationale
-
Instead, the base class should provide virtual accessor methods to the information.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
Implement open()/close() methods
Rule
-
Use
open() methods to initialize objects rather than constructors. Likewise, use
close() methods to finalize objects rather than destructors.
Example
-
Suppose we are creating an object that with many different constructors. It is tedious to implement each constructor if many of the initializations are similar. The process can be simplified by implementing an
open() method which performs the common initializations.
Moreover, as indicated in the Initialize on first use rule, it is often useful to create static objects but to postpone their initialization (until the existence and initialization of their dependents can be verified). The existence of an open() method provides a mechanism for allowing this to take place.
For destructors, consider the Use unguarded destructors rule. However, if shared data has been dynamically allocated, then it becomes necessary to serialize the contexts that attempt to release the allocated data. The presence of a close() method provides a callable method for which this can be done and allows the destructor to remain guard free.
Rationale
-
Creation and initialization are orthogonal issues. Likewise, destruction and finalization are orthogonal.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
3. Compiler Concessions
This section presents rules that allow the framework design to flexibly cope the wide range of C++ compilers and their various levels of compliance with the C++ Standard.
Avoid using inline directly
Rule
-
Avoid using the
inline keyword directly in C++ code. Instead, use a macro that can be automatically toggled to inline or not inline. Separate the inlines into their own implementation files,
i.e., do not put inlined functions in header files.
Example
-
C++ inline functions are often useful to eliminate the invocation overhead of small, commonly used C++ methods at run-time. The following is a common way of writing this code:
class ACE_SOCK_IO
{
public:
ssize_t send (const void *buf, int n) const
{
// Call underlying OS function to send <buf>.
return ::write (this->get_handle (), buf, buf_size);
}
// ...
};
Although this approach works, it is inflexible since it hard-codes the use of inlining into the framework and makes it difficult to separate the interface of a class from the implementations of its methods.
It is often useful, however, to disable inlining when debugging a framework or debugging applications build using a framework for the following reasons:
- Many debuggers get confused when they encounter inlined code. This makes it hard to step through the execution sequence of a program in the debugger.
- If the code is always inlined, any changes to method implementations will cause all dependent object files to be recompiled.
- Separating the interface from the implementation makes it easier to understand the documented semantics of a class.
Therefore, it is often more useful to structure inlined C++ code as follows:
// System-wide include file OS.h
#if defined (__ACE_INLINE__)
#define ACE_INLINE inline
#else
#define ACE_INLINE
#endif /* __ACE_INLINE__ */
// Header file SOCK_IO.h
#include "ace/OS.h"
class ACE_SOCK_IO
{
public:
ssize_t send (const void *buf, int n) const;
// ...
};
#if defined (__ACE_INLINE__)
#include "ace/SOCK_IO.i"
#endif /* __ACE_INLINE__ */
// Include file SOCK_IO.i
ACE_INLINE
ssize_t
ACE_SOCK_IO::send (const void *buf, int n) const
{
// Call underlying OS function to send <buf>.
return ::write (this->get_handle (), buf, buf_size);
}
// Implementation file SOCK_IO.cpp
#if !defined (__ACE_INLINE__)
#include "ace/SOCK_IO.i"
#endif /* __ACE_INLINE__ */
Rationale
-
By using a macro like
ACE_INLINE in place of the
inline keyword, the same source file can be used for building both debug and optimized versions of the framework. For debug versions, the macro can expand to an empty string, and the source compiled separately. For optimized versions, the macro can expand to
inline, and be
#include'd by the header file. This separation of concerns makes it easy to switch between debug and optimized configurations.
If the inlined code has not been separated, then the change causes all dependent objects to be recompiled. In addition, by completely separating method implementation from method interfaces, header files can more clearly document the semantics of each method.
Applicability
-
Use this rule whenever you write C++ methods that should be easy to debug, as well as optimized.
Consequences
-
Editors that support source code parsing (for correct syntax and color highlighting) may get confused and parse the macro incorrectly. In addition, creating separate
*.i files can increase the number of files that programmers must understand.
Exceptions
-
If a function is very short, will never fail, and should always be inlined, then it may make sense to use the
inline
keyword directly in the
*.i
file, as follows:
// Include file SOCK_IO.i
inline
ssize_t
send (const void *buf, int n) const
{
return ::write (this->get_handle (), buf, buf_size);
};
Note that it is still a good idea to have a separate include file, rather than including the method implementation in the class definition to enhance clarity and ensure flexibility for future changes.
Known Uses
-
The ACE framework uses this rule extensively.
Enforcement
-
Can be automated.
Separate templates from non-templates
Rule
-
Template classes should not be mixed with non-template classes.
Example
-
Suppose a framework developer has created a template class,
ACE_Task, which provides a general interface for active objects. The developer follows the
Use a non-template class as base in order to allow references to this class to be passed as an argument to methods of other classes, such as the
ACE_Thread_Manager. Then, the developer may be tempted to define the template and base at their point of use.
#ifndef TASK_H
#define TASK_H
class ACE_Task_Base
{ // ...
};
template
class ACE_Task : public ACE_Task_Base
{ // ...
};
#endif
However, upon compiling applications based on the framework, the developer finds the linker to complain about multiple definitions for ACE_Task_Base.
Rationale
-
This is a portability issue. Some compilers require that templates be in some accessible header file in order for the linker to resolve template instantiations. However, if mixed with non-template classes, multiple definitions may result.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
Use traits in templates
Rule
-
Use traits to merge multiple (related) template arguments.
Example
-
Consider the following example from the ACE framework. ACE defines an abstraction that can be used to parameterize allocation strategies applied by classes that need dynamic memory management. This abstraction is called
ACE_Allocator. In order to facilitate the deployment of a variety of allocation strategies ACE provides a template called
ACE_Malloc. This template is parameterized by
MEMORY_POOL type, and by the locking strategy. Thus, enabling the allocation to be drawn from a static, dynamic, and/or persistent
MEMORY_POOL. The type of
MEMORY_POOL is
typedef'd withing the
ACE_Malloc class to create a
trait. This trait is used when
ACE_Malloc is specialized and applied to the
ACE_Allocator_Adapter (an adapter template that subclasses from
ACE_Allocator and delegates to a memory allocation strategy, such as
ACE_Malloc). Without the use of the trait,
MEMORY_POOL would have to be passed as a paramter the
ACE_Allocator_Adapter.
Rationale
-
By employing traits, templates achieve greater consistency, are more reliable, and are easier to use.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Automation is probable.
Use a non-template class as base
Rule
-
Try to have a base class that is not a template, from which specialized classes can be derived that may have template parameters.
Example
-
Rationale
-
This rule also reduces the number of template parameters needed by allowing a non-template class provide the interface by which the template must follow. Thus, the instance of the template may be referenced though its base. See
Use traits in templates.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
#guard template source files
Rule
-
Templates may require source. Hence, template source files may need to be guarded against multiple inclusion too.
Example
-
Suppose we are following the
Avoid using inline directly design rule, and have separated implementation code from the object declaration. Furthermore, the implementation code that can be inlined is held in their own separate file. The table below depicts the situation.
class.h
| class.i
|
#if !defined (CLASS_H) #define CLASS_H
#include "ace/OS.h"
class A { public: ACE_INLINE A (void); ~A (void); };
#if defined (__ACE_INLINE__) #include "class.i" #endif /* __ACE_INLINE__ */
#endif /* CLASS_H */
| #if !defined (CLASS_I) #define CLASS_I A::A (void) { // ... } #endif /* CLASS_I */
|
class.cpp
|
#include "class.h"
#if !defined (__ACE_INLINE__) #include "class.i" #endif /* ! __ACE_INLINE__ */
A::~A (void) { // ... }
|
Rationale
-
See
#guard header files.
Applicability
-
All implementations of templates.
Consequences
-
Exceptions
-
None.
Known Uses
-
All template classes and functions in ACE follow this design rule.
Enforcement
-
Can be automated.
4. Configuration Concerns
The issues addressed by these guidelines impact the design of frameworks with regard to how to ease porting tasks among different compiler, OS and hardware platforms.
#guard against compiler quirks
Rule
-
Guard against quirky compiler implementations through
#define abstractions (whereever possible).
Example
-
Some compilers require special keywords in order to make objects exportable in a dynamically linked library (MSVC++). Other compilers require explicit template instantiation. Different compilers may have different mechanisms to explicitly instantiate templates (
e.g.,
#pragma instantiate, or via a special declaration form).
Rationale
-
The special keywords are only needed when the header file is used to build the library. When the user
#include's the header file, the keyword should be ommitted. The use of a
#define makes it possible to reuse the same header file for both framework developer and framework user.
Applicability
-
Files that are compiled on multiple platforms.
Consequences
-
Unless carefully done, may complicate things for code parsing tools.
Exceptions
-
Single platform environments.
Known Uses
-
ACE, JAWS, TAO.
Enforcement
-
Code review.
Specify a platform by its features
Rule
-
Don't use conditional compilation based on compiler/OS/hardware platform, but upon available features.
Example
-
Rationale
-
The conditional compilation preprocessing lines become descriptive. It is conceptually easier to port the framework to a new platform, since it only requires that the platform be described in terms of the features that are available.
The alternative would be identify every conditional pre-processing line in the source and determine whether or not the platform under consideration requires to be added to the conditional or not.
Applicability
-
Any framework where portability is an issue.
Consequences
-
Eases porting task. If a new platform has a feature that none of the previous platforms have, then it can result in feature descriptions that are both positive (ACE_HAS_FEATURE) and negative (ACE_LACKS_FEATURE).
Exceptions
-
If portability is not an issue, rule does not apply.
Known Uses
-
ACE, JAWS, TAO.
Enforcement
-
Can possibly be automated.
Use a config.h file
Rule
-
Centralize portability #ifdefs in a single place to ease portability maintainence.
Example
-
Suppose that the code for application
App was written for
UNIX, and is then to be ported to
Windows
NT, and it consists of many source files. It is a bad idea to visit each source file and add
#ifdefs UNIX
// ...
#else
// ...
#endif
Everywhere the code needs to change.
Rationale
-
The next platform that
App needs to be ported to will require a re-visitation to each source file. If instead the incongruities are reconciled in a single place, then portability is enhanced by reducing the number of files that require changing.
Applicability
-
Any framework that depends upon portability.
Consequences
-
Ultimately, this leads to the creation of wrappers.
Exceptions
-
If the framework is never intended to be ported elsewhere, this rule does not apply.
Known Uses
-
ACE.
Enforcement
-
Might be automated.
Initialialization issues arise naturally in frameworks, since there are various objects for which only one instance should ever exist, known as singletons. Often, singleton classes will employ the use of static declarations for its own data members. This section warns about such practices.
Rule
-
Avoid the use of static/global objects whose constructors must be run in order to initialize the objects correctly. Instead, use Singletons that apply the
Double-checked locking optimzation pattern.
Example
-
Suppose there is a static class of type
A, that contains a data member that is a class of type
B. Further, suppose that
B contains static data.
Rationale
-
Since C++ does not guarantee the order of static initializations, unexpected results are possible if
A depends upon the static values in
B in its initialization.
Applicability
-
Use this rule whenever you develop class libraries or frameworks that require the use of objects that are logically global or static.
Consequences
-
It can be tedious to remove all uses of static and global objects from a large software system.
Exceptions
-
Static/global objects can be used correctly if all their initial values are 0 and/or they are simple pointers.
Known Uses
-
The ACE framework contains no unsafe static/global objects. Instead, it uses a single global Object Manager to control the lifetime of static/global objects.
Enforcement
-
Automation probable.
Rule
-
Do not make static objects whose correctness depends on constructors being called (
i.e., if 0 initialization is not sufficient).
Example
-
Rationale
-
See
nostatics.
Applicability
-
Consequences
-
Exceptions
-
None.
Known Uses
-
Enforcement
-
Automation probable.
Rule
-
Ensure Singleton destruction by providing hooks that delete Singletons
before a process exits.
Example
-
Consider an editor application that uses a filemanager object to remember which files are opened in memory. Suppose this object is a singleton. The editor may need to maintain session information, so that the next editor invocation will have all the same files open as the last.
Rationale
-
An ideal time for the information to be recorded is before the editor actually exits. Since the information is contained in the filemanager, the destructor of the filemanager can record the necessary information so that the next invocation can open the files that had been open before.
Applicability
-
Any occurrence of a singleton object.
Consequences
-
Requires programmer to consider the possibility of singleton destruction.
Exceptions
-
None.
Known Uses
-
JAWS Filecache.
Enforcement
-
Automatic enforcement probable.
The design rules here encourage the use of particularly appropriate patterns that lend themselves to providing identifiable framework idioms. In general, these patterns particularly strengthen the skeleton nature of the framework while increasing the flexibility available to the application developer.
Strategize Memory Allocation
Rule
-
Allow customization of memory allocation on a per-object or per-thread basis.
Example
-
Consider a real-time Object Request Broker, such as
TAO. To reduce lock contention and avoid priority inversion, it is important to localize the scope of dynamic memory operations to memory pools allocated in thread-specific storage. Likewise, for medical imaging systems, it is often useful to allocate memory out of memory pools residing in shared memory to reduce data copying between separate image processing processes.
One way to control of memory allocation/deallocation in C++ is to overload operators new
and delete
either globally or in a class-specific manner. However, this design is too general and can cause many unrelated parts of the system to behave incorrectly.
A more flexible way of controlling memory allocation/deallocation in C++ is to define an Allocator
component that can be parameterized in various ways, such as on a per-object or per-thread basis. For example, rather than saying:
Image *create_image (bool use_shared_memory)
{
Image *image;
if (use_shared_memory)
{
// ... mmap() file, obtain a pointer to shared memory
// region, etc.
image = new (shared_memory_pointer) Image (use_shared_memory);
}
else // use local memory.
image = new Image (use_shared_memory);
return image;
}
Developers can write
Image *create_image (ACE_Allocator *allocator)
{
char *buf = allocator.malloc (sizeof Image);
return new (buf) Image (allocator);
}
This design makes it possible to replace memory management strategies wholesale without affecting the application interfaces. In addition, it greatly reduces the effort required to keep track of which memory management strategies are associated with each object or thread.
Rationale
-
If memory allocation is a parameterized strategy, then it is straightforward to extend objects or threads to use different memory management policies transparently.
Applicability
-
This rule should be used whenever a framework requires fine-grained control over memory allocation and deallocation.
Consequences
-
Allows allocation strategies to be parameterizable. Requires generic allocation interfaces. Also, may require each object and their subobjects to be parameterized by a memory management strategy.
Exceptions
-
Known Uses
-
ACE, JAWS, and TAO use parameterized memory management strategies extensively.
Enforcement
-
Code review.
Apply the strategy pattern
Rule
-
Apply the strategy pattern to factor out common sources of variation in a component.
Example
-
Rationale
-
A framework should provide enough structure as to form a skeleton application. Yet, it should provide enough flexibility so that application developers can do their job. Liberal application of the strategy pattern helps achieve this balance of structure and flexibility.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Automation unlikely.
Create a wrapper for similar functions
Rule
-
Define wrappers around clusters of functionality that are semantically the same but may have accidental incompatibilities. E.g., semaphores, readers/writer locks, mutex + condition variables, which can be used by threads, processes, that may or may not reside on the same machine.
Example
-
Rationale
-
Application of the adapter pattern. Eases usability of the framework by providing users with identical programming interfaces for similar OS services.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Automation unlikely.
Parameterize components by wrappers
Rule
-
Define generic components that can be parameterized by the wrappers defined
above (in the previous point).
Example
-
Rationale
-
This enables components to keep their logic constant across different platforms.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Automation unlikely.
Implement iterators
Rule
-
In searchable containers, separate traversal logic from item operations.
Example
-
Consider a container class that is used to maintain a dynamic priority queue of events. There are a number of operations which require examining all the items in the container. For instance, the insertion of a new event may require that the priorities of all events be re-evaluated. Without an iterator on the container, the priority queue is required to understand the underlying representation of the container class in order to implement the priority re-evaluator.
Rationale
-
Providing a method to iterate over items in a container insulates the user from the underlying representation of the container.
Applicability
-
Any searchable container.
Consequences
-
Permits entier contents of container to be inspected. Enables a
dump() method to be more easily implemented.
Exceptions
-
None.
Known Uses
-
ACE containers.
Enforcement
-
Automation unlikely.
Separate creation from use
Rule
-
Separate the creation of an object from its use.
Example
-
Suppose we are implementing a client/server system. Consider the server task of establishing a passive connection entry point which clients can use to negotiate a commnication channel to the server. There are details involved in this process, table lookups and structure initialization, that must be done before the passive connection is actually created.
Rationale
-
The abstraction is useful in situations where the logic behind the use of an object remains constant, but initialization of the object may be different across platforms.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
In ACE, this is applied to the Acceptor and Connector, as well as the Service Handler.
Exceptions
-
Automation unlikely.
7. Multi-threaded Matters
The use of these guidelines help framework designs avoid the common pitfalls present in the development of concurrent systems. Namely, race conditions and deadlocks. Also included are design rules that help avoid contention.
Make all methods reentrant
Rule
-
In multi-threaded objects, all methods should be re-entrant.
Example
-
Rationale
-
If a method does not modify shared data, then concurrency is possible. Otherwise, mutual exclusion is required. Eitherway, race conditions are avoided.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Automation unlikely.
Avoid using sentinels
Rule
-
Do not use sentinels or any other shared state that must be updated while searching a container that uses readers/writer locks.
Example
-
Containers are abstract data types (ADTs) that provide operations like
bind/unbind
, which insert and remove items in a container and
find
, which searches for an item in a container. Common implementations of these containers use linked data structures to implement hash tables, lists, and trees.
In non-threaded applications, it is often possible to speed up container operations by using a sentinel. For instance, an implementation of a map that uses a hash table with ``bucket chaining'' to resolve collisions might be implemented as follows:
template
class ACE_Hash_Map_Manager
{
public:
ACE_Hash_Map_Manager (size_t table_size);
// Constructor.
int find (EXT_ID ext_id, INT_ID &int_id);
// Returns the
if
is in the hash table.
// ...
private:
struct ACE_Hash_Map_Manager_Entry
{
EXT_ID ext_id_;
// External identify used as a key to find
// the internal identifer.
INT_ID ext_id_;
// Internal identifier holds the value.
ACE_Hash_Map_Manager_Entry *next_;
// Points to the next entry in the overflow bucket.
};
ACE_Hash_Map_Manager_Entry **hash_table_;
// Array of pointers to the linked list of entries.
ACE_Hash_Map_Manager_Entry *sentinel_;
// Sentinel node.
LOCK lock_;
// Synchronization strategy.
};
In a sentinel-based hash-table implementation, the constructor typically allocates a sentinel and initializes all the pointers in hash table to reference the sentinel, as follows:
ACE_Hash_Map_Manager
Once the sentinel has been initialized, the
find
method can be optimized as follows:
int
ACE_Hash_Map_Manager
Although this code will work correctly if given a ``NULL'' lock (such as
ACE_Null_Mutex
) it will fail if given a readers/writer lock (such as
ACE_RW_Mutex
). The problem is that a readers/writer lock only works correctly if the region of code it protects does not modify the state of the object. In this case, the
sentinel_
maintains state that is shared by all threads that access the hash table. Therefore, the
find
can fail since there are race conditions if multiple threads concurrently update the
sentinel_
.
Rationale
-
Avoiding the use of sentinels in
find
operations reduces contention by removing the critical section around the storage of search value in the sentinel. In addition, it also avoids subtle race conditions that arise if the synchronization strategies are configured into parameterized types.
Applicability
-
Any searchable container class that uses readers/writer locks.
Consequences
-
One drawback to using this rule is that containers whose synchronization strategy is configured via parameterized types may not run as optimially when configured with "null" locking strategies.
Exceptions
-
Contains that will only be used in single-threaded applications need not follow this rule.
Known Uses
-
All ACE containers that support
find
operations and can be configured with readers/writer locks do not use sentinels.
Enforcement
-
Automation unlikely.
Use unguarded destructors
Rule
-
The state of an object does not need to be guarded in destructors.
Example
-
Suppose class
A has destructor
A::~A.
A::~A should only be performing idempotent actions that release resources.
Rationale
-
There is no purpose in creating unnecessary resource contention. If class
A is holding a shared resource, then the resource itself should be guarded, not
A.
Applicability
-
Consequences
-
Destructors should not directly modify static memory if the action is not idempotent.
Exceptions
-
Known Uses
-
Enforcement
-
Automation unlikely.
Guard open()/close() methods
Rule
-
Guard against multiple open() and close() calls, for idempotency.
Example
-
Assume the we have followed the
open design rule so that we have separated object initialization from object creation. Suppose we have such an object
A which happens to dynamically allocate memory upon initialization. Furthermore,
A is referenced from multiple contexts (
e.g., threads of control). It may happen that each context may attempt to initialize
A if both concurrently view
A as uninitialized. If
A is unguarded, then a memory leak may result. Similar problems may occur in the finalization of
A.
Rationale
-
The presence of guards would avoid race conditions. The use of the double check locking pattern can be used to achieve idempotent initialization and finalization.
Applicability
-
Every object that may be multiply referenced.
Consequences
-
Allows initilialization and finalization to be idempotent.
Exceptions
-
If object is never referenced by more than once, rule does not apply.
Known Uses
-
ACE.
Enforcement
-
Code review.
Use unguarded protected/private methods
Rule
-
In classes that require locking, have public methods acquire the locks and call protected/private methods that do not acquire them.
Example
-
Consider a multi-threaded searchable container class. Typical methods include find, insert, and remove. Suppose, however, that the user requires the ability to atomically update an entry. Since the given remove and insert methods are themselves atomic, they cannot be used by update (otherwise, deadlock). Thus, adding an update method to the container class requires copying and pasting code from both remove and insert.
This problem is resolved by providing private internal methods that perform the actual actions of finding, inserting and removing. These internal methods assume that locks are already held when they are called, and so do not require locking themselves. If these methods are called find_i, insert_i, and remove_i, then update can easily be implemented by applying remove_i followed by insert_i.
Rationale
-
Promotes reuse. Eases programming effort.
Applicability
-
Any data structure that requires synchronization in a multi-threaded framework.
Consequences
-
Exceptions
-
Single threaded environments.
Known Uses
-
ACE containers, JAWS Filecache.
Enforcement
-
Code review.
Wait for spawned threads/processes
Rule
-
Always remember to wait for the termination of spawned threads and/or processes in the main the thread before exiting.
Example
-
Rationale
-
Not waiting may cause the system to lose resources that have not been properly released. For example, if threads hold references to shared memory resources (such as semaphore variables), these may not get released back to the system if the main thread exits prematurely. Another example, "zombie" processes take up process table entries.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Automation unlikely.
Avoid using reference counters
Rule
-
Do not use explicit reference counters to maintain reference information on a shared multi-threaded object.
Example
-
In JAWS, cached file objects may be accessed by more than one thread. Since the object cannot be destroyed until all the threads that are currently referencing it are done, the number of current references need to be maintained. The most straight forward solution is to apply an explicit reference count. A better solution is to use readers/writer locks to maintain an implicit reference count.
Rationale
-
Since the reference count of an object is modifiable state that is shared among all threads accessing the object, acquiring and releasing the object requires 2 synchronization calls each. The amount of synchronization overhead can be cut in half by using an implicit reference count maintained by a readers/writer lock. Acquiring the object corresponds to a acquiring a reader lock, and releasing the object corresponds to releasing the reader lock. By trying to obtain a writer lock, it can be determined whether or not the object is no longer being referenced.
Applicability
-
Caches, references to automatic variables (smart pointers), all applications related to reference counting.
Consequences
-
Reduces the synchronization overhead required to access shared objects. If the object is a database object that has been updated, additional complexity is needed to properly release the object from the database.
Exceptions
-
Only applies in multi-threaded (and concurrent programming) environments. For single-threaded applications, reference counters are easier to program and maintain.
Known Uses
-
JAWS Filecache.
Enforcement
-
Code review.
8. Real-time Rules
The rules in this section describe promote framework designs that lead to the development of more correct and efficient framework components.
Avoid unequal priority sharing
Rule
-
To minimize priority inversion, try not to share resources between threads of different priorities.
Example
-
Assume we are to implement a real-time system monitoring and control unit with 2 sensor inputs and a number of actuators. Suppose that one of the sensors supplies feedback at a higher rate than the other. Further assume the solution requires a separate thread to process the feedback from each sensor, and that a higher priority thread is used to process the input freom the high frequency sensor.
One possible design is to have both sensor inputs be modeled as prioritized events that are fed into a single event queue, and each thread queries the the event queue for their respective sensor input. The problem with this approach is that it may happen that the higher priority thread may be blocked out by the lower priority thread if the lower priority holds a lock on the queue while a new sensor input has come in for the higher priority thread. This results in priority inversion.
The better design is to dedicate a separte event queue for each thread, which removes contention for accessing the events.
Rationale
-
By not sharing resources between threads of different priorities, it is not possible for a lower priority thread to cause a higher priority thread to wait for the resource to be released.
Applicability
-
Consequences
-
Exceptions
-
Known Uses
-
Enforcement
-
Code review.
9. Internationalization Intents
As systems have to be ported to environments in which character sets are much larger than can be represented by 8 bits, internationalization of systems and frameworks can have a significant impact on design. These rules help reduce this impact.
Use an abstraction for char
Rule
-
Do not use the char type directly. Abstract it for wide characters.
Example
-
Suppose we are following the
Create a wrapper for similar functions design rule. So, wrapper functions are created to utility functions that can operate on both regular
char strings and
wchar_t strings.
In ACE, this is accomplished by creating overloaded wrapper functions for these utility functions. For example, ACE provides the following typedef and wrappers for these two static functions.
#if defined (UNICODE)
typedef char TCHAR
#else
typedef wchar_t TCHAR
#endif
namespace ACE
{
static int strcmp (const char *s, const char *t);
static int strcmp (const wchar_t *s, const wchar_t *t);
}
Users of the framework can use TCHAR, and their code can then be easily ported to a UNICODE environment.
Rationale
-
This abstraction eases the task of porting applications to international environments.
Applicability
-
Consequences
-
When using the abstraction for the
char type, it is no longer valid assume that a character only occupies a single byte.
Exceptions
-
Known Uses
-
Enforcement
-
Can be automated.
10. Conclusions
Last modified: Sun Mar 29 02:40:33 CST 1998