Skip to content

Commit 727721a

Browse files
committed
Add implementation of immutable object
1 parent 03083ff commit 727721a

File tree

2 files changed

+331
-0
lines changed

2 files changed

+331
-0
lines changed

src/ImmutableTrait.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
declare(strict_types=1);
3+
/**
4+
* Immutable object library
5+
*
6+
* @copyright Copyright 2019 Lisachenko Alexander <[email protected]>
7+
*
8+
* This source file is subject to the license that is bundled
9+
* with this source code in the file LICENSE.
10+
*/
11+
12+
namespace Immutable;
13+
14+
use LogicException;
15+
use Throwable;
16+
use function array_keys, get_object_vars, spl_object_id, serialize, unserialize;
17+
18+
trait ImmutableTrait
19+
{
20+
/**
21+
* Stores a unique identifier of this object in PHP, used only for cloning
22+
*
23+
* @var int
24+
*/
25+
private $__objectId;
26+
27+
/**
28+
* Constructs an instance of immutable object
29+
*
30+
* @param array $properties Initial value for properties
31+
*/
32+
final public function __construct(array $properties)
33+
{
34+
$this->applyState($properties);
35+
}
36+
37+
/**
38+
* Prevents an update of immutable property
39+
*
40+
* @param mixed $value New value for property
41+
*
42+
* @throws LogicException
43+
*/
44+
final public function __set(string $name, $value): void
45+
{
46+
throw new LogicException('You can not modify immutable property ' . static::class . '->' . $name);
47+
}
48+
49+
/**
50+
* Returns property value
51+
*
52+
* @return mixed
53+
*/
54+
final public function __get(string $name)
55+
{
56+
return ObjectContext::get($this)[$name] ?? null;
57+
}
58+
59+
/**
60+
* Checks if a property is set
61+
*/
62+
final public function __isset(string $name): bool
63+
{
64+
return isset(ObjectContext::get($this)[$name]);
65+
}
66+
67+
/**
68+
* Prevents unset of immutable property
69+
*
70+
* @throws LogicException
71+
*/
72+
final public function __unset(string $name): void
73+
{
74+
throw new LogicException('You can not unset immutable property ' . static::class . '->' . $name);
75+
}
76+
77+
/**
78+
* Returns debug representation of object
79+
*/
80+
final public function __debugInfo(): array
81+
{
82+
$result = [];
83+
try {
84+
$result = ObjectContext::get($this);
85+
} catch (Throwable $e) {
86+
die ($e->getMessage());
87+
} finally {
88+
return $result;
89+
}
90+
}
91+
92+
/**
93+
* Destroys immutable object
94+
*/
95+
final public function __destruct()
96+
{
97+
ObjectContext::destroy($this);
98+
}
99+
100+
/**
101+
* Clone handler for immutable object performs context copying during clone procedure
102+
*/
103+
final public function __clone()
104+
{
105+
$newObjectId = spl_object_id($this);
106+
ObjectContext::copy($this->__objectId, $newObjectId);
107+
$this->__objectId = $newObjectId;
108+
}
109+
110+
/**
111+
* Serialization handler for immutable objects
112+
*/
113+
final public function serialize(): string
114+
{
115+
return serialize(ObjectContext::get($this));
116+
}
117+
118+
/**
119+
* Unserialization handler
120+
*
121+
* @param string $serialized
122+
*/
123+
final public function unserialize($serialized)
124+
{
125+
$state = unserialize($serialized);
126+
$this->applyState($state);
127+
}
128+
129+
/**
130+
* Protects class from default serialization handler
131+
*/
132+
final public function __sleep()
133+
{
134+
throw new LogicException('Class ' . static::class . ' should implement Serializable interface');
135+
}
136+
137+
/**
138+
* Protects class from default unserialization handler
139+
*/
140+
final public function __wakeup()
141+
{
142+
throw new LogicException('Class ' . static::class . ' should implement Serializable interface');
143+
}
144+
145+
/**
146+
* Applies given state to the object context, can be performed only once for uninitialized object
147+
*
148+
* @param array $properties
149+
*/
150+
final private function applyState(array $properties)
151+
{
152+
$state = [];
153+
foreach (array_keys(get_object_vars($this)) as $propertyName) {
154+
if ($propertyName === '__objectId') {
155+
continue;
156+
}
157+
if (isset($properties[$propertyName])) {
158+
$this->$propertyName = $properties[$propertyName];
159+
}
160+
$state[$propertyName] = &$this->$propertyName;
161+
unset($this->$propertyName);
162+
}
163+
$this->__objectId = spl_object_id($this);
164+
ObjectContext::set($this, $state);
165+
}
166+
}

