Skip to content

Commit

Permalink
Enable non-allocating enumeration for IList<T>
Browse files Browse the repository at this point in the history
The interface `IList<T>` is used in many APIs and interfaces. Enumerating `IList<T>` with `foreach` always requires a heap allocation.

Most concrete implementations of `IList<T>` will be `List<T>` which has a non-allocating struct enumerator.

This change adds an `NoAllocEnumerate()` extension method that avoids any enumerator allocation when the concrete `IList<T>` type is determined to be `List<T>` at runtime.
  • Loading branch information
drewnoakes committed Jun 16, 2023
1 parent c7cd11a commit 7f60154
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 5 deletions.
138 changes: 138 additions & 0 deletions build/Shared/EnumerationExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#nullable enable

using System;
using System.Collections.Generic;

namespace NuGet;

internal static class NoAllocEnumerateExtensions
{
/// <summary>
/// Avoids allocating an enumerator when enumerating an <see cref="IList{T}"/> which
/// has concrete type <see cref="List{T}"/>.
/// </summary>
/// <remarks>
/// <para>
/// Like many concrete collection types, <see cref="List{T}"/> provides a struct-based
/// enumerator type (<see cref="List{T}.Enumerator"/>) which the compiler can use in
/// <see langword="foreach" /> statements. When using a struct-based enumerator, no heap allocation occurs during
/// enumeration. This is in contrast to the interface-based enumerator <see cref="IEnumerator{T}"/> which will
/// always be allocated on the heap.
/// </para>
/// <para>
/// This method returns a struct-based enumerator that will avoid any heap allocation if <paramref name="collection"/>
/// (which is declared via interface <see cref="IEnumerable{T}"/>) is actually of concrete type
/// <see cref="List{T}"/> at run-time. If so, it delegates to that type's own struct-based
/// enumerator.
/// </para>
/// <para>
/// If <paramref name="list"/> is not of that concrete type, the returned enumerator falls back to the
/// interface-based enumerator, which will heap allocate. Benchmarking shows the overhead in such cases is low enough
/// to be within the measurement error.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// <![CDATA[IList<string> list = ...;
///
/// foreach (string item in list.NoAllocEnumerate())
/// {
/// // ...
/// }]]>
/// </code>
/// </example>
public static OptimisticallyNonAllocatingListEnumerable<T> NoAllocEnumerate<T>(this IList<T> list)
where T : notnull
{
#pragma warning disable CS0618 // Type or member is obsolete
return new(list);
#pragma warning restore CS0618 // Type or member is obsolete
}

/// <summary>
/// Provides a struct-based enumerator for use with <see cref="IList{T}"/>.
/// Do not use this type directly. Use <see cref="NoAllocEnumerate{T}(IList{T})"/> instead.
/// </summary>
public readonly ref struct OptimisticallyNonAllocatingListEnumerable<T>
where T : notnull
{
private readonly IList<T> _collection;

[Obsolete("Do not construct directly. Use internal static class EnumerableExtensions.NoAllocEnumerate instead.")]
internal OptimisticallyNonAllocatingListEnumerable(IList<T> collection) => _collection = collection;

public Enumerator GetEnumerator() => new(_collection);

/// <summary>
/// A struct-based enumerator for use with <see cref="IEnumerable{T}"/>.
/// Do not use this type directly. Use <see cref="NoAllocEnumerateExtensions.NoAllocEnumerate{T}(IList{T})"/> instead.
/// </summary>
public struct Enumerator : IDisposable
{
private readonly int _enumeratorType;
private readonly IEnumerator<T>? _fallbackEnumerator;
private List<T>.Enumerator _concreteEnumerator;

internal Enumerator(IList<T> list)
{
_concreteEnumerator = default;
_fallbackEnumerator = null;

if (list.Count == 0)
{
// The collection is empty, just return false from MoveNext.
_enumeratorType = 100;
}
else if (list is List<T> concrete)
{
_enumeratorType = 0;
_concreteEnumerator = concrete.GetEnumerator();
}
else
{
_enumeratorType = 99;
_fallbackEnumerator = list.GetEnumerator();
}
}

public T Current
{
get
{
return _enumeratorType switch
{
0 => _concreteEnumerator.Current,
99 => _fallbackEnumerator!.Current,
_ => default!,
};
}
}

public bool MoveNext()
{
return _enumeratorType switch
{
0 => _concreteEnumerator.MoveNext(),
99 => _fallbackEnumerator!.MoveNext(),
_ => false,
};
}

public void Dispose()
{
switch (_enumeratorType)
{
case 0:
_concreteEnumerator.Dispose();
break;
case 99:
_fallbackEnumerator!.Dispose();
break;
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ internal static List<LockFileContentFile> GetContentFileGroup(
{
var codeLanguage = group.Properties[ManagedCodeConventions.PropertyNames.CodeLanguage] as string;

foreach (var item in group.Items)
foreach (var item in group.Items.NoAllocEnumerate())
{
if (!entryMappings.ContainsKey(item.Path))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ private static IEnumerable<LockFileItem> GetLockFileItems(

if (group != null)
{
foreach (var item in group.Items)
foreach (var item in group.Items.NoAllocEnumerate())
{
var newItem = new LockFileItem(item.Path);
object locale;
Expand Down Expand Up @@ -975,7 +975,7 @@ private static List<LockFileRuntimeTarget> GetRuntimeTargetItems(List<ContentIte
var rid = (string)group.Properties[ManagedCodeConventions.PropertyNames.RuntimeIdentifier];

// Create lock file entries for each assembly.
foreach (var item in group.Items)
foreach (var item in group.Items.NoAllocEnumerate())
{
results.Add(new LockFileRuntimeTarget(item.Path)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -838,7 +838,7 @@ private static void ValidateFileFrameworks(IEnumerable<IPackageFile> files)
ContentExtractor.GetContentForPattern(collection, pattern, targetedItemGroups);
foreach (ContentItemGroup group in targetedItemGroups)
{
foreach (ContentItem item in group.Items)
foreach (ContentItem item in group.Items.NoAllocEnumerate())
{
var framework = (NuGetFramework)item.Properties["tfm"];
if (framework == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ internal static IEnumerable<PackagingLogMessage> ValidateFiles(IEnumerable<strin
ContentExtractor.GetContentForPattern(collection, pattern, targetedItemGroups);
foreach (ContentItemGroup group in targetedItemGroups)
{
foreach (ContentItem item in group.Items)
foreach (ContentItem item in group.Items.NoAllocEnumerate())
{
var exists = item.Properties.TryGetValue("tfm_raw", out var frameworkRaw);
string frameworkString = (string)frameworkRaw;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Moq;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Xunit;

namespace NuGet.Common.Test;

public class NoAllocEnumerateExtensionsTests
{
[Fact]
public void GetOptimisticallyNonAllocatingEnumerable_List()
{
List<int> list = new() { 0, 1, 2, 3 };

ValidateEnumeration(list);
}

[Fact]
public void GetOptimisticallyNonAllocatingEnumerable_ImmutableList()
{
ImmutableList<int> list = ImmutableList.Create(0, 1, 2, 3);

ValidateEnumeration(list);
}

[Fact]
public void GetOptimisticallyNonAllocatingEnumerable_ImmutableArray()
{
ImmutableArray<int> list = ImmutableArray.Create(0, 1, 2, 3);

ValidateEnumeration(list);
}

[Fact]
public void GetOptimisticallyNonAllocatingEnumerable_Fallback()
{
int[] array = { 0, 1, 2, 3 };

var mock = new Mock<IList<int>>(MockBehavior.Strict);

mock.SetupGet(o => o.Count)
.Returns(array.Length);
mock.Setup(o => o.GetEnumerator())
.Returns(((IEnumerable<int>)array).GetEnumerator());

ValidateEnumeration(mock.Object);

mock.Verify();
}

[Fact]
public void GetOptimisticallyNonAllocatingEnumerable_OptimizedForEmpty()
{
var mock = new Mock<IList<int>>(MockBehavior.Strict);

mock.SetupGet(o => o.Count).Returns(0);

// NOTE because the source is empty, GetEnumerator should not be called at all.

foreach (int i in mock.Object.NoAllocEnumerate())
{

}

mock.Verify();
}

private static void ValidateEnumeration(IList<int> collection)
{
var actual = new List<int>();

foreach (var item in collection.NoAllocEnumerate())
{
actual.Add(item);
}

Assert.Equal(4, actual.Count);
Assert.Equal(0, actual[0]);
Assert.Equal(1, actual[1]);
Assert.Equal(2, actual[2]);
Assert.Equal(3, actual[3]);
}
}

0 comments on commit 7f60154

Please sign in to comment.