Skip to content

Commit 94e1c84

Browse files
feature #574 [DeepClone] Add DEEPCLONE_HYDRATE_PRESERVE_REFS flag + hot-path perf (nicolas-grekas)
This PR was squashed before being merged into the 1.x branch. Discussion ---------- [DeepClone] Add DEEPCLONE_HYDRATE_PRESERVE_REFS flag + hot-path perf Mirrors [symfony/php-ext-deepclone#12](symfony/php-ext-deepclone#12) on the polyfill side. ## 1. PRESERVE_REFS flag (BC break) By default, `deepclone_hydrate()` now drops PHP `&` references from `$vars` on write; pass the new `DEEPCLONE_HYDRATE_PRESERVE_REFS` flag to keep ref links. Callers that intentionally share a value slot between properties need to add the flag. `symfony/var-exporter`'s `Hydrator` / `Instantiator` pass the flag by default to preserve the pre-existing contract. ## 2. unserialize()-permissive property-name handling in scoped mode In scoped mode, the pre-v0.4.0 hot-path validation rejected integer keys, NUL-in-middle names, and mangled-shape keys with a `ValueError`. That was stricter than what `unserialize()` does on the same shapes in an `O:…` payload. The polyfill now matches `unserialize()`: - **Integer keys** — coerce to string on dynamic property access (PHP engine rule). - **NUL-in-middle names** — stored as raw dynamic properties. - **NUL-prefix names** — surface the engine's native `Error: Cannot access property starting with "\0"`. `DEEPCLONE_HYDRATE_MANGLED_VARS` mode still parses and validates mangled keys (that's the entire point of the flag). ## Perf The ref-preservation path used to run unconditionally and required a per-call probe (`ReflectionReference::fromArrayElement` over every key) plus a by-ref double-write of every property. The per-prop `str_contains(\$name, \"\\0\")` / `is_string(\$name)` checks were another ~18 ns per property on top. With both relaxed, the polyfill now **matches or beats raw Reflection** for flat-class hydration. ### Individual wins - **Ref-probe gated on the flag** — default path skips it entirely, lean single-write inner loop. - **Property-name validation matches `unserialize()`** — dropped the per-prop `is_string + str_contains NUL` check in favor of the engine's native semantics. - **`use ($notByRef)` closure capture** replacing `(array) $this` — avoids the per-call stdClass→array cast. - **1-scope inline fast path** in `deepclone_hydrate` — skips the outer `foreach ($scoped_vars as ...)` + cross-scope validation for the most common shape. - **Three closure variants each split by `$hasRefs`** — lean no-refs loop sits next to the ref-preserving double-write loop, branch-predicted once per call. ### Results PHP 8.4 / opcache, 100k iters, realistic DTO hydration: | case | before | after | vs Reflection | |----------------------------------------|-------:|------:|--------------:| | 6-prop mixed (pub + priv + prot) | 1,483 | 812 | 1.36× | | 8 typed props | 1,766 | 1,077 | 1.32× | | stdClass, 5 props | 1,182 | 609 | — | | 3-level inheritance | 1,932 | 1,128 | 3.80× | | readonly via ctor promotion | 1,519 | 1,144 | 2.35× | Scenarios 3 (3-level inheritance) and 4 (readonly) still lag Reflection — residual outer multi-scope iteration and per-readonly-prop `ReflectionProperty` invocation inside the hydrator. Flat-class hydration (scenarios 1, 2, 5) is at or below Reflection. ## Test plan - [x] 374/374 DeepClone tests green locally - [ ] CI green across the PHP matrix Commits ------- 7e0c66d [DeepClone] Match unserialize() permissiveness on scoped-mode prop names a1c9ed9 [DeepClone] Add DEEPCLONE_HYDRATE_PRESERVE_REFS flag + hot-path perf
2 parents 10d0b8c + 7e0c66d commit 94e1c84

3 files changed

Lines changed: 178 additions & 80 deletions

File tree

src/DeepClone/DeepClone.php

Lines changed: 138 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -372,21 +372,13 @@ public static function deepclone_from_array(array $data, ?array $allowed_classes
372372

373373
public static function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object
374374
{
375-
if ($flags & ~(\DEEPCLONE_HYDRATE_CALL_HOOKS | \DEEPCLONE_HYDRATE_NO_LAZY_INIT | \DEEPCLONE_HYDRATE_MANGLED_VARS)) {
375+
if ($flags & ~(\DEEPCLONE_HYDRATE_CALL_HOOKS | \DEEPCLONE_HYDRATE_NO_LAZY_INIT | \DEEPCLONE_HYDRATE_MANGLED_VARS | \DEEPCLONE_HYDRATE_PRESERVE_REFS)) {
376376
throw new \ValueError('deepclone_hydrate(): Argument #3 ($flags) contains unknown bits');
377377
}
378378
if (($flags & \DEEPCLONE_HYDRATE_CALL_HOOKS) && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) {
379379
throw new \ValueError('deepclone_hydrate(): Argument #3 ($flags) DEEPCLONE_HYDRATE_CALL_HOOKS and DEEPCLONE_HYDRATE_NO_LAZY_INIT are mutually exclusive');
380380
}
381381

382-
if ($flags & \DEEPCLONE_HYDRATE_MANGLED_VARS) {
383-
$mangled_vars = $vars;
384-
$scoped_vars = [];
385-
} else {
386-
$scoped_vars = $vars;
387-
$mangled_vars = [];
388-
}
389-
390382
if (\is_string($class = $object_or_class)) {
391383
$r = self::$reflectors[$class] ??= self::getClassReflector($class);
392384
if (self::$cloneable[$class]) {
@@ -406,6 +398,34 @@ public static function deepclone_hydrate(object|string $object_or_class, array $
406398
$class = $object::class;
407399
}
408400

401+
// Fast path: scoped mode, single scope equal to the object's class, no flags
402+
// other than (optionally) PRESERVE_REFS. Most common shape for DTO hydration.
403+
if (!($flags & ~\DEEPCLONE_HYDRATE_PRESERVE_REFS) && isset($vars[$class]) && 1 === \count($vars)) {
404+
$properties = $vars[$class];
405+
if (\is_array($properties) && $properties && !isset($properties["\0"])) {
406+
$hasRefs = false;
407+
if ($flags & \DEEPCLONE_HYDRATE_PRESERVE_REFS) {
408+
foreach ($properties as $k => $_) {
409+
if (\ReflectionReference::fromArrayElement($properties, $k)) {
410+
$hasRefs = true;
411+
break;
412+
}
413+
}
414+
}
415+
(self::$simpleHydrators[$class] ??= self::getSimpleHydrator($class))($properties, $object, $hasRefs);
416+
417+
return $object;
418+
}
419+
}
420+
421+
if ($flags & \DEEPCLONE_HYDRATE_MANGLED_VARS) {
422+
$mangled_vars = $vars;
423+
$scoped_vars = [];
424+
} else {
425+
$scoped_vars = $vars;
426+
$mangled_vars = [];
427+
}
428+
409429
if ($mangled_vars) {
410430
// self::$reflectors must be populated via getClassReflector() (companion caches), so read with ??.
411431
$r ??= self::$reflectors[$class] ?? new \ReflectionClass($class);
@@ -427,7 +447,7 @@ public static function deepclone_hydrate(object|string $object_or_class, array $
427447
$scopeName = substr($name, 1, $sep - 1);
428448
$realName = substr($name, $sep + 1);
429449

430-
if (\str_contains($realName, "\0")) {
450+
if (str_contains($realName, "\0")) {
431451
throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode contains an invalid mangled key');
432452
}
433453

@@ -468,15 +488,19 @@ public static function deepclone_hydrate(object|string $object_or_class, array $
468488
$r->getConstructor()->invokeArgs($object, $special);
469489
}
470490
}
471-
foreach ($properties as $name => $v) {
472-
if (!\is_string($name)) {
473-
throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) scope "%s" must have only string keys', $scope));
474-
}
475-
if (\str_contains($name, "\0")) {
476-
throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) scope "%s" contains an invalid property name; use bare property names in scoped mode, or pass DEEPCLONE_HYDRATE_MANGLED_VARS in $flags', $scope));
477-
}
478-
}
479491
if ($properties) {
492+
// Under PRESERVE_REFS, probe once to preserve PHP & references only
493+
// when the input carries any. Otherwise skip ref handling entirely.
494+
$hasRefs = false;
495+
if ($flags & \DEEPCLONE_HYDRATE_PRESERVE_REFS) {
496+
foreach ($properties as $k => $_) {
497+
if (\ReflectionReference::fromArrayElement($properties, $k)) {
498+
$hasRefs = true;
499+
break;
500+
}
501+
}
502+
}
503+
480504
$effectiveFlags = $flags & \DEEPCLONE_HYDRATE_CALL_HOOKS;
481505
if (\PHP_VERSION_ID >= 80400 && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) {
482506
$r ??= self::$reflectors[$class] ?? new \ReflectionClass($class);
@@ -485,7 +509,7 @@ public static function deepclone_hydrate(object|string $object_or_class, array $
485509
}
486510
}
487511
$cacheKey = $effectiveFlags ? $effectiveFlags.$scope : $scope;
488-
(self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($scope, $effectiveFlags))($properties, $object);
512+
(self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($scope, $effectiveFlags))($properties, $object, $hasRefs);
489513
}
490514
}
491515

@@ -1322,10 +1346,16 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu
13221346
{
13231347
$callHooks = (bool) ($flags & \DEEPCLONE_HYDRATE_CALL_HOOKS);
13241348
$noLazyInit = \PHP_VERSION_ID >= 80400 && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT);
1325-
$baseHydrator = self::$simpleHydrators['stdClass'] ??= static function ($properties, $object) {
1326-
foreach ($properties as $name => &$value) {
1327-
$object->$name = $value;
1328-
$object->$name = &$value;
1349+
$baseHydrator = self::$simpleHydrators['stdClass'] ??= static function ($properties, $object, $hasRefs) {
1350+
if ($hasRefs) {
1351+
foreach ($properties as $name => &$value) {
1352+
$object->$name = $value;
1353+
$object->$name = &$value;
1354+
}
1355+
} else {
1356+
foreach ($properties as $name => $value) {
1357+
$object->$name = $value;
1358+
}
13291359
}
13301360
};
13311361

@@ -1379,7 +1409,7 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu
13791409
}
13801410

13811411
if (!$classReflector->isInternal()) {
1382-
$notByRef = new \stdClass();
1412+
$notByRef = [];
13831413
$unsetOnNull = [];
13841414
$backedEnum = [];
13851415
foreach ($classReflector->getProperties() as $propertyReflector) {
@@ -1388,23 +1418,22 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu
13881418
}
13891419
if ($noLazyInit && !$propertyReflector->isVirtual()) {
13901420
// Virtual hooked props fall through — setRawValueWithoutLazyInitialization rejects them.
1391-
$notByRef->{$propertyReflector->name} = $propertyReflector->setRawValueWithoutLazyInitialization(...);
1421+
$notByRef[$propertyReflector->name] = $propertyReflector->setRawValueWithoutLazyInitialization(...);
13921422
continue;
13931423
}
13941424
if (\PHP_VERSION_ID >= 80400 && !$propertyReflector->isAbstract() && $propertyReflector->getHooks()) {
13951425
if ($propertyReflector->isVirtual()) {
1396-
$notByRef->{$propertyReflector->name} = true;
1426+
$notByRef[$propertyReflector->name] = true;
13971427
} else {
1398-
$notByRef->{$propertyReflector->name} = $callHooks
1428+
$notByRef[$propertyReflector->name] = $callHooks
13991429
? $propertyReflector->setValue(...)
14001430
: $propertyReflector->setRawValue(...);
14011431
}
14021432
} elseif ($propertyReflector->isReadOnly()) {
1403-
$notByRef->{$propertyReflector->name} = static function ($object, $value) use ($propertyReflector) {
1433+
$notByRef[$propertyReflector->name] = static function ($object, $value) use ($propertyReflector) {
14041434
// Idempotent rehydrate: skip same-value writes that the engine would reject.
14051435
if ($propertyReflector->isInitialized($object)
1406-
&& $propertyReflector->getValue($object) === $value)
1407-
{
1436+
&& $propertyReflector->getValue($object) === $value) {
14081437
return;
14091438
}
14101439
$propertyReflector->setValue($object, $value);
@@ -1424,11 +1453,81 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu
14241453
}
14251454
}
14261455

1427-
// Three variants so the lean path skips per-iteration checks that can't trip.
1456+
$scope = $class;
1457+
1458+
// Three variants × ref-preserving vs ref-less, so the common (no-refs) hot path skips
1459+
// the by-ref double-write and the per-iteration checks that can't trip.
14281460
if (!$unsetOnNull && !$backedEnum) {
1429-
return (function ($properties, $object) {
1430-
$notByRef = (array) $this;
1461+
return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $scope): void {
1462+
if ($hasRefs) {
1463+
foreach ($properties as $name => &$value) {
1464+
if (!$noRef = $notByRef[$name] ?? false) {
1465+
$object->$name = $value;
1466+
$object->$name = &$value;
1467+
} elseif (true !== $noRef) {
1468+
$noRef($object, $value);
1469+
} else {
1470+
$object->$name = $value;
1471+
}
1472+
}
1473+
} else {
1474+
foreach ($properties as $name => $value) {
1475+
if (!$noRef = $notByRef[$name] ?? false) {
1476+
$object->$name = $value;
1477+
} elseif (true !== $noRef) {
1478+
$noRef($object, $value);
1479+
} else {
1480+
$object->$name = $value;
1481+
}
1482+
}
1483+
}
1484+
}, null, $class);
1485+
}
1486+
if (!$backedEnum) {
1487+
return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull, $scope): void {
1488+
if ($hasRefs) {
1489+
foreach ($properties as $name => &$value) {
1490+
if (null === $value && isset($unsetOnNull[$name])) {
1491+
unset($object->$name);
1492+
continue;
1493+
}
1494+
if (!$noRef = $notByRef[$name] ?? false) {
1495+
$object->$name = $value;
1496+
$object->$name = &$value;
1497+
} elseif (true !== $noRef) {
1498+
$noRef($object, $value);
1499+
} else {
1500+
$object->$name = $value;
1501+
}
1502+
}
1503+
} else {
1504+
foreach ($properties as $name => $value) {
1505+
if (null === $value && isset($unsetOnNull[$name])) {
1506+
unset($object->$name);
1507+
continue;
1508+
}
1509+
if (!$noRef = $notByRef[$name] ?? false) {
1510+
$object->$name = $value;
1511+
} elseif (true !== $noRef) {
1512+
$noRef($object, $value);
1513+
} else {
1514+
$object->$name = $value;
1515+
}
1516+
}
1517+
}
1518+
}, null, $class);
1519+
}
1520+
1521+
return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull, $backedEnum, $scope): void {
1522+
if ($hasRefs) {
14311523
foreach ($properties as $name => &$value) {
1524+
if (null === $value && isset($unsetOnNull[$name])) {
1525+
unset($object->$name);
1526+
continue;
1527+
}
1528+
if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) {
1529+
$value = $backedEnum[$name]::from($value);
1530+
}
14321531
if (!$noRef = $notByRef[$name] ?? false) {
14331532
$object->$name = $value;
14341533
$object->$name = &$value;
@@ -1438,49 +1537,25 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu
14381537
$object->$name = $value;
14391538
}
14401539
}
1441-
})->bindTo($notByRef, $class);
1442-
}
1443-
if (!$backedEnum) {
1444-
return (function ($properties, $object) use ($unsetOnNull) {
1445-
$notByRef = (array) $this;
1446-
foreach ($properties as $name => &$value) {
1540+
} else {
1541+
foreach ($properties as $name => $value) {
14471542
if (null === $value && isset($unsetOnNull[$name])) {
14481543
unset($object->$name);
14491544
continue;
14501545
}
1546+
if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) {
1547+
$value = $backedEnum[$name]::from($value);
1548+
}
14511549
if (!$noRef = $notByRef[$name] ?? false) {
14521550
$object->$name = $value;
1453-
$object->$name = &$value;
14541551
} elseif (true !== $noRef) {
14551552
$noRef($object, $value);
14561553
} else {
14571554
$object->$name = $value;
14581555
}
14591556
}
1460-
})->bindTo($notByRef, $class);
1461-
}
1462-
1463-
return (function ($properties, $object) use ($unsetOnNull, $backedEnum) {
1464-
$notByRef = (array) $this;
1465-
1466-
foreach ($properties as $name => &$value) {
1467-
if (null === $value && isset($unsetOnNull[$name])) {
1468-
unset($object->$name);
1469-
continue;
1470-
}
1471-
if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) {
1472-
$value = $backedEnum[$name]::from($value);
1473-
}
1474-
if (!$noRef = $notByRef[$name] ?? false) {
1475-
$object->$name = $value;
1476-
$object->$name = &$value;
1477-
} elseif (true !== $noRef) {
1478-
$noRef($object, $value);
1479-
} else {
1480-
$object->$name = $value;
1481-
}
14821557
}
1483-
})->bindTo($notByRef, $class);
1558+
}, null, $class);
14841559
}
14851560

14861561
if ($classReflector->name !== $class) {

src/DeepClone/bootstrap81.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ function deepclone_from_array(array $data, ?array $allowed_classes = null): mixe
3030
if (!defined('DEEPCLONE_HYDRATE_MANGLED_VARS')) {
3131
define('DEEPCLONE_HYDRATE_MANGLED_VARS', 1 << 2);
3232
}
33+
if (!defined('DEEPCLONE_HYDRATE_PRESERVE_REFS')) {
34+
define('DEEPCLONE_HYDRATE_PRESERVE_REFS', 1 << 3);
35+
}
3336
if (!function_exists('deepclone_hydrate')) {
3437
function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object { return p\DeepClone::deepclone_hydrate($object_or_class, $vars, $flags); }
3538
}

0 commit comments

Comments
 (0)