Search code examples
phpoopinterfaceabstract-classtraits

providing factory methods to other classes


I want to create a GenericCollection class which is instantiated using another class as a template ("objects") and then performs task on it, such as loading objects from a database. My Collection basically looks like this:

final class GenericCollection {
    private $template;
    private $db;

    public function __construct(CollectableInterface $template, DbInterface $db) {
        $this->template = $template;
        $this->db       = $db;
    }

    public function load(array $filter): iterable {
        $results = $this->db->read(['t' => $this->template::tableName()], $filter, $this->template::getFields());
        foreach ($results as $row) {
            yield $this->template::fromArray($row);
        }
    }
}

$collection = new GenericCollection(MyObject::template(), $db);

My issue is with actually providing the common functionality to my object classes. What I want to achieve is

  1. Minimal code duplication, so generic code like the fromArray function should be somewhere once and be reusable in all the various object classes.
  2. Proper type hinting to support my IDE and enforce compatibility at runtime.
  3. Minimal misuse potential, so incomplete code pieces should not be instantiable.

I've tried the following designs (all of which uphold goal 1):

1. Interface + Trait

interface CollectableInterface {
    public static function template(): object;
    public static function getFields(): array;
    public static function tableName(): string;
    public static function fromArray(array $data): object;
}

trait CollectableTrait {
    public static function fromArray(array $data = []): object {
        foreach (get_object_vars($obj = new self) as $property => $default) {
            $obj->$property = $data[$property] ?? $default;
        }
        return $obj;
    }

    public static function template(): object {
        return new self;
    }
}

final class MyObject implements CollectableInterface {
    use CollectableTrait;
    // properties, implementations of tableName() and getFields()
}

This doesn't work because the type hints are incompatible. There's actually no way to make them fit that I'm aware of. Using object seems to be the only way to make the interface and the trait compatible (self fails because the trait doesn't implement the interface, same issue if I explicitly type hint the interface), but then I can't actually type hint the interface in the Collection's constructor (same issue if I use the trait as the type hint, assuming that would even run).

The only way to make this approach work that I came up with is removing pretty much all the type hints, violating my second goal.

2. Abstract class

abstract class CollectableObject {
    public static function fromArray(array $data = []): self {
        foreach (get_object_vars($obj = new self) as $property => $default) {
            $obj->$property = $data[$property] ?? $default;
        }
        return $obj;
    }

    public static function template(): self {
        return new self;
    }

    abstract public static function getFields(): array;
    abstract public static function tableName(): string;
}

final class MyObject extends CollectableObject {
    // properties, implementations of tableName() and getFields()
}

and of course change the type hint in the Collection constructor to CollectableObject.

This doesn't work because I can't instantiate the abstract class in the common methods (new self).

3. non-abstract Class to extend

This is essentially the same as the previous one, but the class isn't made abstract so the common methods can create an instance. This violates goal 3 as this class isn't supposed to be instantiable, but will be by calling CollectableObject::fromArray() even if I make the constructor private to stop new CollectableObject.


Is there any way to achieve all of my goals in this setup? Is my entire premise/overall structure flawed? Is there a different approach or some way to modify one of my approached so it works?


Solution

  • With either a trait or an abstract class, you could instantiate static instead of self:

    Trait

    trait builder {
        public static function buildToo() {
            return new static();
        }
    }
    
    class Baz {
        use builder;
    }
    
    $b = Baz::buildToo();
    var_dump($b);
    
    /* output:
    object(Baz)#2 (0) {}
    */
    

    Abstract Class

    abstract class Foo {
        
        public static function build() {
            return new static();
        }
    }
    
    class Bar extends Foo {}
    
    $a = Bar::build();
    
    /* output:
    object(Bar)#1 (0) {}
    */
    

    These are forms of late static binding.

    Starting with PHP 8, to be released later this month (Nov 2020), you will be even be able to declare static as a return type hint:

    abstract class Foo {
        public static function build(): static
        {
            return new static();
        }
    }