Advertisement

More about C++ classes

March 04, 2004

Dan_Saks-March 04, 2004

Click here for reader response to this article

Class constructors guarantee object initialization. Const member functions protect objects from spurious changes.

An abstract type is a type that's packaged to separate its functional behavior from its implementation. Specifically, an abstract type is a data structure along with a set of basic operations on that structure. That set must be designed in a way that enables you to use the type without knowing how it's implemented.

In my last column1, I started to show how classes in C++ provide support for abstract types. I used the example of a class for a character ring buffer but didn't finish the example. After a brief recap, I'll fill in the remaining pieces of that class.

A ring buffer as an abstraction
A character ring buffer is a first-in-first-out data structure. You insert characters at the buffer's back end and remove them from the front end.

In another column2, I implemented the ring buffer in C as a struct named ring_buffer, along with various functions that perform fundamental ring_buffer operations. Here are those functions:

  • rb_start(&rb) initializes ring_buffer rb
  • rb_empty(&rb) returns true if and only if ring_buffer rb has no elements in it
  • rb_front(&rb) returns the character at rb's front end
  • rb_pop_front(&rb) removes the character from rb's front end
  • rb_push_back(&b, c) appends character c to rb's back end
The ring_buffer struct definition appears in a header called ring_buffer.h, shown in Listing 1, along with the inline function definitions. All the functions except one are short enough to define as inline functions in the same header. (I explained what inline functions are and why they often belong in headers in an earlier column3.) Only rb_push_back is too long to define as inline. I declared it in the header and defined it in a corresponding source file, ring_buffer.c, appearing in Listing 2.

Listing 1: The declarations for a ring buffer in C

// ring_buffer.h

#ifndef RING_BUFFER_INCLUDED
#define RING_BUFFER_INCLUDED

enum { rb_size = 32 };
typedef struct ring_buffer ring_buffer;
struct ring_buffer
    {
    char array[rb_size];
    int head, tail;
    };
inline
void rb_start(ring_buffer *b)
    {
    b->head = b->tail = 0;
    }

inline
bool rb_empty(ring_buffer const *b)
    {
    return b->head == b->tail;
    }

inline
char rb_front(ring_buffer const *b)
    {
    return b->array[b->head];
    }

inline
void rb_pop_front(ring_buffer *b)
    {
    if (++b->head >= rb_size)
      b->head = 0;
    }

void rb_push_back(ring_buffer *b, char c);

#endif

Listing 2: The definition for the noninline ring buffer function

// ring_buffer.cpp

#include "ring_buffer.h"

void rb_push_back(ring_buffer *b, char c)
    {
    int new_tail = b->tail;
    if (++new_tail >= rb_size)
        new_tail = 0;
    if (new_tail != b->head)
        {
        b->buffer[b->tail] = c;
        b->tail = new_tail;
        }
    }

Listing 3: The declarations for a ring buffer class in C++

// ring_buffer.h

#ifndef RING_BUFFER_INCLUDED
#define RING_BUFFER_INCLUDED

enum { rb_size = 32 };

typedef class ring_buffer ring_buffer;
class ring_buffer
    {
public:
    ring_buffer();
    bool empty() const;
    char front() const;
    void pop_front();
    void push_back(c);
private:
    char array[rb_size];
    int head, tail;
    };

inline
ring_buffer::ring_buffer()
    {
    head = tail = 0;
    }

inline
bool ring_buffer::empty() const
    {
    return head == tail;
    }

inline
char ring_buffer::front() const
    {
    return array[head];
    }

inline
void ring_buffer::pop_front()
    {
    if (++head >= rb_size)
        head = 0;
    }

#endif

A ring buffer class
Listing 3 shows the C++ version of the ring_buffer header file, with ring_buffer as a class instead of a struct. A C++ class has the same basic structure as a C struct. A C++ class can contain everything that a C struct can, plus some other things, including:

  • the access specifiers, public and private
  • members other than data members
Remember that classes are for implementing abstract types. An abstract type provides functionality while hiding nasty implementation details. The public members of a class represent the interface through which other parts of the program can employ that functionality. The private members represent the hidden details. Every part of a program that uses the ring_buffer class can access ring_buffer public members, but only ring_buffer's members can access ring_buffer's private members.

Each access specifier, public or private, can appear more than once and in any order. One appearance of each is usually enough. Each access specifier applies to all the members that follow it, until the next access specifier or the end of the class. Any member (function or data) can be public or private, but well-written classes, like the ring_buffer class, generally have public functions and private data.

The ring_buffer class contains three private data members—array, head, and tail—the same three data members that appeared in the original C struct. The class also contains five public member functions, which correspond one-to-one with the rb_ functions declared in the C header:

  • ring_buffer() initializes a ring_buffer
  • empty() returns true if and only the ring_buffer has no elements in i.
  • front() returns the character at the ring_buffer's front end
  • pop_front() removes the character from the ring_buffer's front end
  • push_back(c) appends character c to the ring_buffer's back end
Each of these functions has the same name, less the rb_ prefix, and the same functionality as its corresponding C function, except the function named ring_buffer, which corresponds to rb_start. Every member function except push_back is defined as an inline function in the header. The noninline definition for push_back is in the separate source file ring_buffer.cpp, shown in Listing 4.

Listing 4: The definition for the noninline ring buffer member function

// ring_buffer.cpp

#include "ring_buffer.h"

void ring_buffer::push_back(char c)
    {
    int new_tail = tail;
    if (++new_tail >= rb_size)
        new_tail = 0;
    if (new_tail != head)
        {
        buffer[tail] = c;
        tail = new_tail;
        }
    }

