I would like to represent a numeric range in C#. Either open-ended, such as "up to 35" or "100 on up" or closed-ended, such as "34 to 65". I'd like to represent the open end with NULL. Further, I'd like to parse strings into the range. Strings will look like "-35", "100+", "34-65" or even "-10.5--0.5". I hate that the dash means negative and the range separator, but it is what I'm given. That all being said, I've tested my cases against this code and they work. My questions are: is this the best way to accomplish this? Is it okay to let certain exceptions be thrown for invalid data or should I do more checking on it beforehand? Should this even be called Range? Is Interval better?
public struct Range<T> where T : struct, IComparable<T>
{
private static readonly Regex _RemoveSpaces = new Regex(@"\s+", RegexOptions.Compiled);
private static readonly char[] _SplitOnDash = { '-' };
private static readonly TypeConverter _Converter = TypeDescriptor.GetConverter(typeof(T));
private readonly T? _Minimum;
private readonly T? _Maximum;
public Range(T? minimum = null, T? maximum = null)
{
if ((minimum != null) && (maximum != null) && Comparer<T?>.Default.Compare(minimum, maximum) > 0)
{
throw new ArgumentException("minimum is greater than maximum.");
}
this._Minimum = minimum;
this._Maximum = maximum;
}
public T? Minimum
{
get
{
return this._Minimum;
}
}
public T? Maximum
{
get
{
return this._Maximum;
}
}
public static Range<T> Parse(string range)
{
range = _RemoveSpaces.Replace(range, string.Empty);
// No maximum.
if (range.EndsWith("+"))
{
return new Range<T>(_Converter.ConvertFromString(range.Substring(0, range.Length - 1)) as T?);
}
// No minimum.
if (range.StartsWith("-") && !range.Substring(2).Contains("-"))
{
return new Range<T>(null, _Converter.ConvertFromString(range.Substring(1)) as T?);
}
var rangeParts = range.Split(_SplitOnDash);
// Two positive numbers.
if (rangeParts.Length == 2)
{
return new Range<T>(
_Converter.ConvertFromString(rangeParts[0]) as T?,
_Converter.ConvertFromString(rangeParts[1]) as T?);
}
// One negative, one positive number.
if (rangeParts.Length == 3)
{
return new Range<T>(
_Converter.ConvertFromString("-" + rangeParts[1]) as T?,
_Converter.ConvertFromString(rangeParts[2]) as T?);
}
// Two negative numbers.
if (rangeParts.Length == 4)
{
return new Range<T>(
_Converter.ConvertFromString("-" + rangeParts[1]) as T?,
_Converter.ConvertFromString("-" + rangeParts[3]) as T?);
}
// No idea what we were given, give a completely open range.
return new Range<T>(null, null);
}
public override string ToString()
{
return (this._Minimum == null) && (this._Maximum == null)
? string.Empty
: (this._Minimum == null
? string.Format("-{0}", this._Maximum)
: (this._Maximum == null
? string.Format("{0}+", this._Minimum)
: string.Format("{0} - {1}", this._Minimum, this._Maximum)));
}
}
2 Answers 2
I would check the result of range
range = _RemoveSpaces.Replace(range, string.Empty);
against string.Empty
because that is one of the cases where the code reaches this.
// No idea what we were given, give a completely open range. return new Range<T>(null, null);
Returning early will get you some performance here. Doing a null
check on range
before that call isn't necessary because Regex.Replace()
throws a ArgumentNullException
for the case that range == null
.
Speaking about Regex.Replace
I would prefer string.Replace()
over Regex.Replace
because it seems faster. See: comparing regex replace string replace and stringbuilder replace which has better performance
One should not always do what can be done.
public override string ToString() { return (this._Minimum == null) && (this._Maximum == null) ? string.Empty : (this._Minimum == null ? string.Format("-{0}", this._Maximum) : (this._Maximum == null ? string.Format("{0}+", this._Minimum) : string.Format("{0} - {1}", this._Minimum, this._Maximum))); }
IMO this isn't readable at first glance so maybe some good old if..else
would be better. Having underscore prefixed membervariables and using this
is too much.
public override string ToString()
{
if ((_Minimum == null) && (_Maximum == null)) { return string.Empty; }
if (_Minimum == null) { return string.Format("-{0}", _Maximum); }
if (_Maximum == null) { return string.Format("{0}+", _Minimum); }
return string.Format("{0} - {1}", _Minimum, _Maximum)));
}
If you are using C# 6 you could use the string-interpolation operator $
instead of string.Format()
.
About Interval
vs Range
I would prefer Range
because Interval
IMO suits only with dates and times but e.g don't suits with int
.
__
Inside the Parse()
method I would check the found minimum
and maximum
before calling the constructor. IMO throwing an exception in a constructor should be avoided if possible.
In general you code looks tidy and clean.
- You are using good variables- and methodnames.
- You are using braces although they are optional.
- Your code is easy to read.
-
\$\begingroup\$ Excellent suggestions; thank you! I have incorporated those into my code. Though I wish I could use string-interpolation
$
and read-only auto properties. \$\endgroup\$Jesse C. Slicer– Jesse C. Slicer2015年11月04日 16:58:51 +00:00Commented Nov 4, 2015 at 16:58
In addition to what's been already said, I find the Parse
method way too long and that it does a little too much. Too make it easier I'd start with transforming the original string in a format that makes it easier to parse, for example by using only -->
to indicate the range. The intervals become something like:
--> a
to indicate values up toa
,a --> b
for values froma
tob
,b -->
to indicate values fromb
and up.
In order to transform the original string to an accepted string you just have to do some substitutions and then by splitting the accepted string by -->
you get the values.
So, to recap, in order to parse the string
you get in input you have to:
- Transform it from the original format (in this case
<digits>-<digits>
) into the format of your choice (in this case<digits> --> <digits>
), - Get the two
string
s representing the two sides of theRange
. - Parse the two
string
s and build the resultingRange
object.
In code this would be similar to the following (not tested, it's just to explain the concept):
// expected formats: "-a","--a","a-b","-a-b","-a--b","a--b","-b+","b+"
string ChangeFormat(string original)
{
string result = original;
int[] indicesOfDash = result.IndicesOf("-");
if(indicesOfDash.Length == 3)
{
// "-a--b"
result = result.Substitute("--", " --> -");
}
else if(indicesOfDash.Length == 2)
{
if(indicesOfDash[0] == indicesOfDash[1] - 1)
{
// "--a" or "a--b"
result = result.Substitute("--", " --> -");
}
else
{
// "-a-b"
result = result.Substitute("-", " --> ", startingFrom: 1);
}
}
else if(!result.Contains("+"))
{
// "-a","a-b"
result = result.Substitute("-", " --> ");
}
return result.Substitute("+", " --> ");
}
Range Parse(string original)
{
string rightFormat = ChangeFormat(original);
// the following 3 lines could be put in a new method in order
// to follow the single responsibility principle
string[] elements = rightFormat.Split("-->");
string left = elements[0].Trim();
string right = elements[1].Trim();
return new Range(ConvertTo<T?>(left), ConvertTo<T?>(right));
}
Let me know if anything's unclear.
-
\$\begingroup\$ From my question: "I hate that the dash means negative and the range separator, but it is what I'm given." The string given is from another party I have no control over. \$\endgroup\$Jesse C. Slicer– Jesse C. Slicer2015年11月04日 18:38:57 +00:00Commented Nov 4, 2015 at 18:38
-
\$\begingroup\$ @JesseC.Slicer That's the string you should transform to the "accepted" string. The format I suggested is the format you parse. The string given from the other party is just transformed to this intermediate format. In this way, if the other party decides to add another format to the game you just transform it into the intermediate format and work with it. Also, the parse method gets significantly easier because you have just digits and the
-->
symbol to work with. \$\endgroup\$Gentian Kasa– Gentian Kasa2015年11月04日 18:42:33 +00:00Commented Nov 4, 2015 at 18:42 -
\$\begingroup\$ But then that makes the transformation approximately as complex as the parse, no? \$\endgroup\$Jesse C. Slicer– Jesse C. Slicer2015年11月04日 19:10:30 +00:00Commented Nov 4, 2015 at 19:10
-
\$\begingroup\$ @JesseC.Slicer, not really. In that case you just have to replace the right
-
and/or+
with-->
. I'll add an example to the answer as soon as I can. \$\endgroup\$Gentian Kasa– Gentian Kasa2015年11月04日 20:53:47 +00:00Commented Nov 4, 2015 at 20:53 -
\$\begingroup\$ @JesseC.Slicer I added some pseudo code to better explain the concept. \$\endgroup\$Gentian Kasa– Gentian Kasa2015年11月04日 21:35:07 +00:00Commented Nov 4, 2015 at 21:35
IEnumerable
? \$\endgroup\$_RemoveSpaces
and_SplitOnDash
to a separateinternal static class
so that there isn't a copy of these made for every variation of<T>
. \$\endgroup\$