src/ObjectContext.php

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
declare(strict_types=1);
3+
/**
4+
* Immutable object library
5+
*
6+
* @copyright Copyright 2019 Lisachenko Alexander <[email protected]>
7+
*
8+
* This source file is subject to the license that is bundled
9+
* with this source code in the file LICENSE.
10+
*/
11+
12+
namespace Immutable;
13+
14+
use LogicException;
15+
use function debug_backtrace, in_array, spl_object_id;
16+
17+
/**
18+
* Class ObjectContext
19+
*
20+
* @method static set(object $instance, array $value): void Initializes instance state
21+
* @method static get(object $instance): array Returns instance state
22+
* @method static destroy(object $instance): void Destroys instance in memory
23+
* @method static copy(int $sourceObjectId, int $destinationObjectId): void Copies context from one objectId to another
24+
*/
25+
final class ObjectContext
26+
{
27+
/**
28+
* Static magic-method handler
29+
*
30+
* @param string $name Method name to call
31+
* @param array $arguments Method arguments
32+
*
33+
* @return array|void
34+
*/
35+
final public static function __callStatic(string $name, array $arguments)
36+
{
37+
// Static variable in the function prevents changing its value from the outside
38+
static $immutableObjects = [];
39+
40+
switch ($name) {
41+
case 'set':
42+
[$instance, $value] = $arguments;
43+
self::setObjectState($instance, $value, $immutableObjects);
44+
break;
45+
46+
case 'get':
47+
[$instance] = $arguments;
48+
49+
return self::getObjectState($instance, $immutableObjects);
50+
51+
case 'destroy':
52+
[$instance] = $arguments;
53+
self::destroyObjectState($instance, $immutableObjects);
54+
break;
55+
56+
case 'copy':
57+
[$sourceObjectId, $destinationObjectId] = $arguments;
58+
self::copyObjectState($sourceObjectId, $destinationObjectId, $immutableObjects);
59+
break;
60+
61+
default:
62+
throw new \RuntimeException("Unsupported method ($name)");
63+
}
64+
}
65+
66+
/**
67+
* Returns an object state as key=>value associative array
68+
*
69+
* @param object $instance Instance of immutable object
70+
* @param array $immutableObjects Storage of all immutable object states, by reference
71+
*
72+
* @return array Previously stored object state
73+
* @throws LogicException If there is no object state for given instance
74+
*/
75+
final private static function getObjectState(object $instance, array &$immutableObjects): array
76+
{
77+
self::guardInstanceScope($instance);
78+
$objectId = spl_object_id($instance);
79+
if (!isset($immutableObjects[$objectId])) {
80+
throw new LogicException('Immutable object context is not available');
81+
}
82+
83+
return $immutableObjects[$objectId] ?? [];
84+
}
85+
86+
/**
87+
* Stores an object state in the storage
88+
*
89+
* @param object $instance Instance of immutable object
90+
* @param array $value Object state to store
91+
* @param array $immutableObjects Storage of all immutable object states, by reference
92+
*
93+
* @throws LogicException If there is an object state already for given instance
94+
*/
95+
private static function setObjectState(object $instance, array $value, array &$immutableObjects): void
96+
{
97+
self::guardInstanceScope($instance);
98+
$objectId = spl_object_id($instance);
99+
if (isset($immutableObjects[$objectId])) {
100+
throw new LogicException('Immutable values can be assigned only once');
101+
}
102+
$immutableObjects[$objectId] = $value;
103+
}
104+
105+
/**
106+
* Performs cleaning of object state in the storage
107+
*
108+
* @param object $instance Instance of immutable object
109+
* @param array $immutableObjects Storage of all immutable object states, by reference
110+
*/
111+
private static function destroyObjectState(object $instance, array &$immutableObjects): void
112+
{
113+
self::guardInstanceScope($instance);
114+
$objectId = spl_object_id($instance);
115+
if (isset($immutableObjects[$objectId])) {
116+
unset($immutableObjects[$objectId]);
117+
}
118+
}
119+
120+
/**
121+
* Performs copying of state by object identifiers
122+
*
123+
* @param int $sourceObjectId Source object ID
124+
* @param int $destinationObjectId Destination object ID
125+
* @param array $immutableObjects Storage of all immutable object states, by reference
126+
*/
127+
private static function copyObjectState(
128+
int $sourceObjectId,
129+
int $destinationObjectId,
130+
array &$immutableObjects
131+
): void {
132+
if (!isset($immutableObjects[$sourceObjectId])) {
133+
throw new LogicException('Immutable object context is not available');
134+
}
135+
if (isset($immutableObjects[$destinationObjectId])) {
136+
throw new LogicException('Immutable values can be assigned only once');
137+
}
138+
$immutableObjects[$destinationObjectId] = $immutableObjects[$sourceObjectId];
139+
}
140+
141+
/**
142+
* Protects our code from calling directly
143+
*
144+
* @param object $instance Instance of immutable object
145+
*/
146+
final private static function guardInstanceScope(object $instance): void
147+
{
148+
static $knownThrowMethods = ['__set', '__unset', '__sleep', '__wakeup'];
149+
150+
$trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 5);
151+
if (!isset($trace[3]['object']) || $trace[3]['object'] !== $instance) {
152+
throw new LogicException('Scope access violation');
153+
}
154+
$isClosure = strpos($trace[3]['function'], '{closure}') !== false;
155+
$isDebugInfo = $trace[3]['function'] === '__debugInfo';
156+
$isInternal = isset($trace[4]['class']) && ($trace[3]['class'] === $trace[4]['class']);
157+
$isInternal = $isInternal && !(in_array($trace[4]['function'], $knownThrowMethods, true));
158+
if ($isClosure) {
159+
throw new LogicException('Binding to the ' . get_class($instance) . ' is not allowed');
160+
}
161+
if ($isDebugInfo && $isInternal) {
162+
throw new LogicException('The class ' .get_class($instance) . ' should not be debugged');
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)