Search code examples
powershell

Why does (ConvertFrom-Yaml $yaml).anArray.ForEach({ $_ }) return `$null`, when the expression without ForEach returns a list?


Normally, I'm able to use .ForEach({ ... }) on a System.Array, which yields a System.Object with name Collection'1, that I'm again able to use ForEach({ ... }) on:

@(1,2,3).ForEach({ $_ }).ForEach({ $_ })
1
2
3

However, in the following code, I'm able to use .ForEach({ ... }) on the System.Object with name List'1, but it returns $null. It took me long time to realize this, but I don't understand why it returns $null.

Why does (ConvertFrom-Yaml $yaml).anArray.ForEach({ $_ }) return $null?

Code:

Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name powershell-yaml

$yaml = @"
anArray:
- 1
- 2
- 3
"@

(ConvertFrom-Yaml $yaml).anArray
1
2
3

(ConvertFrom-Yaml $yaml).anArray.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     List`1                                   System.Object

# returns $null
(ConvertFrom-Yaml $yaml).anArray.ForEach({ $_ })

@(1,2,3).ForEach({ $_ })
1
2
3

Solution

  • This is unrelated to ConvertFrom-Yaml and related to List<T>, when you do .ForEach on a list you're actually calling its .ForEach(Action<T>) Method instead of the intrinsic PowerShell .ForEach Method and Action<T> is a void delegate, thus you see no output from it but if you added console output to your delegate you'd see it is actually enumerating:

    (ConvertFrom-Yaml $yaml).anArray.ForEach({param($s) [System.Console]::WriteLine($s) })
    

    So the suggestion in this case is to not use .ForEach (use ForEach-Object or foreach) or convert the List<T> to an Array:

    (ConvertFrom-Yaml $yaml).anArray.ToArray().ForEach({ $_ })
    

    Addressing the questions in comments:

    So does List overwrite the intrinsic PowerShell ForEach? Why does the intrinsic version exist on arrays and collections, when it doesn’t exist on lists?

    Both exists, just that the list's instance method takes priority over the intrinsic one.

    In your code, you supply a lambda function? Why am I able to supply a script block?

    No, we're supplying a scriptblock, it happens to be that a scriptblock can be coerced to be an Action delegate without problems.

    $delegate = [System.Action[int, int]] { param($x, $y) $x + $y | Write-Host }
    $delegate.Invoke(1, 1) # 2
    

    Coercion of a scriptblock to a delegate isn't applicable only to Action, it can work with any delegate, try this example if you're running PowerShell 7+:

    Add-Type '
    using System.Collections.Generic;
    using System.Management.Automation;
    
    public static class Test
    {
        public delegate object MyCustomDelegate(object x);
    
        public static IEnumerable<object> ForEach2(
            MyCustomDelegate myDelegate,
            object x)
        {
            if (!LanguagePrimitives.IsObjectEnumerable(x))
            {
                yield return myDelegate(x);
                yield break;
            }
    
            foreach (object i in LanguagePrimitives.GetEnumerable(x))
            {
                yield return myDelegate(i);
            }
        }
    }'
    
    Update-TypeData -TypeName System.Object -Value {
        param([Test+MyCustomDelegate] $delegate)
    
        [Test]::ForEach2($delegate, $this)
    } -MemberName ForEach2 -MemberType ScriptMethod
    
    'hello'.ForEach2({param($i) $i + ' world!' })
    (0..10).ForEach2({param($i) "[$i]" })
    

    Is there any way to call the intrinsic version on lists?

    I don't think it's possible. Unfortunately the logic used for the .Where and .ForEach methods is hard to find in the PowerShell source to give a deeper explanation as to why they have less priority.