I am trying to write a universal ConstrainWithinBounds
method that will let me truncate any value, nullable value, or class object that implements IEquatable
and IComparable
to within a defined range. I'd also like the method to allow null values in the parameters, which would be treated semantically as "do not bound this side of the range".
However, in my attempts to do so I've run into a strange problem that I don't understand.
The following two ConstrainWithinBounds
method overloads I came up with have essentially the same logic, but the upper one has generic type constraint struct
and the lower, class
. The first allows nullable value types for the 2nd and 3rd parameters:
public static T ConstrainWithinBounds<T>(this T value, T? lowerBound, T? upperBound)
where T : struct, IEquatable<T>, IComparable<T>
{
return lowerBound.HasValue && value.CompareTo(lowerBound.Value) < 0
? lowerBound.Value
: upperBound.HasValue && value.CompareTo(upperBound.Value) > 0
? upperBound.Value
: value;
}
public static T ConstrainWithinBounds<T>(this T value, T lowerBound, T upperBound)
where T : class, IEquatable<T>, IComparable<T>
{
return value == null
? null
: lowerBound != null && value.CompareTo(lowerBound) < 0
? lowerBound
: upperBound != null && value.CompareTo(upperBound) > 0
? upperBound
: value;
}
Programming note: implementing both IEquatable
and IComparable
ensures that the type not only has inequality semantics but may be a proper graduating range, more strongly implying that resetting a value/class/struct to the bound is a sensible operation. For example, a series of order statuses might have a sequence to them, but it wouldn't make sense to constrain OrderPlaced
to be between OrderShipped
and OrderReturned
.
Except, the compiler resolves the following call to the second overload instead of the first:
DateTime bounded = new DateTime(2016, 1, 1).ConstrainWithinBounds(
new DateTime(2016, 2, 1),
new DateTime(2016, 3, 1)
);
But then it gives a compilation error:
The type '
DateTime
' must be a reference type in order to use it as parameter 'T
' in the generic type or method 'ConstrainWithinBounds<T>(T, T, T)
'
Correct, DateTime
is a non-nullable struct, so why is overload selection picking the wrong one? If I name the overloads differently, the exact same call to the first overload compiles (and runs) correctly.
What I'd really like is to know how to have a single method group, ConstraintWithinBounds
, that does the job.
Now, maybe what I'm asking for isn't possible, or isn't possible elegantly, but I'd really like to know!
Test platform that any suggested answers need to cover
Here's my test class that I'm using for a class type (C# 6.0):
public sealed class MyClass : IEquatable<MyClass>, IComparable<MyClass> {
public MyClass(int value) { Value = value; }
public int Value { get; }
public bool Equals(MyClass other) => other != null && Value == other.Value;
public int CompareTo(MyClass other) => other == null ? 1 : Value.CompareTo(other.Value);
public override bool Equals(object obj) => obj != null && Value == (obj as MyClass)?.Value;
public override int GetHashCode() => Value.GetHashCode();
public static bool operator ==(MyClass a, MyClass b) => ReferenceEquals(a, b) || (object) a != null && (object) b != null && a.Value == b.Value;
public static bool operator !=(MyClass a, MyClass b) => !(a == b);
public override string ToString() => Value.ToString();
}
And some unit testing to exercise the overload resolution and return values:
Console.WriteLine(0.ConstrainWithinBounds(5, 10) == 5);
Console.WriteLine(7.ConstrainWithinBounds(5, 10) == 7);
Console.WriteLine(15.ConstrainWithinBounds(5, 10) == 10);
Console.WriteLine(testDate.ConstrainWithinBounds(low, high) == low);
Console.WriteLine(testDate.ConstrainWithinBounds(low, highN) == low);
Console.WriteLine(testDate.ConstrainWithinBounds(low, null) == low);
Console.WriteLine(testDate.ConstrainWithinBounds(lowN, high) == low);
Console.WriteLine(testDate.ConstrainWithinBounds(lowN, highN) == low);
Console.WriteLine(testDate.ConstrainWithinBounds(lowN, null) == low);
Console.WriteLine(testDate.ConstrainWithinBounds(null, high) == testDate);
Console.WriteLine(testDate.ConstrainWithinBounds(null, high) == testDate);
Console.WriteLine(testDate.ConstrainWithinBounds(null, highN) == testDate);
Console.WriteLine(testDate.ConstrainWithinBounds(null, null) == testDate);
Console.WriteLine(testDateN.ConstrainWithinBounds(low, high) == lowN);
Console.WriteLine(testDateN.ConstrainWithinBounds(low, highN) == lowN);
Console.WriteLine(testDateN.ConstrainWithinBounds(low, null) == lowN);
Console.WriteLine(testDateN.ConstrainWithinBounds(lowN, high) == lowN);
Console.WriteLine(testDateN.ConstrainWithinBounds(lowN, highN) == lowN);
Console.WriteLine(testDateN.ConstrainWithinBounds(lowN, null) == lowN);
Console.WriteLine(testDateN.ConstrainWithinBounds(null, high) == testDateN);
Console.WriteLine(testDateN.ConstrainWithinBounds(null, highN) == testDateN);
Console.WriteLine(testDateN.ConstrainWithinBounds(null, null) == testDateN);
Console.WriteLine(testDate2.ConstrainWithinBounds(low, high) == high);
Console.WriteLine(testDate2.ConstrainWithinBounds(low, highN) == high);
Console.WriteLine(testDate2.ConstrainWithinBounds(low, null) == testDate2);
Console.WriteLine(testDate2.ConstrainWithinBounds(lowN, high) == high);
Console.WriteLine(testDate2.ConstrainWithinBounds(lowN, highN) == high);
Console.WriteLine(testDate2.ConstrainWithinBounds(lowN, null) == testDate2);
Console.WriteLine(testDate2.ConstrainWithinBounds(null, high) == high);
Console.WriteLine(testDate2.ConstrainWithinBounds(null, highN) == high);
Console.WriteLine(testDate2.ConstrainWithinBounds(null, null) == testDate2);
Console.WriteLine(testDate2N.ConstrainWithinBounds(low, high) == highN);
Console.WriteLine(testDate2N.ConstrainWithinBounds(low, highN) == highN);
Console.WriteLine(testDate2N.ConstrainWithinBounds(low, null) == testDate2N);
Console.WriteLine(testDate2N.ConstrainWithinBounds(lowN, high) == highN);
Console.WriteLine(testDate2N.ConstrainWithinBounds(lowN, highN) == highN);
Console.WriteLine(testDate2N.ConstrainWithinBounds(lowN, null) == testDate2N);
Console.WriteLine(testDate2N.ConstrainWithinBounds(null, high) == highN);
Console.WriteLine(testDate2N.ConstrainWithinBounds(null, highN) == highN);
Console.WriteLine(testDate2N.ConstrainWithinBounds(null, null) == testDate2N);
Console.WriteLine(my0.ConstrainWithinBounds(my5, my10).Value == 5);
Console.WriteLine(my0.ConstrainWithinBounds(null, my10).Value == 0);
Console.WriteLine(my0.ConstrainWithinBounds(my5, null).Value == 5);
Console.WriteLine(my0.ConstrainWithinBounds(null, null).Value == 0);
Console.WriteLine(myNull.ConstrainWithinBounds(null, null) == null);
Console.WriteLine(myNull.ConstrainWithinBounds(my5, null) == null);
Console.WriteLine(myNull.ConstrainWithinBounds(null, my10) == null);
Console.WriteLine(myNull.ConstrainWithinBounds(my5, my10) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(low, high) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(low, highN) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(low, null) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(lowN, high) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(lowN, highN) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(lowN, null) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(null, high) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(null, highN) == null);
Console.WriteLine(nullDt.ConstrainWithinBounds(null, null) == null);
Console.WriteLine(my7.ConstrainWithinBounds(my5, my10).Value == 7);
Console.WriteLine(my7.ConstrainWithinBounds(null, my10).Value == 7);
Console.WriteLine(my7.ConstrainWithinBounds(my5, null).Value == 7);
Console.WriteLine(my7.ConstrainWithinBounds(null, null).Value == 7);
Console.WriteLine(my15.ConstrainWithinBounds(null, null).Value == 15);
Console.WriteLine(my15.ConstrainWithinBounds(my5, null).Value == 15);
Console.WriteLine(my15.ConstrainWithinBounds(null, my10).Value == 10);
Console.WriteLine(my15.ConstrainWithinBounds(my5, my10).Value == 10);
I know these aren't great unit tests, what with the duplication and all, but hey, they'll at least work to prove the code is doing what it should...
Not the real question, but some further thoughts
P.S. On the thought that perhaps the nullable 2nd and 3rd parameters are causing a problem, I tried adding one more overload:
public static T ConstrainWithinBounds<T>(T value, T lowerBound, T upperBound)
where T : struct, IEquatable<T>, IComparable<T>
{
return value.CompareTo(lowerBound) < 0
? lowerBound
: value.CompareTo(upperBound) > 0
? upperBound
: value;
}
But this just causes a conflict between the 2nd overload and this new one:
Type 'LogicHelper' already defines a member called 'ConstrainWithinBounds' with the same parameter types
Bonus question: Does this mean that overload resolution is done only on method name and parameters without respect to generic type constraints, and only later in the compilation process are type constraints checked?