从【返回值陷阱】谈【PowerShell 脚本编写建议】

RareH

前言

本文的所有测试都是在 PowerShell 5.1 中执行的,示例代码在其他版本中可能会有不一样的结果,请以实际情况为准。

想看干货可直接从正文开始阅读,这一章我想聊聊别的酝酿一下感情。

第一次说这个问题 PowerShell 中 function 返回值的陷阱 的时候我还在学校,转眼三年过去了,我工作了,PowerShell 也发展到 7.0 了。它在学生时代给我带来了折腾的乐趣:为了从网站上下载学习资料(懂得都懂)写了一个 m3u8 视频链接的下载工具,结合 ffmpeg 最后可拼接成 mp4;在工作后也让我在需求方面前威风了一把:为了整理若干文件的文件名,我提供了一个可结合 excel 对若干文件批量重命名的工具。

所以我对 PowerShell 感情还挺深的,最近发现利用 WPF 开发一个简单的图形界面供一些脚本小工具使用是还是挺不错的,而且许多用户使用的 Win10 系统预装了 PowerShell 5.1,很多时候开发好的东西直接丢一个脚本给用户就可以了,不需要让用户进行任何额外的安装。

下面是我过去一段时间根据自己踩过的坑总结的建议,内容较长,难免有笔误,欢迎读者指正,感激不尽。

正文

三个建议

其实遵循以下三条建议可以避免除【返回值陷阱】以外的更多的陷阱:

  1. 忘记 IEnumerable、Linq、foreach,牢记 Foreach-Object、Where-Object、Select-Object,如果运行效率确实不能满足需要可以考虑使用 Add-Type 编写 C# 代码;
  2. 不要过于纠结这里的对象类型,不管是什么都当它是“鸭子类型”,相信它会在需要时自动转换,出现报错再具体分析;
  3. 善用管道,用"一行代码"达到目的,把一个脚本中要做的事情适当拆分成多个 function 然后使用一行代码作为该脚本的 "Main Method";

消失但又无处不在的 IEnumerable

当我在写 C# 时,如果需要处理一组数据,会闭着眼选 IEnumerable、Linq、foreach 一顿操作;本来以为 .Net 家族的 PowerShell 也可以用这一套,结果傻眼了,且不说没有办法优雅地使用 Enumerable 提供的扩展方法,就连 IEnumerable 这个类型也变得非常奇怪。直接上代码,各位感受一下什么叫魔法:

首先使用 Add-Type 定义一个实现了 IEnumerable 的类型用于演示,注意该类型还有一个返回 IEnumerable 的静态方法

using System.Collections;

public class Fucker : IEnumerable {

  // 从 0 开始枚举指定数目的整数
  public Fucker(int count) { this.Count = count; }

  public int Count { get; set; }
  public void Add(int x) { this.Count += x; }
  public IEnumerator GetEnumerator() {
    for(int i = 0; i < this.Count; i++) yield return i;
  }

  // 从 0 开始枚举指定数目的整数
  public static IEnumerable Fuck(int count) {
    return new Fucker(count);
    // 使用 yield 语法时会得到编译器生成的一个类型
    // for(int i = 0; i < count; i++) yield return i;
  }
}

然后下面是直接调用 .net 的效果,看起来一切正常

# 直接创建对象立刻赋值
$f = [Fucker]::new(0)
Write-Host "Fucker(0) is $($f.GetType().Name)"
try { $f.Add(1); Write-Host 'Fucker(0) can Add' } catch { Write-Host 'Fucker(0) can''t Add' }
Write-Host "Fucker(0) Count is $($f.Count)"
# 从 .net 方法返回
$f = [Fucker]::Fuck(0)
Write-Host "Fuck(0) is $($f.GetType().Name)"
try { $f.Add(1); Write-Host 'Fucker(0) can Add' } catch { Write-Host 'Fucker(0) can''t Add' }
if ($f -is [System.Collections.IEnumerable]) { Write-Host "Fuck(0) is IEnumerable" }
Write-Host "Fuck(0) Count is $($f.Count)"

当从 PowerShell 的 function 中返回时,神奇的事情发生了

# 看好这两个函数定义,看上去不管传入的 count 是多少,
# 都应该返回一个 Fucker/IEnumerable 对象。
function FuckerReturn($count) { return [Fucker]::new($count) }
function FuckReturn($count) { return [Fucker]::Fuck($count) }

# 用于上述两个函数返回值的函数
function Test-FuckerReturn($count) {
  $f = FuckerReturn($count)
  if ($null -eq $f) { Write-Host "FuckerReturn($count) is NULL" }
  else {
    Write-Host "FuckerReturn($count) is $($f.GetType().Name)"
    try { $f.Add(1); Write-Host "FuckerReturn($count) can Add" } catch { Write-Host "FuckerReturn($count) can't Add" }
    Write-Host "FuckerReturn($count) Count is $($f.Count)"
  }
}
function Test-FuckReturn($count) {
  $f = FuckReturn($count)
  if ($null -eq $f) { Write-Host "FuckReturn($count) is NULL" }
  else {
    Write-Host "FuckReturn($count) is $($f.GetType().Name)"
    try { $f.Add(1); Write-Host "FuckReturn($count) can Add" } catch { Write-Host "FuckReturn($count) can't Add" }
    Write-Host "FuckReturn($count) Count is $($f.Count)"
  }
}

