Unikanie cyklicznych zależności

15 Marca 2017

Zależności określają, jak klasa lub moduł wykorzystuje inną klasę lub moduł. W zakresie globalnym biblioteki mają zależności, korzystając z funkcjonalności innych bibliotek. Tylko te zależności mogą się rozrosnąć i wprowadzić dodatkową złożoność w naszej aplikacji.

W F# nie można tworzyć cyklicznych zależności ze względu na kolejność kompilacji kodu. Ale jak ich omijać podczas programowania?

W moim przypadku, postanowiłem stworzyć klasę Scene, która odpowiada za zawartość ekranu i zachowanie. Czyli mam jedną konsolę i tylko wymieniam sceny. No fajnie, warto żeby konsola zaczynała się sceną startową.

type MainConsole(width, height) =
    inherit Console(width, height)

    member val Scene : Scene = StartScene() :> Scene
        with get, set

Tu klasa Scene jest abstrakcyjna, a StartScene po niej dziedziczy implementując pożądaną logikę (wyświetlenie welcomeScreen).

Niby wszystko ok, Scene i StartScene muszą być zadeklarowane przed MainConsole zgodnie z kolejnością kompilacji. Ale co jeśli chcę, aby podczas wywołania metody ProcessKeyboard scena startowa ma zmienić scenę na inną?

[<AbstractClass>]
type Scene(console) =
    member this.Console = console
    abstract member ProcessKeyboard: KeyboardInfo -> unit

//...

type MainConsole(width, height) as this =
    inherit Console(width, height)

    member val Scene : Scene = StartScene(this) :> Scene
        with get, set

W tym momencie kompilator zgłasza błąd, ponieważ mamy cykliczną zależność. Kompilując klasę Scene nie wiemy nic o MainConsole.

Co tu zrobić…

Scott Walschin proponuje np. zastosowanie generyczności w pierwszej z klas, a następnie utworzenie konkretnego typu Scene<MainConsole> na samym dole, kiedy mamy już informacje o obu klasach. To spowoduje, że kod będzie działał, ale nadal mamy brzydką zależność (pośrednią).

Rozwiązanie I

Zacząłem ostro główkować, jak zmienić mój kod, żeby tę cykliczną zależność usunąć i pierwsze na co wpadłem to zmiana sposobu interakcji Scene z klasą MainConsole. Po co Scene ma mieć dostęp do własności konsoli? Niech ProcessKeyboard zwraca None jeśli nie zmieniamy sceny i Some newScene jeśli zmieniamy.

[<AbstractClass>]
type Scene() =
    abstract member ProcessKeyboard: KeyboardInfo -> Scene option

//...

type MainConsole(width, height)=
    inherit Console(width, height)

    member val Scene : Scene = StartScene() :> Scene
        with get, set

    override this.ProcessKeyboard(keyboardInfo) = 
        match this.Scene.ProcessKeyboard(keyboardInfo) with
         | None -> ()
         | Some newScene -> this.Scene <- newScene
        true

Na razie działa. Ale można lepiej! Przecież piszę funkcyjnie, więc powinienem wykorzystać cechy tego paradygmatu.

Rozwiązanie II

Funkcyjne podejście mówi, że mamy separację wartości od zachowań. Dlatego Scene będzie prostym rekordem.

type SceneType = Start | Game

type Scene = {
                ConsoleObjects: GameObject list
                Type: SceneType
             }

Tak samo scena startowa to będzie tylko

let startScene = { Type = Start; ConsoleObjects = [welcomeScreen] }

Następnie nasze zachowania to będą funkcje Scene -> Scene. Kiedy dodam gracza i środowisko to dojdzie do Scene jeszcze nieco informacji.

Więc mamy funkcję update, która aktualizuje stan na podstawie czasu od ostatniej aktualizacji oraz funkcję processKeyboard, która dostaje oprócz sceny obecny stan klawiatury.

Przy okazji odkryłem błąd w kodzie SadConsole: jeśli nie korzystamy z Engine.ActiveConsole tylko z Engine.ConsoleRenderStack to metoda ProcessKeyboard w konsoli nigdy nie zostanie wywołana. MonoDevelop pozwala na przeglądanie zdekompilowanego kodu w ILu bądź w C#. Dzięki temu mogłem zrozumieć jak to w ogóle działa i napisać to zachowanie samodzielnie.

Więc ostatecznie moja konsola wygląda tak:

type MainConsole(width, height) =
    inherit Console(width, height)

    let mutable Scene = Scenes.startScene

    let processKeyboardAndUpdate =
        Scenes.processKeyboard (Engine.Keyboard)
        >> Scenes.update (Engine.GameTimeElapsedUpdate)

    override this.Update() =
        Scene <- processKeyboardAndUpdate Scene

    override this.Render() =
        Scene.ConsoleObjects
        |> List.iter (fun entity -> entity.Render())

Wyeliminowałem całkowicie cykliczne zależności jednocześnie upraszczając trochę kod. Moje funkcje są teraz bez efektów ubocznych i mogłem zastosować operator złożenia funkcji >>.

Aby dokładniej zapoznać się z technikami unikania zależności zobaczcie artykuł na F# for fun and profit.