Archive for the ‘IEnumerable’ Category

IEnumerable<T> er ikke en container

Tuesday, August 11th, 2009

Jeg har brugt en del tid på at tale med flere kolleger om IEnumerable<T>, der jo, som følge af LINQ, har fået en langt mere central placering i vores .NET-værktøjskasse.

En af de misforståelser, der synes at dukke op i forbindelse med IEnumerable<T>, er, at betragte den som en container. IEnumerable<T> er ikke en container, har aldrig været det, og det har LINQ ikke ændret ved. Punktum.

Det er sandt, at mange containere som f.eks. List<T>, T[] osv. af indlysende årsager implementerer IEnumerable<T>, så vi har mulighed for at betragte containeren som en sekvens og løbe elementerne igennem, men det omvendte er ikke nødvendigvis sandt. En IEnumerable<T> behøver ikke at være en container. Nedenstående metode returnerer IEnumerable<int> men ikke en container:

public IEnumerable<int> EndlessStreamOfOnes() {
   while (true) yield return 1;
}

Forskelle

Med en container ved vi, at selve containeren samt dens elementer findes. Så hvis en metode returnerer en container, ved vi, at hele containeren og dens indhold vil være til stede, når vi får vores returværdi. Vi ved også, at det er eller i det mindste har været mulig at ændre indholdet af containeren (ReadOnlyCollection<T> er en container, der giver runtime-fejl, hvis vi forsøger at ændre indholdet). Og sidst, men ikke mindst ved vi, at en container har en endelig størrelse.

Det eneste, vi ved om en IEnumerable<T>, er, at vi kan gennemløbe dens elementer. Vi kan ikke tilføje eller fjerne elementer til sekvensen. Vi ved ikke om elementerne i sekvensen findes allerede, eller om de produceres i forbindelse med gennemløb. Vi ved heller ikke hvor mange elementer, der er tale om, og i princippet kan sekvensen være uendelig som i eksemplet ovenfor (hvilket overlader ansvaret for at udvælge det rette antal elementer til brugeren).

Der er altså muligvis subtile, men nok så væsentlige forskelle mellem en container og en IEnumerable<T>.

Databaseanalogien

Hvilket billede kan vi så bruge for at beskrive, hvad det vil sige, at en type implementerer IEnumerable<T>? Jeg har brugt en database cursor som model, men det er strengt taget forkert. IEnumerable<T> giver os nemlig ikke mulighed for løbe noget som helst igennem. IEnumerable<T> specificerer reelt kun en metode, nemlig GetEnumerator().

GetEnumerator() returnerer en instans af en type, der implementerer IEnumerator<T>, og det er denne instans, der faktisk tillader os at gennemløbe sekvensen. Til mit forsvar vil jeg sige, at compilerens mange tiltag for at gøre arbejdet med disse to interfaces så let så mulig har sløret billedet en hel del, og derfor ser det ud som om, at det er IEnumerable<T>, der er cursoren, men det er det altså ikke.

Jon Skeet har i et svar på StackOverflow forsøgt sig med en lignende analogi. Han sammenligner IEnumerable<T> med en tabel og IEnumerator<T> med en cursor. Det er oplagt, at sidstnævnte er et bedre valg end mit, men i forhold til at komme førnævnte misforståelse til livs er en tabel et dårligt billede på IEnumerable<T>. Vi kan jo netop ikke manipulere indholdet via IEnumerable<T>.

Jeg savner derfor en bedre model til at forklare IEnumerable<T> og IEnumerator<T> og forslag modtages gerne.

De bedste intentioner, men det værste resultat

Wednesday, July 22nd, 2009

I forbindelse med en kodegennemgang faldt jeg over noget kode, der kan beskrives som følger: En metode, der tager en IEnumerable som input og gøre noget med de elementer sekvensen returnerer. Det kunne f.eks. se ud som følger:

public void SomeMethod(IEnumerable<SomeType> instances) {
   if(instances.Count() == 0) return;

   // Do something with the instances
   foreach (var i in 
            from instance in instances 
               where instance.IsRelevant 
               select instance.SomeData) {
       Console.WriteLine(i);
   }
}

Det er ikke relevant, hvad metoden gør ved elementerne, og for den sags skyld kunne den også bare returnere en ny IEnumerable i stedet for at gøre noget med dem som i dette tilfælde. 

Det interessante er, at udvikleren har indlagt en lille optimering: Hvis sekvensen er tom, returnerer metoden så hurtig som mulig. Eller rettere: Det har sikkert været intentionen, men det er desværre ikke resultatet. Lad os se på hvorfor.

Der er fire relevante brugsmønstre for denne metode:

  1. Metoden kaldes med en tom sekvens
  2. Metoden kaldes med en sekvens med få elementer
  3. Metoden kaldes med en sekvens med mange elementer
  4. Metoden kaldes med en sekvens med uendelig mange elementer

