PHP Tricks: Uncommon match() expressions

in PHP7 months ago (edited)

Recently, I've been helping a new developer out in a chat room. He's still learning PHP and working through tutorials, with lots of questions along the way. Earlier this week, by accident, he stumbled across a use of match() statements I'd not considered before. While technically not right for his use case, it did suggest another, potentially valid use for match() I'd not seen. Let's add it to the list of cool things you can do with match().

A quick match() refresher

Introduced in PHP 8, match() is a branching expression. I like to think of it as the more general case of the ternary operator. It's general form is like so:

$value = match ($food) {
    'apple' => 'This food is an apple',
    'bar' => 'This food is a bar',
    'cake' => 'This food is a cake',
};

The match value in () is compared with strict equality === against each of the possible expressions in each "arm" of the expression. For whichever one evaluates to true first, the right side of the => will be evaluated and the whole expression evaluates to that result. Expressions on the right side that had no match will never evaluate. Furthermore, if none of the left-side values are === the match value, and there is no default arm, the entire expression fails and throws an Error. That means you are guaranteed that exactly one expression on the right side runs, and its result is what the whole expression evaluates to.

That's just an expression

Because everything in match() is expression-based, that allows for some interesting and clever combinations. But what exactly is an expression?

An "expression" is a code sequence that can be evaluated, and that evaluation results in a value that can be part of further evaluation. That is, expressions can always be composed into compound expressions.

For example, 5 + 3 is an expression that evaluates to 8. 8 is itself an expression, in fact, because it can be embedded within another expression, for example (5 + 3) - 2. That expression evaluates to 6, which is itself an expression, and so on. That compositionality of expressions is extremely powerful.

Expressions are in contrast to statements, which are declarations of code that run and do not evaluate to a value. A statement in PHP can be composed of one or more expressions as well as other syntax, is terminated with a ;. return is a statement, for instance, because it does not evaluate to anything. That means it cannot be used inside a match() statement, whereas throw can, because it was redesigned from a statement to an expression in PHP 8.0.

Because expressions are so much more flexible, many if not most programming language theorists today consider statements a mistake and feel a language should consist only of expressions. Such languages do exist, and are quite powerful because of how many ways they can be combined. I agree with that position.

An expression of equality

In PHP, many things are expressions that you may not think of. =, for example, is technically an expression, not a statement, because an assignment operation evaluates to the value assigned. That's why you can do things like this:

if ($a = get_value($b)) { ... }

That code will call get_value($b), assign whatever that result is to $a, and then evaluate to that result as well. You can think of it as an execution stack (and it is often exactly that). get_value($b) is called and its result substituted for it in the code, leading to $a = $__result. $a = $__result is evaluated, causing the value of $a to be set and then the expression evaluates to $a. Then if ($a) runs and does whatever that does.

Crazy expressions

With all that setup out of the way, here's (approximately) what our new PHP programmer wrote:

function leftTurn(string $dir): string
{
    match ($dir) {
        'North' => $next = 'West',
        'West' => $next = 'South',
        'South' => $next = 'East',
        'East' => $next = 'North',
    };
    return $next;
}

To a veteran PHP programmer, that is clearly wrong. However, it is syntactically valid, runs, and returns the correct result! I was momentarily shocked to see it actually work, but on a little thought it's obvious why it does.

Consider if $dir is the value "West". match() first checks $dir === 'North', which is false, so it moves on. Then it checks $dir === 'West', which is true. It then evaluates $next = 'South', which assigns a value to $next and evaluates to 'South'. The match() then evaluates to 'South', although that value is then ignored. Then $next is returned, which happens to have been set a moment ago to 'South'.

To be clear, this is the wrong way to handle this case. The more appropriate way would be:

function leftTurn(string $dir): string
{
    return match ($dir) {
        'North' => 'West',
        'West' => 'South',
        'South' => 'East',
        'East' => 'North',
    };
}

