Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Working around incomplete pattern matching on enums

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
like image 672
Daniel Avatar asked May 20 '11 17:05

Daniel


3 Answers

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

Example

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.

like image 190
Daniel Avatar answered Nov 16 '22 11:11

Daniel


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 System.Reflection.BindingFlags, which are used for bitmasking and yet indistinguishable through type information from simple enums, further complicating the picture 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).

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.

like image 13
Stephen Swensen Avatar answered Nov 16 '22 13:11

Stephen Swensen


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.

like image 4
kvb Avatar answered Nov 16 '22 13:11

kvb