.netpowershellidisposableresource-cleanup

Dispose of a StreamWriter without declaring a variable in one line


The following Powershell command fails to copy the entire file; a few characters are always missing from the end.

[System.IO.StreamWriter]::new('C:\TEMP\b.csv', [System.Text.Encoding]::UTF8).Write([System.IO.StreamReader]::new('C:\Temp\a.csv', [System.Text.Encoding]::GetEncoding('iso-8859-1')).ReadToEnd())

I suspect it's because the writer doesn't flush the last bits because this does copy the entire file:

$X = [System.IO.StreamReader]::new('C:\Temp\a.csv', [System.Text.Encoding]::GetEncoding('iso-8859-1'))
$Y = [System.IO.StreamWriter]::new('C:\TEMP\b.csv', [System.Text.Encoding]::UTF8)
$Y.Write($X.ReadAll())
$X.Dispose()
$Y.Dispose()

Is it possible to dispose of (and flush) the reader & writer without having created variables to reference them?

EDIT: I tried this one-liner using streamreader/writer hoping the reader's read buffer would directly transfer to the writer's write buffer rather than waiting for the reader to read the entire file into memory and then write. What technique might achieve that?

I personally find code that does not declare a single-use object to often be cleaner / more succinct, but my focus is on understanding whether/how objects dispose of themselves, not the style.

There's no need to eschew variables or write on one line, but this behaviour isn't what I expected. In VBA one can copy a file like so and trust it will dispose of itself properly without having to declare a variable and explicitly flush (I think).

Sub Cpy()
With New Scripting.FileSystemObject
    .CreateTextFile("c:\Temp\Out.txt").Write .OpenTextFile("C:\Temp\In.txt", ForReading).ReadAll
End With
End Sub

One can achieve similar behaviour in a custom VBA class by writing appropriate 'clean-up' code in a Class_Terminate() procedure. I assumed the Streamwriter would similarly flush data upon termination via garbage collection once the line executes and there's no longer a variable associated with it.

I also noticed that the file remains locked and I cannot delete it until I close the powershell session. Is there a way to flush contents and release the file without having declared a variable to work with?


Solution


  • As for your general question:

    As of PowerShell (Core) 7.2.1:

    It is up to each type implementing the IDisposable interface whether it calls the .Dispose() methods from the finalizer. Only if so is an object automatically disposed of eventually, by the garbage collector.

    For System.IO.StreamWriter and also the lower-level System.IO.FileStream class, this appears not to be the case, so in PowerShell you must call .Close() or .Dispose() explicitly, which is best done from the finally block of a try / catch / finally statement.

    You can cut down on the ceremony somewhat by combining the aspects of object construction and variable assignment, but a robust idiom still requires a lot of ceremony:

    $x = $y = $null
    try {
      ($y = [System.IO.StreamWriter]::new('C:\TEMP\b.csv', [System.Text.Encoding]::UTF8)).
        Write(
          ($x = [System.IO.StreamReader]::new('C:\Temp\a.csv', [System.Text.Encoding]::GetEncoding('iso-8859-1'))).
            ReadToEnd()
        )
    } finally {
      if ($x) { $x.Dispose() }
      if ($y) { $y.Dispose() }
    }
    

    A helper function, Use-Object (source code below) can alleviate this a bit:

    Use-Object 
      ([System.IO.StreamReader]::new('C:\Temp\a.csv',[System.Text.Encoding]::GetEncoding('iso-8859-1'))), 
      ([System.IO.StreamWriter]::new('C:\TEMP\b.csv', [System.Text.Encoding]::UTF8)) `
      { $_[1].Write($_[0].ReadToEnd()) }
    

    Note how the disable objects passed as the first argument are referenced via $_ as an array in the script-block argument (as usual you may use $PSItem in lieu of $_).

    A more readable alternative:

    Use-Object 
      ([System.IO.StreamReader]::new('C:\Temp\a.csv',[System.Text.Encoding]::GetEncoding('iso-8859-1'))), 
      ([System.IO.StreamWriter]::new('C:\TEMP\b.csv', [System.Text.Encoding]::UTF8)) `
      { 
        $reader, $writer = $_
        $writer.Write($reader.ReadToEnd()) 
      }
    

    Or, perhaps even better, albeit with slightly different semantics (which will rarely matter),[1] as Darin suggests:

    Use-Object 
      ($reader = [System.IO.StreamReader]::new('C:\Temp\a.csv',[System.Text.Encoding]::GetEncoding('iso-8859-1'))), 
      ($writer = [System.IO.StreamWriter]::new('C:\TEMP\b.csv', [System.Text.Encoding]::UTF8)) `
      { 
        $writer.Write($reader.ReadToEnd()) 
      }
    

    Use-Object source code:

    function Use-Object {
      param( 
        [Parameter(Mandatory)] $ObjectsToDispose,  # a single object or array
        [Parameter(Mandatory)] [scriptblock] $ScriptBlock
      )
    
      try {
        ForEach-Object $ScriptBlock -InputObject $ObjectsToDispose
      }
      finally {
        foreach ($o in $ObjectsToDispose) {
          if ($o -is [System.IDisposable]) {
            $o.Dispose()
          }
        }
      }
      
    }
    

    [1] With this syntax, you're creating the variables in the caller's scope, not in the function's, but this won't matter, as long as you don't try to assign different objects to these variables with the intent of also making the caller see such changes. (If you tried that, you would create a function-local copy of the variable that the caller won't see) - see this answer for details.