Is it possible to enforce that a Record respects some invariants?
Asked Answered
S

3

27

Suppose I wanted to create a Record type that represents acceptable min/max bounds:

type Bounds = { Min: float; Max: float }

Is there a way to enforce that Min < Max? It is easy to write a validateBounds function, I was just wondering if there was a better way to do this.

Edit: I realized that for this specific example I could probably get away with exposing two properties and re-order the arguments, so let's say we were trying to do

type Person = { Name: string }

and Name needs to have at least one character.

Spanker answered 18/12, 2012 at 2:18 Comment(3)
Unless I am mistaken, Records don't give you an explicit constructor.Spanker
No, you'd need a stronger type system like the refinement types in F*. Code Contracts can enforce this invariant in C#, but AFAIK the Code Contracts contract checker still doesn't work correctly for F# projects.Draw
You may consider to switch to structs which also give you structural equality. Pattern matching can be facilitated through active patterns. The advantange is that it's easy to enforce variants using explicit constructors and then construct, etc.Hako
G
18

Here's another solution based on protection levels:

module MyModule =
    type Bounds = private { _min: float; _max: float } with
        // define accessors, a bit overhead
        member public this.Min = this._min
        member public this.Max = this._max
        static member public Make(min, max) =
            if min > max then raise (ArgumentException("bad values"))
            {_min=min; _max=max}

    // The following line compiles fine,
    // e.g. within your module you can do "unsafe" initialization
    let myBadBounds = {_min=10.0; _max=5.0}

open MyModule
let b1 = Bounds.Make(10.0, 20.0) // compiles fine
let b1Min = b1.Min
let b2 = Bounds.Make(10.0, 5.0) // throws an exception
// The following line does not compile: the union cases of the type 'Bounds'
// are not accessible from this code location
let b3 = {_min=10.0; _max=20.0}
// The following line takes the "bad" value from the module
let b4 = MyModule.myBadBounds
Gennagennaro answered 18/12, 2012 at 2:59 Comment(4)
That is clever - I didn't know you could make the "Constructor" private.Spanker
It is not written in MSDN, but if you try type Bounds = {private min: float}, the compiler outs: error FS0575: Accessibility modifiers are not permitted on record fields. Use 'type R = internal ...' or 'type R = private ...' to give an accessibility to the whole representation.Gennagennaro
@Mathias: Keep in mind you lose most of the benefits of records when the fields are private. Might as well use a class.Drexler
@Daniel: you do get to keep free comparison at least though. Throw in a Bounds active pattern like I showed for my class solution and this might be the best way to go.Bastogne
B
7

I think your best bet is a static member:

type Bounds = { Min: float; Max: float }
    with
        static member Create(min: float, max:float) =
            if min >= max then
                invalidArg "min" "min must be less than max"

            {Min=min; Max=max}

and use it like

> Bounds.Create(3.1, 2.1);;
System.ArgumentException: min must be less than max
Parameter name: min
   at FSI_0003.Bounds.Create(Double min, Double max) in C:\Users\Stephen\Documents\Visual Studio 2010\Projects\FsOverflow\FsOverflow\Script2.fsx:line 5
   at <StartupCode$FSI_0005>.$FSI_0005.main@()
Stopped due to error
> Bounds.Create(1.1, 2.1);;
val it : Bounds = {Min = 1.1;
                   Max = 2.1;}

However, as you point out, the big down-side of this approach is that there is nothing preventing the construction of an "invalid" record directly. If this is a major concern, consider using a class type for guaranteeing your invariants:

type Bounds(min:float, max:float) = 
    do
        if min >= max then
            invalidArg "min" "min must be less than max"

    with
        member __.Min = min
        member __.Max = max

together with an active pattern for convenience similar to what you get with records (specifically with regard to pattern matching):

let (|Bounds|) (x:Bounds) =
    (x.Min, x.Max)

all together:

> let bounds = Bounds(2.3, 1.3);;
System.ArgumentException: min must be less than max
Parameter name: min
   at FSI_0002.Bounds..ctor(Double min, Double max) in C:\Users\Stephen\Documents\Visual Studio 2010\Projects\FsOverflow\FsOverflow\Script2.fsx:line 4
   at <StartupCode$FSI_0003>.$FSI_0003.main@()
Stopped due to error
> let bounds = Bounds(1.3, 2.3);;

val bounds : Bounds

> let isMatch = match bounds with Bounds(1.3, 2.3) -> "yes!" | _ -> "no";;

val isMatch : string = "yes!"

> let isMatch = match bounds with Bounds(0.3, 2.3) -> "yes!" | _ -> "no";;

val isMatch : string = "no"
Bastogne answered 18/12, 2012 at 2:42 Comment(5)
I was considering this - it looks to me like a factory method. The one issue, though, is that nothing prevents a caller to bypass the Create method and instantiate an "invalid" record. Or am I missing something?Spanker
No, you are absolutely correct, the Create method could always be bypassed. It's certainly a big issue, enough that you may consider making the record private, or otherwise opting for more complex class type.Bastogne
Thanks for the response! It's funny, because I had similar gripes in the past with the C# struct and its always-present empty constructor, which also causes validation headaches.Spanker
And yes, it looks like a Class is a way to address the issue - at the cost of some of the Record benefits, like out-of-the-box value-wise equality, etc...Spanker
You're welcome! Indeed, the active pattern gets us part way there, but not all the way.Bastogne
V
-1

A dodgy solution for the string example - use a DU

type cleverstring = |S of char * string

This will force the string to have at least one charcter. Then you can just use cleverstring instead of string in your record, although you probably want to write some wrapper functions to make it look like a string.

Varela answered 18/12, 2012 at 2:50 Comment(2)
Oh - that is deliciously ugly :)Spanker
It actually makes slightly more sense when you use it in a parserVarela

© 2022 - 2024 — McMap. All rights reserved.