13

I've run into some curious behaviour with a default argument that appears to change value (null or an empty string) based on whether that function used a . or & to invoke a native command inside it.

Here is an example script with two identical functions, where the only difference is how they invoke a native command (cmd.exe):

function Format-Type($value)
{
    if ($value -eq $null) { '(null)' } else { $value.GetType().FullName }
}

function Use-Dot
{
    param(
        [string] $Arg = [System.Management.Automation.Language.NullString]::Value
    )

    Write-Host ".: $(Format-Type $Arg)"

    . cmd.exe /c exit 0
}

function Use-Ampersand
{
    param(
        [string] $Arg = [System.Management.Automation.Language.NullString]::Value
    )

    Write-Host "&: $(Format-Type $Arg)"

    & cmd.exe /c exit 0
}

Use-Dot
Use-Ampersand

On PowerShell 5.1 I get the following output which shows that the argument's value is different in the two cases:

.: (null)
&: System.String

It sounds ludicrous for this behaviour to be correlated in this way and therefore I'm sure I must be missing something obvious (or maybe very subtle) here, but what is that?

--

The question What is the . shorthand for in a PowerShell pipeline? talks about the difference in scope between . and & but that doesn't mention why a default argument, which is not even referenced in the command invocation, might be affected by its use. In my example the caller appears to be affected before the command is even invoked.

Chris Oldwood
  • 1,060
  • 10
  • 17
  • What's provoking the question? – Bill_Stewart Dec 19 '18 at 23:14
  • Possible duplicate of [What is the \`.\` shorthand for in a PowerShell pipeline?](https://stackoverflow.com/questions/30855549/what-is-the-shorthand-for-in-a-powershell-pipeline) – Owain Esau Dec 19 '18 at 23:15
  • @Bill_Stewart I changed a `.` to an `&` and it broke a script because the argument's default changed from null to an empty string and the logic to handle the default case then didn't run causing it to fail later. – Chris Oldwood Dec 19 '18 at 23:20
  • I'm baffled by the result, as I don't see how the & and . lines have anything to do with the write-host commands before them that are displaying the different values. – Mike Shepard Dec 19 '18 at 23:28
  • @OwainEsau Answer updated to explain why I don't believe that question is relevant, unless the scope affects the _caller_ before it's even been invoked. – Chris Oldwood Dec 19 '18 at 23:29
  • To add some clarity, I found that the results are the same if you explicitly pass `([NullString]::Value)` to `Use-Dot`/`Use-Ampersand` (so default parameter values are not the issue here), as well as if you call/dot-source an empty `.ps1` file instead of an `.exe` file. I found [this answer](https://stackoverflow.com/a/51354791/150605) helpful in explaining how `[NullString]` behaves with parameters and how to work around it, but that still doesn't explain the behavior of `.` vs. `&` here. – Lance U. Matthews Dec 20 '18 at 01:08
  • Why did you change a `.` to a `&`? – Bill_Stewart Dec 20 '18 at 03:52
  • @BACON Seems like it's the same or similar bug. There's the same difference in the output from `Get-Variable` and the same workaround fixes the problem. I guess this would be worth filing a bug report. – marsze Dec 20 '18 at 07:42
  • @Bill_Stewart That's besides the point. The question is about why the default parameter value appears to differ depending on an operator that is used in the function body (which should not affect parameter default values). This behavior is clearly unexpected. – Ansgar Wiechers Dec 20 '18 at 10:07
  • The more you look at it the more confusing it gets... `Format-Type` is used before the `. cmd...` but `. cmd...` uses the scope of the function to pass its parameter to the `$Arg`. But with `&` you are using `& cmd` in a lower scope than the function. For me the question is not why but why in this order... – T-Me Dec 20 '18 at 12:08
  • Even stranger, if you comment out the cmd lines, they both show system.string – Mike Shepard Dec 20 '18 at 13:51
  • I'm simply wondering about the scenario that provoked the question (I don't understand the reasoning behind changing `.` [invoke in current scope] to `&` [invoke command]). (I agree that the behavior seems unexpected.) – Bill_Stewart Dec 20 '18 at 14:53
  • @Bill_Stewart The driver was simply consistency. I prefer to use `&` by default and only to use `.` when there is a genuine need to affect the current scope. The inconsistent use _on the same executable_ across multiple scripts kept raising questions. – Chris Oldwood Dec 21 '18 at 23:21
  • IMO it seems "wrong" semantically to use `.` to invoke an executable in the first place (hence my question). I guess my point is: Use the semantically correct operator and avoid the confusion/difference in behavior. – Bill_Stewart Dec 26 '18 at 19:10
  • It is weird. Just for debugging purposes, I modified the Write-Host lines: ``Write-Host ".: $($Arg -eq $null) - $(Format-Type $Arg)" Write-Host "&: $($Arg -eq $null) - $(Format-Type $Arg)"`` The output is: ``.: True - (null) &: False - System.String`` The dot triggers the $Arg to be equal to $null. The ampersand somehow converts the null-value to a 0-length string. This is exactly the same when I replace ``&`` with ``Start-Process cmd.exe -ArgumentList '/c exit 0'``. – Joost Jan 16 '19 at 12:43
  • I agree with @T-Me that the more important question is: How can either cmd.exe statements affect the value of $Arg which is defined BEFORE they are even executed. I think it must have something to do with how PS pre-processes the script code before it executes it. – John Pankowicz Feb 23 '19 at 05:11

2 Answers2

1

Spent some digging into this and this is what I've observed.

First for clarity I do not believe that you should consider the NullString value the same as null in a basic comparison. Not sure why you need this either, as this is normally something I'd expect from c# development. You should be able to just use $null for most work in PowerShell.



if($null -eq [System.Management.Automation.Language.NullString]::Value)
{
    write-host "`$null -eq [System.Management.Automation.Language.NullString]::Value"
}
else
{
    write-host "`$null -ne [System.Management.Automation.Language.NullString]::Value"
}

