Status of C# 8.0 functional features with a comparison to Haskell - Luca Bolognese

Status of C# 8.0 functional features with a comparison to Haskell

Luca -

☕☕☕☕ 21 min. read

Abstract

Writing C# func­tional code has be­come eas­ier with each new re­lease of the lan­guage (i.e. nul­lable ref types, tu­ples, switch expr, …). This doc­u­ment pre­sents a re­view of the cur­rent sta­tus of ba­sic func­tional fea­tures for C# 8.0. It fo­cuses mostly on syn­tax and aims to achieve its goal us­ing code ex­am­ples. It does­n’t touches on more ad­vanced top­ics as Monad, Functors, etc …

Haskell has been cho­sen as the comparison’ lan­guage (using few ex­am­ples from here and else­where). This is not in­tended as a state­ment of value of one lan­guage vs the other. The lan­guages are pro­foundly dif­fer­ent in un­der­ly­ing philoso­phies. They both do much more than what is pre­sented here (i.e. C# sup­ports OO and im­per­a­tive style, while Haskell goes much deeper in type sys­tem power etc…).

I also pre­sent sam­ples of us­age of lan­guage-ext to show what can be achieved in C# us­ing a func­tional li­brary.

Using a li­brary of func­tions

In C# you can use sta­tic func­tions as if they were floating’ by im­port­ing the sta­tic class they are de­fined to with the syn­tax using static CLASSNAME. Below an ex­am­ple of both im­port­ing Console.WriteLine and us­ing it.

using System;
using System.IO;

using LanguageExt;
using LanguageExt.TypeClasses;
using LanguageExt.ClassInstances;
using System.Collections.Generic;

using static System.Console;
using static System.Linq.Enumerable;
using static System.Math;
using static LanguageExt.Prelude;

public static class Core {

static void UseFunc() => WriteLine("System.WriteLine as a floating function");

Writing sim­ple func­tions

In Haskell, a sim­ple func­tion might be writ­ten as:

   square :: Num a => a -> a
square x = x * x

Which in C# looks like the be­low.

    static int Square(int x) => x * x; 

As an aside, note that we lose the gen­er­al­ity of the func­tion (i.e. we need a dif­fer­ent one for dou­bles).This is due to the lack of ad-hoc poly­mor­phism in C#. By us­ing lan­guage-ext, you can fake it so that it looks like this:

    static A Square<NumA, A>(A x) where NumA : struct, Num<A> => default(NumA).Product(x, x);

static void SquarePoly() {
WriteLine(Square<TInt, int>(2));
WriteLine(Square<TDouble, double>(2.5));
}

In Haskell, it is con­ven­tional to write the type of a func­tion be­fore the func­tion it­self. In C# you can use Func and Action to achieve a sim­i­lar goal (aka see­ing the types sep­a­rately from the func­tion im­ple­men­ta­tion), de­spite with more ver­bose syn­tax, as be­low:

    static Func<int, int> SquareF = x => x * x;

Pattern match­ing

Here is an ex­am­ple in Haskell:

    lucky :: (Integral a) => a -> String  
lucky 7 = "LUCKY NUMBER SEVEN!"
lucky x = show x

In C#, you can write it in a few ways. Either as a func­tion sta­tic prop­erty …

    static Func<int, string> Lucky = x => x switch {
7 => "LUCKY NUMBER SEVEN!",
_ => x.ToString()
};

Or with a nor­mal func­tion:

    static string Lucky1(int x) => x switch {
7 => "LUCKY NUMBER SEVEN!",
_ => x.ToString()
};

Or even us­ing the ternary op­er­a­tor (a peren­nial fa­vorite of mine).

    static string Lucky2(int x) => x == 7 ? "LUCKY NUMBER SEVEN!"
: x.ToString();

Pattern match­ing on tu­ples works with sim­i­lar syn­tax:

    static bool And(bool x, bool y) =>
(x, y) switch
{
(true, true) => true,
_ => false
};

In Haskell you can match on lists as fol­lows:

    sum :: Num a => [a] -> a
sum [] = 0
sum (x:xs) = x + sum xs

Writing this in stan­dard C# looks like this:

    static int Sum(IEnumerable<int> l) => l.Count() == 0
? 0
: l.First() + Sum(l.Skip(1));

static int Sum1(IEnumerable<int> l) => l.Count() switch {
0 => 0,
_ => l.First() + Sum(l.Skip(1))
};

Language-ext gives you a sim­pler syn­tax, with more flex­i­bil­ity on what you can match against:

    static int Sum2(Seq<int> l) =>
match(l,
() => 0,
(x, xs) => x + Sum1(xs));

Obviously you al­ways have to be care­ful with re­cur­sion in C# (here). Better use the var­i­ous meth­ods on Enumerable.

Guards (and case ex­pres­sions)

Let’s ex­plore guards. Case ex­pres­sions have an iden­ti­cal trans­la­tion in C#.

In Haskell guards are used as be­low:

    bmiTell :: (RealFloat a) => a -> a -> String  
bmiTell weight height
| weight / height ^ 2 <= 18.5 = "Under"
| weight / height ^ 2 <= 25.0 = "Normal"
| weight / height ^ 2 <= 30.0 = "Over"
| otherwise = "Way over"

Which can be coded in C# as:

    static string BmiTell(double weight, double height) =>
(weight, height) switch
{
_ when Pow(weight / height, 2) <= 18.5 => "Under",
_ when Pow(weight / height, 2) <= 25.0 => "Normal",
_ when Pow(weight / height, 2) <= 30.0 => "Over",
_ => "Way over"
};

Obviously this is quite bad. You would like some­thing more like:

    bmiTell :: (RealFloat a) => a -> a -> String  
bmiTell weight height
| bmi <= skinny = "Under"
| bmi <= normal = "Normal"
| bmi <= fat = "Over"
| otherwise = "Way Over"
where bmi = weight / height ^ 2
skinny = 18.5
normal = 25.0
fat = 30.0

But it is not triv­ial in C# to de­clare vari­ables in ex­pres­sion bod­ied mem­bers. You can ei­ther move it to a nor­mal method or abuse the LINQ query syn­tax. Both shown be­low. No­tice that this is more sim­i­lar to let ex­pres­sions in Haskell, as they come be­fore the ex­pres­sion, not af­ter.

    static string BmiTell1(double weight, double height) {
double bmi = Pow(weight / height, 2),
skinny = 18.5,
normal = 25.0,
fat = 30;

return bmi switch
{
_ when bmi <= skinny => "Under",
_ when bmi <= normal => "Normal",
_ when bmi <= fat => "Over",
_ => "Way over"
};
}

static string BmiTell2(double weight, double height) =>
( from _ in "x"
let bmi = Pow(weight / height, 2)
let skinny = 18.5
let normal = 25.0
let fat = 30
select bmi <= skinny ? "Under"
: bmi <= normal ? "Normal"
: bmi <= fat ? "Over"
: "Way over").First();

Product types (aka Records)

In Haskell you de­fine a prod­uct type as be­low:

    data Person = Person { firstName :: String  
, lastName :: String
, age :: Int
} deriving (Show)

In C#, it is cur­rently com­pli­cated to de­fine an im­mutable prod­uct type with struc­tural equal­ity, struc­tural or­der­ing and ef­fi­cient hash­ing.

In essence, you have to im­ple­ment a bunch of in­ter­faces and op­er­a­tors, some­how sim­i­lar to be­low (and I am not im­ple­ment­ing or­der­ing, and it is prob­a­bly not too ef­fi­cient ei­ther).

    public readonly struct PersonData: IEquatable<PersonData> {
public readonly string FirstName;
public readonly string LastName;
public readonly int Age;

public PersonData(string first, string last, int age) => (LastName, FirstName, Age) = (last, first, age);

public override int GetHashCode() => (FirstName, LastName, Age).GetHashCode();
public override bool Equals(object other) => other is PersonData l && Equals(l);
public bool Equals(PersonData oth) => LastName == oth.LastName && FirstName == oth.FirstName && Age == oth.Age;
public static bool operator ==(PersonData lhs, PersonData rhs) => lhs.Equals(rhs);
public static bool operator !=(PersonData lhs, PersonData rhs) => !(lhs == rhs);
}

If you use a struct, you are still open to some­one sim­ply new­ing it us­ing the de­fault con­struc­tor, that you can’t make private.

Using a class (as be­low) avoids that, but loses the pass by value se­man­tic.

    public class PersonData1 : IEquatable<PersonData1> {
public readonly string FirstName;
public readonly string LastName;
public readonly int Age;

public PersonData1(string first, string last, int age) => (LastName, FirstName, Age) = (last, first, age);

public override int GetHashCode() => (FirstName, LastName, Age).GetHashCode();
public override bool Equals(object oth) => oth is PersonData l && Equals(l);
public bool Equals(PersonData1 other) => LastName == other.LastName && FirstName == other.FirstName && Age == other.Age;
public static bool operator ==(PersonData1 lhs, PersonData1 rhs) => lhs.Equals(rhs);
public static bool operator !=(PersonData1 lhs, PersonData1 rhs) => !(lhs == rhs);
}

So, there is no easy fix. Using Language-ext you can do it much more sim­ply, by in­her­i­tance. But ob­vi­ously it uses IL gen­er­a­tion that is slow the first time around. Try run­ning the code and no­tice the de­lay when IL gen­er­at­ing.

    public class PersonData2 : Record<PersonData2> {
public readonly string FirstName;
public readonly string LastName;
public readonly int Age;

public PersonData2(string first, string last, int age) => (LastName, FirstName, Age) = (last, first, age);

}

Sum types (aka Discriminated Union)

In Haskell you write:

    data Shape =
Circle Float Float Float
| Rectangle Float Float Float Float
| NoShape
deriving (Show)

There is no ob­vi­ous equiv­a­lent in C#, and dif­fer­ent li­braries has sprung up to pro­pose pos­si­ble so­lu­tions (but not lan­guage-ext) (i.e. here or here).

One pos­si­ble pure lan­guage’ im­ple­men­ta­tion, not con­sid­er­ing struc­tural equal­ity/​or­der­ing/​hash, fol­lows:

    abstract class Shape {

public sealed class NoShape : Shape { }
public sealed class Circle : Shape {
internal Circle(double r) => Radius = r;
public readonly double Radius;
}
public sealed class Rectangle : Shape {
internal Rectangle(double height, double width) => (Height, Width) = (height, width);
public readonly double Height;
public readonly double Width;
}
}

static Shape.Circle Circle(double x) => new Shape.Circle(x);
static Shape.Rectangle Rectangle(double x, double y) => new Shape.Rectangle(x,y);
static Shape.NoShape NoShape() => new Shape.NoShape();

You can then pat­tern match on it in var­i­ous ob­vi­ous ways:

    static double Area(Shape s) => s switch
{
Shape.NoShape _ => 0,
Shape.Circle {Radius: var r } => Pow(r, 2) * PI,
Shape.Rectangle r => r.Height * r.Width,
_ => throw new Exception("No known shape")
};

static void CalcAreas() {
var c = Circle(10);
var r = Rectangle(10, 3);
var n = NoShape();

WriteLine(Area(c));
WriteLine(Area(r));
WriteLine(Area(n));
}

Maybe (Or Option) type

In Haskell you write:

     f::Int -> Maybe Int
f 0 = Nothing
f x = Just x

g::Maybe Int -> Int
g Nothing = 0
g (Just x) = x

In C#, this eas­ily trans­lates to Nullable value and ref­er­ence types. Assume you have Nullable = enabled in your pro­ject

    static int? F(int i) => i switch {
0 => new Nullable<int>(),
_ => i
};

static int G(int? i) => i ?? 0;

Main Method

Let’s wrap all the sam­ples with a Main func­tion and then write a full pro­gram.

    static void Main() {
UseFunc();
SquarePoly();
WriteLine(Square(2) == SquareF(2));
WriteLine(Sum(new[] { 1, 2, 3, 4 }) == Sum1(new[] { 1, 2, 3, 4 }));
WriteLine(Sum1(new[] { 1, 2, 3, 4 }) == Sum2(new[] { 1, 2, 3, 4 }.ToSeq()));
WriteLine(BmiTell(80, 100) == BmiTell1(80, 100));
WriteLine(BmiTell(80, 100) == BmiTell2(80, 100));

WriteLine(new PersonData("Bob", "Blake", 40) == new PersonData("Bob", "Blake", 40));
WriteLine(new PersonData1("Bob", "Blake", 40) == new PersonData1("Bob", "Blake", 40));
WriteLine("Before IL gen");
WriteLine(new PersonData2("Bob", "Blake", 40) == new PersonData2("Bob", "Blake", 40));
WriteLine(new PersonData2("Alphie", "Blake", 40) <= new PersonData2("Bob", "Blake", 40));
WriteLine("Already genned");
WriteLine(new PersonData2("Bob", "Blake", 40) == new PersonData2("Bob", "Blake", 40));
WriteLine(new PersonData2("Alphie", "Blake", 40) <= new PersonData2("Bob", "Blake", 40));

CalcAreas();

Hangman.Core.MainHangman();

}
}

Full pro­gram

Let’s fin­ish with a semi-work­ing ver­sion of Hangman, from an ex­er­cise in Haskell pro­gram­ming from first prin­ci­ples, just to get an over­all im­pres­sion of how the two lan­guages look for big­ger things.

The ex­er­cise was a fill-in-the-blanks kind of thing with the func­tion names and types given, so I don’t think I butcher it too badly, but maybe not.

The Haskell code is:

module Main where

import Control.Monad (forever) -- [1]
import Data.Char (toLower) -- [2]
import Data.Maybe (isJust) -- [3]
import Data.List (intersperse) -- [4]
import System.Exit (exitSuccess) -- [5]
import System.Random (randomRIO) -- [6]

type WordList = [String]

allWords :: IO WordList
allWords = do
dict <- readFile "data/dict.txt"
return (lines dict)

minWordLength :: Int
minWordLength = 5

maxWordLength :: Int
maxWordLength = 9

gameWords :: IO WordList
gameWords = do
aw <- allWords
return (filter gameLength aw)
where gameLength w =
let l = length (w :: String)
in l > minWordLength && l < maxWordLength

randomWord :: WordList -> IO String
randomWord wl = do
randomIndex <- randomRIO ( 0, length wl - 1)
return $ wl !! randomIndex

randomWord' :: IO String
randomWord' = gameWords >>= randomWord

data Puzzle = Puzzle String [Maybe Char] [Char]

instance Show Puzzle where
show (Puzzle _ discovered guessed) =
(intersperse ' ' $ fmap renderPuzzleChar discovered)
++ " Guessed so far: " ++ guessed

freshPuzzle :: String -> Puzzle
freshPuzzle s = Puzzle s (map (const Nothing) s) []

charInWord :: Puzzle -> Char -> Bool
charInWord (Puzzle s _ _) c = c `elem` s

alreadyGuessed :: Puzzle -> Char -> Bool
alreadyGuessed (Puzzle _ _ s) c = c `elem` s

renderPuzzleChar :: Maybe Char -> Char
renderPuzzleChar Nothing = '_'
renderPuzzleChar (Just c) = c

fillInCharacter :: Puzzle -> Char -> Puzzle
fillInCharacter (Puzzle word filledInSoFar s) c =
Puzzle word newFilledInSoFar (c : s)
where zipper guessed wordChar guessChar =
if wordChar == guessed
then Just wordChar
else guessChar
newFilledInSoFar =
zipWith (zipper c) word filledInSoFar

handleGuess :: Puzzle -> Char -> IO Puzzle
handleGuess puzzle guess = do
putStrLn $ "Your guess was: " ++ [guess]
case (charInWord puzzle guess, alreadyGuessed puzzle guess) of
(_, True) -> do
putStrLn "You already guessed that character, pick something else!"
return puzzle
(True, _) -> do
putStrLn "This character was in the word,filling in the word accordingly"
return (fillInCharacter puzzle guess)
(False, _) -> do
putStrLn "This character wasn't in the word, try again."
return (fillInCharacter puzzle guess)

gameOver :: Puzzle -> IO ()
gameOver (Puzzle wordToGuess _ guessed) =
if (length guessed) > 7 then do
putStrLn "You lose!"
putStrLn $ "The word was: " ++ wordToGuess
exitSuccess
else return ()

gameWin :: Puzzle -> IO ()
gameWin (Puzzle _ filledInSoFar _) =
if all isJust filledInSoFar then do
putStrLn "You win!"
exitSuccess
else return ()

runGame :: Puzzle -> IO ()
runGame puzzle = forever $ do
gameOver puzzle
gameWin puzzle
putStrLn $ "Current puzzle is: " ++ show puzzle
putStr "Guess a letter: "
guess <- getLine
case guess of
[c] -> handleGuess puzzle c >>= runGame
_ -> putStrLn "Your guess must be a single character"


main :: IO ()
main = do
word <- randomWord'
let puzzle = freshPuzzle (fmap toLower word)
runGame puzzle

Which loosely trans­late to the code be­low (pure C#, no lan­guage-ext) . A few com­ments:

  1. I tried to keep the trans­la­tion as 1:1 as pos­si­ble.
  2. I used ex­pres­sion bod­ied mem­bers for every­thing ex­cept IO re­turn­ing func­tion. That’s a pleas­ing con­ven­tion to me.
  3. Things trans­late rather straight­for­wardly ex­cept for:
    1. Cheated us­ing a sim­ple struct in­stead of a Record, but of­ten it is ok to do so.
    2. Needed to use LINQ query syn­tax to trans­late more com­plex ex­pres­sions, but can­not have lam­badas in it.
    3. Needed to do man­ual cur­ry­ing (language-ext would have beau­ti­fied that).
  4. The line count for this ex­am­ple is roughly sim­i­lar. I think that’s ran­dom. Also the C# code extends to the right’ more.
  5. Notably ab­sent from the code are sum types, which would have been ver­bose to im­ple­ment in C#.
namespace Hangman {
using WordList = IEnumerable<String>;
using static System.Linq.Enumerable;

static class Core {

const int MinWordLength = 5;
const int MaxWordLength = 9;

static WordList AllWords => File.ReadAllLines("data/dict.txt");

static WordList GameWords =>
AllWords.Where(w => w.Length > MinWordLength && w.Length < MaxWordLength);

static Random r = new Random();

static string RandomWord(WordList wl) => GameWords.ElementAt(r.Next(0, wl.Length()));

static string RandomWord1 => RandomWord(GameWords);

static char RenderPuzzleChar(char? c) => c ?? '_';

struct Puzzle { // Not implemented Eq and Ord because not needed in this program
public string Word;
public IEnumerable<char?> Discovered;
public string Guessed;

public override string ToString() =>
$"{string.Join(" ", Discovered.Select(RenderPuzzleChar))}"
+ " Guessed so far: " + Guessed;
}

static Puzzle FreshPuzzle(string s) => new Puzzle {
Word = s,
Discovered = s.Select(_ => new Nullable<char>()),
Guessed = ""
};

static bool CharInWord(Puzzle p, char c) => p.Word.Contains(c);
static bool AlreadyGuessed(Puzzle p, char c) => p.Guessed.Contains(c);

// Can't assign lambda expression to range variable with let, hence separate function
static char? Zipper(char guessed, char wordChar, char? guessChar) =>
wordChar == guessed ? wordChar : guessChar;

// Manual curry. Could use language-ext to make it more beautiful.
static Func<char, char?, char?> Zipper1(char c) => (c1, c2) => Zipper(c, c1, c2);

static Puzzle FillInCharacter(Puzzle p, char c) =>
(from _ in "x"
let newFilledInSoFar = System.Linq.Enumerable.Zip<char, char?, char?>(p.Word, p.Discovered, Zipper1(c))
select new Puzzle {
Word = p.Word,
Discovered = newFilledInSoFar,
Guessed = c + p.Guessed
}).First();

static Puzzle HandleGuess(Puzzle puzzle, char guess) {// Braces means IO ...
WriteLine($"Your guess was {guess}");
switch (CharInWord(puzzle, guess), AlreadyGuessed(puzzle, guess)) {
case (_, true):
WriteLine("You already guessed that character, pick something else!");
return puzzle;
case (true, _):
WriteLine("This character was in the word,filling in the word accordingly");
return FillInCharacter(puzzle, guess);
case (false, _):
WriteLine("This character wasn't in the word, try again.");
return FillInCharacter(puzzle, guess);
}
}

static void GameOver(Puzzle p) {
if(p.Guessed.Length > 7) {
WriteLine("You lose!");
WriteLine($"The word was {p.Word}");
Environment.Exit(0);
}
}

static void GameWin(Puzzle p) {
if(p.Discovered.All(c => c.HasValue)) {
WriteLine("You win!");
Environment.Exit(0);
}
}

static void RunGame(Puzzle puzzle) {
while(true) {
GameOver(puzzle);
GameWin(puzzle);
WriteLine($"Current puzzle is: {puzzle}");
WriteLine("Guess a letter: ");
var guess = ReadLine();
if(guess.Length == 1) RunGame(HandleGuess(puzzle, guess[0]));
else WriteLine("Your guess must be a single char");
}
}

public static void MainHangman() {
var puzzle = FreshPuzzle(RandomWord1);
RunGame(puzzle);
}

}
}
0 Webmentions

These are webmentions via the IndieWeb and webmention.io.