DEV Community

Wes
Wes

Posted on • Edited on

Unified symbol tables for properties and methods in PHP

Introduction

If you ever worked with the callable type, you probably already know its quirks (... and even more quirks). I bet you hate them as much as I do. In short, a callable that once passed a callable type declaration, not necessarily will pass all callable type declarations (example). I think the callable type was a bad idea altogether, and I think modern PHP code can be much better than that.

Closures type declarations have no context-dependent behavior. A Closure reference is actually callable everywhere.

What we all should be doing, instead, is:

<?php
//  1- Retrieve the Closure from a callable just once
//  2- Only pass around Closures and always type-hint for Closure
//     ...
// 42- Profit!
public function bar(Closure $in): Closure{
    $in();
    return Closure::fromCallable([$this, "method"]);
}
Enter fullscreen mode Exit fullscreen mode

Making it nicer with Symbola

I wrote a small tool that improves the way we access object members. You can immediately install it and try it out using composer (composer require netmosfera/symbola) or have a look at it on github.

Here is what it does:

<?php

use Netmosfera\Symbola\Symbola;

class Bar
{
    use Symbola;

    public $myProperty;

    function __construct(Closure $p){
        $this->myProperty = $p;
    }

    public function myMethod(){
        echo "myMethod() called!\n";
    }    
}

$p = function(){
    echo "myProperty() called!\n";
};

$bar = new Bar($p);

// Feature #1:
// Call a property like a method:
$bar->myProperty(); // myProperty() called!

// Feature #2:
// Reference a method like a property:
$methodReference = $bar->myMethod;
$methodReference(); // method() called!
Enter fullscreen mode Exit fullscreen mode

Visibility support

Properties can be called only from compatible scopes, and method handles can be retrieved only from compatible scopes:

<?php

use Netmosfera\Symbola\Symbola;

class Bar
{
    use Symbola;

    private $myProperty;

    function __construct(){
        $this->myProperty = function(){
            echo "myProperty() called!\n";
        };
    }

    private function myMethod(){
        echo "myMethod() called!\n";
    }

    public function callProperty(){
        $this->myProperty();
    }

    public function getMethod(): Closure{
        return $this->myMethod;
    }
}

$bar = new Bar();

//------------------------------------------------------------------------------------------

try{
    // The property is private, it cannot be called publicly:
    $bar->myProperty();
} catch(Error $e){
    echo $e->getMessage() . "\n";
    // Error: Referenced the either undefined or
    // non-public object member `Bar::myProperty`
}

//------------------------------------------------------------------------------------------

try{
    // The method is private, it cannot be referenced publicly:
    $methodReference = $bar->myMethod;
} catch(Error $e){
    echo $e->getMessage() . "\n";
    // Error: Referenced the either undefined or
    // non-public object member `Bar::myMethod`
}

//------------------------------------------------------------------------------------------

// The property can be called privately, however:
$bar->callProperty(); // myProperty() called!

// Similarly, the method can be referenced privately,
// and the obtained reference is free to be passed to
// other scopes:
$methodReference = $bar->getMethod();
$methodReference(); // myMethod() called!
Enter fullscreen mode Exit fullscreen mode

Obviously, protected is also supported; a protected method can be referenced only within the class' hierarchy, and a protected property can be called only within the class' hierarchy.

Equatable Closures

A problem that Closure::fromCallable() has, is that the returned objects cannot be compared for equality (latest PHP version tested is 7.2):

<?php

class A{ function bar(){} }
$a = new A;
$c1 = Closure::fromCallable([$a, "bar"]);
$c2 = Closure::fromCallable([$a, "bar"]);
assert($c1 === $c2); // Fail!
Enter fullscreen mode Exit fullscreen mode

But Symbola solves this by keeping a handle for each of the created Closures, which is then reused when needed:

<?php

use Netmosfera\Symbola\Symbola;

class A{ use Symbola; function bar(){} }
$a = new A;
$c1 = $a->bar;
$c2 = $a->bar;
assert($c1 === $c2); // Works!
Enter fullscreen mode Exit fullscreen mode

The handles for Closures are kept in the object itself and they are garbage-collected when the object is destroyed, thus naturally limiting the number of objects created in the program.

What's $this set to?

$this in property-Closures is not rebound to the host class' $this; if this is needed, it must be performed manually using Closure::bind().

<?php

use Netmosfera\Symbola\Symbola;

class Baz
{
    use Symbola;

    public $qux;

    function __construct($qux){
        $this->qux = $qux;
    }
}

class Foo
{
    function getClosure(): Closure{
        return function(){
            assert($this instanceof Foo);
        };
    }
}

$foo = new Foo();
$baz = new Baz($foo->getClosure());
$baz->qux(); // $this in qux() is $foo, not $baz
Enter fullscreen mode Exit fullscreen mode

IDE support

One of the worst problems with callable is the lack of static analysis. IDEs really struggle distinguishing between strings/arrays and callables.

<?php

// I look like a simple string, but trust me,
// I'm actually a callable ¯\_(ツ)_/¯ 
$foo = "baz";

// ...

// ಠ_ಠ
$foo();
Enter fullscreen mode Exit fullscreen mode

With Symbola, magic functionality can be annotated using phpdoc's @property and @method:

<?php

use Netmosfera\Symbola\Symbola;

/**
 * @property Closure $bar
 * @method int qux(float $a)
 */
class Baz
{
    use Symbola;

    public $qux;

    function __construct(){
        $this->qux = function(float $a): int{};
    }

    function bar(){}
}
Enter fullscreen mode Exit fullscreen mode

Now, that's far from being nice, but... it's something. Refactoring works, static analysis works (mostly), search works.

Result in PHPStorm

What's missing

  • Function-scope static variables don't work, but nobody uses them, right? They are a mess regardless.

  • Static methods and properties are also not supported. I don't think it's important to add support only for __callStatic given the lack of a __getStatic magic method.

  • It is not possible to create Closures out of parent:: methods. I might add this at some point; e.g. Symbola::parent("method") as alternative to Closure::fromCallable("parent::method").

  • Probably something else, let me know what :-P

Installation

composer require netmosfera/symbola
Enter fullscreen mode Exit fullscreen mode

Symbola on GitHub

I hope I convinced you to try it out, I bet you will like it!
Thanks for reading, and let me know what you think in the comments.

Top comments (0)