31. 5. 2015

Refactoring antipatternu "god method"

Ve staticky typovaných jazycích, mezi něž patří i Java, je ve srovnání s dynamicky typovanými jazyky mnohem snazší refactoring. V tomto příspěvku popíšu způsob, jak jednoduše a přitom typově bezpečně vyřešit situaci, kdy potřebujeme na mnoha místech opravit jeden způsob volání metody na jiný.

Problém

V projektu existovala helper metoda pro kopírování fieldů z entity do DTO, jejíž rozhraní a použití vypadala takto:

public static <I,O> O copyProperties(I input, Class<O> output, String... ignored) {...}
...
FooDto dto = Helper.copyProperties(fooEntity, FooDto.class, "hoo");

a která byla volána na stovkách míst pro desítky různých tříd a kombinací varargs. Ze zpočátku užitečné metody se postupem času vyklubal antipattern nejčastěji označovaný jako God object, akorát aplikovaný na metodu. Udělám pro vás všechno, naházejte mi to sem, já si přeberu vstup a postarám se o instanciaci třídy. Další požadavky (typicky specifikování jiného způsobu transformace properties nebo řízení zapnutí/vypnutí transformace) by nutily přidávat další parametry a tím antipattern ještě více zabetonovaly. Proto bylo vytvořeno nové řešení založené na Guava Function:

FooDto dto = new FooDtoFunction().apply(fooEntity);


Nicméně, jak bezbolestně přejít od starého řešení k novému, aniž bychom šli cestou "vše zahodíme a napíšeme to znovu"? V podmínkách projektu by nebylo praktické překlopit volání jedním commitem. Jak zajistit migraci pouze pro malou část projektu, např. pro jednu třídu FooDto?

Řešení


