tisdag 6 oktober 2009

En liten funtionell reflektion

Första gången jag såg yield return i C# tyckte jag att det var lite larvigt. Nu har jag provat det lite grann och kommit på att jag gillar det. Det fina är att man nu kan använda Linqs extensions till IEnumerable till att göra saker riktigt funktionellt. Man kan använda IEnumerable till exekveringslogik på ett sätt man inte kunde tidigare.
Låt säga att man vill sortera kommaseparerade ord givna i konsolen. I traditionell C# något i stil med:
static void Main(string[] args)
{
var elements = new List();
string input;
while (!string.IsNullOrEmpty(input = GetInput()))
foreach (var element in input.Split(','))
elements.Add(element.Trim());
elements.Sort();
Console.WriteLine(string.Join(", ", elements.ToArray()));
Console.Read();
}

static string GetInput()
{
Console.Write("Input: ");
return Console.ReadLine();
}
Detta är lättläst i den bemärkelse att man är van vid det. Det är så man alltid skrivit. Problemet med denna kod är att man i detalj får ange hur man ska göra saker. En lista ska skapas, vi stegar igenom var inmatning tills den är tom... osv. Koden är imperativ om man så vill.
En helt annan angreppspunkt som gör exakt samma sak är denna:
static void Main(string[] args)
{
Console.WriteLine(string.Join(", ",
Inputs().
TakeWhile(s => !string.IsNullOrEmpty(s)).
SelectMany(s => s.Split(',')).
Select(s => s.Trim()).
OrderBy(s => s).
ToArray()));
Console.Read();
}

static IEnumerable Inputs()
{
while (true)
yield return GetInput();
}

static string GetInput()
{
Console.Write("Input: ");
return Console.ReadLine();
}
Sådärja, inte nog med att koden blev obegriplig, det blev fler rader. Vad har vi vunnit på detta?
Till att börja med kan jag förklara vad som händer. Funktionen Inputs() kommer att generera en oändlig uppräkning av inmatade strängar. TakeWhile(...) kommer att ta dessa strängar tills någon är tom. SelectMany(...) delar upp var inmatning efter kommatecken. Select(...) skalar bort mellanslag i vart element. OrderBy(...) sorterar alla element. Resten av logiken finns är i princip identisk med foreach-varianten ovan.
Återigen: Vad har vi vunnit? För att nämna några saker:
  1. Koden beskriver vad som ska göras mer än hur. Vi ber om alla inmatningar fram till den första tomma; dessa vill vi ha uppdelade, trimmade och sorterade. Vi säger inget om hur detta ska göras.
  2. Alla operationer vi gör är i princip lika; de är filter i en filterkedja. Att logiken för att avsluta vid tom inmatning och logiken för att dela upp var inmatning i kommaseparerade element har en så påfallande lik struktur syns inte alls i foreach-exemplet.
  3. Inga lokala variabler behövs. Vi behöver inte spara någon variabel för att samla i en lista eller lagra inmatning. Detta gör var rad isolerad i betydelse. Ingen logik är spridd över hela beskrivningen (som listan element som initieras, fylles och presenteras).
  4. Koden blir mer återanvändbar. Man kan bryta ut en funktion som tar en IEnumerable som filtrerar som ovan så att f(Inputs()) ger vårt resultat men även f(textFile.GetRows()) skulle fungera. Detta skulle vara knöligt i foreach-fallet.
  5. Den senare lösningen är enormt mycket mer kraftfull vad gäller utökningar av logiken. Detta visar jag i några exempel nedan.
Låt säga att vi vill ta bara 5 inmatade rader (om inte användaren matat in en tom rad dessförinnan). I foreach-fallet skulle vi behöva definiera en räknare som avbryter while-loopen. Låt oss säga att vi dessutom bara vill ha totalt 25 inmatade element (var rad kan ha fler element). I foreach-fallet får vi då se till storleken på elements och avbryta while-loopen. I det senare fallet betyder det att vi vill ta 5 respektive 25 element ur kedjan. På engelska och i C# heter detta Take(5) respektive Take(25). Se koden nedan för var de ska in i kedjan. Även om man skulle vilja ta max 8 element per rad kan detta göras med en Take(8) på rätt ställe.
Ett annat exempel är att man kanske vill gruppera alla inmatade ord efter deras längd. I foreach-fallet skulle vi förmodligen tvingas skapa en ny klass för att samla på sig den logiken. De extrafunktioner man får från Linq gör att det inte behövs i det senare fallet. Denna kod gör just detta:
Console.WriteLine(string.Join(", ",
Inputs().
Take(5).
TakeWhile(s => !string.IsNullOrEmpty(s)).
SelectMany(s => s.Split(',')).
Take(25).
Select(s => s.Trim()).
GroupBy(s => s.Length).
OrderBy(p => p.Key).
Select(p => p.Key + ": " + string.Join(", ", p.OrderBy(s => s).ToArray())).
ToArray()));
Min gissning är att den logiken skulle bli synnerligen besvärlig i foreach-fallet. Om du orkar och kan göra det smidigt kan du gärna klippa in koden i en kommentar.

Varför ska du också blogga, Martin?

I mitt förra förvärv forskade jag i fysik. Trots det bad somliga personer mig förklara vad jag forskade om. Jag hade en liten, muntlig, snabb prototyppresentation som jag ganska snabbt tröttnade på. Efter att ha skrivit ned den (Kapitel ett i denna PDF om du är nyfiken på strängteori) kunde jag hänvisa dit. Det var skönt.
Nu har jag programmerat tillräckligt länge för att ha reflektioner över detta och tänkte ha samma strategi igen. Om jag berättar samma sak för fler personer än tre ska jag försöka skriva ett inlägg om det.