Domain-mallinnus, DSL-kieli
Domain-mallilla tarkoitetaan (kehittäjille suunnattua) tietomallia (=rakennetta), ohjaamaan ongelmanratkaisua tietyille raiteille.
Domain Specific Language (DSL) viittaa tässä vain Embedded / Internal tyyliseen kieleen, jossa itsenäinen funktionaalinen (kombinaattori-)kirjasto upotetaan osaksi F#-kieltä. Emme käy läpi Fowler:in OO-pohjaista DSL-kirjaa.
Mallinnuksen vaiheet
Mallinnus koostuu kolmesta vaiheesta
- Valitaan/mallinnetaan alkiot (primitives)
- Järjestelmän perus-yksiköiden tilat.
- Vain keskeisin/oleellisin tieto
- Mallinnetaan kompositio (yhdistäminen)
- Miten primitiivejä yhdistellään niin, että ne sopivat saumattomasti yhteen
- Kombinaattorit
- Syntaksi
- Lisäkoristelut, jotta palikoita on kiva käyttää.
1. & 2. Mallinnetaan alkiot ja niiden kompositio
Toiminnallisuudet voidaan lähteä mallintamaan joko tietona (contract as data) tai laskentaoperaatioina (contract as computation). Riippumatta tästä, käytössä on samat työkalut.
Mallinnuksen työkalut
Mallinnus kannattaa tehdä käyttäen kolmea työkalua:
- Tuplet
- "Product type", voidaan ajatella AND-operaationa
- Discriminated union
- "Sum type", voidaan ajatella OR-operaationa
- Funktiot
- Siirtymä tilojen välillä
- Toiminnon mallinnus
Teoriassa boolean-operaatiot riittävät tietotekniikassa mallintamaan kaiken. Selkein domain tuleekin käyttäen näitä (ts. lähtökohtaisesti vältä oliorakenteita), mutta käytännössä halutaan ehkä käyttää apuna vielä seuraavia:
- Record, jos yhteen-niputettavaa tietoa on paljon (esim. "ERP-olio")
- Lista, jos yksittäisiä kohteita ei haluta listata
- Discriminated union voidaan jälkikäteen vaihtaa rajapinnaksi, jos kaikki sen jäsenet eivät ole tiedossa, ts. halutaan plugin-arkkitehtuuri.
Domain-mallin mallintaminen (contract as data)
Otetaan esimerkkinä yksinkertainen osake/pörssikauppa.
Kuinka mallinnetaan:
- Haluan ostaa Microsoftin osakkeita 100kpl
- Haluan ostaa Nokian osakkeita 500kpl
- Haluan myydä Googlen osakkeita 300kpl
Alkiot (primitiivit) olisivat yksittäisiä kauppoja, ja kompositio on sitä, että primitiiveistä yhdistellään eräänlainen abstrakti syntaksipuu.
Usein kannattaa lähteä mallintamaan järjestelmän toimintoja (commandeja, verbejä), eikä niinkään "perus-olioita" (substantiiveja). Malli voisi näyttää esimerkiksi tältä:
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: |
module ``OptionTrade case 1`` = //Primitives: type OptionTrade = | Buy of string*decimal // Buy "MSFT" 100 (amount) | Sell of string*decimal //Composition combinators: | ContractUntil of System.DateTime*OptionTrade | ContractAfter of System.DateTime*OptionTrade | Combine of OptionTrade*OptionTrade //or OptionTrade list |
Tai tältä:
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: |
module ``OptionTrade case 2`` = //Primitives: type OperationKind = Buy | Sell type DateTimeKind = Until | After type OptionTrade = | Operation of OperationKind*string*decimal //Composition combinators: | Contract of DateTimeKind*System.DateTime*OptionTrade | Combine of OptionTrade*OptionTrade |
Näiden kahden merkittävin ero on siinä, että sitten kun näitä käytetään, niin kuinka helposti päästään käsiksi parametritietoihin:
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: |
module ``OptionTrade case 1 usage`` = open ``OptionTrade case 1`` let create = Combine( Combine( Buy("MSFT",100m), Buy("NOK",500m)), Sell("GOOG",300m)) let rec purge = function | Buy (name,amount) -> "" //... | Sell (name,amount) -> "" //... | ContractUntil (dt,opt) -> if dt<=System.DateTime.Now then purge opt else "" //... | ContractAfter (dt,opt) -> if dt>=System.DateTime.Now then purge opt else "" //... | Combine (a,b) -> purge a + purge b module ``OptionTrade case 2 usage`` = open ``OptionTrade case 2`` let create = Combine( Combine( Operation(Buy,"MSFT",100m), Operation(Buy,"NOK",500m)), Operation(Sell,"GOOG",300m)) let rec purge = function | Operation (kind,name,amount) -> // you could use when or... "" // use common name and amount functionality and then // match kind with buy/sell when needed | Contract (dtk, dt,opt) -> "" // match dt with...purge(opt) | Combine (a,b) -> purge a + purge b |
Kannattaa valita vaihtoehto 2, jos käytettävät parametrit kuvaavat samaa tietosisältöä, kuten tässä. Silloin operaatio on helpompi muokata muodosta toiseen, esim. vaihtaa Buy -> Sell. Tee mieluummin useita tyyppejä kuin mega-tyyppejä.
Tämä on helpoin ja selkein tapa mallintaa, näin saat selkeän tietomallin. Haittapuolena on, että operaatiot ovat kiinteitä (/"kovakoodattu"), eli et voi kirjastomaisesti yhdistää omia rakenteita tekemään mitä tahansa, vaan toiminnallisuuden on kunnioitettava tietomallin toiminnallisuuksia. Usein tämä riittää, kun tavoitteena on rakentaa yksittäinen järjestelmää, jolla on selkeät rajat.
Siinä missä C# usein johtaa olio-mappaus-koodiin tyypistä toiseen, auttavat F# Object Expressionit, joilla voi luoda instansseja (structeista, olioista, interfaceista) ilman boilerplate-koodia:
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: |
type MyInvoice = { Sum : decimal; Tax : decimal; //... Property01 : string; Property02 : string; Property03 : string; //... Property99 : string; } let myInstance = { Sum=10m; Tax=10m; Property01="a"; Property02="b"; Property03="c"; Property99="zzz" } // Don't have to define all copied propertis like in LINQ-select you would have to: let myNewCopy = { myInstance with Property01="Hello!" } |
Tietomallin mallintaminen laskentaoperaatioina (contract as computation)
Tämä on perus-ohjelmaan usein turhan monimutkainen konsepti (ellei call-cc ja Haskell-kirjastot ole entuudestaan tuttuja), mutta hyvä vaihtoehto, jos on pakko yrittää rakentaa jonkinlainen oma workflow/prosessi/sääntö -moottori tai framework. Erona edelliseen on se, että käyttäjä voi referoida tämän kirjastona ja itse tehdä komposito-operaatioita lisää. Tällöin kyseessä alkaa olla jo kombinaattorikirjasto tai DSL-kieli.
Tietomallia voidaan mallintaa toimintona/funktiona ('a -> M<'b>)
, jossa a on "ohjelman tila", ts. mitä vastaan kombinaatiosääntöjä tehdään, ja M kuvaa jonkinlaista kapselia/monadia (vaikkapa lista), ja b taas on tuloksen tyyppi. Kapseli on vapaaehtoinen ja myöskään a ja b ei tarvitse olla geneerisiä, jos tarkemmat tyypit on business-mielessä mahdollista määrittää. Aluksi annetaan funktiolle nimi, jotta ei hukuta nuoliin:
1: 2: |
type OptionTrade<'a,'b> = | Operation of ('a -> List<'b>) |
Nyt voidaan luoda funktio, joka yhdistää kaksi toiminnallisuutta, esim. listalla:
1: 2: 3: 4: |
let combine (Operation f1) (Operation f2) = Operation(fun a -> [f1(a); f2(a)]) // OptionTrade -> OptionTrade -> OptionTrade |
ja määritellään vielä toinen funktio, itse suoritus:
1:
|
let eval (Operation f) a = f(a) |
Nyt käyttäjä voi tehdä mitä tahansa Operation-tyylisiä funktioita, jotka toteuttavat syntaksin (a -> List<b>)
ja näitä voidaan yhdistellä käyttäjän mielen mukaan.
Esim:
1:
|
let buy name amount = Operation(fun a -> [(name, amount)]) |
Lopuksi käyttäjä pyytää suorittamaan ajon.
Koodin logiikkaa ei erikseen tehdä domainin rinnalle, vaan kirjasto jo suorittaa koodin logiikan. Eli abstrakti-syntaksi-puu muodostuukin kooste-funktioista lähdekoodissa:
1: 2: 3: 4: 5: |
let myCombination = combine (buy "MSFT" 100m) (buy "NOK" 500m) let myDone = eval myCombination "now!" |
Tässä tehtiin koodista turhan geneeristä ja myynti-(sell)-operaatio jätetään harjoitustehtäväksi. Käytännön tarkempi lähdekoodiesimerkki (jossa myös myynti on toteutettu, ja kasa muita operaatioita) on saatavilla netistä, kirjan "Real-World Functional Programming" koodiesimerkeistä, kappale 15.
Standardi-operaatiot
Yhdisteltäessä eri toiminnallisuuksia (jopa eri kirjastojen välillä), on usein kätevää käyttää tuttuja standardi-operaatiota kuten esim:
- map
('T -> 'R) -> M<'T> -> M<'R>
- bind
('T -> M<'R>) -> M<'T> -> M<'R>
- return
'T -> M<'T>
Esim map menisi näin (yhdellä ja kahdella parametrilla):
1: 2: 3: 4: |
//Map with one parameters, just basic composition: let map f (Operation f1) = Operation(fun a -> f(f1(a))) //Map with f having two parameters: let map2 f (Operation f1) (Operation f2) = Operation(fun a -> f (f1 a) (f2 a)) |
Vältä sivuvaikutuksia. Operaation tietotyypistä voi jo päätellä mitä itse operaatio tekee.
3. Syntaksi
Jotta toiminnallisuuksiasi olisi parempi käyttää, niin F#:ssa on lukuisia hienouksia: omat custom-operaattorit, Builder-syntaksi, Quotations (kuin C# -expression-puu), Query-syntaksi, ... Tässä esiteltynä tarkemmin kaksi:
Custom-operaattorit
F#:ssa voit tehdä omat operaattorisi, kuten normaali funktio, mutta kirjoittamalla operaattorin sulkuihin. Käytettäessä operaattorin ensimmäinen parametri tulee ennen operaattoria. Esimerkkinä seuraava "hauska jekku":
1: 2: |
let (+) x y = x-y let minusOne = 5+6 |
mutta tälle on myös erittäin mukavia käyttötarkoituksia omissa DSL-kielissä, tästä esimerkkinä edellisen kohda combine-funktio (partial application:in takia ei tarvitse listata parametreja):
1: 2: 3: 4: 5: 6: 7: 8: |
let (&) = combine let myCombination2 = buy "MSFT" 100m & buy "NOK" 500m & sell "GOOG" 300m let myDone2 = eval myCombination2 "now!" |
Ylikuormitettavat operaattorit voi myös määrittää tavallisina member-funktioina tyypille. F#:ssa voit tehdä myös extension-metodeja (ja extension-propertyjä!):
1: 2: 3: |
type System.String with member x.yell = x + "!" // "hello".yell |
Builder-syntaksi
Omia computational expressioneita (/monadeita) voi rakentaa lennosta: Keksit vain tilan/sivuvaikutuksen, jonka kapseloit. Tämän sisällä ohjelmoidaan konteksti{ ... }
-syntaksilla (jossa "konteksti" voi olla melkein mikä tahansa valitsemasi sana).
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: |
// Return class like Async<T> or IEnumerable<T>: type MyReturnClass(n:int) = member x.Value = n type MyContextBuilder() = member t.Return(x) = MyReturnClass(x) member t.Bind(x:MyReturnClass, rest) = printfn "Binded %d" x.Value rest(x.Value) let context = MyContextBuilder() let test = context{ let! a = MyReturnClass(3) //"let!" calls builder's Bind let! b = MyReturnClass(5) //"let!" calls builder's Bind //Inside the monad you program like usual F#: let mult = a * b let sum = mult + 1 return sum //"return" calls builder's Return(x) } |
- Kontekstin sisällä huutomerkki-käskyt ("syntaktisokeria") ohjaavat builder-"rajapinnan" vastaaviin metodeihin.
- "Rajapinnasta" ei tarvitse täyttää kuin valitsemasi metodit. Homma perustuu continuationiin (tarkemmin: call-cc) ja reify:yn.
- Tarkempi kuvaus rajapinnan metodeista ja sisäisestä toiminnasta löytyy netistä.
Tätä voidaan käyttää kapseloimaan jokin asiaan liittyvä tila, osakekauppaesimerkissä se voisi olla vaikkapa pankkitilin rahamäärä, joka on käytössä kauppoihin.
Harjoitustehtävät
Harjoitustehtävä 1
Ohessa on piirretty kuvitteellinen lainanhakuprosessi: pdf.
Siitä copy & pastella on hutaistu tekstit oheisiin tietotyyppeihin:
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: |
type Terms = string type Loan = | Offer of decimal*DateTime | Approved of decimal*DateTime type ``Loan application state`` = | ``Status Received`` | ``Has Client`` of bool | ``Has Credit`` of bool | ``Offer terms are ok`` of Terms*bool | ``Loan rejected`` | ``Manual offer terms`` of Terms | ``Offer created`` of Terms*Loan | ``Offer approved`` of Terms*Loan*bool | ``Offer closed`` of Terms*Loan | ``Money transferred`` of Loan | ``Loan fully paid`` of bool*Loan | ``Report created`` of Loan |
- Miten optimoisit näitä tietotyyppejä?
- Kirjoita myös muutama kuvitteellinen metodi käyttämään tätä kuvattua prosessia ja kuljettamaan lainahakemusta prosessin vaiheesta toiseen.
- Prosessiin kuulumaton data kannattaa kapseloida mieluummin uutena tuplena myöhemmin rinnalle, kuin prosessin sisään.
Harjoitustehtävä 2
Mieti mikä olisi huono domain-malli?
Harjoitustehtävä 3
Kappaleessa "Tietomallin mallintaminen laskentaoperaatioina" oli jätetty myynti-operaatio pois. Myynti voisi olla joko vain negaatio, tai sitten lisätään taas rinnalle oma type OperationKind = Buy | Sell
.
Koska myyntejä on vain yksi, ja combine ottaa kaksi parametria, niin tarvitaan vähän koodimuutoksia, esim. wrapperi käytettyyn listaan, ja ehkä tälle oma custom-operaattori:
1:
|
let ``return`` (Operation f1) = Operation(fun a -> [f1(a)]) |
Toteuta myyntioperaatio ja koita saada se toimimaan alkuperäisessä esimerkissä.
Tässä esimerkki miten tehdään omia funktioita:
1: 2: 3: 4: |
//Example use, with list-parameter, one parameters: let doubleTradeAmount = map (fun al -> [fst(al |> List.head),snd(al |> List.head)*2m]) let goneDouble = doubleTradeAmount (buy "MSFT" 100m) eval goneDouble ("not-used-initial-state",0m) |
Kuten huomataan, niin tässä kontekstissa turhasta listakapselista on nyt vaivaa (turha List.head). Voit koittaa ottaa listan pois.
Tee jokin oma operaatio, joka käyttää map2-funktiota.
Linkit / Lähteet
Tomas Petricek - Domain Specific Languages in F# - Video, Slides
Simon Peyton-Jones - Composing contracts: an adventure in financial engineering
Lab49 - The Algebra of Data, and the Calculus of Mutation
Philip Wadler - Theorems for free!
| Buy of string * decimal
| Sell of string * decimal
| ContractUntil of DateTime * OptionTrade
| ContractAfter of DateTime * OptionTrade
| Combine of OptionTrade * OptionTrade
Full name: DomainModelFin.OptionTrade case 1.OptionTrade
val string : value:'T -> string
Full name: Microsoft.FSharp.Core.Operators.string
--------------------
type string = System.String
Full name: Microsoft.FSharp.Core.string
val decimal : value:'T -> decimal (requires member op_Explicit)
Full name: Microsoft.FSharp.Core.Operators.decimal
--------------------
type decimal = System.Decimal
Full name: Microsoft.FSharp.Core.decimal
--------------------
type decimal<'Measure> = decimal
Full name: Microsoft.FSharp.Core.decimal<_>
type DateTime =
struct
new : ticks:int64 -> DateTime + 10 overloads
member Add : value:TimeSpan -> DateTime
member AddDays : value:float -> DateTime
member AddHours : value:float -> DateTime
member AddMilliseconds : value:float -> DateTime
member AddMinutes : value:float -> DateTime
member AddMonths : months:int -> DateTime
member AddSeconds : value:float -> DateTime
member AddTicks : value:int64 -> DateTime
member AddYears : value:int -> DateTime
...
end
Full name: System.DateTime
--------------------
System.DateTime()
(+0 other overloads)
System.DateTime(ticks: int64) : unit
(+0 other overloads)
System.DateTime(ticks: int64, kind: System.DateTimeKind) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int, calendar: System.Globalization.Calendar) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: System.DateTimeKind) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: System.Globalization.Calendar) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, millisecond: int) : unit
(+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, millisecond: int, kind: System.DateTimeKind) : unit
(+0 other overloads)
| Buy
| Sell
Full name: DomainModelFin.OptionTrade case 2.OperationKind
| Until
| After
Full name: DomainModelFin.OptionTrade case 2.DateTimeKind
| Operation of OperationKind * string * decimal
| Contract of DateTimeKind * DateTime * OptionTrade
| Combine of OptionTrade * OptionTrade
Full name: DomainModelFin.OptionTrade case 2.OptionTrade
Full name: DomainModelFin.OptionTrade case 1 usage.create
Full name: DomainModelFin.OptionTrade case 1 usage.purge
Full name: DomainModelFin.OptionTrade case 2 usage.create
Full name: DomainModelFin.OptionTrade case 2 usage.purge
{Sum: decimal;
Tax: decimal;
Property01: string;
Property02: string;
Property03: string;
Property99: string;}
Full name: DomainModelFin.MyInvoice
Full name: DomainModelFin.myInstance
Full name: DomainModelFin.myNewCopy
Full name: DomainModelFin.OptionTrade<_,_>
module List
from 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<_>
Full name: DomainModelFin.combine
Full name: DomainModelFin.eval
Full name: DomainModelFin.buy
Full name: DomainModelFin.myCombination
Full name: DomainModelFin.myDone
Full name: DomainModelFin.map
Full name: DomainModelFin.map2
Full name: DomainModelFin.minusOne
Full name: DomainModelFin.myCombination2
Full name: DomainModelFin.myDone2
type String =
new : value:char -> string + 7 overloads
member Chars : int -> char
member Clone : unit -> obj
member CompareTo : value:obj -> int + 1 overload
member Contains : value:string -> bool
member CopyTo : sourceIndex:int * destination:char[] * destinationIndex:int * count:int -> unit
member EndsWith : value:string -> bool + 2 overloads
member Equals : obj:obj -> bool + 2 overloads
member GetEnumerator : unit -> CharEnumerator
member GetHashCode : unit -> int
...
Full name: System.String
--------------------
System.String(value: nativeptr<char>) : unit
System.String(value: nativeptr<sbyte>) : unit
System.String(value: char []) : unit
System.String(c: char, count: int) : unit
System.String(value: nativeptr<char>, startIndex: int, length: int) : unit
System.String(value: nativeptr<sbyte>, startIndex: int, length: int) : unit
System.String(value: char [], startIndex: int, length: int) : unit
System.String(value: nativeptr<sbyte>, startIndex: int, length: int, enc: System.Text.Encoding) : unit
Full name: DomainModelFin.yell
type MyReturnClass =
new : n:int -> MyReturnClass
member Value : int
Full name: DomainModelFin.MyReturnClass
--------------------
new : n:int -> MyReturnClass
val int : value:'T -> int (requires member op_Explicit)
Full name: Microsoft.FSharp.Core.Operators.int
--------------------
type int = int32
Full name: Microsoft.FSharp.Core.int
--------------------
type int<'Measure> = int
Full name: Microsoft.FSharp.Core.int<_>
Full name: DomainModelFin.MyReturnClass.Value
type MyContextBuilder =
new : unit -> MyContextBuilder
member Bind : x:MyReturnClass * rest:(int -> 'a) -> 'a
member Return : x:int -> MyReturnClass
Full name: DomainModelFin.MyContextBuilder
--------------------
new : unit -> MyContextBuilder
Full name: DomainModelFin.MyContextBuilder.Return
Full name: DomainModelFin.MyContextBuilder.Bind
Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.printfn
Full name: DomainModelFin.context
Full name: DomainModelFin.test
Full name: DomainModelFin.Terms
| Offer of decimal * obj
| Approved of decimal * obj
Full name: DomainModelFin.Loan
Full name: Microsoft.FSharp.Core.bool
Full name: DomainModelFin.doubleTradeAmount
Full name: Microsoft.FSharp.Core.Operators.fst
Full name: Microsoft.FSharp.Collections.List.head
Full name: Microsoft.FSharp.Core.Operators.snd
Full name: DomainModelFin.goneDouble