Secondly, the issue is not necessarily because of the call operator, ie &. I believe instead you are dealing with underlying parameter binding coercion. Strong data typing is definitely a weak area for PowerShell, as even explicitly declared [int]$val could end up being set to a string type by PowerShell automatically in the next line when writing Write-Host $Val.

To identify the underlying behavior, I used the Trace-Command function (Trace Command) .

I changed the Use-Dot to just call the function as no write-host was needed to output the string.


function Use-Ampersand
{
    param(
        [string]$NullString = [System.Management.Automation.Language.NullString]::Value
    )
    Format-Type $NullString
    &cmd.exe /c exit 0
}

The Format-Type I modified to also use what is considered a better practice of $null on the left, again due to type inference.

function Format-Type($v= [System.Management.Automation.Language.NullString]::Value)
{

    if ($null  -eq $v)
    {       
     '(null)'
    }
    else {
        $v.GetType().FullName
     }
}

To narrow down the issue with the data types, I used the following commands, though this is not where I found insight into the issue. The both when called directly worked the same.

Trace-Command -Name TypeConversion -Expression { Format-Type $NullString} -PSHost
Trace-Command -Name TypeConversion -Expression { Format-Type ([System.Management.Automation.Language.NullString]$NullString) } -PSHost

However, when I ran the functions using TypeConversion tracing, it showed a difference in the conversions that likely explains some of your observed behavior.

Trace-Command -Name TypeConversion  -Expression { Use-Dot} -PSHost
Trace-Command -Name TypeConversion  -Expression { Use-Ampersand} -PSHost
# USE DOT
DEBUG: TypeConversion Information: 0 :  Converting "" to "System.String".
DEBUG: TypeConversion Information: 0 :      Converting object to string.
DEBUG: TypeConversion Information: 0 :  Converting "" to "System.Object". <<<<<<<<<<<
DEBUG: TypeConversion Information: 0 :  Converting ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.PY;.PYW;.CPL" to "System.String".
DEBUG: TypeConversion Information: 0 :      Result type is assignable from value to convert's type

OUTPUT: (null)

