compiler icon indicating copy to clipboard operation
compiler copied to clipboard

Reusable property hooks

Open thekid opened this issue 1 year ago • 2 comments

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/

thekid avatar May 19 '23 06:05 thekid

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 be NULL
  • 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

thekid avatar May 19 '23 06:05 thekid

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.

thekid avatar May 21 '23 20:05 thekid