Chapter 10. Generics

WHAT'S IN THIS CHAPTER?

  • Understanding generics in F#

  • Using generic types

  • Applying type constraints

  • Working with statically resolved types

For the experienced C# or Visual Basic developer, this chapter will likely be simultaneously similar and yet maddeningly different from what's familiar in those languages. Generics have always played a key part in functional languages, thanks in large part due to the type inferenced nature of those languages, and as a result generics will be used far more often when writing F# code. This represents both a blessing and a curse: A blessing, in that code will often "silently" be more reusable and extensible than the C# or Visual Basic developer originally intended, but also a curse, in that some of the deeper and darker corners of generics and type systems, safely ignorable when writing object-oriented code, must now be confronted and programmers' demons slain.

BASICS

Normally, when developers write code, they use placeholders that will eventually contain values and manipulate those values in particular ways; we call those placeholders "variables" and, if those variables are part of a class instance, "fields." Generics, also known as parametric types, provide the ability for developers to write code using placeholders for the types of those variables or fields. Generics allow the compiler to continue to exercise full static type-checking, yet write classes that can be used with a variety of different types, all statically checked at compile-time.

In languages like C# and Visual Basic, writing genericized code for anything but the simplest of cases becomes frustrating and difficult, because those languages require explicit type descriptors that must be used anywhere a type is used. For example, a generic "queue" class, a first-in-first-out (FIFO) data structure that maintains an order of items pushed into it, would look something like this in C#:

public class Queue<T>
{
    public void Clear();
    public bool Contains(T t);
    public int Count { get; }
    public T Dequeue();
    public void Enqueue(T o);
    public T Peek();
}

Its cousin written in Visual Basic would look not much simpler, but with different punctuation marks:

Class Queue(Of T)
    Public Overridable Sub Clear()
    Public Overridable Function Contains(t As T) As Boolean
    Public Overridable ReadOnly Property Count As Integer
    Public Overridable Function Dequeue() As T
    Public Overridable Sub Enqueue(t As T)
    Public Overridable Function Peek() As T
End Class

Later, the developer may want to take that Queue and put a bunch of Person objects into it, which, because the Queue is instantiated with a Person type at its construction, means the compiler can ensure that only Person objects are put into the Queue. Any attempt to put a non-Person object into that Queue will fail at compile-time and thus prevent a bug from occurring.

Queue<Person> line = new Queue<Person>();
Line.Enqueue(new String("Will this fail?"));

For most C# and VB developers, the story around generics more or less ends here: Parameterized types provide type-safe collections, and beyond that, they get awkward and hard to use and thus rarely show up. For example, any attempt to put a Student (which derives from Person) into a Person-only Queue would work, but attempts to pass a Queue of Students where a Queue of Persons was expected would not, driving O-O developers mad.

Parameterized types have had a long, rich history in functional programming languages, and, thanks to type inference, often far exceed the O-O community in their usage. Because the compiler can infer generic types in some cases, sometimes the only thing to say about parameterized types is that they're there, and the developer need not worry about it beyond that.

But in many cases, F# code will want or need to make the parameterization more explicit, and the F# language provides some simple rules to do so.

Type Parameters

Creating a generic type is remarkably easy, and syntactically similar to what the C# developer already uses. To create the classic "stack" (LIFO) using generics, the type parameter is placed inside angle brackets after the type name declaration, and that parameter name can be used as a type substitute throughout the remainder:

type Stack<'T>() =
    let mutable data = []
    member this.Push(elem : 'T) =
        data <- elem :: data
    member this.Pop() =
        let temp = data.Head
        data <- data.Tail
        temp
    member this.Length() =
        data.Length
                                                          
Type Parameters

Note

Note that this is hardly the most efficient implementation, but it serves for a demonstration.

Notice that the type parameter is prefixed with a single-quote; this is a non-negotiable part of the type parameter. Historically (dating back to its OCaml days), just as C# prefers type parameters named "T" and "U", and so on, F# prefers type parameters named 'a and 'b, so idiomatically, the preceding code should be rewritten as:

type Stack<'a>() =
    let mutable data = []
    member this.Push(elem : 'a) =
        data <- elem :: data
    member this.Pop() =
        let temp = data.Head
        data <- data.Tail
        temp
    member this.Length() =
        data.Length

More than one parameter can appear in the type declaration, as long as they are separated by a comma:

type TwoArgGeneric<'a, 'b>(a : 'a, b : 'b) =
    let vA = a
    let vB = b
    override tag.ToString() =
        System.String.Format("TwoArgGeneric({0},{1})", a, b)

Creating an instance of the generic type is straightforward, using similar syntax as creating a nongeneric object instance, but passing a type argument in brackets:

let s1 = new Stack<System.String>()

In (almost) any expression where a type is expected, a type parameter can be used. Thus, for example, the type parameter can be used in a typeof expression to obtain the Type object for the type it represents:

type Reflector<'a>() =
    member r.GetMembers() =
        let ty = typeof<'a>
        ty.GetMembers()

Of course, genericizing the entire type here is a bit unnecessary, because the type itself is only used inside the GetMembers() method. In this case, the type parameter can be localized to the method itself, making it a method type parameter.

Member Type Parameters

Member type parameters are localized uses of generics, limiting the scope of the type parameter's use to the method on which it is declared:

type Reflector2() =
    static member GetMembers<'a>() =
        typeof<'a>.GetMembers()

Typical use of generic methods often require no special syntax, because type inference can often pick up the correct type to use from its context, but in those cases when an explicit type needs to be given (such as in the preceding code), put the bracketed type parameter between the method name and the argument list (if any):

let stringMembers = Reflector2.GetMembers<System.String>()

Of course, a type can have (different) type parameters at both the type scope and method scope, if wanted.

TYPE CONSTRAINTS

One thing the compiler has to be careful about is making promises it can't keep. For example, in the following code (which will not compile), the compiler can't be sure that this will work:

type InterestingType<'a>(data : 'a) =
    member it.DoIt() =
        data.DoSomething()

Specifically, because DoSomething() isn't a method that is guaranteed to appear on whatever type happens to be passed in for 'a when the InterestingType instance is created, the compiler can't be certain that the call will work. As a result, it fails to compile this code, complaining that "Lookup on Object of Indeterminate Type Based on Information Prior to This Program Point."

Several solutions present themselves. One is to eliminate the generic entirely and use traditional O-O techniques to handle this, by creating an interface that declares the DoSomething method, and force any types that are passed in to the InterestingType constructor to be instances of that interface:

[<Interface>]
type IDoSomething =
    abstract DoSomething : unit -> unit

type OOInterestingType(data : IDoSomething) =
    member it.DoIt() =
        data.DoSomething()

Doing this has some drawbacks, however, stemming from the fact that the type isn't known at compile-time — only a part of the type is known (it inherits from the IDoSomething interface). Although this is sufficient in this simple demo, it won't always do.

The second approach is to use a type constraint, which is a declaration, enforceable by the compiler, that must be met when used:

[<Interface>]
type IDoSomething =
    abstract DoSomething : unit -> unit

type InterestingType<'a when 'a :> IDoSomething>(data : 'a) =
    member it.DoIt() =
        data.DoSomething()

The when clause in the type parameter declaration tells the compiler that whatever type is passed in for this parameter must meet the requirement, which in this case, given the :> syntax, means that the 'a type must inherit from the named type (IDoSomething).

The following sections explore the many other kinds of type constraints that are enforced by the F# compiler.

Type Constraint

A type constraint, shown in the previous text, requires that the type argument inherit from the type specified, or if the type specified is an interface, that the type argument implement that interface.

Equality Constraint

The equality constraint insists that the type has the capability to be compared against other values of its type for equality:

type MustBeEquallable<'a when 'a : equality>(data : 'a) =
    member it.Equal(other : 'a) =
        data = other

This is a commonly used constraint, along with the comparison constraint, next.

Comparison Constraint

The comparison constraint insists that the type has support for doing comparison operations:

type MustBeComparable<'a when 'a : comparison>(data : 'a) =
    member it.Greater(other : 'a) =
        data > other
    member it.Lesser(other : 'a) =
        data < other

How the type implements its less-than or greater-than support is, of course, entirely up to the type in question, so long as it satisfies the basic signature of the two operations.

Null Constraint

A null constraint simply lists null in the constraint clause:

type MustBeNullable<'a when 'a : null>(data : 'a) =
    class
    end

When used, this tells the compiler that the type parameter must be "nullable," meaning the constant value null is an acceptable value for it. In .NET 4.0, this means every type (thanks to nullable types introduced in .NET 2.0) is acceptable; the only exceptions are the F# list, tuple, function, class, record, or union types.

Constructor Constraint

A constructor constraint, as its name implies, requires that a given constructor member be present and accessible:

type MustBeConstructible<'a when 'a : (new : unit -> 'a)> =
    member it.NewIt() =
        new 'a()

This will be particularly useful in situations where a given component needs to be instantiated within a particular context — rather than use Reflection to invoke a constructor, the constructor can be directly called with confidence (and better performance), because the compiler has ensured it is present.

Value Type and Reference Type Constraints

At times, it will be necessary to restrict acceptable types to either the set of value types or reference types:

type MustBeStruct<'a when 'a : struct>() =
    class
    end
type MustBeClass<'a when 'a : not struct>() =
    class
    end

Note that the not is not a general "reverse" of the type constraint but is a formal part of the reference type constraint. That is, we cannot write "not null" to create a "not-nullable" type constraint.

Other Constraints

Other constraint types (enumeration type constraints, delegate constraints, and unmanaged constraints) are available, but are marked by the F# documentation as Not Intended for Common Use. The explicit member constraint is also labeled as such, but its use has shown up enough in F# code and samples that knowing how to read it is a good idea, even if it's not recommended for casual use.

STATICALLY RESOLVED TYPE PARAMETERS

In certain cases, the F# compiler can eliminate the generic type parameter entirely and simply replace the type parameter with the actual type at compile time. These kinds of type parameters are indicated with the caret symbol (^) instead of the single-quote character when declaring the type parameter, and they are most commonly used with the type constraints listed in the previous section. In particular, they are used frequently within the F# library and for that reason should at least be readable by F# developers.

As the name implies, the major difference between statically resolved type parameters and "regular" type parameters is that statically resolved type parameters are replaced at compile time (much as C++ template parameters are), rather than used at runtime to instantiate the generic type, as "classic" .NET generics are. As a result, there are a few differences between "regular" generics and statically resolved type parameter generics.

For starters, statically resolved type parameters cannot be used on types — only methods and functions (described in Part III, "Functional Programming") can have statically resolved type parameters. So, for example, it is possible to write a generic function that does some odd or highly specialized math:

let inline (+@) (x : ^a) (y : ^a) =
    x + x * y

let result = 1 +@ 2
System.Console.WriteLine("result = {0}", result)

In addition, as the preceding example demonstrates, statically resolved type parameters can be used on inline functions, where "regular" generic parameters cannot. (Functions and inline functions are described more in Chapter 13.)

Because statically resolved type parameters are compile-time resolved, they allow for an additional generic constraint type.

Explicit Member Constraint

An explicit member constraint tells the compiler to ensure that a given member is present on the type, such as a method or property:

type MustBeDoItable<'a when 'a : (member DoIt : unit -> unit)>() =
    class
    end

This would be a possible replacement for the inheritance-based constraint used earlier, assuming the compile-time replacement was acceptable (instead of runtime replacement). In general, however, if there are multiple members that need to be specified, it's going to be easier to put those members into an interface and use that as the constraint.

SUMMARY

F#'s support for parameterized types is rich and powerful, particularly when combined with constraints and statically resolved type parameters, and makes writing reusable code just that much more powerful. But just writing reusable code is only part of the reusability story; the code must be packaged into a form that promotes reusability and reduces name conflicts, which is the subject of the next chapter.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset