How to trigger code before/after a foreach loop, only if this one would be entered, in an efficient way in PHP 7.4+?

Viewed 786

This is somewhat similar to question: How to determine the first and last iteration in a foreach loop?, however, that +10 years old question is heavily array oriented and none of the answer are compatible with the fact that many different types can be looped on.

Given a loop on something that can be iterated in PHP >= 7.4 (arrays, iterators, generators, PDOStatement, DatePeriod, object properties,...), how can we trigger, in an efficient way, code that needs to happen before / after the loop, but only in the case the loop would be entered?

A typical use case could be the generation of an HTML list:

<ul>
  <li>...</li>
  <li>...</li>
  ...
</ul>

<ul> and </ul> must be printed only if there are some elements.

Those are the constraint I discovered so far:

  1. empty(): Can't be used on generators/iterators.
  2. each(): is deprecated.
  3. iterator_to_array() defeats the advantage of generators.
  4. A boolean flag tested inside and after the loop is not considered efficient as it would result in that test to be executed at every single iteration instead of once at the start and once at the end of the loop.
  5. While output buffering or string concatenations to generate the output may be used in the above example, it would not fit the case where a loop would not produce any output. (thanks @barmar for the additional idea)

The following code snippet summarize many different types on which we can iterate with foreach, it can be used as a start to provide an answer:

<?php

// Function that iterates on $iterable
function iterateOverIt($iterable) {
    // How to generate the "<ul>"?
    foreach ($iterable as $item) {
        echo "<li>", $item instanceof DateTime ? $item->format("c") : (
            isset($item["col"]) ? $item["col"] : $item
        ), "</li>\n";
    }
    // How to generate the "</ul>"?
}

// Empty array
iterateOverIt([]);
iterateOverIt([1, 2, 3]);

// Empty generator
iterateOverIt((function () : Generator {
    return;
    yield;
})());
iterateOverIt((function () : Generator {
    yield 4;
    yield 5;
    yield 6;
})());

// Class with no public properties
iterateOverIt(new stdClass());
iterateOverIt(new class { public $a = 7, $b = 8, $c = 9;});

$db = mysqli_connect("localhost", "test", "test", "test");
// Empty resultset
iterateOverIt($db->query("SELECT 0 FROM DUAL WHERE false"));
iterateOverIt($db->query("SELECT 10 AS col UNION SELECT 11 UNION SELECT 12"));

// DatePeriod generating no dates
iterateOverIt(new DatePeriod(new DateTime("2020-01-01 00:00:00"), new DateInterval("P1D"), new DateTime("2020-01-01 00:00:00"), DatePeriod::EXCLUDE_START_DATE));
iterateOverIt(new DatePeriod(new DateTime("2020-01-01 00:00:00"), new DateInterval("P1D"), 3, DatePeriod::EXCLUDE_START_DATE));

Such a script should result in the following output:

<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<ul>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>
<ul>
<li>7</li>
<li>8</li>
<li>9</li>
</ul>
<ul>
<li>10</li>
<li>11</li>
<li>12</li>
</ul>
<ul>
<li>2020-01-02T00:00:00+00:00</li>
<li>2020-01-03T00:00:00+00:00</li>
<li>2020-01-04T00:00:00+00:00</li>
</ul>
3 Answers

Well this was at least an interesting thought experiment...

class BeforeAfterIterator implements Iterator
{
    private $iterator;

    public function __construct(iterable $iterator, $before, $after)
    {
        if (!$iterator instanceof Iterator) {
            $iterator = (function ($iterable) {
                yield from $iterable;
            })($iterator);
        }

        if ($iterator->valid()) {
            $this->iterator = new AppendIterator();
            $this->iterator->append(new ArrayIterator([$before]));
            $this->iterator->append($iterator);
            $this->iterator->append(new ArrayIterator([$after]));
        } else {
            $this->iterator = new ArrayIterator([]);
        }
    }

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

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

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

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

    public function rewind()
    {
        $this->iterator->rewind();
    }
}

Example usage:

function generator() {
    foreach (range(1, 5) as $x) {
        yield "<li>$x";
    }
}

