Kotlin Factory Method

Przeznaczenie

Podobnie jak Builder, Factory Method jest wzorcem konstrukcyjnym. Określa interfejs do tworzenia obiektów. Zamiast wprost wywoływać konstruktor, możemy wywołać metodę obiektu-wytwórni, która generuje implementację interfejsu. W odróżnieniu od Buildera, zazwyczaj nie będzie nas interesowało podanie wszystkich wymaganych przez obiekt argumentów i zależności — to będzie należeć do zadań Factory.

Fabryka może dostarczać obiekty różnych typów implementujących ten sam interfejs, wyłącznie na podstawie dostarczonych argumentów. Całkowitemu odizolowanie implementacji od interfejsu pozwala na podmianę implementację w runtime, a nie tylko w czasie kompilacji.

Korzystając z Fabryki, mówimy mniej więcej: “wiem tylko to i tamto, daj mi poprawny obiekt implementujący dany interface”. Przykład: Locale.forLanguageTag("pl-PL") - dostarczy nam obiekt Locale, z którego wyciągniemy sobie pełną nazwę kraju lub języka. W przypadku buildera, byłoby to raczej: “daj mi obiekt, który będzie miał ustawione to i tamto a resztę zostaw domyślne”. Fabryki często mają wstrzykiwane zależności, które pozwalają im na tworzenie rozbudowanych obiektów z minimalną ilością informacji dostarczonych przez klienta.

Jest kilka wariantów tego wzorca:

  • Static Factory Method (już opisany tutaj )
  • “Typowe” Factory Method
  • Abstract Factory

W tym poście chciałbym opisać Factory Method, zostawiając Abstract Factory na osobny wpis.

Przykłady implementacji

Factory Method może występować pod kilkoma postaciami. Najważniejszą cechą jest poleganie wyłącznie na interfejsach zamiast na konkretnych implementacjach.

Przykład bandy czworga (prosty)

Jest to przykład bardzo podstawowy, pochodzący z książki “Wzorce Projektowe”1 tzw. bandy czworga (Gamma, Helm, Johnson, Vlissides)

// Podstawowe użycie Factory Method
val creator: Creator = ConcreteCreator()
val concreteProduct: Product = creator.factoryMethod()
concreteProduct.doStuff()

// nadal można utworzyć instancję ConcreteProduct przez konstruktor
val concreteProduct2 = ConcreteProduct()
ProductCreatorfactoryMethod():ProductConcreteCreatorfactoryMethod():ProductConcreteProductreturn ConcreteProduct()creates

Elementy

  • Product - interface instancji dostarczanej przez fabrykę
  • ConcreteProduct - implementacja interfejsu, czyli konkretny obiekt, który zostanie zbudowany
  • Creator - interface fabryki, zawiera deklarację metody wytwarzającej instancję typu Product
  • ConcreteCreator - implementacja fabryki budująca instancję Product, w tym wypadku ConcreteProduct

Implementacja

interface Product {
    fun doStuff()
}
// "konkretny" produkt implementujący interface
internal class ConcreteProduct : Product {
    override fun doStuff() {
        println("ConcreteProduct is doing stuff")
    }
}

interface Creator {
    // metoda fabryczna dostarczająca instancję obiektu implementującego interface `Product`
    fun factoryMethod() : Product
}

// "konkretna" implementacja fabryki, wytwarzająca `ConcreteProduct`
class ConcreteCreator: Creator {
    // zwracany typ musi być ogólny, 
    // zwracanie `ConcreteProduct` który jest internal powoduje błąd kompilacji
    override fun factoryMethod() : Product {
        println("ConcreteCreator is using factory method")
        return ConcreteProduct()
    }
}

W Javie brak odpowiednika internal class, ale wydaje się dobrze pasować do powyższego zastosowania. Użycieinternal nie pozwala wyciekać wewnętrznym typom, co jest przydatne, jeśli tworzymy bibliotekę i nie chcemy zdradzać konkretów implementacji.

Ciekawszy przykład od bandy czworga

