Discussion: Constrained types #413
Replies: 18 comments
-
How exactly do you imagine this to be implemented? Are they "proper" types as far as the CLR is concerned? Or wrapping structs adorned with attributes describing what would be valid values? Do you have any proposed syntax for declaring a constrained type? |
Beta Was this translation helpful? Give feedback.
-
@scottdorman It's possible to create a system like this with generics now. I have a few useful classes in a library I develop:
The system uses the ideas from the concepts proposal:
So from your example of public struct D0 : Const<double> { public double Value => 0.0; }
public struct D360 : Const<double> { public double Value => 360.0; }
class DegreesArc : FloatType<DegreesArc, TDouble, double, Range<TDouble, double, D0, D360>>
{
public DegreesArc(double value) : base(value) {}
} From that you get a ton of functionality: var e = DegreesArc.New(361.0); // throws an ArgumentOutOfRangeException
var x = DegreesArc.New(90.0);
var y = DegreesArc.New(90.0);
var z = x + x + x + x + x; // throws an ArgumentOutOfRangeException
DegreesArc z = x / y; // all arithmetic operators implemented
bool b = x == y; // all equality and comparison operators implemented
DegreesArc r = from a in x // LINQ support for unwrapping the bound values
from b in y
select UseDoubles(a, b);
// The following from Float<A>
Abs, Signum, Min, Max, Exp, Sqrt, Log, Pow, LogBase, Sin, Cos,
Tan, Asin, Acos, Cosh, Tanh, Asinh, Acosh, Atanh
// As well as a standard set of functional operators
Bind, Exists, ForAll, Map, Select, Iter, Fold, FoldBack, Sum
Obviously a language feature that did this would be much more attractive (and I'd be interested in helping spec something like that - more type-safety is always good), but I figured that seeing as it's doable today, you may be interested in the approach. |
Beta Was this translation helpful? Give feedback.
-
What would be the benefits of having this over using a property that checks the assignment values in the setter and behaves appropriately (either returning the values to the allowed range or throwing)? |
Beta Was this translation helpful? Give feedback.
-
@sirgru I think now I've added the constraints to the type-aliases proposal, the arguments are pretty much the same I made there. Which is that types should be the point of truth about values; not relying on the mental model of programmers to inject validation at all usage points. |
Beta Was this translation helpful? Give feedback.
-
@scottdorman the compiler gives error if the value is not in range or you will have exception at run time? |
Beta Was this translation helpful? Give feedback.
-
Rather than broad "Constrained Type", I would go for more specific "Ranged Numeric Type". Because, with non-nullable references(I disagree with the word "type" there), most scenarios will be covered. Another constraint can be regex on strings. But that will be costly. And composite types like structs and classes can just use these two constraints with their members. Behavior: I think ranged numeric type should behave like the non-nullable references. So, range-less base types can interchanged and in CLR these ranges will not be there. But, during compile time, compiler would try to ensure if range constraints are met. For numeric literals or const assignments, it is easy. But for other cases, compiler will try to statically analyze if range conditions are met. If not sure, compiler will show warning about it. And just adding an Syntax: I think the syntax can be like //Example method with constrained parameter
int squareRoot(int<0..> num)
{
// do the calculation
return root;
}
// all cases are separate and independent of each other
squareRoot(0); //case 1: Ok. For numeric literals and const, compiler can check validity by itself
squareRoot(-1); //case 2: Warning
// For non-constants, for example userInput here
squareRoot(userInput); //case 3: Warning, compiler not sure
if(userInput >= 0 )
squareRoot(userInput); //case 4: Ok
if(userInput > 1000)
squareRoot(userInput); //case 5: Ok
if(userInput > -1 )
squareRoot(userInput); //case 6: Warning, compiler not sure
if(userInput < 0)
return;
squareRoot(userInput); //case 7: Ok And these ranged types can be renamed with aliasing, when available. |
Beta Was this translation helpful? Give feedback.
-
Constraints on numbers should cause the same behavior as currently happens when an invalid value or overflow occurs, both at compile time and run time. The only difference is where min and max occur. |
Beta Was this translation helpful? Give feedback.
-
@fanoI I think @bondsbw answered your question before I could. Yes, to both. If the compiler has enough information at compile time to know that the value is outside the valid bounds then it's a compiler error; if not, then it's an exception thrown at run time. @louthy Interesting approach, particularly in light of the fact that we don't have native language support right now. I'll have to look in to what you have there in more detail. You're right, though, that native support would be preferable. @sirgru Properties wouldn't work as well for this. The code becomes much more verbose/cluttered and it relies on everything going through the property accessor, including internal/private member access, and would only work if we could define properties that don't require an explicit backing field but that can contain logic. (There's a proposal already for that, although I don't recall the issue number.) By making these an intrinsic data type, you can define a new variable that is used without having to rely on everyone remembering to go through the property. @gulshan Yes, this is most immediately useful for numeric types. In Ada, if I remember correctly (it's been a long time since I've written any Ada code) it can only be used for numeric. I'd be fine with limiting this to only numeric types as well. I'm not sure I agree with the possibility of the range being open-ended. To me that sort of defeats the purpose and isn't supported in Ada either. @HaloFour I was imagining these to be proper types as far as the CLR is concerned as I think that's the only way to allow compile time checking. We could leverage attributes, but I don't think that would allow the compile time checking and would probably introduce too much overhead at runtime. As for syntax, I think something like this could work: // Original Ada line
// Li: Integer range 1..10;
int<1..10> Li;
// or
int range 1..10 Li;
// or even
int Li where range 1..10; The notion of doing a subtype would only work if #410 were implemented, so (using one of the possible syntax suggestions): // subtype Degrees_Arc is Integer range 0..360;
public alias Degrees_Arc = int <0..360>;
// or
public alias Degrees_Arc = int range 0..360;
// or even
public alias Degrees_Arc = int where range 0..360; |
Beta Was this translation helpful? Give feedback.
-
I think it is super useful to constrain primitive types to match the problem domain and have these types throughout your app rather than the more generic string, int, Guid, etc. If you see the value of having specific domain types for the big stuff like Person and Address and Product, it gets better when you have types for the little stuff like ZipCode, PhoneNumber, and ProductId. I completely agree with @louthy that "types should be the point of truth about values; not relying on the mental model of programmers to inject validation at all usage points". But I'm not sure we need a specific language feature for range-limited values since it appears you can build something equivalent and more customized using the clever approach @louthy uses. You could have an Email type that uses a Regex to validate. You could have type that only accepted even numbers. Or ranges that include the lower bound but exclude the upper bound. These ranges probably model some real-world domain and it is helpful to turn these concepts into full-blown types and not just implement them as "an integer in the range x to y". I'm not opposed to a new language feature that makes it easy to create range-limited values and there are problem a lot of uses for it. But I'd prefer to see a way to easily create fully-realized small validated domain objects. I probably wouldn't use the approach proposed by @louthy because (1) you can't associate a friendly type name to your type. It ends up looking something like One solution is to use an approach like @louthy suggests combined with some kind of limited struct "inheritance". When you subclass the struct you can give it a friendly name AND also attach helpful type-specific static and instance methods and maybe additional constructors. There are technical problems with struct inheritance that I don't understand. I'm not looking for virtual methods and other fanciness but rather a way to share the base-struct code with the derived struct. Maybe when you derive from a struct you can't add more fields or under-the-covers with reflection the derived struct doesn't look like it inherits from anything. Something like this.
|
Beta Was this translation helpful? Give feedback.
-
I created an little generic library to handle this exact situation. In my case, I wanted to move some responsibility for value constraints to the private backing field rather than in the public getter/setter of my properties. This prevents accidental illegal values from ever happening (say, if default is an illegal value, or if you bypass your setter and accidentally don't properly validate the value). It also allows the same constrained types to be reused, making it easier to compose rather than inherit properties with the same constraints across multiple types. https://github.com/richardtallent/RT.ValueFilter While having something at a language level to create a contract around a type's allowed values would be nice, my workaround has been working for me so far. |
Beta Was this translation helpful? Give feedback.
-
I made a similar proposal here #1198 |
Beta Was this translation helpful? Give feedback.
-
So, there are 4 possible ways to do it:
Sure we all want some syntax to simplify validation codes. |
Beta Was this translation helpful? Give feedback.
-
There is the 5th way -- wrapping the backing field of a property with a struct that protects it from being set or defaulted to an illegal value. That's the way the library I posted above handles it. There's a small memory cost of storing the delegate reference to filter the values, but it's one that could be removed by hand-coding the structs. Here's an example based on the library: public class LatLong {
private Filtered<int> lat = new Filtered<int>(v => v.Min(-90).Max(90));
private Filtered<int> long = new Filtered<int>(v => v.Min(-180).Max(180));
public int Latitude { get => lat; set => lat.Value = value; }
public int Longitude { get => long; set => long.Value = value; }
} Not all constraints to a range are contiguous. Constraints can involve complex pattern matching (not just regular expressions -- numbers with checksums, such as CAS numbers, are an example), or non-contiguous ranges (odd numbers, multiple date periods, etc.). This sort of approach allows any valid C# code to be used for the validation, and any action to be taken when a bad value is assigned (use the default, use the closest value, throw an exception, etc.). I do wish it were possible to do something more like the above without wiring up the backing property explicitly, but there are language roadblocks to various ways to do it more declaratively (lack of virtual struct methods, lack of static interfaces, lack of generic parameters for a delegate rather than a type, inability to specify a different internal type for an auto-implemented property, etc.). But at least with this approach, the logic can be reused readily, the overhead is low, and no changes are required to the language. |
Beta Was this translation helpful? Give feedback.
-
Regarding the syntax of ranged numeric type we can use the int in 0..360 angle1 = 0;
int in 0..360 angle2 = 0;
int in 0..360 add(int in 0..360 angle1, int in 0..360 angle2)
{
var result = angle1 + angle2;
if(result > 360)
result -= 360;
return result;
} As mentioned, like non-nullable references, the complier can show a warning when it cannot be sure if the range condition is met during assignment, by static analysis. As this is using a new syntax unlike non-nullable reference types, these can be even errors. And in case of errors/warnings, compiler should also offer a code-fix by adding a condition checking. The range is still limited to |
Beta Was this translation helpful? Give feedback.
-
Why not just use an attribute? Foo([In(0, 360)] int angle1) { } ?
All of that can be done today with attributes and analyzers. No need to wait years (or decades) for the language to do this. |
Beta Was this translation helpful? Give feedback.
-
That could work for your code. But it would not be enforced for third-party APIs or consumers of your APIs, and it would not benefit from the enhanced ecosystem that would come from language support. This really becomes useful when applied end-to-end, including UI and ORM support. Granted, that's a tall order. |
Beta Was this translation helpful? Give feedback.
-
Attributes won't work for local variables. And for return types, it looks different. |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
It would be nice to have the ability to create constrained types. These are typically (but not necessarily restricted to) numeric types that have well defined constraints on the upper and lower bound of legal values.
In Ada, these are defined using
range
:These are very useful for discrete mathematical operations that cannot go beyond certain boundaries. The best example I can come up with is software that controls a gimbal servo. The hardware has restrictions on how far it can turn before causing physical damage and the software has to take those bounds in to account by not allowing a variable to hold a value outside of those bounds.
This is different than the range operator being discussed in #198 but both of these could probably play well together.
Beta Was this translation helpful? Give feedback.
All reactions