rfc:records
Differences
This shows you the differences between two versions of the page.
Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
rfc:records [2024/07/25 19:31] – further clarifications and details withinboredom | rfc:records [2024/11/17 21:38] (current) – withinboredom | ||
---|---|---|---|
Line 1: | Line 1: | ||
====== PHP RFC: Records ====== | ====== PHP RFC: Records ====== | ||
+ | |||
* Version: 0.9 | * Version: 0.9 | ||
* Date: 2024-07-19 | * Date: 2024-07-19 | ||
- | * Author: Robert Landers, landers.robert@gmail.com | + | * Author: Robert Landers, |
- | * Status: | + | * Status: Under Discussion |
* First Published at: http:// | * First Published at: http:// | ||
===== Introduction ===== | ===== Introduction ===== | ||
- | In modern PHP development, | + | This RFC proposes the introduction |
- | ===== Proposal ===== | + | ==== Value objects |
- | This RFC proposes the introduction of a new record keyword in PHP to define | + | Value objects are immutable objects |
- | === Syntax | + | Consider this example where a function accepts an integer as a user ID, and the ID is accidentally set to a nonsensical value: |
- | == Definition == | + | <code php> |
+ | function updateUserRole(int $userId, string $role): void { | ||
+ | // ... | ||
+ | } | ||
+ | |||
+ | $user = getUser(/ | ||
+ | $uid = $user-> | ||
+ | // ... | ||
+ | $uid = 5; // accidentally sets uid to an unrelated integer | ||
+ | // ... | ||
+ | updateUserRole($uid, | ||
+ | </ | ||
- | A '' | + | Currently, the only solution to this is to use a **class**, but this requires significant boilerplate code. Further, **readonly classes** have many edge cases and are rather unwieldy. |
- | A '' | + | === The solution === |
- | A '' | + | Like arrays, strings, and other values, **record** objects |
- | Static properties and methods are forbidden in a '' | + | Let’s take a look at an updated example using a '' |
<code php> | <code php> | ||
- | namespace Geometry; | + | record UserId(int $id); |
- | interface Shape { | + | function |
- | public | + | // ... |
} | } | ||
- | trait Dimension | + | $user = getUser(/ |
- | public function | + | $uid = $user-> |
- | return | + | // ... |
+ | $uid = 5; | ||
+ | // ... | ||
+ | updateUserRole($uid, | ||
+ | </ | ||
+ | |||
+ | Now, if '' | ||
+ | |||
+ | ===== Proposal ===== | ||
+ | |||
+ | This RFC proposes the introduction of a '' | ||
+ | |||
+ | ==== Syntax and semantics ==== | ||
+ | |||
+ | === Definition === | ||
+ | |||
+ | A **record** is defined by the keyword '' | ||
+ | |||
+ | A **record** may optionally implement an interface using the '' | ||
+ | |||
+ | A **record** may not extend another record or class. | ||
+ | |||
+ | A **record** may contain a traditional constructor with zero arguments to perform further initialization. | ||
+ | |||
+ | A **record** body may contain property hooks, methods, and use traits. | ||
+ | |||
+ | A **record** body may also declare properties whose values are only mutable during a constructor call. At any other time, the property is immutable. | ||
+ | |||
+ | A **record** body may also contain static methods and properties, which behave identically to static methods and properties in classes. They may be accessed using the '' | ||
+ | |||
+ | As an example, the following code defines a **record** named '' | ||
+ | |||
+ | <code php> | ||
+ | namespace Paint; | ||
+ | |||
+ | // Define a record with several primary color properties | ||
+ | record Pigment(int $red, int $yellow, int $blue) { | ||
+ | |||
+ | // property hooks are allowed | ||
+ | public string $hexValue { | ||
+ | get => sprintf("# | ||
+ | } | ||
+ | |||
+ | // methods are allowed | ||
+ | public function | ||
+ | return $this->with( | ||
+ | red: $this-> | ||
+ | yellow: | ||
+ | blue: $this-> | ||
+ | ); | ||
+ | } | ||
+ | |||
+ | // all properties are mutable in constructors | ||
+ | public function __construct() { | ||
+ | $this-> | ||
+ | $this-> | ||
+ | $this-> | ||
+ | } | ||
+ | |||
+ | public function with() { | ||
+ | // prevent the creation of a new Pigment from an existing pigment | ||
+ | throw new \LogicException(" | ||
} | } | ||
} | } | ||
- | record | + | // simple records do not need to define a body |
+ | record | ||
- | record | + | record |
- | use Dimension; | + | public |
- | | + | return |
- | public | + | } |
- | public | + | public |
- | public int $height { get => $this->rightBottom->y - $this->topLeft-> | + | return array_reduce($this->constituents, |
- | + | ||
- | public function area(): float { | + | |
- | return $this-> | + | |
} | } | ||
} | } | ||
</ | </ | ||
- | == Usage == | + | === Usage === |
- | A '' | + | A record may be used much like a class, as the behavior of the two is very similar, assisting |
<code php> | <code php> | ||
- | $rect1 = Rectangle(Point(0, 0), Point(1, -1)); | + | $gray = $bucket-> |
- | $rect2 = $rect1->with(topLeft: Point(0, 1)); | + | </ |
+ | |||
+ | Records are instantiated in a function format, with '' | ||
+ | |||
+ | <code php> | ||
+ | $black = & | ||
+ | $white = & | ||
+ | $blackPaint = & | ||
+ | $whitePaint = & | ||
+ | $bucket = & | ||
+ | |||
+ | $gray = $bucket->mixIn($blackPaint)-> | ||
+ | $grey = $bucket-> | ||
- | var_dump($rect2-> | + | assert($gray === $grey); // true |
</ | </ | ||
- | == Optional parameters and default values == | + | === Optional parameters and default values |
- | A '' | + | A '' |
+ | |||
+ | One or more properties defined in the inline constructor may have a default value declared using the same syntax and rules as any other default parameter in methods/ | ||
<code php> | <code php> | ||
record Rectangle(int $x, int $y = 10); | record Rectangle(int $x, int $y = 10); | ||
- | var_dump(Rectangle(10)); | + | var_dump(&Rectangle(10)); |
</ | </ | ||
- | == Auto-generated | + | === Auto-generated with method |
- | To enhance the usability of records, the RFC proposes | + | To make records |
- | The auto-generated | + | == How the with method works == |
+ | |||
+ | **Named arguments** | ||
+ | |||
+ | The '' | ||
+ | |||
+ | **Variadic arguments** | ||
+ | |||
+ | Variadic arguments from the inline constructor don’t require named arguments in the '' | ||
+ | |||
+ | Using named arguments: | ||
<code php> | <code php> | ||
- | $point1 = Point(3, 4); | + | record UserId(int $id) { |
- | $point2 = $point1-> | + | |
- | $point3 = $point1-> | + | |
- | echo $point1->x; // Outputs: 3 | + | public function __construct() { |
- | echo $point2->x; // Outputs: 5 | + | |
+ | } | ||
+ | } | ||
+ | |||
+ | $userId = & | ||
+ | $otherId = $userId-> | ||
+ | $otherId = $userId->with(serialNumber: | ||
+ | $otherId = $userId-> | ||
</ | </ | ||
- | A developer may define their own '' | + | Using variadic arguments: |
+ | |||
+ | <code php> | ||
+ | record Vector(int $dimensions, | ||
+ | |||
+ | $vector = & | ||
+ | $vector = $vector-> | ||
+ | $vector = $vector-> | ||
+ | $vector = $vector-> | ||
+ | </ | ||
- | === Performance considerations === | + | == Custom with method |
- | To ensure that records are both performant and memory-efficient, the RFC proposes leveraging PHP's copy-on-write | + | A developer may define their own '' |
<code php> | <code php> | ||
- | $point1 | + | record Planet(string $name, int $population) { |
- | $point2 | + | // create a with method that only accepts population updates |
- | $point3 | + | public function with(int $population): |
+ | return parent:: | ||
+ | } | ||
+ | } | ||
+ | $pluto = Planet(" | ||
+ | // we made it! | ||
+ | $pluto = $pluto-> | ||
+ | // and then we changed | ||
+ | $mickey | ||
+ | </ | ||
- | $point4 | + | === Constructors === |
+ | |||
+ | A **record** has two types of constructors: | ||
+ | |||
+ | The inline constructor is always required and must define at least one parameter. The traditional constructor is optional and can be used for further initialization logic, but must not accept any arguments. | ||
+ | |||
+ | When a traditional constructor exists and is called, the properties are already initialized to the values from the inline constructor and are mutable until the end of the method, at which point they become immutable. | ||
+ | |||
+ | <code php> | ||
+ | // Inline constructor defining two properties | ||
+ | record User(string | ||
+ | public string $id; | ||
+ | |||
+ | // Traditional constructor | ||
+ | public function __construct() { | ||
+ | if (!is_valid_email($this-> | ||
+ | throw new InvalidArgumentException(" | ||
+ | } | ||
+ | |||
+ | $this-> | ||
+ | $this-> | ||
+ | | ||
+ | } | ||
+ | } | ||
</ | </ | ||
- | == Cloning and with() | + | ==== Implementing Interfaces ==== |
- | Calling '' | + | A **record** can implement interfaces, but it cannot extend other records or classes, but may use traits: |
- | '' | + | <code php> |
+ | interface Vehicle {} | ||
- | === Equality === | + | interface Car extends Vehicle { |
+ | public function drive(): void; | ||
+ | } | ||
- | A '' | + | interface SpaceShip extends Vehicle { |
+ | public function launch(): void; | ||
+ | } | ||
- | Comparison operations will behave exactly like they do for classes. | + | record FancyCar(string $model) implements Car { |
+ | public function drive(): void { | ||
+ | echo " | ||
+ | } | ||
+ | } | ||
- | ==== Reflection ==== | + | record SpaceCar(string $model) implements Car, SpaceShip { |
+ | public function drive(): void { | ||
+ | echo " | ||
+ | } | ||
+ | |||
+ | public function launch(): void { | ||
+ | echo " | ||
+ | } | ||
+ | } | ||
+ | |||
+ | record Submarine(string $model) implements Vehicle { | ||
+ | use Submersible; | ||
+ | } | ||
+ | |||
+ | record TowTruct(string $model, private Car $towing) implements Car { | ||
+ | use Towable; | ||
+ | } | ||
+ | </ | ||
- | Records in PHP will be fully supported by the reflection API, providing access to their properties | + | ==== Mental models |
- | === ReflectionClass support === | + | From the perspective of a developer, declaring a record declares an object with the same name. The developer can consider the record function (the inline constructor) as a factory function that creates a new object or retrieves an existing object from an array. |
- | '' | + | For example, this would be a valid mental model for a Point record: |
<code php> | <code php> | ||
- | $point = Point(3, 4); | + | record |
- | $reflection | + | public float $magnitude; |
+ | |||
+ | public function __construct() { | ||
+ | | ||
+ | } | ||
- | foreach | + | public function add(Point $point): Point { |
- | | + | return & |
+ | } | ||
+ | |||
+ | public function dot(Point $point): int { | ||
+ | return | ||
+ | } | ||
+ | } | ||
+ | |||
+ | // similar to declaring the following function and class | ||
+ | |||
+ | // used during construction to allow mutability | ||
+ | class Point_Implementation { | ||
+ | public int $x; | ||
+ | public int $y; | ||
+ | public float $magnitude; | ||
+ | |||
+ | public function __construct() { | ||
+ | $this-> | ||
+ | | ||
+ | |||
+ | public function with(...$parameters) { | ||
+ | // validity checks omitted for brevity | ||
+ | $parameters = array_merge([$this->x, $this-> | ||
+ | return Point(...$parameters); | ||
+ | } | ||
+ | |||
+ | public function add(Point $point): Point { | ||
+ | return Point($this-> | ||
+ | } | ||
+ | |||
+ | public function dot(Point $point): int { | ||
+ | return $this->x * $point-> | ||
+ | } | ||
+ | } | ||
+ | |||
+ | // used to enforce immutability but has nearly the same implementation | ||
+ | readonly class Point { | ||
+ | public float $magnitude; | ||
+ | |||
+ | public function __construct(public int $x, public int $y) {} | ||
+ | |||
+ | public function with(...$parameters): self { | ||
+ | // validity checks omitted for brevity | ||
+ | $parameters = array_merge([$this-> | ||
+ | return Point(...$parameters); | ||
+ | } | ||
+ | |||
+ | public function add(Point $point): Point { | ||
+ | return Point($this->x + $point-> | ||
+ | } | ||
+ | |||
+ | public function dot(Point $point): int { | ||
+ | return $this->x * $point-> | ||
+ | } | ||
+ | } | ||
+ | |||
+ | function Point(int $x, int $y): Point { | ||
+ | static $points = []; | ||
+ | |||
+ | $key = hash_object($mutablePoint); | ||
+ | if ($points[$key] ?? null) { | ||
+ | // return an existing point | ||
+ | return $points[$key]; | ||
+ | } | ||
+ | |||
+ | // create a new point | ||
+ | $reflector = new \ReflectionClass(Point_Implementation:: | ||
+ | $mutablePoint = $reflector-> | ||
+ | $mutablePoint-> | ||
+ | $mutablePoint-> | ||
+ | $mutablePoint-> | ||
+ | |||
+ | // copy properties to an immutable Point and return it | ||
+ | $point = new Point($mutablePoint-> | ||
+ | $point-> | ||
+ | return $points[$key] = $point; | ||
} | } | ||
</ | </ | ||
- | === Immutability enforcement === | + | In reality, this is quite different from how it works in the engine, but this provides a mental model of how behavior should be expected to work. |
- | Attempts | + | ==== Performance considerations ==== |
+ | |||
+ | To ensure that records are both performant and memory-efficient, | ||
+ | |||
+ | <code php> | ||
+ | $point1 = & | ||
+ | $point2 = $point1; // No data duplication, | ||
+ | $point3 = Point(3, 4); // No data duplication, | ||
+ | |||
+ | $point4 = $point1-> | ||
+ | $point5 = & | ||
+ | </ | ||
+ | |||
+ | === Cloning and with() === | ||
+ | |||
+ | Calling '' | ||
+ | |||
+ | If '' | ||
+ | |||
+ | ==== Serialization and deserialization ==== | ||
+ | |||
+ | Records are fully serializable and deserializable, | ||
+ | |||
+ | <code php> | ||
+ | record Single(string $value); | ||
+ | record Multiple(string $value1, string $value2); | ||
+ | |||
+ | echo $single = serialize(& | ||
+ | echo $multiple = serialize(& | ||
+ | |||
+ | echo unserialize($single) === & | ||
+ | echo unserialize($multiple) === & | ||
+ | </ | ||
+ | |||
+ | If a record contains objects or values that are unserializable, | ||
+ | |||
+ | ==== Equality ==== | ||
+ | |||
+ | A '' | ||
+ | |||
+ | Comparison operations will behave exactly like they do for classes, which is currently undefined. | ||
+ | |||
+ | === Non-trivial values === | ||
+ | |||
+ | For non-trivial values (e.g., objects, closures, resources, etc.), the '' | ||
+ | |||
+ | For example, if two different DateTime records reference the exact same date and are stored in a record, the records will not be considered equal: | ||
+ | |||
+ | <code php> | ||
+ | $date1 = DateTime(' | ||
+ | $date2 = DateTime(' | ||
+ | |||
+ | record Date(DateTime $date); | ||
+ | |||
+ | $dateRecord1 = Date($date1); | ||
+ | $dateRecord2 = Date($date2); | ||
+ | |||
+ | echo $dateRecord1 === $dateRecord2; | ||
+ | </ | ||
+ | |||
+ | However, this can be worked around by being a bit creative (see: mental model) as only the values passed in the constructor are compared: | ||
<code php> | <code php> | ||
- | try { | + | record Date(string $date) |
- | $property = $reflection-> | + | |
- | | + | |
- | } catch (\ReflectionException $e) { | + | |
- | echo ' | + | $this->datetime = new DateTime($this-> |
+ | } | ||
} | } | ||
+ | |||
+ | $date1 = & | ||
+ | $date2 = & | ||
+ | |||
+ | echo $date1-> | ||
</ | </ | ||
- | === ReflectionFunction for implicit constructor | + | ==== Type hinting ==== |
- | Using '' | + | A '' |
<code php> | <code php> | ||
- | $constructor = new \ReflectionFunction(' | + | function doSomething(\Record $record): void { |
- | echo ' | + | |
- | foreach ($constructor-> | + | |
- | | + | |
} | } | ||
</ | </ | ||
- | === New functions | + | The only method on the interface is '' |
- | * Calling '' | + | ==== Reflection ==== |
- | * A new function, '' | + | |
- | * Calling '' | + | |
- | === var_dump === | + | A new reflection class will be added to support records: '' |
- | Calling | + | * '' |
+ | * '' | ||
+ | * '' | ||
+ | * '' | ||
+ | * '' | ||
+ | * '' | ||
- | < | + | Using '' |
+ | |||
+ | Attempting to use '' | ||
+ | |||
+ | === finalizeRecord() === | ||
+ | |||
+ | The '' | ||
+ | |||
+ | Calling '' | ||
+ | |||
+ | === isRecord() === | ||
+ | |||
+ | The '' | ||
+ | |||
+ | === getInlineConstructor() === | ||
+ | |||
+ | The '' | ||
+ | |||
+ | Invoking the '' | ||
+ | |||
+ | === getTraditionalConstructor() === | ||
+ | |||
+ | The '' | ||
+ | |||
+ | Invoking the '' | ||
+ | |||
+ | === makeMutable() === | ||
+ | |||
+ | The '' | ||
+ | |||
+ | A mutable record can be finalized again using '' | ||
+ | |||
+ | === isMutable() === | ||
+ | |||
+ | The '' | ||
+ | |||
+ | === Custom deserialization example === | ||
+ | |||
+ | In cases where custom deserialization is required, a developer can use '' | ||
+ | |||
+ | < | ||
+ | record Seconds(int $seconds); | ||
+ | |||
+ | $example = & | ||
+ | |||
+ | $reflector = new ReflectionRecord(Seconds:: | ||
+ | $expiration = $reflector-> | ||
+ | $expiration-> | ||
+ | assert($example !== $expiration); | ||
+ | $expiration = $reflector-> | ||
+ | assert($example === $expiration); | ||
+ | </ | ||
+ | |||
+ | ==== var_dump ==== | ||
+ | |||
+ | When passed an instance of a record the '' | ||
+ | |||
+ | <code txt> | ||
record(Point)# | record(Point)# | ||
[" | [" | ||
Line 179: | Line 550: | ||
==== Considerations for implementations ==== | ==== Considerations for implementations ==== | ||
- | A '' | + | A '' |
+ | |||
+ | ==== Autoloading ==== | ||
+ | |||
+ | Records will be autoloaded in the same way as classes. | ||
+ | |||
+ | ==== New Functions ==== | ||
+ | |||
+ | * '' | ||
===== Backward Incompatible Changes ===== | ===== Backward Incompatible Changes ===== | ||
- | No backward | + | |
+ | To avoid conflicts with existing code, the '' | ||
+ | |||
+ | Since '' | ||
===== Proposed PHP Version(s) ===== | ===== Proposed PHP Version(s) ===== | ||
+ | |||
PHP 8.5 | PHP 8.5 | ||
===== RFC Impact ===== | ===== RFC Impact ===== | ||
+ | |||
==== To SAPIs ==== | ==== To SAPIs ==== | ||
+ | |||
N/A | N/A | ||
==== To Existing Extensions ==== | ==== To Existing Extensions ==== | ||
+ | |||
N/A | N/A | ||
==== To Opcache ==== | ==== To Opcache ==== | ||
+ | |||
Unknown. | Unknown. | ||
==== New Constants ==== | ==== New Constants ==== | ||
+ | |||
None | None | ||
==== php.ini Defaults ==== | ==== php.ini Defaults ==== | ||
+ | |||
None | None | ||
===== Open Issues ===== | ===== Open Issues ===== | ||
- | Todo | + | |
+ | * Distill how CoW works, exactly. | ||
+ | * Address conflict with '' | ||
===== Unaffected PHP Functionality ===== | ===== Unaffected PHP Functionality ===== | ||
Line 212: | Line 603: | ||
===== Future Scope ===== | ===== Future Scope ===== | ||
+ | * Records for " | ||
+ | * Short definition syntax for classes | ||
===== Proposed Voting Choices ===== | ===== Proposed Voting Choices ===== | ||
- | Include these so readers know where you are heading and can discuss the proposed voting options. | + | |
+ | 2/3 majority. | ||
===== Patches and Tests ===== | ===== Patches and Tests ===== | ||
Line 221: | Line 615: | ||
===== Implementation ===== | ===== Implementation ===== | ||
- | After the project is implemented, | + | |
- | - the version(s) it was merged into | + | To be completed during |
- | - a link to the git commit(s) | + | |
- | - a link to the PHP manual entry for the feature | + | |
- | - a link to the language specification section (if any) | + | |
===== References ===== | ===== References ===== | ||
- | Links to external references, discussions or RFCs | + | |
+ | * [[https:// | ||
===== Rejected Features ===== | ===== Rejected Features ===== | ||
- | Keep this updated with features that were discussed on the mail lists. | ||
+ | TBD | ||
rfc/records.1721935893.txt.gz · Last modified: 2024/07/25 19:31 by withinboredom