W tej samej książce znajduje się ciekawszy przykład. Załóżmy, że mamy aplikację, w której możemy tworzyć i następnie modyfikować figury geometryczne. Każda figura będzie miała jakieś współrzędne i na przykład sposób obliczania pola. Łatwo sobie wyobrazić, że każdy typ figury będzie się inaczej skalować lub zmieniać jej położenie. Zamiast tworzyć metody wewnątrz figury, można stworzyć nowy obiekt — Manipulator, który będzie wiedział jak zmienić położenie wierzchołków danej figury. Jednak nie chcemy, żeby klient naszego API musiał znać szczegóły implementacyjne Manipulatora albo Figury.

// utworzenie instancji przez konstruktor
val square = Square()
// użycie manipulatora dla kwadratu, ale przez generyczny interface
square.createManipulator().drag()

val figureFactory: FigureFactory = ByTypeFactory()
// stworzenie koła przez fabrykę przyjmującą `enum` z oczekiwanym rodzajem figury
val circle: Figure = figureFactory.createFigure(FigureFactory.Type.Circle)
// analogiczna modyfikacja figury
circle.createManipulator().drag()
FigurecreateManipulator(): FigureManipulatorFigureManipulatordrag()resize(scale: Float)ClientCirclecreateManipulator(): FigureManipulatorSquarecreateManipulator(): FigureManipulatorCircleManipulatorfigure:Circledrag()resize(scale: Float)SquareManipulatorfigure:Squaredrag()resize(scale: Float)

Elementy

  • Figure - interface figury implementowany przez klasy Circle, Square i Line
  • FigureManipulator - interface obiektu umożliwiającego modyfikację figury
  • CircleManipulator - implementacja FigureManipulator dla konkretnej figury, np Circle

Implementacja

Figure

interface Figure {
    // Figure potrafi stworzyć dla siebie FigureManipulator, 
    // ale dla klienta jest on generyczny a nie konkretny.
    fun createManipulator(): FigureManipulator<out Figure> // metoda wytwórcza
}

// `internal` oznacza że klasa nie jest dostępna poza modułem, 
// jest dostępna tylko dla klas z którymi jest kompilowana.
// Jest to przydatne podczas tworzenia bibliotek kiedy nie chcemy wyciekać konkretnych typów
internal class Circle: Figure {
    // w razie czego manipulator może zostać zastąpiony innym, bez konieczności aktualizacji klienta
    override fun createManipulator() = CircleManipulator(this)
}

internal class Square : Figure {
    override fun createManipulator() = SquareManipulator(this)
}

internal class Line : Figure {
    override fun createManipulator() = LineManipulator(this)
}

Manipulator

Interface Manipulatora pozwala na przeciąganie i zmianę rozmiaru. Konkretna implementacja tego interfejsu jest ściśle związana z typem figury.

// wykorzystanie generyków zapewnia, że konkretny FigureManipulator będzie umiał obsłużyć 
// tylko konkretny typ figury np. CircleManipulator nie przyjmie argumentu typu Square
interface FigureManipulator<T : Figure> {
    fun drag()
    fun resize(scale: Float)
}

// typ argumentu w konstruktorze musi zgadzać się z zadeklarowanym przez FigureManipulator
internal class CircleManipulator<T>(private val figure: T) : FigureManipulator<Circle> {
    override fun drag() = println("CircleManipulator is manipulating circle $figure")
    override fun resize(scale: Float) = println("CircleManipulator is resizing circle $figure")
}
internal class SquareManipulator<T>(private val figure: T) : FigureManipulator<Square> {...}
internal class LineManipulator<T>(private val figure: T) : FigureManipulator<Line> {...}

Factory

interface FigureFactory {
    // umieszczenie enuma wewnątrz klasy Factory zamiast Figure,
    // to fabryka zna typy produkowanych przez siebie obiektów, 
    // a nie sam obiekt wie w jakich formach występuje
    enum class Type { Circle, Square, Line, Undefined }

    fun createFigure(type: Type): Figure
}

