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

Patrick Allaert :

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 :

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.

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=24236&siteId=1