In embedded systems, network programming, or real-time data stream processing, we often need a fixed-capacity queue structure that supports overwrite. A typical representative of this structure is the Circular Buffer.
This article will guide you through implementing a modern C++ style circular buffer from scratch, supporting:
✅ Compile-time fixed capacity✅ Automatic overwrite of the oldest data✅ Safe index access✅ Iterator traversal✅ Fully <span>constexpr</span> usable
And at the end, we will provide the complete source code 👇
🧠 What is a Circular Buffer?
A Circular Buffer (also known as a Ring Buffer) is a queue structure that utilizes an array with its ends connected. When the write pointer reaches the end of the array, it automatically wraps around to the start, cycling through a fixed memory like a “ring”.
Typical applications include:
-
Audio stream buffering;
-
Network packet caching;
-
Real-time logging systems;
-
Data samplers.
Unlike <span>std::queue</span> or <span>std::vector</span>, the memory of a circular buffer does not grow dynamically, with fixed capacity, stable performance, and no memory allocation.
🧩 Core Design Philosophy
We use a fixed-size <span>std::array</span> to store data while maintaining three key members:
size_type head_ = 0; // Points to the oldest data
size_type tail_ = 0; // Points to the newest data
size_type size_ = 0; // Current number of elements
The core logic is as follows:
-
push_back(): If not full, write directly; if full, overwrite the oldest data;
-
pop_front(): Pop the oldest data;
-
operator[]: Access by logical position;
-
full() / empty(): Check status.
This structure allows each insertion and deletion to be completed in O(1) time with almost no locking, making it very suitable for real-time systems.
💡 Introducing Modern C++ Features
Compared to traditional C implementations, we fully utilize the language features of C++20:
| Feature | Function |
|---|---|
<span>requires(N > 0)</span> |
Ensures template parameter validity |
<span>std::array</span> |
Avoids raw pointer management |
<span>std::reference_wrapper</span> |
Safely holds container references |
<span>constexpr</span> |
Executable at compile time |
| Custom iterators | Support for <span>range-based for</span> traversal |
For example:
constexpr reference operator[](size_type pos) { return data_[(head_ + pos) % N];}
This indexing method automatically handles the “wrap-around” issue without cumbersome checks.
🧭 Custom Iterators
To allow <span>circular_buffer</span> to be traversed with <span>for (auto &x : buffer)</span>, we must define custom iterators.
We designed the <span>circular_buffer_iterator</span>, implementing a complete random access interface:
-
<span>operator++</span>,<span>operator--</span> -
Comparison operators
<span><</span>,<span><=</span>,<span>></span> -
Offset operations
<span>operator+</span>,<span>operator-</span> -
Element access
<span>operator*</span>,<span>operator-></span>
The core logic is as follows:
const_reference operator*() const { return buffer_.get().data_[ (buffer_.get().head_ + index_) % buffer_.get().capacity() ];}
By calculating <span>head_ + index_</span> for the actual position and then taking the modulus to achieve circular indexing. Thus, <span>begin()</span> starts from <span>head_</span>, while <span>end()</span> points to the logical position after the tail.
Example traversal code:
circular_buffer<int, 5> buf;for (int i = 0; i < 7; ++i) buf.push_back(i);
for (auto &x : buf) { std::cout << x << ' ';}// Output: 2 3 4 5 6
Even with data overwriting, iteration remains safe.⚙️ Complete interface overview
constexpr void push_back(const T& value);
constexpr T pop_front();
constexpr reference front();
constexpr reference back();
constexpr reference operator[](size_type pos);
constexpr size_type size() const noexcept;
constexpr bool full() const noexcept;
constexpr void clear() noexcept;
iterator begin();
iterator end();
This gives the <span>circular_buffer</span> an experience similar to <span>std::deque</span>, but lighter, faster, and safer.
🚀 Practical Example: Fixed Capacity Log Buffer
Suppose we need a module that only keeps the most recent 100 logs:
circular_buffer<std::string, 100> logs;
void log_message(std::string msg) { logs.push_back(std::move(msg));}
void print_recent() { for (auto &line : logs) std::cout << line << '\n';}
The memory never grows, so there is no need to worry about excessive log space usage. At the same time, the order during traversal is correct and the logic is clear.
🧩 Design Highlights Summary
✅ No dynamic allocation: Uses <span>std::array</span> for fixed memory.✅ Automatic overwrite: No need to manually clean old data.✅ Type safety: Supports any copyable type.✅ Iterator friendly: Supports STL-style traversal and algorithms.✅ constexpr support: Suitable for compile-time or embedded development.
📦 Complete Source Code
The project source code is implemented in a single header file, compatible with C++20.
#include <cstddef>
#include <array>
#include <algorithm>
template<typename T, std::size_t N>requires(N > 0)
class circular_buffer_iterator;
template<typename T, std::size_t N>requires(N > 0)
class circular_buffer {
public:
using value_type = T;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
using reference = value_type&;
using const_reference = const value_type&;
using pointer = value_type*;
using const_pointer = const value_type*;
using iterator = circular_buffer_iterator<T, N>;
using const_iterator = circular_buffer_iterator<const T, N>;
public:
constexpr circular_buffer() = default;
constexpr circular_buffer(const value_type(&values)[N])
: size_(N), tail_(N - 1) {
std::copy(std::begin(values), std::end(values), data_.begin());
}
constexpr circular_buffer(const_reference v)
: size_(N), tail_(N - 1) {
std::fill(data_.begin(), data_.end(), v);
}
constexpr size_type size() const noexcept { return size_; }
constexpr size_type capacity() const noexcept { return N; }
constexpr bool empty() const noexcept { return size_ == 0; }
constexpr bool full() const noexcept { return size_ == N; }
constexpr void clear() noexcept { size_ = 0; head_ = 0; tail_ = 0; }
constexpr reference operator[](const size_type pos) {
return data_[(head_ + pos) % N];
}
constexpr const_reference operator[](const size_type pos) const {
return data_[(head_ + pos) % N];
}
constexpr reference at(const size_type pos) {
if(pos < size_) return data_[(head_ + pos) % N];
throw std::out_of_range("Index is out of range!");
}
constexpr const_reference at(const size_type pos) const {
if(pos < size_) return data_[(head_ + pos) % N];
throw std::out_of_range("Index is out of range!");
}
constexpr reference front() {
if(size_ > 0) return data_[head_];
throw std::logic_error("Buffer is empty");
}
constexpr const_reference front() const {
if(size_ > 0) return data_[head_];
throw std::logic_error("Buffer is empty");
}
constexpr reference back() {
if(size_ > 0) return data_[tail_];
throw std::logic_error("Buffer is empty");
}
constexpr const_reference back() const {
if(size_ > 0) return data_[tail_];
throw std::logic_error("Buffer is empty");
}
constexpr void push_back(const T& value) {
if(empty()) {
data_[tail_] = value;
++size_;
} else if(!full()) {
data_[++tail_] = value;
++size_;
} else {
head_ = (head_ + 1) % N;
tail_ = (tail_ + 1) % N;
data_[tail_] = value;
}
}
constexpr void push_back(T&& value) {
if(empty()) {
data_[tail_] = value;
++size_;
} else if(!full()) {
data_[++tail_] = std::move(value);
++size_;
} else {
head_ = (head_ + 1) % N;
tail_ = (tail_ + 1) % N;
data_[tail_] = std::move(value);
}
}
constexpr T pop_front() {
if(empty()) throw std::logic_error("Buffer is empty");
size_type index = head_;
head_ = (head_ + 1) % N;
--size_;
return data_[index];
}
iterator begin() {
return iterator(*this, 0);
}
iterator end() {
return iterator(*this, size_);
}
const_iterator begin() const {
return const_iterator(*this, 0);
}
const_iterator end() const {
return const_iterator(*this, size_);
}
private:
friend circular_buffer_iterator<T, N>;
std::array<value_type, N> data_;
size_type head_ = 0;
size_type tail_ = 0;
size_type size_ = 0;
};
template<typename T, std::size_t N>requires(N > 0)
class circular_buffer_iterator {
public:
using self_type = circular_buffer_iterator<T, N>;
using value_type = T;
using reference = value_type&;
using const_reference = const value_type&;
using pointer = value_type*;
using const_pointer = const value_type*;
using iterator_category = std::random_access_iterator_tag;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
public:
explicit circular_buffer_iterator(circular_buffer<T, N>& buffer, const size_type index)
: buffer_(buffer), index_(index) {}
self_type& operator++() {
if(index_ >= buffer_.get().size())
throw std::out_of_range("Iterator cannot be incremented past the end of the range");
++index_;
return *this;
}
self_type operator++(int) {
self_type temp = *this;
++*this;
return temp;
}
bool operator==(const self_type& other) const {
return compatible(other) &&& index_ == other.index_;
}
bool operator!=(const self_type& other) const {
return !(*this == other);
}
const_reference operator*() const {
if(buffer_.get().empty() || !in_bounds())
throw std::logic_error("Cannot dereference the iterator");
return buffer_.get().data_[(buffer_.get().head_ + index_) % buffer_.get().capacity()];
}
const_pointer operator->() const {
return std::addressof(operator*());
}
reference operator*() {
if(buffer_.get().empty() || !in_bounds())
throw std::logic_error("Cannot dereference the iterator");
return buffer_.get().data_[(buffer_.get().head_ + index_) % buffer_.get().capacity()];
}
pointer operator->() {
return std::addressof(operator*());
}
bool compatible(const self_type& other) const {
return buffer_.get().data_.data() == other.buffer_.get().data_.data();
}
bool in_bounds() const {
return !buffer_.get().empty() &&&
(buffer_.get().head_ + index_) % buffer_.get().capacity() <= buffer_.get().tail_;
}
self_type& operator--() {
if(index_ <= 0)
throw std::out_of_range("Iterator cannot be decremented before the beginning of the range");
--index_;
return *this;
}
self_type operator--(int) {
self_type temp = *this;
--*this;
return temp;
}
self_type operator+(difference_type offset) const {
self_type temp = *this;
return temp += offset;
}
self_type operator-(difference_type offset) const {
self_type temp = *this;
return temp -= offset;
}
difference_type operator-(const self_type& other) const {
return index_ - other.index_;
}
self_type& operator+=(const difference_type offset) {
difference_type next = (index_ + offset) % buffer_.get().capacity();
if(next >= buffer_.get().size())
throw std::out_of_range("Iterator cannot be incremented past the bounds of the range");
index_ = next;
return *this;
}
self_type& operator-=(const difference_type offset) {
return *this += -offset;
}
bool operator<(const self_type& other) const {
return index_ < other.index_;
}
bool operator>(const self_type& other) const {
return other < *this;
}
bool operator<=(const self_type& other) const {
return !(other < *this);
}
bool operator>=(const self_type& other) const {
return !(*this < other);
}
value_type& operator[](const difference_type offset) {
return *((*this + offset));
}
const value_type& operator[](const difference_type offset) const {
return *((*this + offset));
}
private:
std::reference_wrapper<circular_buffer<T, N>> buffer_;
size_type index_ = 0;
};
👉 You can directly copy the complete implementation above into your project.
🔚 Final Thoughts
This <span>circular_buffer</span> template is not just a container, but also an exercise in thinking about modern C++ design philosophy.
By properly using <span>constexpr</span>, concept constraints, and custom iterators, we can build high-performance, type-safe, zero-allocation modern containers without relying on STL.
If you find this useful, feel free to like, bookmark, or share this article ❤️ Your support is my greatest motivation to continue sharing more high-quality C++ code!