Powershell: Unobvious type casting effects on ETS

Created on 22 Nov 2019  路  4Comments  路  Source: PowerShell/PowerShell

Steps to reproduce

# [string]
PS > $str1 = 'abc' | Add-Member -NotePropertyMembers @{Test=1} -PassThru
PS > $str1 | Get-Member -View Extended

   TypeName: System.String
Name MemberType   Definition
---- ----------   ----------
Test NoteProperty int Test=1

PS > [string]$str2 = 'abc' | Add-Member -NotePropertyMembers @{Test=1} -PassThru
PS > $str2 | Get-Member -View Extended
PS > $str3 = [string]('abc' | Add-Member -NotePropertyMembers @{Test=1} -PassThru)
PS > $str3 | Get-Member -View Extended
PS >

# [int]
PS > $num1 = 1 | Add-Member -NotePropertyMembers @{Test='abc'} -PassThru
PS > $num1 | Get-Member -View Extended

   TypeName: System.Int32
Name MemberType   Definition
---- ----------   ----------
Test NoteProperty string Test=abc

PS > [int]$num2 = 2 | Add-Member -NotePropertyMembers @{Test='abc'} -PassThru
PS > $num2 | Get-Member -View Extended

   TypeName: System.Int32
Name MemberType   Definition
---- ----------   ----------
Test NoteProperty string Test=abc

PS > $num3 = [int](3 | Add-Member -NotePropertyMembers @{Test='abc'} -PassThru)
PS > $num3 | Get-Member -View Extended
PS >

Expected behavior

Type casting both implicit (by variable type) and explicit (by expression casting) should
either drop extented object members in case of any type, including [string] and [int], or
leave them as is and perform only type checks.

Actual behavior

In case of [string] both implicit and explicit type casting drop extended object members,
and case of [int] only explicit type casting does that.

Environment data

PSVersion                      6.2.3
PSEdition                      Core
GitCommitId                    6.2.3
OS                             Microsoft Windows 10.0.18362
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0鈥
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0
Issue-Question Resolution-Answered WG-Engine

Most helpful comment

Note that I consider PSObject a legacy artifact that might never have been introduced if the DLR existed before PowerShell v1. The key to the resurrection table is typically not a PSObject - this allows one to pass objects to strongly typed .Net methods and recover the instance members.

Instance members + value types don't really work well together. Values are copied unpredictably and you can only access instance members on boxed instances. If I could revisit this - I would disallow adding instance members to value types and instead require a wrapper type Box<T> where T: struct to avoid apparent inconsistencies.

To make matters worse, for performance reasons, PowerShell interns some boxed value types, e.g. $true/$false and small constant integers. This leads to lots of surprising behavior:

5 | Add-Member -NotePropertyName foo -NotePropertyValue "what?"
($y = 5).foo     # Returns `what?`
10005 | Add-Member -NotePropertyName foo -NotePropertyValue "what?"
($y = 10005).foo # Returns nothing

Strings are always wrapped in PSObject as a special case because:

  • they are not value types
  • it seems reasonable to add instance members
  • the runtime (not PowerShell) interns them sometimes

All 4 comments

So all of this makes sense, not necessarily in a design sort of way but a side effect sort of way. I'm not justifying any of this, just providing context for those confused by it.

First it's important to note that all ETS is done by attaching objects to a PSObject. Behind the scenes, most things are wrapped in PSObject's at some point, often several points. Due to the constant wrapping and unwrapping of PSObject's, you need a way to persist any ETS members that were manually added between PSObject instances.

The way PowerShell does this is via "Resurrection Tables". A static dictionary (well ConditionalWeakTable) stores all of the members for an instance. With reference types this is easy to match on, the dictionary key is just the reference. For value types, this is a little bit harder. Since they aren't typically stored on the heap, it boxes the value as object as uses that reference.

# [string]
PS > $str1 = 'abc' | Add-Member -NotePropertyMembers @{Test=1} -PassThru
PS > $str1 | Get-Member -View Extended

   TypeName: System.String
Name MemberType   Definition
---- ----------   ----------
Test NoteProperty int Test=1