Notice that the access specifiers and members functions don't occupy storage in each ring_buffer object. Only the data members occupy storage. Thus, a ring_buffer object occupies the same storage whether it's implemented as a class or a struct.

Constructors
A C program that uses a ring_buffer should call rb_start to initialize that buffer before it uses the buffer for anything else. If the buffer is declared local to a function, then the call to rb_start should appear as one of the first statements in the function, as in:

int f(char *s)
    {
    ring_buffer tx;
    rb_start(&tx);
    ...
    }

If the buffer is global, you don't need to call rb_start, but that's just because this particular implementation lets you get away with it. C programs initialize global objects to zero by default, and that's all this version of rb_start does. If rb_start initialized the ring_buffer's data members to nonzero values, you couldn't omit the call.

If needed, the call to rb_start should appear somewhere among the first statements in main, as in:

ring_buffer tx;
...

int main()
    {
    rb_start(&tx);
    ...
    }

Alternatively, the call could be from an initialization function called somewhere near the start of main.

Forgetting to call initialization functions like rb_start is an easy thing to do. Improper initialization can lead to subtle, latent bugs that can go undiscovered for years.

In C++, you can declare initialization functions as special members called constructors. The beauty of constructors is that they provide guaranteed initialization. You don't write calls to constructors. Whenever you declare an object with a class type, the compiler automatically plants a call to the object's constructor at the right place in the program. That's good. One less thing.4

In C++, a constructor is simply any class member function that has the same name as its class. In the ring_buffer class, it's the function named ring_buffer. Although this particular constructor has no parameters, a constructor can have parameters just like any other function. However, a constructor can't have a return type—not even void. Since you can't call a constructor, you have no opportunity to capture a return value.

When you declare a ring_buffer local to a function in a C++ program, the compiler generates a call to the constructor immediately after the declaration. For example: int f(char *s)
    {
 ring_buffer tx;
    // call constructor for tx here
    ...
    }

For a global ring_buffer object, the compiler generates a call to the constructor somewhere in the program startup code.

When you write classes with constructors, you gain the security of knowing that your objects will be initialized properly, but you give up some control over the order of initialization for global objects. If you need precise control, you can always write initialization functions that aren't constructors and call them explicitly.

Const member functions
Each rb_ function in the C implementation has a parameter that points to the ring_buffer object to which the function applies. For example, calling:

rb_pop_front(&tx);

passes the address of tx to to rb_pop_front, which in turn discards a character from the front of that ring_buffer.

None of ring_buffer class member functions looks like it has that parameter, but in effect, they all do. For each ring_buffer member function, the compiler automatically declares a parameter:

ring_buffer *this

For example, when it encounters a call such as:

tx.pop_front();

the compiler generates code that passes the address of tx as the value of this. Thus, the call generates code that's essentially identical to the C code generated by calling:

rb_pop_front(&tx);

Either way, the call passes a pointer to a ring_buffer object.

The definition of the pop_front member function appears in Listing 3 as:

void ring_buffer::pop_front()
    {
    if (++head >= rb_size)
      head = 0;
    }

When it compiles this code, the compiler determines that head is a member of the ring_buffer class. It compiles the references to head as if you had written them as this->head. It fact, you can write them that way in the code. Writing pop_front as:

void ring_buffer::pop_front()
    {
    if (++this->head >= rb_size)
      this->head = 0;
    }

produces the same object code as before. This function body is identical to the body of rb_pop_front in Listing 1, except it uses this instead of rb. However, the prevailing practice among C++ programmers, with which I agree, is to omit this-> whenever possible.

The rb_pop_front and rb_push_back functions each have a parameter declared as:

ring_buffer *rb

That is, each function treats *rb as a nonconstant object, which it can modify. After all, neither function can do what it's supposed to do unless it can make changes to its buffer.

In contrast, rb_empty simply returns true if the ring_buffer is empty, and false otherwise. It has no need to change the buffer, and so it shouldn't. Similarly, rb_front simply returns the buffer's front character. It has no need to change the buffer either. Therefore, each function has a parameter declared as:

ring_buffer const *rb

In other words, each function treats *rb as a constant object, which it can't modify.

Once again, the member functions in the ring_buffer class don't declare the rb parameter. The compiler automatically declares a parameter named this instead. By default, this is a pointer to a nonconstant object. If you want a member function's this parameter to point to a constant object, you must declare the member function as a const member function by placing the keyword const after the parameter list in the function heading, as in:

bool empty() const;
char empty() const;

In effect, a const member function promises that it will not modify the object to which it applies.

Moving on
There's a lot more to C++ than the features that I've explained thus far. I'll introduce other C++ features as needed in the future. I'm not going to abandon those of you who are still wedded to C. Although I'll continue discussing programming techniques that are valuable to both C and C++ programmers, I just won't hold back from showing you how much better the techniques can be applied in C++.

Dan Saks is president of Saks & Associates, a C/C++ training and consulting company. You can write to him at dsaks@wittenberg.edu.

Endnotes

  1. Saks, Dan. "C++ Classes as Abstractions," Programming Pointers, Embedded Systems Programming, January 2004, p. 29.
  2. Saks, Dan. "Abstract Types Using C," Programming Pointers, Embedded Systems Programming, November 2003, p.39.
  3. Saks, Dan. "Incomplete Types as Abstractions," Programming Pointers, Embedded Systems Programming, December 2003, p.43.
  4. Gump, Forrest. "...And so then I got a call from him saying we don't have to worry about money no more. And I said, 'That's good. One less thing.'"


Reader Response

This has to be the most clear and concise article I've seen thus far on C++ classes.

Mr. Saks communicates very efficiently. Please post more articles like this one.

Alma Duran

Loading comments...