Powershell wat: break/continue outside of loop constructs in Powershell

Today, I had an interesting day with PowerShell, debugging a script.

Consider the following attempt at solving the classic Fizz Buzz puzzle. (Yes, it’s written in a slightly unusual style, but not obviously incorrect.) Can you spot the error?

function Get-FizzBuzz($max) {
    1..$max | Foreach-Object {
        if ($_ % 15 -eq 0) {
            "fizzbuzz"
            continue
        }
        if ($_ % 3 -eq 0) {
            "fizz"
            continue
        }
        if ($_ % 5 -eq 0) {
            "buzz"
            continue
        }
        $_
    }
}

Get-FizzBuzz 20
"All done!"

The output of this script is:

1
2
fizz

And this is my reaction:
Wat

Not only did the loop end earlier than expected. But I never even got “All done!” out on my screen. Why would the script just stop half-way? There’s no obvious reason.

Except once you focus in on the fact that Foreach-Object is not a language construct, where continue or break makes any sense. It’s a cmdlet, and the stuff between Foreach-Object { … } is a ScriptBlock that is executed for every object.

It turns out that, unlike in every programming language I’ve worked with before, a “continue” and a “break” is legal, even if you’re not actually directly inside a loop. But in PowerShell, you can break and continue in a loop that’s in a function that you’re being called from!

This is really astonishing, because that simply should not be possible in a structured programming language. Not even C allows for a called function to mess with the control flow of a loop (unless the called function does some low-level trickery to the stack).

So, what you’re supposed to do, is that if you use Foreach-Object, instead of “continue” you can use “return”, and instead of “break”… well… you’re SOL.

This is described for example in this slightly mis-titled Stack Overflow question: Why does continue behave like break in a Foreach-Object?.

I did spend some time on the Virtual PowerShell Group discussing the implications of this. @larryweiss linked me a blog post on pluralsight.com by Adam Bertram named PowerShell: Tips for terminating code execution, which advocates for using “break” to quit a script prematurely (emphasis mine):

Break can also be used outside of its typical constructs, and can simply be placed in a script outside of a loop. In this case, break will stop all script execution, even if called within a function in the script. It’s a great way to ensure the entire script halts wherever it may be (excluding in a loop or switch statement).

To me, that sounds like he’s making a pretty poor case for using it, after all, you can never know as the author of a function if you’re being called inside a loop or not.

To further illustrate this language feature, consider this (crazy but functional) implementation of FizzBuzz:

function Get-Fizz($i) {
    if ($i % 3 -eq 0) {
        "fizz"
        continue
    }
}

function Get-Buzz($i) {
    if ($i % 5 -eq 0) {
        "buzz"
        continue
    }
}

function Get-FizzBuzz($i) {
    if ($i % 15 -eq 0) {
        "fizzbuzz"
        continue
    }
}

for ($i = 1; $i -lt 20; $i++) {
    Get-FizzBuzz $i
    Get-Fizz $i
    Get-Buzz $i
    $i
}

Notice how the Get-FizzBuzz, Get-Fizz and Get-Buzz functions actually control the flow of the for loop in the main script!

We scratched our heads a little, trying to come up with a legitimate use case for this language feature. That’s when I came up with this:

function Foreach-ObjectImproved([ScriptBlock]$sb) {
    Begin {
        $did_break = $false
    }
    Process {
        if (-not $did_break) {
            for ($i = 0; $i -lt 1; $i++) {
                $did_breakOrContinue = $true # Being set speculatively...
                Invoke-Command $sb -ArgumentList $_
                # Will not reach here if Invoke-Command did break or continue...
                $did_breakOrContinue = $false
            }
            # If the ScriptBlock called "break" to break out of the the loop above, $i will be at 0, because the loop-increment and loop-test will not have run.
            # If the ScriptBlock called "continue" to continued the loop above, $i will be at 1, because the loop-increment and loop-test will have run
            if ($did_breakOrContinue -and $i -eq 0) {
                $did_break = $true
            }
        }
    }
    End {
    }
}

1..100 | Foreach-ObjectImproved {
    if ($_ -eq 20) {
        break
    }
    if ($_ % 15 -eq 0) {
        "fizzbuzz"
        continue
    }
    if ($_ % 3 -eq 0) {
        "fizz"
        continue
    }
    if ($_ % 5 -eq 0) {
        "buzz"
        continue
    }
    $_
}

Foreach-ObjectImproved in the code above uses some trickery to detect when the provided scriptblock does break and continue, and then acts accordingly, again, turning something that actually shouldn’t work into something that does. Makes me wonder why Microsoft didn’t implement it this way to start with. Or at least something like it.

(I take no responsibility for Foreach-ObjectImproved, it’s an awful hack and is probably subtly broken in some way that’s not immediately obvious to me. If it breaks, you get to keep both pieces.)

Leave a Reply

Your email address will not be published. Required fields are marked *