Caynadian
Caynadian

Reputation: 757

Powershell Scope Issue in Sub-Function

I have the following Powershell script:

Function Get-InstalledPrograms {
  [CmdletBinding()]
  param (
    [parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)][string]$computername=$env:computername,
    [parameter(Mandatory=$false)] [string]$ExportFile
  )
  Begin {
    $results = @()

    function ItemizeApps ($hive, $computer, $uninstallkey) {
      write-verbose "Opening registry uninstall root key $uninstallkey"
      $regkey=$hive.OpenSubKey($uninstallkey) 
      if ($regkey) {
        write-verbose "Getting uninstall subkeys"
        $subkeys=$regkey.GetSubKeyNames() 
        write-verbose "Itemizing $($subkeys.count) subkeys"
        foreach($key in $subkeys){
          $path=$uninstallkey+"\"+$key
          $thisSubKey=$reg.OpenSubKey($path) 
          write-verbose "Processing uninstall key $path"
          $obj = New-Object PSObject
          $obj | Add-Member -MemberType NoteProperty -Name "ComputerName" -Value $computer
          $obj | Add-Member -MemberType NoteProperty -Name "UninstallKey" -Value $path
          $obj | Add-Member -MemberType NoteProperty -Name "DisplayName" -Value $($thisSubKey.GetValue("DisplayName"))
          $obj | Add-Member -MemberType NoteProperty -Name "DisplayVersion" -Value $($thisSubKey.GetValue("DisplayVersion"))
          $obj | Add-Member -MemberType NoteProperty -Name "InstallLocation" -Value $($thisSubKey.GetValue("InstallLocation"))
          $obj | Add-Member -MemberType NoteProperty -Name "Publisher" -Value $($thisSubKey.GetValue("Publisher"))
          $results += $obj
        } 
      } else {
        write-verbose "No subkeys"
      }
    }
  }
  
  Process {
    # Process HKEY_LOCAL_MACHINE uninstall keys
    $reg=[microsoft.win32.registrykey]::OpenRemoteBaseKey('LocalMachine',$computername)
    if ($reg) {
      # Process x86 uninstalls
      ItemizeApps $reg $computername "\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
      # Process x86 uninstalls
      ItemizeApps $reg $computername "\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
    }
    
    # Process HKEY_USERS uninstall keys
    $reg=[microsoft.win32.registrykey]::OpenRemoteBaseKey('Users',$computername)
    if ($reg) {
      $subkeys = $reg.GetSubKeyNames()
      foreach ($key in $subkeys) {
        ItemizeApps $reg $computername "$key\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
      }
    }
  }
  
  End {
    write-verbose 'Complete'
    if ($ExportFile) {
      $results | Where-Object { $_.DisplayName } | select ComputerName, UninstallKey, DisplayName, DisplayVersion, InstallLocation, Publisher | Export-Csv $ExportFile -append
    } else {
      $results
    }
  }
}

I am getting some weird issues with the $result += $obj. Sometimes it will add the object (first time through?) and then sometimes I get the error:

Method invocation failed because [System.Management.Automation.PSObject] does not contain a method named 'op_Addition'. At D:\Scripts\Get-InstalledPrograms.ps1:26 char:11
+           $global:results += $obj
+           ~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (op_Addition:String) [], RuntimeException
    + FullyQualifiedErrorId : MethodNotFound

Either way, when I come out of the function, $results is empty. I am guessing it has something to do with the scoping of $results but I don't know how to fix this. I have tried $global:results and $script:results and neither work. What am I missing?

Upvotes: 0

Views: 202

Answers (2)

Theo
Theo

Reputation: 61068

I wouldn't add objects to an array using +=, because that is extremely inefficient as the entire array needs to be recreated on each addition.

The way your code is setup, and without making too many changes to it, I suggest you create a [System.Collections.Generic.List[object]] to store the different results in.

You didn't tag this question PowerShell 2.0, so I gather you use version 3.0 or better. As of PS 3.0, you can create PSObjects much easier using [PsCustomObject]@{}.

Also I noticed you open registrykeys, but never close them..

For the revised code below I deliberately used a different name for the resulting list, because you may still have $results as a global variable in memory.

Try

