13.3. The queue class¶
A queue is another special purpose container adapter that limits random element access to all parts of the storage. A queue is just another word for a line. Like a stack, a queue restricts element access to the ends. Unlike a stack, a queue allows access to both ends:
New elements can only be added to the "back" of the line
Elements can only be retrieved from the "front" of the line.
Imagine a line at the bank or a store. An orderly queue means that the people who get in line first are the first customers called. This is the guarantee queue enforces. A queue is a FIFO (first-in, first-out) data structure.
The std::queue is a container adapter that gives the programmer the functionality of a queue.
The class template acts as a wrapper to the underlying container - only a specific set of functions is provided. The queue pushes elements on the back of the underlying container, and pops them from the front.
The defining operations of a queue are:
- push
Add a new element to the back (end) of the queue.
- pop
Remove an element from the front (beginning) of the queue.
- front
Get the value of the element at the beginning of the queue.
- back
Get the value of the element at the end of the queue.
Note
Like std::stack it is your responsibility to only call
front(), back(), or pop() when the queue is not empty.
Minor modifications change pop_all() from a function
performing stack operations into one
performing queue operations:
#include <iostream>
#include <queue>
template <typename Container>
void pop_all(Container& q) {
while(!q.empty()) {
std::cout << q.front() << " ";
q.pop();
}
std::cout << "\npopped all from queue\n";
}
The standard library containers std::list and std::deque can be adapted
to create a queue.
13.3.1. Circular queues¶
A circular queue, cyclic buffer, or ring buffer is a data structure that uses a single, fixed-size buffer as if it were connected end-to-end. A ring buffer is a good choice when you need the behavior of a queue and the buffer size can be fixed.
There are many ways to implement this data structure and the following is just an example of one.
Empty
Conceptually, a circular buffer is a closed ring of data.
One element needs to be chosen as the start of the data. We will call
this index head. The head identifies the oldest element currently
stored in the queue.
A ring buffer is initially empty and of some predefined length. For example, this is an 8-element buffer conceptually:
This example stores head and size. It does not store tail
separately. The next available position is computed whenever it is
needed:
For an empty buffer, head == 0 and size == 0, so tail == 0.
When the buffer is full, size == capacity and the formula produces
tail == head. The size value removes the ambiguity that would
otherwise make head == tail mean either empty or full.
The capacity is the maximum number of elements that can be
stored in the buffer.
In this example, the capacity is 8.
The size is the current number of elements used in the buffer.
In our initially empty buffer, the size is 0.
Since there are no true circular sections of memory, it is normal to represent a circular buffer in a normal contiguous linear memory block. An array is a good choice.
Add
Adding one element stores a new value at the computed tail location and
increases size. The next tail is then computed from the unchanged
head and the new size.
And in the array:
The buffer size is now 1.
If two more items are added, the tail moves accordingly. The head does not move as items are added.
The buffer size is now 3.
Remove
Removing an element from the buffer involves
returning the oldest element from the buffer
moving the head
decreasing the buffer size
C++ does not erase the old value from the array when an element is
removed. Once head moves and size decreases, that old slot is
outside the active sequence and can be reused by a later write.
The buffer size is now 2.
Full Buffer
Starting with our buffer containing [B,C], we can add elements until it is completely full.
Adding three more elements produces [B,C,D,E,F] in the active
sequence. The stale A remains in its old slot, but it is not part
of the queue. The state is now head == 1, size == 5, and
tail == (1 + 5) % 8 == 6.
Adding G at index 6 and H at index 7 leaves one unused
logical slot. The state is now head == 1, size == 7, and
tail == (1 + 7) % 8 == 0. The tail has wrapped around to the first
array slot.
One more write stores I at index 0. The buffer is now full:
head == 1, size == 8, and
tail == (1 + 8) % 8 == 1.
The queue now contains B through I in logical order, beginning
at head and wrapping from index 7 to index 0.
If we stored only head and tail, the equal indices would not
distinguish this full state from the empty state. Storing size
makes both states explicit. A design that omits size must reserve
one slot, limiting the maximum number of elements to
capacity - 1.
Different designs could result in different outcomes, there are no hard and fast rules here. I chose this implementation because it is easy to reason about and does not waste a storage slot, at the cost of an additional variable.
What do we do when our buffer is full? At this point, we have choices:
Allow no writes to the buffer until elements are removed.
This is common when it is important to never lose any information, such as when processing keystrokes from the user, or managing a print queue.
In this policy, a write is rejected when
size == capacity.Allow writes to overwrite the oldest elements. The oldest values are lost in favor of new values.
In this policy, the next write uses the derived
tailposition, which equalsheadwhen the buffer is full. After overwriting, bothheadandsizeare updated to describe the retained data.This implementation is used when the oldest information may no longer be important enough by the time the buffer is full.
More to Explore
C++ queue class
MyCodeSchool video: Data structures: introduction to queue
Circular buffer on wikipedia