Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions src/Util/RingBuffer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,21 @@

/**
* Ring buffer implementation with a fixed size that will overwrite the oldest elements when at capacity.
* Backed by `SplFixedArray`, which means that it will always have a constant memory footprint while
* also avoiding dynamically resizing.
* This is NOT a copy-on-write data structure. Extra cloning is necessary to achieve this.
* Backed by a regular PHP array that will dynamically grow in size until capacity is reached.
*
* `push` and `peek` operations are O(1).
*
* `toArray` and `drain` are O(n) where n is the count of the buffer.
*
* This implementation will never duplicate arrays unless `toArray` or `drain` is called.
* `toArray` and `drain` are O(n) where n is the count of the buffer. As long as no element was
* overwritten or shifted, both are O(1) because the backing array can be returned as-is
*
* @template T
*/
class RingBuffer implements \Countable
{
/**
* @var \SplFixedArray<T|null>
* @var array<int, T|null>
*/
private $buffer;
private $buffer = [];

/**
* @var int
Expand Down Expand Up @@ -60,7 +57,6 @@ public function __construct(int $capacity)
throw new \RuntimeException('RingBuffer capacity must be greater than 0');
}
$this->capacity = $capacity;
$this->buffer = new \SplFixedArray($capacity);
}

/**
Expand Down Expand Up @@ -110,7 +106,7 @@ public function push($value): void

$this->tail = ($this->tail + 1) % $this->capacity;

if ($this->isFull()) {
if ($this->count === $this->capacity) {
$this->head = ($this->head + 1) % $this->capacity;
} else {
++$this->count;
Expand All @@ -125,7 +121,7 @@ public function push($value): void
*/
public function shift()
{
if ($this->isEmpty()) {
if ($this->count === 0) {
return null;
}
$value = $this->buffer[$this->head];
Expand All @@ -146,12 +142,11 @@ public function shift()
*/
public function peekBack()
{
if ($this->isEmpty()) {
if ($this->count === 0) {
return null;
}
$idx = ($this->tail - 1 + $this->capacity) % $this->capacity;

return $this->buffer[$idx];
return $this->buffer[($this->tail - 1 + $this->capacity) % $this->capacity];
}

/**
Expand All @@ -162,7 +157,7 @@ public function peekBack()
*/
public function peekFront()
{
if ($this->isEmpty()) {
if ($this->count === 0) {
return null;
}

Expand All @@ -174,9 +169,7 @@ public function peekFront()
*/
public function clear(): void
{
for ($i = 0; $i < $this->count; ++$i) {
$this->buffer[($this->head + $i) % $this->capacity] = null;
}
$this->buffer = [];
$this->count = 0;
$this->head = 0;
$this->tail = 0;
Expand All @@ -190,14 +183,28 @@ public function clear(): void
*/
public function toArray(): array
{
$result = [];
for ($i = 0; $i < $this->count; ++$i) {
$value = $this->buffer[($this->head + $i) % $this->capacity];
/** @var T $value */
$result[] = $value;
if ($this->count === 0) {
return [];
}

return $result;
// When we have never overwritten an element, or we did a full revolution, we can
// just return the buffer as-is because it has the correct order
if ($this->head === 0 && $this->count === \count($this->buffer)) {
/** @var array<T> */
return $this->buffer;
}

// If the data doesn't wrap around, we can just return the slice from head to count
if ($this->head + $this->count <= $this->capacity) {
/** @var array<T> */
return \array_slice($this->buffer, $this->head, $this->count);
}

/** @var array<T> */
return array_merge(
\array_slice($this->buffer, $this->head),
\array_slice($this->buffer, 0, $this->tail)
);
}

/**
Expand Down
51 changes: 51 additions & 0 deletions tests/Util/RingBufferTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,57 @@ public function testFixedCapacity(): void
$this->assertEquals(['bar', 'baz'], $buffer->toArray());
}

public function testToArraySnapshotIsNotAffectedByLaterPushes(): void
{
$buffer = new RingBuffer(3);
$buffer->push('foo');
$buffer->push('bar');
$buffer->push('baz');

$snapshot = $buffer->toArray();
$buffer->push('qux');

$this->assertEquals(['foo', 'bar', 'baz'], $snapshot);
$this->assertEquals(['bar', 'baz', 'qux'], $buffer->toArray());
}

public function testToArrayAfterShift(): void
{
$buffer = new RingBuffer(5);
$buffer->push('foo');
$buffer->push('bar');
$buffer->push('baz');

$buffer->shift();

$this->assertEquals(['bar', 'baz'], $buffer->toArray());
}

public function testToArrayWrapped(): void
{
$buffer = new RingBuffer(3);
for ($i = 1; $i <= 7; ++$i) {
$buffer->push($i);
}

$this->assertSame(3, $buffer->count());
$this->assertEquals([5, 6, 7], $buffer->toArray());
}

public function testPushAfterDrain(): void
{
$buffer = new RingBuffer(3);
$buffer->push('foo');
$buffer->push('bar');

$this->assertEquals(['foo', 'bar'], $buffer->drain());

$buffer->push('baz');

$this->assertCount(1, $buffer);
$this->assertEquals(['baz'], $buffer->toArray());
}

public function testClear(): void
{
$buffer = new RingBuffer(5);
Expand Down
Loading