Skip to content
Merged
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
14 changes: 11 additions & 3 deletions src/List/ArrayList/ArrayIndexStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
16 changes: 12 additions & 4 deletions src/Map/IntMap/IntKeyValueStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,19 @@ public static function fromAssoc(iterable $source): self
{
/** @var self<NV> $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;
}
Expand Down
8 changes: 6 additions & 2 deletions src/Map/StringMap/StringKeyValueStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ public static function fromAssoc(iterable $source): self
{
/** @var self<NV> $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;
}
Expand Down
2 changes: 2 additions & 0 deletions tests/Collection/List/Case/AbstractListTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -21,6 +22,7 @@ abstract class AbstractListTestCase extends AbstractCollectionTestCase
use CollectionMutateWrite;
use ListAccess;
use ListConvert;
use ListCopyOnWrite;
use ListIndex;
use ListMutate;
}
111 changes: 111 additions & 0 deletions tests/Collection/List/ListCopyOnWrite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Tests\Collection\List;

use PHPUnit\Framework\Attributes\Test;
use function Noctud\Collection\listOf;
use function Noctud\Collection\mutableListOf;

trait ListCopyOnWrite
{
private const int ListCowSize = 100_000;

private const float ListCowMaxMemoryIncreasePercent = 25.0;

/** @var list<mixed> */
private array $listCowAnchors = [];

/**
* @return list<string>
*/
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<string> $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,
),
);
}
}
2 changes: 2 additions & 0 deletions tests/Map/IntMap/Case/AbstractIntMapTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,6 +28,7 @@ abstract class AbstractIntMapTestCase extends TestCase implements IntMapTestCase
use EnumerableCopyOnWrite;
use IntMapBasics;
use IntMapConvert;
use IntMapCopyOnWrite;
use IntMapKeyEnforcement;

/**
Expand Down
111 changes: 111 additions & 0 deletions tests/Map/IntMap/IntMapCopyOnWrite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Tests\Map\IntMap;

use PHPUnit\Framework\Attributes\Test;
use function Noctud\Collection\intMapOf;
use function Noctud\Collection\mutableIntMapOf;

trait IntMapCopyOnWrite
{
private const int IntMapCowSize = 100_000;

private const float IntMapCowMaxMemoryIncreasePercent = 25.0;

/** @var list<mixed> */
private array $intMapCowAnchors = [];

/**
* @return array<int, string>
*/
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<int, string> $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,
),
);
}
}
2 changes: 2 additions & 0 deletions tests/Map/StringMap/Case/AbstractStringMapTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,6 +28,7 @@ abstract class AbstractStringMapTestCase extends TestCase implements StringMapTe
use EnumerableCopyOnWrite;
use StringMapBasics;
use StringMapConvert;
use StringMapCopyOnWrite;
use StringMapKeyEnforcement;

/**
Expand Down
Loading
Loading