From ee8153c1a4d4bd913e03fc7855995160a78a6766 Mon Sep 17 00:00:00 2001 From: forgnar Date: Thu, 12 Nov 2026 22:22:34 +0100 Subject: [PATCH] refactor: simplify generic reads and tidy up after .NET 11 GA --- CONTRIBUTING.md | 7 ++-- src/DotMaybe/MaybeNullableExtensions.cs | 2 +- src/DotMaybe/MaybeTaskQueryExtensions.cs | 37 +++++++++++++++---- .../DotMaybe.CompilerContractTests/Snippet.cs | 7 +--- .../AllocationProperties.cs | 19 ++++++++++ .../MaybeArbitraries.cs | 2 +- tests/DotMaybe.PropertyTests/MaybeAssert.cs | 2 +- tests/DotMaybe.PropertyTests/Maybes.cs | 15 ++------ 8 files changed, 59 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af34433..bb50c45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,12 +92,13 @@ on every call); read the fields or use `TryGetSome` instead. Benchmarks are for measuring by hand, not part of CI (CI only builds them): ```bash -dotnet run -c Release --project benchmarks/DotMaybe.Benchmarks -- --filter '*' +dotnet run -c Release --project benchmarks/DotMaybe.Benchmarks -- --filter '*' --inProcess dotnet run -c Release --project benchmarks/DotMaybe.Benchmarks -- --filter '*Chain*' --inProcess ``` -`--inProcess` runs without generating a separate project, which helps while BenchmarkDotNet does not know a new -.NET version yet. +`--inProcess` runs without generating a separate project. It is required while BenchmarkDotNet does not know the +.NET version: BenchmarkDotNet 0.15.8 fails on .NET 11 without it (`GetRuntimeVersion not implemented for +NotRecognized`). Drop it once a BenchmarkDotNet release supports .NET 11. ## Quality checks diff --git a/src/DotMaybe/MaybeNullableExtensions.cs b/src/DotMaybe/MaybeNullableExtensions.cs index f84e2d5..88f5a5a 100644 --- a/src/DotMaybe/MaybeNullableExtensions.cs +++ b/src/DotMaybe/MaybeNullableExtensions.cs @@ -31,5 +31,5 @@ public static class MaybeReferenceTypeExtensions /// The maybe. /// The value, or . public static T? ToNullable(this Maybe maybe) - where T : class => maybe.Match(v => v, () => null); + where T : class => maybe.TryGetSome(out var value) ? value : null; } diff --git a/src/DotMaybe/MaybeTaskQueryExtensions.cs b/src/DotMaybe/MaybeTaskQueryExtensions.cs index 0dba2ba..a096720 100644 --- a/src/DotMaybe/MaybeTaskQueryExtensions.cs +++ b/src/DotMaybe/MaybeTaskQueryExtensions.cs @@ -1,3 +1,5 @@ +using static DotMaybe.Prelude; + namespace DotMaybe; /// @@ -98,8 +100,17 @@ public static ValueTask> SelectMany( { ArgumentNullException.ThrowIfNull(selector); ArgumentNullException.ThrowIfNull(resultSelector); - return source.BindAsync(async v1 => - (await selector(v1).ConfigureAwait(false)).Map(v2 => resultSelector(v1, v2))); + return source.TryGetSome(out var value) + ? Project(value, selector, resultSelector) + : ValueTask.FromResult>(none); + + static async ValueTask> Project( + T value, + Func>> selector, + Func resultSelector) => + (await selector(value).ConfigureAwait(false)).TryGetSome(out var selected) + ? resultSelector(value, selected) + : none; } /// @@ -124,7 +135,7 @@ public static ValueTask> SelectMany( ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(selector); ArgumentNullException.ThrowIfNull(resultSelector); - return source.BindAsync(v1 => selector(v1).Map(v2 => resultSelector(v1, v2))); + return new ValueTask>(source).SelectMany(selector, resultSelector); } /// @@ -149,8 +160,7 @@ public static ValueTask> SelectMany( ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(selector); ArgumentNullException.ThrowIfNull(resultSelector); - return source.BindAsync(async v1 => - (await selector(v1).ConfigureAwait(false)).Map(v2 => resultSelector(v1, v2))); + return new ValueTask>(source).SelectMany(selector, resultSelector); } /// @@ -164,7 +174,13 @@ public static ValueTask> SelectMany( { ArgumentNullException.ThrowIfNull(selector); ArgumentNullException.ThrowIfNull(resultSelector); - return source.BindAsync(v1 => selector(v1).Map(v2 => resultSelector(v1, v2))); + return SelectManyAfter(source, selector, resultSelector); + + static async ValueTask> SelectManyAfter( + ValueTask> source, + Func> selector, + Func resultSelector) => + (await source.ConfigureAwait(false)).SelectMany(selector, resultSelector); } /// @@ -178,7 +194,12 @@ public static ValueTask> SelectMany( { ArgumentNullException.ThrowIfNull(selector); ArgumentNullException.ThrowIfNull(resultSelector); - return source.BindAsync(async v1 => - (await selector(v1).ConfigureAwait(false)).Map(v2 => resultSelector(v1, v2))); + return SelectManyAfter(source, selector, resultSelector); + + static async ValueTask> SelectManyAfter( + ValueTask> source, + Func>> selector, + Func resultSelector) => + await (await source.ConfigureAwait(false)).SelectMany(selector, resultSelector).ConfigureAwait(false); } } diff --git a/tests/DotMaybe.CompilerContractTests/Snippet.cs b/tests/DotMaybe.CompilerContractTests/Snippet.cs index 47ec3b8..27a319c 100644 --- a/tests/DotMaybe.CompilerContractTests/Snippet.cs +++ b/tests/DotMaybe.CompilerContractTests/Snippet.cs @@ -12,7 +12,7 @@ internal static class Snippet { private static readonly Lazy> References = new(LoadReferences); - private static readonly CSharpParseOptions ParseOptions = new(CSharp15()); + private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp15); private static readonly CSharpCompilationOptions CompilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) @@ -43,11 +43,6 @@ public static CompilationResult Compile(string source) return new CompilationResult(source, diagnostics); } - private static LanguageVersion CSharp15() => - LanguageVersionFacts.TryParse("15", out var version) - ? version - : throw new InvalidOperationException("The SDK's compiler does not know C# 15. Check global.json."); - // The running test host already loads the .NET 11 framework and DotMaybe, so its trusted platform // assemblies are exactly what a consumer compiles against. private static ImmutableArray LoadReferences() diff --git a/tests/DotMaybe.PropertyTests/AllocationProperties.cs b/tests/DotMaybe.PropertyTests/AllocationProperties.cs index 99cf7b9..e3eb400 100644 --- a/tests/DotMaybe.PropertyTests/AllocationProperties.cs +++ b/tests/DotMaybe.PropertyTests/AllocationProperties.cs @@ -25,6 +25,8 @@ public void Reading_allocates_nothing(Maybe maybe) Allocations.AssertNone("Tap", () => _ = maybe.Tap(static _ => { })); Allocations.AssertNone("ToNullable", () => _ = maybe.ToNullable()); Allocations.AssertNone("switch", () => _ = maybe switch { int value => value, None => 0 }); + Allocations.AssertNone("is T (generic)", () => _ = IsSome(maybe)); + Allocations.AssertNone("switch (generic)", () => _ = ValueOr(maybe, 0)); } [Property(Skip = Allocations.SkipInDebug)] @@ -124,7 +126,24 @@ public void Async_steps_on_completed_sources_with_synchronous_steps_allocate_not Allocations.AssertNone( "async query", () => _ = (from value in fromValueTask where value > 0 select value + 1).IsCompleted); + Allocations.AssertNone( + "async query with two from (ValueTask)", + () => _ = (from value in fromValueTask from half in Half(value) select value + half).IsCompleted); + Allocations.AssertNone( + "async query with two from (Task)", + () => _ = (from value in fromTask from half in Half(value) select value + half).IsCompleted); } private static Maybe Half(int value) => value % 2 == 0 ? value / 2 : none; + + // Pattern matching through a type parameter, as consumers write it in generic code since the .NET 11 GA compiler. + private static bool IsSome(Maybe maybe) + where T : notnull => maybe is T; + + private static T ValueOr(Maybe maybe, T fallback) + where T : notnull => maybe switch + { + T value => value, + None => fallback, + }; } diff --git a/tests/DotMaybe.PropertyTests/MaybeArbitraries.cs b/tests/DotMaybe.PropertyTests/MaybeArbitraries.cs index 05e1446..86c2cfd 100644 --- a/tests/DotMaybe.PropertyTests/MaybeArbitraries.cs +++ b/tests/DotMaybe.PropertyTests/MaybeArbitraries.cs @@ -27,7 +27,7 @@ public static Arbitrary> MaybeOf(Arbitrary values) private static IEnumerable> Shrink(Maybe maybe, Arbitrary values) where T : notnull => - Maybes.TryGetValue(maybe, out var value) + maybe is T value ? values.Shrinker(value) .Select(smaller => Maybes.Some(smaller)) .Prepend(Maybes.Empty()) diff --git a/tests/DotMaybe.PropertyTests/MaybeAssert.cs b/tests/DotMaybe.PropertyTests/MaybeAssert.cs index 347e904..a58f9ec 100644 --- a/tests/DotMaybe.PropertyTests/MaybeAssert.cs +++ b/tests/DotMaybe.PropertyTests/MaybeAssert.cs @@ -8,7 +8,7 @@ internal static class MaybeAssert public static void HoldsValue(T expected, Maybe actual) where T : notnull { - if (Maybes.TryGetValue(actual, out var value)) + if (actual is T value) { Assert.Equal(expected, value); return; diff --git a/tests/DotMaybe.PropertyTests/Maybes.cs b/tests/DotMaybe.PropertyTests/Maybes.cs index ab69682..d2ecc05 100644 --- a/tests/DotMaybe.PropertyTests/Maybes.cs +++ b/tests/DotMaybe.PropertyTests/Maybes.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using static DotMaybe.Prelude; namespace DotMaybe.PropertyTests; @@ -8,9 +7,8 @@ namespace DotMaybe.PropertyTests; /// /// /// Building goes through the union conversions, which are never ambiguous in generic code -/// (e.g. for Maybe<Maybe<int>>). Reading goes through the union members. It predates the .NET 11 -/// GA compiler, which accepts maybe is T value for a type parameter (see the compiler contracts); the RC1 -/// compiler rejected it with CS8780. Tests on concrete types read values with pattern matching. +/// (e.g. for Maybe<Maybe<int>>). Reading uses pattern matching, maybe is T value, which works +/// for a type parameter too (see the compiler contracts). /// internal static class Maybes { @@ -20,17 +18,10 @@ public static Maybe Some(T value) public static Maybe Empty() where T : notnull => none; - public static bool TryGetValue(Maybe maybe, [MaybeNullWhen(false)] out T value) - where T : notnull - { - Maybe.IUnionMembers members = maybe; - return members.TryGetValue(out value); - } - /// /// Describes a maybe independently of ToString, for assertion messages. /// public static string Describe(Maybe maybe) where T : notnull => - TryGetValue(maybe, out var value) ? $"a value ({value})" : "None"; + maybe is T value ? $"a value ({value})" : "None"; }