├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── LICENSE ├── composer.json ├── src ├── CycleWithDestructor.php └── WeakMap.php └── tools └── phpstan ├── composer.json └── phpstan.neon /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: BenMorel 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | phpunit: 9 | name: PHPUnit 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | php-version: 16 | - "7.4" 17 | - "8.0" # testing on PHP 8 with an actual WeakMap ensures that our tests are valid 18 | - "8.1" 19 | - "8.2" 20 | - "8.3" 21 | - "8.4" 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php-version }} 31 | coverage: xdebug 32 | 33 | - name: Install composer dependencies 34 | uses: "ramsey/composer-install@v1" 35 | 36 | - name: Run PHPUnit 37 | run: vendor/bin/phpunit 38 | if: ${{ matrix.php-version != '7.4' }} 39 | 40 | - name: Run PHPUnit with coverage 41 | run: | 42 | mkdir -p mkdir -p build/logs 43 | vendor/bin/phpunit --coverage-clover build/logs/clover.xml 44 | if: ${{ matrix.php-version == '7.4' }} 45 | 46 | - name: Upload coverage report to Coveralls 47 | run: vendor/bin/php-coveralls --coverage_clover=build/logs/clover.xml -v 48 | env: 49 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | if: ${{ matrix.php-version == '7.4' }} 51 | 52 | phpstan: 53 | name: PHPStan 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v2 59 | 60 | - name: Setup PHP 61 | uses: shivammathur/setup-php@v2 62 | with: 63 | php-version: "8.4" 64 | 65 | - name: Install composer dependencies 66 | uses: ramsey/composer-install@v2 67 | with: 68 | working-directory: tools/phpstan 69 | 70 | - name: Run PHPStan 71 | run: tools/phpstan/vendor/bin/phpstan analyze -c tools/phpstan/phpstan.neon 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Benjamin Morel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benmorel/weakmap-polyfill", 3 | "description": "A WeakMap polyfill for PHP 7.4", 4 | "type": "library", 5 | "keywords": [ 6 | "WeakMap", 7 | "WeakRef", 8 | "WeakReference" 9 | ], 10 | "license": "MIT", 11 | "require": { 12 | "php": "^7.4 || ^8.0" 13 | }, 14 | "require-dev": { 15 | "phpunit/phpunit": "^9.0", 16 | "php-coveralls/php-coveralls": "^2.4" 17 | }, 18 | "autoload": { 19 | "classmap": ["src/"] 20 | }, 21 | "autoload-dev": { 22 | "classmap": ["tests/"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/CycleWithDestructor.php: -------------------------------------------------------------------------------- 1 | destructorFx = $destructorFx; 17 | $this->cycleRef = new \stdClass(); 18 | $this->cycleRef->x = $this; 19 | } 20 | 21 | public function __destruct() 22 | { 23 | ($this->destructorFx)(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/WeakMap.php: -------------------------------------------------------------------------------- 1 | 27 | * @implements IteratorAggregate 28 | */ 29 | final class WeakMap implements ArrayAccess, Countable, IteratorAggregate 30 | { 31 | /** 32 | * The minimum number of offset*() calls after which housekeeping will be performed. 33 | * Housekeeping consists in freeing memory associated with destroyed objects. 34 | */ 35 | private const HOUSEKEEPING_EVERY = 100; 36 | 37 | /** 38 | * Only perform housekeeping when housekeepingCounter >= count(weakRefs) / HOUSEKEEPING_THRESHOLD. 39 | * 40 | * For example, for a WeakMap currently having 500 elements, this would housekeep after at least 50 elements, 41 | * and would instead wait for HOUSEKEEPING_EVERY(100). 42 | * 43 | * For a WeakMap with 100,000 elements, this would instead housekeep after every 10,000 operations. 44 | */ 45 | private const HOUSEKEEPING_THRESHOLD = 10; 46 | 47 | /** 48 | * @var array>> 49 | */ 50 | private static array $housekeepingInstances = []; 51 | 52 | private static bool $destructorFxSetUp = false; 53 | 54 | /** 55 | * The number of offset*() calls since the last housekeeping. 56 | */ 57 | private int $housekeepingCounter = 0; 58 | 59 | /** 60 | * A map of spl_object_id to WeakReference objects. This must be kept in sync with $values. 61 | * 62 | * @var array> 63 | */ 64 | private array $weakRefs = []; 65 | 66 | /** 67 | * A map of spl_object_id to values. This must be kept in sync with $weakRefs. 68 | * 69 | * @var array 70 | */ 71 | private array $values = []; 72 | 73 | public function __construct() 74 | { 75 | $this->setupHousekeepingOnGcRun(); 76 | } 77 | 78 | public function __destruct() 79 | { 80 | unset(self::$housekeepingInstances[spl_object_id($this)]); 81 | } 82 | 83 | /** 84 | * @param TKey $object 85 | */ 86 | public function offsetExists($object) : bool 87 | { 88 | $this->housekeeping(); 89 | $this->assertValidKey($object); 90 | 91 | $id = spl_object_id($object); 92 | 93 | if (isset($this->weakRefs[$id])) { 94 | if ($this->weakRefs[$id]->get() !== null) { 95 | return isset($this->values[$id]); 96 | } 97 | 98 | // This entry belongs to a destroyed object. 99 | unset( 100 | $this->weakRefs[$id], 101 | $this->values[$id] 102 | ); 103 | } 104 | 105 | return false; 106 | } 107 | 108 | /** 109 | * @param TKey $object 110 | * 111 | * @return TValue 112 | */ 113 | public function offsetGet($object) 114 | { 115 | $this->housekeeping(); 116 | $this->assertValidKey($object, true); 117 | 118 | $id = spl_object_id($object); 119 | 120 | if (isset($this->weakRefs[$id])) { 121 | if ($this->weakRefs[$id]->get() !== null) { 122 | return $this->values[$id]; 123 | } 124 | 125 | // This entry belongs to a destroyed object. 126 | unset( 127 | $this->weakRefs[$id], 128 | $this->values[$id] 129 | ); 130 | } 131 | 132 | throw new Error(sprintf('Object %s#%d not contained in WeakMap', get_class($object), $id)); 133 | } 134 | 135 | /** 136 | * @param TKey $object 137 | * @param TValue $value 138 | */ 139 | public function offsetSet($object, $value) : void 140 | { 141 | $this->housekeeping(); 142 | $this->assertValidKey($object, true); 143 | 144 | $id = spl_object_id($object); 145 | 146 | $this->weakRefs[$id] = WeakReference::create($object); 147 | $this->values[$id] = $value; 148 | } 149 | 150 | /** 151 | * @param TKey $object 152 | */ 153 | public function offsetUnset($object) : void 154 | { 155 | $this->housekeeping(); 156 | $this->assertValidKey($object); 157 | 158 | $id = spl_object_id($object); 159 | 160 | unset( 161 | $this->weakRefs[$id], 162 | $this->values[$id] 163 | ); 164 | } 165 | 166 | public function count() : int 167 | { 168 | $this->housekeeping(true); 169 | 170 | return count($this->weakRefs); 171 | } 172 | 173 | public function getIterator() : Traversable 174 | { 175 | foreach ($this->weakRefs as $id => $weakRef) { 176 | $object = $weakRef->get(); 177 | 178 | if ($object !== null) { 179 | yield $object => $this->values[$id]; 180 | } else { 181 | // This entry belongs to a destroyed object. 182 | unset( 183 | $this->weakRefs[$id], 184 | $this->values[$id] 185 | ); 186 | } 187 | } 188 | 189 | $this->housekeepingCounter = 0; 190 | } 191 | 192 | /** 193 | * NOTE: The native WeakMap does not implement this method, 194 | * but does throw Error for setting dynamic properties. 195 | * 196 | * @param mixed $value 197 | */ 198 | public function __set(string $name, $value): void { 199 | throw new Error("Cannot create dynamic property WeakMap::\$$name"); 200 | } 201 | 202 | // NOTE: The native WeakMap does not implement this method, 203 | // but does forbid serialization. 204 | public function __serialize(): array { 205 | throw new Exception("Serialization of 'WeakMap' is not allowed"); 206 | } 207 | 208 | private function housekeeping(bool $force = false) : void 209 | { 210 | if ( 211 | $force 212 | || ( 213 | ++$this->housekeepingCounter >= self::HOUSEKEEPING_EVERY 214 | && $this->housekeepingCounter * self::HOUSEKEEPING_THRESHOLD >= count($this->weakRefs) 215 | ) 216 | ) { 217 | foreach ($this->weakRefs as $id => $weakRef) { 218 | if ($weakRef->get() === null) { 219 | unset( 220 | $this->weakRefs[$id], 221 | $this->values[$id] 222 | ); 223 | } 224 | } 225 | 226 | $this->housekeepingCounter = 0; 227 | } 228 | } 229 | 230 | /** 231 | * @param mixed $key 232 | */ 233 | private function assertValidKey($key, bool $checkNull = false) : void 234 | { 235 | if ($checkNull && $key === null) { 236 | throw new Error('Cannot append to WeakMap'); 237 | } 238 | 239 | if(!is_object($key)) { 240 | throw new TypeError('WeakMap key must be an object'); 241 | } 242 | } 243 | 244 | /** 245 | * @see Based on https://github.com/php/php-src/pull/13650 PHP GC behaviour. 246 | */ 247 | private function setupHousekeepingOnGcRun() : void 248 | { 249 | if (!self::$destructorFxSetUp) { 250 | self::$destructorFxSetUp = true; 251 | 252 | $gcRuns = 0; 253 | $setupDestructorFx = static function () use (&$gcRuns, &$setupDestructorFx): void { 254 | $destructorFx = static function () use (&$gcRuns, &$setupDestructorFx): void { 255 | $gcRunsPrev = $gcRuns; 256 | $gcRuns = gc_status()['runs']; 257 | if ($gcRunsPrev !== $gcRuns) { // prevent recursion on shutdown 258 | $setupDestructorFx(); 259 | } 260 | 261 | foreach (self::$housekeepingInstances as $v) { 262 | $map = $v->get(); 263 | if ($map !== null) { 264 | $map->housekeeping(true); 265 | } 266 | } 267 | }; 268 | 269 | new CycleWithDestructor($destructorFx); 270 | }; 271 | $setupDestructorFx(); 272 | } 273 | 274 | self::$housekeepingInstances[spl_object_id($this)] = WeakReference::create($this); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /tools/phpstan/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "phpstan/phpstan": "^1.10" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tools/phpstan/phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | phpVersion: 70400 3 | level: 9 4 | paths: 5 | - ../../src 6 | --------------------------------------------------------------------------------