Skip to content

Commit eb0ebb4

Browse files
authored
Merge pull request #97 from GerardSmit/feature/alternate-equality-comparer
Reduce allocations in .NET 9
2 parents ba7197e + acd0691 commit eb0ebb4

12 files changed

+277
-31
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ jobs:
2424
submodules: true
2525
fetch-depth: 0
2626

27-
- name: Install .NET 8.0
27+
- name: Install .NET 9.0
2828
uses: actions/setup-dotnet@v3
2929
with:
30-
dotnet-version: '8.0.x'
30+
dotnet-version: '9.0.x'
3131

3232
- name: Build, Test, Pack, Publish
3333
if: matrix.os == 'windows-latest'

src/Zio.Tests/TestSearchPattern.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public void TestExpectedExceptions()
9191
{
9292
var searchPattern = "*";
9393
var search = SearchPattern.Parse(ref path, ref searchPattern);
94-
Assert.Throws<ArgumentNullException>(() => search.Match(null));
94+
Assert.Throws<ArgumentNullException>(() => search.Match(((string)null)!));
9595
}
9696
}
9797

src/Zio.Tests/TestUPath.cs

+44
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,22 @@ public void TestGetDirectory(string path1, string expectedDir)
267267
Assert.Equal(expectedDir, result);
268268
}
269269

270+
[Theory]
271+
[InlineData("", "")]
272+
[InlineData("/", "")]
273+
[InlineData("/a", "/")]
274+
[InlineData("/a/b", "/a")]
275+
[InlineData("/a/b/c.txt", "/a/b")]
276+
[InlineData("a", "")]
277+
[InlineData("../a", "..")]
278+
[InlineData("../../a/b", "../../a")]
279+
public void TestGetDirectoryAsSpan(string path1, string expectedDir)
280+
{
281+
var path = (UPath)path1;
282+
var result = path.GetDirectoryAsSpan().ToString();
283+
Assert.Equal(expectedDir, result);
284+
}
285+
270286
[Theory]
271287
[InlineData("", ".txt", "")]
272288
[InlineData("/", ".txt", "/.txt")]
@@ -324,6 +340,34 @@ public void TestSplit()
324340
Assert.Equal(new List<string>() { "a", "b", "c" }, ((UPath)"a/b/c").Split());
325341
}
326342

343+
[Fact]
344+
public void TestSplitSpan()
345+
{
346+
Assert.Equal(new List<string>(), ToList((UPath)""));
347+
Assert.Equal(new List<string>(), ToList((UPath)"/"));
348+
Assert.Equal(new List<string>() { "a" }, ToList((UPath)"/a"));
349+
Assert.Equal(new List<string>() {"a", "b", "c"}, ToList((UPath) "/a/b/c"));
350+
Assert.Equal(new List<string>() { "a" }, ToList((UPath)"a"));
351+
Assert.Equal(new List<string>() { "a", "b" }, ToList((UPath)"a/b"));
352+
Assert.Equal(new List<string>() { "a", "b", "c" }, ToList((UPath)"a/b/c"));
353+
return;
354+
355+
List<string> ToList(UPath path)
356+
{
357+
var enumerator = path.SpanSplit();
358+
var list = new List<string>(enumerator.Count);
359+
360+
foreach (var span in enumerator)
361+
{
362+
list.Add(span.ToString());
363+
}
364+
365+
Assert.Equal(enumerator.Count, list.Count);
366+
367+
return list;
368+
}
369+
}
370+
327371

328372
[Fact]
329373
public void TestExpectedException()

src/Zio.Tests/Zio.Tests.csproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>net8.0;net472</TargetFrameworks>
4+
<TargetFrameworks>net8.0;net9.0;net472</TargetFrameworks>
55
<IsPackable>false</IsPackable>
66
<LangVersion>10</LangVersion>
77
</PropertyGroup>

src/Zio/FileSystems/MemoryFileSystem.cs

+10-6
Original file line numberDiff line numberDiff line change
@@ -1304,7 +1304,7 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha
13041304
var isRequiringExclusiveLockForParent = (flags & (FindNodeFlags.CreatePathIfNotExist | FindNodeFlags.KeepParentNodeExclusive)) != 0;
13051305

