Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API Proposal]: ArgumentOutOfRangeException helper methods #69590

Closed
hrrrrustic opened this issue May 20, 2022 · 31 comments
Closed

[API Proposal]: ArgumentOutOfRangeException helper methods #69590

hrrrrustic opened this issue May 20, 2022 · 31 comments
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-System.Runtime
Milestone

Comments

@hrrrrustic
Copy link
Contributor

hrrrrustic commented May 20, 2022

UPD: Fully rewrote description after creating PR
UPD2: Initially IComparisonOperators<T, T> was used instead of IComparable<T>
Proposed methods (split them as mush as possible) after looking at /runtime code:
(Not sure about struct constraint, everything looks working without it, but it exists in Linq.Max/Min)

namespace System;

public class ArgumentOutOfRangeException : ArgumentException
{
      public static void ThrowIfZero<T>(T value, [CallerArgumentExpression("value")] string? paramName = null) 
          where T : struct, INumberBase<T>, IComparable<T>;
      public static void ThrowIfNegative<T>(T value, [CallerArgumentExpression("value")] string? paramName = null) 
          where T : struct, INumberBase<T>, ISignedNumber<T>, IComparable<T>;
      public static void ThrowIfNegativeOrZero<T>(T value, [CallerArgumentExpression("value")] string? paramName = null) 
          where T : struct, INumberBase<T>, ISignedNumber<T>, IComparable<T>;
      
      public static void ThrowIfGreaterThan<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null) 
          where T : struct, IComparable<T>;
      public static void ThrowIfGreaterThanOrEqual<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null) 
          where T : struct, IComparable<T>;
      
      public static void ThrowIfLessThan<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null)
          where T : struct, IComparable<T>;
      public static void ThrowIfLessThanOrEqual<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null) 
          where T : struct, IComparable<T>;
      
      public static void ThrowIfNotBetween<T>(T value, T inclusiveLower, T inclusiveUpper, [CallerArgumentExpression("value")] string? paramName = null)
          where T : struct, IComparable<T>;
}

Some statistics

Actual usages count will be a little bit lower since some of them are under libraries with old targets (net 4.x or netstandard)

  • ThrowIfNull
    Usages: <10
    I didn't split this in PR, but saw a few of them with ArgumentOutOfRangeException, InvalidArgumentException and base ArgumentException. It probably makes sense to add this one for better null analysis
  • ThrowIfZero
    I personally don't think that this one is good enough, but there is a [API Proposal]: Add ArgumentOutOfRangeException.ThrowIfZero() #68339
    Usages: 8
  • ThrowIfNegative
    Usages: 755
  • ThrowIfNegativeOrZero
    Usages: 100
  • ThrowIfGreaterThan
    Usages: 204
  • ThrowIfGreaterThanOrEqual
    Usages: 32
  • ThrowIfLessThan
    Usages: 174
  • ThrowIfLessThanOrEqual
    Usages: 4
  • ThrowIfNotBetween
    Usages: 255

Risks & Problems & Questions

  • Using casts while passing argument to helper will cause saving it to ParamName property in ArgumentException. That's break all tests with Assert.Throw(paramName, () => {}) because they're strictly comparing parameter name and ParamName property. I don't think this is a problem for end users, but replacement in /runtime could be tricky
  • ThrowIf should contains additional argument for passing custom message. Should it be just string or InterpolatedStringHandler? Discussed in [API Proposal]: More helper methods for exception types #69637 and ThrowIf completely removed from proposal.
  • ThrowIfLessThan and others similar methods with IComparisonOperators constraint doesn't work for TimeSpan, DateTime, etc. Related to Determine recommended behavior of checked/unchecked operators for DateTime and similar types #67744, but Idk why these types can't implement IComparisonOperators that doesn't cause any overflowing issues. Could be replaced by IComparable<T>? UPD: replaced by IComparable<T>
  • ThrowIfNegative{OrZero} requires too much for custom types due to IBaseNumber<> constraint (we only need T.Zero). I think most of the users will use ThrowIfLess with manual passing "zero" value instead (only IComparable<T> required for this)
  • I saw a lot of similar conditions with throwing base ArgumentException instead ArgumentOutOfRangeException. Should we duplicate these methods for it?
  • [API Proposal]: Throw helpers should return the valid value back #70515
  • Renaming ThrowIfNotBetween -> ThrowIfOutsideRange ArgumentOutOfRangeException throw helpers #69767 (comment)
@hrrrrustic hrrrrustic added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label May 20, 2022
@dotnet-issue-labeler
Copy link

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

