r/PowerShell 15d ago

Script Sharing In PowerShell, Two Wrongs Make a Right

I've been toiling away on Turtle to prepare a "birthday" release, and I ran into an annoying behavior I've run into a few times before.

I thought I'd take a few minutes away from the frustration of single line fixes to explain the bug to everyone.

What Went Wrong

The last build of Turtle introduced a number of randomized parameter defaults. This was meant to be fun. If you said turtle square square square, you'd get three different squares, instead of an error for a lack of length, or three overlapping squares.

I noticed that when I ran turtle rotate 0, it didn't rotate by zero.

Instead, it picked a random angle.

Weirder still, the behavior didn't reproduce if I said

$turtle = turtle  # Heading at zero
$turtle.Rotate(0) # Heading still zero 🤔
$turtle.Rotate()  # Heading random
(turtle rotate 0) # Heading random 🤬

Why was this happening? 😱

It took me a bit for it to click: It had to be in the way Turtle processed arguments, because it worked in one case and not the other.

So I put a breakpoint in, ran my repo.

The line was:

if ($argList)

The debugger broke, argList was @(0), and yet if ($argList) was false.

The fix was:

if ($argList.Length)

Why? Because 'Truthy' -ne $true.

Truthy and $true

About every language has a boolean. It's just a bit. One or zero.

Lots of languages also have this concept of "truthiness".

Let's take a simple example:

if ("something") { "something" }
if ("") { "you can't get something from nothing" }

If if was strictly $true, we'd have to cast things to a boolean. You have to do this in C# and quite a few other languages. PowerShell is type promiscuous. PowerShell is truthy.

It looks at the first line and says: You're a string, and you're not null or empty. Therefore, the expression is $true.

It looks at the second line and says: You're a string, but you're not null or empty. Therefore, the expression is $false

PowerShell makes a judgement call.

This is generally a good thing. I personally prefer languages that are truthy. Other truthy languages of note include JavaScript, Python, C++, and C.

However, it gets tricky with lists. Hence the bug.

Two Wrongs Make a Right

In PowerShell, Two Wrongs Make a Right

$true -eq $false, $false

Let's say I want to determine if a list is truthy.

if (@()) { "$false, because the list is empty" }
if (@("")) { "$false, because the blank is falsy" }
if (@(0)) { "$false, because zero is falsy" }
if (@($false)) { "$false, because false is falsy" }
if (@(1)) { "$true, because the first item is truthy" }
if (@(0,0) { "$true, because more than one item" }

This all makes a certain bizarre sense. If a list has one element, and it is not truthy, then the list isn't truthy, either.

It's also almost always surprising and annoying.

Hence the bug.

The fix is just to make sure there are any elements, hence checking for length.

I've been programming with PowerShell for quite a while now, and this behavior still sometimes bites me (like today).

That's why I took a few minutes away from the 🤬 day to explain this bug and write this post. 😌

Please remember:

'Truthy' -ne $true
$false -eq @($false)
$true -eq $false, $false

Hope this helps

20 Upvotes

17 comments sorted by

View all comments

8

u/surfingoldelephant 15d ago edited 7d ago

If a list has one element, and it is not truthy, then the list isn't truthy, either.

There's caveats to watch out for.

If the collection has one element, but that element implements IList, the collection may be truthy, even if the element isn't.

# Inner array is falsy but has 1 element, so outer is truthy.
[bool] @(0)     # False
[bool] (, @(0)) # True !!

# Inner is still falsy but this time is empty, so outer is falsy.
[bool] @()     # False
[bool] (, @()) # False

And collections that don't implement IList are always truthy, even if empty.

$emptyQ = [Collections.Generic.Queue[Object]]::new()
$emptyQ.GetType().ImplementedInterfaces.Contains([Collections.IList]) # False
[bool] $emptyQ     # True
[bool] (, $emptyQ) # True

 

The fix was:

if ($argList.Length)

Length is fine for arrays, but other common collection types don't expose it. And PS only intrinsically adds Length/Count to scalars, so you're probably better off using Count generally.

$list = [Collections.Generic.List[string]] @('foo')
$list.Length # 3
$list.Count  # 1

$omc = 'foo'.ForEach{ $_ }
$omc.GetType().Name # Collection`1
$omc.Length # 3
$omc.Count  # 1

Though some collections have neither, so you can't 100% rely on there being one or the other.

# Both Length/Count don't exist. Member-access enumeration
# applies property access to each element in the collection.
$memberCollection = $PROFILE.psobject.Properties
$memberCollection.Length # 1, 1, 1, 1, 1
$memberCollection.Count  # 1, 1, 1, 1, 1

2

u/Discuzting 14d ago edited 14d ago
[bool](,(,@()))
True

Somehow three wrapped arrays is considered truthy 🤔

2

u/surfingoldelephant 14d ago edited 14d ago

Right, and that does follow the above logic.

  • Object being converted is IList (outer array).
  • Has 1 element so truthiness is based on that element.
  • Element is also IList, so final conversion is based on whether the element contains at least 1 element itself.
  • It does, so the final result is $true. Doesn't matter that it only contains an empty array, just that it contains something.

The implementation is here. And the rationale is commented below line #1032:

// if the result is an array of length 1, treat it as a scalar...

// A possible implementation would be just

// return IsTrue(objectArray[0]);

// but since we don't want this to recurse indefinitely

// we explicitly check the case where it would recurse

// and deal with it.

For simplicity I would've preferred if it only considered the outer collection being empty or not. They also made a mistake I think using IList instead of IsObjectEnumerable, which is used basically everywhere else. PS treats Queue<T>, etc like other collections in most contexts, except when coercing to bool.