【问题标题】:Does there exist a designated (sub)index delimiter?是否存在指定的(子)索引分隔符?
【发布时间】:2022-11-10 06:37:34
【问题描述】:

背景

在 PowerShell 中构建 hash table 以通过特定属性快速访问对象是很常见的,例如基于LastName 的索引:

$List =  ConvertFrom-Csv @'
Id, LastName, FirstName, Country
 1, Aerts,    Ronald,    Belgium
 2, Berg,     Ashly,     Germany
 3, Cook,     James,     England
 4, Duval,    Frank,     France
 5, Lyberg,   Ash,       England
 6, Fischer,  Adam,      Germany
'@

$Index = @{}
$List |ForEach-Object { $Index[$_.LastName] = $_ }

$Index.Cook

Id LastName FirstName Country
-- -------- --------- -------
3  Cook     James     England

在某些情况下,需要在两个(甚至更多)属性上构建索引,例如FirstNameLastName。为此,您可以创建一个多维键,例如:

$Index = @{}
$List |ForEach-Object {
     $Index[$_.FirstName] = @{}
     $Index[$_.FirstName][$_.LastName] = $_
}

$Index.James.Cook

Id LastName FirstName Country
-- -------- --------- -------
3  Cook     James     England

但是将这两个属性连接起来更容易(甚至可能更快)。如果仅用于检查条目是否存在:$Index.ContainsKey('James').ContainsKey('Cook'),如果FirstName 不存在,则可能会发生错误。
要连接属性,需要在属性之间使用分隔符,否则不同的属性列表可能最终成为相同的键。例如:AshlyBergAshLyberg

$Index = @{}
$List |ForEach-Object { $Index["$($_.FirstName)`t$($_.LastName)"] = $_ }

$Index."James`tCook"

Id LastName FirstName Country
-- -------- --------- -------
3  Cook     James     England

笔记:以上是Minimal, Reproducible Examples。在现实生活中,我多次提出以下问题,其中包括一般连接对象,其中背景和索引中使用的属性数量是可变的。

问题:

  1. 在这种情况下连接(连接)属性是一种好习惯吗?
  2. 如果是,是否有(标准?)分隔符? (意味着一个字符 - 或一系列字符 - 不应该在属性名称中使用/存在)

【问题讨论】:

  • 我认为加入密钥是否是一种好习惯,取决于您是否只需要按全名查找。我用过单位分隔符(ASCII 0x1F) 作为字符串字段的分隔符。 List of control characters
  • 另一种选择是为您构建的每个表生成一个随机分隔符(很像多部分电子邮件中的边界分隔符):[string]$delim = New-Guid; $List |%{ $Index[($_.FirstName,$_.LastName-join$delim)] = $_ }

标签: .net powershell indexing hashtable


【解决方案1】:

编辑:虽然以下解决方案有效,但它不如我推荐使用的solution provided by mklement0 干净。元组在许多其他情况下也很有用,所以我会以这个答案为例。


我建议在Tuple 类的帮助下使用“拆分密钥”,而不是加入密钥。在这种情况下,不需要分隔符,因为键没有连接,而是存储为单独的属性在一个对象中。 Tuple 类提供了必要的接口,因此当在任何 Dictionary(例如 Hashtable)中使用时,元组就像单个键一样。

$List =  ConvertFrom-Csv @'
Id, LastName, FirstName, Country
 1, Aerts,    Ronald,    Belgium
 2, Berg,     Ashly,     Germany
 3, Cook,     James,     England
 4, Duval,    Frank,     France
 5, Lyberg,   Ash,       England
 6, Fischer,  Adam,      Germany
'@

$Index = @{}
$List.ForEach{ $Index[ [Tuple]::Create( $_.LastName, $_.FirstName ) ] = $_ }

$Index

当写入控制台时,拆分键的格式会很好:

Name                           Value
----                           -----
(Berg, Ashly)                  @{Id=2; LastName=Berg; FirstName=Ashly; Country=Germany}
(Lyberg, Ash)                  @{Id=5; LastName=Lyberg; FirstName=Ash; Country=England}
(Duval, Frank)                 @{Id=4; LastName=Duval; FirstName=Frank; Country=France}
(Aerts, Ronald)                @{Id=1; LastName=Aerts; FirstName=Ronald; Country=Belgium}
(Cook, James)                  @{Id=3; LastName=Cook; FirstName=James; Country=England}
(Fischer, Adam)                @{Id=6; LastName=Fischer; FirstName=Adam; Country=Germany}

要查找条目,请创建一个临时元组:

$Index[ [Tuple]::Create('Duval','Frank') ]

Tuple 类的一个优点是您可以轻松获取组成拆分键的各个键,而无需拆分字符串:

# Using member access enumeration
$Index.Keys.Item1  # Prints all last names
$Index.Keys.Item2  # Prints all first names

