Programowanie obiektowe w F#

F# jest językiem głównie funkcyjnym, ale działa w oparciu o platformę .NET, która jest zorientowana obiektowo. Jeśli piszemy kod w F# do użycia w F# to nie potrzebujemy zbytnio klas i interfejsów, ale jeśli chcemy wykorzystać fsharpową bibliotekę w C# to musi ona udostępnić klasy. Więc powiemy dziś sobie o klasach.

Deklarowanie klas

Przejdziemy teraz krok po kroku przez deklarację klasy i jej atrybutów. Najpierw kawałek kodu

type MojaKlasa(x:int, y:int) =
    let mutable para = x, y
    let version = "1.0"
    
    let suma () = (fst para) + (snd para)

    do printfn "Nowa instancja mojej klasy."

    member this.X 
        with get () = fst para
        and set value = para <- (value, snd para)

    member this.Y
        with get () = snd para
        and set value = para <- (fst para, value)

    member this.Suma () = suma ()
    member this.IsGreaterThan a b =
        let c, d = para
        c > a && d > b

Tyle wystarczy na początek. Idziemy po koleji:

type MojaKlasa(x:int, y:int) =

Mamy tutaj deklarację nowego typu o nazwie MojaKlasa, którego domyślny konstruktor ma dwa parametry: x i y, oba typu int.

    let mutable para = x, y
    let version = "1.0"

Następnie deklarujemy zmienną para, której przypisujemy krotkę (x, y) wartości z naszego konstruktora. Do tego mamy stałą version = "1.0". Są to deklaracje lokalne, więc będą one prywatnymi polami naszej klasy.

    let suma () = (fst para) + (snd para)

Dalej mamy deklarację lokalnej (prywatenej) funkcji suma, która zwraca sumę pierwszego i drugiego elementu krotki.

    do printfn "Nowa instancja mojej klasy."

Teraz zauważmy, że nasz konstruktor przyjmuje dwa parametry, ale gdzie jest jego ciało? Otóż jest nim ciało klasy. Deklarowanie zmiennych i stałych jest również ich inicjalizowaniem w konstruktorze na odpowiednią wartość. Jeśli zaś chcemy wykonać kawałek kodu to użyjemy słowa kluczowego do. W tym wypadku wypiszemy kawałek tekstu na konsolę.

Jeśli będziemy chcieli się odwołać do metod lub własności naszej klasy, które są deklarowane niżej, to również możemy to zrobić w sekcji do, ale jeśli odwołujemy się do zmiennych/stałych/funkcji lokalnych to wymagana jest kolejność zapisu - to do czego się odwołujemy musiało być wcześniej zadeklarowane.

    member this.X 
        with get () = fst para
        and set value = para <- (value, snd para)

Tutaj widzimy publiczną własność X wraz z funkcjami get i set. Deklaracje elementów instancyjnych wymagają słówka member, a następnie #.Nazwa, gdzie Nazwa jest nazwą własności lub metody, a # to dowolne słowo (tutaj użyłem this), ale musi być to to samo słowo w całej klasie.

Więc, jak widzimy, własność deklarujemy przez podanie nazwy, a następnie użycie with z definicją metody get, a potem and - przedłużenie with - z definicją metody set.

    member this.Suma () = suma ()
    member this.IsGreaterThan a b =
        let c, d = para
        c > a && d > b

Podobnie jak własność deklarujemy metodę, która będzie miała argumenty i definicję po znaku =.

Tworzenie i implementacja interfejsów

Czasem oprócz klas będziemy też tworzyli interfejsy, które w F# są dość proste:

type MojInterfejs =
    abstract member Metoda: unit -> int

    abstract member Własność: string
    abstract member Własność: string with set

Czyli tworząc interfejs tworzymy typ, który posiada abstrakcyjnych członków (ang. member) o odpowiedniej sygnaturze typu. Wydaje mi się, że jest to dość proste.

Teraz będziemy chcieli ten interfejs zaimplementować:

type MojaImplementacja() =
    let mutable a = ""

    interface MojInterfejs with
        member this.Metoda() = 10

        member this.Własność
            with get() = a
            and set value = a <- value

