06 April, 2011

Designing an API

How do you design an API? My experience is that just like with most other issues concerning code formatting and standards, every programmer has their own set of preferences. Which of course means this post is entirely based on my personal views, and I will obviously assume you agree with the choices I make.

This is part two in my series of posts about building a profiling library. The previous post covered the base code for measuring elapsed time. Just like the code for that post, the code for this and the next parts will be available through github, released to the public domain:


Let's get down to business! An API should be:
  • Consistent. All functions, parameters and types should follow a well defined pattern in order to make it easy to remember how function names are constructed and how to pass the expected parameters.
  • Orthogonal. A function should not have any side effects, and there should be only one way to perform an operation in the system.
  • Compact. The API needs to be compact, meaning the user can use it without using a manual. Note though that "compact" does not mean "small". If you follow the first rule, a consistent naming scheme makes the API easier to use and remember.
  • Contained. Following from the rant in my previous post, we should also avoid third party dependencies and prefer to use primitive or well-defined data types.
  • Specialized. A function in an API should perform a single task. Don't create functions that do completely different unrelated tasks depending on the contents of the variables passed in.
And now we apply these principles to the problem of designing a profiling API. The minimal set of functionality we need is to be able to define an execution block for which we want to measure the elapsed time. We need to be able to identify the block, as well as have blocks within blocks. For blocks that execute over a prolonged period of time we could also use a method to check if the thread has migrated to another core. By requiring that a call to begin a block is matched by a call to end, we can hide all the hierarchy details in the implementation.
 void         profile_begin_block( const char* identifier );  
 void         profile_update_block();
 void         profile_end_block();
Everything else we add might violate one or more of the rules, so we need to tread carefully. A reasonable feature of this system would be to be able to group blocks together in a frame (where the meaning of a "frame" would be open for interpretation by the user). Since the end of a frame automatically marks the beginning of the next frame, we only need one function (compact). The function will have no side effects and only fulfill the primary purpose of marking a group of blocks as belonging to a frame (orthogonal). We name the function according to the previous block functions and use a standard integer type to identify the frame number (consistent, contained).
 void         profile_end_frame( uint64_t counter );
Going forward, we also want a function to enable/disable profiling as well as functionality to insert generic log messages into the profiling data stream. It could also be useful to add notifications of mutex locks/unlocks as they control the flow and execution of threads. However, we're now close to breaking the orthogonality rule as we could potentially achieve these goals by using the generic log message function with messages of certain pre-defined formats. But this is where the "specialized" rule kicks in. Having a generic log message function performing both the task of inserting a log message into the profile data as well as inserting notifications about mutex locks would make the API harder to remember and more bug-prone.
 void         profile_enable( int enable );
 void         profile_log( const char* message );
 void         profile_trylock( const char* name );
 void         profile_lock( const char* name );
 void         profile_unlock( const char* name );
Finally we need functions to initialize and shutdown the profiling backend, and a function to declare how to output the gathered date. In order to minimize the dependencies of the library and allow the user to output in application-specific ways, we'll use a callback.
 typedef void (*profile_write_fn)( void*, uint64_t );
 void         profile_initialize( char* identifier );
 void         profile_shutdown();
 void         profile_output( profile_write_fn writer );
As you might have guessed I've placed a few restrictions/demands on the user of the API. In order to stick with primitive data types and minimize the amount of memory management going on in the implementation of the library, the strings passed to the functions must be valid until the data has been flushed to the output stream.

That concludes this small exercise in API design. In the next part I'll go through the implementation of the profiling library, dealing with the thread synchronization issues and the steps taken to minimize the overhead of the implementation. To be effective, the profiling code itself can only spend an order of magnitude less time than the code being profiled.

Labels: ,