A minap egy kolléga (legyen a fedőneve mondjuk Charlie Firpo) egy érdekes rejtett “finomságra” hívta fel a figyelmem egy refoktárálás tapasztalataiból, a hiba megfejtése után. A prbléma magjára redukálva könnyen érthető, de temelésben azért nehezebb volt megtalálni a eredendő okot. Ezt fogom bemutatni az úri közönségnek!

Írjunk egy kis kódot, hogy legyen mit refaktorálni!

Szerencsére nem kell sok a jelenség bemutatásához. Egy metódus, és egy teszt hozzá épp elég lesz:

public string Example(List<char> input){
    input.Reverse();
    return string.Join("", input);
}

[Fact]
public void Test(){
    var input = new List<char>() {'a', 'b', 'c'};

    Assert.Equal("cba", Example(input));
}

Itt örömmel konstatálhatjuk, hogy a metódus működik, amit az alapos 🙂 tesztje is kiválóan bizonyít!

Azonban minden kód életében eljön az idő, amikor refaktorálják! Tegyün így mi is: minek az a List? Ne a konkrét típustól függjünk, hanem az interfészétől, írjuk át IList-re! Nem csalás, nem ámítás, egy karakter a változás:

public string Example(IList<char> input){
    input.Reverse();
    return string.Join("", input);
}

[Fact]
public void Test(){
    var input = new List<char>() {'a', 'b', 'c'};

    Assert.Equal("cba", Example(input));
}

Meglepve konstatáljuk, hogy a tesztek nem futnak többé helyesen! Mi lehet az oka?

Extension method: a jó, a rossz, és a csúf BCL

A C# nyelv 3-as verziójával kapta meg az extension method nevű funkciót, amire a LINQ támogatásához volt szükség. Ez a funkció meglévő típusok kibővítását teszi lehetővé, leszármazás nélkül, legalábbis látszólag. Valójában statikus metódusokról van szó, és némi szintaktikai édesítőszerről, mellyel a saját kódunkban ezeket a statikus metódusokat olyan szintaxissal hívhatjuk, mintha a típus metódusai lennének. Így a meglévő kódokban sem kell változtatni, de mintegy *mixin*ekkel dekorálhatjuk a típust saját kódunkban, ha a megfelelő névtér using bejegyzésével aktiváljuk őket az adott fileban.

Ez sok helyzetben vitathatlanul hasznos lehet. Nincs konszenzus a funkció megítélését illetően, de tény, hogy előnyei mellett egyes helyzetekben buktatókat is tartogat.

Hogyan is működnek az extension methodok?

Létrehozunk egy metódust, egy static class-ban, aminek első paramétere egy this kulcsszóval kerül megjelölésre, és annak a típusára tapad rá a bővítmény, a példányokra. A metódus természetesen sima static method szintaxissal is meghívható, de a példányszintexissal hívás a dolog értelme. Egy példa:

using System.Collections.Generic;
using System.Linq;

namespace Kodfodrasz.Blog.Example {
    public static class ExtensionMethodExample {
        public static string StringifyAndJoin <T>(IEnumerable<T> enumerable, string separator){
            var stringified = enumerable.Select( i => i.ToString());
            return string.Join(separator, stringified);
        }
    }
}
// be is kell ám kapcsolni a felhasználás helyén!
using Kodfodrasz.Blog.Example;

public void Main(){
    var arr = new []{1, 2, 3};
    var joined = arr.StringifyAndJoin(",");
    Console.WriteLine(joined)
}

A kimeneten pedig megjelenik, hogy 1,2,3. Ez elég jó, úgy tudtunk egy praktikus funkciót hozzáadni más típusához, hogy sem azt, sem más kódot nemkellett megbolygatni! (Amiket meg sem tudnánk egy könnyen tenni…)

Hogyan működik ez? A fordító a hívás helyén olyan IL kódot generál, ami a statikus metódust hívja. Ha pedig a típusnak van azonos szignatúrájú metódusa, akkor azt hívja a fordító. Az extension methodok közül a referencia típusa szerint legkonkrétabbat hívja a fordító, így lehet(ne) pl. a int IEnumerable<T>.Count() hatékonyabb overloadja az int IList<T>.Count().

Itt már gyanús kezd lenni a dolog! Valami ronda dolog van a BCL-ben elásva…

A csúf igazság

Vizsgáljuk meg a problémában érintett metódusokat!

Az eredeti kódban hívott metódus szignatúrája: void List<T>.Reverse() megfordítja a listán belül az elemek sorrendjét, mutálja a gyűjteményt! Az új kódban pedig az public static IEnumerable<TSource> Reverse<TSource>(this IEnumerable<TSource> source) metódus hívódott, ami egy fordított nézetet ad a módosítatlan gyűjteményről!

Itt elkerekedhetne az ember szeme: hiszen a visszatérési típusok teljesen más, nem is ugyanaz a szignatúra! Azonban C#-ban a visszatérési típus nem a szignatúra része, ahogy return type covariance sincs (sajnos, de egyszer talán eljön). Így azonban a két metódus azonos szignatúrájúnak számít, így a konkrétabb híváshoz “köt” a fordító az első, és a lazábbhoz a második esetben. Az első esetben a hívás mellékhatását használjuk ki, a második esetben a hívás eredményét eldobjuk, és a mellékhatás híján más eredményt kapunk, mint számítunk.

Hogy lehetett volna ezt kiküszöbülni? Nem szabadott volna azonos szignatúrájó, teljesen mást eredményező nevű metódusokat rakni legalább a BCL-ben levő gyűjtemények “fölé” az öröklési láncba. Hogy kellett volna ezt a metódust elnevezni? Bár dolgokat elnevezni nehéz, de talán a Reversed név az interfészen jó lett volna. Nekem azt is sugalmazza, hogy nem azt a példányt kapom, amin hívom, míg a Reverse az valamennyire sugalja, hogy azt a példányt módosítja. Ráadásul mivel egy szekvencia potenciálisan végtelen is lehet, ezért nem biztos, hogy megfordítható, így nem az IEnumerable<T> hanem az IList<T> típusra raktam volna csak rá a Reverse() műveletet.

Mit tanulhatunk ebből?

Még egyszer szeretném megköszönni Charlie Firpo kollégámnak a történetet, aki ezzel motivált, hogy jobban utánanézzek az extension methodoknak!