A minap egy kolléga (legyen a fedőneve mondjuk Charlie Firpo) egy érdekes rejtett “finomságra” hívta fel a figyelmem egy refaktorálás tapasztalataiból, a hiba megfejtése után. A probléma magjára redukálva könnyen érthető, de termelé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ünk í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ány-szintaxissal 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?
- Használjunk olyan programnyelvet, amiben ha nincs értékül adva/felhasználva egy visszatérési érték, vagy nincs explicit jelölve, hogy nem érdekel minket, akkor az a program le se forduljon! Ha nem ilyen nyelvben dolgozunk (C# 😏), akkor használjunk statikus analízist, ami jelzi ezeket a potenciális hibákat!
- Ne írjunk extension methodokat, ha csak nincs rájuk szükség!
- Figyeljünk a metódusok elnevezésére, és a visszafele kompatibilitásra, különösen az extension metódusaink elnevezésére! Mi ne ássunk efféle csapdákat könyvtáraink felhasználóinak!
- Tegyük extension methodjaink olyan névtérbe, hogy mindig explicit kelljen bekapcsolni őket!
- Írjunk unit-teszteket! Soha ne refaktoráljunk amíg nincsenek tesztek az érintett kódra!
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!