F# - Funktionales Programmieren unter .NET

Informatik Seminar WS09/10 von Sören Militzer


Seminarthemen WS09/10 :: Einleitung | Grundlagen Funktionaler Programmierung | Datenstrukturen | Imperative Aspekte | Objektorientierte Aspekte


Objektorientierte Aspekte

Zugriff auf Objekte aus der .NET Welt

Sobald eine Sprache bzw. ein Framework eine gewisse Grenze überschritten hat, werden soviele Bibliotheken dafür entwickelt das man annehmen kann, dass es für jedes Problem bereits vorhandene Bibliotheken gibt. Diese Grenze hat das .NET Framework schon überschritten, demnach ist einer der Hauptvorteile von F# die Anbindung an die .NET Welt und die Möglichkeit diese bereitgestellte Funktionalität zu nutzen. Das folgende Codebeispiel definiert eine Funktion welche ein Fenster öffnet, in dem innerhalb einer Tabelle die Inhalte von Listen und Sequenzen angezeigt werden.

open System.Windows.Forms
open System.Drawing

let inspectSeq aSeq =
    let myForm = new Form(Visible = true, TopMost = true, Font = new Font("SegoiUI", 18.f))
    myForm.Text <- "Grüße aus der FSI"
    let dGrid = new DataGrid(Dock = DockStyle.Fill, Visible = true)
    myForm.Controls.Add(dGrid)
    dGrid.DataSource <- aSeq |> Seq.toArray

let myData = Seq.init 11 (fun x -> (x,x*x,x*x*x,x*x*x*x))

inspectSeq myData

Das Formular wird geöffnet, es wird im Formular ein DataGrid plaziert auf welchem dann die Daten präsentiert werden. In dem Codebeispiel fällt auf, das auf beschreibbare Eigenschaften von anderen Klassen auch mittels Zuweisungs-Operator "<-" zugegriffen wird. Als Zweites fällt auf, dass bei der Erzeugung eines Objektes wie dem Formular über benannte Argumente die initialen Werte von Eigenschaften übergeben werden könnne. Vorraussetzung für diese Möglichkeit ist die Existenz eines Konstruktors, welcher keine Parameter annimmt. Das obrige Beispiel für das Formular würde ohne diesen syntaktischen Zucker folgendermaßen aussehen:

let myForm = new Form()
myForm.Visible <- true
myForm.TopMost <- true
myForm.Font = new Font("SegoiUI", 18.f)


Definition von Klassen

Innerhalb dieser Ausarbeitung wurden bereits zwei Mal Klassen definiert, einmal in der Form von Records und einmal als Discriminated Unions. Bei der Verwendung dieser beiden Typen kann man auf einige Konstrukte zur Definition von Klassen zurückgreifen, wie zum Beispiel die Definition von Instanz- bzw. Klassenmethoden, auf andere Konstrukte aus der objektorientierten Welt kann im Zusammenhang mit diesen beiden Typen nicht zugegriffen werden. Beide Typen sind per se symetrische Typen, die Werte aus denen die Objekte dieser Typen konstruiert werden sind exakt die gleichen Werte, die auf diesen Objekten gespeichert werden, es ist nicht möglich in der Definition eines Records bzw. Discriminated Unions Felder zu definieren, die nur innerhalb des Objektes sichtbar sind. Außerdem ist es nicht möglich, bei diesen beiden Typen die Vererbungshierarchie explizit zu steuern bzw. zu verändern, ein Record ist immer eine versiegelte Klasse die von System.Object erbt, ein Discriminated Union besteht immer aus versiegelten konkreten Klassen die von einer abstrakten Klasse erben.

Wie jedoch schon angesprochen, können für Records und Discrimnated Unions Instanz- und Klassenmethoden definiert, welches im nächsten Beispiel bei der Definition von boolschen Ausdrücken näher beleuchtet werden soll.

type BoolExpr =
    | SimpleValue of bool
    | AndExpr of BoolExpr * BoolExpr
    | OrExpr of BoolExpr * BoolExpr
    | NegExpr of BoolExpr

    member be.Value =
        match be with
        | SimpleValue(sv) -> sv
        | AndExpr(ae1,ae2) -> ae1.Value && ae2.Value
        | OrExpr(oe1,oe2) -> oe1.Value && oe2.Value
        | NegExpr(ne) -> not ne.Value

    member be.ToString =
        match be with
        | SimpleValue(sv) -> System.Convert.ToString(sv)
        | AndExpr(ae1,ae2) -> "(" + ae1.ToString + " AND " + ae2.ToString + ")"
        | OrExpr(oe1, oe2) -> "(" + oe1.ToString + " OR " + oe2.ToString + ")"
        | NegExpr(ne) -> "!" + ne.ToString

    static member True = SimpleValue(true)
    static member False = SimpleValue(false)