function Get-InstalledPrograms {
  [CmdletBinding()]
  param (
    [parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
    [string]$computername=$env:computername,
    [parameter(Mandatory=$false)] 
    [string]$ExportFile
  )
  Begin {
    # create a List object to store PsObjects
    $softwareList = [System.Collections.Generic.List[object]]::new()

    function ItemizeApps ($hive, $computer, $uninstallkey) {
      Write-Verbose "Opening registry uninstall root key $uninstallkey"
      $regkey = $hive.OpenSubKey($uninstallkey) 
      if ($regkey) {
        Write-Verbose "Getting uninstall subkeys"
        $subkeys = $regkey.GetSubKeyNames() 
        Write-Verbose "Itemizing $($subkeys.count) subkeys"
        foreach($key in $subkeys){
          $path = Join-Path -Path $uninstallkey -ChildPath $key
          $thisSubKey = $reg.OpenSubKey($path) 
          Write-Verbose "Processing uninstall key $path"

          # Power shell v 3.0 has the easy [PsCustomObject] type accellerator
          $softwareList.Add([PsCustomObject]@{
              ComputerName    = $computer
              UninstallKey    = $path
              DisplayName     = $thisSubKey.GetValue("DisplayName")
              DisplayVersion  = $thisSubKey.GetValue("DisplayVersion")
              InstallLocation = $thisSubKey.GetValue("InstallLocation")
              Publisher       = $thisSubKey.GetValue("Publisher")
              UninstallString = $thisSubKey.GetValue("UninstallString")
          })
          # close the opened key
          $thisSubKey.Close()
        }
        # close the opened key
        $regkey.Close()
      } else {
        Write-Verbose "No subkeys"
      }
    }
  }

  Process {
    # Process HKEY_LOCAL_MACHINE uninstall keys
    $reg = [microsoft.win32.registrykey]::OpenRemoteBaseKey('LocalMachine',$computername)
    if ($reg) {
      # Process x86 uninstalls
      ItemizeApps $reg $computername "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
      # Process x86 uninstalls
      ItemizeApps $reg $computername "SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
      # close the opened key
      $reg.Close()
    }

    # Process HKEY_USERS uninstall keys
    $reg = [microsoft.win32.registrykey]::OpenRemoteBaseKey('Users',$computername)
    if ($reg) {
      $subkeys = $reg.GetSubKeyNames()
      foreach ($key in $subkeys) {
        ItemizeApps $reg $computername "$key\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
      }
      # close the opened key
      $reg.Close()
    }
  }

  End {
    Write-Verbose 'Complete'
    if ($ExportFile) {
      # since you want to export all the properties, there is no need to insert a Select-Object here
      $softwareList | Where-Object { $_.DisplayName } | Export-Csv $ExportFile -Append -NoTypeInformation
    } else {
      $softwareList
    }
  }
}

Get-InstalledPrograms -Verbose

Upvotes: 1

AdminOfThings
AdminOfThings

Reputation: 25001

Theo's helpful answer gives good advice and provides a robust solution. I wanted to add explanation as to what caused errors and unintended results in your code.

When a function is called, it creates its own scope. Without targeting a specific variable scope or explicitly calling a function within a certain scope, you won't have access to its variables. If you want data created by a function, best practice will be to return that data to the calling scope and then store it.

If you call a function using ., it will run in the current scope. Variable modifications not explicitly scoped will be reflected back in the calling scope when the function returns.

When using the global: scope modifier, the global variable will be available in the calling and called scopes.

Using += to add elements to an array is not efficient. This is because you are not actually adding a new element to an array. You are outputting the current array and the new element values and storing those results into a new array. As an array gets larger, that process uses more system resources.

Regarding your issues with +=, you will receive that error if $results is either not already an array, is a type that can't be converted into an array with the + operation, or a scalar that doesn't support appending with +. When $results is a type PSCustomObject, you cannot add another PSCustomObject to it to make an array.

# Example of storing function output in calling scope
$results = Get-InstalledPrograms

# Example Using . to call a function. $results will be updated in the calling scope
. Get-InstalledPrograms
$data | . Get-InstalledPrograms

# Example using global scope modifier
Function Get-InstalledPrograms {
    $global:results = 'I am global'
}
Get-InstalledPrograms
$global:results
I am global

# Example of problematic +=
$results = $null
$results += [pscustomobject]@{a=1}
$results += [pscustomobject]@{a=2} # This will error because $results is not yet an array

# Example of error free +=
$results = @()
$results += [pscustomobject]@{a=1}
$results += [pscustomobject]@{a=2} # Works because $results was already an array

# Example of mixing scopes and problematic +=
$results = 25 # This is global since it is in top level scope
Function Foo {
    $global:results += [pscustomobject]@{a=2}
}
Foo # Error because $results already existed as a non-array and incompatible type for the + operation in global scope

Upvotes: 1

Related Questions