Search code examples
powershellvariablesvariable-assignment

What's different between "$myvariable =" and Set-Variable in PowerShell?


When I study PowerShell scripting language, I try to use "Write-Output" command to display variable.

I use a different method to create variables.

Example:

$myvariable = 0x5555

Set-Variable -Name myvariable2 -Value 0x5555

The data type of these two variables is Int32.

When I use the command as below,

Write-Output $myvariable $myvariable2

the result is 21845 and 0x5555.

What's different between these two variables?

How can I display format result like printf %d %x?


Solution

  • Preface:

    • The answer to the general question in the post's title is:
      $var = $value is typically the same as Set-Variable var $value
      (Set-Variable -Name var -Value $value)

      • Set-Variable has additional parameters, which provide functionality that direct assignment using =, the assignment operator, cannot.

      • Unless you need these parameters, there's usually no good reason to use Set-Variable - direct assignment is more concise, performs better, and avoids surprises.

    • As the code in the question's body shows, this is not always the case, and such edge cases are explained below.

      • The tl;dr of avoiding the problem is:
        • If you must use Set-Variable, enclose the -Value argument in (...):
          Set-Variable -Name myvariable2 -Value (0x5555)

        • This ensures that the value is a regular, non-[psobject]-wrapped [int] (System.Int32) instance, which PowerShell's output formatting renders in decimal format by default.

    • As an aside: If your intent is to control a number's string representation explicitly:

      • Either: Use that number's .ToString() method; e.g. (0x5555).ToString('D')

      • Or: Use PowerShell's -f operator; e.g. '{0:D}' -f 0x5555

      • Both approaches rely on .NET numeric format strings, either standard or custom ones.

    Read on for background information:


    PetSerAl, as many times before, has given the crucial pointer in a comment (and later helped improve this answer):

    In argument-parsing mode - such as when invoking Set-Variable - PowerShell parses an unquoted literal argument that looks like a number as a number and wraps it in a "half-transparent" [psobject] instance, whose purpose is to also preserve the exact argument as specified as a string.

    By half-transparent I mean that the resulting $myVariable2:

    • primarily is a number - a regular (unwrapped) [int] instance - for the purpose of calculations; e.g., $myVariable2 + 1 correctly returns 21846

      • additionally, it shows that it is a number when you ask for its type with .GetType() or via the Get-Member cmdlet; in other words: in this case PowerShell pretends that the wrapper isn't there (see below for a workaround).
    • situationally behaves like a string - returning the original argument literal exactly as specified - in the context of:

      • output formatting, both when printing directly to the console and generally with Out-* cmdlets such as Out-File (but not Set-Content) and Format-* cmdlets.

      • string formatting with -f, PowerShell's format operator (which is based on .NET's String.Format() method; e.g., 'The answer is {0}' -f 42 is equivalent to [string]::Format('The answer is {0}', 42)).

      • Surprisingly, it does not behave like a string inside an expandable string ("$myVariable2") and when you call the .ToString() method ($myVariable2.ToString()) and (therefore also) with Set-Content.

        • However, the original string representation can be retrieved with $myVariable2.psobject.ToString()

    Note that specifying number literals as command arguments is inherently ambiguous, because even string arguments generally don't need quoting (unless they contain special characters), so that, for instance, an argument such as 1.0 could be interpreted as a version string or as a floating-point number.

    PowerShell's approach to resolving the ambiguity is to parse such a token as a number, which, however, situationally acts as a string[1], as shown above.

    The ambiguity can be avoided altogether by typing parameters so as to indicate whether an argument bound to it is a string or a number.

    However, the -Value parameter of the Set-Variable and New-Variable cmdlets is - of necessity - [object] typed, because it must be able to accept values of any type, and these cmdlets don't have a parameter that would let you indicate the intended data type.


    The solution is to force the -Value argument to be treated as the result of an expression rather than as an unquoted literal argument, by enclosing it in (...):

    # Due to enclosing in (...), the value that is stored in $myvariable2 
    # is *not* wrapped in [psobject] and therefore behaves the same as
    # $myvariable = 0x55555
    Set-Variable -Name myvariable2 -Value (0x5555)
    

    Conversely, if you don't apply the above solution, you have two choices for unwrapping $myvariable2's value on demand:

    # OK: $myvariable isn't wrapped in [psobject], so formatting it as a
    #     hex. number works as expected:
    PS> 'hex: 0x{0:x}' -f $myvariable
    hex: 0x5555  # OK: Literal '0x' followed by hex. representation of the [int]
    
    # !! Does NOT work as expected, because $myvariable2 is treated as a *string*
    # !! That is, {0:x} is effectively treated as just {0}, and the string
    # !! representation stored in the [psobject] wrapper is used as-is.   
    PS> 'hex: 0x{0:x}' -f $myvariable2
    hex: 0x0x5555  # !! Note the extra '0x'
    
    # Workaround 1: Use a *cast* (with the same type) to force creation of
    #               a new, *unwrapped* [int] instance:
    PS> 'hex: 0x{0:x}' -f [int] $myvariable2
    hex: 0x5555  # OK
    
    # Workaround 2: Access the *wrapped* object via .psobject.BaseObject.
    #               The result is an [int] that behaves as expected.
    PS> 'hex: 0x{0:x}' -f $myvariable2.psobject.BaseObject
    hex: 0x5555  # OK
    

    Note: That -f, the format operator, unexpectedly treats a [psobject]-wrapped number as a string is the subject of GitHub issue #17199; sadly, the behavior was declared to be by design.

    Detecting a [psobject]-wrapped value:

    The simplest solution is to use -is [psobject]:

    PS> $myvariable -is [psobject]
    False  # NO wrapper object
    
    PS> $myvariable2 -is [psobject]
    True  # !! wrapper object
    

    (PetSerAl offers the following, less obvious alternative:
    [Type]::GetTypeArray((, $myvariable2)), which bypasses PowerShell's hiding-of-the-wrapper trickery.)

    On a general note:

    • [psobject] is generally meant to be an invisible helper type. While it provides intentional behavior modification in the case at hand, there are several scenarios in which its incidental use causes undesired variations in behavior: see GitHub issue #5579.

    [1] Preserving the input string representation in implicitly typed numbers passed as command arguments:

    Unlike traditional shells, PowerShell uses rich types, so that an argument literal such as 01.2 is instantly parsed as a number - a [double] in this case, and if it were used as-is, it would result in a different representation on output, because - once parsed as a number - default output formatting is applied on output (where the number must again be turned into a string):

     PS> 01.2
     1.2  # !! representation differs (and is culture-sensitive)
    

    However, the intent of the target command may ultimately be to treat the argument as a string and in that case you do not want the output representation to change. (Note that while you can disambiguate numbers from strings by using quoting (01.2 vs. '01.2'), this is not generally required in command arguments, the same way it isn't required in traditional shells.)

    It is for that reason that a [psobject] wrapper is used to capture the original string representation and use it on output.

    Note: Arguably, a more consistent approach would have been to always treat unquoted literal arguments as strings, except when bound to explicitly numerically typed parameters in PowerShell commands.

    This is a necessity for invoking external programs, to which arguments can only ever be passed as strings.

    That is, after initial parsing as a number, PowerShell must use the original string representation when building the command line (Windows) / passing the argument (Unix-like platforms) as part of the invocation of the external program.

    If it didn't do that, arguments could inadvertently be changed, as shown above (in the example above, the external program would receive string 1.2 instead of the originally passed 01.2).

    You can also demonstrate the behavior using PowerShell code, with an untyped parameter - though note that is generally preferable to explicitly type your parameters:

     PS> & { param($foo) $foo.GetType().Name; $foo } -foo 01.2
     Double  # parsed as number - a [double]
     01.2    # !! original string representation, because $foo wasn't typed
    
    • $foo is an untyped parameter, which means that the type that PowerShell inferred during initial parsing of literal 01.2 is used.

    • Yet, given that the command (a script block ({ ... }) in this case) didn't declare a parameter type for $foo, the [psobject] wrapper that is implicitly used shows the original string representation on output.