For at gøre billedet komplet ville det også være relevant at se på, hvad der sker, hvis metoden kaldes uden en sekvens, men eftersom vi taler om optimering her, vil jeg se bort fra det. Lad os blot se på de fire tilfælde:

Tom sekvens

Hvis metoden kaldes med en tom sekvens, er det mest relevante i realiteten, om den er forberedt på denne situation eller ej. Det er helt legalt at gennemløbe en tom sekvens, og ikke overraskende er det tilmed en konstant operation. Alt er ok. Det er mindre relevant, om det går hurtigt eller meget hurtigt, men læg mærke til, at det er præcis denne situation, programmøren har forsøgt at optimere. 

Count() er implementeret ved, at såfremt den aktuelle instans implementerer ICollection, bruges Count fra dette interface. Hvis vi er heldige, returnerer denne property en allerede kendt værdi. Hvis instansen derimod ikke implementerer ICollection, løbes elementerne igennem for en optælling. At tælle antallet af elementer i en tom sekvens er derfor også en konstant operation, så vi ender givetvis med at forøge den samlede køretid en anelse, men vi ændrer ikke ved, at SomeMethod() kører i konstant tid ved en tom sekvens som input.

Få elementer

I den næste situation har vi få elementer. Her betaler vi igen et lille overhead for at se, om vi kan undgå at gøre yderligere, og derefter betaler vi prisen for at lave det egentlige arbejde. Denne situation adskiller sig faktisk kun fra den tredje situation ved, at omkostningerne for de to delopgaver er til at overse, og derfor kan vi i praksis højst sandsynligt være ligeglade med den faktiske performance i dette tilfælde.

Mange elementer

Den tredje og mest interessante situation, er der, hvor sekvensen returnerer mange elementer. Her vil vi altid betale prisen, hvad den så end må være, for at udføre selve opgaven, men i ovenstående implementering vil vi deslige bære omkostningerne for at løbe alle elementerne igennem, inden vi laver det egentlige arbejde. Hvis den faktiske instans, der implementerer IEnumerable, også implementerer ICollection, kan vi være så heldige, at det er en fast omkostning, men ellers stiger omkostninger ved denne operation i bedste fald lineært med længden af sekvensen. 

Uendelig mange elementer

I sidste tilfælde vil vi åbenlyst have et problem, idet vi forsøger, at tælle antallet af elementer i en uendelig sekvens. Til al held optræder disse ikke så ofte, men der er f.eks. intet i vejen for at implementere Fibonacci ved at returnere IEnumerable<int>, og så lade det være op til den kaldende kode, at udtage det relevante antal elementer via Take() eller lignende konstruktion. I det tilfælde er det ret usmart at kalde Count()

Bag om IEnumerable

Så med andre ord: Den lille optimering gør metoden dyrere og potentielt meget dyrere at kalde i de tilfælde, hvor det virkelig betyder noget. Det har næppe været udviklerens intention, så hvad kan have ansporet denne optimering?

Mit gæt er, at det skyldes en manglende forståelse af IEnumerable, så lad os se lidt på, hvad dette interface egentlig indebærer.

Hvis en type implementerer IEnumerable, kan vi være sikre på, at vi kan gennemløbe dens elementer som en sekvens. Det er, hvad IEnumerable giver os. Det og intet andet. Vi kan ikke sige noget om, hvorvidt elementerne i sekvensen allerede eksisterer, eller om de bliver genereret, efterhånden som sekvensen gennemløbes, og vi ved tilmed ikke om sekvensen er endelig eller uendelig. 

Givet disse egenskaber kan det således være en særdeles risikabel affære at give sig til at tælle antallet af elementer i sekvensen, men hvis vi tænker en anelse videre, er vi faktisk også ligeglade med antallet. Vi har blot brug for at vide, om der er nogen elementer eller ej.

En måde at gøre dette, er naturligvis at forsøge at tælle dem, men hvis der bare er et element, er vi ligeglade med hvor mange flere, der er. Desværre tager Count() ikke hensyn til denne betragtning, og derfor risikerer vi at komme til at tælle alle elementerne for blot at konstatere, at sekvensen ikke er tom.

Linq.Enumerable implementerer Any(), som gør præcis det, vi er efter her, så brug den fremfor Count() == 0.

Det giver et konstant og begrænset overhead, uanset hvor mange elementer sekvensen indeholder.

Hvad er gevinsten?

Men når det er sagt, vil jeg ikke nødvendigvis anbefale brug af Any() i denne situation. Hvis den opgave, vi efterfølgende udfører på sekvensen, ikke er dyr i tilfældet af nul elementer, er ovenstående optimering ligegyldig, da den ikke vil give nogen væsentlig besparelse i praksis, og derfor foretrækker jeg den simplere kode uden denne. 

Hele denne historie kan, som så mange andre fortællinger om optimering, koges ned til følgende: Optimer kun der, hvor det faktisk gør en positiv og målbar forskel.