@ghost ghost added the untriaged New issue has not been triaged by the area owner label May 20, 2022
@stephentoub
Copy link
Member

Some optimization hacks like uint casts can be lost by using this API

@tannergooding, were you looking to add an IsBetween method to one of the generic math interfaces?

@stephentoub
Copy link
Member

stephentoub commented May 20, 2022

Methods in the second group needs more information for throwing (at least message arg). But I'm not sure should it be just a string or interpolated handler

Can you elaborate? Do you mean the message needs to include information about the bounds being checked?

@hrrrrustic
Copy link
Contributor Author

Methods in the second group needs more information for throwing (at least message arg). But I'm not sure should it be just a string or interpolated handler

Can you elaborate? Do you mean the message needs to include information about the bounds being checked?

Yes. Most of the usages in dotnet/runtime use prepared messages from SR and can be just passed as one more string param

throw new ArgumentOutOfRangeException(nameof(value), SR.net_io_timeout_use_gt_zero);

But I think it is fine to do something like this sometimes

if (arg < someMinValue) throw ArgumentOutOfRangeException(nameof(arg), $"value has to be at least {someMinValue})

and in the new API laziness of interpolation will be lost without interpolation handler

@stephentoub
Copy link
Member

These helper APIs are meant for the most common cases where the caller wants to use a standard error message. I don't think these APIs should accept a message; the implementation can use the bounds it's given to create the message, but the user shouldn't be able to customize it. At that point, they can just write their own check.

@tannergooding
Copy link
Member

were you looking to add an IsBetween method to one of the generic math interfaces?

Yep. I'll be getting a proposal up later today most likely.

@tannergooding
Copy link
Member

ThrowIfNotPositive

This one needs a different name. In the realm of generic math, the IEEE 754 (floating-point) spec, and a few other cases 0 is considered positive and INumberBase.IsPositive will return true. Likewise for -0 it will return false and IsNegative will return true.

@tannergooding
Copy link
Member

tannergooding commented May 20, 2022

I'd likely recommend the other APIs have slightly different names as well:

  • ThrowIfNegative - LGTM
  • ThrowIfNotPositive - Needs a different name, as per above
  • ThrowIfGreater - Should probably be ThrowIfGreaterThan
  • ThrowIfLower - Should probably be ThrowIfLessThan
  • ThrowIfNotInRange - Should probably be ThrowIfNotBetween

*GreaterThan(x, y) is the name we use in the vector types, LINQ expressions, and various other places. *LessThan is then its counterpart.

IsBetween is the name we took last week for some other APIs and is what I'll be proposing for INumber.IsBetween, so IfNotBetween seems like the opposite of that.

@hrrrrustic
Copy link
Contributor Author

hrrrrustic commented May 20, 2022

Agree, but not sure how can ThrowIfNotPositive be renamed, I thought above ThrowIfNegativeOrZero but is still falls in -0and +0 😄

Updated proposal for other methods.

@tannergooding
Copy link
Member

tannergooding commented May 20, 2022

ThrowIfNegativeOrZero seems reasonable to me. -0 is negative and/or zero, depending on how you look. +0 is zero. So both will cause a throw and only positive/non-zero values will "succeed".

This also "fits" the naming convention we'd presumably do if we had ThrowIfGreaterThanOrEqual and ThrowIfLessThanOrEqual methods.

@hrrrrustic
Copy link
Contributor Author

Uhh, make sense, updated proposal

@ghost
Copy link

ghost commented May 20, 2022

Tagging subscribers to this area: @dotnet/area-system-runtime
See info in area-owners.md if you want to be subscribed.

Issue Details

Background and motivation

Similar to #62628, #58047 and #51700 (also #68339)

Tried to collect all possible helpers for ArgumentOutOfRangeException in this issue as per

We've said we're ok with such helpers in general (not this specific proposal), as long as they're common use cases (there are an infinite number of methods we'd have to add if we wanted to handle all of the argument validation anyone does today). In particular, we've talked about someone needing to come up with a proposal for what the helpers should be for ArgumentOutOfRangeException in order to cover the 95% of use for it across dotnet/runtime.

Originally posted by @stephentoub in #68339 (comment)

API Proposal

All methods below are inside ArgumentOutOfRangeException

Group 1:

public static void ThrowIfNegative<T>(T value, [CallerArgumentExpression("value")] string? paramName = null)
    where T: ISignedNumber<T> {}

public static void ThrowIfNegativeOrZero<T>(T value, [CallerArgumentExpression("value")] string? paramName = null)
    where T: INumber<T> {}

