13

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
Daniel
  • 47,404
  • 11
  • 101
  • 179

4 Answers4

13

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.

Stephen Swensen
  • 22,107
  • 9
  • 81
  • 136
  • Just to clarify, I'm not trying to make the case that F# should treat enums differently, but when I only want to allow defined values I'm looking for creative ways to handle it. – Daniel May 20 '11 at 18:32
  • 2
    My biggest beef with using the wildcard pattern is not being warned when members are added to the enum. – Daniel May 20 '11 at 18:39
  • 1
    @StephenSwensen : Not that it diminishes the validity of your answer, it's worth noting that bitmask enums such as `BindingFlags` are indeed distinguishable through type information, as they have the [`Flags` attribute](http://msdn.microsoft.com/en-us/library/system.flagsattribute.aspx) applied to their definitions. – ildjarn May 20 '11 at 19:19
  • @ildjarn - neat, I didn't know that – Stephen Swensen May 20 '11 at 19:33
  • Just for grins, I created a [snippet](http://fssnip.net/4V) to validate enums, including flags. – Daniel May 20 '11 at 20:16
  • 2
    @Daniel - neat! augmenting your `Enum.unexpected` implementation to indicate whether the failure was due to members being added to the enum vs. a bad value would definitely get you closer to what you're after. – Stephen Swensen May 20 '11 at 20:43
10

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.

Daniel
  • 47,404
  • 11
  • 101
  • 179
4

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.

kvb
  • 54,864
  • 2
  • 91
  • 133
  • As I mentioned in response to Stephen's answer, I think the way F# handles enums makes perfect sense. I'm mostly wondering if there's some way to avoid (hide) the obligatory exception when pattern matching. – Daniel May 20 '11 at 18:35
  • 1
    To expand this a bit: Is it possible to pattern match on enums in such a way that the compiler notifies when new enum members are defined (as it would with a complete pattern match over a DU)? – Daniel May 20 '11 at 18:41
0

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.

Charles Roddie
  • 952
  • 5
  • 16
  • I disagree; in particular, new enum values can be added between different versions of a dependency and code that produces those new values is not "broken". Because .NET enums are extensible by design, forcing handling of "unknown" values really is a feature. – kvb Mar 06 '18 at 21:14
  • Let's compare (A) the current approach using the code in the OP, and (B) what would happen if F# didn't force handling of unknown values. And see how they deal with your problem of a dependency update adding to the enum. In each case, if you run the code ignoring warnings, there will be a runtime error on encountering the unknown enum. No difference there. What if you look out for warnings? (A) would not give a warning and would fail on runtime. (B) would give a warning and would lead you to update your pattern match. So (B) deals better with your situation. – Charles Roddie Mar 06 '18 at 22:06
  • No, you misunderstand my scenario - I'm envisioning that you compile your F# application against v1 of a library with an enum; then you update your dependency to v2 that adds a new value to an existing enum type _without recompiling_ (this is not generally considered a breaking change). Then if F# didn't warn, you could get a runtime error when the compiler indicated there was nothing that could go wrong with your code. – kvb Mar 07 '18 at 04:07
  • OK but that seems odd behavior to update a dependency without recompiling. And with the warning system you will also get a runtime error as you will have disabled the warning in some way. With the warning system you are alerted to the fact that something may go wrong (minimally possible) and have to make changes, but your changes typically don't prevent runtime errors in those situations, and make your code likely to fail in more common situations (the enum is changed, you recompile, but you don't update find the error because you have a workaround to the warning in place). – Charles Roddie Mar 08 '18 at 21:18
  • The current behavior is like a fire alarm system which, since no sensor is perfect, decides to warn all the time. The manufacturers know that the user will switch it off, but think that the act of switching off reminds the user that there could be a fire at any time. – Charles Roddie Mar 08 '18 at 21:27