Skip to main content

Ring Buffer Series Part 2 - Implementation

class In Part 1 we covered the fundamental building blocks: classes, functions, constructors and how to organize code into header and implementation files. We created a simple Person class to illustrate these concepts and now it is time to apply what we learned to our main project: The RingBuffer. Remember, our ring buffer needs to do a few key things: store elements, track where to add the next one, and know when it’s full. Let’s begin by creating a class.

#ifndef RINGBUFFER_H
#define RINGBUFFER_H

class RingBuffer
{
public:
    RingBuffer();       //Constructor
    ~RingBuffer();      //Destructor
    
    void print_buffer() const;                  //Outputs the buffers contents to the screen
    void push_back(unsigned int new_element);   //Adds a new element into the buffer
    void pop_front();                           //Deletes the oldest element from the buffer
    bool empty() const;                         //Is the buffer empty
    bool full() const;                          //Is the buffer full
    unsigned int front() const;                 //Retrieves the oldest element
    unsigned int size() const;                  //Retrieves the number of elements in the buffer

private:
    unsigned int buffer_[8];                    //The actual buffer container, in this case an array.
    unsigned int head_;                         //Index of oldest element (read position)
    unsigned int tail_;                         //Index of next write position
    unsigned int count_;                        //Total number of elements in the buffer
};

#endif // RINGBUFFER_H

Perhaps you are wondering what these #ifndef, #define, #endif are. To answer that there are a few things we need to talk about first.

Preprocessor Directives

A preprocessor directive is a special instruction that run as a separate step before compilation. Preprocessor directives are a “find-and-replace” tool that makes changes to the source code before it is compiled. For example #include <vector> tells the preprocessor to take the contents of a file called vector and paste them into the current file. What I am using in the RingBuffer example is a different set of preprocessor directives known as compiler guards, header guards or include guards.

Compiler Guards

The combination of #ifndef, #define, #endif prevent the preprocessor from copying a file more than once, which happens a lot in larger projects. if we don’t use compiler guards, then a header file could be copied more than once creating code duplication errors. Note that compiler guards are commonly placed in header files.

Defining the Buffer Methods

Constructor

RingBuffer::RingBuffer(): buffer_{}, head_{}, tail_{}, count_{} {
    
}

Notice that I have put the variables to the right of the constructor along with {}. This is called a constructor initialization list and in C++ we do things this way for a couple reasons. In C++ when execution reaches a class contructor’s body, the class members have already been created if we were to initialize the values in the old fashion way as follows:

RingBuffer::RingBuffer() {
    head_ = 0; 
    tail_ = 0;
    count_ = 0;
}

However in C++, is known as constructor assignment, so essentially what happens is that the program will create the member variables of RingBuffer and then will construct-assign them. In smaller programs, this is fine, but when we are talking about real world programs, this could have a significant performance impact. When variables are instead contruct-initialized, then the compiler can initialize member variables at creation time, meaning that the assignment step is no longer needed.

push_back(unsigned int new_element)

void RingBuffer::push_back(unsigned int new_element)
{
    if(count_ == 8){
        // Buffer full, advance head_ to abandon oldest element
        head_++;
        if(head_ >= 8){
            head_ = 0;
        }
    }else{
        count_++;
    }

    buffer_[tail_] = new_element;
    tail_++;
    if(tail_ >= 8){
        tail_ = 0;
    }
}

When adding a new element to the buffer it is important to always ask two questions: Where do we write, and is the buffer full?. We always write at tail. That’s the next available slot, but before writing, we check if the buffer is already full. If it is full, then we have to overwrite the oldest element and to do that we need to advance head_ first, if we don’t then head would point to a slot that contains the newest element, breaking our ability to read in the correct order. After writing, tail_ advances to the next slot. When it reaches 8 (last index), it wraps back to 0. This is why it is called a ring or a circular buffer. empty_buffer adding_value updating_buffer adding_another_value

What if the buffer becomes full?

Once count_ reaches 8, every new push overwrites the oldest value. Now head_ and tail_ move together like two runners on a track, with tail_ always one lap ahead. full_buffer replacing_value tail_wraps_buffer adding_another_value

pop_front()

void RingBuffer::pop_front()
{
    if(count_ == 0){
        // Buffer empty, nothing to remove
        return;
    }

    head_++;
    if(head_ >= 8){
        head_ = 0;
    }
    count_--;
}

