Bereitstellung einer Schnittstelle zur Erstellung von Familien verwandter oder abhängiger Objekte ohne
Abstrakte Fabrik – GoF
ihre konkreten Klassen zu spezifizieren.
Beispiel einer abstrakten Fabrik
Um dieses Entwurfsmuster besser zu verstehen, springen wir direkt in ein kleines Beispiel. Nehmen wir an, wir haben ein Spiel, das eine Karte/Landschaft hat (zum Beispiel Age of Empires oder ähnliche Strategiespiele). Das Spiel muss verschiedene Regionen unterstützen, wie z.B. Wald, Wüste usw. Je nach Region braucht es unterschiedliche Objekte, die dennoch eine Abstraktion verbindet. Im Wald gibt es Bäume aber in der Wüste wird es zum Beispiel Kakteen geben. Bäume und Kakteen können als Vegetation abstrahiert werden. Es stellt sich heraus, dass diese Denkweise auf viele verschiedene Objekte angewendet werden kann (z.B. Terrain, Nahrung, etc.). Es ist möglich, das Spiel völlig von der Region zu entkoppeln, wenn wir sagen, es gibt “Vegetation” oder “Gelände”. Auf diese Weise weiß das Spiel nur etwas über die anderen Klassen, ohne konkrete Implementierungen zu kennen.
Aber natürlich muss die Klasse Game konkrete Objekte verwenden. An dieser Stelle kommt die Fabrik ins Spiel. Sie bietet den zentralen Zugangspunkt zur Erstellung der konkreten Objekte. Je nach Region werden unterschiedliche Objekte erstellt. Das folgende UML-Diagramm veranschaulicht dies. Alle Klassen, die blau sind, sind High-Level-Klassen. Alle Klassen, die grün sind, sind waldbezogene Klassen. Alle Klassen, die gelb sind, sind wüstenbezogene Klassen.
SOLID Prinzipien
Bevor wir uns mit dem Code befassen, möchten wir über die SOLID-Prinzipien des OO-Designs sprechen. Dieses Entwurfsmuster leistet einen wichtigen Beitrag zu diesen Grundsätzen. Es entkoppelt High-Level-Klassen von Low-Level-Klassen durch die Verwendung von Schnittstellen (Interfaces). Dies führt zu einem wiederverwendbaren Code. Das Hinzufügen neuer Regionen hat keinen Einfluss auf die Klassen der höheren Abstraktionsebenen (es ist openclosed). Alle Klassen sind sehr kohärent (es folgt dem Single Responsibility). High-level Klassen sind nicht von Implementierungsdetails abhängig (es gilt das Dependency Inversion Prinzip).
Tatsächlich ist das abstrakte Fabrikmuster häufig anzutreffen, weil es einfach zu verstehen ist und dennoch zu sehr gut getrenntem Code führt.
Code Beispiel
open class Vegetation
class Cactus : Vegetation()
class Tree : Vegetation()
open class Terrain
class Sand : Terrain()
class Grass : Terrain()
interface Factory {
fun createTerrain() : Terrain
fun createVegetation() : Vegetation
}
class DesertFactory : Factory {
override fun createTerrain() : Terrain {
return Sand()
}
override fun createVegetation() : Vegetation {
return Cactus()
}
}
class ForrestFactory : Factory {
override fun createTerrain(): Terrain {
return Grass()
}
override fun createVegetation(): Vegetation {
return Tree()
}
}
class Game(val factory : Factory) {
private lateinit var terrain : Terrain
private lateinit var tree : Vegetation
init {
terrain = factory.createTerrain()
tree = factory.createVegetation()
}
}
Wie Sie sehen können, ist die Implementierung recht einfach. Kotlin bietet (wie Java) verschiedene Methoden zur Implementierung von Polymorphismus. Die erste ist die Verwendung von Basisklassen. Dieser Ansatz wurde für die Klassen Terrain und Vegetation verwendet. Die zweite Möglichkeit ist die Verwendung einer Schnittstelle (Interfaces), wie sie für die Fabrik verwendet wurde. Es hängt vom Kontext ab, welche Sie verwenden müssen, aber wir wollten beide Varianten im Code zeigen.
TDD – Test Driven Development
Die abstrakte Fabrik ist oft in Kombination mit testgetriebener Entwicklung zu sehen. Wie man sagt, führt TDD zu einem SOLID-Code, daher ist es keine Überraschung, dieses Muster bei der Durchführung von TDD zu finden.
Nehmen wir als Beispiel eine Funktion, die eine URL aufruft. Wenn der Aufruf erfolgreich ist (z.B. Statuscode 200), gibt sie die Zeichenkette “OK” zurück. Andernfalls gibt sie “FAILURE” zurück.
Eine Implementierung könnte wie folgt aussehen. Die Funktion callUrl ist diejenige, die getestet werden muss. Intern verwendet sie eine Klasse namens Network, die den eigentlichen Netzwerkzugriff durchführt (hier nicht implementiert). Dies könnte eine Coroutine oder etwas ähnliches sein.
class Network(private val url: String = "") {
private var errorCode = 200
fun isSuccessful() : Boolean {
return errorCode == 200
}
fun execute() {
}
}
fun callUrl(url : String) : String {
val network = Network(url)
network.execute()
if(network.isSuccessful()) return "OK"
return "FAILURE"
}
Dieser Code ist jedoch sehr schwierig, wenn nicht gar unmöglich zu testen. Der Zugriff auf das Netzwerk entzieht sich der Kontrolle durch die Tests. Dies könnte geändert werden, indem das abstrakte Fabrikmuster verwendet wird, um Mock-Netzwerkobjekte einzuführen. Das folgende Beispiel demonstriert dies.
interface Network {
fun isSuccessful() : Boolean
fun execute()
}
interface NetworkFactory {
fun createNetwork(url : String) : Network
}
class NetworkImpl(private val url: String = "") : Network{
private var errorCode = 200
override fun isSuccessful() : Boolean {
return errorCode == 200
}
override fun execute() {
}
}
class NetworkFactoryImpl : NetworkFactory {
override fun createNetwork(url : String) : Network {
return NetworkImpl(url)
}
}
class NetworkMock : Network{
override fun isSuccessful() : Boolean {
return true
}
override fun execute() {
}
}
class NetworkMockFactory : NetworkFactory {
override fun createNetwork(url : String) : Network {
return NetworkMock()
}
}
Die zu testende Funktion wird nur geringfügig geändert. Standardmäßig verwendet sie die konkrete Implementierung der Netzwerkfabrik. Während des Tests akzeptiert sie jedoch die Fabrik, die Mock-Objekte zurückgibt. Da wir die Kontrolle über diese Fabrik und damit über die Mock-Objekte haben, können wir das Verhalten des Netzwerks manuell steuern. Außerdem hat sich für den Client, der diese Funktion verwendet, nichts geändert. Die Signatur der Funktion ist die gleiche (dank des Standardparameters). Intern hat sich zwar nur eine Zeile geändert, aber die Codequalität hat sich dadurch erheblich verbessert, da die Funktion jetzt von einer Schnittstelle abhängt und nicht mehr von einem konkreten Objekt.
fun callUrl(url : String, factory : NetworkFactory = NetworkFactoryImpl()) : String {
val network = factory.createNetwork(url)
network.execute()
if(network.isSuccessful()) return "OK"
return "FAILURE"
}
Alternativen / Verwandte Muster
Es gibt einige Muster, die zusammen mit der abstrakten Fabrik verwendet werden oder als Ersatz dienen können.
Builder pattern
Das Builder-Muster wird in der Regel verwendet, wenn die konkreten Objekte so unterschiedlich sind, dass sie nicht die gleiche Schnittstelle haben. Die Konstruktion ist jedoch ähnlich.
Singleton pattern
Da es sich bei den Fabriken in der Regel um einzelne Instanzen handelt, ist es sinnvoll, ihre Erstellung zu kontrollieren. Das Singleton-Pattern stellt sicher, dass nur ein einziges Factory-Objekt erzeugt werden kann und dass es leicht zugänglich ist. Der Nachteil ist jedoch, dass es die Flexibilität etwas einschränkt.
Decorator pattern
Ein Vorteil der Verwendung dieses Musters ist, dass es einen perfekten Zugangspunkt bietet, um Objekten zusätzliches Verhalten hinzuzufügen, indem das Dekorator-Muster verwendet wird. Im vorherigen Beispiel über die Klasse Network könnten wir zum Beispiel einen Decorator hinzufügen, der jeden Netzwerkzugriff protokolliert. Dies kann getan werden, ohne die Klasse Network überhaupt zu berühren.
class LogNetworkDecorator (private var network : Network) : Network {
override fun isSuccessful(): Boolean {
return network.isSuccessful()
}
override fun execute() {
// log
return network.execute()
}
}
class NetworkFactoryImpl : NetworkFactory {
override fun createNetwork(url : String) : Network {
var obj = NetworkImpl(url)
return LogNetworkDecorator(obj)
}
}