|
| 1 | +Immutable objects in PHP |
| 2 | +----------------- |
| 3 | +This library provides native immutable objects for PHP>=7.4.2 |
| 4 | + |
| 5 | +[](https://travis-ci.org/lisachenko/immutable-object) |
| 6 | +[](https://github.com/lisachenko/immutable-object/releases/latest) |
| 7 | +[](https://php.net/) |
| 8 | +[](https://packagist.org/packages/lisachenko/immutable-object) |
| 9 | + |
| 10 | +Rationale |
| 11 | +------------ |
| 12 | +How many times have you thought it would be nice to have immutable objects in PHP? How many errors could be avoided if |
| 13 | +the objects could warn about attempts to change them outside the constructor? Unfortunately, |
| 14 | +[Immutability RFC](https://wiki.php.net/rfc/immutability) has never been implemented. |
| 15 | + |
| 16 | +What to do? |
| 17 | +Of course, there is [psalm-immutable](https://psalm.dev/docs/annotating_code/supported_annotations/#psalm-immutable) |
| 18 | +annotation which can help us find errors when running static analysis. But during the development of the code itself, |
| 19 | +we will not see any errors when trying to change a property in such an object. |
| 20 | + |
| 21 | +However, with the advent of [FFI](https://www.php.net/manual/en/book.ffi.php) and the |
| 22 | +[Z-Engine](https://travis-ci.org/lisachenko/z-engine) library, it became possible to use PHP to expand the capabilities |
| 23 | +of the PHP itself. |
| 24 | + |
| 25 | +Pre-requisites and initialization |
| 26 | +-------------- |
| 27 | +As this library depends on `FFI`, it requires PHP>=7.4 and `FFI` extension to be enabled. |
| 28 | + |
| 29 | +To install this library, simply add it via `composer`: |
| 30 | +```bash |
| 31 | +composer require lisachenko/immutable-object |
| 32 | +``` |
| 33 | +To enable immutability, you should activate `FFI` bindings for PHP first by initializing the `Z-Engine` library with |
| 34 | +short call to the `Core::init()`. And you also need to activate immutability handler for development mode (or do not |
| 35 | +call it for production mode to follow Design-by-Contract logic and optimize performance and stability of application) |
| 36 | + |
| 37 | +```php |
| 38 | +use Immutable\ImmutableHandler; |
| 39 | +use ZEngine\Core; |
| 40 | + |
| 41 | +include __DIR__.'/vendor/autoload.php'; |
| 42 | + |
| 43 | +Core::init(); |
| 44 | +ImmutableHandler::install(); |
| 45 | +``` |
| 46 | + |
| 47 | +Probably, `Z-Engine` will provide an automatic self-registration later, but for now it's ok to perform initialization |
| 48 | +manually. |
| 49 | + |
| 50 | +Applying immutability |
| 51 | +-------- |
| 52 | +In order to make your object immutable, you just need to implement the `ImmutableInterface` interface marker in your |
| 53 | +class and this library automatically convert this class to immutable. Please note, that this interface should be added |
| 54 | +to every class (it isn't guaranteed that it will work child with parent classes that was declared as immutable) |
| 55 | + |
| 56 | +Now you can test it with following example: |
| 57 | +```php |
| 58 | +<?php |
| 59 | +declare(strict_types=1); |
| 60 | + |
| 61 | +use Immutable\ImmutableInterface; |
| 62 | +use Immutable\ImmutableHandler; |
| 63 | +use ZEngine\Core; |
| 64 | + |
| 65 | +include __DIR__.'/vendor/autoload.php'; |
| 66 | + |
| 67 | +Core::init(); |
| 68 | +ImmutableHandler::install(); |
| 69 | + |
| 70 | +final class MyImmutableObject implements ImmutableInterface |
| 71 | +{ |
| 72 | + public $value; |
| 73 | + |
| 74 | + public function __construct($value) |
| 75 | + { |
| 76 | + $this->value = $value; |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +$object = new MyImmutableObject(100); |
| 81 | +echo $object->value; // OK, 100 |
| 82 | +$object->value = 200; // FAIL: LogicException: Immutable object could be modified only in constructor or static methods |
| 83 | +``` |
| 84 | + |
| 85 | +Low-level details (for geeks) |
| 86 | +-------------- |
| 87 | +Every PHP class is represented by `zend_class_entry` structure in the engine: |
| 88 | + |
| 89 | +```text |
| 90 | +struct _zend_class_entry { |
| 91 | + char type; |
| 92 | + zend_string *name; |
| 93 | + /* class_entry or string depending on ZEND_ACC_LINKED */ |
| 94 | + union { |
| 95 | + zend_class_entry *parent; |
| 96 | + zend_string *parent_name; |
| 97 | + }; |
| 98 | + int refcount; |
| 99 | + uint32_t ce_flags; |
| 100 | +
|
| 101 | + int default_properties_count; |
| 102 | + int default_static_members_count; |
| 103 | + zval *default_properties_table; |
| 104 | + zval *default_static_members_table; |
| 105 | + zval ** static_members_table; |
| 106 | + HashTable function_table; |
| 107 | + HashTable properties_info; |
| 108 | + HashTable constants_table; |
| 109 | +
|
| 110 | + struct _zend_property_info **properties_info_table; |
| 111 | +
|
| 112 | + zend_function *constructor; |
| 113 | + zend_function *destructor; |
| 114 | + zend_function *clone; |
| 115 | + zend_function *__get; |
| 116 | + zend_function *__set; |
| 117 | + zend_function *__unset; |
| 118 | + zend_function *__isset; |
| 119 | + zend_function *__call; |
| 120 | + zend_function *__callstatic; |
| 121 | + zend_function *__tostring; |
| 122 | + zend_function *__debugInfo; |
| 123 | + zend_function *serialize_func; |
| 124 | + zend_function *unserialize_func; |
| 125 | +
|
| 126 | + /* allocated only if class implements Iterator or IteratorAggregate interface */ |
| 127 | + zend_class_iterator_funcs *iterator_funcs_ptr; |
| 128 | +
|
| 129 | + /* handlers */ |
| 130 | + union { |
| 131 | + zend_object* (*create_object)(zend_class_entry *class_type); |
| 132 | + int (*interface_gets_implemented)(zend_class_entry *iface, zend_class_entry *class_type); /* a class implements this interface */ |
| 133 | + }; |
| 134 | + zend_object_iterator *(*get_iterator)(zend_class_entry *ce, zval *object, int by_ref); |
| 135 | + zend_function *(*get_static_method)(zend_class_entry *ce, zend_string* method); |
| 136 | +
|
| 137 | + /* serializer callbacks */ |
| 138 | + int (*serialize)(zval *object, unsigned char **buffer, size_t *buf_len, zend_serialize_data *data); |
| 139 | + int (*unserialize)(zval *object, zend_class_entry *ce, const unsigned char *buf, size_t buf_len, zend_unserialize_data *data); |
| 140 | +
|
| 141 | + uint32_t num_interfaces; |
| 142 | + uint32_t num_traits; |
| 143 | +
|
| 144 | + /* class_entry or string(s) depending on ZEND_ACC_LINKED */ |
| 145 | + union { |
| 146 | + zend_class_entry **interfaces; |
| 147 | + zend_class_name *interface_names; |
| 148 | + }; |
| 149 | +
|
| 150 | + zend_class_name *trait_names; |
| 151 | + zend_trait_alias **trait_aliases; |
| 152 | + zend_trait_precedence **trait_precedences; |
| 153 | +
|
| 154 | + union { |
| 155 | + struct { |
| 156 | + zend_string *filename; |
| 157 | + uint32_t line_start; |
| 158 | + uint32_t line_end; |
| 159 | + zend_string *doc_comment; |
| 160 | + } user; |
| 161 | + struct { |
| 162 | + const struct _zend_function_entry *builtin_functions; |
| 163 | + struct _zend_module_entry *module; |
| 164 | + } internal; |
| 165 | + } info; |
| 166 | +}; |
| 167 | +``` |
| 168 | +You can notice that this structure is pretty big and contains a lot of interesting information. But we are interested in |
| 169 | +the `interface_gets_implemented` callback which is called when some class trying to implement concrete interface. Do you |
| 170 | +remember about `Throwable` class that throws an error when you are trying to add this interface to your class? This is |
| 171 | +because `Throwable` class has such handler installed that prevents implementation of this interface in user-land. |
| 172 | + |
| 173 | +We are going to use this hook for our `ImmutableInterface` interface to adjust original class behaviour. `Z-Engine` |
| 174 | +provides a method called `ReflectionClass->setInterfaceGetsImplementedHandler()` that is used for installing custom |
| 175 | +`interface_gets_implemented` callback. |
| 176 | + |
| 177 | +But how we will make existing class and objects immutable? Ok, let's have a look at one more structure, called |
| 178 | +`zend_object`. This structure represents an object in PHP. |
| 179 | + |
| 180 | +```text |
| 181 | +struct _zend_object { |
| 182 | + zend_refcounted_h gc; |
| 183 | + uint32_t handle; |
| 184 | + zend_class_entry *ce; |
| 185 | + const zend_object_handlers *handlers; |
| 186 | + HashTable *properties; |
| 187 | + zval properties_table[1]; |
| 188 | +}; |
| 189 | +``` |
| 190 | +You can see that there is handle of object (almost not used), there is a link to class entry (`zend_class_entry *ce`), |
| 191 | +properties table and strange `const zend_object_handlers *handlers` field. This `handlers` field points to the list of |
| 192 | +object handlers hooks that can be used for object casting, operator overloading and much more: |
| 193 | + |
| 194 | +```text |
| 195 | +struct _zend_object_handlers { |
| 196 | + /* offset of real object header (usually zero) */ |
| 197 | + int offset; |
| 198 | + /* object handlers */ |
| 199 | + zend_object_free_obj_t free_obj; /* required */ |
| 200 | + zend_object_dtor_obj_t dtor_obj; /* required */ |
| 201 | + zend_object_clone_obj_t clone_obj; /* optional */ |
| 202 | + zend_object_read_property_t read_property; /* required */ |
| 203 | + zend_object_write_property_t write_property; /* required */ |
| 204 | + zend_object_read_dimension_t read_dimension; /* required */ |
| 205 | + zend_object_write_dimension_t write_dimension; /* required */ |
| 206 | + zend_object_get_property_ptr_ptr_t get_property_ptr_ptr; /* required */ |
| 207 | + zend_object_get_t get; /* optional */ |
| 208 | + zend_object_set_t set; /* optional */ |
| 209 | + zend_object_has_property_t has_property; /* required */ |
| 210 | + zend_object_unset_property_t unset_property; /* required */ |
| 211 | + zend_object_has_dimension_t has_dimension; /* required */ |
| 212 | + zend_object_unset_dimension_t unset_dimension; /* required */ |
| 213 | + zend_object_get_properties_t get_properties; /* required */ |
| 214 | + zend_object_get_method_t get_method; /* required */ |
| 215 | + zend_object_call_method_t call_method; /* optional */ |
| 216 | + zend_object_get_constructor_t get_constructor; /* required */ |
| 217 | + zend_object_get_class_name_t get_class_name; /* required */ |
| 218 | + zend_object_compare_t compare_objects; /* optional */ |
| 219 | + zend_object_cast_t cast_object; /* optional */ |
| 220 | + zend_object_count_elements_t count_elements; /* optional */ |
| 221 | + zend_object_get_debug_info_t get_debug_info; /* optional */ |
| 222 | + zend_object_get_closure_t get_closure; /* optional */ |
| 223 | + zend_object_get_gc_t get_gc; /* required */ |
| 224 | + zend_object_do_operation_t do_operation; /* optional */ |
| 225 | + zend_object_compare_zvals_t compare; /* optional */ |
| 226 | + zend_object_get_properties_for_t get_properties_for; /* optional */ |
| 227 | +}; |
| 228 | +``` |
| 229 | +But there is one important fact. This field is declared as `const`, this means that it cannot be changed in runtime, we |
| 230 | +need to initialize it only once during object creation. We can not hook into default object creation process without |
| 231 | +writing a C extension, but we have an access to the `zend_class_entry->create_object` callback. We can replace it with |
| 232 | +our own implementation that could allocate our custom object handlers list for this class and save a pointer to it in |
| 233 | +memory, providing API to modify object handlers in runtime, as they will point to one single place. |
| 234 | + |
| 235 | +We will override low-level `write_property` handler to prevent changes of properties for every instance of class. But |
| 236 | +we should preserve original logic in order to allow initialization in class constructor, otherwise properties will be |
| 237 | +immutable from the beginning. Also we should throw an exception for attempts to unset property in `unset_property` hook. |
| 238 | +And we don't want to allow getting a reference to properties to prevent indirect modification like `$obj->field++` or |
| 239 | +`$byRef = &$obj->field; $byRef++;`. |
| 240 | + |
| 241 | +This is how immutable objects in PHP are implemented. Hope that this information will give you some food for thoughts ) |
| 242 | + |
| 243 | +Code of Conduct |
| 244 | +-------------- |
| 245 | + |
| 246 | +This project adheres to the Contributor Covenant [code of conduct](CODE_OF_CONDUCT.md). |
| 247 | +By participating, you are expected to uphold this code. |
| 248 | +Please report any unacceptable behavior. |
| 249 | + |
| 250 | +License |
| 251 | +------- |
| 252 | +This library is distributed under [MIT-licens](LICENSE) and it uses `Z-Engine` library distributed under |
| 253 | +**RPL-1.5** license. |
| 254 | +Please be informed about [Reciprocal Public License 1.5 (RPL-1.5)](https://opensource.org/licenses/RPL-1.5) rules. |
0 commit comments