// Częste wykorzystanie factory do zwracania obiektu danego typu z wykorzystaniem enuma
class ByTypeFactory : FigureFactory {
    override fun createFigure(type: FigureFactory.Type): Figure =
        when (type) { // wystąpi błąd kompilacji jeśli nie obsłużymy wszystkich typów
            FigureFactory.Type.Circle -> Circle()
            FigureFactory.Type.Square -> Square()
            FigureFactory.Type.Line -> Line()
            // typ Undefined nie jest obsłużony, ale wpadnie w `else`
            else -> throw Exception("unknown figure, don't know how to create it")
        }
}

FigureFactory zawiera w sobie enum odpowiadający typom figur, jakie dostarcza. Nie są to bezpośrednio typy samych obiektów, a jedynie pomocniczy typ wyliczeniowy. Takie podejście ma kilka zalet:

  • Nie wyciekamy wewnętrznych typów konkretnych implementacji. Fabryka udostępnia jedynie enum a klient widzi jedynie bazowy interfejs obiektow a nie konkretne typy.
  • Aby użyć typu wyliczeniowego, trzeba się do niego odwołać przez interface np. FigureFactory.Type.Circle. Zmniejsza ryzyko użycia złego typu, jeśli korzystamy z wielu fabryk w jednym miejscu i każda ma swój enum Type zadeklarowany w pliku, a nie w interfejsie. Takiego problemu można również uniknąć nazywając enum mniej ogólnie, np. FigureType, ale nadal inna fabryka może chcieć korzystać z takiej nazwy.
  • Figure nie wie tego, pod jakimi postaciami może występować, ale Fabryka wie jakie instancje może dostarczać.
  • Poza tym enum FigureType ma sens wyłącznie użyty z FigureFactory. Umieszczenie go poza tą klasą, może sugerować, że warto go również użyć w zupełnie innym kontekście… co może prowadzić do problemów z utrzymaniem kodu w przyszłości.

Inne implementacje Factory

Obiekty anonimowe (bez dokładnej klasy, ale implementujące interface) również mogą być dostarczane przez Fabrykę:

// fabryka może dostarczać obiekty anonimowe, dla klienta to bez znaczenia
class UndefinedFigureFactory : FigureFactory {
    // niezależnie od parametru `type` zwrócony będzie taki sam obiekt
    override fun createFigure(type: FigureFactory.Type) = object: Figure {
        // z takim samym manipulatorem
        override fun createManipulator() = object: FigureManipulator<Figure> {
            override fun drag() = println("UndefinedFigure dragging")
            override fun resize(scale: Float) = println("UndefinedFigure resizing")
        }
    }
}

// użycie
UndefinedFigureFactory()
        .createFigure(FigureFactory.Type.Circle) // w tym przypadku typ nie ma znaczenia
        .createManipulator() // ale API obiektu jest takie samo
        .drag()

Często podczas testowania nie potrzebujemy konkretnego obiektu, a jedynie zaślepkę lub Mock, który pozwoli nam zweryfikować poprawność działania programu. Można to łatwo osiągnąć, jeśli mamy interfejs Fabryki i możemy zaimplementować jej wersję pod testy, a następnie wstrzyknąć tam, gdzie jest używana:

// w testach czasami przydaje się zastąpienie prawdziwego factory jakąś zaślepką
class FakeFactory : FigureFactory {
    override fun createFigure(type: FigureFactory.Type): Figure {
        return FakeFigure()
    }
}
// dla klienta to nadal bez znaczenia jaki dostaje obiekt, tak długo jak implementuje `Figure`
class FakeFigure : Figure {
    override fun createManipulator(): FigureManipulator<out Figure> {
        return FakeFigureManipulator()
    }
}

class FakeFigureManipulator : FigureManipulator<FakeFigure> {
    override fun drag() = println("FakeFigure dragging")
    override fun resize(scale: Float) = println("FakeFigure resizing")
}

// użycie
val figure = FakeFactory()
                .createFigure(FigureFactory.Type.Circle) // to raczej nie będzie kółko...
figure.createManipulator().drag() // ale działa jak kółko :)

Można się nawet pokusić o losowe wybieranie implementacji fabryki. Nie ma to za bardzo sensu w przypadku figur geometrycznych, ale proceduralne generowane elementów mapy czy przeciwników w grze wydaje się już całkiem dobrym przykładem.

