Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can methods of objects be intercepted when iterating over them as part of a collection?

I'm wondering if an object belonging to a collection class, whilst being iterated on can know it's being iterated and know about the collection class it belongs to? e.g.

<?php
class ExampleObject
{
    public function myMethod()
    {
        if( functionForIterationCheck() ) {
           throw new Exception('Please do not call myMethod during iteration in ' . functionToGetIteratorClass());
        }
    }
}

$collection = new CollectionClass([
    new ExampleObject,
    new ExampleObject,
    new ExampleObject
]);

foreach($collection as $item) {
    $item->myMethod(); //Exception should be thrown.
}

(new ExampleObject)->myMethod(); //No Exception thrown.

I've done some Google'ing and couldn't find anything, I'm guessing it's not possible because it breaks an OOP principal somewhere but thought I'd ask anyway!

like image 585
SlashEquip Avatar asked Aug 14 '17 07:08

SlashEquip


2 Answers

I think we can split this into the following problems:

  1. We need to create a Collection that is iterable
  2. The Collection should

    a. have the names of prohibited methods hard-coded (bad) or

    b. be able to fetch the names of prohibited methods from the elements of the collection

  3. when iterating over the collection, it should yield proxies to the original object, intercepting calls to methods which should not be allowed to be called when iterating over the collection

1) Collection should be iterable

This is easy, just make it implement the Iterator interface:

class Collection implements \Iterator
{
    /**
     * @var array
     */
    private $elements;

    /**
     * @var int
     */
    private $key;

    public function __construct(array $elements)
    {
        // use array_values() here to normalize keys 
        $this->elements = array_values($elements);
        $this->key = 0;
    }

    public function current()
    {
        return $this->elements[$this->key];
    }

    public function next()
    {
        ++$this->key;
    }

    public function key()
    {
        return $this->key;
    }

    public function valid()
    {
        return array_key_exists(
            $this->key,
            $this->elements
        );
    }

    public function rewind()
    {
        $this->key = 0;
    }
}

2) Collection should be able to fetch methods from elements

Rather than hard-coding the prohibited methods into the collection, I would suggest to create an interface, and have that be implemented by the elements of the collection, if need be, for example:

<?php

interface HasProhibitedMethods
{
    /**
     * Returns an array of method names which are prohibited 
     * to be called when implementing class is element of a collection.
     *
     * @return string[]
     */
    public function prohibitedMethods();
}

This also has the advantage that the collection would work with all kinds of elements, as long as it is able to fetch that information from the element.

Then have your elements, if need be, implement the interface:

class Element implements HasProhibitedMethods
{ 
    public function foo()
    {
        return 'foo';
    }

    public function bar()
    {
        return 'bar';
    }

    public function baz()
    {
        return 'baz';
    }

    public function prohibitedMethods()
    {
        return [
            'foo',
            'bar',
        ];
    }
}

3) When iterating, yield proxies

As suggested in a different answer by @akond, you could use ocramius/proxymanager, and specifically, an Access Interceptor Value Holder Proxy.

Run

$ composer require ocramius/proxymanager

to add it to your project.

Adjust the collection as follows:

<?php

use ProxyManager\Factory\AccessInterceptorValueHolderFactory;

class Collection implements \Iterator
{
    /**
     * @var array
     */
    private $elements;

    /**
     * @var int
     */
    private $key;

    /**
     * @var AccessInterceptorValueHolderFactory
     */
    private $proxyFactory;

    public function __construct(array $elements)
    {
        $this->elements = array_values($elements);
        $this->key = 0;
        $this->proxyFactory = new AccessInterceptorValueHolderFactory();
    }

    public function current()
    {
        $element = $this->elements[$key];

        // if the element is not an object that implements the desired interface
        // just return it
        if (!$element instanceof HasProhibitedMethods) {
            return $element;
        }

        // fetch methods which are prohibited and should be intercepted
        $prohibitedMethods = $element->prohibitedMethods();

        // prepare the configuration for the factory, a map of method names 
        // and closures that should be invoked before the actual method will be called
        $configuration = array_combine(
            $prohibitedMethods,
            array_map(function ($prohibitedMethod) {
                // return a closure which, when invoked, throws an exception
                return function () use ($prohibitedMethod) {
                    throw new \RuntimeException(sprintf(
                        'Method "%s" can not be called during iteration',
                        $prohibitedMethod
                    ));
                };
            }, $prohibitedMethods)
        );

        return $this->proxyFactory->createProxy(
            $element,
            $configuration
        );
    }

    public function next()
    {
        ++$this->key;
    }

    public function key()
    {
        return $this->key;
    }

    public function valid()
    {
        return array_key_exists(
            $this->key,
            $this->elements
        );
    }

    public function rewind()
    {
        $this->key = 0;
    }
}

Example

<?php

require_once __DIR__ .'/vendor/autoload.php';

$elements = [
    new Element(),
    new Element(),
    new Element(),
];

$collection = new Collection($elements);

foreach ($collection as $element) {
    $element->foo();
}

Note This can still be optimized, for example, you could store references to the created proxies in the Collection, and instead of creating new proxies every time, current() could return previously created proxies, if need be.

For reference, see:

  • http://php.net/manual/en/class.iterator.php
  • https://github.com/ocramius/proxymanager
  • https://ocramius.github.io/ProxyManager/docs/access-interceptor-value-holder.html
like image 118
localheinz Avatar answered Nov 06 '22 08:11

localheinz


I should create two different classes in order to comply with single responsibility principle. One would be a Collection, and the other would be an Object itself. Objects get returned only as members of Collection. Every time when Collection gets iterated, it "loads" corresponding Objects.

If it seems appropriate, you might want to create a Lazy loading ghost object proxy for each of those Objects.

like image 1
akond Avatar answered Nov 06 '22 08:11

akond