However, that highlights one... limitation? Characteristic? Quirk? Feature? (depending on your point of view)... of match(). It evaluates to a single value, which can then be assigned to a single variable. Generally that is what you want, and in those cases match() is excellent, and far superior to the clunk set of nested if-else-if or switch blocks that were typical before PHP 8.0.

Sometimes, though, you do want to branch your logic but not in a way that assigns to a single, unified value. There are two caveats to that standard pattern that are relevant here:

  • match() always evaluates to a value, but there's no requirement that you actually care about that value.
  • Any expression may be used anywhere in a match() statement, even things you wouldn't expect to be expressions (such as =).

Usually, if you're taking advantage of the former, it means whatever you're doing in the match arms is impure code with some kind of side effect, and thus you're probably Doing It Wrong(tm). There are exceptions, though.

Let's consider a few oddball cases, just for fun.

Counts

enum Action
{
    case Create;
    case Update;
    case Delete;
}

class Counters
{
    protected int $createCount = 0;
    protected int $updateCount = 0;
    protected int $deleteCount = 0;
    
    public function record(Action $action): int
    {
        return match($action) {
            Action::Create => ++$this->createCount,
            Action::Update => ++$this->updateCount,
            Action::Delete => ++$this->deleteCount,
        };
    }
}

The ++ operator is another dual-behavior operator, like =. It will increment the variable to which it is applied, and then evaluate to the updated value. (If it's on the right side of the variable, it will evaluate to the pre-incremented value.) Therefore, when called with an enum, the match() statement will branch to the appropriate arm, the ++ operator will be evaluated causing the value to increment, then it will evaluate to the incremented value, then match() evaluates to that incremented value, then that result will be returned.

Take action

That's not too esoteric, though, so let's try something more aggressive.

enum Action
{
    case Create;
    case Update;
    case Delete;
}

class LastActions
{
    public string $lastCreated = '';
    public string $lastUpdated = '';
    public string $lastDeleted = '';
    
    public function record(Action $action, string $title): void
    {
        match($action) {
            Action::Create => $this->lastCreated = $title,
            Action::Update => $this->lastUpdated = $title,
            Action::Delete => $this->lastDeleted = $title,
        };
    }
}

In this example, record() will update a different property depending on what the $action is. In any arm, the match() will end up evaluating to the value of$title, although that result will be ignored.

This is rather clever, and I don't mean that in a pejorative way. Consider what the alternative way would be with a switch statement.

class Counters
{
    public string $lastCreated = '';
    public string $lastUpdated = '';
    public string $lastDeleted = '';
    
    public function record(Action $action, string $title): void
    {
        switch($action) {
            case Action::Create:
                $this->lastCreated = $title;
                break;
            case Action::Update:
                $this->lastUpdated = $title;
                break;
            case Action::Delete:
                $this->lastDeleted = $title;
                break;
        };
    }
}

That has the same net effect. However:

  • It is longer to type.
  • It has more syntax flotsam that makes it harder to read.
  • It's easy to forget the break statement and get weird bugs.
  • $action will be compared with weak equality == instead of strict ===. (In this particular case that doesn't matter, but in many cases it could.)
  • It does not protect you from forgetting a case the way match() does. If Action has more than 3 possibilities, match() will error out if you pass in one you didn't handle; switch will silently ignore it and do nothing. That's rarely good for error handling. (If you really want match() to ignore other values, the default arm can be used and then evaluate to null or 0 or something that makes sense in context.)

Use it when?

Frankly, this isn't a trick I would recommend using frequently. It's clever, which is sometimes useful but can also backfire. Sometimes, a series of if-else-if blocks really is the better solution, especially if your condition checks are more complex.

But match(), being all-expression-based, lends itself to some interesting compositions that you wouldn't expect. This is just one of them, which I've added to my back pocket of useful tricks. There are others as well that I use more frequently, but that's a topic for another post.

Sort:  

Congratulations @crell! You have completed the following achievement on the Hive blockchain and have been rewarded with new badge(s):

You published more than 80 posts.
Your next target is to reach 90 posts.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

To support your work, I also upvoted your post!

Support the HiveBuzz project. Vote for our proposal!