// losowo zwraca ByTypeFactory lub FakeFactory, 
// dla klienta to bez różnicy bo i tak zna tylko interface FigureFactory
object RandomFigureFactory {
    fun getFigureFactory(): FigureFactory = if (Random.nextBoolean()) ByTypeFactory() else FakeFactory()
}

// użycie
RandomFigureFactory.getFigureFactory() // losowo wybrane FigureFactory, albo Fake albo ByType
        .createFigure(FigureFactory.Type.Circle)
        // nie mamy pewności jaki obiekt dostaniemy, ale zawsze będzie to Figure
        .createManipulator().drag() // więc można ten obiekt zmieniać zgodnie z jego API

Sealed class

Załóżmy, że mamy w aplikacji 3 bazy danych: MySQL, Realm i MongoDB. Mimo tego, że są zupełnie różne (SQL, obiektowa, No-SQL), to udostępniamy je klientom schowane za wspólnym interfejsem Database. Aby ułatwić sobie korzystanie z konkretnej bazy, użyjemy fabryki, która dostarczy nam instancję bazy tylko na podstawie konfiguracji.

// różne konfiguracje baz danych
val mySqlConfig = MySqlConfig("dbAddress", "port")
val realmConfig = RealmConfig // object czyli Singleton nie ma konstruktora
val mongoDbConfig = MongoDbConfig("fileUri", "table")

// nie wiemy jaką instancję bazy dostaniemy z fabryki
val db: Database = DatabaseFactory.getDatabaseForConfig(mySqlConfig)
// znamy tylko generyczny interfejs
db.save("Save me!")
Databasesave(item: Any)getItem(id: Int)«Sealed Class»DatabaseConfigMySqlConfigaddressportRealmConfigdbNameMongoDBConfigfileUritableNameMySqlconfig:MySqlConfigsave(item: Any)getItem(id: Int)Realmconfig:RealmConfigsave(item: Any)getItem(id: Int)MongoDBconfig:MongoDBConfigsave(item: Any)getItem(id: Int)FactorygetDatabaseForConfig(config: DatabaseConfig): DatabaseBadConfig
// wspólny interface bazy udostępniany klientowi
interface Database {
    fun save(item: Any)
    fun getItem(id: Int)
}

// konkretna implementacja bazy przyjmująca konfigurację w konstruktorze
internal class MySql(val config: MySqlConfig) : Database {
    override fun save(item: Any) = println("saving $item in MySQL")
    override fun getItem(id: Int) = println("getting item at $id from MySQL")
}
internal class Realm(val config: RealmConfig) : Database {...}
internal class MongoDB(val config: MongoDbConfig) : Database {...}

Nie ma tutaj podawania typu bazy z enum jak poprzednio, tylko cały obiekt konfiguracji z właściwymi tylko dla siebie polami.

Udało się to osiągnąć dzięki Kotlinowym klasom sealed. Mogą po niej dziedziczyć wyłącznie klasy zadeklarowane w tym samym pliku, mamy więc ścisłą kontrolę nad typami pochodnymi. Niektórzy nazywają to nawet “enumem na sterydach”, chociaż sam enum jest pełnoprawną klasą i również może mieć pola i metody, a nie tylko nazwę.

sealed class DatabaseConfig

// od Kotlin 1.1 można używać `data class` i tworzyć je poza `sealed` klasą, 
// ale tylko w tym samym pliku
data class MySqlConfig(val address: String, val port: String) : DatabaseConfig()
object RealmConfig : DatabaseConfig() // z klasy `sealed` często dziedziczą Singletony

// po tej klasie `open` można dziedziczyć również poza plikiem z klasą `sealed`
open class MongoDbConfig(val fileUri: String, val tableName: String) : DatabaseConfig()

