AwadyLab.MoreExtensions
1.5.0
See the version list below for details.
dotnet add package AwadyLab.MoreExtensions --version 1.5.0
NuGet\Install-Package AwadyLab.MoreExtensions -Version 1.5.0
<PackageReference Include="AwadyLab.MoreExtensions" Version="1.5.0" />
<PackageVersion Include="AwadyLab.MoreExtensions" Version="1.5.0" />
<PackageReference Include="AwadyLab.MoreExtensions" />
paket add AwadyLab.MoreExtensions --version 1.5.0
#r "nuget: AwadyLab.MoreExtensions, 1.5.0"
#:package AwadyLab.MoreExtensions@1.5.0
#addin nuget:?package=AwadyLab.MoreExtensions&version=1.5.0
#tool nuget:?package=AwadyLab.MoreExtensions&version=1.5.0
AwadyLab.MoreExtensions
Extension methods for .NET that the BCL doesn't ship: allocation-free span-based string slicing, lazy LINQ
operators, calendar and workday helpers, a first-class DateTimeRange value type, generic-math numerics, fast
type conversions, and utilities for enums, reflection, Guid, tasks and certificates.
Built on C# 14 extension members, so most of these appear as ordinary instance members on the type they extend.
dotnet add package AwadyLab.MoreExtensions
Target framework: net10.0
Contents
| Area | What it covers |
|---|---|
| Strings | Span slicing, glob matching, whitespace, hashing |
| Enumerable | Windowing, lag/lead, padding, sampling, safe materialisation |
| Collections | IsNullOrEmpty, bulk AddRange, non-mutating WithRange |
| Dictionaries | GetOrAdd, AddRange, RemoveWhere |
| Date and time | Boundaries, rounding, weekends and workdays |
| DateTimeRange | An interval type with ordering, formatting, overlap classification and parsing |
| Numerics | IsBetween, PercentOf, float comparison |
| Conversions | Fast, exception-free type conversion and primitive parsing |
| Guid | base64url encoding |
| Enums | Attributes, descriptions, flag decomposition |
| Tasks | Then, FireAndForget |
| Expressions | Predicate composition |
| Reflection | Assembly scanning, nullability |
| Random | Random string generation |
| Security | Certificate lookup, SAN enumeration, secure random strings |
| Benchmarks | BenchmarkDotNet results for the perf-sensitive members |
Strings
The slicing members are declared on ReadOnlySpan<char> and return spans, so none of them allocate. Thanks to
first-class span conversions you can call them directly on a string.
using AwadyLab.MoreExtensions;
const string cs = "Server=db.example.com;Database=Orders;Encrypt=True";
cs.LeftOf(";") // "Server=db.example.com" (ReadOnlySpan<char>, 0 allocations)
cs.RightOfLast("=") // "True"
cs.Between("Database=", ";") // "Orders"
cs.Left(6) // "Server"
cs.Right(4) // "True"
| Member | Returns | Notes |
|---|---|---|
Left(int length) |
ReadOnlySpan<char> |
Whole span when shorter than length |
Right(int length) |
ReadOnlySpan<char> |
Whole span when shorter than length |
LeftOf(token, comparison?) |
ReadOnlySpan<char> |
Everything before the first token; whole span when absent |
RightOf(token, comparison?) |
ReadOnlySpan<char> |
Everything after the first token; empty when absent |
LeftOfLast(token, comparison?) |
ReadOnlySpan<char> |
As LeftOf, from the last occurrence |
RightOfLast(token, comparison?) |
ReadOnlySpan<char> |
As RightOf, from the last occurrence |
Between(start, end, comparison?) |
ReadOnlySpan<char> |
Empty when either delimiter is missing |
TryBetween(start, end, out result, comparison?) |
bool |
Distinguishes "missing" from "empty match" |
TrimPrefix(prefix, comparison?) |
ReadOnlySpan<char> |
Removes every leading repetition |
TrimSuffix(suffix, comparison?) |
ReadOnlySpan<char> |
Removes every trailing repetition |
TrimPrefixOnce(prefix, comparison?) |
ReadOnlySpan<char> |
Removes one leading occurrence |
TrimSuffixOnce(suffix, comparison?) |
ReadOnlySpan<char> |
Removes one trailing occurrence |
NthIndexOf(value, occurrence, comparison?) |
int |
1-based occurrence; counts non-overlapping matches; -1 when fewer |
IsLike(pattern, ignoreCase?) |
bool |
Glob match: * any run, ? exactly one |
IsLike runs in O(n·m) worst case with O(1) memory and no recursion, so patterns like "*a*a*a*b" cannot
cause catastrophic backtracking.
"order-2024-01-02.json".IsLike("order-*.json"); // true
"Report.PDF".IsLike("*.pdf", ignoreCase: true); // true
String members
| Member | Returns | Notes |
|---|---|---|
IsNullOrEmpty() |
bool |
Flows nullability: a false result narrows the reference to non-null |
IsNullOrWhiteSpace() |
bool |
Likewise |
ToIntOrDefault(defaultValue = 0) |
int |
Invariant culture |
ToInt64OrDefault(defaultValue = 0) |
long |
Invariant culture |
CollapseWhitespace() |
string |
Every whitespace run becomes one space, both ends trimmed |
Hash(algorithm, toHexString = true) |
string |
UTF-8 bytes, hex or base64 |
CollapseWhitespace returns the original instance when nothing needs changing, and otherwise builds the result
in a single allocation using a stack or pooled buffer.
string? name = GetName();
if (!name.IsNullOrWhiteSpace())
Console.WriteLine(name.Length); // no null-forgiving operator needed
" a\t\tb \n c ".CollapseWhitespace(); // "a b c"
using var sha = SHA256.Create();
"payload".Hash(sha); // "5B3C..." hex
Enumerable
Every operator validates its arguments eagerly — an ArgumentNullException is thrown at the call site, not
deferred to the first MoveNext() — and then streams lazily.
int[] readings = [3, 5, 4, 8, 6];
readings.Window(3); // [3,5,4], [5,4,8], [4,8,6]
readings.Lag(1, (now, prev) => now - prev); // 3, 2, -1, 4, -2
readings.TakeUntil(x => x > 5); // 3, 5, 4, 8 (inclusive)
readings.IsSorted(); // false
| Member | Description |
|---|---|
EmptyIfNull() |
The sequence, or an empty one when null |
Flatten(children) |
Depth-first pre-order tree walk. Iterative — deep trees cannot overflow the stack |
Cartesian(other) |
Cartesian product; other is buffered once, the source streams |
Without(item, comparer?) |
Filters out every element equal to item |
DistinctAdjacent(comparer?) |
Drops elements equal to their immediate predecessor |
WhereNotNull() |
Filters nulls and narrows the element type (separate overloads for classes and nullable structs) |
TakeUntil(predicate) |
Yields up to and including the first match |
SkipUntil(predicate) |
Skips until the first match, then yields it and the rest |
TakeEvery(step) |
The first element, then every step-th after it |
Window(size) |
Sliding windows of exactly size; shorter sequences yield nothing |
Lag(offset, selector) |
Pairs each element with the one offset before it (default at the start) |
Lead(offset, selector) |
Pairs each element with the one offset after it (default at the end) |
Pad(width, padding?) |
Appends padding until the sequence reaches width |
PadStart(width, padding?) |
Prepends padding; buffers only when the source is shorter than width |
ZipLongest(other, selector) |
Zips to the longer length, filling with default |
MaxByOrDefault(keySelector, defaultValue?, comparer?) |
Like MaxBy but never throws on an empty sequence |
MinByOrDefault(keySelector, defaultValue?, comparer?) |
Likewise |
ToDictionarySafe(keySelector, valueSelector, behavior?, comparer?) |
Never throws on duplicate keys |
RandomSubset(count, random?) |
Reservoir sampling — one pass, O(count) memory |
Shuffle(random?) |
Returns a new array with every element in random order (Random.Shuffle under the hood) |
IsSorted(comparer?, descending?) |
Single-pass check |
ToList(capacity) |
Materialises with a known initial capacity |
ToArray(sizeHint) |
Exactly-sized array via a pooled working buffer |
Duplicate keys are resolved by DuplicateKeyBehavior.KeepFirst (the default) or KeepLast:
var byId = orders.ToDictionarySafe(o => o.Id, o => o, DuplicateKeyBehavior.KeepLast);
Windowallocates one array per window, so each element is copiedsizetimes. That is what makes every yielded window independently safe to keep. For a hot loop over a large sequence, preferLag/Leador index into an array directly.
Collections
| Member | Applies to | Description |
|---|---|---|
IsNullOrEmpty() |
IEnumerable<T>? |
True when null or empty. A false result narrows to non-null |
IsNullOrEmpty() |
ImmutableArray<T> |
True when default (uninitialised) or empty; no boxing |
IsNullOrEmpty() |
ImmutableArray<T>? |
True when null, default or empty |
AddRange(items) |
ICollection<T> |
Uses the receiver's native bulk-add when it has one |
WithRange(items) |
IEnumerable<T> |
Returns a new T[] containing the receiver followed by items without mutating the source |
AddRange dispatches to List<T>.AddRange or ISet<T>.UnionWith where available, pre-sizing when the source
count is known, and falls back to a per-item loop otherwise.
WithRange is a non-mutating companion that allocates an exactly-sized array (using ICollection<T>.CopyTo
when counts are known) and works for arrays or read-only sequences without modifying the receiver.
IEnumerable<int>? maybe = GetItems();
if (!maybe.IsNullOrEmpty())
Process(maybe); // narrowed to non-null
ICollection<string> target = new HashSet<string>();
target.AddRange(["a", "b", "c"]);
int[] baseItems = [1, 2, 3];
int[] combined = baseItems.WithRange([4, 5, 6]); // [1, 2, 3, 4, 5, 6]
IsNullOrEmptyon a sequence whose count isn't known advances the enumerator by one element to answer. Don't call it on a single-use iterator you intend to enumerate afterwards.
Dictionaries
| Member | Description |
|---|---|
GetOrAdd(key, valueFactory) |
Returns the existing value, or creates, stores and returns a new one |
GetOrAdd(key, value) |
Returns the existing value, or stores and returns value |
AddRange(items, overwrite = false) |
Bulk insert; existing keys are skipped unless overwrite is set |
RemoveWhere(predicate) |
Removes matching entries, returns how many |
var cache = new Dictionary<string, Config>();
var config = cache.GetOrAdd("orders", LoadConfig);
var removed = cache.RemoveWhere(e => e.Value.IsExpired);
GetOrAdd's factory must not mutate the dictionary — only the requested key is ever written, and only by
GetOrAdd itself once the factory returns.
These are not thread-safe. For concurrent access use
ConcurrentDictionary<TKey, TValue>, which has its ownGetOrAdd.
Date and time
Every member below exists for DateTime, DateTimeOffset and DateOnly where it makes sense. DateTime
overloads preserve Kind; DateTimeOffset overloads preserve Offset.
| Member | Description |
|---|---|
StartOfDay() / EndOfDay() |
Midnight / the last tick of the day |
StartOfMonth() / EndOfMonth() |
First / last moment of the month |
StartOfYear() / EndOfYear() |
First / last moment of the year |
StartOfWeek(firstDayOfWeek = null) / EndOfWeek(...) |
Week boundaries; defaults to the current culture's FirstDayOfWeek |
AddWeeks(weeks) |
Add or subtract whole weeks |
Floor(interval) / Round(interval) / Ceiling(interval) |
Round to a multiple of interval; halves round up |
IsPast(timeProvider?) / IsFuture(timeProvider?) |
Comparison against a clock; inject a TimeProvider to test |
IsWeekend(culture?) / IsWeekday(culture?) |
Culture-aware |
NextWorkday(culture?) |
The next working day, preserving time of day |
AddWorkdays(workdays, culture?) |
Move by working days, skipping weekends |
var t = new DateTime(2024, 6, 12, 14, 32, 17);
t.StartOfMonth(); // 2024-06-01 00:00:00
t.Floor(TimeSpan.FromMinutes(15)); // 2024-06-12 14:30:00
t.AddWorkdays(10); // skips weekends
AddWorkdays is O(1) for the whole-week portion rather than walking day by day, and handles negative values
(-1 moves to the previous working day).
Weekend configuration
Weekends default to Saturday/Sunday, or Friday/Saturday for Arabic, Hebrew, Persian and Bangladeshi cultures. Override per culture:
CultureInfo.CurrentCulture.SetWeekendDays(DayOfWeek.Friday, DayOfWeek.Saturday);
// or
culture.WeekendDays = new HashSet<DayOfWeek> { DayOfWeek.Friday };
Configuration is stored per culture name and shared process-wide, so it applies to every
CultureInfowith that name — including the fresh instances ASP.NET Core request localization creates per request. Set it once during start-up. An empty set, or one containing all seven days, is rejected.
DateTimeRange
An immutable interval over DateTimeOffset, implementing IEquatable, IComparable, IFormattable,
ISpanFormattable, IParsable and ISpanParsable. Overlaps and Intersect use half-open [Start, End)
semantics, while Contains and IsIn default to the inclusive-end [Start, End] reading unless inclusiveEnd: false
is passed.
var window = new DateTimeRange(start, end);
var hour = DateTimeRange.FromDuration(start, TimeSpan.FromHours(1));
var bounds = DateTimeRange.FromCollection(timestamps);
window.Duration; // TimeSpan
window.IsEmpty; // bool (Start == End)
window.Contains(instant); // end-inclusive by default: [Start, End]
window.Contains(instant, inclusiveEnd: false);// end-exclusive: [Start, End)
window.Contains(otherRange); // range containment (both bounds within window)
window.Overlaps(other); // touching ranges do NOT overlap ([Start, End) semantics)
window.GetOverlapType(other); // RangeOverlapType (Before, After, Overlapping, Contains, ContainedBy, Equal)
window.Intersect(other); // DateTimeRange? — null when they share no instant
window.Union(other); // smallest range covering both
window.Enumerate(TimeSpan.FromDays(1)); // every step while still before End
var (from, to) = window; // deconstruction
window.ToString(); // ISO 8601 interval: "start/end"
DateTimeRange.Parse("2024-01-01T00:00:00Z/2024-02-01T00:00:00Z");
instant.IsIn(window); // extension on DateTimeOffset and DateTime
Overlaps and Intersect agree: ranges that merely touch share no instant under half-open semantics, so
Overlaps is false and Intersect returns null. In contrast, Contains(instant) and instant.IsIn(range)
default to inclusiveEnd: true ([Start, End]). Ranges sort by Start, then End. ToString(format, provider)
formats both bounds with the given format (default "O"); only the default round-trips through Parse.
The delimiter can be configured process-wide via DateTimeRange.Separator (defaults to '/'). It can only be
set once per process — set it during start-up, before any formatting or parsing happens; a second assignment
throws InvalidOperationException.
Numerics
Generic-math based, so these work for every numeric type.
5.IsBetween(1, 10); // true (inclusive by default)
5.IsBetween(5, 10, inclusive: false); // false
25.PercentOf(200); // 12.5
(0.1 + 0.2).ApproximatelyEquals(0.3, 1e-9); // true
| Member | Constraint | Notes |
|---|---|---|
IsBetween(min, max, inclusive = true) |
INumber<T> |
Throws if min > max |
PercentOf(total) |
INumber<T> |
Returns 0 when total is zero |
ApproximatelyEquals(other, tolerance) |
IFloatingPoint<T> |
NaN equals nothing; equal infinities are equal |
Conversions
General extension methods for converting and parsing loosely-typed values or strings to a target type without allocating exceptions on failure.
Target types can be supplied explicitly (e.g. value.ConvertTo<int>()). Target Nullable<T> types are
automatically unwrapped (null maps to null for reference and nullable types, and throws for non-nullable
value types).
Supported conversions include:
- All numeric and Boolean primitives (via fast non-throwing
TryParsepaths that eliminate exception overhead) - Enums by member name (case-insensitive) or underlying integer value
Guid,DateOnly,TimeOnly,DateTimeOffset,TimeSpan,Uri, andVersion- General types via
Convert.ChangeTypewith invariant culture
"123".ConvertTo<int>(); // 123
"true".ConvertTo<bool>(); // true
"Active".ConvertTo<Status>(); // Status.Active
"2024-01-15".ConvertTo<DateOnly>(); // DateOnly(2024, 1, 15)
"invalid".ConvertToOrDefault(fallback: -1); // -1
null.ConvertToOrDefault<int?>(); // null
if (input.TryConvertTo<Guid>(out var id))
{
// Fast path: no FormatException allocated if input is not a Guid
}
| Member | Returns | Description |
|---|---|---|
ConvertTo<TResult>() |
TResult |
Invariant culture conversion; unwraps Nullable<T>; throws on failure |
TryConvertTo<TResult>(out result) |
bool |
Fast non-throwing conversion; leverages primitive TryParse paths to avoid exception overhead |
ConvertToOrDefault<TResult>(fallback?) |
TResult? |
Returns fallback (or default) instead of throwing when conversion fails |
Guid
Encodes the raw 16 bytes as unpadded base64url — 22 characters instead of the 32 of ToString("N"), and safe
in URLs and file names.
var id = Guid.NewGuid();
id.ToBase64UrlString(); // "3Zf0xQ7rTEGm1gWq9kPzXA"
Guid.FromBase64UrlString(text); // throws FormatException when invalid
Guid.TryParseBase64Url(text, out var parsed); // non-throwing
Span<char> buffer = stackalloc char[22];
id.TryFormatBase64Url(buffer, out var written); // no allocation
Enums
enum Status { [Description("In progress")] Active, Done }
Status.Active.GetDescription(); // "In progress"
Status.Active.GetAttribute<DescriptionAttribute>(); // the attribute, or null
Status.Active.IsDefined(); // true
Status.GetEnumNameFromDescription("In progress"); // Status.Active, or null
(FileAccess.Read | FileAccess.Write).GetFlags(); // Read, Write
| Member | Description |
|---|---|
GetAttribute<TAttribute>() |
The attribute on the member, or null. Cached |
GetDescription() |
[Description] text, falling back to ToString() |
IsDefined() |
Instance form of Enum.IsDefined |
GetFlags() |
Every single-bit flag set, ascending. Composite and zero members are never yielded |
TEnum.GetEnumNameFromDescription(description, comparison?) |
Reverse description lookup. Called on the type |
Attribute and description lookups are cached per enum type, so only the first call pays for reflection.
Tasks
Task<int> count = GetCountAsync();
count.Then(n => n * 2); // Task<int>
count.Then(n => LoadAsync(n)); // Task<T>, flattened
BackgroundWork().FireAndForget(ex => logger.LogError(ex, "background work failed"));
| Member | Description |
|---|---|
Then(map) |
Projects the result. Exceptions and cancellation propagate unchanged |
Then(asyncMap) |
As above with an async projection, flattened |
FireAndForget(onError?) |
Runs unawaited while guaranteeing the fault is observed |
Then completes synchronously when the source task already has, without allocating a state machine.
FireAndForget always observes the fault — with or without a handler — so it can never surface as an
unobserved-task exception. Cancellation is swallowed. Available on Task, Task<T>, ValueTask and
ValueTask<T>.
Expressions
Composes predicate expression trees, so the result is still translatable by EF Core and other providers.
Expression<Func<Order, bool>> isOpen = o => o.Status == Status.Open;
Expression<Func<Order, bool>> isLarge = o => o.Total > 1000;
var query = db.Orders.Where(isOpen.AndAlso(isLarge));
// Or fold a whole collection:
var combined = filters.CombineAndAlso(); // empty => always true
var anyOf = filters.CombineOrElse(); // empty => always false
| Member | Description |
|---|---|
AndAlso(right) / OrElse(right) |
Combines two predicates, rewiring parameters |
CombineAndAlso() / CombineOrElse() |
Folds a sequence of predicates |
CombineExpressionsWithAndAlso(targetType) / ...WithOrElse(targetType) |
Folds untyped LambdaExpressions against a target type |
For the untyped overloads, targetType must be the expression's own parameter type or derive from it — a
Expression<Func<Base, bool>> can be retargeted to Derived, not the other way round.
Reflection
var handlers = assembly.GetClassesImplementing<IHandler>();
var generic = assembly.GetClassesImplementsGenericInterface(typeof(IHandler<>));
var derived = assembly.GetClassesImplementGenericBaseClass(typeof(EntityBase<>));
typeof(int?).IsNullable(); // true
typeof(int?).GetUnderlyingTypeOrSelf(); // typeof(int)
propertyInfo.IsNullable();
| Member | Description |
|---|---|
GetClassesImplementing<TInterface>(throwOnLoadError = false) |
Classes implementing the interface |
GetClassesImplementsInterface(interfaceType, throwOnLoadError = false) |
Same, with a Type argument |
GetClassesImplementsGenericInterface(interfaceType, throwOnLoadError = false) |
Classes implementing an open generic interface at any type arguments |
GetClassesDerivedFrom<TBaseClass>(throwOnLoadError = false) |
Same, generic-argument shorthand |
GetClassesImplementBaseClass(baseClass, throwOnLoadError = false) |
Concrete classes deriving from a non-generic base, at any depth |
GetClassesImplementGenericBaseClass(baseClass, throwOnLoadError = false) |
Concrete classes deriving from an open generic base, at any depth |
IsNullable() |
On Type or PropertyInfo |
GetUnderlyingTypeOrSelf() |
Unwraps Nullable<T> |
Types that fail to load are skipped by default, so scanning an assembly with missing optional dependencies
returns what it could resolve. Pass throwOnLoadError: true to get the ReflectionTypeLoadException instead.
Random
Random.Shared.GenerateNumericString(6); // "480913"
Random.Shared.GenerateAlphaNumericString(12); // "a7Kp2Qm9Xz1B"
⚠️
Randomis not cryptographically secure and its output is predictable from observed values. Never use these for one-time passwords, password-reset codes, session tokens, or anything else security-sensitive — use the secure counterparts in Security instead.
Security
using var cert = StoreLocation.CurrentUser.Find(
StoreName.My, X509FindType.FindBySubjectName, "api.example.com");
foreach (var uri in cert.Extensions
.OfType<X509SubjectAlternativeNameExtension>()
.SelectMany(e => e.EnumerateUriSans()))
Console.WriteLine(uri);
| Member | Description |
|---|---|
Find(storeName, findType, findValue, requirePrivateKey = true) |
Finds a certificate; by default requires an accessible private key (RSA or ECDSA), pass false for a public-only lookup |
TryFind(storeName, findType, findValue, out certificate, requirePrivateKey = true) |
Same, without throwing when nothing matches or the private key is unavailable |
EnumerateUriSans() |
URI Subject Alternative Names from the extension (RFC 5280 §4.2.1.6) |
SecurityExtensions.GenerateSecureNumericString(length) |
Unpredictable random digits |
SecurityExtensions.GenerateSecureAlphaNumericString(length) |
Unpredictable random alphanumerics |
Secure random strings
The counterparts of the Random generators, backed by RandomNumberGenerator and free of modulo
bias. These are the ones to reach for whenever an attacker must not be able to predict the value.
SecurityExtensions.GenerateSecureNumericString(6); // one-time password
SecurityExtensions.GenerateSecureAlphaNumericString(32); // session token / API key
Find returns a non-null certificate that the caller owns and must dispose; certificates it examined but
did not select are disposed for you. It throws InvalidOperationException when nothing matched or the match
has no private key, and CryptographicException when the private key exists but cannot be opened.
TryFind only swallows those two expected "no usable match" outcomes; a genuine infrastructure failure (e.g.
the store itself cannot be opened) still propagates instead of being reported as "not found".
Benchmarks
(BenchmarkDotNet) compares each extension against the straightforward code a caller would otherwise write. Run it yourself with:
All results below are .NET 10.0.12, X64 RyuJIT, ShortRun job (3 iterations); treat the absolute numbers as
indicative, not lab-grade — re-run on your own hardware for anything you're optimizing against.
Strings (StringSlicingBenchmarks)
| Method | Mean | Allocated | vs. baseline |
|---|---|---|---|
LeftOf on string (substring) |
5.0 ns | 88 B | 1.00× (baseline) |
LeftOf on span |
2.7 ns | 0 B | 0.53× time, 0 allocation |
RightOfLast on string |
3.8 ns | 32 B | — |
RightOfLast on span |
2.2 ns | 0 B | — |
Between via manual split |
11.0 ns | 72 B | — |
Between on span |
7.4 ns | 0 B | — |
TrimPrefix on string |
11.6 ns | 160 B | — |
TrimPrefix on span |
1.5 ns | 0 B | — |
CollapseWhitespace (Regex.Replace) |
103.7 ns | 72 B | 1.00× (baseline) |
CollapseWhitespace extension |
11.4 ns | 32 B | 0.11× time, 0.44× allocation |
IsLike glob match |
9.7 ns | 0 B | — |
Takeaway: the span-returning members are consistently 2–7× faster and allocation-free versus the string
equivalent, exactly the point of slicing without copying. CollapseWhitespace beats Regex.Replace by ~9×
using a stack/pooled buffer instead of regex overhead.
Enumerable (EnumerableBenchmarks)
| Method | N | Mean | Allocated | vs. baseline |
|---|---|---|---|---|
Window extension |
100,000 | 1,406.7 µs | 8,798,784 B | 0.94× time, 0.92× allocation vs. naive Skip/Take |
Window naive (Skip/Take/First) |
100,000 | 1,492.6 µs | 9,598,560 B | 1.00× (baseline) |
Lag extension |
100,000 | 485.6 µs | 160 B | — |
Lag indexed loop |
100,000 | 48.4 µs | 0 B | ~10× faster — the loop skips the delegate-per-element and iterator overhead |
RandomSubset (reservoir) |
100,000 | 3,713.0 µs | 458 B | O(count) memory regardless of N |
RandomSubset (shuffle all + take) |
100,000 | 946.6 µs | 400,418 B | Faster here, but O(N) memory — reservoir sampling exists for when N doesn't fit in memory |
IsSorted extension |
100,000 | 135.0 µs | 0 B | 0.16× time, 0 allocation vs. Zip+Any |
IsSorted via Zip+Any |
100,000 | 843.7 µs | 160 B | — |
ToDictionarySafe extension |
100,000 | 519.3 µs | 2,175,368 B | 0.69× time, 1.76× allocation vs. GroupBy |
ToDictionarySafe via GroupBy |
100,000 | 754.7 µs | 1,238,936 B | — |
Takeaway: IsSorted's single-pass comparison is ~6× faster than Zip+Any (which allocates an enumerator
pair per element). RandomSubset's reservoir sampling trades raw speed for O(count) memory — the naive
shuffle-then-take is faster only because it materializes the whole 100k-element source, which the reservoir
approach is specifically built to avoid for sources too large to buffer. Lag's delegate-based API costs
~10× over a hand-written indexed loop — expected LINQ-style overhead for the flexibility.
Conversions (ConversionBenchmarks)
| Method | Mean | Allocated | vs. baseline |
|---|---|---|---|
int.TryParse failure (native) |
3.7 ns | 0 B | 1.00× (baseline) |
TryConvertTo<int> failure |
6.7 ns | 0 B | 1.81× — still allocation-free |
TryConvertTo<int> success |
13.9 ns | 24 B | — |
TryConvertTo<Guid> success |
24.1 ns | 0 B | — |
TryConvertTo<Guid> failure |
7.0 ns | 0 B | — |
TryConvertTo<int?> failure |
56.2 ns | 32 B | — |
ConvertToOrDefault failure |
7.6 ns | 0 B | — |
TryConvertTo<DayOfWeek> success |
21.0 ns | 24 B | — |
Takeaway: the failure path — the one that mattered for the 2.0.0 fix — stays within ~2× of the raw native
TryParse, confirming the primitive fast path avoids the old throw/catch-FormatException cost inside
Convert.ChangeType.
Guid and DateTime (GuidAndDateBenchmarks)
| Method | Mean | Allocated | vs. baseline |
|---|---|---|---|
Guid.ToString("N") |
7.4 ns | 88 B | 1.00× (baseline) |
Guid.ToBase64UrlString() |
23.0 ns | 72 B | — |
Guid.TryFormatBase64Url (span, no allocation) |
9.3 ns | 0 B | 1.26× time, 0 allocation |
Guid.FromBase64UrlString (parse) |
42.8 ns | 72 B | — |
StartOfMonth |
7.8 ns | 0 B | — |
EndOfMonth |
11.4 ns | 0 B | — |
Floor (15-minute) |
1.7 ns | 0 B | — |
IsWeekend (explicit culture) |
10.6 ns | 0 B | — |
AddWorkdays extension |
15.9 ns | 0 B | 0.20× time vs. naive day-by-day loop |
AddWorkdays naive day-by-day loop |
78.4 ns | 0 B | — |
Takeaway: TryFormatBase64Url is the one to reach for in a hot path — it matches ToString("N")'s speed
with zero allocation, versus ToBase64UrlString()'s extra string allocation. AddWorkdays's closed-form
calculation is ~5× faster than looping a day at a time to skip weekends.
DictionaryExtensions, CollectionExtensions, DateTimeRange (DictionaryCollectionRangeBenchmarks)
| Method | N | Mean | Allocated | vs. baseline |
|---|---|---|---|---|
GetOrAdd (naive TryGetValue+Add) |
100,000 | 1,226.9 µs | 6,037,714 B | 1.00× (baseline) |
GetOrAdd extension |
100,000 | 1,218.1 µs | 6,037,867 B | 0.99× time, same allocation |
RemoveWhere extension (pooled array) |
100,000 | 456.5 µs | 2,172,960 B | 0.37× time, 0.36× allocation vs. List<T> buffer |
RemoveWhere naive List<T> buffer |
100,000 | 496.0 µs | 2,697,685 B | — |
WithRange extension |
100,000 | 43.2 µs | 800,056 B | on par with [.. source.Concat(items)] |
WithRange via Concat+spread |
100,000 | 43.0 µs | 801,188 B | — |
DateTimeRange.Contains |
100,000 | ~0.02 ns | 0 B | indistinguishable from an empty method — effectively free |
DateTimeRange.GetOverlapType |
100,000 | ~0.07 ns | 0 B | indistinguishable from an empty method — effectively free |
Takeaways: GetOrAdd's single-lookup fast path (CollectionsMarshal.GetValueRefOrAddDefault) matches the
naive two-step version on time while keeping the same allocation profile — the win is behavioural (one
dictionary operation, not the API shape). RemoveWhere's pooled buffer meaningfully beats a List<T> buffer
on both time and allocations, since it never grows/reallocates. WithRange's exact-size allocation performs
the same as Concat+spread — BCL's ToArray on Concat is already well-optimized, so the manual version
buys correctness/clarity (array-specific overloads, no IEnumerable boxing) rather than raw speed here.
DateTimeRange's comparison-based members carry no measurable overhead.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.