PS > [string]$str2 = 'abc' | Add-Member -NotePropertyMembers @{Test=1} -PassThru
PS > $str2 | Get-Member -View Extended
PS > $str3 = [string]('abc' | Add-Member -NotePropertyMembers @{Test=1} -PassThru)
PS > $str3 | Get-Member -View Extended

Strings are special cased when determining the key for member resurrection tables. When a PSObject is wrapping a string, the key is the PSObject. This means that it's more or less excluded from the resurrection tables.

# [int]
PS > $num1 = 1 | Add-Member -NotePropertyMembers @{Test='abc'} -PassThru
PS > $num1 | Get-Member -View Extended

   TypeName: System.Int32
Name MemberType   Definition
---- ----------   ----------
Test NoteProperty string Test=abc

The constant 1 is wrapped in a PSObject as it moves through the pipeline and into Add-Member. The cmdlet then acts as it does with any other PSObject, and returns the result. Same instance throughout, including as it's saved to a variable.

PS > [int]$num2 = 2 | Add-Member -NotePropertyMembers @{Test='abc'} -PassThru
PS > $num2 | Get-Member -View Extended

   TypeName: System.Int32
Name MemberType   Definition
---- ----------   ----------
Test NoteProperty string Test=abc

Same thing happens here. Strongly typed variables in PowerShell work mostly the same with some added validation. This can also work as PowerShell's form of "implicit" conversion which is slightly less forced than "explicit" conversions. The PSObject is not unwrapped as it's saved to $num2 (or if it is unwrapped, it's still the same boxed value so the resurrection table will pick it up.

PS > $num3 = [int](3 | Add-Member -NotePropertyMembers @{Test='abc'} -PassThru)
PS > $num3 | Get-Member -View Extended
PS >

An "explicit" conversion is a lot more forced. This will actually unwrap the PSObject and unbox the object that wraps the int. It is subsequently reboxed when saved as a variable (or immediately after since most PowerShell expressions from the compilers perspective are expected to return object) which will yield a unique key for resurrection tables.

Note that the int examples in particular are complicated further by the fact that the engine caches a decent number of int's (up to 10,000 I think?) on initialization. So a lot of the common int's that would be literal constants in a script are actually the same boxed reference type.

Last note, if you ever find a scenario where you need to defend against this for some reason, you can explicitly unwrap a PSObject with $myVar.psobject.BaseObject.

Note that I consider PSObject a legacy artifact that might never have been introduced if the DLR existed before PowerShell v1. The key to the resurrection table is typically not a PSObject - this allows one to pass objects to strongly typed .Net methods and recover the instance members.

Instance members + value types don't really work well together. Values are copied unpredictably and you can only access instance members on boxed instances. If I could revisit this - I would disallow adding instance members to value types and instead require a wrapper type Box<T> where T: struct to avoid apparent inconsistencies.

To make matters worse, for performance reasons, PowerShell interns some boxed value types, e.g. $true/$false and small constant integers. This leads to lots of surprising behavior:

5 | Add-Member -NotePropertyName foo -NotePropertyValue "what?"
($y = 5).foo     # Returns `what?`
10005 | Add-Member -NotePropertyName foo -NotePropertyValue "what?"
($y = 10005).foo # Returns nothing

Strings are always wrapped in PSObject as a special case because:

  • they are not value types
  • it seems reasonable to add instance members
  • the runtime (not PowerShell) interns them sometimes

@SeeminglyScience @lzybkr ok, got it

I also found some other issues related to that (see #11167 and #11169)

btw, the original issue I came across was impossibility to add a NoteProperty (or ScriptProperty) to a [string] class property so that consumers can benefit from this PowerShell feature while having strictly typed class members

btw, the original issue I came across was impossibility to add a NoteProperty (or ScriptProperty) to a [string] class property so that consumers can benefit from this PowerShell feature while having strictly typed class members

You can, you just need to wrap it first:

$myString = [psobject]'this is a string'
$myString | Add-Member -NotePropertyName Test -NotePropertyValue 'some value'
$myString.Test
# some value

The main difference between string and other reference types is that instance members won't be mirrored in other instances of PSObject that reference the same string.

Was this page helpful?
0 / 5 - 0 ratings