// Fabryka dostarczająca implementację Database pod konkretną konfigurację
object DatabaseFactory {
    // metoda przyjmuje generyczny interface i zwraca generyczną instancję Database
    fun getDatabaseForConfig(config: DatabaseConfig): Database {
        // when w połączeniu z sealed class daje pewność obsłużenia wszystkich przypadków,
        // lub błąd kompilacji
        return when (config) {
            is MongoDbConfig -> MongoDB(config)
            is MySqlConfig -> MySql(config)
            is RealmConfig -> Realm(config)
            
            // `BadConfig` rozszerza `MongoDbConfig` ale brak tego przypadku nie spowoduje błędu, 
            // bo `BadConfig` jest `MongoDbConfig`, więc ten przypadek zostanie obsłużony
            is BadConfig -> MongoDB(config) 
        }
    }
}

Tymczasem w innym pliku:

// błąd, nie można rozszerzać klasy sealed poza plikiem gdzie się znajduje
data class BadConfig(val badData: String): DatabaseConfig()
// klasa MongoDbConfig jest open, więc można z niej dziedziczyć
data class BadConfig(val badData: String): MongoDbConfig("", "")

Jednak samo używanie klas open powinno być dobrze przemyślane, a w przypadku klas już rozszerzających klasę sealed wygląda na antywzorzec, podający w wątpliwość sens użycia sealed.

Rejestrowane fabryki

W książce “Thinking in Java”2 Bruce Eckel opisał ciekawy przykład zastosowania Static Factory Method (opisany tutaj) w połączeniu z fabryką z rejestrem. Ogólnie chodzi o to umożliwienie dodawania obiektów z własnymi fabrykami, implementującymi jakiś wspólny interfejs, do rejestru nadrzędnej fabryki dostarczającej ich instancje — bez znajomości konkretnych typów obiektów. Poprzednio Fabryka sama definiowała te typy jak Square, Circle, Line lub mieliśmy skończoną liczbę klas dziedziczących z sealed class, a w tym przypadku rola fabryki ogranicza się w zasadzie do uruchomienia Static Factory Method zarejestrowanego typu. Sam obiekt i jego wewnętrzna fabryka może pochodzić z dowolnego miejsca i nie być znany nadrzędnej fabryce w momencie kompilacji — tak długo, jak implementowane są odpowiednie interfejsy.

// AirFilter ma companion object z metodą `create()` czyli Static Factory Method
val airFilter: Part = AirFilter.create()
val fuelFilter: Part = FuelFilter.create()
val fuelFilter: Part = FuelFilter() // błąd, prywatny konstruktor na to nie pozwala

// rejestrowanie factory, nazwa klasy wskazuje na companion object
RandomPartCreator.registerFactory(AirFilter)
RandomPartCreator.registerFactory(FuelFilter)
RandomPartCreator.registerFactory(OilFilter)
// zarejestrować można też fabrykę nie będącą companion object
RandomPartCreator.registerFactory(EngineFactory())

// tworzymy 10 instancji
repeat(10) {
    // losowe tworzenie instancji, nie znamy tutaj konkretnego typu
    val randomPart = RandomPartCreator.createRandomPart()
    println(randomPart.description())
    // no chyba że sprawdzimy
    println("is it an AirFilter? ${randomPart is AirFilter}")
}

Od środka wygląda to następująco:

PartPartFactoryT: Partcreate():TAirFiltercompanion objectconstructor()«CompanionObject»AirFilterFactoryAirFiltercreate():AirFilterFuelFiltercompanion object«CompanionObject»FuelFilterFactoryFuelFiltercreate():FuelFilterEngineEngineFactoryEngineRandomPartCreatorpartFactories: Set<PartFactory>registerFactory(factory: PartFactory)createRandomPart(): Part
// interface implementowany przez każdą część dostarczaną przez nadrzędną Fabrykę
interface Part {
    fun description(): String
}

// interface dla companion object, dostarczający konkretny typ części rozszerzający `Part`
interface PartFactory<T : Part> {
    fun create(): T
}

// implementacja konkretnej części z prywatnym konstruktorem
internal class FuelFilter private constructor() : Part {
    // companion object implementujący interface
    companion object Factory : PartFactory<FuelFilter> {
        // Static Factory Method dostarczająca instancję zgodną z zadeklarowanym typem
        override fun create(): FuelFilter = FuelFilter()
    }
    override fun description() = "I'm a Fuel Filter"
}
internal class AirFilter private constructor() : Part {...}
internal class OilFilter private constructor() : Part {...}

