2
\$\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\$
  • 1
    \$\begingroup\$ The pattern doesn't match. I think you have a typo? The last ^ should perhaps be $. \$\endgroup\$ – Paul Sep 17 '15 at 0:46
  • \$\begingroup\$ Do you know the named keys in advance? Because if you do, you can use array_intersect_key. \$\endgroup\$ – Elias Van Ootegem Sep 21 '15 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\$ – mickmackusa Feb 8 '19 at 4:49
3
\$\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

|improve this answer|||||
\$\endgroup\$
1
\$\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.

|improve this answer|||||
\$\endgroup\$
  • \$\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\$ – Elias Van Ootegem Sep 21 '15 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\$ – Daniel Costa Sep 21 '15 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\$ – Elias Van Ootegem Sep 21 '15 at 12:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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