Das Schlüsselwort "member" leitet also innerhalb einer Typendefinition die Definition einer Methode bzw. Eigenschaft ein. Ist die Methode eine Instanzmethode, so steht vor dem "member"-Schlüsselwort kein "static" und vor dem Methodennamen wird der Instanzenparameter (hier "be") angegeben, anhand dessen auf die aktuelle Instanz zugegriffen werden kann. Ist die Methode eine Klassenmethode, fehlt der Instanzparameter und das Schlüsselwort "static" steht vor dem "member".

In dem obrigen Beispiel wurden keine Methoden sondern nur Eigenschaften der jeweiligen Instanz bzw. Klasse definiert. Die Definition einer Eigenschaft und die Definition einer Methode unterscheiden sich nur in dem Punkt, das neben dem eventuellen Instanzenparameter zusätzlich noch weitere Parameter für die Methode angegeben werden.


Konstruierte Klassen

Da Records und Descriminated Unions symetrische Typen sind, macht es ihre Definition knapp und klar und es hilft das die beiden Typen noch weitere Eigenschaften dadurch erhalten: Der Compiler ist durch die klare Definition in der Lage selbstständig Funktionalitäten zur Bestimmung von Gleichheit bzw. zum Hashen der definierten Klassen automatisch zu implementieren.

Jedoch muss bei der objektorientierten Programmierung oftmals diese Symetrie gebrochen werden, um bestimmte Wege zur Problemlösung gehen zu können. So möchte man zum Beispiel in einer Klasse die einen 2D Vektor speichert nicht bei der Erzeugung des Vektors dessen Länge zur Konstruktion übergeben müssen, noch soll die Länge eines unveränderlichen Vektors jedes Mal neu berechnet werden. Ein symetrischer Typ kann diesen Anforderungen nicht genügen, also ist es zweckdienlicher auf eine generellere Notation der konstruierten Klassen zurückzugreifen. Das folgende Beispiel zeigt, wie der 2D Vektor mittels konstruierter Klasse implementiert werden könnte.

type Vector2D( x : float, y : float ) =
    let privLength = sqrt( (x * x) + (y * y) )
    member vec.X = x
    member vec.Y = y
    member vec.Length = privLength

    static member Zero = new Vector2D(0.0, 0.0)
    static member Identity = new Vector2D(1.0, 1.0)

Der Unterschied in der Schreibweise ist marginal, die Definition der Felder ist aus dem Definitionsrumpf in den Kopf der Definition gerutscht. Außerdem konnte nun ein privates Symbol innerhalb des Typs definiert werden, was bei den Records bzw. Descriminated Unions für eine Fehlermeldung seitens des Compilers geführt hätte da private Symbole innerhalb dieser Typen nicht zulässig sind.

Die Notation der konstruierten Klasse zu verwenden hat dazu geführt das die definierten Felder der Klasse nun privat sind und nur explizit über vom Programmier festgelegten Schnittstellen zugegriffen werden können. Das private Symbol "privLength" wird während der Erzeugung von Instanzen dieser Klasse berechnet, durch die Definition der beiden Felder im Kopf der Definition erhält die Klasse automatisch einen Standardkonstruktor, der die Werte für diese beiden Felder annimmt.


Vererbung

Durch die Notation der konstruierten Klassen ist es nun auch möglich, explizit den vererbenden Typ einer Klasse festzulegen.

type Rectangle(width : float, height : float) =
    member r.Width = width
    member r.Height = height

type Square(sideLen : float) =
    inherit Rectangle(sideLen,sideLen)

let mySqr = Square(2.0)

(mySqr.Width,mySqr.Height)

val it : float * float = (2.0, 2.0)

Der Vererbungausdruck, eingeleitet durch das "inherit"-Schlüsselwort muss der erste Teil einer Typdefinition sein. Wie man an dem Beispiel erkennen kann, muss bei der Defninition des vererbenden Types gleich definiert werden, welche Werte aus dem Standardkonstruktor des erbenden Types an den Standardkonstruktor des vererbenden Types weitergeleitet werden sollen.


Interfaces

Interfaces werden genauso wie konkrete Typen definiert, mit dem Unterschied das für Interfaces keine privaten Felder definiert werden sowie das die Methoden- und Eigenschaften Definitionen mit dem Schlüsselwort "abstract" beginnen.

type IShape =
    abstract member Position : Vector2D

Die durch das Interface definierte Eigenschaft soll nun in dem Rechteck und dem Quadrat implementiert werden.

type IShape =
    abstract member Position : Vector2D

type Rectangle(width : float, height : float, pos: Vector2D) =
    member r.Width = width
    member r.Height = height
    interface IShape with
        member r.Position = pos

type Square(sideLen : float, pos : Vector2D) =
    inherit Rectangle(sideLen, sideLen, pos)

let mySqr = Square(2.0, new Vector2D(3.,4.))

Durch die explizite Zuordnung einer Methode zu einem Interface kann man sogar in einer Klasse mehrere Interfaces implementieren, deren Eigenschaften und Methoden gleich heißen.