The Case For F# – Artikel uit SDN 145
1. 6 redenen om voor je volgende project F# te kiezen
Functional programming is in. Als je tech nieuws bijhoudt, dan lees je met enige regelmaat over bijvoorbeeld Haskell, OCaml, Reason, Clojure, Scala of F#. Je leest dat je met een functionele programmeertaal leesbaardere, eenvoudigere en kortere code kunt schrijven waar je makkelijker over kunt redeneren.
Echter, in de praktijk zien we deze programmeertalen niet vaak gebruikt worden door bedrijven. “Selection bias” kan tot het beeld leiden dat functionele progammeertalen vaak gebruikt worden, maar in vergelijking met meer traditionele objectgeoriënteerde talen valt dat best mee. Functionele programmeertalen scoren laag op populariteitslijstjes. [1][2]
Veel traditionele programmeertalen nemen echter steeds meer functies over van functionele programmeertalen, dus er zit wel enige waarde in die functies.
Dit artikel beschrijft de belangrijkste functies van functionele programmeertalen in het algemeen en F# in het bijzonder. Het doel is om te laten zien dat gebruik van functionele programmeertalen nuttig kan zijn en dat het geen kwaad kan om F# te overwegen voor een aankomend project.
1.1. Immutability
Bij functionele programmeertalen wordt de voorkeur gegeven aan waardes die niet kunnen veranderen. Niet alle talen zijn daar even streng in, maar dat is wel altijd de insteek. Het idee is dat de output van een functie, gebaseerd is op de input. Een functie past zijn input niet aan, verzamelt zelf geen eigen input behalve wat is opgegeven en maakt geen aanpassingen aan de buitenwereld (bijvoorbeeld Console.WriteLine of Random.Shared.Next()).
Dit lijkt een redelijk simpel idee en de voordelen van functies zonder bijwerkingen (ook wel pure genoemd) zijn groot. Als gebruiker van een functie zonder bijwerkingen kun je ervan uitgaan dat alle objecten of data die meegegeven worden (en ook alle objecten en data die niet meegegeven wordt), onveranderd blijven.
Neem bijvoorbeeld de volgende implementatie van een functie die de waardes in een lijst met getallen verdubbelt.
List<int> Double(List<int> list)
{
for (int i = 0; i < list.Count; ++i) {
list[i] *= 2;
}
return list;
}
// Verderop …
List<int> numbers = new() { 1, 2, 3 };
List<int> doubled = Double(list);
In het voorbeeld hierboven is het aan de gebruiker van de functie om te bepalen of de input verandert. Vergelijk dit met dezelfde functie in een functionele programmeertaal, waar het veranderen van de inputlijst onmogelijk is zonder daarvoor je best te doen:
let double list = List.map ((*) 2) list
List.map is een functie die zijn eerste argument, een functie, uitvoert op alle waardes in het tweede argument, een lijst, en het resultaat teruggeeft zonder dat de input wordt aangepast. Het equivalent in C# ziet er als volgt uit:
List<int> Double(List<int list) => list.Select(x => x * 2);
C# heeft de laatste jaren een heleboel functionaliteit overgenomen uit F# en dit voorbeeld geeft aan hoeveel de talen op sommige punten op elkaar lijken.
Dit is een erg geforceerd voorbeeld, maar dingen die onverwachts veranderen in een flinke code base zijn een veelvoorkomende oorzaak voor lange debugsessies.
1.2. Type inference
Functionele programmeertalen leiden in het algemeen zelf de types van functies en hun arguments af zonder dat deze opgegeven hoeven te worden. Het resultaat daarvan is dat deze types niet geschreven hoeven te worden. Dat houdt dan weer in dat een functionele programmeertaal minder woorden nodig heeft om hetzelfde uit te drukken als in een taal die deze types niet zelf afleidt.
Neem als voorbeeld onderstaande code in zowel C# als F#. Het is een definitie van een functie die als argument een integer accepteert en als output een integer teruggeeft. Beide voorbeelden zijn strongly typed, maar in het F#-voorbeeld hebben we dat niet aan hoeven geven.
int Square(int x) => x * x; // C#
let square x = x * x // F#
Je zou kunnen beargumenteren dat dit de code, zeker in een complexe codebase, minder leesbaar maakt, maar niets is minder waar. Het type van de functie wordt gewoon getoond door de editor.
1.3. Sum types & Pattern matching
Een sum type is een datastructuur gemaakt om een waarde te bevatten die een van verschillende, voorgedefiniëerde types kan zijn. In de praktijk lijkt dit op een enum met data van een bepaald type voor iedere mogelijke waarde van de enum. Dit kan er bijvoorbeeld als volgt uitzien:
type Shape =
| Circle of float // Cirkel met radius
| Rect of (int * int) // Rechthoek met hoogte * breedte
| Composed of (Shape * Shape) // Combinatie van twee figuren
Aan de hand hiervan kunnen we deze waardes gemakkelijk creëren en beslissingen nemen aan de hand van het type figuur. Om dezelfde structuur te krijgen in een object georiënteerde taal zouden we een aparte klasse moeten maken voor ieder van de figuren. Ook is ofwel een typeof-check, ofwel functies toevoegen aan Shape die niet altijd logisch zijn voor subclasses, óf bijvoorbeeld het Visitor design pattern nodig. Daar is niks mis mee, maar het is wel behoorlijk meer code dan de functionele optie.
Als een sum type een super-enum is, is pattern matching een super-switch. Een voorbeeld, gebasseerd op bovestaande Shape.
- letrecareashape =
- match shape with
- | Circle radius -> radius * radius * 3.14
- | Rect (width, height) -> float (width * height)
- | Composed (Circle radiusA, Circle radiusB) -> (radiusA * radiusA * 3.14) + (radiusB * radiusB * 3.14)
- | Composed (a, b) -> (area a) + (area b)
Dit is redelijk duidelijke code, maar er gebeurt toch een hoop.
Regel (1) definiëert een recursieve functie, area, die de oppervlakte van de figuren berekent. Op regel (2) begint de match op de argument, shape.
Regel (3) matcht een Circle. De waarde die daarbij hoort, een float, kennen we toe aan de naam radius. Achter de -> staat de expressie die match uiteindelijk zal teruggeven.
Regel (4) matcht een Rect en kent de waarde toe. De waarde van een Rect en een tuple met twee integers. De integers worden toegekend aan de namen width en height.
Regel (5) matcht een Composed, enkel als de Composed bestaat uit twee Circle s Deze regel toont dat match meerdere clausules kan hebben voor hetzelfde type en voegt verder weinig toe
Regel (6) tenslotte matcht de overige waardes van Composed. Zouden we deze regel weglaten, dan klaagt de F#-compiler dat niet alle mogelijkheden gematcht worden. Doordat alle mogelijkheden afgehandeld worden is een default case niet nodig.
C# heeft sinds versie 7 ook pattern matching, al is het niet even krachtig als de pattern matching in F#.
abstract record Shape();
record Circle(double Radius) : Shape;
record Rect(int Width, int Height) : Shape;
record Composed(Shape A, Shape B) : Shape;
static double Area(Shape shape)
{
return shape switch {
Circle c => c.Radius * c.Radius * 3.14,
Rect r => r.Width * r.Height,
Composed { A: Circle c1, B: Circle c2 } =>
c1.Radius * c1.Radius * 3.14 + c2.Radius * c2.Radius * 3.14,
Composed c => Area(c.A) + Area(c.B),
_ => throw new ArgumentOutOfRangeException(nameof(shape), shape, null)
};
}
Het belangrijkste verschil in de C#-versie is de laatste regel. De C#-compiler weet niet dat, zonder de laatste regel, alle mogelijke combinaties zijn gecontroleerd. Daardoor moet de laatste clausule, de wildcard _, ook worden toegevoegd. Het eerste nadeel daarvan is dat erover moet worden nagedacht wat erin gedaan moet worden in het geval er geen match is. Callers moeten in dit geval de ArgumentOutOfRangeException afhandelen. Het tweede nadeel is dat wanneer we een extra type Shape toevoegen, de C#-compiler niet zal klagen en deze Exception ook echt voor gaat komen. De F#-compiler klaagt wel.
In C# kan deze eenvoudige case natuurlijk ook worden opgelost door een abstract method op Shape. En daar is niets mis mee. Polymorphism en Sum Types lossen hetzelfde probleem op een andere manier op. Beiden hebben voor- en nadelen.
In het geval van polymorphism is het makkelijker om nieuwe types toe te voegen. Sum Types maken het makkelijker om nieuwe functies toe te voegen.
1.4. Units of Measure
Het is in functionele programmeertalen door de combinatie van type inference en de eenvoudige syntax om types te definiëren, erg makkelijk om kleine types te maken voor type safety. Units of Measure zijn een built-in manier van F# om dit te doen voor getallen.
Stel je voor dat je werkt met snelheden. Er zijn landen op de wereld waar ze nog altijd niet inzien dat kilometers per uur de makkelijkste manier is om te rekenen, maar waar nog altijd in mijlen (per uur) gewerkt wordt.
Als we de twee door elkaar heen gebruiken, kan dat voor problemen zorgen.
bool CheckSpeedLimit(int speedInKmh) => speedInKmh <= 100;
// Verderop …
int speedInMph = 80;
CheckSpeedLimit(speedInMph); // Returns true!
F# geeft ons de mogelijkheid om dit (eenvoudig) te voorkomen:
- [<Measure>]type km
- [<Measure>]type mile
- [<Measure>]type h
- letcheckSpeedLimitspeed = speed <= 100.0<km/h>
- checkSpeedLimit 80.0<km/h>
- checkSpeedLimit 80.0<mile/h>
- checkSpeedLimit 80.0
Regels (1-3) definiëren types voor kilometers, mijlen en uren. Regel (5) definiëert een functie die een snelheid (in km/h) als input heeft en een boolean als output.
Deze functie wordt op regel (8) aangeroepen met een snelheid in km/h. Deze aanroep geeft true terug, zoals verwacht.
Op regel (9) wordt een poging gedaan om de functie aan te roepen met een snelheid in miles per uur. Dat lukt echter niet. F# geeft een foutmelding:
error FS0001: Type mismatch. Expecting a ‘float<km/h>’ but given a ‘float<mile/h>’ The unit of measure ‘km/h’ does not match the unit of measure ‘mile/h’
Ook de call op regel (10), zonder unit of measure, gaat fout:
error FS0001: This expression was expected to have type ‘float<km/h>’ but here has type ‘float’
Gelukkig is het mogelijk om te converteren tussen Units of Measure. De onderstaande code definieert een functie om te converteren van mile/h naar km/h en roept vervolgens checkSpeedLimit aan met deze conversie. Deze chauffeur rijdt duidelijk te snel.
let milesPerHourToKmPerHour (mph : float<mile/h>) = mph * 1.6<km> / 1.0<mile>
checkSpeedLimit (milesPerHourToKmPerHour 80.0<mile/h>)
1.5. .NET Interop
In de introductie worden programmeertalen als Haskell en OCaml genoemd. Hoewel dat prima programmeertalen zijn, hebben ze ook een groot nadeel. Omdat relatief weinig mensen ze gebruiken, zijn er relatief weinig (open source) libraries.
Een groot voordeel van F# op dit gebied is dat het een .NET-taal is. Daardoor kan gebruik gemaakt worden van het volledige .NET-ecosysteem. Inclusief .NET Framework libraries, .NET6 libraries en alles op NuGet.
Een nadeel is dat libraries geschreven voor C# over het algemeen niet de best practices voor F# hanteren, zoals immutability en pure functions. Dat is iets om in de gaten te houden bij het gebruik van een third party library. Desalnietemin is het feit dat er veel beschikbaar is, een groot voordeel.
Dit werkt twee kanten op. Een module geschreven in F# kan in C#-code gebruikt worden. Je zit dus nooit vast aan de taal.
1.6. Multi paradigm
Hoewel de focus van dit artikel ligt op functional programming, is F# geen pure functionele programmeertaal. De taal zelf ondersteunt mutatie en object georiënteerd programmeren.
Dat betekent dat, om te beginnen met F#, je kunt beginnen door simpelweg de syntax van de taal te leren, zonder je bezig te hoeven houden met het leren van functional design patterns en de manier van denken die hoort bij het schrijven van code zonder side effects, terwijl je wel direct het voordeel hebt van pattern matching, sum types en units of measure.
Wanneer je eenmaal comfortabel bent met de syntax kun je dan functionele concepten toevoegen aan je modules. Binnen een .NET-solution kunnen C#-projecten en F#-projecten door elkaar heen gebruikt worden. Binnen een project niet.
1.7. Conclusie
We hebben gekeken naar zes features van functionele programmeertalen en F# in het bijzonder. Deze features maken dat het code geschreven in een functionele programmeertaal voorspelbaarder, korter en eenvoudiger is dan dezelfde code in traditionele programmeertalen. Ook kan er door het type-system van functionele programmeertalen meer verantwoordelijkheid bij de compiler gelegd worden.
Hopelijk geeft dit artikel inspiratie om F# in een volgend project als serieuze kandidaat te overwegen.
1.8. Referenties
[1]: https://www.tiobe.com/tiobe-index/ [2]: https://pypl.github.io/PYPL.html
1.9 Over de auteur
Bart van Nierop is een bevlogen software-ontwikkelaar die altijd op zoek is naar nieuwe ideeën en technieken om software en het leven een beetje beter te maken.