Description
Description
UInt64.CreateSaturating uses truncation behavior rather than saturation with Int128. For example, ulong.CreateSaturating((Int128)ulong.MaxValue + (Int128)10L))
returns 9 instead of 18446744073709551615 (ulong.MaxValue).
Internally, it's doing truncation because UInt64.CreateSaturating first calls TryConvertFromSaturating (here), which doesn't handle Int128, and then it falls back to Int128.TryConvertToSaturating. Unfortunately, that method does a truncating cast to ulong (here) rather than using MaxValue logic like it does for uint (here).
Here's a sample C# program that demonstrates this inconsistency with UInt64 and Int128. For contrast, it also shows that UInt32 correctly saturates with Int64, and it shows that UInt64 correctly saturates with Decimal.
namespace GenericMath;
using System;
using System.Runtime.CompilerServices;
internal class Program
{
static void Main()
{
Show("----UInt32 Saturates With Int64----");
Show(uint.CreateSaturating(-10L));
long longValue;
Show(longValue = uint.MaxValue + 10L);
Show(uint.CreateSaturating(longValue));
Show("\n----UInt64 Truncates With Int128!----");
Show(ulong.CreateSaturating((Int128)(-10L)));
Show(ulong.MaxValue);
Int128 int128Value;
Show(int128Value = (Int128)ulong.MaxValue + (Int128)10L);
// Things go wrong here with Int128. ulong doesn't handle it correctly.
Show(ulong.CreateSaturating<Int128>(int128Value));
Show(" CreateSaturating should return ulong.MaxValue not 9.");
Show(" CreateSaturating is incorrectly doing truncation.");
Show(ulong.CreateTruncating<Int128>(int128Value));
Show("\n----UInt64 Saturates With Decimal----");
Show(ulong.CreateSaturating(-10m));
decimal decimalValue;
Show(decimalValue = ulong.MaxValue + 10m);
Show(ulong.CreateSaturating(decimalValue));
}
static void Show(object value, [CallerArgumentExpression(nameof(value))] string? expression = null)
=> Console.WriteLine(value is string text ? text : $"{expression,-58}: {value,20}");
}
Output:
----UInt32 Saturates With Int64----
uint.CreateSaturating(-10L) : 0
longValue = uint.MaxValue + 10L : 4294967305
uint.CreateSaturating(longValue) : 4294967295
----UInt64 Truncates With Int128!----
ulong.CreateSaturating((Int128)(-10L)) : 0
ulong.MaxValue : 18446744073709551615
int128Value = (Int128)ulong.MaxValue + (Int128)10L : 18446744073709551625
ulong.CreateSaturating<Int128>(int128Value) : 9
CreateSaturating should return ulong.MaxValue not 9.
CreateSaturating is incorrectly doing truncation.
ulong.CreateTruncating<Int128>(int128Value) : 9
----UInt64 Saturates With Decimal----
ulong.CreateSaturating(-10m) : 0
decimalValue = ulong.MaxValue + 10m : 18446744073709551625
ulong.CreateSaturating(decimalValue) : 18446744073709551615
Reproduction Steps
If the sample program in the description is too verbose, here's the gist of the problem:
ulong.CreateSaturating((Int128)ulong.MaxValue + (Int128)10L)
Expected behavior
The repro line of code should return ulong.MaxValue (18446744073709551615) to be consistent with other CreateSaturating methods.
Actual behavior
The repro line of code returns 9, which is a bitwise truncation of the Int128 value. That's the same value that CreateTruncating returns, and it's inconsistent with other CreateSaturating methods.
Regression?
I've only tried this in .NET 7, which is where Int128 was introduced. .NET 8 hasn't been officially released yet.
Known Workarounds
For Int128 values that fit into Decimal, you can use ulong.CreateSaturating<decimal>
instead.
Configuration
.NET 7.0.13 on Windows 11 x64 with .NET SDK 7.0.403.
Other information
Int128.TryConvertToSaturating does a truncating cast to ulong (here) rather than using MaxValue logic like it does for uint (here). For ulong, Int128.TryConvertToSaturating is only saturating on the lower bound not on the upper bound, so it's a half-saturating, half-truncating implementation. That's the heart of the bug, and that inconsistency makes CreateSaturating untrustworthy for my generic math usage.