Reusable property hooks
As noted in the "Future Scope" section of the property hooks RFC, reusable package hooks are not part of the first RFC, but the authors @iluuu1994 and @Crell envision it being added later. Here are some ideas of how it could be implemented.
Swift
The concept here is called property wrappers. Here's an example from their documentation:
@propertyWrapper
struct TwelveOrLess {
private var number = 0
var wrappedValue: Int {
get { return number }
set { number = min(newValue, 12) }
}
}
struct SmallRectangle {
@TwelveOrLess var height: Int
@TwelveOrLess var width: Int
}
rectangle.height = 10 // Width is set to 10
rectangle.height = 24 // Width is set to 12
See https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/#Property-Wrappers
Kotlin
The concept here is called property delegates. Here's an example of how these are implemented:
// Syntax
var name: String by NameDelegate()
// Compiled to
val name$delegate = NameDelegate()
var name: String
get() = name$delegate.getValue(this, this::name)
set(value) { name$delegate.setValue(this, this::name, value) }
See https://blog.kotlin-academy.com/kotlin-programmer-dictionary-field-vs-property-30ab7ef70531
Idea for PHP
Going down the same path as Kotlin (which the PHP RFC is inspired quite a bit from) but without introducing any new keywords, we could come up with the following:
class Environment {
// Syntax with "as" (doesn't conflict with syntax highlighting like "use" would)
public string $home as new ByLazy(fn() => getenv('HOME'));
// Compiled to the following which already works with PR #166 merged
private $__home_delegate= new ByLazy(fn() => getenv('HOME'));
public string $home {
get => $this->__home_delegate->get($this, (object)['value' => &$field]);
set => $this->__home_delegate->set($this, (object)['value' => &$field], $value);
}
}
The ByLazy implementation is as follows:
class ByLazy {
public function __construct(private callable $init) { }
public function get($self, $property) {
return ($property->value??= [($this->init)()])[0];
}
public function set($self, $property, $value) {
$property->value= [$value];
}
}
Note: Using an array for a value will allow initializing the property to NULL.
See also
- https://github.com/xp-framework/compiler/pull/166
- https://www.swiftbysundell.com/articles/property-wrappers-in-swift/
- https://kotlinlang.org/docs/delegated-properties.html
- https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.properties/-delegates/
Proof of concept implementation
See https://gist.github.com/thekid/dc12c4c4f4cf3f971b7dbbf4a5cd83b4
The property object holds the following:
-
name- property name as a string -
type- type as a string, may beNULL -
value- a reference to the property value (as seen above)
Delegates
These could be added to a lang.delegate package:
class ByLazy {
public function __construct(private callable $init) { }
public function get($self, $property) {
return ($property->value??= [($this->init)()])[0];
}
public function set($self, $property, $value) {
$property->value= [$value];
}
}
class InitOnly {
public function get($self, $property) {
return $property->value[0];
}
public function set($self, $property, $value) {
if ($property->value) throw new IllegalStateException('Can not be modified after initialization');
$property->value= [$value];
}
}
class Observable {
public function __construct(private callable $observer) { }
public function get($self, $property) {
return $property->value;
}
public function set($self, $property, $value) {
if (false === ($this->observer)($property->value, $value)) return;
$property->value= $value;
}
}
class Configured {
public function __construct(private Properties $config, private ?string $section= null) { }
public function get($self, $property) {
if (null === $property->value) {
$setting= preg_replace_callback('/([a-z]+)([A-Z])/', fn($m) => $m[1].'-'.strtolower($m[2]), $property->name);
$property->value= match ($property->type) {
'bool' => $this->config->readBool($this->section, $setting),
'int' => $this->config->readInteger($this->section, $setting),
'float' => $this->config->readFloat($this->section, $setting),
'string' => $this->config->readString($this->section, $setting),
'array' => $this->config->readArray($this->section, $setting),
};
}
return $property->value;
}
}
class Delegate {
private static $INITONLY;
public static function initonly() { return self::$INITONLY??= new InitOnly(); }
}
Lazy example
class Environment {
public string $user as new ByLazy(function() {
Console::writeLine('Getting environment variable');
return getenv('USER');
});
}
$env= new Environment();
isset($argv[1]) && $env->user= $argv[1];
Console::writeLine($env->user); // Gets env var unless initialized above
Console::writeLine($env->user); // Prints cached copy
Init only example
class Person {
public string $name as Delegate::initonly();
public function __construct($name) { $this->name= $name; }
}
$person= new Person($argv[1]);
try {
$person->name= 'Modified';
} catch (IllegalStateException $e) {
Console::writeLine('Caught expected ', $e);
}
Console::writeLine($person->name);
Observabe example
class Employee {
public Money $salary= new Money(0, Currency::$EUR) as new Observable(function($old, $new) {
if ($old->compareTo($new) < 0) {
Console::writeLine('Prevented salary cut from ', $old, ' -> ', $new, '!');
return false;
}
Console::writeLine($this->name, '\'s salary changing from ', $old, ' -> ', $new);
});
public function __construct(private $name) { }
}
$emp= new Employee('Test');
$emp->salary= new Money(100_000, Currency::$EUR); // Test's salary changing ...
$emp->salary= new Money(90_000, Currency::$EUR); // Prevented salary cut ...
Console::writeLine($emp->salary); // 100,000.00 EUR
Configuration example
title=Test
os[]=Windows
os[]=MacOS
os[]=Un*x
new-window=true
class Preferences {
public string $title as $this->configured;
public array $os as $this->configured;
public bool $newWindow as $this->configured;
public function __construct(private Configured $configured) { }
}
$pref= new Preferences(new Configured(new Properties('config.ini')));
Console::writeLine('Title "', $pref->title, '"'); // Title "Test"
Console::writeLine('OS ', $pref->os); // OS ["Windows", "MacOS", "Un*x"]
Console::writeLine('New window? ', $pref->newWindow); // New window? true
It would be great to have a builtin Property class instead of the stdClass created by the (object) cast. I thought of using the ReflectionProperty class here, too.
For some added safety, delegates could be forced to implement a builtin ReadProperty interface if they only implement get, and ReadWriteProperty if they wish to implement both get and set.
A good showcase of this is the following:
class Entry {
private $attributes= [];
public string $slug {
get => $this->attributes['slug'];
set { $this->attributes['slug']= $value; }
}
public Date $date {
get => $this->attributes['date'];
set { $this->attributes['date']= $value; }
}
// Repeated for a handful more properties
}
This class is used to unmarshal request entities in a PATCH request, meaning not all properties need to be passed, only those that should be changed. We could pass this as a hashmap, with the problem of having to typecast the values ourselves; see https://github.com/thekid/dialog/blob/5d179d53b4c41010165981b1aba55d8376bdc5a3/src/main/php/de/thekid/dialog/api/Entry.php
If we however had this functionality, the above could be shortened to something along the lines of:
class Entry {
private $attributes= [];
public string $slug as new ByAttributes($this->attributes, 'slug');
public Date $date as new ByAttributes($this->attributes, 'date');
// Again, repeated for a handful more properties
}