# Using the enumerator to loop over the index
$Index.GetEnumerator().ForEach{ $_.Key.Item1 }

.NET Framework 4.7 添加了ValueTuple结构(what's the difference?)。可能值得测试它是否提供更好的表现对于这个用例。此外,用通用的 Dictionary 替换 Hashtable 也可以提高性能:

$Index = [Collections.Generic.Dictionary[ ValueTuple[String,String], object]]::new()

除了构建字典,ValueTuple 可以像Tuple 一样使用。只需将前面代码示例中的Tuple 替换为ValueTuple

【讨论】:

    【解决方案2】:

    多组件哈希表(字典)键的内置分隔符.

    至于一个风俗分隔器:对于组件本身不太可能出现的角色,您最好的选择是NUL(字符与代码点0x0),您可以将其表示为"`0"在 PowerShell 中。然而,表演基于约定的每次查找的字符串操作都很尴尬(例如$Index."James`0Cook"),一般来说仅在以下情况下有效串化关键部件是可行的- 或者如果它们都是以字符串开头,如您的示例所示。

    使用数组for multi-component keys 在语法上更可取,但通常使用集合不是按原样工作,因为 .NET引用类型通常,即使它们恰好代表相同的数据,也不会有意义地比较不同的实例 - 请参阅this answer

    • 注意:以下假设元素作为键的集合有意义地比较(他们自己字符串或 .NET值类型或具有自定义相等逻辑的 .NET 引用类型)。如果该假设不成立,则没有可靠的通用解决方案,但是您自己提出的基于链接答案中显示的 CLIXML 序列化的尽力而为的方法可能会起作用。

    zett42's helpful answer 使用元组, 哪个进行有意义的比较不同的实例,其会员包含相等的数据。 然而,需要构造一个元组实例对于每个添加/修改/查找语法上很尴尬(例如。,
    $Index.([Tuple]::Create('James', 'Cook'))

    那里一种制作常规 PowerShell 的方法数组作为匆忙的钥匙, 以某种方式只会增加复杂性创造哈希表(调用构造函数),同时允许常规数组语法添加/更新查找(例如,$Index.('James', 'Cook'))。

    • 注意:以下[ordered] 哈希表同样有效,但是,必须通过它们的真实类型名称来引用它们,以便能够调用构造,即[System.Collections.Specialized.OrderedDictionary]
      然而,它确实不是一起工作通用的字典 ([System.Collections.Generic.Dictionary[TKey, TValue]])。
    # Sample objects for the hashtable.
    $list =  ConvertFrom-Csv @'
    Id, LastName, FirstName, Country
     1, Aerts,    Ronald,    Belgium
     2, Berg,     Ashly,     Germany
     3, Cook,     James,     England
     4, Duval,    Frank,     France
     5, Lyberg,   Ash,       England
     6, Fischer,  Adam,      Germany
    '@
    
    # Initialize the hashtable with a structural equality comparer, i.e.
    # a comparer that compares the *elements* of the array and only returns $true
    # if *all* compare equal.
    # This relies on the fact that [System.Array] implements the
    # [System.Collections.IStructuralEquatable] interface.
    $dict = [hashtable]::new([Collections.StructuralComparisons]::StructuralEqualityComparer)
    
    # Add entries that map the combination of first name and last name 
    # to each object in $list.
    # Note the regular array syntax.
    $list.ForEach({ $dict.($_.FirstName, $_.LastName) = $_ })
    
    # Use regular array syntax for lookups too.
    # Note: CASE MATTERS
    $dict.('James', 'Cook')
    

    重要的: 以上执行区分大小写的比较(就像 zett42 的元组解决方案一样),不像常规的 PowerShell 哈希表。

    进行不区分大小写的比较需要更多的工作,因为需要[System.Collections.IEqualityComparer]接口的自定义实现,即case-麻木不仁[System.Collections.StructuralComparisons]::StructuralEqualityComparer 提供的实现:

    # Case-insensitive IEqualityComparer implementation for arrays.
    class CaseInsensitiveArrayEqualityComparer: System.Collections.IEqualityComparer {
      [bool] Equals([object] $o1, [object] $o2) {
        if ($o1 -isnot [array] -or $o2 -isnot [array]) { return $false }
        return ([System.Collections.IStructuralEquatable] $o1).Equals($o2, [System.StringComparer]::InvariantCultureIgnoreCase)
      }
      [int] GetHashCode([object] $o) {
        if ($o -isnot [Array]) { return $o.GetHashCode() }
        return ([System.Collections.IStructuralEquatable] $o).GetHashCode([StringComparer]::InvariantCultureIgnoreCase)
      }
    }
    
    # Pass an instance of the custom equality comparer to the constructor.
    $dict = [hashtable]::new([CaseInsensitiveArrayEqualityComparer]::new())
    

    笔记:

    • Santiago Squarzon 发现 ([System.Collections.IStructuralEquatable] $o).GetHashCode([StringComparer]::InvariantCultureIgnoreCase) 是一种内置方法,可以根据元素的大小写获取数组的哈希码-麻木不仁哈希码。

    • 下面的原始解决方案计算数组的不区分大小写的哈希码逐个元素,这样既麻烦又效率低。也许他们仍然对如何计算哈希码感兴趣。


    可选阅读:逐个元素的哈希码实现:

    # Case-insensitive IEqualityComparer implementation for arrays.
    # See the bottom section of this answer for a better .NET 7+ alternative.
    class CaseInsensitiveArrayEqualityComparer: System.Collections.IEqualityComparer {
      [bool] Equals([object] $o1, [object] $o2) {
        if ($o1 -isnot [array] -or $o2 -isnot [array]) { return $false }
        return ([System.Collections.IStructuralEquatable] $o1).Equals($o2, [System.StringComparer]::InvariantCultureIgnoreCase)
      }
      [int] GetHashCode([object] $o) {
        if ($o -isnot [Array]) { return $o.GetHashCode() }
        [int] $hashCode = 0
        foreach ($el in $o) {
          if ($null -eq $el) { 
            continue
          } elseif ($el -is [string]) {
            $hashCode = $hashCode -bxor $el.ToLowerInvariant().GetHashCode()
          } else {
            $hashCode = $hashCode -bxor $el.GetHashCode()
          }
        }
        return $hashCode
      }
    }
    
    $list =  ConvertFrom-Csv @'
    Id, LastName, FirstName, Country
     1, Aerts,    Ronald,    Belgium
     2, Berg,     Ashly,     Germany
     3, Cook,     James,     England
     4, Duval,    Frank,     France
     5, Lyberg,   Ash,       England
     6, Fischer,  Adam,      Germany
    '@
    
    # Pass an instance of the custom equality comparer to the constructor.
    $dict = [hashtable]::new([CaseInsensitiveArrayEqualityComparer]::new())
    
    $list.ForEach({ $dict.($_.FirstName, $_.LastName) = $_ })
    
    # Now, case does NOT matter.
    $dict.('james', 'cook')
    

    关于的注释.GetHashCode() 执行在上面的自定义比较器类中:

    • 需要自定义 .GetHashCode() 实现来返回相同的所有比较相等的对象的哈希码([int] 值)(也就是说,如果 $o1 -eq $o2$true$o1.GetHashCode()$o2.GetHashCode() 必须返回相同的值)。

    • 虽然不需要哈希码独特的(并且不可能在所有情况下),理想情况下,尽可能少的对象共享相同的哈希码,因为这样可以减少所谓的冲突的数量,这会降低哈希表的查找效率 - 有关背景信息,请参阅相关的 Wikipedia article .

    • 上面的实现使用了一个相当简单的基于-bxor(按位异或)的算法,它为两个具有相同元素的数组生成相同的哈希码,但在不同的顺序.

      • .GetHashCode() 帮助主题显示了更复杂的方法,包括使用辅助的元组实例,如它的哈希码算法是顺序感知的——虽然简单,但这种方法的计算成本很高,并且需要更多的工作才能获得更好的性能。有关 .NET 7+ 选项,请参阅底部部分。

    zett42碰撞测试代码(已改编),它确定在 1000 个数组中,有多少具有给定数量的元素的随机字符串值导致相同的哈希码,即产生冲突,并从中计算冲突百分比。如果您需要提高上述实现的效率,您可以使用此代码对其进行测试(可能也用于测量测试)运行看看不同的实现如何比较)。

    # Create an instance of the custom comparer defined above.
    $cmp = [CaseInsensitiveArrayEqualityComparer]::new()
    
    $numArrays = 1000
    foreach ($elementCount in 2..5 + 10) {
    
      $numUniqueHashes = (
          1..$numArrays | 
          ForEach-Object { 
            $cmp.GetHashCode(@(1..$elementCount | ForEach-Object { "$(New-Guid)" })) 
          } |
          Sort-Object -Unique
        ).Count
    
      [pscustomobject] @{
        ElementCount = $elementCount
        CollisionPercentage = '{0:P2}' -f (($numArrays - $numUniqueHashes) / $numArrays)
      }
    
    }
    

    所有测试的 about 输出为 0%,因此 -bxor 方法似乎足以防止冲突,至少对于随机字符串并且不包括元素不同的数组变体命令只要。 继续阅读以获得卓越的 .NET 7+ 解决方案。


    .NET 7+ 中的高级自定义相等比较器实现(至少需要 PowerShell 7.3 的预览版):

    zett42 指出,[HashCode]::Combine(),在 .NET 7+ 中可用,允许更有效的实现,因为它:

    • 订单感知
    • 允许确定哈希码a中的值单次操作.

    笔记:

    • 该方法仅限于最多8数组元素- 但对于多组件应该就足够了。

    • 要组合的值 - 在这种情况下是数组元素 - 必须作为个别论点到方法 - 传递数组作为一个整体没有按预期工作。这使得实现有些麻烦。

    # .NET 7+ / PowerShell 7.3+
    # Case-insensitive IEqualityComparer implementation for arrays
    # using [HashCode]::Combine() - limited to 8 elements.
    class CaseInsensitiveArrayEqualityComparer: System.Collections.IEqualityComparer {
      [bool] Equals([object] $o1, [object] $o2) {
        if ($o1 -isnot [array] -or $o2 -isnot [array]) { return $false }
        return ([System.Collections.IStructuralEquatable] $o1).Equals($o2, [System.StringComparer]::InvariantCultureIgnoreCase)
      }
      [int] GetHashCode([object] $o) {
        if ($o -isnot [Array] -or 0 -eq $o.Count) { return $o.GetHashCode() }
        $o = $o.ForEach({ $_ -is [string] ? $_.ToLowerInvariant() : $_ })
        $hashCode = switch ($o.Count) {
          1 { [HashCode]::Combine($o[0]) }
          2 { [HashCode]::Combine($o[0], $o[1]) }
          3 { [HashCode]::Combine($o[0], $o[1], $o[2]) }
          4 { [HashCode]::Combine($o[0], $o[1], $o[2], $o[3]) }
          5 { [HashCode]::Combine($o[0], $o[1], $o[2], $o[3], $o[4]) }
          6 { [HashCode]::Combine($o[0], $o[1], $o[2], $o[3], $o[4], $o[5]) }
          7 { [HashCode]::Combine($o[0], $o[1], $o[2], $o[3], $o[4], $o[5], $o[6]) }
          8 { [HashCode]::Combine($o[0], $o[1], $o[2], $o[3], $o[4], $o[5], $o[6], $o[7]) }
          default { throw 'Not implemented for more than 8 array elements.' }
        }
        return $hashCode
      }
    }
    

    然而,正如 zett42 指出的那样,您可以通过调用[HashCode]::Combine() 来克服值计数限制迭代地, 在一个循环中.

    在案件的情况下——麻木不仁执行,这不是太多的开销,因为无论如何你都需要一个循环,即为了在 [string] 类型的值上调用 .ToLowerInvariant()(这是上面的 .ForEach() 调用隐式执行的操作)。

    这是他的实现:

    # .NET 7+ / PowerShell 7.3+
    # Case-insensitive IEqualityComparer implementation for arrays
    # using [HashCode]::Combine() *iteratively*, with *no* element-count limit.
    class CaseInsensitiveArrayEqualityComparer: System.Collections.IEqualityComparer {
      [bool] Equals([object] $o1, [object] $o2) {
        if ($o1 -isnot [array] -or $o2 -isnot [array]) { return $false }
        return ([System.Collections.IStructuralEquatable] $o1).Equals($o2, [System.StringComparer]::InvariantCultureIgnoreCase)
      }
      [int] GetHashCode([object] $o) {
        if ($o -isnot [Array] -or 0 -eq $o.Count) { return $o.GetHashCode() }
        
        $hashCode = 0
        $o.ForEach({ 
            $value = $_ -is [string] ? $_.ToLowerInvariant() : $_
            $hashCode = [HashCode]::Combine( $hashCode, $value )
        })
    
        return $hashCode
      }
    }
    

    【讨论】:

    • 谢谢,@ zett42 - 请查看我的更新。最大的收获单身的[HashCode]::Combine() 如果您出于其他原因(例如此处不区分大小写)也不必遍历数组,则会调用。
    • C# 版本 (CaseInsensitiveArrayEqualityComparer3) 的性能提升令人印象深刻。在我的机器上,它的运行速度比基于 PowerShell 开关的版本快大约 10 倍。让我知道您对代码的看法。我试图通过测试实现的接口而不是显式的Array 类型检查来使其更通用。
    • @zett42, github.com/PowerShell/PowerShell/issues/17614 可能会感兴趣:当我查看您的 C# 代码时,我注意到 new to ,并试图避免使用明确的接口实现 - 只是发现 PowerShell 然后找不到 .Equals() 方法,除非使用接口转换。
    • 喜欢这个答案,已经得到了支持,但经过一些研究,我想知道你为什么不使用 ([IStructuralEquatable] $o).GetHashCode([StringComparer]::InvariantCultureIgnoreCase)
    • 谢谢,@圣地亚哥。答案很简单:因为我不知道 :) 干得好。请看我的更新。
    猜你喜欢
    • 2020-01-13
    • 2017-07-02
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-09-17
    • 1970-01-01
    相关资源
    最近更新 更多