Přímočarý způsob se nabízí: vyhledat volání textově pomocí regulárního výrazu např. copyProperties\(.*?, *FooDto\.class. To je časté u dynamických jazyků a i v Javě v některých případech nezbývá než to dělat takto. Výhodou je jednoduchost a to, že si procvičíme regulární výrazy. Nevýhodou je, že se tak nemusí najít 1:1 všechny výskyty (pro každý regex lze zkonstruovat situaci vedoucí k nenalezení nebo naopak falešně pozitivnímu nálezu), i když uznávám, že v praxi by pravděpodobně tyto situace byly okrajové a postižitelné jinak. Především je ale nevýhodou to, že se vzdáváme zacházení s kódem na úrovni struktury. Máme k dispozici pouze text, takže i pro další transformaci se s ním musí tak zacházet (typicky vymyslet replacement string, což neřeší importy atd.). Jak se udržet v blízkosti kódu?

Právě využitím statické typové kontroly v jazyce, konkrétně přetížení. Připomeňme si, že přetížená metoda se vybírá již při překladu. Pokud přidáme ještě následující přetížení metody copyProperties

public static <I> FooDto copyProperties(I input, Class<FooDto> outputClass, String... ignored) {
    return Helper.<I,FooDto>copyProperties(input, FooDto.class, ignored);
}

bude pro volání copyProperties(fooEntity,FooDto.class,"ignored","properties") vítězit tato metoda. V prvním kroku nová metoda pouze deleguje volání na původní metodu, ale už tu je otevřený prostor pro další úpravy: změnu implementace a nakonec pravděpodobně inlinování tohoto přetížení do volajícího kódu. Volání pro ostatní třídy přistanou na původní metodě, změna je tedy bezpečná a přitom ovlivní pouze tu část projektu, která je potřeba.

Základní princip pak může být obměňován:

Varianta: omezení generického typu


Někdy se copyProperties používá pro kopírování z DTO objektu do DTO. Je však žádoucí vybrat pouze kopírování z entit do DTO. Pokud všechny entity dědí z abstraktního předka, pomůže opět přetížení se specifičtější mezí generického parametru.

public static <I extends AbstractEntity> FooDto copyProperties(I input, Class<FooDto> outputClass, String... ignored) {
    return Helper.<I,FooDto>copyProperties(input, FooDto.class, ignored);
}

Varianta: přetížení pro konkrétní typy


Pokud je parametr příliš obecný, rozliší se volání konkrétního typu zavedením přetížení pro konkrétní typ nebo všechny konkrétní typy.

Příklad:

public void doWith(Fruit fruit) {...}

Pokud chceme vysledovat volání a upravit logiku pouze pro jablka, použijeme refactoring:

public void doWith(Apple apple) {...}
public void doWith(Fruit fruit) {...}

Pokud chceme vysledovat volání pro všechny konkrétní typy a zajistit, že se obecná metoda nevolá, použijeme refactoring:

public void doWith(Apple apple) {...}
public void doWith(Pear pear) {...}
public void doWith(Orange orange) {...}
public void doWith(Banana banana) {...}
private void doWith(Fruit fruit) {...}

U obecné metody nejprve minimalizujeme viditelnost (tím vyskáčou volání z jiných tříd jako chyby a v téže třídě už se výskyt snadno ohlídá) a později ji úplně odstraníme.

Aby to fungovalo, předpokládá se, že skutečný parametr je deklarován s konkrétním typem, tj. Apple apple = ...; doWith(apple);, nikoli Fruit apple = ...; doWith(apple);. To může být někdy problém, ale pokud se držíme smyslu, proč refactoring provádíme, zpravidla se vždy nějak rozhodne.

Varianta: migrace pro Guava funkci

K původní metodě copyProperties existovala ještě další za účelem použití jako Guava funkce při transformaci kolekcí (podtržítko na začátku je projektová konvence pro usnadnění budoucího přechodu na Java8 operátor ::):

public static <I,O> Function<I,O> _copyProperties(Class<O> output, String... ignored) {...}
...
List<FooDto> dtoList = Lists.transform(entityList,_copyProperties(FooDto.class,"hoo");

Naneštěstí, na rozdíl od základní varianty je signatura

public static <I> Function<I,FooDto> _copyProperties(Class<FooDto> outputClass, String... ignored) {...}

po type erasure úplně stejná a v této podobě tedy překladač hlásí chybu. Protože pro její odstranění stačí jen to, aby metoda vrátila specifičtějšího potomka, nabízí se následující workaround:

public interface Function2<I,O> extends Function<I,O> {}
public static <I> Function2<I,FooDto> _copyProperties(Class<FooDto> outputClass, String... ignored) {
    return (Function2<I,FooDto>)Helper.<I,FooDto>_copyProperties(FooDto.class, ignored);
}

Ztratí se tím sice možnost metodu volat (cast by selhal na ClassCastException), ale struktura aspoň zůstane zachována pro refactoring a call hierarchy.


Varianta: rozklad varargs


Dalším příkladem přetížení je likvidace varargs parametrů, které nejdou inlinovat. (Samotný inline v Eclipse nefunguje, protože tělo metody je příliš složité.)


Metodu:

public static void monster(Foo... parameters) {
        for (Foo parameter : parameters) {
            doWith(parameter)
        }
}

rozdělíme na dvě:

public static void monster() {}

public static void monster(Foo parameter1, Foo... rest) {
        doWith(parameter1);
        for (Foo parameter : rest) {
            doWith(parameter)
        }
}

Každé z dosavadních volání pak spadne do jednoho z nově vzniklých přetížení. První přetížení bez varargs už jde bez problémů inlinovat. Pro druhé přetížení (s varargs) zjistíme Call Hierarchy.
Pokud se nikde nevolá, odstraníme ho. Pokud se volá, odřízneme z varargs další argument (vznikne metoda monster(Foo parameter1, Foo parameter2, Foo... rest) a postup opakujeme (případně, pokud zbývá jediné volání, můžeme signaturu rovnou přizpůsobit tomuto volání).

Závěr


Refactoring s využitím přetížení se osvědčil jako užitečná technika umožňující přesně zacílit rozsah změn. Současně je zachována typová bezpečnost a bezpečnost transformací kódu během jednotlivých kroků. Nevýhodou je - stejně jako u každého refactoringu - omezení na živý projekt v IDE, takže do modulů, které na sobě závisejí pouze na úrovni jaru, samozřejmě nedosáhne. Myšlenku přetížení je možné omezeně použít i v případech, kdy sice nelze zachovat bezpečnost za překladu, ale kdy se pořád vyplatí do přetížené metody dát alespoň vyhození UnsupportedOperationException, rozlišení dle volající metody (v Javě ze stacktracu) nebo jinou reakci, která se projeví až za běhu.

Pro úspěšné použití refactoringu není bezpodmínečně nutné umět zpaměti odrecitovat pravidla výběru přetížené metody nebo být kovaný v rozdílech mezi přetěžování a překrýváním v souvislosti s generikami. Sám to taky do takových podrobností neznám. Na druhou stranu znalost těchto věcí jistě napomáhá v situaci, kdy je třeba se z nějakých špaget vymotat. Vždy mne těší, když nějaký jazykový rys, který je popsán hromadou paragrafů a běžně ho považujeme za dobrý tak maximálně pro složení certifikační zkoušky, prokáže využití v praxi. A to je myslím právě tento případ.


Žádné komentáře:

Okomentovat