Reputation: 47904
Are there any creative ways to work around .NET's "weak" enums when pattern matching? I'd like them to function similarly to DUs. Here's how I currently handle it. Any better ideas?
[<RequireQualifiedAccess>]
module Enum =
let unexpected<'a, 'b, 'c when 'a : enum<'b>> (value:'a) : 'c = //'
failwithf "Unexpected enum member: %A: %A" typeof<'a> value //'
match value with
| ConsoleSpecialKey.ControlC -> ()
| ConsoleSpecialKey.ControlBreak -> ()
| _ -> Enum.unexpected value //without this, gives "incomplete pattern matches" warning
Upvotes: 13
Views: 2738
Reputation: 947
This is a minor annoyance of the F# language, not a feature. Invalid enums are possible to create, but that doesn't mean that F# pattern matching code should have to deal with them. If a pattern match fails because the enum took a value outside of the defined range, the error is not in the pattern match code but in the code that generated the meaningless value. Therefore there is nothing wrong with a pattern match on an enum that does not account for invalid values.
Imagine if, by the same logic, F# users were forced to do a null check every time they came across a .Net reference type (which can be null, just like an enum can store an invalid integer). The language would become unusable. Fortunately enums don't come up as much and we can substitute DUs.
Edit: this issue is now solved by https://github.com/dotnet/fsharp/pull/4522, subject to users adding #nowarn "104" manually. You will get warnings on unmached defined DU cases, but no warning if you have covered them all.
Upvotes: 0
Reputation: 47904
Following the suggestion Stephen made in the comments to his answer, I ended up with the following solution. Enum.unexpected
distinguishes between invalid enum values and unhandled cases (possibly due to enum members being added later) by throwing a FailureException
in the former case and Enum.Unhandled
in the latter.
[<RequireQualifiedAccess>]
module Enum =
open System
exception Unhandled of string
let isDefined<'a, 'b when 'a : enum<'b>> (value:'a) =
let (!<) = box >> unbox >> uint64
let typ = typeof<'a>
if typ.IsDefined(typeof<FlagsAttribute>, false) then
((!< value, System.Enum.GetValues(typ) |> unbox)
||> Array.fold (fun n v -> n &&& ~~~(!< v)) = 0UL)
else Enum.IsDefined(typ, value)
let unexpected<'a, 'b, 'c when 'a : enum<'b>> (value:'a) : 'c =
let typ = typeof<'a>
if isDefined value then raise <| Unhandled(sprintf "Unhandled enum member: %A: %A" typ value)
else failwithf "Undefined enum member: %A: %A" typ value
type MyEnum =
| Case1 = 1
| Case2 = 2
let evalEnum = function
| MyEnum.Case1 -> printfn "OK"
| e -> Enum.unexpected e
let test enumValue =
try
evalEnum enumValue
with
| Failure _ -> printfn "Not an enum member"
| Enum.Unhandled _ -> printfn "Unhandled enum"
test MyEnum.Case1 //OK
test MyEnum.Case2 //Unhandled enum
test (enum 42) //Not an enum member
Obviously, it warns about unhandled cases at run-time instead of compile-time, but it seems to be the best we can do.
Upvotes: 11
Reputation: 22297
I think in general this is a tall order, exactly because enums are "weak". ConsoleSpecialKey
is a good example of a "complete" enum where ControlC
and ControlBreak
, which are represented by 0 and 1 respectively, are the only meaningful values it can take on. But we have a problem, you can coerce any integer into a ConsoleSpecialKey
!:
let x = ConsoleSpecialKey.Parse(typeof<ConsoleSpecialKey>, "32") :?> ConsoleSpecialKey
So the pattern you gave really is incomplete and really does needs to be handled.
(not to mention more complex enums like edit: actually, @ildjarn pointed out that the Flags attribute is used, by convention, to distinguish between complete and bitmask enums, though the compiler won't stop you from using bitwise ops on an enum not marked with this attribute, again revealing the weakens of enums).System.Reflection.BindingFlags
, which are used for bitmasking and yet indistinguishable through type information from simple enums, further complicating the picture
But if you are working with a specific "complete" enum like ConsoleSpecialKey
and writing that last incomplete pattern match case all the time is really bugging you, you can always whip up a complete active pattern:
let (|ControlC|ControlBreak|) value =
match value with
| ConsoleSpecialKey.ControlC -> ControlC
| ConsoleSpecialKey.ControlBreak -> ControlBreak
| _ -> Enum.unexpected value
//complete
match value with
| ControlC -> ()
| ControlBreak -> ()
However that's akin to simply leaving the incomplete pattern match case unhandled and suppressing the warning. I think your current solution is nice and you would be good just to stick with it.
Upvotes: 13
Reputation: 55185
I'd argue that it's a feature of F# that it forces you to handle unexpected values of an enum (since it is possible to create them via explicit conversions, and since additional named values may be added by later versions of an assembly). Your approach looks fine. Another alternative would be to create an active pattern:
let (|UnhandledEnum|) (e:'a when 'a : enum<'b>) =
failwithf "Unexpected enum member %A:%A" typeof<'a> e
function
| System.ConsoleSpecialKey.ControlC -> ()
| System.ConsoleSpecialKey.ControlBreak -> ()
| UnhandledEnum r -> r
Here the process of matching against the UnhandledEnum pattern will throw an exception, but the return type is variable so that it can be used on the right hand side of the pattern no matter what type is being returned from the match.
Upvotes: 4