Re: [RFC[ Property accessor hooks, take 2
| From: | Stephen Reay | Date: | Fri, 01 Mar 2024 06:28:08 +0000 |
| Subject: | Re: [RFC[ Property accessor hooks, take 2 | ||
| References: | 1 2 3 4 5 6 | Groups: | php.internals |
| Request: | Send a blank email to internals+get-122537@lists.php.net to get a copy of this message | ||
> On 28 Feb 2024, at 21:25, Larry Garfield <larry@garfieldtech.com> wrote:
>
> On Wed, Feb 28, 2024, at 7:49 AM, Stephen Reay wrote:
>
> *snip*
>
>>> == Re virtual properties:
>>>
>>> Ilija and I talked this through, and there's pros and cons to a
>>>
virtual keyword. Ilija also suggested a
>>> backed keyword, which forces a backed property to exist even if
>>> it's not used in the hook itself.
>>>
>>> * Adding virtual adds more work for the developer, but
>>> more clarity. It would also mean $this->$propName or $this->{__PROPERTY__} would work
>>> "as expected", since there's no auto-detection for virtual-ness. On the downside, if
>>> you have a could-be-virtual property but never actually use the backing value, you have an extra
>>> backing value hanging around in memory that is inaccessible normally, but will still show up in some
>>> serialization formats, which could be unexpected. If you omit one of the hooks and forget to mark
>>> it virtual, you'll still get the default of the other operation, which could be unexpected.
>>> (Mostly this would be for a virtual-get that accidentally has a default setter because you forgot to
>>> mark it virtual.)
>>> * Doing autodetection as now, but with an added "make a backing value anyway"
>>> flag would resolve the use case of "My set hook just calls a method, and that method sets the
>>> property, but since the hook doesn't mention the property it doesn't get created"
>>> problem. It would also allow for $this->$propName to work if a property is explicitly backed.
>>> On the flipside, it's one more thing to think about, and the above example it solves would be
>>> trivially solved by having the method just return the value to set and letting the set hook do the
>>> actual write, which is arguably better and more reliable code anyway.
>>> * The status quo (auto-detection based on the presence of $this->propName). This
>>> has the advantage it "just works" in the 95% case, without having to think about anything
>>> extra. The downside is it does have some odd edge cases, like needing $this->propName to be
>>> explicitly used.
>>>
>>> I don't think any is an obvious winner. My personal preference would be for
>>> status quo (auto-detect) or explicit-virtual always. I could probably live with either, though I
>>> think I'd personally favor status quo. Thoughts from others?
>>>
>>
>> I agree that a flag to make the field *virtual* (thus disabling the
>> backing store) makes more sense than a flag to make it backed; It's
>> also easier to understand when comparing hooked properties with regular
>> properties (essentially, backed is the default, you have to opt-in to
>> it being virtual). I don't think the edge cases of "auto" make it
>> worthwhile just to not need "virtual".
>
> Uh, I can't tell if you're saying "use status quo" or "use the virtual
> keyword." Please clarify. :-)
I'm saying I think an explicit virtual keyword is the better option of the three
(because the benefits of the 'auto' mode don't outweigh the edge cases it
introduces).
>
>>> == Re reference-get
>>>
>>> Allowing backed properties to have a reference return creates a situation where any
>>> writes would then bypass the set hook, and thus any validation implemented there. That is, it makes
>>> the validation unreliable. A major footgun. The question is, do we favor caveat-emptor flexibility
>>> or correct-by-construction safety? Personally I always lead toward the latter, though PHP in
>>> general is... schizophrenic about it, I'd say. :-)
>>>
>>> At this point, we'd much rather leave it blocked to avoid the issue; it's
>>> easier to enable that loophole in the future if we really want it than to get rid of it if it turns
>>> out to have been a bad idea.
>>>
>>> There is one edge case that *might* make sense: If there is no set hook defined, then
>>> there's no set hook to worry about bypassing. So it may be safe to allow &get on backed
>>> properties IFF there is no set hook. I worry that is "one more quirky edge case", though,
>>> so as above it may be better to skip for now as it's easier to add later than remove. But if
>>> the consensus is to do that, we're open to it. (Question for everyone.)
>>>
>>
>> I don't have strong feeling about this, but in general I usually tend
>> to prefer options that are consistent, and give power/options to the
>> developer. If references are opt-in anyway, I see that as accepting the
>> trade-offs. If a developer doesn't want to allow by-ref modifications
>> of the property, why would they make it referenceable in the first
>> place? This sounds a bit like disallowing regular public properties
>> because they might be modified outside the class - that's kind of the
>> point, surely.
>>
>>> == Re
>>>
>>> == Re arrays
>>>
>>>> Regarding arrays, have you considered allowing array-index writes if
>>> an &get hook is defined? i.e. "$x->foo['bar'] = 42;" could
>>> be treated
>>> as semantically equivalent to "$_temp =& $x->foo; $_temp['bar'] =
>>> 42;
>>> unset($_temp);"
>>>
>>> That's already discussed in the RFC:
>>>
>>>> The simplest approach would be to copy the array, modify it accordingly, and pass
>>>> it to set hook. This would have a large and obscure performance penalty, and a set implementation
>>>> would have no way of knowing which values had changed, leading to, for instance, code needing to
>>>> revalidate all elements even if only one has changed.
>>>
>>> Unless we were OK with that bypassing the set hook entirely if defined, which, as noted
>>> above, means any safety guarantees provided by a set hook are bypassed, leading to untrustworthy
>>> code.
>>>
>>> == Re hook shorthands and return values
>>>
>>> Ilija and I have been discussing this for a bit, and we've both budged a little.
>>> :-) Here's our counter-proposal:
>
> *snip*
>
>> I think the examples given are clear, and the lack of the top-level
>> short closure-esque version makes it more obvious. Forgive me, I must
>> have missed some of the previous comments - is there a reason the
>> 'full' setter can't return a value, for the sake of consistency? I
>> understand that you don't want "return to set" to be the *only* option,
>> for the sake of e.g. change/audit logging type functionality (i.e. set
>> and then some action to record that the change was made), but it seems
>> a little odd and inconsistent to me that the return value of a short
>> closure would be used when the return value of the long version isn't.
>> This isn't really a major issue, I'm just curious if there was some
>> explanation about it?
>
> Mainly because it introduces a lot more complexity. As far as I'm aware, determining at
> runtime whether or not to make use of the return value is impossible. (Ie, we cannot differentiate
> between "return", "no return statement", and "return null".) So it
> would require compile time branching to generate two different pathways after lexically detecting if
> there is a "return" token present somewhere in the hook body. That is probably doable,
> technically, but introduces more complexity to an already necessarily-large RFC. It's also
> something that is simple enough to add later as an option without any BC breaks or implications for
> other parts of the design, so it's safe to punt. (Some things are harder to punt on than
> others, as noted, but this one is easy/safe to skip for now.)
>
Right, i hadn't considered that whole "everything implicitly returns, even if it's
null" scenario. Makes sense. The inconsistency is still a bit jarring but I understand the
reasoning now, thanks.
>>> == Re the $value variable in set
>
> *snip*
>
>>> So what makes the most sense to me is to keep $value optional, but IF you specify an
>>> alternate name, you must also specify a type (which may be wider). So these are equivalent:
>>>
>>> public int $foo { set (int $value) => $value + 1 }
>>> public int $foo { set => $value + 1 }
>>>
>>> And only those forms are legal. But you could also do this, if the situation called
>>> for it:
>>>
>>> public int $foo { set(int|float $num) => floor($num) + 1; }
>>>
>>> This "all or nothing" approach seems like it strikes the best balance, gives
>>> the most flexibility where needed while still having the least redundancy when not needed, and when
>>> a name/type is provided, its behavior is the same as for a method being inherited.
>>>
>>> Does that sound acceptable? (Again, question for everyone.)
>>>
>>
>> My only question with this is the same as I had in an earlier reply
>> (and I'm not sure it was ever answered directly?), and you allude to
>> this yourself: everywhere *else*, ($var) means a parameter
>> with type
>> mixed. Why is the type *required* here, when you've
>> specifically said
>> you want to avoid boilerplate? If we're going to assume people can
>> understand that `(implicit property-type $value) is implicit, surely we
>> can also assume that they will understand "specifying a parameter
>> without a type" means the parameter has no type (i.e. is
>> mixed).
>>
>> Again, for myself I'd be likely to type it (or regular parameters,
>> properties, etc) as mixed if that's what I want
>> *anyway*, but the
>> inconsistency here seems odd, unless there's some until-now unknown
>> drive to deprecate type-less parameters/properties/etc.
>
> If we went this route, then an untyped set param would likely imply "mixed", just
> like on methods. Which, since mixed is the super type of everything, would still technically work,
> but would weaken the type enforcement and thus static analysis potential. (Just as a method param
> can be widened in a child class all the way to mixed/omitted, and it would be unwise for all the
> same reasons.)
>
Having a mixed param in the set hook shouldn't weaken the actual backing parameter
though - when the hook writes to $this->prop, the parent type is still enforced,
*surely*? If not, why not?
As for how static analysis tools handle this concept - I'd have thought it's too early to
suggest what static analysis tools will or won't support given how much they already support
based on less-formal syntax like docblocks. It's *already* possible to have a property that is
reported (to static analysis tools/IDEs/etc) as a fixed type, but accepts a wider type on write,
with the current mish-mash of typed properties, docblocks, magic getters and setters, and the
bizarre unset behaviour. This is simply converting that into standardised syntax. The
RFC itself proposes a scenario where a wider type is accepted in the set hook. I find it hard to
believe that a static analysis tool can model "this property is Foo but it accepts
string|Foo on write" but not "this property is Foo but it
accepts mixed on write". Heck if that's such a problem what is said tool
going to do when someone *explicitly* widens the parameter to mixed in a set hook?
I would argue that if the language is providing better support for typed properties by adding
'hooks' like this, the need for static analysis of those specific parts reduces greatly -
if someone wants to accept mixed when storing as a string, and convert it in the hook,
and the language can **enforce those types at runtime**, why should some hypothetical static
analysis be a hangup for that?
> In the RFC as currently written, omitted means "derive from the property," which is a
> concept that doesn't exist in methods; the closest equivalent would be if omitting a type in a
> child method parameter meant "use the parent's type implicitly," which is not how
> that works right now.
For the third time: I'm well aware of how parameter types work everywhere else, and that's
why I'm asking why the same behaviour isn't being followed here?
- You've said you want to avoid boilerplate; and
- You've said you would expect most people to just use the implicit same-type
$value parameter; and
- You've acknowledged that the existing 'standard' is that a parameter without a type
is considered mixed; and
- You've acknowledged in your RFC that there is a use-case for wanting to accept a wider type
than what a property stores internally.
So why then is it so unacceptable that the existing standard be followed, such that a set hook with
an "untyped" parameter would be treated as mixed as it is everywhere else?
Yes, I know you said "widening to mixed is unwise". I don't seem to
recall amongst all the type-related previous RFCs, any that suggested that child parameters widening
to mixed (either explicitly or implicitly) should be deprecated, so I'm sorry but
I don't see much value in that argument.
>
> --Larry Garfield
>
Cheers
Stephen