Group 2:

// Note that there are no 'INumber<T>' constraint! This methods can be used for any types, not only for numbers.
// Also possible to extend usages for user defined types by implementing 'IComparisonOperators'

public static void ThrowIfGreaterThan<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null)
    where T: IComparisonOperators<T, T> {}

public static void ThrowIfLowerThan<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null)
    where T: IComparisonOperators<T, T> {}

public static void ThrowIfNotBetween<T>(T value, T left, T right, [CallerArgumentExpression("value")] string? paramName = null) 
    where T: IComparisonOperators<T, T> {}

API Usage

Group 1:

ThrowIfNegative is obviously the most usable helper that will cover most of the boring if with validation input args. 😆

public Version(int major, int minor, int build, int revision)
{
if (major < 0)
throw new ArgumentOutOfRangeException(nameof(major), SR.ArgumentOutOfRange_Version);
if (minor < 0)
throw new ArgumentOutOfRangeException(nameof(minor), SR.ArgumentOutOfRange_Version);
if (build < 0)
throw new ArgumentOutOfRangeException(nameof(build), SR.ArgumentOutOfRange_Version);
if (revision < 0)

There is also kind of this helper in Random

public virtual long NextInt64(long maxValue)
{
if (maxValue < 0)
{
ThrowMaxValueMustBeNonNegative();

ThrowIfNegativeOrZero
The only difference from previous method that zero is also invalid value. There are just a few cases (found them by regex <= 0.+throw) for it in dotnet/runtime, but I believe this could also make sense

public IntegerValidator(int minValue, int maxValue, bool rangeIsExclusive, int resolution)
{
if (resolution <= 0) throw new ArgumentOutOfRangeException(nameof(resolution));

Group 2:
ThrowIfGreaterThan and ThrowIfLowerThan has two types of usage:

ThrowIfNotBetween commonly used in some inserting/coping operation to confirm that passed index > 0 and < length

if (arrayIndex < 0 || arrayIndex > array.Length)
{
throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, SR.ArgumentOutOfRange_IndexMustBeLessOrEqual);

Alternative Designs

  1. Additional overloads for IFloatingPoint with epsilon arg
  2. Some naming changes like
    * ThrowIfNotPositive -> ThrowIfNegativeOrZero
    * ThrowIfNotInRange -> ThrowIfOutsideRange
    * ThrowIfGreater -> ThrowIf{Larger/Bigger} and ThrowIfLower -> ThrowIfSmaller

Risks

  • Some optimization hacks like uint casts can be lost by using this API
    UPD: Shoud be fixed with IsBetween in generic math

  • Methods in the second group needs more information for throwing (at least message arg). But I'm not sure should it be just a string or interpolated handler
    UPD: Decided that for most cases default messages will be enough, otherwise user should use old style

  • This code compiles

    public void Test(uint a)
    {
        if (a > 1)
            throw new ArgumentOutOfRangeException();
    }

    But this (without u suffix) not

    public void Test(uint a)
    {
        MyArgumentOutOfRangeException.ThrowIfGreater(a, 1);
    }

    Same for ulong and coming uint128

Author: hrrrrustic
Assignees: -
Labels:

api-suggestion, area-System.Runtime, untriaged

Milestone: -

@hrrrrustic
Copy link
Contributor Author

hrrrrustic commented May 23, 2022

Is there anything else to do to make this ready to review? @stephentoub @tannergooding
Basic implementation should be pretty straightforward and upgraded later to use IsBetween

This also "fits" the naming convention we'd presumably do if we had ThrowIfGreaterThanOrEqual and ThrowIfLessThanOrEqual methods.

I don't think ThrowIfGreaterThanOrEqual and ThrowIfLessThanOrEqual are fairly common enough for now and can be simulated in integers by adding 1 to the ranges (but could be added for API completeness)

@stephentoub
Copy link
Member

stephentoub commented May 23, 2022

Is there anything else to do to make this ready to review?

This is an issue where we need high confidence that it's going to address most of our own usage; otherwise, we'll take it through API review and either there will be a bunch of questions about what's missing, or it'll get approved and then when we go to use it we might find a bunch of issues, and we'll need to flip it back to unapproved to take it back through again.

As such, my preference is that the proposal actually include doing the work to roll it out across dotnet/runtime, so that we can fully understand what issues there might be / what might be missing. That work will be necessary anyway, so it's better to just front-load it.

Obviously some things might need to be tweaked post API-review, but hopefully it'd mostly be search-and-replace for things like naming.

@hrrrrustic
Copy link
Contributor Author

hrrrrustic commented May 24, 2022

@stephentoub
Should I ignore usages with message argument from SR or convert them to a new API without custom message?

if (startIndex < 0)
throw new ArgumentOutOfRangeException(nameof(startIndex), SR.ArgumentOutOfRange_StartIndex);

ArgumentOutOfRangeException.ThrowIfNegative(startIndex);

There are only 79 files that uses ArgumentOutOfRangeException only with paramName overload

@stephentoub
Copy link
Member

It'll depend on whether the message contains any meaningful additional information. In the example you reference, the message is just "StartIndex cannot be less than zero", which doesn't provide anything useful beyond the default message would for a ThrowIfNegative method, so this case could be changed. Thanks.

@hrrrrustic
Copy link
Contributor Author

hrrrrustic commented Jun 27, 2022

@stephentoub I created a PR with changes across /runtime and updated the proposal

See also #69767 (comment)

@dakersnar dakersnar removed the untriaged New issue has not been triaged by the area owner label Jun 29, 2022
@dakersnar dakersnar added this to the Future milestone Jun 29, 2022
@hrrrrustic
Copy link
Contributor Author

hrrrrustic commented Sep 22, 2022

@stephentoub @tannergooding Is there anything else to do here to make this one ready to review or at least get some thoughts? As I see review meetings started processing issues for net8

@jeffhandley
Copy link
Member

I was just looking for a throw helper that would help in the Console App scenario where I want to ensure I received the expected number of arguments. ArgumentOutOfRangeException.ThrowIfLessThan would be useful for that. Before I reached for that though, I was also curious if ArgumentException.ThrowIfNullOrEmpty([NotNull] string?[]? argument, ...) existed (it doesn't).

@hrrrrustic
Copy link
Contributor Author

@stephentoub @tannergooding Just met another copy paste helper class with these methods, any chance to make progress on this? 😢

@tannergooding
Copy link
Member

The proposal looks reasonable to me. I'll let Stephen comment as well and then we can look at getting this into API review.

@jeffhandley
Copy link
Member

I'm enthused about this too. We're working through some planning tasks right now, and as part of that we're crafting our "work planned" epics across our areas. I'm going to mark this as 8.0.0 so that we can include this and commit to refining these if/as needed and getting the approved set into an early preview of .NET 8.

@geeknoid
Copy link
Member

geeknoid commented Nov 1, 2022

Please consider this addition to validate enum values:

    /// <summary>
    /// Throws an <see cref="System.ArgumentOutOfRangeException"/> if the enum value is not valid.
    /// </summary>
    /// <param name="argument">The argument to evaluate.</param>
    /// <param name="paramName">The name of the parameter being checked.</param>
    /// <typeparam name="T">The type of the enumeration.</typeparam>
    /// <returns>The original value of <paramref name="argument"/>.</returns>
    public static T ThrowIfOutOfRange<T>(T argument, [CallerArgumentExpression("argument")] string paramName = "")
        where T : struct, Enum
    {
#if NET5_0_OR_GREATER
        if (!Enum.IsDefined<T>(argument))
#else
        if (!Enum.IsDefined(typeof(T), argument))
#endif
        {
            throw new ArgumentOutOfRangeException(paramName, $"{argument} is an invalid value for enum type {typeof(T)}");
        }

        return argument;
    }

@stephentoub
Copy link
Member

stephentoub commented Nov 3, 2022

Thanks, @hrrrrustic. The set looks reasonable to me. A few notes:

  • We should use IComparable<T> instead of IComparisonOperators<T, T>.
  • ThrowIfNull should be removed; I assume that's just a copy/paste error.
  • I assume all of these are intended to be on ArgumentOutOfRangeException. Please update the API outline to include that around the methods.
  • For ThrowIfNotBetween, I assume the left/right boundaries are meant to be inclusive for the range, and thus exclusive for what's not between them, e.g. calling it with 3, 7 would throw for 2 and 8 but not 3 or 7. We should try to come up with parameter names to make that clear, maybe lowerInclusive and upperInclusive.
  • Why does ThrowIfZero require the T be signed?
  • For Martin's proposed ThrowIfOutOfRange, I'm hesitant there as that will inevitably be slower than what many developers write for enums they know about, e.g. (uint)dayOfWeek <= (uint)DayOfWeek.Saturday is much faster than Enum.IsDefined<DayOfWeek>(day), yet by adding it we'll be pushing developers to use it instead. Let's keep that out of this discussion. Martin, if you'd like to add that to the other issue you opened, please feel free to do so.

@hrrrrustic
Copy link
Contributor Author

Thanks, @hrrrrustic. The set looks reasonable to me. A few notes:

  • We should use IComparison<T> instead of IComparisonOperators<T, T>.
  • ThrowIfNull should be removed; I assume that's just a copy/paste error.
  • I assume all of these are intended to be on ArgumentOutOfRangeException. Please update the API outline to include that around the methods.
  • For ThrowIfNotBetween, I assume the left/right boundaries are meant to be inclusive for the range, and thus exclusive for what's not between them, e.g. calling it with 3, 7 would throw for 2 and 8 but not 3 or 7. We should try to come up with parameter names to make that clear, maybe lowerInclusive and upperInclusive.
  • Why does ThrowIfZero require the T be signed?
  • For Martin's proposed ThrowIfOutOfRange, I'm hesitant there as that will inevitably be slower than what many developers write for enums they know about, e.g. (uint)dayOfWeek <= (uint)DayOfWeek.Saturday is much faster than Enum.IsDefined<DayOfWeek>(day), yet by adding it we'll be pushing developers to use it instead. Let's keep that out of this discussion. Martin, if you'd like to add that to the other issue you opened, please feel free to do so.
  1. Assuming that you mean IComparable<T>, udpated
  2. Yep, that was in initial proposal, but removed later
  3. Done
  4. Renamed a little bit arguments and add comment above
  5. Copy/paste error, removed
  6. Yep, thought about enum support, but looks like there is no way to do it without losing some performance. By the way, there were something around 400 usages on enums in /runtime (can be found in linked PR)

@stephentoub stephentoub added api-ready-for-review API is ready for review, it is NOT ready for implementation and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Nov 3, 2022
@geeknoid
Copy link
Member

geeknoid commented Nov 4, 2022

It's a shame to not support enums, it's a fairly common use case. Then perhaps an overload which has the developer specify th lower bound and upper bound of the enum value explicitly?

@hrrrrustic
Copy link
Contributor Author

hrrrrustic commented Nov 5, 2022

It's a shame to not support enums, it's a fairly common use case. Then perhaps an overload which has the developer specify th lower bound and upper bound of the enum value explicitly?

I don't remember any way to compare generic enums without boxing. We only have EqualityComparer<T>.Default for Equals, but that's not enough
You can manually cast values to int 😄

I guess there is a #17456 for that

@terrajobst
Copy link
Member

terrajobst commented Nov 8, 2022

Video

  • We should remove the struct constraint as it's not necessary
  • On the first three methods, we don't need the ISignedNumber<T> and IComparable<T> constraints
  • We don't like ThrowIfNotBetween due the ambiguity if the third argument is inclusive/exclusive and/or a boundary or a length.
namespace System;

public partial class ArgumentOutOfRangeException
{
    public static void ThrowIfZero<T>(T value, [CallerArgumentExpression("value")] string? paramName = null) 
        where T : INumberBase<T>;

    public static void ThrowIfNegative<T>(T value, [CallerArgumentExpression("value")] string? paramName = null) 
        where T : INumberBase<T>;

    public static void ThrowIfNegativeOrZero<T>(T value, [CallerArgumentExpression("value")] string? paramName = null) 
        where T : INumberBase<T>;
    
    public static void ThrowIfGreaterThan<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null) 
        where T : IComparable<T>;

    public static void ThrowIfGreaterThanOrEqual<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null) 
        where T : IComparable<T>;
    
    public static void ThrowIfLessThan<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null)
        where T : IComparable<T>;

    public static void ThrowIfLessThanOrEqual<T>(T value, T other, [CallerArgumentExpression("value")] string? paramName = null) 
        where T : IComparable<T>;
}

@terrajobst terrajobst added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels Nov 8, 2022
@stephentoub
Copy link
Member

@hrrrrustic, thanks for all of your work here. Do you want to revive your work from before and open a PR for this?

@hrrrrustic
Copy link
Contributor Author

@hrrrrustic, thanks for all of your work here. Do you want to revive your work from before and open a PR for this?

Yes, you can assign this one on me
I'll also look at ThrowIfNotBetween usages for some patterns except ThrowIfNotInRange(index, ICollection)

@stephentoub
Copy link
Member

Fixed by #78222

@ghost ghost locked as resolved and limited conversation to collaborators Jan 4, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-System.Runtime
Projects
None yet
Development

No branches or pull requests

8 participants