Return 每次都需要重置值时的多重枚举安全 IEnumerable

Return a multiple-enumeration-safe IEnumerable when a value needs resetting each time

以下代码多次枚举失败,因为 existingNames 哈希集仍然包含上次枚举的结果,因此数字后缀比正确的提前了。有什么优雅的方法可以加强此方法,使其在多次枚举时正常工作?

public static IEnumerable<TOutput> UniquifyNames<TSource, TOutput>(
   this IEnumerable<TSource> source,
   Func<TSource, string> nameSelector,
   Func<TSource, string, TOutput> resultProjection
) {
   HashSet<string> existingNames = new HashSet<string>();
   return source
      .Select(item => {
         string name = nameSelector(item);
         return resultProjection(
            item,
            Enumerable.Range(1, int.MaxValue)
               .Select(i => {
                  string suffix = i == 1
                     ? ""
                     : (name.EndsWithDigit() ? "-" : "") + i.ToString();
                  return $@"{name}{suffix}";
               })
               .First(candidateName => existingNames.Add(candidateName))
         );
      });
}

private static bool EndsWithDigit(this string value)
   => !string.IsNullOrEmpty(value) && "0123456789".Contains(value[value.Length - 1]);

我考虑创建一个扩展方法,例如 UponEnumeration 来包装外部可枚举,当枚举再次开始时,它将回调 Action 到 运行(这可能是用于重置 HashSet)。这是个好主意吗?

我刚刚意识到这不是一个好主意,因为相同的结果 IEnumerable 可以同时被不同的 类 枚举(从一个地方开始枚举,而另一个是仍在枚举中途,因此在恢复枚举后事情会中断,因为 HashSet 已被清除)。听起来最好的办法就是 ToList() 但我真的很想尽可能保留惰性求值。

通过使您的代码成为延迟 IEnumerable 本身,当其他人 运行 它多次时,它也会 运行 多次。

public static IEnumerable<TOutput> UniquifyNames<TSource, TOutput>(
   this IEnumerable<TSource> source,
   Func<TSource, string> nameSelector,
   Func<TSource, string, TOutput> resultProjection
) {
   HashSet<string> existingNames = new HashSet<string>();
   var items = source
      .Select(item => {
         string name = nameSelector(item);
         return resultProjection(
            item,
            Enumerable.Range(1, int.MaxValue)
               .Select(i => {
                  string suffix = i == 1
                     ? ""
                     : (name.EndsWithDigit() ? "-" : "") + i.ToString();
                  return $@"{name}{suffix}";
               })
               .First(candidateName => existingNames.Add(candidateName))
         );
      });
    foreach(TOutput item in items)
    {
        yield return item;
    }
}

就我个人而言,如果我真的这样做,我会 "unroll" LINQ 查询并在 foreach 循环中自己执行它们的等效项。这是我第一次快速改变它。

public static IEnumerable<TOutput> UniquifyNames<TSource, TOutput>(
    this IEnumerable<TSource> source,
    Func<TSource, string> nameSelector,
    Func<TSource, string, TOutput> resultProjection
    )
{
    HashSet<string> existingNames = new HashSet<string>();
    foreach (TSource item in source)
    {
        string name = nameSelector(item);
        yield return resultProjection(item, GenerateName(name, existingNames));
    }
}

private static string GenerateName(string name, HashSet<string> existingNames)
{
    return Enumerable.Range(1, int.MaxValue)
        .Select(i =>
        {
            string suffix = i == 1
                ? ""
                : (name.EndsWithDigit() ? "-" : "") + i.ToString();
            return $@"{name}{suffix}";
        }).First(existingNames.Add);
}

请注意 yielding/deferred IEnumerables 的最佳做法是在一个方法中检查空参数,然后 return 实际私有实现的结果。这样一来,错误情况下的 IEnumerable 将立即抛出 invocation/creation,而不是在它被枚举后抛出(可能在远离创建它的代码的代码中)。

public static IEnumerable<TOutput> UniquifyNames<TSource, TOutput>(
   this IEnumerable<TSource> source,
   Func<TSource, string> nameSelector,
   Func<TSource, string, TOutput> resultProjection
) {
   if (source == null) {
      throw new ArgumentNullException(nameof(source));
   }
   if (nameSelector == null) {
      throw new ArgumentNullException(nameof(nameSelector));
   }
   if (resultProjection == null) {
      throw new ArgumentNullException(nameof(resultProjection));
   }
   return UniquifyNamesImpl(source, nameSelector, resultProjection);
}

我确实想出了一个可行的方法,但我不知道它是否好:

public class ResettingEnumerable<T> : IEnumerable<T> {
    private readonly Func<IEnumerable<T>> _enumerableFetcher;

    public ResettingEnumerable(Func<IEnumerable<T>> enumerableFetcher) {
        _enumerableFetcher = enumerableFetcher;
    }

    public IEnumerator<T> GetEnumerator() => _enumerableFetcher().GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

然后UniquifyNames的正文变成这样:

return new ResettingEnumerable<TOutput>(() => {
   /* old body here */
};

虽然看到了,但我认为他的想法可能更好:把它写成一个让IEnumerable重新成为运行的属性每次 GetEnumerator 被调用。这是解决问题的一个很好的通用解决方案,当不能容忍多个枚举时,切换到延迟 IEnumerable.


作为记录,我选择了一个略有不同的最终实现。最初,我想保留 IEnumerable 的惰性求值方面,其中集合可以少于完全枚举并产生有用的结果。但是,我意识到尽可能少地改变任何现有名称的目标导致我选择了一种不同的算法,该算法需要完全枚举列表(在开始任何数字递增之前按原样获取所有可以使用的名称)。这是适合您的解决方案:

private class NamedItem<TSource> {
   public TSource Item { get; set; }
   public string Name { get; set; }
}

private static bool EndsWithADigit(this string value) =>
   !string.IsNullOrEmpty(value) && "0123456789".Contains(value[value.Length - 1]);

private static string GetNumberedName(string name, int index) =>
   name + (index == 1 ? "" : name.EndsWithADigit() ? $"-{index}" : $"{index}");

private static bool ConditionalSetName<T>(
   NamedItem<T> namedItem, string name, HashSet<string> hashset
) {
   bool isNew = hashset.Add(name);
   if (isNew) { namedItem.Name = name; }
   return !isNew;
}

public static IEnumerable<TOutput> UniquifyNames<TSource, TOutput>(
   this IEnumerable<TSource> source,
   Func<TSource, string> nameSelector,
   Func<TSource, string, TOutput> resultProjection
) {
   var seen = new HashSet<string>();
   var result = source.Select((item, seq) => new NamedItem<TSource>{
      Item = item, Name = nameSelector(item)
   }).ToList();
   var remaining = result;
   int i = 1;
   do {
      remaining = remaining.Where(namedItem =>
         ConditionalSetName(namedItem, GetNumberedName(namedItem.Name, i++), seen)
      ).ToList();
   } while (remaining.Any());
   return result.Select(namedItem => resultProjection(namedItem.Item, namedItem.Name));
}

有了这个输入:

"String2", "String", "String", "String3", "String3"

这给出了结果:

"String2", "String", "String4", "String3", "String3-2"

这更好,因为名称 String3 未被修改。

我最初的实现给出了这个结果:

"String2", "String", "String3", "String3-2", "String3-3"

这更糟,因为它不必要地改变了第一个 String3