// klasa bez companion object nadal może być użyta
class Engine: Part {
    override fun description() = "I'm an Engine!"
}
// fabryka poza obiektem
class EngineFactory : PartFactory<Engine>{
    override fun create(): Engine {
        // klasa Engine nie może mieć jednak prywatnego konstruktora
        return Engine()
    }
}
object RandomPartCreator {
    // rejestr wewnętrznych fabryk, czyli Static Factory Method
    // Set zapewnia brak duplikatów
    // typ tworzony przez PartFactory musi rozszerzać `Part`
    private val partFactories = mutableSetOf<PartFactory<out Part>>()

    // dodanie nowej fabryki do rejestru
    fun registerFactory(factory: PartFactory<out Part>) {
        partFactories.add(factory)
    }

    fun createRandomPart(): Part { // tzw. generator - tworzy instancję bez podawania parametrów
        // losowa liczba z zakresu ograniczonego rozmiarem rejestru
        val randomFactory = Random.nextInt(partFactories.size)
        // wywołanie metody `create()` na wylosowanej fabryce
        // niestety nie da się wyciągnąć n-tego elementu z Set-u bez zmiany typu na listę
        return partFactories.toList()[randomFactory].create()
    }
}

Nic nie stoi na przeszkodzie, żeby rejestr w Fabryce był bardziej rozbudowany, np. do postaci mapy gdzie kluczem będzie typ wyliczeniowy a zawartością Statyczna Metoda Fabryczna.

Podsumowanie

Fabryka jest często używanym wzorcem, zwłaszcza w formie ze zwracaniem obiektów na podstawie enuma. Mimo, że występuje w wielu formach, zasada jest zawsze ta sama: odizolowanie tworzenia obiektu od jego implementacji schowanej za interfejsem. W przypadku posiadania rodziny obiektów znacząco to uelastycznia program, pozwalając sprawnie podmieniać obiekty i wygodnie tworzyć mocki w testach.

Kotlin bardzo pomaga w chowaniu implementacji przez internal klasy, oraz obsłudze wszystkich podklas dzięki klasom sealed.

Zalety

  • całkowite oddzielenie implementacji od interfejsu - z poziomu klienta fabryki interesuje nas wyłącznie interfejs obiektu, co pozwala na łatwe dodawanie nowych implementacji bez konieczności zmian klienta.
  • ograniczenie widoczności typów - jest to przydatne, jeśli tworzymy bibliotekę i chcemy schować wewnętrzną implementację przed użytkownikiem.
  • łatwość testowania - już sam fakt polegania na interfejsach, zamiast konkretnej implementacji, pozwala łatwo stworzyć Mocka lub Stuba. W połączeniu z możliwością wstrzykiwania całej, skonfigurowanej na potrzeby testu fabryki, ułatwia to wyizolowanie testowanej logiki i skupienie się na tym, co faktycznie chcemy przetestować.
  • reużywanie obiektów - podobnie jak w przypadku Buildera, zwrócony obiekt niekoniecznie musi być nową instancją. Konstruktor zawsze zwraca nowy obiekt, ale Fabryka może mieć jakiś wewnętrzny cache i jeśli ma to sens zwrócić utworzony wcześniej obiekt. Chociaż może się to trochę kłócić z intuicyjnym rozumieniem “fabryki”, która wytwarza nowe elementy, np. fabryka samochodów produkuje nowe auta, zamiast je tylko wypożyczać z parkingu.

Wady

  • konieczność tworzenia dodatkowych klas - interfejsów, fabryk, typów wyliczeniowych itd. Niekoniecznie jest to wada, zwłaszcza jeśli chodzi o interfejsy, bo znacząco pomaga to potem testować kod. Należy wykazać się tutaj rozsądkiem, jeśli dostarczamy instancje pojedynczej klasy i nie ma perspektyw na zwiększenie liczby typów, to może nie ma potrzeby tworzyć ten cały boilerplate.


Adam Świderski

software engineer