In F# ist alles mit Ausdrücken (expressions) aufgebaut, wo andere Sprachen Statements (statements) verwenden. Diese Ausdrücke werden in folgender Reihenfolge ausgewertet:
von links nach rechts
von oben nach unten
Also nichts ungewöhnliches, genau wie die Deutsche Sprache geschrieben wird. D.h. alles was verwendet wird ist oberhalb oder in anderen Modulen (oder Assemblies) definiert.
Die einzige Ausnahme ist bei type member
, also Klassen Member, deren Reihenfolge willkürlich sein kann. Wieder mit der Ausnahme, dass der namenlose Konstruktor (also der, der nicht New
heisst) als erstes kommt.
In einem Projekt sind die Sourcefiles auch strikt hierarchisch geordnet. Also das unterste kann die oberen mit open
(analog C# using
) einbinden, nicht umgekehrt.
Was macht man, wenn es scheinbar nicht möglich ist so strikt zu strukturieren?
Wie geht das bei zirkulären Abhängigkeiten (circular dependency)?
Für Typen- type
und Funktions- let
Definitionen innerhalb eines Moduls gibt es das and
Konstrukt, das jeweils die abhängigen type
oder let
Schlüsselworte ersetzt. Dies erlaubt es wechselseitig Anhängige Definitionen zu bilden. So sind zirkuläre Definitionen möglich (ohne Vorausdeklarationen und Mutation).
Wieso ist das so strikt und stur?
Einerseits ist es so, dass es für den Compiler und vor allem für die Type Inferenz so sicher einfacher ist. Das könnte man als Nachteil sehen, dass man gezwungen wird den Aufbau so zu machen. Die Erkenntnis ist aber, dass ein so aufgebautes Software System sehr klar ist. Hierarchisch strikt durchschaubar. Keine Spagetti-hafte Kreuz Abhängigkeiten zwischen den Modulen. Was hier detailliert analysiert wird.
Zirkuläre Abhängigkeit von Komponenten verhindert die Schichten-Bildung (Layering) und ist somit schlechter testbar, wartbar, erweiterbar und wiederverwendbar.
Abhilfe für zirkuläre Abhängigkeiten
Die Tricks sind:
- Die Datentypen von den Funktionen trennen. Was genau die Umkehrung vom Objektorientierten Design ist, also die Memberfunktionen aus den Klassen reissen und die herausfallenden mutierenden Zustände vermeiden. Diese Funktionen generisch machen und so deren Wiederverwendbarkeit ermöglichen. Was bei Funktionaler Programmierung typischerweise der Fall ist, weil die Daten immutable sind, funktioniert das einfach so.
Daten sind Programmiersprachen unabhängig, Objekt Klassen nicht. Man denke an die Speicherung.
- Abstrahieren der Datentypen, möglichst alle Funktionen für generische Typen auslegen. Was in F# automatisch passiert, wenn man keinen Type spezifiziert.
Werte sind die Besten Interfaces.
Nicht aufgeben
Also wenn man etwas versucht das mit der strikten Hierarchischen Struktur nicht klar kommt, dann sollte man sich genau überlegen was es ist, das nicht in diese Form passt. Meist zeigt sich, dass es auch anders geht. Und so kommt es automatisch zu guten einfachen Designs.
Der Vorteil ist, dass man den Code so wie ein Buch lesen kann und die Reihenfolge ist logisch aufbauend auf schon bekanntem. Man muss nicht im Code oder den Komponenten herum springen um ihn zu lesen und zu verstehen.
Man kann es vom Kleinen zum Grossen lesen, oder umgekehrt beim Grossen beginnen um sich einen Überblick zu verschaffen und dann in die Details gehen. Man weiss wo man starten kann. Dies ermöglicht einem neuen Teammitglied den schnellen Einstieg in eine vorhandene Code-Basis. Man muss nicht erst Design Dokumentation lesen, falls solche vorhanden ist und sich mit dieser anfreunden oder erlernen.
Beispiel zu wechselseitig Anhängigen Typen
Die in diesem Beispiel voneinander abhängigen Typen sind Person und Ortschaft, da eine Person ein Wohnsitz hat und eine Ortschaft Einwohner hat.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: |
// Datentypen (Records) type Person = { name : string wohnort : Ortschaft } and Ortschaft = { name : string einwohner : Person list } // Funktionen let getPerson name ort = { Person.name = name; wohnort = { Ortschaft.name = ort; einwohner = [] } } let lebtInOrtschaft ortschaftName person = person.wohnort.name = ortschaftName let einwohnerVon ortschaftName personen = personen |> List.filter (lebtInOrtschaft ortschaftName) let getEinwohnerZahl (ort : Ortschaft) = ort.einwohner.Length let getOrtschaft ort personen = { name = ort einwohner = personen |> einwohnerVon ort } // Aufbau der Datenstruktur let Fritz = getPerson "Fritz" "Zürich" let Hans = getPerson "Hans" "Genf" let Max = getPerson "Max" "Genf" let Personen = [ Fritz; Hans; Max ] let Genf = getOrtschaft "Genf" Personen let Zurich = getOrtschaft "Zürich" Personen let Ortschaften = [Genf; Zurich] // Anwendung List.map getEinwohnerZahl Ortschaften // = [2; 1] |
Aufgabe
: Max zieht um von Genf nach Zürich. Wie bekommt man das hin, weil ja alles unveränderlich (immutable) ist?
Lösung
: Alle Veränderungen werden in diesem Beispiel hier mit angehängtem '
an den Wertenamen markiert. Das ist reine Dekoration, man könnte auch immer eine 2
anhängen. Wichtig ist, es entsteht ein neuer Wert, hier mit neuem Namen.
1: 2: 3: 4: 5: 6: 7: |
let Max' = { Max with wohnort = Zurich } let Personen' = [ Fritz; Hans; Max' ] let Genf' = getOrtschaft "Genf" Personen' let Zurich' = getOrtschaft "Zürich" Personen' let Ortschaften' = [ Genf'; Zurich' ] List.map getEinwohnerZahl Ortschaften' // = [1; 2] |
Jetzt könnte man meinen dies sei Speicherverschwendung, immer alles neu aufzubauen?
Der Code spezifiziert Was gemacht wird, nicht das Wie.
Es wird intern nicht alles neu Aufgebaut, nur die Änderungen. Die Liste ist eine spezielle Funktionale Datenstruktur, die weil ja alles immutable ist, intern auf die bereits existenten unveränderlichen Daten verweist. Somit ist die Liste natürlich auch Thread-Safe.
Siehe auch Vorteile Funktionale Datenstruktur und Chris Okasaki’s 1998 Buch “Purely functional data structures” und Chris Okasaki’s Blog
Grob gesagt nur die neue Mutation, also der Max'
benötigt zusätzlichen Speicher. Ein Vorteil ist so auch, dass man die ganze History immer vorhanden ist. Gewisse Anwendungsbereiche leben davon, wie z.B. Source Control, Bankkonten. Da wird die ganze Geschichte aufbewahrt und nicht einfach die Speicherstelle Kontostand verändert.
Dieses Beispiel konkurriert natürlich nicht mit speziell dafür (was immer genau die Anforderungen sind?) ausgelegten Datenstrukturen. Und F# kann auch Daten ändern, siehe ref
und mutable
, dazu später mehr.
Erinnern wir uns, das Beispiel zeigt wie man wechselseitig abhängige Typen definiert, erzeugt und handhabt.
{name: string;
wohnort: Ortschaft;}Full name: InterpretationderFAusdrücke.Person
val string : value:’T -> stringFull name: Microsoft.FSharp.Core.Operators.string
——————–
type string = System.String
Full name: Microsoft.FSharp.Core.string
{name: string;
einwohner: Person list;}Full name: InterpretationderFAusdrücke.Ortschaft
module Listfrom Microsoft.FSharp.Collections
——————–
type List<‘T> =
| ( [] )
| ( :: ) of Head: ‘T * Tail: ‘T list
interface IEnumerable
interface IEnumerable<‘T>
member Head : ‘T
member IsEmpty : bool
member Item : index:int -> ‘T with get
member Length : int
member Tail : ‘T list
static member Cons : head:’T * tail:’T list -> ‘T list
static member Empty : ‘T list
Full name: Microsoft.FSharp.Collections.List<_>