From 07481396266b10983dac860a61d91b5fdf2a8d75 Mon Sep 17 00:00:00 2001 From: Delacry <45132928+delacry@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:38:05 +0100 Subject: [PATCH] Add copy-on-write when constructing collections from arrays --- src/List/ArrayList/ArrayIndexStore.php | 14 ++- src/Map/IntMap/IntKeyValueStore.php | 16 ++- src/Map/StringMap/StringKeyValueStore.php | 8 +- .../List/Case/AbstractListTestCase.php | 2 + tests/Collection/List/ListCopyOnWrite.php | 111 ++++++++++++++++++ .../IntMap/Case/AbstractIntMapTestCase.php | 2 + tests/Map/IntMap/IntMapCopyOnWrite.php | 111 ++++++++++++++++++ .../Case/AbstractStringMapTestCase.php | 2 + tests/Map/StringMap/StringMapCopyOnWrite.php | 111 ++++++++++++++++++ 9 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 tests/Collection/List/ListCopyOnWrite.php create mode 100644 tests/Map/IntMap/IntMapCopyOnWrite.php create mode 100644 tests/Map/StringMap/StringMapCopyOnWrite.php diff --git a/src/List/ArrayList/ArrayIndexStore.php b/src/List/ArrayList/ArrayIndexStore.php index 1fe0467..e8cca12 100644 --- a/src/List/ArrayList/ArrayIndexStore.php +++ b/src/List/ArrayList/ArrayIndexStore.php @@ -30,13 +30,21 @@ final class ArrayIndexStore extends AbstractElementStore implements ReadWriteInd */ public function __construct(iterable $source = []) { - $this->addAll($source); + if (is_array($source)) { + $this->elements = array_is_list($source) ? $source : array_values($source); + } else { + $this->addAll($source); + } } public function addAll(iterable $source): void { - foreach ($source as $element) { - $this->elements[] = $element; + if (is_array($source)) { + array_push($this->elements, ...array_is_list($source) ? $source : array_values($source)); + } else { + foreach ($source as $element) { + $this->elements[] = $element; + } } } diff --git a/src/Map/IntMap/IntKeyValueStore.php b/src/Map/IntMap/IntKeyValueStore.php index bfe55d4..26e0131 100644 --- a/src/Map/IntMap/IntKeyValueStore.php +++ b/src/Map/IntMap/IntKeyValueStore.php @@ -38,11 +38,19 @@ public static function fromAssoc(iterable $source): self { /** @var self $store */ $store = new self(); - foreach ($source as $k => $v) { - if (!is_int($k)) { // @phpstan-ignore function.alreadyNarrowedType - throw new InvalidKeyTypeException(sprintf('IntMap requires int keys, got %s', get_debug_type($k))); + if (is_array($source)) { + if (array_is_list($source) || array_all($source, fn (mixed $v, int|string $k): bool => is_int($k))) { // @phpstan-ignore function.alreadyNarrowedType + $store->data = $source; + } else { + throw new InvalidKeyTypeException('IntMap requires int keys, got string'); + } + } else { + foreach ($source as $k => $v) { + if (!is_int($k)) { // @phpstan-ignore function.alreadyNarrowedType + throw new InvalidKeyTypeException(sprintf('IntMap requires int keys, got %s', get_debug_type($k))); + } + $store->data[$k] = $v; } - $store->data[$k] = $v; } return $store; } diff --git a/src/Map/StringMap/StringKeyValueStore.php b/src/Map/StringMap/StringKeyValueStore.php index ca388dd..546473a 100644 --- a/src/Map/StringMap/StringKeyValueStore.php +++ b/src/Map/StringMap/StringKeyValueStore.php @@ -37,8 +37,12 @@ public static function fromAssoc(iterable $source): self { /** @var self $store */ $store = new self(); - foreach ($source as $k => $v) { - $store->data[$k] = $v; + if (is_array($source)) { + $store->data = $source; + } else { + foreach ($source as $k => $v) { + $store->data[$k] = $v; + } } return $store; } diff --git a/tests/Collection/List/Case/AbstractListTestCase.php b/tests/Collection/List/Case/AbstractListTestCase.php index dca560c..3c90866 100644 --- a/tests/Collection/List/Case/AbstractListTestCase.php +++ b/tests/Collection/List/Case/AbstractListTestCase.php @@ -13,6 +13,7 @@ use Noctud\Collection\Tests\Collection\CollectionMutateWrite; use Noctud\Collection\Tests\Collection\List\ListAccess; use Noctud\Collection\Tests\Collection\List\ListConvert; +use Noctud\Collection\Tests\Collection\List\ListCopyOnWrite; use Noctud\Collection\Tests\Collection\List\ListIndex; use Noctud\Collection\Tests\Collection\List\ListMutate; @@ -21,6 +22,7 @@ abstract class AbstractListTestCase extends AbstractCollectionTestCase use CollectionMutateWrite; use ListAccess; use ListConvert; + use ListCopyOnWrite; use ListIndex; use ListMutate; } diff --git a/tests/Collection/List/ListCopyOnWrite.php b/tests/Collection/List/ListCopyOnWrite.php new file mode 100644 index 0000000..5338a45 --- /dev/null +++ b/tests/Collection/List/ListCopyOnWrite.php @@ -0,0 +1,111 @@ + */ + private array $listCowAnchors = []; + + /** + * @return list + */ + private function generateListCowArray(): array + { + $array = []; + for ($i = 0; $i < self::ListCowSize; $i++) { + $array[] = "value_$i"; + } + + return $array; + } + + /** + * Measures how much memory a full (non-COW) copy of the array uses. + * Forces PHP to duplicate the hash table by iterating element-by-element. + * + * @param list $source + */ + private function measureListArrayCopyCost(array $source): int + { + gc_collect_cycles(); + $before = memory_get_usage(); + $copy = []; + foreach ($source as $v) { + $copy[] = $v; + } + $this->listCowAnchors[] = $copy; + gc_collect_cycles(); + + return max(1, memory_get_usage() - $before); + } + + #[Test] + public function constructing_list_from_array_uses_copy_on_write(): void + { + $array = $this->generateListCowArray(); + $referenceMemory = $this->measureListArrayCopyCost($array); + + gc_collect_cycles(); + $before = memory_get_usage(); + + $this->listCowAnchors[] = listOf($array); + + gc_collect_cycles(); + $after = memory_get_usage(); + + $increase = max(0, $after - $before); + $percent = ($increase / $referenceMemory) * 100; + + $this->assertLessThan( + self::ListCowMaxMemoryIncreasePercent, + $percent, + sprintf( + 'Copy-on-write failed: constructing list from array used %.1f%% of array copy memory (expected near 0%%)', + $percent, + ), + ); + } + + #[Test] + public function constructing_mutable_list_from_array_uses_copy_on_write(): void + { + $array = $this->generateListCowArray(); + $referenceMemory = $this->measureListArrayCopyCost($array); + + gc_collect_cycles(); + $before = memory_get_usage(); + + $this->listCowAnchors[] = mutableListOf($array); + + gc_collect_cycles(); + $after = memory_get_usage(); + + $increase = max(0, $after - $before); + $percent = ($increase / $referenceMemory) * 100; + + $this->assertLessThan( + self::ListCowMaxMemoryIncreasePercent, + $percent, + sprintf( + 'Copy-on-write failed: constructing mutable list from array used %.1f%% of array copy memory (expected near 0%%)', + $percent, + ), + ); + } +} diff --git a/tests/Map/IntMap/Case/AbstractIntMapTestCase.php b/tests/Map/IntMap/Case/AbstractIntMapTestCase.php index a25a461..7a3a2b2 100644 --- a/tests/Map/IntMap/Case/AbstractIntMapTestCase.php +++ b/tests/Map/IntMap/Case/AbstractIntMapTestCase.php @@ -15,6 +15,7 @@ use Noctud\Collection\Tests\EnumerableCopyOnWrite; use PHPUnit\Framework\TestCase; use Noctud\Collection\Tests\Map\IntMap\IntMapConvert; +use Noctud\Collection\Tests\Map\IntMap\IntMapCopyOnWrite; use Noctud\Collection\Tests\Map\IntMap\IntMapKeyEnforcement; use Noctud\Collection\Tests\Map\IntMap\IntMapBasics; @@ -27,6 +28,7 @@ abstract class AbstractIntMapTestCase extends TestCase implements IntMapTestCase use EnumerableCopyOnWrite; use IntMapBasics; use IntMapConvert; + use IntMapCopyOnWrite; use IntMapKeyEnforcement; /** diff --git a/tests/Map/IntMap/IntMapCopyOnWrite.php b/tests/Map/IntMap/IntMapCopyOnWrite.php new file mode 100644 index 0000000..7eddfce --- /dev/null +++ b/tests/Map/IntMap/IntMapCopyOnWrite.php @@ -0,0 +1,111 @@ + */ + private array $intMapCowAnchors = []; + + /** + * @return array + */ + private function generateIntMapCowArray(): array + { + $array = []; + for ($i = 0; $i < self::IntMapCowSize; $i++) { + $array[$i] = "value_$i"; + } + + return $array; + } + + /** + * Measures how much memory a full (non-COW) copy of the array uses. + * Forces PHP to duplicate the hash table by iterating element-by-element. + * + * @param array $source + */ + private function measureIntMapArrayCopyCost(array $source): int + { + gc_collect_cycles(); + $before = memory_get_usage(); + $copy = []; + foreach ($source as $k => $v) { + $copy[$k] = $v; + } + $this->intMapCowAnchors[] = $copy; + gc_collect_cycles(); + + return max(1, memory_get_usage() - $before); + } + + #[Test] + public function constructing_int_map_from_array_uses_copy_on_write(): void + { + $array = $this->generateIntMapCowArray(); + $referenceMemory = $this->measureIntMapArrayCopyCost($array); + + gc_collect_cycles(); + $before = memory_get_usage(); + + $this->intMapCowAnchors[] = intMapOf($array); + + gc_collect_cycles(); + $after = memory_get_usage(); + + $increase = max(0, $after - $before); + $percent = ($increase / $referenceMemory) * 100; + + $this->assertLessThan( + self::IntMapCowMaxMemoryIncreasePercent, + $percent, + sprintf( + 'Copy-on-write failed: constructing IntMap from array used %.1f%% of array copy memory (expected near 0%%)', + $percent, + ), + ); + } + + #[Test] + public function constructing_mutable_int_map_from_array_uses_copy_on_write(): void + { + $array = $this->generateIntMapCowArray(); + $referenceMemory = $this->measureIntMapArrayCopyCost($array); + + gc_collect_cycles(); + $before = memory_get_usage(); + + $this->intMapCowAnchors[] = mutableIntMapOf($array); + + gc_collect_cycles(); + $after = memory_get_usage(); + + $increase = max(0, $after - $before); + $percent = ($increase / $referenceMemory) * 100; + + $this->assertLessThan( + self::IntMapCowMaxMemoryIncreasePercent, + $percent, + sprintf( + 'Copy-on-write failed: constructing mutable IntMap from array used %.1f%% of array copy memory (expected near 0%%)', + $percent, + ), + ); + } +} diff --git a/tests/Map/StringMap/Case/AbstractStringMapTestCase.php b/tests/Map/StringMap/Case/AbstractStringMapTestCase.php index a5311d9..44765be 100644 --- a/tests/Map/StringMap/Case/AbstractStringMapTestCase.php +++ b/tests/Map/StringMap/Case/AbstractStringMapTestCase.php @@ -15,6 +15,7 @@ use Noctud\Collection\Tests\EnumerableCopyOnWrite; use PHPUnit\Framework\TestCase; use Noctud\Collection\Tests\Map\StringMap\StringMapConvert; +use Noctud\Collection\Tests\Map\StringMap\StringMapCopyOnWrite; use Noctud\Collection\Tests\Map\StringMap\StringMapKeyEnforcement; use Noctud\Collection\Tests\Map\StringMap\StringMapBasics; @@ -27,6 +28,7 @@ abstract class AbstractStringMapTestCase extends TestCase implements StringMapTe use EnumerableCopyOnWrite; use StringMapBasics; use StringMapConvert; + use StringMapCopyOnWrite; use StringMapKeyEnforcement; /** diff --git a/tests/Map/StringMap/StringMapCopyOnWrite.php b/tests/Map/StringMap/StringMapCopyOnWrite.php new file mode 100644 index 0000000..c482a83 --- /dev/null +++ b/tests/Map/StringMap/StringMapCopyOnWrite.php @@ -0,0 +1,111 @@ + */ + private array $stringMapCowAnchors = []; + + /** + * @return array + */ + private function generateStringMapCowArray(): array + { + $array = []; + for ($i = 0; $i < self::StringMapCowSize; $i++) { + $array["key_$i"] = "value_$i"; + } + + return $array; + } + + /** + * Measures how much memory a full (non-COW) copy of the array uses. + * Forces PHP to duplicate the hash table by iterating element-by-element. + * + * @param array $source + */ + private function measureStringMapArrayCopyCost(array $source): int + { + gc_collect_cycles(); + $before = memory_get_usage(); + $copy = []; + foreach ($source as $k => $v) { + $copy[$k] = $v; + } + $this->stringMapCowAnchors[] = $copy; + gc_collect_cycles(); + + return max(1, memory_get_usage() - $before); + } + + #[Test] + public function constructing_string_map_from_array_uses_copy_on_write(): void + { + $array = $this->generateStringMapCowArray(); + $referenceMemory = $this->measureStringMapArrayCopyCost($array); + + gc_collect_cycles(); + $before = memory_get_usage(); + + $this->stringMapCowAnchors[] = stringMapOf($array); + + gc_collect_cycles(); + $after = memory_get_usage(); + + $increase = max(0, $after - $before); + $percent = ($increase / $referenceMemory) * 100; + + $this->assertLessThan( + self::StringMapCowMaxMemoryIncreasePercent, + $percent, + sprintf( + 'Copy-on-write failed: constructing StringMap from array used %.1f%% of array copy memory (expected near 0%%)', + $percent, + ), + ); + } + + #[Test] + public function constructing_mutable_string_map_from_array_uses_copy_on_write(): void + { + $array = $this->generateStringMapCowArray(); + $referenceMemory = $this->measureStringMapArrayCopyCost($array); + + gc_collect_cycles(); + $before = memory_get_usage(); + + $this->stringMapCowAnchors[] = mutableStringMapOf($array); + + gc_collect_cycles(); + $after = memory_get_usage(); + + $increase = max(0, $after - $before); + $percent = ($increase / $referenceMemory) * 100; + + $this->assertLessThan( + self::StringMapCowMaxMemoryIncreasePercent, + $percent, + sprintf( + 'Copy-on-write failed: constructing mutable StringMap from array used %.1f%% of array copy memory (expected near 0%%)', + $percent, + ), + ); + } +}