-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Add support for "write-once" properties #5186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
I think this is a good idea, and the keyword final is quite appropriate here. Java has final class/method/property, while C# doesnt have the final keyword and instead use other keywords like 'sealed' and 'readonly'. From the aspect of consistency, the keyword final is very suitable for immutable properties too. |
@HallofFamer Unfortunately, I won't propose final as a keyword in the RFC :( In PHP, |
This doesn't implement the "references are not allowed" part yet, right? |
I would say I only had a (really bad) try at it, but haven't looked at it again since then... php-src/Zend/zend_object_handlers.c Line 1054 in 6513e68
|
Maybe I missed it in RFC, but what happens when you call next($this->c); ? Will it move the internal pointer (of an array) or throw an exception? |
@mart-k I've just done a quick test, and actually, |
@kocsismate Err, why would next() work? It modifies the array, so it should not be permitted. Note that foreach(), ArrayIterator etc all work independently of the internal array pointer and can work fine with a readonly property. |
Oh, I thought that the internal pointer is used during iteration, so my reasoning was that then |
One of the reasons why I pointed next() out is that there are whole family of similar functions that read/write array's "internal pointer". For example what happens if you call current()? It is read only and doesn't change the pointer, but if next(), each() etc. give error, will allowing current() even make sense? |
@mart-k Calling |
@kocsismate Right, I don't see why these need any special consideration. They are normal by-value or by-reference functions, depending on whether they perform modification or not. |
What about clonable objects? There is a popular Like this: <?php
class C
{
writeonce public int $prop;
public function __construct(int $prop)
{
$this->prop = $prop;
}
public function withProp(int $prop): self
{
$that = clone $this;
$that->prop = $prop; // will fail?
return $that;
}
}
$instance = new C(1);
$newInstance = $instance->withProp(2); |
@someniatko Cloning is currently under debate. My point of view is that it's not a prerequisite for writeonce/readonly properties since you can create a completely new instance instead of using |
IMO, this suggestion is extremely unsettling. Probably on the level of singletons - it produces a sudden behavior change that can't be explained by surrounding code logic. If you want special actions taken for writing data to the property, a more generic approach can be taken. Something like [static] property [typehint] $name [write methodName]; where It is arguable if a setter requires |
What would happen with property reflection? I haven't read about reflection in the RFC, I guess it should be taken into account and add a method to identify whether a property is immutable or not. |
@Aaronidas yeah, I'll definitely add a new |
@kocsismate i think you should be consistent and name it |
@someniatko sorry, I was a bit unclear: the actual method name depends on the outcome of the secondary vote which is about the keyword choice. |
59f47ad
to
b0a042d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm missing tests for the "lazy initialization" pattern, that is unset the uninit property and use __get() to perform initialization.
I also saw that you do allow acquiring references via foreach-by-ref -- is that intentional? Basically, we should either be allowing all references (and preventing assignments to them) or forbidding all references. Allowing some references combines the disadvantages of both :)
Zend/tests/final_properties/access/property_incrementing_decrementing_by_ref.phpt
Outdated
Show resolved
Hide resolved
Zend/tests/final_properties/inheritance/inheritance_success1.phpt
Outdated
Show resolved
Hide resolved
Zend/tests/final_properties/inheritance/inheritance_success2.phpt
Outdated
Show resolved
Hide resolved
@nikic My newest commit addresses some of your concerns, like adding the missing tests and inheritance checks. Thanks to your help I think I managed to make the "foreach-by-ref"-related tests work. However, lazy initialization didn't yet work because |
b0a042d
to
9dd62b8
Compare
Zend/tests/final_properties/access/property_by_ref_access3.phpt
Outdated
Show resolved
Hide resolved
9dd62b8
to
184c879
Compare
@@ -1502,6 +1532,14 @@ ZEND_API zval *zend_std_get_static_property_with_info(zend_class_entry *ce, zend | |||
ret = CE_STATIC_MEMBERS(ce) + property_info->offset; | |||
ZVAL_DEINDIRECT(ret); | |||
|
|||
if (UNEXPECTED((type == BP_VAR_RW || type == BP_VAR_W) && property_info->flags & ZEND_ACC_FINAL && Z_TYPE_P(ret) != IS_UNDEF)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm... I think there's a pretty big problem here :(
We currently compile the operands of property accesses with the property access mode, i.e. Foo::$bar->baz = 1
is going to perform a W fetch on the static property, rather than the R fetch you would expect.
This means that code like this is going to error, while it should not:
<?php
class Foo {
final public static stdClass $prop;
}
Foo::$prop = new stdClass;
Foo::$prop->bar = 1;
Probably more test coverage for modification of objects stored in readonly properties is needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to fix this in #3865, but ran into some compatibility issues with SimpleXML.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds really bad. :/ But I'll add more tests of course. Do you have any idea if this issue is a deal breaker or could be somehow circumvented? Btw is it worth to have SimpleXML in core if it has issues like this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the record, I opened #5250 and started a thread at https://externals.io/message/108950.
Btw is it worth to have SimpleXML in core if it has issues like this?
It's not, but people will complain if we drop it :)
if (result) ZVAL_ERROR(result); | ||
return 0; | ||
} | ||
break; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this code actually do anything? I don't see any corresponding compiler code that sets these flags, so I would expect it to never run.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, not much. I put it as an earlier experimentation (without checking the compiler), but forgot to remove it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, or is it possible that I'll need this code? Currently, I'm trying to implement the restriction for the ZEND_FETCH_OBJ_UNSET
opcode. I see that zend_fetch_property_address()
is called from the opcode handler with flags=0
. However, should I have this piece of code while setting flags
to 1 then maybe unset($foo->a[0])
could be checked? Or is there a better way?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think you're going to need at least the UNSET portion of this, in order to distinguish between unset($foo->a[0])
and unset($foo->a->b)
(otherwise you could just check for BP_VAR_UNSET). You'll have to add the corresponding flag in the compiler (like it is done for the two flags that are checked above). One value was freed up in PHP 8, so it should fit in.
I'm missing some tests for |
@@ -0,0 +1,104 @@ | |||
--TEST-- | |||
Test that final properties can't be mutated by assignment operators |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests might not be very useful because all of them boil down to simple assignments from the perspective of "write-once" properties
} | ||
|
||
try { | ||
$foo->property2 ??= "foo"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't managed to make this case work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This only does an assignment if the property is null. So it's correct that this doesn't error as-is, you'd have to use a different default value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What a little bastard :'D But thanks for the info, it was too difficult for me to discover how it really works.
One more thing to test would be by-ref passing. For example |
2041586
to
4b4cd0c
Compare
Zend/zend_vm_def.h
Outdated
@@ -1804,7 +1804,7 @@ ZEND_VM_HELPER(zend_fetch_static_prop_helper, ANY, ANY, int type) | |||
|
|||
SAVE_OPLINE(); | |||
|
|||
if (UNEXPECTED(zend_fetch_static_property_address(&prop, NULL, opline->extended_value & ~ZEND_FETCH_OBJ_FLAGS, type, opline->extended_value & ZEND_FETCH_OBJ_FLAGS OPLINE_CC EXECUTE_DATA_CC) != SUCCESS)) { | |||
if (UNEXPECTED(zend_fetch_static_property_address(&prop, NULL, opline->extended_value & ~(type == BP_VAR_UNSET ? ZEND_FETCH_DIM_UNSET_FLAG : ZEND_FETCH_OBJ_FLAGS), type, opline->extended_value & (type == BP_VAR_UNSET ? ZEND_FETCH_DIM_UNSET_FLAG : ZEND_FETCH_OBJ_FLAGS) OPLINE_CC EXECUTE_DATA_CC) != SUCCESS)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is still a work in progress part
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could add tests involving unserialize() and final properties - For example, setting final properties with or without defaults in __unserialize() or __wakeup(), or no magic methods at all, or after unserialization (should throw if defined, and not throw if undefined).
Unserializers have been problematic, e.g. for typed properties https://bugs.php.net/bug.php?id=79002 was changed in 7.4.2. I'd guess the implementation probably works, though
Also, are there tests of references to fields of properties (e.g. what is the intended behavior for preg_match('/abc/', 'abcdef', $x->prop['field'])
) - I skimmed the tests and didn't see any
Zend/zend_object_handlers.c
Outdated
if (Z_TYPE_P(variable_ptr) != IS_UNDEF) { | ||
if (UNEXPECTED(Z_PROP_FLAG_P(variable_ptr) != IS_PROP_UNINIT && prop_info && prop_info->flags & ZEND_ACC_FINAL)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this could just be ZEND_ASSERT(prop_info)
before this (only for IS_VALID_PROPERTY_OFFSET
). The prop_info should be set if a valid property reference was null, if I understand how this works correctly.
I'd guess it'd be more efficient as UNEXPECTED(prop_info->flags & ZEND_ACC_FINAL && Z_PROP_FLAG_P(variable_ptr) != IS_PROP_UNINIT)
, because ACC_FINAL would probably be much less common than being initialized.
#define IS_VALID_PROPERTY_OFFSET(offset) ((intptr_t)(offset) > 0)
#define IS_WRONG_PROPERTY_OFFSET(offset) ((intptr_t)(offset) == 0)
#define IS_DYNAMIC_PROPERTY_OFFSET(offset) ((intptr_t)(offset) < 0)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll change the order of the condition, thank you for the pointing this out!
However, it seems to me that it's not possible to add a ZEND_ASSERT(prop_info)
because as far as I saw, prop_info
is only set for typed properties. And the if (UNEXPECTED(prop_info))
condition some lines below also suggests this I think.
echo $exception->getMessage() . "\n"; | ||
} | ||
|
||
$foo->property2 ??= null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could document in the source of the test case that this is the first assignment to property2, and the test chose null
because ??=
would do nothing if the property value was not null and not initialized - I assume others may be as confused as you.
Same for the tests of static.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do! Actually, I was already considering it before, but then changed my mind.
Other edge cases that might need extra code in the unserializer, or warrant tests (I'd definitely think they'd be fixable if they have bugs):
I can imagine serialize() being used with final properties (e.g. to load from the database once and cache in memcache). I'd naively guess the below snippet might throw an exception the way this is currently implemented, and if it doesn't, it'd still be useful to have the test to ensure behavior doesn't accidentally change in the future. <?php
class X {
final public $hasDefault = ['v7'];
}
$x = new X();
$s = serialize($x);
// Desired: This should not throw.
var_dump(unserialize($x)); (I maintain https://github.com/igbinary/igbinary , a compact binary serializer for php) |
@TysonAndre I've started to add tests for serialization. I can say that
I'll continue tomorrow :) |
@TysonAndre Today's findings:
A serialized data like that is currently possible to unserialize. However, these properties can be currently modified via reference. :/.
Currently, an exception is thrown in this case with a message of "Cannot modify final property Foo::$property1 after initialization". I added a test for this case. |
4b4cd0c
to
0845f94
Compare
ext/standard/var_unserializer.re has a check for whether it's possible to assign a value to a typed property while unserializing - I assume a similar error should be thrown if it was write-once. Not completely familiar with this part of the code. if (UNEXPECTED(info)) {
if (!zend_verify_prop_assignable_by_ref(info, data, /* strict */ 1)) {
zval_ptr_dtor(data);
ZVAL_UNDEF(data);
zval_dtor(&key);
goto failure;
}
if (Z_ISREF_P(data)) {
ZEND_REF_ADD_TYPE_SOURCE(Z_REF_P(data), info);
}
} |
@@ -6396,19 +6398,18 @@ void zend_compile_prop_decl(zend_ast *ast, zend_ast *type_ast, uint32_t flags) / | |||
"Property %s::$%s cannot have type %s", | |||
ZSTR_VAL(ce->name), ZSTR_VAL(name), ZSTR_VAL(str)); | |||
} | |||
} else if (flags & ZEND_ACC_FINAL) { | |||
zend_error_noreturn(E_COMPILE_ERROR, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I missed this part of the RFC. https://wiki.php.net/rfc/write_once_properties#compile-type_restrictions
Sadly, there's no way to write final public resource $fileHandle;
, because resource
isn't a type declaration in PHP (to give the flexibility to switch resources to objects).
I'd wonder if it'd be possible to remove the syntactic restriction internally (for write-once properties) and represent final
as a property type info with "every type" if there is no explicit type, i.e. null|bool|int|float|string|object|array|<resource>
(MAY_BE_RESOURCE) - I don't know if that's actually easy to do, though.
- The alternative I have has its own drawbacks of implicitly and unexpectedly using the uninitialized state of typed properties, so enforcing manually adding a type is probably the best approach for now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think something similar has already been discussed on the mailing list before. That is, implicitly treating all non-typed properties to be mixed
. I think it would be much simpler and more straightforward to just wait for the mixed
type instead. (I really cheer for it to reach PHP 8).
@@ -209,7 +209,7 @@ typedef struct _zend_oparray_context { | |||
/* Static method or property | | | */ | |||
#define ZEND_ACC_STATIC (1 << 4) /* | X | X | */ | |||
/* | | | */ | |||
/* Final class or method | | | */ | |||
/* Final class, property, or method | | | */ | |||
#define ZEND_ACC_FINAL (1 << 5) /* X | X | | */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update the visual representation in the table as well?
/* Class, property and method flags class|meth.|prop.|const*/
#define ZEND_ACC_FINAL (1 << 5) /* X | X | | */ | |
#define ZEND_ACC_FINAL (1 << 5) /* X | X | X | */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would rather not update it for now because a new flag will be added for sure if the RFC is accepted (unless it's ok if I use the readonly keyword with ZEND_ACC_FINAL
- but I think it's not).
#define ZEND_FETCH_OBJ_FLAGS 3 | ||
#define ZEND_FETCH_REF 1 | ||
#define ZEND_FETCH_DIM_WRITE 2 | ||
#define ZEND_FETCH_DIM_UNSET_FLAG 3 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is deliberately the same value as ZEND_FETCH_OBJ_FLAGS, document the reason why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, this is just because the flag that was removed from the same place a while ago had the same value. I'll have another shot with this flag since I couldn't make it work. :)
0845f94
to
21f8405
Compare
@kocsismate The RFC seems to be not accepted, do you know why and do you plan a revision/revote? |
@mvorisek I believe these were the main reasons against: some people would prefer property accessors, and some people disliked the fact that cloning doesn't play well with readonly properties. I'll certainly try to revisit this for PHP 8.1. :) |
Old school style <?php
class Point {
protected int $x;
protected int $y;
public function __construct(int $x, int $y = 0) {
$this->x = $x;
$this->y = $y;
}
} PHP 8.0.0 style <?php
class Point {
public function __construct(protected int $x, protected int $y = 0) {
}
} This is all good but I still need to write getters. And I'm disappointed that I don't see properties in the class description <?php
class Point {
public function __construct(protected int $x, protected int $y = 0) {
}
public function getX(): int
{
return $this->x;
}
public function getY(): int
{
return $this->y;
}
} That would be a great option for example if I want to use a class for DTO <?php
class Point {
readonly public int $x;
readonly public int $y;
} Example <?php
class Point {
readonly public int $x;
readonly public int $y;
}
$point = new Point();
$point->x = 1;
$point->y = 1;
$arr = (array)$point;
echo json_encode($arr); @kocsismate I will be glad to see this RFC in 8.1 |
Currently, the implementation makes use of the
final
keyword, although the actual name is to be decided.RFC: https://wiki.php.net/rfc/write_once_properties