# 神奇的事情发生了,返回的对象类型与 count 有关
0..2 | ForEach-Object { Test-FuckerReturn -count $_ }
0..2 | ForEach-Object { Test-FuckReturn -count $_ }

测试 FuckerReturn 返回值的输出如下(FuckReturn 也一样)

FuckerReturn(0) is NULL
FuckerReturn(1) is Int32
FuckerReturn(1) can't Add
FuckerReturn(1) Count is 1
FuckerReturn(2) is Object[]
FuckerReturn(2) can't Add
FuckerReturn(2) Count is 2

可以看到 PowerShell 的 function(ScriptBlock 同理可测)只要发现返回的对象实现了 IEnumerable 都会把它枚举出来,枚举不出来就认定它没有任何返回值,进一步可以看下面这个函数,初学者定会以为它返回了两个 Fucker 对象,但很不幸因为 Fucker 对象实现了 IEnumerable 接口,所以它返回了三个整数(详见上文 Fucker 源代码),如果直接赋值给变量,那就是包含三个整数的 object[]

function FuckerReturn {
  [Fucker]::new(1)
  [Fucker]::new(2)
}

另外,要小心内置的数组会产生奇怪的“降维”操作,这个已经让人失去解释它的欲望了,在此警告各位:避免使用多维数组

function FuckerReturn2 {
  return @(@([Fucker]::new(1)))
}
function FuckerReturn3 {
  $l = [System.Collections.Generic.List[Fucker]]::new()
  $l.Add([Fucker]::new(1))
  $ll = [System.Collections.Generic.List[Fucker[]]]::new()
  $ll.Add($l.ToArray())
  return $ll.ToArray()
}

这样看 IEnumerable 好像是消失了,但实际上 PowerShell 中的每个 function 都像是 C# 中一个返回值类型为 IEnumerable 的方法一样,比如当你执行下面这个函数时,会收获意外的惊喜(就是 T M D 惊喜),1 和 2 真的会分开返回!

function YieldReturn {
  1 # like yield return in C#
  sleep 3
  2 # like yield return in C#
}
YieldReturn | Foreach-Object { "Time = $(Get-Date -Format 'HH:mm:ss'); Value = $_" }

为了不把精力放在确定某个对象是否实现了 IEnumerable上,使用管道结合 Foreach-Object 进行处理即可,忘记美好的 Linq 方法,即来之则安之……

放心使用“鸭子类型”

我认为理解语言的设计初衷是学习任何一门程序设计语言的指导思想,PowerShell 也不例外。基于上一条事实,结合官方文档在介绍 PowerShell 对象时传达出的倾向,我有理由相信这门语言是鼓励使用鸭子类型的,只需要关心对象有没有你想要的成员即可。

比如看下面这个类型转换的例子,这种转换会直接被忽略:

先使用 Add-Type 定义如下几个类型。

using System;

public interface I { void F(); }

public class B : I { public void F() { Console.WriteLine("B's F"); } }

public class C : B { new public void F() { Console.WriteLine("C's F"); } }

public static class Test {
  public static object O() { return DateTime.Now.Second % 2 == 0 ? new C() : new B(); }
  public static B B() { return DateTime.Now.Second % 2 == 0 ? new C() : new B(); }
  public static I I() { return DateTime.Now.Second % 2 == 0 ? new C() : new B(); }
}

然后会发现下面每一行代码都不能按字面执行,C 类型对象永远是 C 类型的对象;怀疑编译器作祟,我又执行了 Test 中的方法,然后发现不论是在本地创建的对象,还是从 .net 返回的对象,总是无法进行类型转换。

[System.Object]$o = [C]::new(); $o.GetType() # 转 Object
[B]$b = [C]::new(); $b.GetType() # 转基类
[I]$i = [C]::new(); $i.GetType() # 转接口
($b -as [B]).F()
$([B]$b).F()

如果你不服气,非要进行精细的控制,就会发现事情变得得越来越复杂,比如判断变量是否为 $null 这种事,也会变得很恐怖:
震惊!PowerShell 中竟然有两种 null

与其纠结为什么要在比较时把 $null 写在前面,不如避免让代码中出现 $null 至少在自己书写函数时返回 $null 还不如什么都不返回,如果调用的 .net API 确实可能返回 $null 就在返回后立即进行适当处理,避免传播到其它地方;

与其纠结为什么 PowerShell 中有这么多恼人的不受控制的转换(想转换时不转换,不想转换时瞎转换),不如放宽心,Just consider it as a duck!

更少的代码,更少的不确定性

当你对数据的处理已经上升到算法级别的难度,当你需要关心一个变量的类型以及进行类型转换,当你需要进行其他复杂精细的控制时,不要使用 PowerShell!脚本能很方便的对计算机进行管理,也能解决相当一部分需要批量处理的需求,那就让它保持作为一个小工具的本色!

如果真的要用脚本解决对一个比较复杂的问题,也要首先进行全方位的分解,将复杂精细的部分使用 C# 实现,将管理计算机的部分用 function 实现……最终,把要做的事情使用定义好的命令和管道,一行说清

阅读 1.6k
1 声望
0 粉丝
0 条评论
1 声望
0 粉丝
文章目录
宣传栏