Co ciekawe, to że implementacje interfejsów są w F# explicite. Tzn. można użyć metod i własności z interfejsu tylko po przerzutowaniu wartości na typ tego interfejsu

let a = MojaImplementacja()
let d = a.Metoda()          // błąd
let b = a :> MojInterfejs   // :> to operator rzutowania
let d = b.Metoda()          // ok -> 10
Klasy abstrakcyjne

Klasy abstrakcyjne to klasy nie posiadające lub częściowo posiadające implementacje swoich metod. Nie można utworzyć instancji klasy abstrakcyjnej, a należy po niej dziedziczyć. Definujemy ją przez połączenie interfejsu i klasy oraz odpowiedni atrybut:

[<AbstractClass>]
type KlasaAbstrakcyjna() =
    let a = 10
    abstract member P: unit -> int

Kiedy dziedziczymy po dowolnej klasie używamy słowa kluczowego inherit wraz z konstruktorem klasy bazowej, a następnie musimy zrobić override wszystkich niezaimplementowanych metod.

type KlasaDziedziczaca() =
    inherit KlasaAbstrakcyjna()

    override this.P () = 1

Aby utworzyć funkcję wirtualną, czyli taką, która posiada implementację, ale pozwala na zmiany, użyjemy słowa kluczowego default (można zamiennie użyć też override):

type KlasaZWirtualnaMetoda() =
    abstract member M: unit -> unit
    default this.M() = printfn "Metoda"
Modyfikatory dostępu

Jak na razie mieliśmy prywatne zmienne i stałe oraz publiczne metody i własności. Możemy również mieć metody i własności prywatne lub wewnętrzne (internal):

type AccessibilityExample() = 
    member this.PublicValue = 1
    member private this.PrivateValue = 2
    member internal this.InternalValue = 3

Na razie nie ma możliwości tworzenia własności protected aby były widoczne dla podklas.

Automatyczne własności

Widzieliśmy wcześniej jak rozpisać własność z get i set (jeśli chcemy tylko jedno to usuwamy drugie), a co jeśli chcemy mieć to zrobione automatycznie?

Do tego posłuży nam trochę inna składnia:

type Auto() =

    // automatyczna własność {get;}
    member val X = 1

    // automatyczna własność {get; set;}
    member val Y = "Ala" with get, set

Zauważmy, że musimy podać wartość początkową naszej automatycznej własności, tak jak musimy podać wartość kiedy piszemy let. Dzieje się tak, ponieważ w F# nie ma czegoś takiego jak null (albo nie chcemy żeby było) i wszystkie zmienne/stałe/własności muszą mieć wartość.

Więcej konstruktorów

Dotychczas klasy miały tylko jeden konstruktor, ten główny, podany przy nazwie. Mówię główny, bo taki właśnie jest. Każdy inny konstruktor będzie to specjalna funkcja new(...), która wywołuje na koniec inny konstruktor, stworzony lub główny. Może przykład to wyjaśni:

type Konstruktory(a, b:int) =
    member val Prop = b with get, set

    new() = Konstruktory("", 1)
    new(a) = Konstruktory(a, 0)
    new(w:bool) =
        do printfn "Under construction..." 
        Konstruktory(w.ToString())

Jak widzimy new(w) wywołuje konstruktor new(a), który wykonuje konstruktor główny.

Rozszerzanie rekordów i unii

Zauważmy, że deklarując unię albo rekord używamy słowa kluczowego type. Dzieje się tak, ponieważ w rzeczywistości tworzymy nową klasę, która ma pewien wzorzec i jest według niego i naszej customizacji generowana przez kompilator. Dzięki słówku with możemy więc rozszerzyć nasz typ o metody i własności:

type Name = {Name:string}
    with
        member this.Size() = this.Name.Length

type Tree = Leaf | Node of int * Tree
    with
        member this.Child = 
            match this with 
                | Node (x, t) -> t
                | _ -> Leaf

Teraz po utworzeniu danego typu możemy korzystać również z jego członków (members).

Wideo

Na koniec wideo z poniedziałkowego spotkania Grupy .NET MIMUW, na którym te rzeczy omawialiśmy.