13061306
var parentNode = _rootDirectory;
1307-
var names = path.Split();
1307+
var names = path.SpanSplit();
13081308

13091309
// Walking down the nodes in locking order:
13101310
// /a/b/c.txt
@@ -1324,21 +1324,25 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha
13241324
isParentLockTaken = true;
13251325
}
13261326

1327-
for (var i = 0; i < names.Count && parentNode != null; i++)
1327+
for (var i = 0; names.MoveNext() && parentNode != null; i++)
13281328
{
1329-
var name = names[i];
1329+
ReadOnlySpan<char> name = names.Current;
13301330
bool isLast = i + 1 == names.Count;
13311331

13321332
DirectoryNode? nextParent = null;
13331333
bool isNextParentLockTaken = false;
13341334
try
13351335
{
13361336
FileSystemNode? subNode;
1337-
if (!parentNode.Children.TryGetValue(name, out subNode))
1337+
#if HAS_ALTERNATEEQUALITYCOMPARER
1338+
if (!parentNode.Children.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(name, out subNode))
1339+
#else
1340+
if (!parentNode.Children.TryGetValue(name.ToString(), out subNode))
1341+
#endif
13381342
{
13391343
if ((flags & FindNodeFlags.CreatePathIfNotExist) != 0)
13401344
{
1341-
subNode = new DirectoryNode(this, parentNode, name);
1345+
subNode = new DirectoryNode(this, parentNode, name.ToString());
13421346
}
13431347
}
13441348
else
@@ -1361,7 +1365,7 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha
13611365
flags &= ~(FindNodeFlags.KeepParentNodeExclusive | FindNodeFlags.KeepParentNodeShared);
13621366
}
13631367

1364-
result = new NodeResult(parentNode, subNode, name, flags);
1368+
result = new NodeResult(parentNode, subNode, name.ToString(), flags);
13651369

13661370
// The last subnode may be null but we still want to return a valid parent
13671371
// otherwise, lock the final node if necessary

src/Zio/FileSystems/ZipArchiveFileSystem.cs

+23-14
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,18 @@ protected override void CopyFileImpl(UPath srcPath, UPath destPath, bool overwri
196196

197197
if (srcEntry == null)
198198
{
199-
if (!DirectoryExistsImpl(srcPath.GetDirectory()))
199+
if (!DirectoryExistsImpl(srcPath.GetDirectoryAsSpan()))
200200
{
201201
throw new DirectoryNotFoundException(srcPath.GetDirectory().FullName);
202202
}
203203

204204
throw FileSystemExceptionHelper.NewFileNotFoundException(srcPath);
205205
}
206206

207-
var parentDirectory = destPath.GetDirectory();
207+
var parentDirectory = destPath.GetDirectoryAsSpan();
208208
if (!DirectoryExistsImpl(parentDirectory))
209209
{
210-
throw FileSystemExceptionHelper.NewDirectoryNotFoundException(parentDirectory);
210+
throw FileSystemExceptionHelper.NewDirectoryNotFoundException(parentDirectory.ToString());
211211
}
212212

213213
if (DirectoryExistsImpl(destPath))
@@ -261,12 +261,12 @@ protected override void CreateDirectoryImpl(UPath path)
261261
throw FileSystemExceptionHelper.NewDestinationDirectoryExistException(path);
262262
}
263263

264-
var parentPath = new UPath(GetParent(path.FullName));
265-
if (parentPath != "")
264+
var parentPath = GetParent(path.AsSpan());
265+
if (!parentPath.IsEmpty)
266266
{
267267
if (!DirectoryExistsImpl(parentPath))
268268
{
269-
CreateDirectoryImpl(parentPath);
269+
CreateDirectoryImpl(parentPath.ToString());
270270
}
271271
}
272272

@@ -405,7 +405,12 @@ protected override void DeleteFileImpl(UPath path)
405405
/// <inheritdoc />
406406
protected override bool DirectoryExistsImpl(UPath path)
407407
{
408-
if (path.FullName is "/" or "\\" or "")
408+
return DirectoryExistsImpl(path.FullName.AsSpan());
409+
}
410+
411+
private bool DirectoryExistsImpl(ReadOnlySpan<char> path)
412+
{
413+
if (path is "/" or "\\" or "")
409414
{
410415
return true;
411416
}
@@ -414,7 +419,11 @@ protected override bool DirectoryExistsImpl(UPath path)
414419

415420
try
416421
{
417-
return _entries.TryGetValue(path, out var entry) && entry.IsDirectory;
422+
#if HAS_ALTERNATEEQUALITYCOMPARER
423+
return _entries.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(path, out var entry) && entry.IsDirectory;
424+
#else
425+
return _entries.TryGetValue(path.ToString(), out var entry) && entry.IsDirectory;
426+
#endif
418427
}
419428
finally
420429
{
@@ -651,7 +660,7 @@ protected override void MoveFileImpl(UPath srcPath, UPath destPath)
651660
{
652661
var srcEntry = GetEntry(srcPath) ?? throw FileSystemExceptionHelper.NewFileNotFoundException(srcPath);
653662

654-
if (!DirectoryExistsImpl(destPath.GetDirectory()))
663+
if (!DirectoryExistsImpl(destPath.GetDirectoryAsSpan()))
655664
{
656665
throw FileSystemExceptionHelper.NewDirectoryNotFoundException(destPath.GetDirectory());
657666
}
@@ -706,7 +715,7 @@ protected override Stream OpenFileImpl(UPath path, FileMode mode, FileAccess acc
706715
}
707716
else
708717
{
709-
if (!DirectoryExistsImpl(path.GetDirectory()))
718+
if (!DirectoryExistsImpl(path.GetDirectoryAsSpan()))
710719
{
711720
throw FileSystemExceptionHelper.NewDirectoryNotFoundException(path.GetDirectory());
712721
}
@@ -911,18 +920,18 @@ private ZipArchiveEntry CreateEntry(UPath path, bool isDirectory = false)
911920

912921
private static readonly char[] s_slashChars = { '/', '\\' };
913922

914-
private static string GetName(ZipArchiveEntry entry)
923+
private static ReadOnlySpan<char> GetName(ZipArchiveEntry entry)
915924
{
916925
var name = entry.FullName.TrimEnd(s_slashChars);
917926
var index = name.LastIndexOfAny(s_slashChars);
918-
return name.Substring(index + 1);
927+
return index == -1 ? name.AsSpan() : name.AsSpan(index + 1);
919928
}
920929

921-
private static string GetParent(string path)
930+
private static ReadOnlySpan<char> GetParent(ReadOnlySpan<char> path)
922931
{
923932
path = path.TrimEnd(s_slashChars);
924933
var lastIndex = path.LastIndexOfAny(s_slashChars);
925-
return lastIndex == -1 ? "" : path.Substring(0, lastIndex);
934+
return lastIndex == -1 ? ReadOnlySpan<char>.Empty : path.Slice(0, lastIndex);
926935
}
927936

928937
private FileSystemEventDispatcher<FileSystemWatcher>? TryGetDispatcher()

src/Zio/SearchPattern.cs

+17
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// This file is licensed under the BSD-Clause 2 license.
33
// See the license.txt file in the project root for more information.
44

5+
using System.Linq;
56
using System.Text;
67
using System.Text.RegularExpressions;
78

@@ -43,6 +44,22 @@ public bool Match(string name)
4344
return _exactMatch != null ? _exactMatch == name : _regexMatch is null || _regexMatch.IsMatch(name);
4445
}
4546

47+
/// <summary>
48+
/// Tries to match the specified path with this instance.
49+
/// </summary>
50+
/// <param name="name">The path to match.</param>
51+
/// <returns><c>true</c> if the path was matched, <c>false</c> otherwise.</returns>
52+
public bool Match(ReadOnlySpan<char> name)
53+
{
54+
#if NET7_0_OR_GREATER
55+
// if _execMatch is null and _regexMatch is null, we have a * match
56+
return _exactMatch != null ? name.SequenceEqual(_exactMatch) : _regexMatch is null || _regexMatch.IsMatch(name);
57+
#else
58+
// Regex.Match(ReadOnlySpan<char>) is only available starting from .NET
59+
return Match(name.ToString());
60+
#endif
61+
}
62+
4663
/// <summary>
4764
/// Parses and normalize the specified path and <see cref="SearchPattern"/>.
4865
/// </summary>

src/Zio/UPath.cs

+19
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ public static explicit operator string(UPath path)
116116
return path.FullName;
117117
}
118118

119+
/// <summary>
120+
/// Performs an explicit conversion from <see cref="UPath"/> to <see cref="ReadOnlySpan{Char}"/>.
121+
/// </summary>
122+
/// <param name="path">The path.</param>
123+
/// <returns>The result as a span of the conversion.</returns>
124+
public static explicit operator ReadOnlySpan<char>(UPath path)
125+
{
126+
return path.FullName.AsSpan();
127+
}
128+
119129
/// <summary>
120130
/// Combines two paths into a new path.
121131
/// </summary>
@@ -322,6 +332,15 @@ public override string ToString()
322332
return FullName;
323333
}
324334

335+
/// <summary>
336+
/// Creates a new readonly span from this path.
337+
/// </summary>
338+
/// <returns>A new readonly span from this path.</returns>
339+
public ReadOnlySpan<char> AsSpan()
340+
{
341+
return FullName.AsSpan();
342+
}
343+
325344
/// <summary>
326345
/// Tries to parse the specified string into a <see cref="UPath"/>
327346
/// </summary>

src/Zio/UPathComparer.cs

+41
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
// This file is licensed under the BSD-Clause 2 license.
33
// See the license.txt file in the project root for more information.
44

5+
using System.Diagnostics;
6+
57
namespace Zio;
68

79
public class UPathComparer : IComparer<UPath>, IEqualityComparer<UPath>
10+
#if HAS_ALTERNATEEQUALITYCOMPARER
11+
, IAlternateEqualityComparer<ReadOnlySpan<char>, UPath>, IAlternateEqualityComparer<string, UPath>
12+
#endif
813
{
914
public static readonly UPathComparer Ordinal = new(StringComparer.Ordinal);
1015
public static readonly UPathComparer OrdinalIgnoreCase = new(StringComparer.OrdinalIgnoreCase);
@@ -16,6 +21,10 @@ public class UPathComparer : IComparer<UPath>, IEqualityComparer<UPath>
1621
private UPathComparer(StringComparer comparer)
1722
{
1823
_comparer = comparer;
24+
25+
#if HAS_ALTERNATEEQUALITYCOMPARER
26+
Debug.Assert(_comparer is IAlternateEqualityComparer<ReadOnlySpan<char>, string>);
27+
#endif
1928
}
2029

2130
public int Compare(UPath x, UPath y)
@@ -32,4 +41,36 @@ public int GetHashCode(UPath obj)
3241
{
3342
return _comparer.GetHashCode(obj.FullName);
3443
}
44+
45+
#if HAS_ALTERNATEEQUALITYCOMPARER
46+
bool IAlternateEqualityComparer<ReadOnlySpan<char>, UPath>.Equals(ReadOnlySpan<char> alternate, UPath other)
47+
{
48+
return ((IAlternateEqualityComparer<ReadOnlySpan<char>, string>)_comparer).Equals(alternate, other.FullName);
49+
}
50+
51+
int IAlternateEqualityComparer<ReadOnlySpan<char>, UPath>.GetHashCode(ReadOnlySpan<char> alternate)
52+
{
53+
return ((IAlternateEqualityComparer<ReadOnlySpan<char>, string>)_comparer).GetHashCode(alternate);
54+
}
55+
56+
UPath IAlternateEqualityComparer<ReadOnlySpan<char>, UPath>.Create(ReadOnlySpan<char> alternate)
57+
{
58+
return ((IAlternateEqualityComparer<ReadOnlySpan<char>, string>)_comparer).Create(alternate);
59+
}
60+
61+
bool IAlternateEqualityComparer<string, UPath>.Equals(string alternate, UPath other)
62+
{
63+
return _comparer.Equals(alternate, other.FullName);
64+
}
65+
66+
int IAlternateEqualityComparer<string, UPath>.GetHashCode(string alternate)
67+
{
68+
return _comparer.GetHashCode(alternate);
69+
}
70+
71+
UPath IAlternateEqualityComparer<string, UPath>.Create(string alternate)
72+
{
73+
return alternate;
74+
}
75+
#endif
3576
}

0 commit comments

Comments
 (0)