5

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>
Patrick Allaert
  • 1,751
  • 18
  • 44
  • You could use the output buffering functions. Buffer the output of the loop. Then check whether the buffer is empty. – Barmar Mar 02 '20 at 18:46
  • Or just put the results of the loop in a string instead of echoing it, and check if the string is empty. – Barmar Mar 02 '20 at 18:46
  • Kind of overkill to me as it would lead to an intermediate string to grow, whether it's a PHP variable, or PHP's internal output buffering's one. Then the repetition of the test on a boolean value seems much lighter. In addition to that, the printing here is just an example, I'm looking for a generic way to do pre- and post- handling in an foreach loop. The loop may not print anything. – Patrick Allaert Mar 02 '20 at 19:00
  • I don't think there's really a generic solution to this. You can use a boolean variable to detect the first time through the loop, but I can't think of any way to detect the last. – Barmar Mar 02 '20 at 19:02
  • @Barmar: the same boolean could be checked after the foreach to see if we actually entered the loop, but again, I'm seeking for a solution that doesn't add *any* overhead at each iteration. – Patrick Allaert Mar 02 '20 at 19:07
  • I think you're looking for something impossible. – Barmar Mar 02 '20 at 19:09
  • Find a solution that works, don't worry about tiny bits of overhead like that. – Barmar Mar 02 '20 at 19:09
  • Maybe impossible currently. Could serve as a starting point for an RFC for PHP 8.0. But want to investigate first. – Patrick Allaert Mar 02 '20 at 19:12
  • 2
    I can't even think of how this could be added to the language. A generator can't generally know when it's on the last iteration. – Barmar Mar 02 '20 at 19:13
  • A custom iterator implementation that wraps the existing iterator feels like it could be an option. Test the inner iterator, if it's valid then output your "before" value. Then delegate to it until it's no longer valid, and finally output your "after" value. – iainn Mar 02 '20 at 19:14
  • 2
    I believe this task is not possible since there is no way to tell if Generator will actually generate any values until you iterate over it. – El_Vanja Mar 02 '20 at 19:19
  • @Barmar: Imagine that we could do: `foreach ($iterable as $item; $calledOnEnter; $calledOnLeave)` with `$calledOnEnter`/`$calledOnLeave` being `callable`, or any other similar syntax that would allow pre/post treatment. – Patrick Allaert Mar 02 '20 at 19:24
  • Your `Empty generator` test case is broken. You pass in a closure instead of IIFE. Was it meant to be like that? – Dharman Mar 02 '20 at 19:35
  • @Dharman no, you are right, about to fix the code snippet! – Patrick Allaert Mar 02 '20 at 19:40

3 Answers3

0

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.

iainn
  • 16,826
  • 9
  • 33
  • 40
  • 1
    Interesting idea, however this really works only passing something that implements `Iterator`. This question is really about `foreach` and all possible types that `foreach` can iterate on. – Patrick Allaert Mar 02 '20 at 19:47
  • @PatrickAllaert That's a very fair point. I've updated it to support all instances of [`iterable`](https://www.php.net/manual/en/language.types.iterable.php) (including arrays), by converting them into a generator. See https://3v4l.org/HiPWo for another example – iainn Mar 02 '20 at 20:03
  • 1
    You need to remove the type hint after your edit. It no longer accepts objects of type Iterator – Dharman Mar 02 '20 at 20:32
  • 1
    DatePeriod of mysqli_result is still not going to work. – Dharman Mar 02 '20 at 20:33
  • @Dharman Also very fair points. I've done one more edit, it should support anything iterable at this point, [`DatePeriod` is certainly ok](https://3v4l.org/kkfgL). If not, then I'm writing this off as a bad idea. – iainn Mar 02 '20 at 20:48
  • DatePeriod now doesn't display Before/After. – Dharman Mar 02 '20 at 20:53
  • @Dharman That one looks ok to me in my example. It's using two more datetime instances, rather than strings, if that's why it's less clear. – iainn Mar 02 '20 at 21:04
  • I don't understand. I meant that the `echo $value->format('Y-m-d'), PHP_EOL;` can't display `Before/After` because you use the value as an object, not as a string. – Dharman Mar 02 '20 at 21:06
  • @Dharman Well that example loop can't, but that's going to be true for any iterator that returns a mix of objects and strings. It's an issue with the example loop, not with the iterator implemtation. – iainn Mar 02 '20 at 21:08
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/208874/discussion-between-dharman-and-iainn). – Dharman Mar 02 '20 at 21:08
  • 2
    Upvoted for effort. This question seems too theoretical anyway. This question is asking for an over-engineered, too general answer. – Pieter van den Ham Mar 02 '20 at 21:19
0

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.

Patrick Allaert
  • 1,751
  • 18
  • 44
  • I guess that is a generic solution. This is what I suggested in my answer and for your simple scenario it should work. I should probably revise my answer. – Dharman Mar 03 '20 at 15:46
-1

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.

Dharman
  • 30,962
  • 25
  • 85
  • 135
  • Actually, the code I have has an `iterable` type hint. I just wanted to make the question very general about `foreach`. And yes, a general solution exist (which also works for anything that can be iterated, objects included), even if that is not my target. – Patrick Allaert Mar 03 '20 at 14:14