大家都知道,我寫 .NET 程式早已「無 LINQ 不歡」,上癮程度直逼「無 LINQ 吾寧死」 (LINQ or Die)(延伸閱讀:好 LINQ,不用嗎?)。這幾個月切到 PoewrShell 跑道,仗著它能無縫整合 .NET,遇到複雜一點的演算便想掏出 LINQ 解決問題。 但 LINQ 基於 IEnumerable<T>,PowerShell 非強型別語言也沒泛型,有辦法在 PowerShell 裡寫 LINQ 嗎?

先說答案,在 PowerShell 要使用 .NET LINQ 方法是可行的,但不推。至於原因,繼續往下看就會明白。

資料比對是我佷愛用的 LINQ 應用範例。如下圖,假設有兩份庫存資料,要快速整理出有哪些項目是新增的、哪些被刪除、哪些有異動、哪些沒變:

借用 LINQ 的 ToDictionary()、Except()、Intersect() 就能簡潔達成任務:

class Program
{
    static void Main(string[] args)
    {
        var data = new Stock[]
        {
            new Stock("Monitor", 99),
            new Stock("Keyboard", 127),
            new Stock("Speaker", 1024),
        };
        var srcData = new Stock[]
        {
            new Stock("Keyboard",255),
            new Stock("Speaker", 1024),
            new Stock("Mouse", 32767)
        };

        Compare(data, srcData);
        Console.Read();

    }

    public static void Compare(IEnumerable<Stock> data, IEnumerable<Stock> srcData)
    {
        var dict = data.ToDictionary(o => o.Id, o => o);
        var srcDict = srcData.ToDictionary(o => o.Id, o => o);
        var added = dict.Keys.Except(srcDict.Keys);
        var removed = srcDict.Keys.Except(dict.Keys);
        Console.WriteLine("新增項目:");
        Console.WriteLine(string.Join(", ", added.Select(o => dict[o].ToString()).ToArray()));
        Console.WriteLine("移除項目:");
        Console.WriteLine(string.Join(", ", removed.Select(o => srcDict[o].ToString()).ToArray()));
        var matched = dict.Keys.Intersect(srcDict.Keys);
        var same = matched.Where(o => dict[o].Qty == srcDict[o].Qty);
        Console.WriteLine("數量相同項目:");
        Console.WriteLine(string.Join(", ", same.Select(o => dict[o].ToString()).ToArray()));
        Console.WriteLine("數量異動項目:");
        Console.WriteLine(string.Join(", ", matched.Except(same).Select(o => 
            srcDict[o].ToString() + "->" + dict[o].Qty).ToArray()));
    }

    public class Stock
    {
        public string Id { get; set; }
        public int Qty { get; set; }
        public Stock(string id, int qty)
        {
            Id = id;
            Qty = qty;
        }
        public override string ToString()
        {
            return $"[{Id}]:{Qty:n0}";
        }
    }
}

同樣演算法可以搬到 PowerShell 實現嗎?可以,但有個麻煩之處。C# 之所以能在 Stock[] 型別使用 ToDictionary() 方法、在 Dictionary<T, T>.Keys 使用 .Except()、.Intersect()、對 LINQ 方法傳回結果繼續使用 .Select()/ToArray() 是因為它們乽符合 IEnumerable<T> 型別,故能使用針對 IEnumerable<T> 提供的 LINQ 擴充方法。PowerShell 沒有強型別、沒有泛型,若想使用 .ToDictionary()、.ToArray()、Except()、Intersect()、Select()... 等 LINQ 方法,只能改呼叫 Enumerable 的 .ToArray、.ToDictionary、Select、ToArray... 擴充方法本體,把集合物件當成第一個參數傳進去。

來看前述 C# 程式如何移植成 PowerShell?

class Stock {
    [string]$Id
    [int]$Qty
    Stock($id, $qty) {
        $this.Id = $id
        $this.Qty = $qty
    }
    [string]ToString() {
        return "[$($this.Id)]:$($this.Qty)"
    }
}

$data = @(
    [Stock]::new("Monitor", 99),
    [Stock]::new("Keyboard", 127),
    [Stock]::new("Speaker", 1024)
)

$srcData = @(
    [Stock]::new("Keyboard", 255),
    [Stock]::new("Speaker", 1024),
    [Stock]::new("Mouse", 32767)
)

function CompareData($data, $srcData) {

    $dict = [System.Linq.Enumerable]::ToDictionary($data, [Func[object, object]] { param($x) $x.Id }, [Func[object, object]] { param($x) $x })
    $srcDict = [System.Linq.Enumerable]::ToDictionary($srcData, [Func[object, object]] { param($x) $x.Id }, [Func[object, object]] { param($x) $x })

    [object[]]$added = [System.Linq.Enumerable]::Except($dict.Keys, $srcDict.Keys)
    Write-Host "新增項目:"
    [string]::Join(", ", [System.Linq.Enumerable]::ToArray([System.Linq.Enumerable]::Select($added, [Func[object, string]] { param($o) $dict[$o].ToString() })))
    [object[]]$removed = [System.Linq.Enumerable]::Except($srcDict.Keys, $dict.Keys)
    Write-Host "移除項目:"
    [string]::Join(", ", [System.Linq.Enumerable]::ToArray([System.Linq.Enumerable]::Select($removed, [Func[object, string]] { param($o) $srcDict[$o].ToString() })))
    [object[]]$matched = [System.Linq.Enumerable]::Intersect($dict.Keys, $srcDict.Keys)
    [object[]]$same = [System.Linq.Enumerable]::Where($matched, [Func[object, bool]] { param($o) $srcDict[$o].Qty -eq $dict[$o].Qty })
    Write-Host "數量相同項目:"
    [string]::Join(", ", [System.Linq.Enumerable]::Select($same, [Func[object, string]] { param($o) $dict[$o].ToString() }))
    Write-Host "數量異動項目:"
    [object[]]$diff = [System.Linq.Enumerable]::Except([object[]]$matched, $same)
    [string]::Join(", ", [System.Linq.Enumerable]::Select($diff, [Func[object, string]] { param($o) "$($srcDict[$o].ToString()) -> $($dict[$o].Qty )" }))
}