Removing an element from the buffer is simpler. All we need to do is advance head_ to the next index. Notice that we don’t actually delete anything from the buffer. The value stays in the array. We just move head_ and decrement count_. That slot is now considered empty for all intents and purposes and will eventually be overwritten by a future push_back(). before pop after pop

void RingBuffer::print_buffer() const
{
    unsigned int index{head_};
    for(unsigned int i = 0; i < count_; i++){
        std::cout << buffer_[index] << ' ';
        index++;
        if(index >= 8){
            index = 0;
        }
    }
    std::cout << '\n';
}

This function prints only the valid elements in the buffer, from oldest to newest. If the buffer is empty, nothing is printed.

bool RingBuffer::empty()

bool RingBuffer::empty() const
{
    return count_ == 0;
}

This function will check if count_ is zero, if it is, then that means the buffer is empty.

bool RingBuffer::full()

bool RingBuffer::full() const
{
    return count_ == 8;
}

This function will check if count_ is 8, if it is, then that means the buffer is full.

unsigned int RingBuffer::size()

unsigned int RingBuffer::size() const
{
    return count_;
}

This function will return the number of elements stored in the buffer.

unsigned int RingBuffer::front()

unsigned int RingBuffer::front() const
{
    assert(!empty());
    return buffer_[head_];
}

This function will return the oldest element in the buffer.

Putting Everything Together

The following is what RingBuffer.cpp will end up looking like.

#include "ringbuffer.h"
#include <iostream>
#include <cassert>

RingBuffer::RingBuffer(): buffer_{}, head_{}, tail_{}, count_{} {

}

RingBuffer::~RingBuffer(){}

void RingBuffer::print_buffer() const
{
    unsigned int index{head_};
    for(unsigned int i = 0; i < count_; i++){
        std::cout << buffer_[index] << ' ';
        index++;
        if(index >= 8){
            index = 0;
        }
    }
    std::cout << '\n';
}

void RingBuffer::push_back(unsigned int new_element)
{
    if(count_ == 8){
        //Buffer full - We are about to overwrite oldest element
        head_++;
        if(head_ >= 8){
            head_ = 0;
        }
    }else{
        count_++;
    }

    buffer_[tail_] = new_element;
    tail_++;
    if(tail_ >= 8){
        tail_ = 0;
    }
}

void RingBuffer::pop_front()
{
    if(count_ == 0){
        // Buffer empty, nothing to remove
        return;
    }

    head_++;
    if(head_ >= 8){
        head_ = 0;
    }
    count_--;
}

bool RingBuffer::empty() const
{
    return count_ == 0;
}

bool RingBuffer::full() const
{
    return count_ == 8;
}

unsigned int RingBuffer::front() const
{
    assert(!empty());
    return buffer_[head_];
}

unsigned int RingBuffer::size() const
{
    return count_;
}

Seeing it in action:

Now that we have this RingBuffer class defined, let us take it for a test drive.

#include "ringbuffer.h"
#include <iostream>

int main()
{
    RingBuffer buffer_{};
    buffer_.push_back(0);
    buffer_.print_buffer();
    std::cout << std::boolalpha; //Enable printing "true"/"false"
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(1);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(2);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(3);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(4);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(5);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(6);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(7);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(8);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    buffer_.push_back(9);
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    std::cout << "Buffer is full: " << buffer_.full() << '\n';
    buffer_.pop_front();
    buffer_.print_buffer();
    std::cout << "Buffer size: " << buffer_.size() << '\n';
    std::cout << "Buffer is empty: " << buffer_.empty() << '\n';
    std::cout << "Buffer is full: " << buffer_.full() << '\n';
    std::cout << "Oldest element in buffer: " << buffer_.front() << '\n';
}

When running this code, the output is as follows:

0 
Buffer size: 1
0 1 
Buffer size: 2
0 1 2 
Buffer size: 3
0 1 2 3 
Buffer size: 4
0 1 2 3 4 
Buffer size: 5
0 1 2 3 4 5 
Buffer size: 6
0 1 2 3 4 5 6 
Buffer size: 7
0 1 2 3 4 5 6 7 
Buffer size: 8
1 2 3 4 5 6 7 8 
Buffer size: 8
2 3 4 5 6 7 8 9 
Buffer size: 8
Buffer is full: true
3 4 5 6 7 8 9 
Buffer size: 7
Buffer is empty: false
Buffer is full: false
Oldest element in buffer: 3

Next Steps

In the next chapter, we’ll make the buffer work with any data type using templates and give it a dynamic size. After that, we’ll tackle iterators, the mechanism that will let our RingBuffer work with range-based for loops and STL algorithms.