$beforeAfter = new \BeforeAfterIterator(generator(), '<ul>', '</ul>');

foreach ($beforeAfter as $value) {
    echo $value, PHP_EOL;
}

Output

<ul>
<li>1
<li>2
<li>3
<li>4
<li>5
</ul>

If the iterator you pass yields no values, you get no output.

See https://3v4l.org/0Xa1a

I'm in no way endorsing this as a good idea, that's entirely up to you. I'm sure there are some more elegant ways of doing this with some of the more obscure SPL classes. It might also be simpler to do it via extension instead of composition.

A solution that works for all cases (PHP >= 7.2) is to use a double foreach, where the first one acts like an if, that won't really perform the looping, but initiates it:

function iterateOverIt($iterable) {
    // First foreach acts like a guard condition
    foreach ($iterable as $_) {

        // Pre-processing:
        echo "<ul>\n";

        // Real looping, this won't start a *NEW* iteration process but will continue the one started above:
        foreach ($iterable as $item) {
            echo "<li>", $item instanceof DateTime ? $item->format("c") : (
                isset($item["col"]) ? $item["col"] : $item
            ), "</li>\n";
        }

        // Post-processing:
        echo "</ul>\n";
        break;
    }
}

Full demo on 3v4l.org.

I don't think we need to add anything new to the language. In PHP 7.1 we received a new type iterable. Using the type hinting you can force your function/method to accept only arguments which can be iterated and are semantically supposed to be iterated (as opposed to objects, which do not implement Traversable).

// Function that iterates on $iterable
function iterateOverIt(iterable $iterable) {
    echo '<ul>'.PHP_EOL;
    foreach ($iterable as $item) {
        echo "<li>", $item instanceof DateTime ? $item->format("c") : (
            isset($item["col"]) ? $item["col"] : $item
        ), "</li>\n";
    }
    echo '</ul>'.PHP_EOL;
}

Of course this is not going to ensure that the loop will iterate at least once. In your test case you could simply use output buffering, but that will not apply to situations when you need to perform an action only if the loop will iterate at least once. This is not possible. It would be technically impossible to implement such coherence. Let me give a simple example:

class SideEffects implements Iterator {
    private $position = 0;

    private $IHoldValues = [1, 1, 2, 3, 5, 8];

    public function __construct() {
        $this->position = 0;
    }

    public function doMeBefore() {
        $this->IHoldValues = [];
        echo "HAHA!".PHP_EOL;
    }

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

    public function current() {
        return $this->IHoldValues[$this->position];
    }

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

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

    public function valid() {
        return isset($this->IHoldValues[$this->position]);
    }
}

$it = new SideEffects();
if (1/* Some logic to check if the iterator is valid */) {
    $it->doMeBefore(); // causes side effect, which invalidates previous statement
    foreach ($it as $row) {
        echo $row.PHP_EOL;
    }
}

As you can see in this obscure example such perfect coherence in your code is not possible.

The reason why we have different ways of iterating stuff in PHP is because there is no "one size fits all" solution. What you are trying to do is quite the opposite. You are trying to create a solution, which would work for everything which can be iterated upon even if it probably should not be iterated. Instead you should write code, which can handle all approaches appropriately.

function iterateOverIt(iterable $iterable) {
    if (is_array($iterable)) {
        echo 'The parameter is an array'.PHP_EOL;
        if ($iterable) {
            // foreach
        }
    } elseif ($iterable instanceof Iterator) {
        echo 'The parameter is a traversable object'.PHP_EOL;
        /**
         * @var \Iterator
         */
        $iterable = $iterable;
        if ($iterable->valid()) {
            // foreach
        }
    } elseif ($iterable instanceof Traversable) {
        echo 'The parameter is a traversable object'.PHP_EOL;
        // I donno, some hack?
        foreach ($iterable as $i) {
            // do PRE
            foreach ($iterable as $el) {
                // foreach calls reset first on traversable objects. Each loop should start anew
            }
            // do POST
            break;
        }
    } else {
        // throw new Exception?
    }
}

If you really want you can even include normal objects in it using is_object(), but as I said unless it implements Traversable do not iterate over it.

Related