Topic : C++ Debuging
Author : Null Pointer
Page : << Previous 2  Next >>
Go to page :


assumptions your code makes on its environment and on other code/data, and thus it gives you the opportunity to develop better techniques and prevent bugs.
By placing assertions near the top of the function and/or right before and after their conditions are used, other programmers will be able to more easily prevent bugs in the way they use your code.
It helps to communicate the intent of your code, its affects on related functions/data, and any design limitations it might have.
Bugs are easier to track down when using assertions to check key parameters, because they are found when the arguments are passed into the function, not in some obscure algorithm or function down the road, or even worse, going undetected until they cause other errors.
It makes it easier to track incorrect return values (see assert( SUCCEEDED(hr) ) with DirectX) ;)
You don't have to think up exception descriptions or return value error codes, and this is helpful when you are simply writing new code and wish to test something quickly, and for painfully obvious stuff like assert( this ).
Method 2 - Exceptions
Pros: Automatic cleanup and elegant shutdown, opportunity to continue if handled, works in both debug and release builds

Cons: Relatively slow

Explanation
Basically, you use the throw keyword to throw data up to some unknown function caller and the stack continues to unwind (think of it like the program is reversing itself) until someone catches the data. You use the try keyword to enclose code that you'd like to catch exceptions from. See:

void some_function() { throw 5; } // some function that throws an exception

int main(int argc, char* argv[])
{
  try // just letting the compiler know we want to catch any exceptions from this code
  {
    some_function();
  }

  catch(const int x) // if the type matches the data thrown, we execute this code
  {
    // do something about the exception...
  }
}


If you don't place try blocks in your code, then the stack will simply continue to unwind until it gets past the main function, and your program will exit. You don't have to place them everywhere - only where you can catch an exception and can recover from it. If you can only recover from it partially, you can rethrow the original exception (by the empty throw statement "throw;") and the stack will continue unwinding until it hits the next matching catch block.

Examples
Exceptions are best used in key places in debug and release builds to track exceptional conditions. If used properly, they provide automatic cleanup and then either force the application to quit or put itself back into a valid state. Thus exceptions are perfect for release code, because they provide everything the end user wants in a well-behaved program that encounters unexpected errors. Correctly used, they provide the following benefits:

Automatic cleanup of all resources from any point in execution.
They force the app to either quit or put itself back into a valid state.
They force the recipient of the exception to handle it, instead of merely being optional (as with return values and assertions).
They allow the deallocation code to be written by the person who wrote the allocation code and be handled implicitly (destructors, of course!).
Because of the overhead, it is generally a bad idea to use them for normal flow control because other control structures will be faster and more efficient.

Method 3 - Return Values
Pros: Fast when used with built-in types and/or constants, allow a change in the client's logic and possible cleanup

Cons: Error-handling isn't mandatory, values could be confusing

Explanation
Basically, we either return valid data back to the caller, or a value to indicate an error:

const int divide(const int divisor, const int dividend)
{
  if( dividend == 0 ) return( 0 ); // avoid "integer divide-by-zero" error

  return( divisor/dividend );
}


A value of zero indicates that the function failed. Unfortunately, in this example you can also get a return value of zero by passing in zero for the divisor (which is perfectly valid), so the caller has no idea whether this function returned an error. This function is nonsensical, but it illustrates the problem of using return values for error handling. It's hard or impossible to choose error values for all functions.

Conclusion
Return values are best used in conditions when there is a grey area between an error and a simple change in logic. For example, a function might return a set of bit flags, some of which might be considered erroroneous by one client, and not by the other. Return values are great for conditional logic.

A function trusting a function caller to notice an error condition is like a lifeguard trusting other swimmers to notice someone who is drowning.

Method 4 - Logging
Sometimes you do not have access to a debugger, and logging errors to a file can be quite helpful in debugging. Declare a global log file (or use std::clog) and output text to it when an error occurs. It might also help to output the file name and line number so you can tell where the error occurred. __FILE__ and __LINE__ tell the compiler to insert the current filename and line number.

You can also use log files to record messages other than errors, such as the maximum number of matrices in use or some other such data that you can't access with a debugger. Or you could output the data that caused your function to fail, etc. std::fstream is great for this purpose. If you are really clever, you could figure out some way to make your assertions and exceptions log their messages to a file. :)

This provides the following benefits:

It's easy to integrate with your existing code and highly portable.
It can provide human-readable output in lieu of a debugger.
It can provide detailed information without interrupting the program.
Of course, it does have some overhead so you'll have to decide whether that is offset by the benefits in your situation.

One More Thing...
I had intended to finish this article here, but I wish to show how valuable a mixed approach to debugging can be. The easiest way to do this is by creating a simple class, preferrably one that must work with non-C++ code. A file class will do nicely. We'll use C's fopen() and related functions for simplicity and portability.

We need to meet the following requirements:

Constructor and destructor that match the lifetime of the file pointer.
Assertions to describe assumptions made by each function.
Exception types used to force the client to handle exceptional conditions.
Member functions for reading and writing data.
Templated member functions as shortcuts for stack-based data.
Exception safety, including a fail-safe destructor and responsible member functions.
Portability.
Here it is:

#include <cstdio>
#include <cassert>
#include <ciso646>
#include <string>

class file
{
public:

  // Exceptions
  struct exception {};
  struct not_found : public exception {};
  struct end : public exception {};

  // Constants
  enum modes { relative, absolute };

  file(const std::string& filename, const std::string& parameters);
  ~file();

  void seek(const unsigned int position, const enum modes = relative);
  void read(void* const data, const unsigned int size);
  void write(const void* const data, const unsigned int size);
  void flush();

  // Stack only!
  template <typename T> void read(T& data) { read(&data, sizeof(data)); }
  template <typename T> void write(const T& data) { write(&data, sizeof(data)); }

private:
  FILE* pointer;

  file(const file& other) {}
  file& operator = (const file& other) { return( *this ); }
};


file::file(const std::string& filename, const std::string& parameters)
  : pointer(0)
{
  assert( not filename.empty() );
  assert( not parameters.empty() );

  pointer = fopen(filename.c_str(), parameters.c_str());

  if( not pointer ) throw not_found();
}


file::~file()
{
  int n = fclose(pointer);
  assert( not n );
}


void file::seek(const unsigned int position, const enum file::modes mode)
{
  int n = fseek(pointer, position, (mode == relative) ? SEEK_CUR : SEEK_SET);
  assert( not n );
}


void file::read(void* const data, const unsigned int size)
{
  size_t s = fread(data, size, 1, pointer);

  if( s != 1 and feof(pointer) ) throw end();
  assert( s == 1 );
}


void file::write(const void* const data, const unsigned int size)
{
  size_t s = fwrite(data, size, 1, pointer);
  assert( s == 1 );
}


void file::flush()
{
  int n = fflush(pointer);
  assert( not n );
}


int main(int argc, char* argv[])
{
  file myfile("myfile.txt", "w+");

  int x = 5, y = 10, z = 20;
  float f = 1.5f, g = 29.4f, h = 0.0129f;
  char c = 'I';

  myfile.write(x);
  myfile.write(y);
  myfile.write(z);
  myfile.write(f);
  myfile.write(g);
  myfile.write(h);
  myfile.write(c);

  return 0;
}


If you compile this under Windows, make sure the project type is set to "Win32 Console App." What benefits does this class provide to its clients?

It allows the user to manually debug in order to see which function and thus which parameters caused the member function to fail.
It checks all return values

Page : << Previous 2  Next >>