phpoopiteratorproxy-object

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!


Solution

  • 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: