Structural Design Patterns in Scala — Part 2
In the previous article here, I discussed the structural design patterns (adapter, bridge, composite) and presented code examples in Scala to get better understanding. In this article, I will continue to describe the remaining patterns (decorator, facade, flyweight and proxy). As mentioned before, the use of structural design pattern comes in handy when composing classes and objects for new modules or integrating multiple modules. Let’s go over the remaining patterns one by one.
Decorator Pattern:
The Decorator pattern is a design pattern that allows you to add new behavior or features to an object without modifying its underlying code. In software development, this pattern enables you to extend the functionality of a class by creating decorator classes that wrap around the original class. These decorators add additional features to the object being decorated, without altering its core functionality.
Here’s how the Decorator pattern works:
Component: This is the common interface or abstract class representing the base object that needs to be decorated. It defines the basic operations that decorators and the base object share.
Concrete Component: This is the implementation of the Component interface, representing the original class that you want to enhance with additional features.
Decorator: This is the abstract class that also implements the Component interface but has an additional instance variable to hold the reference to the Component. Decorators provide a common interface for all concrete decorators.
Concrete Decorator: These are the classes that extend the Decorator class and add new features to the component. Each concrete decorator can add specific behavior before or after delegating to the wrapped component.
We can use a coffee shop example to present the Decorator pattern, where just Coffee is the concrete component and Coffee with condiments are the decorators.
/ Component: Coffee (interface or abstract class)
trait Coffee {
def cost(): Double
def description(): String
}
// Concrete Component: BasicCoffee
class BasicCoffee extends Coffee {
def cost(): Double = 2.0
def description(): String = "Basic Coffee"
}
// Decorator: CondimentDecorator
abstract class CondimentDecorator(coffee: Coffee) extends Coffee {
def cost(): Double = coffee.cost()
def description(): String = coffee.description()
}
// Concrete Decorator: Milk
class Milk(coffee: Coffee) extends CondimentDecorator(coffee) {
override def cost(): Double = super.cost() + 1.0
override def description(): String = super.description() + ", Milk"
}
// Concrete Decorator: Sugar
class Sugar(coffee: Coffee) extends CondimentDecorator(coffee) {
override def cost(): Double = super.cost() + 0.5
override def description(): String = super.description() + ", Sugar"
}
// Client code
object DecoratorPattern extends App {
// Ordering a basic coffee
var coffee: Coffee = new BasicCoffee()
println(s"Cost: ${coffee.cost()}, Description: ${coffee.description()}")
// Adding milk to the coffee
coffee = new Milk(coffee)
println(s"Cost: ${coffee.cost()}, Description: ${coffee.description()}")
// Adding sugar to the coffee
coffee = new Sugar(coffee)
println(s"Cost: ${coffee.cost()}, Description: ${coffee.description()}")
}
As seen for the above code, The Decorator pattern allows us to dynamically add or remove features to objects, making it easy to create flexible and extensible systems. With decorators, we can add new functionality without modifying the original classes, promoting code re-usability and maintainability.
Facade Pattern:
The Facade pattern is a design pattern that simplifies the interactions with a complex system by providing a unified and user-friendly interface. Instead of dealing with multiple complex classes and their interactions directly, you have a single facade class that hides the complexities and provides a straightforward way to use the system. It helps reduce dependencies and promotes better organization and maintainability in the codebase.
Let’s take an example of movieTheater which has multiple counters for first getting a ticket , then another to choose and reserve a seat and third to buy food. These subsystems can unified by a movieTheaterFacade which provides a single window for all subsystems.
// Theater Subsystem classes
class TicketCounter {
def buyTicket(movieName: String): Unit = {
println(s"Ticket purchased for movie: $movieName")
}
}
class SeatReservation {
def reserveSeat(movieName: String, seatNumber: Int): Unit = {
println(s"Seat $seatNumber reserved for movie: $movieName")
}
}
class FoodCounter {
def orderPopcorn(): Unit = {
println("Popcorn ordered.")
}
def orderSoda(): Unit = {
println("Soda ordered.")
}
}
// Facade class
class MovieTheaterFacade {
private val ticketCounter = new TicketCounter()
private val seatReservation = new SeatReservation()
private val foodCounter = new FoodCounter()
def buyMovieTicket(movieName: String, seatNumber: Int): Unit = {
ticketCounter.buyTicket(movieName)
seatReservation.reserveSeat(movieName, seatNumber)
}
def buyMovieTicketWithSnacks(movieName: String, seatNumber: Int): Unit = {
buyMovieTicket(movieName, seatNumber)
foodCounter.orderPopcorn()
foodCounter.orderSoda()
}
}
// Client code
object FacadePattern extends App {
val theaterFacade = new MovieTheaterFacade()
// Buying a movie ticket
theaterFacade.buyMovieTicket("Avengers: Endgame", seatNumber = 5)
// Buying a movie ticket with snacks
theaterFacade.buyMovieTicketWithSnacks("The Matrix", seatNumber = 8)
}
As seen above, the facade pattern clubs multiple subsystem to provides a simplified interface for external users hiding the internal complexities.
Flyweight Pattern:
The Flyweight pattern aims to minimize memory usage by sharing common data between multiple objects. It is useful when you have a large number of objects that have some shared intrinsic (inherent) state and some extrinsic (contextual) state that can be externalized.
Simply put, the flyweight pattern is based on a factory which recycles created objects by storing them after creation. Each time an object is requested, the factory looks up the object in order to check if it’s already been created. If it has, the existing object is returned — otherwise, a new one is created, stored and then returned.
To implement the Flyweight pattern, you typically have two main components:
Flyweight: This is the interface or abstract class that defines the operations shared by multiple objects. It also declares methods for setting and getting the extrinsic state, if any.
ConcreteFlyweight: This is the implementation of the Flyweight interface that represents the shared objects. Instances of this class are shared among multiple contexts.
The below code demonstrate the Flyweight pattern with a drawing application that uses different colored pens to draw shapes on a canvas.
// Flyweight: Pen
trait Pen {
def draw(x: Int, y: Int): Unit
}
// ConcreteFlyweight: ConcretePen
class ConcretePen(color: String) extends Pen {
def draw(x: Int, y: Int): Unit = {
println(s"Drawing at ($x, $y) with ${color.capitalize} pen")
}
}
// FlyweightFactory: PenFactory
object PenFactory {
private val pens: collection.mutable.Map[String, Pen] = collection.mutable.Map()
def getPen(color: String): Pen = {
pens.getOrElseUpdate(color, new ConcretePen(color))
}
}
// Client code: DrawingApp
class DrawingApp {
def drawShape(shape: String, x: Int, y: Int, color: String): Unit = {
val pen = PenFactory.getPen(color)
println(s"Drawing $shape at ($x, $y) with ${color.capitalize} pen")
pen.draw(x, y)
}
}
// Example usage
object FlyWeightPattern extends App {
private val app = new DrawingApp()
app.drawShape("Circle", 10, 20, "red")
app.drawShape("Square", 30, 40, "blue")
app.drawShape("Triangle", 50, 60, "red")
app.drawShape("Rectangle", 70, 80, "green")
app.drawShape("Circle", 90, 100, "blue")
}
We should keep in mind that the flyweight objects are immutable: any operation on the state must be performed by the factory. As demonstrated in the above example, the Flyweight pattern achieves memory optimization by reusing shared objects instead of creating new ones for each instance.
Proxy Pattern:
The Proxy pattern provides a surrogate or placeholder for another object. It acts as an intermediary or control point to manage access to the real object, allowing additional functionality to be added without modifying the original object’s code. The proxy pattern is useful in lazy loading scenarios where the proxy objects instantiates the real object only when required.
It is also useful in situations where you want to control access to an object or add some extra behavior around the object’s operations. This can be achieved by having a Proxy class that mimics the interface of the real object and delegates calls to the real object when needed.
The below example illustrates the proxy pattern:
// Subject: Image interface
trait Image {
def display(): Unit
}
// RealSubject: RealImage
class RealImage(filename: String) extends Image {
def loadFromDisk(): Unit = {
println(s"Loading $filename from disk...")
}
def display(): Unit = {
println(s"Displaying $filename")
}
}
// Proxy: ImageProxy
class ImageProxy(filename: String) extends Image {
private var realImage: Option[RealImage] = None
def display(): Unit = {
if (realImage.isEmpty) {
// Lazy initialization: Load real image only when needed
realImage = Option(new RealImage(filename))
realImage.get.loadFromDisk()
}
realImage.get.display()
}
}
// Client code
object ProxyPattern extends App {
// Using ImageProxy to load and display the image
val image1: Image = new ImageProxy("image1.png")
val image2: Image = new ImageProxy("image2.png")
// The real image is loaded and displayed only when needed
image1.display()
image2.display()
// Subsequent calls display the real image directly
image1.display()
image2.display()
}
In above example, the ImageProxy class loads the realImage only when the first time display is called on the object. The subsequent calls go directly to the real object.
This concludes the discussion on Structural Design Patterns (Part1 Link). These patterns can be combined and used in combination with other design patterns to solve various structural design challenges. Each pattern addresses a specific set of problems and has its own benefits and trade-offs. Understanding these patterns and their appropriate usage can help you improve your software designs and make your code more robust and flexible.