3
\$\begingroup\$

I have a regex pattern that will match some elements from a string and give them a particular name. For example, #^(?<foo>.*)$# will match the whole string and name it foo.

My problem is that the matches also contain the "classic", numbered matches.

For example:

<?php

$pattern = '#^(?<foo>.*)$#';
$str = '123';
$matches = null;
preg_match($pattern, $str, $matches);
print_r($matches);

will print:

Array
(
    [0] => 123
    [foo] => 123
    [1] => 123
)

As all the matches will always be named, I decided to manually remove the numbered indexes from $matches in order to clean things up:

<?php

$pattern = '#^(?<foo>.*)$#';
$str = '123';
$matches = null;
if (preg_match($pattern, $str, $matches))
{
    foreach ($matches as $key => $value)
    {
        if (is_int($key))
            unset($matches[$key]);
    }
}
print_r($matches);

Which prints:

Array
(
    [foo] => 123
)

It works, but I feel that it can be improved. Is there a better way to do this, especially without the foreach loop?

In practice, $pattern and $str can be much more complicated than the example I gave and I want this to be executed as fast as possible.

\$\endgroup\$
3
  • 1
    \$\begingroup\$ The pattern doesn't match. I think you have a typo? The last ^ should perhaps be $. \$\endgroup\$
    – Paul
    Commented Sep 17, 2015 at 0:46
  • \$\begingroup\$ Do you know the named keys in advance? Because if you do, you can use array_intersect_key. \$\endgroup\$ Commented Sep 21, 2015 at 11:16
  • \$\begingroup\$ Please improve this question. As it is written, there is no reason to use regex (a possible fringe case being that you don't want to match strings that contain a newline character). The logic in the snippet is effectively produced by: $array = ['foo' => $str]; \$\endgroup\$ Commented Feb 8, 2019 at 4:49

3 Answers 3

3
\$\begingroup\$

I've looking for the same solution as you. But after finding your solution and additional research I find this way to do it, which does not contain a loop.

$pattern = "#^/article/(?<id>\d+)-(?<slug>.+)$#";
$url = "/article/42-is-the-answer";

preg_match($pattern, $url, $args);
print_r($args);

$args = array_filter($args, 'is_string', ARRAY_FILTER_USE_KEY);
print_r($args);

Output :

Without array_filter() :

Array
(
    [0] => /article/42-is-the-answer
    [id] => 42
    [1] => 42
    [slug] => is-the-answer
    [2] => is-the-answer
)

With array_filter() :

Array
(
    [id] => 42
    [slug] => is-the-answer
)
\$\endgroup\$
7
  • 2
    \$\begingroup\$ that is an alternative solution, not a review of the existing solution \$\endgroup\$ Commented Jan 26, 2023 at 19:00
  • \$\begingroup\$ Yes it does appear to be an alternate solution but bear in mind that the OP asked “Is there a better way to do this, especially without the foreach loop?” and it has justification: “I find this way to do it, which does not contain a loop.”. \$\endgroup\$ Commented Jan 27, 2023 at 15:01
  • \$\begingroup\$ @SᴀᴍOnᴇᴌᴀ Was this implementation possible when the question was posted? \$\endgroup\$
    – pacmaninbw
    Commented Jan 27, 2023 at 15:13
  • \$\begingroup\$ Welcome to Code Review, could you weigh in on whether or not this was available to the OP at the time of the original post? I agree with @SᴀᴍOnᴇᴌᴀ that in this case it could be considered a review. but if it wasn't available to the original poster, then I would suggest that it is at the very least noted in the answer. \$\endgroup\$
    – Malachi
    Commented Jan 27, 2023 at 15:31
  • 1
    \$\begingroup\$ @pacmaninbw yes the latest version of PHP available as of 03 Sep 2015 was PHP 5.6.13 and the code produces the same output in that version \$\endgroup\$ Commented Jan 27, 2023 at 16:29
4
\$\begingroup\$

If you know what the named matches are up front (ie if you know what the pattern looks like, you can simply use array_intersect_key to extract only the values that have a specific key from the $matches array:

$names = ['foo' => null];

$pattern = '#^(?<foo>.*)$#';//changed ^ to $ at the end ;)
$str = '123';
if (preg_match($pattern, $str, $matches))
{//or return here
    $matches = array_intersect_key($matches, $names);
}
return $matches;

Of course, if you are not in control of the names that will be used in the pattern, you'll have to either iterate over the $matches array like you're doing now. However, I'd recommend you don't use unset on the $matches array, but rather copy the relevant values to a new one and return that array instead:

$returnValue = [];//new array
foreach ($matches as $k => $v) {
    if (!is_int($k)) {
        $returnValue[$k] = $v;
    }
}

There are a couple of reasons for this:

  • It's considered bad practice to change the array you're iterating over inside the loop. It can cause issues in certain cases, and it will definitely bite you if you decide to pick up another language
  • PHP's memory management and copy-on-write mechanisms work well with code like the loop above: the new array will be assigned a reference to the value in $matches, but once the function returns, $matches is GC'ed. The values not referenced by $returnValue will be GC'ed, the other values are then "owned" by the return array (that's not 100% accurate, but it's true enough for now)
  • It's probably the most efficient (in terms of readability and execution time) approach.

Time to get silly

Just for the fun of it: you can opt for an inception-style preg_match_all call on the regex you're passing to preg_match (regex matching on a regex... let's be honest, that sounds a tad absurd). It's silly, but it can be done:

$pattern = '#^(?<foo>.*)$#';//changed ^ to $ at the end ;)
$str = '123';
$names = null;
if (preg_match_all('/(?<=\?<)([^>]+)/', $pattern, $matches))
{//create an assoc array containing the match names
    $names = array_fill_keys($matches[0], null);
}

$matches = null;
if (preg_match($pattern, $str, $matches))
{
    if ($names) {
        //gets only the named keys
        $matches = array_intersect_key($matches, $names);
    }
    return $matches;
}
//throw exception, return null, or do something else here

Now, this is not exactly the way to go, but in some cases it might happen that you're processing a string, but what regex you apply to it can change depending on any number of reasons. In that case, array_intersect_key is definitely worth a look, seeing as it only returns the keys that exist in all of the arguments you pass to it:

class Foo
{
    const DOMAIN_PATTERN = '/(?<=@)(?<domain>[^@\.]+)(?=\.)/';//or something
    const EXTENSION = '/\.(?<extension>[a-z]{3,4})$/';

    protected static $names = [
        'domain'     => null,
        'extension'  => null,
    ];

    protected $mode = null;

    public function setValidationOptions(array $options)
    {//based on these options, one or more specific regex's will be applied to the data
        $this->mode = $options;
        return $this;
    }
    public function validateString($string)
    {
        $regex = $this->getPatterns();
        $result = [];
        foreach ($regex as $pattern) {
            if (preg_match($string, $pattern, $matches)) {
                $result = array_merge(
                    $result,
                    array_intersect_keys(
                        $matches,
                        static::$names
                    )
                );
            }
        }
        return $result;
    }
}

This is just a crude example of how you could use array_intersect_key to handle regex matches with named sub-patterns

\$\endgroup\$
2
\$\begingroup\$

Considering that PHP will always output both named and numeric indexes (also tried with preg_match_all), and considering that the next piece of code expects an array with named indexes only, there's no other way (that I could think of) than to go through all items in the array and unsetting the numeric-indexed items using foreach and unset.

Besides, considering that you're saying that the pattern and subject might be much more complex, the "clean-up" procedure time should be negligible compared to the execution time of preg_match.

If going through all items using foreach, testing and unseting them takes a significant time, I would suggest moving the tests into the code that will use the array, and if !is_int($key) then process the value according to your requirements.

\$\endgroup\$
3
  • \$\begingroup\$ "If going through all items using foreach, testing and unseting them takes a significant time" You state this right after saing that the "clean-up" procedure time is negligible compared to preg_match. Also: moving the code that processes the preg_match return value doesn't reduce complexity of the overall code base, nor does it reduce execution time (because the processing is still being done, it's just moved elsewhere). It's quite likely to end up being duplicated several times over, even... Lastly: a foreach without unset is probably the fastest way to go \$\endgroup\$ Commented Sep 21, 2015 at 11:56
  • \$\begingroup\$ I said that the clean-up SHOULD be negligible. That does need testing and proof. Also, as OP said that he "want[s] this to be executed as fast as possible". His solution is simple and works but involves an extra step that could be cut down: going through all items, unsetting them, then going through all items again - my suggestion is just go through every item and process them if it matches the condition (not is int). \$\endgroup\$ Commented Sep 21, 2015 at 12:37
  • \$\begingroup\$ What condition are you talking about (if not is_int)? Conditions to see if the match actually matches the pattern that is used? That doesn't really make sense. Admittedly, I've not read the question in great detail, though... \$\endgroup\$ Commented Sep 21, 2015 at 12:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.