3

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?

Benjamin Hodgson
  • 42,952
  • 15
  • 108
  • 157
ErikE
  • 48,881
  • 23
  • 151
  • 196
  • Constraints are not considered when performing overload resolution. Neither is the return type. If I had to guess at the actual reasoning the compiler uses, for the example provided, would be that your non-nullable overload types will exactly match the types of the parameters provided, therefore it is the best match. Then, it does not check the constraints until after it has determined the best match, thus you get a constraint error. – Glorin Oakenfoot Jan 27 '16 at 23:14
  • Of *course* return types aren't used in overload resolution--I just made a silly mistake. I'll remove that part from my question. – ErikE Jan 27 '16 at 23:27
  • Can you clarify please ErikE, is your question to understand the quirks of generics, or to solve the issue of how to write a ConstrainWithinBounds that works for all types? As your question is worded, it's a dup. Any advice to solve the problem will be downvoted as not answering your specific question (a failing of SO in my opinion). – Kory Gill Jan 28 '16 at 02:34
  • All: now that I have updated the question to deal with my *real* desire (not to solve generics globally, but to have a `ConstrainWithinBounds` method that works intuitively), this is no longer a duplicate. – ErikE Jan 28 '16 at 16:22
  • What happens if you cast your two `DateTime`s to `DateTime?`s at the call site? – Benjamin Hodgson Jan 28 '16 at 22:45
  • @BenjaminHodgson That could work, though it makes the calling code less clean. I was hoping there could be a reasonable way to achieve it with really clean code that has no extra conversions and doesn't have to call different method names based on the data types in question. I did actually work something up that seems to fit the bill, but I was hoping to give Kory a chance to answer and get points, since he had been interested. – ErikE Jan 28 '16 at 22:56
  • @Kory please see the completely reworked question. – ErikE Jan 28 '16 at 23:00

1 Answers1

0

I suggest you 'just' remove the 'class' constraint in your second method. This should solve the problem.

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 : IEquatable<T>, IComparable<T>   // remove class constraint
{
   return value == null
      ? default(T) // also change this
      : lowerBound != null && value.CompareTo(lowerBound) < 0
         ? lowerBound
         : upperBound != null && value.CompareTo(upperBound) > 0
            ? upperBound
            : value;
}
Samuel Vidal
  • 883
  • 5
  • 16