Generic type constraints by example - Part 2

07 Oct 2014

Introduction

Last time we looked at some of the ways in which generic type constraints can be constrained in F#. This time we'll cover the remaining type constraint mechanisms.

Table of Contents

  1. Equality and comparison constraints
  2. Delegate constraints
  3. Unmanaged constraints
  4. Null constraints
  5. Combining multiple constraints

Equality and comparison constraints

Using the equality constraint will restrict the type to any type that is not decorated with the NoEquality custom attribute. Even types that do not define their own equality implementation will satisfy this constraint.

[<NoEquality; NoComparison>] type Foo(x : int) = class end

type ClassDef<'T when 'T : equality>() = class end

let FuncDef( a : 'a when 'a : equality) = ()

let usage = 
    // Any type is Ok here, even if it doesnt support equality
    new ClassDef<string>() |> ignore
    new ClassDef<System.IO.StreamReader>() |> ignore

    FuncDef("")

    // This however will not compile
    new ClassDef<Foo>() |> ignore
    ()

The comparison constraint is very similar except that the type must implement IComparable, be an array with comparable elements, be System.IntPtr, be System.UIntPtr and must not be decorated with the NoComparison custom attribute.

[<NoEquality; NoComparison>] type Foo(x : int) = class end

type ClassDef<'T when 'T : comparison>() = class end

let funcDef( a : 'a when 'a : comparison) = ()

let usage = 
    // Any type is Ok here, even if it doesnt support equality
    new ClassDef<string>() |> ignore

    // This time we can't use StreamReader because it does not implement
    // IComparable
    //new ExampleClassX<System.IO.StreamReader>() |> ignore

    funcDef("")

    // This however will not compile
    new ClassDef<Foo>() |> ignore
    ()

We can also restrict types to being both equatable and comparable

type ClassDef<'T when 'T : comparison and 'T : equality>() = class end

let funcDef( a : 'a when 'a : comparison and 'a : equality) = ()

Delegate constraints

Probably what will be the least used and most obscure of the constraints. This constraint doesn't actually do what you might want it to. When originally writing this post I assumed that a delegate constraint would allow you to restrict the type parameter to an arbitrary delegate and then be able to invoke it from the function body. It turns out this was a spurious assumption. Here's my completely incorrect assumption about how the delegate constraints could be used:

(*
    NOTE: this will not compile, it merely demonstrates my assumptions that
    turned out to be incorrect
*)
type Foo = delegate of (string * string) -> unit

let funcDef(a : 'a when 'a : delegate<(string * string), unit>) =     
    a("a", "b")
    ()

let usage =
    funcDef(Foo(fun a b -> printf "%s" (a + b)))

The compiler complains about "Foo not being a standard delegate type". If we take a look at the compiler source we can see that the first parameter of the delegate must be of type object and also that the first delegate input parameters cannot be wrapped in (). F# treats the following delegate definitions differently

type Foo = delegate (String * String) -> unit

type Foo' = delegate String * String -> unit

F#'s definition of a "standard" delegate type is (from the source)

interpret a delegate type as a "standard" .NET delegate type associated with an event, with a "sender" parameter.

Armed with this knowledge we can try and redefine our types and constraints to conform to the "standard delegate" format.

type Foo = delegate of obj * String -> unit

let funcDef(a : 'a when 'Del : delegate<obj * string,unit>) =     
    a("a", "b")
    ()

let usage = 
    funcDef(Foo(fun a b -> printf "Output %s %s" (a.ToString()) b))
    ()

Unfortunately, this is still incorrect, for a number of different reasons. First, when declaring the delegate constraint we must omit the obj parameters. Even though we must supply the obj parameter when invoking the delegate we do not include it in the type constraint. Secondly, we must also constrain the parameter to be derived from the System.Delegate base type so that we can use the DynamicInvoke method. Once we make these required changes we'll end up with the following, which does successfully compile and the correct delegate is invoked:

// Our delegate MUST start have obj as the first argument. The arguments MUST be
// tupled. The argument MUST NOT be wrapped in parentheses.
type Foo = delegate of obj * string * string -> unit

// When defining the constraint we MUST omit the obj parameter. We MUST also
// constrain to the System.Delegate base type. This is because the only way to
// invoke the delegate is via the DynamicInvoke method.
let funcDef(a : 'a when 'Del : delegate<string * string,unit> and  'a :> System.Delegate ) =     
    a.DynamicInvoke(([| "a"; "b"; "c"|] : obj[])) |> ignore
    ()

let usage = 
    funcDef(Foo(fun a b c -> printf "Output %s %s %s" (a.ToString()) b c))
    ()

Unmanaged constraints

Unmanaged constraints will constrain the type to be an "unmanaged" type. More specifically, this is any primitive type (sbyte, byte, char, nativeint, unativeint, float32, float64, int16, uint16, int32, uint32, int64, uint64, decimal), any enumeration type, any non-generic structure with fields consisting only of unmanaged types, and finally, nativeptr<_>

type ClassDef<'T when 'T : unmanaged>(x : 'T) = class end

let funcDef(a : 'a when 'a : unmanaged) = ()

let usage = 
    new ClassDef<int>(1) |> ignore
    funcDef 1uy

Null constraints

Null constraints will constrain a generic type such that the type must be nullable. This includes all .NET reference types but does not include F# lists, types, functions, classes, records or unions.

type Foo() = class end

type ClassDef<'T when 'T : null>() = class end

let funcDef(a : 'a when 'a : null) = ()

let usage = 
    new ClassDef<String>() |> ignore
    funcDef (new System.IO.StringReader(""))

    // This is not valid though
    new ClassDef<Foo>() |> ignore
    funcDef []

Combining multiple constraints

It's possible to mix and match the above constraints in different ways.

// 'a must derive from interface IFoo, support equality and have a parameterless
// constructor.
let example1 (a : 'a when 'a :> IFoo and 'a :> equality and 'a : (new : unit -> 'a))= ()

As I've noted in previous sections, it's possible to constrain a type parameter such that the constraint can never be fulfilled. It's easy to see if a type parameter is accidentally impossibly constrained so it's easy to fix but it's worth keeping it in mind.

Published on 07 Oct 2014 permalink Find me on Github

comments powered by Disqus