F# tips weekly #11: Active patterns (2)

F# tips weekly #11: Active patterns (2)

Continuing from the previous week, let's delve into some active pattern implementation details and advanced use cases.

Single-case active pattern

As mentioned in Tip #2, we can use a single-case active pattern to directly transform a value:

let (|FromNull|) = Option.ofObj

let workWithNull (FromNull maybeValue) = ...

It's worth noting that the use case in the definition is optional, for example:

let (|FromNull|) x = FromNull (Option.ofObj x)

is equivalent.

Another interesting case for a single-case active pattern is a pattern that only performs some side effect:

let (|Debug|) x =
    printfn "%A" x // or other logging

Then we can add debugging prints of function parameters by just adding one word. We can even improve it further by marking this pattern as obsolete to ensure we don't forget to remove it later.

let (|Debug|) x =
    printfn "%A" x // or other logging

This will generate a warning for each use of the pattern.

Call as a function

Active patterns not only look like standard functions, they actually are and can be called:

(|Match|_|) """Id=(\d+), Value=(\d+\.\d+)""" "Id=123, Value=3.1415"

This function has a type string -> string -> option<list<string>> as we would expect.

In the case of a complete active pattern, there is a little compiler magic involved. Consider this even/odd active pattern:

let (|Even|Odd|) x = if x % 2 = 0 then Even x else Odd x

We are using pattern cases Even and Odd in the definition as if they were cases of some discriminated union, but the function has the type int -> Choice<int, int>. Choice is a generic type for multiple cases. We can actually use it in the active pattern directly, and the result will be the same:

let (|Even|Odd|) x = if x % 2 = 0 then Choice1Of2 x else Choice2Of2 x

We can call this complete active pattern like this:

let isEven x = (|Even|Odd|) x |> function | Choice1Of2 _ -> true | Choice2Of2 _ -> false

As you can see, in the case of a complete pattern, it doesn't make much sense to call it as a function, as we need to pattern match on the result anyway.

List patterns

List patterns like :: and [] are very useful, but sometimes we wish for more powerful patterns.

For example, we might want to match on the end of a list:

let (|Reversed|) xs = List.rev xs

A fun example is checking for a palindromic list:

let rec isPalindrome xs =
    match xs with
    | [] -> true
    | [x] -> true
    | x :: Reversed(y :: xs) when x = y -> isPalindrome xs
    | _ -> false

Another useful pattern can be a variant of List.take.

let (|TakeN|_|) n xs =
    let rec loop acc n xs =
        match xs with
        | x :: tl when n > 0 -> loop (x :: acc) (n-1) tl
        | [] when n > 0 -> None
        | xs -> Some (List.rev acc, xs)  
    loop [] n xs

Note that this pattern returns both the first n items and the rest of the list. This is common when writing active patterns - not discarding any of the input so we can combine with more patterns.

To get the sixth item of the list:

match [1..10] with
| TakeN 5 (_, x :: _) -> x

To get the third from the back:

match [1..10] with
| Reversed(TakeN 2 (_, x :: _)) -> x

Similarly, we can implement active pattern variants of other List functions, like List.takeWhile.


Active patterns can also be generic:

let (|FromJson|) (x: string) = FromJson (System.Text.Json.JsonSerializer.Deserialize x)

match """{"Id": 1, "Name": "bob"}""" with
| FromJson ({ Id = a; Name = b } as r) -> printfn $"{r}"

Active pattern function calls order

Evaluation of match expressions is lazy - only the patterns that are needed to find the correct pattern are executed. Let's alter our parsing active patterns with logging:

let (|Int|_|) str =
   match System.Int32.TryParse(str: string) with
   | (true, x) -> 
        printfn "parsed %s as int %d" str x
   | _ -> 
        printfn "could not parse %s to int" str

let (|Float|_|) str =
   match System.Double.TryParse(str: string) with
   | (true, x) -> 
        printfn "parsed %s as float %f" str x
   | _ -> 
        printfn "could not parse %s to float" str

Then this expression

match "3.14" with
| Float f -> printfn "Float: %f" f
| Int i -> printfn "Int: %d" i

prints out

parsed 3.14 as float 3.140000
Float: 3.140000

the Int active pattern is not executed. Evaluation of match expression patterns ends when we find a matching case.

But laziness goes deeper than that; it works also for combined patterns:

match ["3.14"; "42"] with
| [Int i; Int j] -> printfn "Case 1"
| [Float f; Int i] -> printfn "Case 2"

prints out

could not parse 3.14 to int
parsed 3.14 as float 3.140000
parsed 42 as int 42
Case 2

We can see that when the first pattern in the list failed, the second one wasn't attempted, and we moved to the second case.