CompareData $data  $srcData

有幾個重點:

  • 不能直接寫 $dict.Keys.Except($srcDict.Keys),會得到以下錯誤
    Method invocation failed because [System.String] does not contain a method named 'Except'.
    必須改成
    [System.Linq.Enumerable]::Except($dict.Keys, $srcDict.Keys)
  • LINQ 中的 o => o > 5,在 PowerShell 要寫成 { param($o) $o -gt 5 }
  • 由於 Enumerable 擴充方法的第一個參數是 this IEnumerable<T> source,第二個參數若為 Lambda,型別會類似 Func<T, bool> ,PowerShell 變數及 Lambda 方法需要明確宣告型別或轉型才符合方法定義,例如:
    $array = 0..9
    [System.Linq.Enumerable]::Where($array, { param($o) $o -gt 5})
    # 以上會出現 Cannot find an overload for "Where" and the argument count: "2". 錯誤
    # 要記得加上 [Func[object,bool]] 轉型
    [System.Linq.Enumerable]::Where($array, [Func[object,bool]]{ param($o) $o -gt 5})
    # 得到 6 7 8 9
    $cmp= 5..9 
    [System.Linq.Enumerable]::Except($array, $cmp)
    # 以上會出現 Cannot find an overload for "Except" and the argument count: "2". 錯誤
    [System.Linq.Enumerable]::Except([object[]]$array, $cmp)
    # 遇到這類問題,加上 [object[]] 轉型多能解決
    
  • LINQ 的串接寫法 (.Where().Select().ToArray()...) 轉成 PowerShell 會像 [System.Linq.Enumerable]::ToArray([System.Linq.Enumerable]::Select([System.Linq.Enumerable]::Where(...))),有點噁心。

在 C# 裡虎虎生風的 LINQ,硬搬進 PowerShell 裡像是沒氣的啤酒,只剩滿滿的苦澀... 看到這裡,應該可以體會為什麼我說"在 PowerShell 裡寫 .NET LINQ 是可行的,但不推"吧?

PowerShell 裡也有 LINQ 概念,像是 ForEach-Object、Where-Object 再用 | 符號串接,功能或許沒有 .NET LINQ 完整,但因屬內建支援語法,寫起來簡潔流暢許多。因此,我的心得是,在 PowerShell 裡實現 LINQ 的策略應為「以 PowerShell 內建方法優先,不足之處再靠 .NET LINQ 救援」,別一心想用 .NET LINQ 硬幹。

運用上述心法,試著將程式改寫成純 PowerShell 版:

function ToDictionary([object[]]$array, [string]$keyPropName) {
    $hash = @{}
    $array | ForEach-Object {
        $hash[($_ | Select-Object -ExpandProperty $keyPropName)] = $_
    }
    return $hash
}

function CompareData($data, $srcData) {
    $dict = ToDictionary $data "Id"
    $srcDict = ToDictionary $srcData "Id"
    $added = $data | Where-Object { $srcDict.Keys -notcontains $_.Id } | ForEach-Object { $_.ToString() }
    Write-Host "新增項目:"
    $added -Join ", "
    $removed = $srcData | Where-Object { $dict.Keys -notcontains $_.Id } | ForEach-Object { $_.ToString() }
    Write-Host "移除項目:"
    $removed -Join ", "
    $matched = $srcData | Where-Object { $dict.Keys -contains $_.Id }
    $same = $matched | Where-Object { $_.Qty -eq $dict[$_.Id].Qty } | ForEach-Object { $_.ToString() }
    Write-Host "數量相同項目:"
    $same -Join ", "
    Write-Host "數量異動項目:"
    $diff = $matched | Where-Object { $_.Qty -ne $dict[$_.Id].Qty } | ForEach-Object { "$($_.ToString()) -> $($dict[$_.Id].Qty)" }
    $diff -Join ", "
}

我算不上 PowerShell 熟手,程式應該還有不少優化空間,但這個案例基本上不必依賴 .NET,全靠 PowerShell 內建方法就能寫好,且程式碼乾淨許多。

回頭想想,對初入 PowerShell 的 .NET 開發者來說,這樣的成長歷程也還好。即使不熟 PowerShell ,遇到難題也不會陷入恐慌,因為永遠可以招喚 .NET 登板救援,醜歸醜,但問題總能及時被解決。先立於不敗之地(不會開天窗被老闆噹),再一步步學好 PowerShell,用更簡潔有效率的寫法完成相同功能。這種「先求有,再求好」的 Minimum Viable Product MVP 觀念還蠻「敏捷」的,呵。

Tips of using .NET LINQ in PowerShell.


Comments

# by guest

微軟真是無聊,當初弄個CSharpShell就好了,硬要弄一個powershell...

# by Ike

PowerShell 的 Code 好難閱讀… ~"~

Post a comment