rfc:mixed_vs_untyped_properties

This is an old revision of the document!


PHP RFC: Harmonise "untyped" and "typed" properties

Introduction

This RFC proposes to remove the distinction between “typed” and “untyped” properties, by treating any property with no type information as though it was declared mixed. This is primarily aimed to reduce confusion around different states, error messages, and behaviours; to do so, it makes the language stricter in some circumstances, and changes the error message in others.

PHP currently has three primary ways of adding properties to an object:

  1. Dynamically. The property is created automatically on a specific instance when it is first assigned to, and can be deleted completely using unset.
  2. Declared, with optional visibility. The property is part of the class definition, and allocated on every instance, even if it is never assigned. The behaviour of unset is complex, hiding but not fully deleting the property.
  3. Declared with a type. In addition to being allocated on every instance, the property is covered by extra guards on assignment to guarantee its type. If it is never assigned a value, or passed to unset, it is assigned a special “uninitialized” state.

The different behaviours of these properties are largely a result of the history of the language, rather than a consistent design. In particular, with the addition of the ''mixed'' type, it would seem logical for private $foo; to be short-hand for private mixed $foo;, since no type-guards are needed; but this is not currently the case, due to the different handling of initial states and unset.

Current Behaviour

Initial State and Unset

The three types of property vary in their initial state, and in their state after calling unset, as can be seen in these three demos, which run the same code with the three types of property: dynamic property, untyped property, typed property.

The states can be summarised in this table:

Property Declaration Initial state After assignment After unset After re-assignment
#[AllowDynamicProperties] Undefined Defined, public Undefined Defined, public
private $foo; null Defined, private ? Defined, private
private mixed $foo; Uninitialized Defined, private Uninitialized Defined, private


Where:

  • “Defined” is straight-forward: the property exists, and can be read subject to visibility constraints.
  • “Undefined” means the property does not exist on the object. It does not show up in views such as var_dump. Attempting to read it currently produces a Warning, but will produce an Error in PHP 9.0.
  • “Uninitialized” is a special state introduced as part of the introduction of typed properties to handle cases where neither null nor an inline initializer can be used. The property is still treated as present on the instance, but with a special value/state; in the example, the var_dump output shows this as [“foo”:“A”:private] => uninitialized(mixed)

The state marked “?”, for untyped properties after unset, is a complex one:

  • In output such as var_dump, it is not listed, as with “undefined”
  • *Reading* it gives a Warning (and future Error) of “Undefined property”
  • Reading or writing still obeys the original visibility constraint - reading an unset private property from outside the class gives “Cannot access private property”, not “Undefined property”
  • Writing to it does not give the deprecation notice for “Creation of dynamic property”; instead, the original declaration (including any visibility specifier) is silently re-used

Dynamic properties will be prohibited on most classes in 9.0, giving the following if we don't make other changes:

var_dump output Error on read Error on write
Not declared on class Not shown “Undefined property” “Creation of dynamic property”
(unless on stdClass or with #[AllowDynamicProperties])
Declared then unset Not shown “Undefined property” None
Typed and uninitialized uninitialized(mixed) “must not be accessed before initialization” None

Variance under inheritance

In a “gradual typing” system such as PHP's, any type that is unspecified is usually analysed as though it has the widest possible type for that position. The language itself makes such an analysis for enforcing correct variance rules in inheritance:

  • A method parameter with no type specified is equivalent to mixed. As input is contravariant, sub-classes cannot change to any narrower type, but can freely add or omit the mixed keyword.
  • A method which specifies no return type may return any type, or the pseudo-types void and never; conceptually, it returns mixed|void|never (although that union cannot be specified explicitly). As return types are covariant, sub-classes can change to any narrower type, including mixed; but omitting the type if the parent specified it would be widening the type to mixed|void|never, so is not allowed.

Properties are invariant (as they can be both written to and read from), so sub-classes must declare them with an exactly equivalent type. As with parameters, the widest possible type is mixed, but this is not currently considered equivalent.

class A {
    /** @var mixed $untyped */
    public $untyped;
 
    public mixed $mixed;
 
    /** @param mixed $a */
    public function acceptsUntyped($a) {}
 
    public function acceptsMixed(mixed $a) {}
 
    public function returnsMixed(): mixed {}
 
    /** @return mixed|void|never */
    public function returnsUntyped() {}
}
 
class B extends A {
    # Not Allowed: considers "mixed" to be distinct from "untyped"
    public mixed $untyped;
    # Also Not Allowed
    public $mixed;
 
    # Allowed: unspecified parameter type is implicitly "mixed", so no variance occurs
    public function acceptsUntyped(mixed $a) {}
    # Also Allowed
    public function acceptsMixed($a) {}
 
    # Not Allowed: widens return type from "mixed" to implicit "mixed|void|never"
    public function returnsMixed() {}
 
    # Allowed: narrows return type from implicit "mixed|void|never" to explicit "mixed"
    # explicit return types of "void" and "never" can also be used here
    public function returnsUntyped(): mixed {}
}

Proposal

Calling ''unset'' on any declared property will result in "uninitialized"

The behaviour of unset will be standardised for all declared properties, regardless of whether a type was included in the declaration.

For an untyped property, this means:

  • The property will show up in var_dump and similar output, with a special type of “uninitialized”
  • The error given when attempting to access the variable will refer to it as “uninitialized” rather than “undefined”

For typed properties, the current error message reads:

Typed property %s::%s must not be accessed before initialization

This will be changed, for both typed and untyped properties, to:

Property %s::%s must not be accessed before initialization

Properties will default to an implicit type of ''mixed''

If no type is specified for a property, its type will be analysed as mixed, as is the case with parameters.

Declared properties will no longer default to null

The remaining difference is the initial value of the property. To be fully consistent, we should do one of two things:

  1. Initialize any property to null if that is a valid value, and it has no other initializer
  2. Never initialize a property to null unless that initial value is explicitly specified

Option 1 has the advantage of not causing errors in any currently valid code; but it goes against the general trend of making the language stricter and more explicit.

Option 2 is a non-trivial breaking change; although the edits to be made can be trivially automated (changing code of the form public $foo; to public $foo = null;), they will be very widespread. Users may rightly question the value of requiring such an edit.

Instead, a compromise is proposed: if a property has neither a type nor an initializer, treat it as though it had a type of mixed and an initializer of null.

In other words, given the following class:

class A {
    public $foo;
    private $bar;
    protected $other = 42;
}

Act as though this was specified:

class A {
    public mixed $foo = null;
    private mixed $bar = null;
    protected mixed $other = 42;
}

This has the downside that adding the keyword mixed to a declaration may still change the behaviour of a program, since it will change the initial state to “uninitialized”; but it retains the behaviour of all existing code without any action from users.

Backward Incompatible Changes

None in current proposal.

Proposed PHP Version(s)

TODO Is this all now a 9.0 target?

In PHP 8.next:

  • Add deprecation notice for untyped properties accessed without initialization

In PHP 9.0:

  • All declared properties will start “uninitialized”, rather than implicitly initialized to null, unless an explicit initial value is given
  • All declared properties will become “uninitialized” when passed to unset
  • The message for accessing uninitialized properties will be changed to remove reference to “typed properties”

RFC Impact

To Opcache

No new functionality is added in the final proposed state. Indeed, some edge cases that currently need to be handled may be removed.

To Reflection

TODO There are presumably special representations for “untyped” vs “mixed” properties...

Open Issues

  • Readonly properties were restricted to typed properties, citing the default null initializer as rationale; should this restriction be explicitly relaxed as part of this RFC?
  • Are there other differences between typed and untyped properties to address?
  • Can uninitialized untyped properties be implemented efficiently during the deprecation period?
  • Is the removal of implicit null initializers controversial, and are there alternative approaches?

Unaffected PHP Functionality

  • The behaviour of dynamic properties, that is those not defined at all in the class definition, is not changed by this RFC.
  • The interaction of unset and magic __get is the same for untyped and typed properties, so will not be affected by harmonising them. Specifically, following unset($foo->bar); a subsequent read of $foo->bar will call $foo->__get('bar') if available: https://3v4l.org/D16Tv

Future Scope

None identified at present.

Proposed Voting Choices

TODO: Either a straight vote for the whole proposal, or separate proposals for initial state and unset behaviour, depending on initial feedback.

Patches and Tests

TODO

References

Rejected Features

None yet.

rfc/mixed_vs_untyped_properties.1700435260.txt.gz · Last modified: 2023/11/19 23:07 by imsop