Search code examples
powershell

PowerShell: Add-Member incorrectly changes Objects that aren't the input


While doing some testing with Add-Member, I created 'copies' of my actual data to test with, but I quickly found that Add-Member was actually adding new properties to the original object without being told or asked to. Is this the expected behavior? If yes, how are by reference variables working in PowerShell?

Example:

# Create a CSV file
@'
Name,Title
Bob,President
Todd,Secretary
'@ > test.csv

# Load the CSV into an object
$Data = Import-Csv test.csv

# Create a duplicate of $Data (this must be the issue)
$NewData = $Data

# Add a new property to the $NewData object
$NewData | Add-Member ScriptProperty "First Initial" {$this.Name[0]}

# Check the original Object ($Data) and see the madness
$Data

# Viewing $Data as a table
Name Title     First Initial
---- -----     -------------
Bob  President             B
Todd Secretary             T

$Data | Get-Member

# Just confirming that there is indeed a MemberType of ScriptProperty that was added to $Data

   TypeName: System.Management.Automation.PSCustomObject

Name          MemberType     Definition
----          ----------     ----------
Equals        Method         bool Equals(System.Object obj)
GetHashCode   Method         int GetHashCode()
GetType       Method         type GetType()
ToString      Method         string ToString()
Name          NoteProperty   string Name=Bob
Title         NoteProperty   string Title=President
First Initial ScriptProperty System.Object First Initial {get=$this.Name[0];}

Solution

  • To flesh out the helpful comments on the question:

    $NewData = $Data does not copy data, because $Data contains a reference to an array, which is an instance of a .NET reference type. Instead, it is the reference that is copied, so that both variables end up referencing the very same array.

    An easy (albeit inefficient) way to create a copy of an array in PowerShell is to enclose it in @(...), the array-subexpression operator:

    # Convenient, but slow with large arrays.
    $NewData = @($Data)
    
    # Faster.
    $NewData = $Data.Clone()
    

    However, if the array's elements too contain references to .NET reference-type instances, the copied array's elements still reference the very same instances - and the [pscustomobject] instances that Import-Csv outputs are indeed reference-type instances.

    You can create a copy of the $Data array and its elements with the following, using the intrinsic .psobject member to create (shallow) copies of [pscustomobject] instances:

    $NewData = $Data | ForEach-Object { $_.psobject.Copy() }
    

    However, as noted, .psobject.Copy() creates a shallow copy of each [pscustomobject] input object. That is:

    • properties that happen to contain instances of .NET value types result in true copies in the copied object; similarly, string values are in effect copies.[1]

    • by contrast, properties containing references to instances of .NET reference types result in those references being copied, which means that both the original object's property value as well as the copied object's point to the very same instance, which means that if the referenced instance is mutable, changes to it are reflected in both containing objects.

    That said, with objects created via Import-Csv, whose properties by definition are all strings, that is not a concern.

    See this answer for more information.


    [1] [string] is technically a reference type, but is usually treated like a value type in .NET. Since a string instance is by definition immutable, you can rely on any copy of it to remain unchanged. A string can never be modified, it can only be replaced by a new string.