# Use-Ampersand
DEBUG: TypeConversion Information: 0 : Converting "" to "System.String".
DEBUG: TypeConversion Information: 0 :     Converting object to string.
DEBUG: TypeConversion Information: 0 : Converting "" to "System.String". <<<<<<<<<<<
DEBUG: TypeConversion Information: 0 :     Converting null to "".        <<<<<<<<<<<
DEBUG: TypeConversion Information: 0 : Converting ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.PY;.PYW;.CPL" to "System.String".
DEBUG: TypeConversion Information: 0 :     Result type is assignable from value to convert's type

OUTPUT: System.String

The noticeable difference is in Use-Ampersand it shows a statement of Converting null to "" vs Converting "" to "System.Object". In PowerShell, $null <> [string]''. An empty string comparison will pass the null check, resulting in the success of outputting GetType().

A Few Thoughts On Approach With PowerShell

Why it's doing this, I'm not certain, but before you invest more time researching, let me provide one piece of advice based on learning the hard way.

If start dealing with issues due to trying to coerce data types in PowerShell, first consider if PowerShell is the right tool for the job

Yes, you can use type extensions. Yes, you can use .NET data types like $List = [System.Collections.Generic.List[string]]::new() and some .NET typed rules can be enforced. However, PowerShell is not designed to be a strongly typed language like C#. Trying to approach it like this will result in a many difficulties. While I'm a huge fan of PowerShell, I've learned to recognize that it's flexibility should be appreciated, and it's limits respected.

If I really had issues that required mapping [System.Management.Automation.Language.NullString]::Value so strongly, I'd consider my approach.

That said, this was a challenging investigation that I had to take a swing at, while providing my 10 cents afterwards.

Other Resources

After posting my answer, I found another answer that seemed relevant, and also backs up the mentioning of not using [NullString] normally, as its usage in PowerShell is not really what it was designed for.

sheldonhull
  • 1,807
  • 2
  • 26
  • 44
1

This one is fascinating, and I can't fully explain it but can confirm that it appears to be caused by dot-sourcing in the first function. I've simplified your script for repro purposes like this:

function Use-Dot
{
    param
    (
        [string]
        $Param1 = [System.Management.Automation.Language.NullString]::Value
    )

    $Param1.GetType()

    . {$null}
}

function Use-Invoke
{
    param
    (
        [string]
        $Param2 = [System.Management.Automation.Language.NullString]::Value
    )

    $Param2.GetType()

    & {$null}
}

Use-Dot
Use-Invoke

This returns a null invocation error for Use-Dot and returns the string type for Use-Invoke:

PS C:\> C:\TestArgScope.ps1
You cannot call a method on a null-valued expression.
At c:\TestArgScope.ps1:10 char:5
+     $Param1.GetType()
+     ~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull


IsPublic IsSerial Name                                     BaseType                                                        
-------- -------- ----                                     --------                                                        
True     True     String                                   System.Object   

Now consider doing it this way:

function Use-Dot
{
    param
    (
        [string]
        $Param1
    )

    $Param1.GetType()

    . {$null}
}

function Use-Invoke
{
    param
    (
        [string]
        $Param2
    )

    $Param2.GetType()

    & {$null}
}

Use-Dot -Param1 [System.Management.Automation.Language.NullString]::Value
Use-Invoke -Param1 [System.Management.Automation.Language.NullString]::Value

This outputs the string type for both functions, which I think indicates that the problem occurs during parameter binding when default parameter values are supplied. As SheldonH pointed out, the dot-sourcing appears to be changing the type-conversion behavior for the function that follows it, but why? I don't have all the answers, but a couple of comments:

  1. there should be no reason to dot-source a call to cmd.exe - dot-sourcing has a very specific use case and is not the same as invoking the command.
  2. I don't really see a reason to use the NullString type at all. I know that this isn't actually your production script so I don't know why you've demonstrated with that, but it really has a specific purpose which is for .NET method calls.
  3. Dot-Sourcing inside a function is probably a bad idea and could have some wonky results.
  4. String comparisons to $null are a bad idea - why use that when you can just do:

    if (!($Arg)) {<do something>}
    
Matt McNabb
  • 362
  • 4
  • 15
  • This _was_ in a production script, which I broke by accident due to the unexpected behaviour. Most of your comments have probably been answered in the comments directly attached to the question. – Chris Oldwood Jun 30 '19 at 19:47