Advertisement

C++ classes as abstractions

December 11, 2003

Dan_Saks-December 11, 2003

C++ classes use the keywords public and private to preserve the integrity of abstractions more effectively than anything you can do with structs in C.

Using abstract types can help you write programs that are more readable and maintainable and therefore more likely to be correct. Using abstract types can even help you write more efficient programs by making it easier to replace an inefficient implementation with an efficient one.

An abstract type is one 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 so that you can use the type without knowing how it's implemented.

As I explained in an earlier column, you can implement an abstract type in C as a struct and a set of related functions. The weakness with this approach is that C compilers don't give you much help in catching programming errors that undermine the abstraction.

You can get better compile-time enforcement of abstractions in C by using incomplete types, as I also explained in another recent column. Unfortunately, using incomplete types as abstractions often incurs performance penalties. It prevents you from using inline functions and usually adds pointers to your data structures.

This month I'll start to show you how classes in C++ avoid these problems.

A ring buffer as an abstraction
As in my earlier columns, I'll use a ring buffer containing characters as an example of an abstract type. A ring buffer is a first-in-first-out data structure. You insert data at the buffer's back end and remove it from the front.

I previously implemented the ring buffer in C as a struct named ring_buffer, along with various functions that performed fundamental ring_buffer operations. These functions included:

  • rb_empty(&rb), which returns true if and only if ring_buffer rb has no elements in it
  • rb_front(&rb), which returns the character at rb's front end
  • rb_pop_front(&rb), which removes the character from rb's front end

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

I defined the ring_buffer struct and functions in a header called ring_buffer.h, which appears in Listing 1. This listing declares a function that didn't appear in earlier columns:

  • rb_push_back(&b, c), which appends character c to rb's back end

This function is too long to define as inline. Thus, it's merely declared in the header and defined in a corresponding source file, ring_buffer.c, in Listing 2.

Listing 2: The definition for the non-inline 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;
        }
    }

What classes do for you
A careful look at the header in Listing 1 reveals why C doesn't support abstract types very well. The interface to an abstract type is the information a programmer must know to be able to use the type. For ring_buffer, the interface is the type name, ring_buffer, along with the declarations of the functions that provide the fundamental ring buffer operations.

Ideally, a header should specify the entire interface for the type, but no more. Unfortunately, the ring_buffer header, like many C headers, contains implementation details in addition to the interface. Specifically, the header defines the ring_buffer struct members array, head, and tail, as well as the symbolic constant rb_size. These implementation details are in the header so that the compiler can take advantage of inline function definitions to lower the cost of the abstraction. As I explained last month, you can remove these implementation details from the header by using an incomplete type, but this approach incurs performance penalties.

Thus, if you want to implement efficient abstractions, you have to place implementation details, not just interface specifications, in header files. When you do this using structs in C, the compiler can't distinguish interface from implementation. It can't tell when the user of the abstraction violates the abstraction by accessing implementation details, and so it can't diagnose such violations.

Proper language support for abstraction requires a language feature for distinguishing implementation details from interface specifications, even when they appear to be thrown together in a single header. That's exactly what a C++ class provides.

A ring buffer class
The syntax for classes in C++ is an extension of syntax for structs in C. The basic form of a class is:

class class-name  
    {
    member-specification;
    member-specification;
    ...
    };

A class name, like a struct name, is a tag. C++ lets you use tag names as type names, almost as if the compiler automatically generates:

typedef class class-name class-name;

For example, given:

class ring_buffer
    {
    ...
    };

you can declare a ring_buffer object as:

ring_buffer tx;

You need not write the keyword class in front of the class name, as in:

class ring_buffer tx;

Unfortunately, C++ let's you hide a class name with a non-type name, so I recommend defining a typedef anyway, as in:

typedef class ring_buffer ring_buffer;

The member specifications in a class can declare data members, just like they can in a struct. However, unlike a C struct, a C++ class can declare members as private so that code outside the class can't access the members directly. For example:

class ring_buffer
    {
private:
    char array[rb_size];
    int head, tail;
    };

The compiler will reject any attempt to access a private member outside the class, as in:

ring_buffer tx;
...
tx.head = 0; // error: tx.head is private

In general, you can't implement the fundamental ring_buffer operations as ordinary C functions, because such functions can't access private members, as in:

void rb_pop_front(ring_buffer *b)
    {
    if (++b->head >= rb_size) // error: head...
        b->head = 0; // error: ...is private
    }

Rather, you implement the ring_buffer operations as functions that are members of the ring_buffer class.

Here's the class with pop_front declared as a member function:

class ring_buffer
    {
public:
    void pop_front();
private:
    char array[rb_size];
    int head, tail;
    };

The keyword public indicates that code outside the ring_buffer class can call the pop_front member function. Public members represent the interface to the class, whereas private members represent implementation details.

The keywords public and private are known as access specifiers because they specify whether code outside the class can access class members. Each access specifier can appear more than once and in any order. An 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 generally have public functions and private data.

The declaration of the pop_front member function differs slightly from the declaration of the corresponding C function, rb_pop_front. First, the function names are different. Each class defines a distinct scope, so the member function name doesn't need a prefix such as rb_ to keep it distinct from other global names. Second, the member function is declared with an empty parameter list.

The rb_pop_front function in the C implementation has a single parameter that points to the ring_buffer object to which the function will apply. A call to the function looks like:

rb_pop_front(&tx);

where tx is a ring_buffer object.

The pop_front member of the ring_buffer class doesn't look like it has that parameter, but it does. The compiler declares the parameter automatically. The code behaves as if that parameter were declared as:

ring_buffer *this

A call to the member function looks like:

tx.pop_front();

The compiler generates code for the call that passes the address of the object to the left of the dot (tx) as the value of implicitly declared parameter (this). Thus, calling rb.pop_front() generates code that's essentially identical to the code generated by calling rb_pop_front(&tx), because they both pass a pointer to a ring_buffer object.

Not all member functions have empty parameter lists. For example, in the C implementation, the rb_push_back function has two parameters:

void rb_push_back(ring_buffer *b, char c);

In the C++ class, the push_back member function is declared as:

class ring_buffer
    {
public:
    void push_back(char c);
    ...

It still has two parameters. One is the explicitly declared parameter, c. The other is the implicitly declared pointer this.

Thus far, the ring_buffer class definition looks like:

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

This class declares member functions, but it doesn't define them. That is, it doesn't provide bodies for the functions. The function definitions typically appear outside the class. For example, the definition for member pop_front looks like:

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

This definition specifies the function name as ring_buffer::pop_front. A name in this form is called a fully qualified name. The :: operator (usually pronounced "colon colon") joins the class name to the member name. If you didn't use the fully qualified name, as in:

inline
void pop_front()
    {
    ...
    }

the compiler would take this as the definition of some non-member function, unrelated to the class.

If the member function definition is defined as inline, it should appear in the same header as the class definition. Otherwise, it should appear in a separate source file.

Listing 3 shows a new version of the ring_buffer header file, with ring_buffer as a class instead of a struct. This class is not yet finished, but it shows all the pieces I've presented thus far. The class declares two member functions, pop_front and push_back. The pop_front function is defined as inline later in the same header. The push_back function is defined as a non-inline in the separate source file in Listing 4.

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:
    void pop_front();
    void push_back(c);
private:
    char array[rb_size];
    int head, tail;
    };

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

#endif

Listing 4: The definition for the non-inline 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;
        }
    }

I'll fill in the missing pieces of the class in my next column.

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, "Abstract Types Using C," Embedded Systems Programming, November 2003, p. 39.
2. Saks, Dan, "Incomplete Types as Abstractions," Embedded Systems Programming, December 2003, p. 43.
3. Saks, Dan, "Tag vs. Type Names," Embedded Systems Programming, October 2002, p. 7.

Loading comments...