diff --git a/src/Util/RingBuffer.php b/src/Util/RingBuffer.php index 7852f53db..9778e9d55 100644 --- a/src/Util/RingBuffer.php +++ b/src/Util/RingBuffer.php @@ -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 + * @var array */ - private $buffer; + private $buffer = []; /** * @var int @@ -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); } /** @@ -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; @@ -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]; @@ -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]; } /** @@ -162,7 +157,7 @@ public function peekBack() */ public function peekFront() { - if ($this->isEmpty()) { + if ($this->count === 0) { return null; } @@ -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; @@ -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 */ + 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 */ + return \array_slice($this->buffer, $this->head, $this->count); + } + + /** @var array */ + return array_merge( + \array_slice($this->buffer, $this->head), + \array_slice($this->buffer, 0, $this->tail) + ); } /** diff --git a/tests/Util/RingBufferTest.php b/tests/Util/RingBufferTest.php index 8f184e449..9c9db4b80 100644 --- a/tests/Util/RingBufferTest.php +++ b/tests/Util/RingBufferTest.php @@ -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);