Java Design Patterns - මූලික හැඳින්වීමක් | SC Guide

Java Design Patterns - මූලික හැඳින්වීමක් | SC Guide

හලෝ යාළුවනේ, කොහොමද සැපද? Software development ලෝකයේ ඔයාලාගේ ගමන සාර්ථකව යනවද?

අද අපි කතා කරන්න යන්නේ ඔයාලා කට්ටියම දන්න, ඒත් සමහරවිට හරියට තේරුම් ගන්න බැරි වුණ, ඒ වගේම ඕනෙම software engineer කෙනෙක් දැනගෙන ඉන්නම ඕනෙ විශේෂ දෙයක් ගැන – ඒ තමයි Design Patterns. Java වලින් code කරන අයට මේවා අත්‍යවශ්‍යයි කිව්වොත් නිවැරදියි. හැමදාම එකම විදිහට code ලිය ලියා හිටියට, පොඩ්ඩක් හිතලා වැඩ කළොත්, ඔයාගේ code එකේ quality එක නියමෙටම වැඩි කරගන්න පුළුවන්.

Design Patterns කියන්නේ මොනවද ඇත්තටම? (What are Design Patterns, really?)

සරලව කිව්වොත්, Design Patterns කියන්නේ software development වලදී නිතරම එන පොදු ගැටළු වලට දීල තියෙන හොඳම, ඔප්පු කරපු විසඳුම්. හිතන්නකෝ, ඔයාලා ගෙයක් හදනවා කියලා. හැම වතාවෙම මුල ඉඳන්ම දොරවල් හදන හැටි, ජනෙල් හදන හැටි, බිත්ති ගොඩනගන හැටි හොය හොයා ඉන්නේ නෑනේ. ඒවාට standard methods, එහෙමත් නැත්නම් designs තියෙනවා. Software development වලත් එහෙමයි. අපි නිතරම දකින problems වලට, කලින්ම හොයාගත්ත, හොඳම solutions තමා මේ patterns.

මේවා මුලින්ම ජනප්‍රිය වුණේ "Gang of Four" (GoF) කියන හතර දෙනෙක් ලියපු "Design Patterns: Elements of Reusable Object-Oriented Software" කියන පොතත් එක්ක. මේ පොතේ Design Patterns 23ක් ගැන විස්තර තියෙනවා, ඒ Patterns ප්‍රධාන වශයෙන් වර්ග තුනකට බෙදලා තියෙනවා:

  • Creational Patterns: Object creation mechanism එක handle කරන patterns. (උදා: Singleton, Factory Method)
  • Structural Patterns: Classes සහ Objects එකිනෙකට සම්බන්ධ කරන patterns. (උදා: Adapter, Decorator)
  • Behavioral Patterns: Classes සහ Objects අතර communication patterns handle කරන patterns. (උදා: Observer, Strategy)

වැදගත්ම දේ තමා Design Pattern එකක් කියන්නේ ready-made code block එකක් නෙවෙයි. ඒක blueprint එකක්, concept එකක්. ඔයාගේ project එකට අනුව customize කරගන්න ඕනේ. හරියට chef කෙනෙක් recipe පොතක් පාවිච්චි කරනවා වගේ. Recipe එකෙන් පොදු මග පෙන්වීමක් දෙනවා, හැබැයි final dish එක හදන්නේ chef ගේ දක්ෂතාවය අනුව.

මොකටද අපිට Design Patterns ඕනේ? (Why do we need them?)

දැන් ඔයාලා හිතනවා ඇති, "අනේ මොකටද මේවා ඉගෙන ගන්නේ? මට ඕනේ විදියට code කරන්න පුළුවන්නේ." ඒක ඇත්ත. හැබැයි මේවා පාවිච්චි කරන එකෙන් ඔයාට ලැබෙන වාසි ගොඩක් තියෙනවා. හිතන්න, ඔයා පොඩි Project එකක් කරනවා. එතකොට ඕනෙම විදිහකට code කළාට ලොකු අවුලක් වෙන්නේ නෑ. හැබැයි ඒ Project එක ලොකු වෙනකොට, තව developersලා add වෙනකොට, "අනේ මන්දා මේ code එකේ මොනවද තියෙන්නේ" කියලා ඔලුව කහ කහා ඉන්න සිද්ධ වෙන්න පුළුවන්. ඒකට තමයි Design Patterns උදව් වෙන්නේ.

  • Reusability (නැවත භාවිතය): එක පාරක් හදපු solution එකක් වෙන තැන්වලදීත් පාවිච්චි කරන්න පුළුවන්. Time saving!
  • Maintainability (නඩත්තුව): Code එක තේරුම් ගන්න පහසුයි, මොකද standard patterns වලින් ලියලා තියෙන නිසා. Bug fix කරන්න, අලුත් features add කරන්න ලේසියි.
  • Scalability (පුළුල් කිරීමේ හැකියාව): Project එක ලොකු වෙනකොට, complex වෙනකොට, patterns පාවිච්චි කරලා තියෙනවා නම් maintain කරන්න පහසුයි. අලුත් features add කරනකොට මුළු system එකම අලුතෙන් ලියන්න ඕනේ නෑ.
  • Common Language (පොදු භාෂාවක්): Developersලා අතරේ පොදු භාෂාවක් ඇති වෙනවා. "අපි මෙතන Singleton එකක් දාමු", "අපි Factory Method එකක් යූස් කරමු" කියලා කියද්දි හැමෝටම තේරෙනවා. මේක team work වලට අතිශයින්ම වැදගත්.
  • Proven Solutions (ඔප්පු කරපු විසඳුම්): මේවා කාලයක් තිස්සේ සාර්ථකව භාවිතා කරලා තියෙන, ඔප්පු කරපු methods. ඉතින් ඔයාට අලුතෙන් solution හොය හොයා ඉන්න ඕනේ නෑ. ඔයාට ඕනේ ගැටලුවට අදාළ pattern එක තෝරගෙන Implement කරන්න විතරයි තියෙන්නේ.

සරලවම කියනවා නම්, Design Patterns පාවිච්චි කරන එකෙන් අපේ code එක හොඳට organize කරගන්න, future එකට ready කරන්න, වගේම කට්ටිය එක්ක වැඩ කරනකොටත් පහසුවක් ඇති කරගන්න පුළුවන්. ඒ වගේම Professional Developer කෙනෙක් විදියට ඔයාට මේ knowledge එක ගොඩක් වටිනවා.

පොදු Design Patterns කිහිපයක් (Common Design Patterns with Examples)

දැන් අපි බලමු නිතරම භාවිතා වෙන, ඔයාලට එදිනෙදා වැඩවලදී ගොඩක් ප්‍රයෝජනවත් වෙන Design Patterns කිහිපයක් ගැන. මේවාගේ Concept එකයි, Java වලින් පොඩි Example එකකුයි අපි බලමු. මේවා හොඳට තේරුම් ගන්න එක ඔයාට ලොකු වාසියක් වේවි.

1. Singleton Pattern (Creational Pattern)

මේක තමයි ගොඩක්ම ජනප්‍රිය එකක්, ඒ වගේම ගොඩක් අවුල් සහගත වෙන්නත් පුළුවන් එකක්. Singleton Pattern එකේ අරමුණ තමා Class එකක එකම එක Object එකක් විතරක් හදන්න පුළුවන් වෙන විදියට සලස්වන එක. ඒ වගේම ඒ object එකට global access point එකක් ලබා දෙන එක. මේක ඉතාමත් ප්‍රයෝජනවත් වෙන්නේ අපිට system එක ඇතුලේ resource එකක් share කරගන්න ඕනෙ වෙලාවට.

කොහෙද මේවා ඕනෙ වෙන්නේ? හිතන්න ඔයාගේ application එකට Database connection pool එකක් තියෙනවා කියලා. අපිට ඕනෙ එකම Database connection pool object එකක් විතරයි, මොකද connection ගොඩක් තිබ්බොත් resource waste වෙනවා. ඒ වගේම Logger එකක්, configuration manager එකක්, Print Spooler වගේ තැන්වලදී අපිට ඕනෙ එකම object එකක් විතරක් තියෙන්න.

Singleton Pattern එක ක්‍රියාත්මක වන ආකාරය:

සාමාන්‍යයෙන් මේක Implement කරන්නේ class එකේ constructor එක private කරලා, ඒ class එකේම static instance එකක් තියාගෙන, ඒ instance එක return කරන static method එකක් හදලා.

Singleton Example: Simple Logger


public class Logger {
    // 1. Static instance to hold the single object.
    // 'volatile' keyword ensures that multiple threads handle the instance correctly
    // when it's being initialized.
    private static volatile Logger instance; 
    private String logFile;

    // 2. Private constructor to prevent instantiation from outside the class.
    private Logger() {
        this.logFile = "application.log";
        System.out.println("Logger instance created for " + logFile);
        // Simulate some initial setup for the logger
        try {
            Thread.sleep(100); // Simulate heavy initialization
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 3. Public static method to provide global access to the instance.
    // This is often called the 'lazy initialization' approach (creates object only when needed).
    public static Logger getInstance() {
        // Double-checked locking to ensure thread safety and efficiency
        if (instance == null) { // First check
            synchronized (Logger.class) { // Synchronize on the class lock
                if (instance == null) { // Second check
                    instance = new Logger(); // Create the instance only if it doesn't exist
                }
            }
        }
        return instance;
    }

    public void log(String message) {
        System.out.println("Logging to " + logFile + ": " + message);
        // In a real application, you would write this message to a file or database.
    }
}

// How to use the Singleton Logger:
public class Application {
    public static void main(String[] args) {
        System.out.println("Attempting to get logger instance 1...");
        Logger logger1 = Logger.getInstance(); // First call, instance will be created
        logger1.log("Application started by User A.");

        System.out.println("\nAttempting to get logger instance 2...");
        Logger logger2 = Logger.getInstance(); // Second call, same instance will be returned
        logger2.log("System configuration loaded.");

        System.out.println("\nChecking if both logger instances are the same...");
        System.out.println("Are logger1 and logger2 the same instance? " + (logger1 == logger2)); // Should print true

        // Let's try from another part of the application or thread
        Runnable task = () -> {
            System.out.println("\n(From a separate thread) Attempting to get logger instance 3...");
            Logger logger3 = Logger.getInstance();
            logger3.log("Background task executed.");
            System.out.println("Are logger1 and logger3 the same instance? " + (logger1 == logger3));
        };

        new Thread(task).start();

        try {
            Thread.sleep(500); // Give background thread some time
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

මේ Example එකේදී, Logger class එකේ constructor එක private කරලා තියෙන නිසා පිටින් object හදන්න බෑ. getInstance() method එකෙන් විතරයි object එක ගන්න පුළුවන්. මේකේ 'double-checked locking' කියන technique එක පාවිච්චි කරලා තියෙන්නේ multiple threads එක්ක වැඩ කරද්දී Singleton එක ආරක්ෂිතව maintain කරන්නයි. පළමු වතාවට request කරනකොට විතරයි instance එක create කරන්නේ, ඊට පස්සේ හැමදාම එකම instance එක return කරනවා. මේක නියමයි නේද?

2. Factory Method Pattern (Creational Pattern)

Factory Method Pattern එකේ අරමුණ තමා object create කරන logic එක Subclasses වලට delegating කරන එක. සරලව කියනවා නම්, අපිට මොන type එකේ object එකක්ද ඕනෙ කියලා කියන්න පුළුවන්, හැබැයි ඒක හදන විදිය ගැන අපිට කරදර වෙන්න ඕනෙ නෑ. Factory එක ඒක බලාගන්නවා. මේක "Dependency Inversion Principle" කියන OOP concept එකටත් ගොඩක් ලොකු උදව්වක්.

හිතන්න ඔයාට different types of Vehicles (Car, Bike, Truck) හදන්න ඕනේ කියලා. මේ හැම Vehicle එකකටම පොදු behavior තියෙන්න පුළුවන් (e.g., drive(), start(), stop()). Factory Method එකක් භාවිතා කරනකොට ඔයාට ඕනෙ vehicle type එක විතරක් කිව්වහම, ඒකට අදාල object එක හදලා දෙනවා. අලුත් Vehicle Type එකක් ආවොත් අපිට Factory එකට තව subclass එකක් add කරන්න විතරයි තියෙන්නේ, existing code එක වෙනස් කරන්න ඕනේ නෑ.

Factory Method Example: Vehicle Factory


// 1. Product Interface: Defines the common interface for objects the factory method creates.
interface Vehicle {
    void drive();
    void honk();
}

// 2. Concrete Products: Implement the Product interface.
class Car implements Vehicle {
    @Override
    public void drive() {
        System.out.println("Driving a Car on the road.");
    }
    @Override
    public void honk() {
        System.out.println("Car horn: Beep! Beep!");
    }
}

class Bike implements Vehicle {
    @Override
    public void drive() {
        System.out.println("Riding a Bike on the street.");
    }
    @Override
    public void honk() {
        System.out.println("Bike bell: Ding! Ding!");
    }
}

class Truck implements Vehicle {
    @Override
    public void drive() {
        System.out.println("Driving a Truck on the highway.");
    }
    @Override
    public void honk() {
        System.out.println("Truck horn: Honk! Honk!");
    }
}

// 3. Creator Abstract Class or Interface (Factory): Declares the factory method, which returns an object of the Product type.
// It also contains logic that uses the product.
abstract class VehicleFactory {
    // The Factory Method - subclasses implement this method to return specific products.
    public abstract Vehicle createVehicle();

    // This method can contain common logic that operates on the created Vehicle.
    public void operateVehicle() {
        Vehicle vehicle = createVehicle(); // Call the factory method to get a vehicle
        vehicle.drive();
        vehicle.honk();
        System.out.println("Vehicle operation complete.\n");
    }
}

// 4. Concrete Creators: Override the factory method to return a specific concrete product.
class CarFactory extends VehicleFactory {
    @Override
    public Vehicle createVehicle() {
        System.out.println("CarFactory: Creating a Car object.");
        return new Car();
    }
}

class BikeFactory extends VehicleFactory {
    @Override
    public Vehicle createVehicle() {
        System.out.println("BikeFactory: Creating a Bike object.");
        return new Bike();
    }
}

class TruckFactory extends VehicleFactory {
    @Override
    public Vehicle createVehicle() {
        System.out.println("TruckFactory: Creating a Truck object.");
        return new Truck();
    }
}

// How to use the Factory Method Pattern:
public class VehicleShowroom {
    public static void main(String[] args) {
        System.out.println("--- Ordering a Car ---");
        VehicleFactory carFactory = new CarFactory();
        carFactory.operateVehicle(); // Output: Creating a Car object. Driving a Car on the road. Car horn: Beep! Beep!

        System.out.println("--- Ordering a Bike ---");
        VehicleFactory bikeFactory = new BikeFactory();
        bikeFactory.operateVehicle(); // Output: Creating a Bike object. Riding a Bike on the street. Bike bell: Ding! Ding!

        System.out.println("--- Ordering a Truck ---");
        VehicleFactory truckFactory = new TruckFactory();
        truckFactory.operateVehicle(); // Output: Creating a Truck object. Driving a Truck on the highway. Truck horn: Honk! Honk!
    }
}

මේකෙන් වෙන්නේ, client code එකට Car හෝ Bike, Truck object එකක් direct create කරන්න ඕනේ නෑ. ඒ වෙනුවට එයාලා Factory එකෙන් ඉල්ලනවා. මේක "Open/Closed Principle" එකටත් උදව් කරනවා; ඒ කියන්නේ, "software entities should be open for extension, but closed for modification". අලුත් Vehicle Type එකක් ආවොත් අපිට අලුත් Factory subclass එකක් හදන්න විතරයි තියෙන්නේ, existing client code එක වෙනස් කරන්න ඕනේ නෑ. නියම විසඳුමක් නේද?

3. Observer Pattern (Behavioral Pattern)

Observer Pattern එකේ අරමුණ තමා object එකක state එක වෙනස් වුණාම, ඒක මත depend වෙලා ඉන්න අනිත් objects (Observers) වලට notification එකක් යවන එක. හරියට ඔයා news channel එකක subscriber කෙනෙක් වගේ. අලුත් news එකක් ආවොත් channel එක ඔයාට update කරනවා. මේක "loose coupling" කියන concept එකට ලොකු උදව්වක් වෙනවා. ඒ කියන්නේ, Subject (publisher) එක Observer (subscriber) එක ගැන කෙලින්ම දැනගන්නේ නෑ, එයාලා අතර direct dependency එකක් නෑ.

කොහෙද මේවා ඕනෙ වෙන්නේ? GUI events, stock market updates, real-time notifications, messaging systems වගේ තැන්වලදී මේ pattern එක ගොඩක් ප්‍රයෝජනවත්. උදාහරණයක් විදියට, Facebook එකේ Post එකකට Comment එකක් දැම්මහම, ඒ Post එකට subscribe වෙලා ඉන්න අනිත් අයට Notification එකක් යනවා නේද? ඒ වගේ වැඩවලට මේක පාවිච්චි කරන්න පුළුවන්.

Observer Pattern Example: Weather Station


import java.util.ArrayList;
import java.util.List;

// 1. Subject (Publisher) Interface: Defines methods for attaching/detaching observers and notifying them.
interface Subject {
    void registerObserver(Observer o);
    void removeObserver(Observer o);
    void notifyObservers();
}

// 2. Observer (Subscriber) Interface: Defines the update method that subjects call to notify changes.
interface Observer {
    void update(float temperature, float humidity, float pressure);
}

// 3. Concrete Subject: Implements the Subject interface and maintains a list of observers.
// It notifies observers when its state changes.
class WeatherData implements Subject {
    private List<Observer> observers;
    private float temperature;
    private float humidity;
    private float pressure;

    public WeatherData() {
        observers = new ArrayList<>();
    }

    @Override
    public void registerObserver(Observer o) {
        observers.add(o);
        System.out.println("Observer registered: " + o.getClass().getSimpleName());
    }

    @Override
    public void removeObserver(Observer o) {
        observers.remove(o);
        System.out.println("Observer removed: " + o.getClass().getSimpleName());
    }

    @Override
    public void notifyObservers() {
        System.out.println("\nNotifying all observers about new measurements...");
        for (Observer observer : observers) {
            observer.update(temperature, humidity, pressure);
        }
    }

    // This method is called when new weather measurements arrive.
    public void setMeasurements(float temperature, float humidity, float pressure) {
        this.temperature = temperature;
        this.humidity = humidity;
        this.pressure = pressure;
        System.out.println("New measurements received: Temp=" + temperature + ", Humid=" + humidity + ", Press=" + pressure);
        notifyObservers(); // Crucial: Notify observers when measurements change
    }
}

// 4. Concrete Observer: Implements the Observer interface and registers with a Subject.
// It performs actions when notified of a change.
class CurrentConditionsDisplay implements Observer {
    private float temperature;
    private float humidity;
    // We don't need to hold a direct reference to WeatherData in a simple observer,
    // but often useful for unregistering or for more complex scenarios.
    // private Subject weatherData;

    public CurrentConditionsDisplay(Subject weatherData) {
        // this.weatherData = weatherData;
        weatherData.registerObserver(this); // Register with the Subject
    }

    @Override
    public void update(float temperature, float humidity, float pressure) {
        this.temperature = temperature;
        this.humidity = humidity;
        // pressure is not displayed by this observer, but received.
        display();
    }

    public void display() {
        System.out.println("  Current Conditions Display: " + temperature + "°F and " + humidity + "% humidity.");
    }
}

class StatisticsDisplay implements Observer {
    private float maxTemp = 0.0f;
    private float minTemp = 200.0f;
    private float tempSum = 0.0f;
    private int numReadings;

    public StatisticsDisplay(Subject weatherData) {
        weatherData.registerObserver(this);
    }

    @Override
    public void update(float temperature, float humidity, float pressure) {
        tempSum += temperature;
        numReadings++;

        if (temperature > maxTemp) {
            maxTemp = temperature;
        }
        if (temperature < minTemp) {
            minTemp = temperature;
        }
        display();
    }

    public void display() {
        System.out.println("  Avg/Max/Min temperature: " + (tempSum / numReadings) + "F/" + maxTemp + "F/" + minTemp + "F");
    }
}


// How to use the Observer Pattern:
public class WeatherStation {
    public static void main(String[] args) {
        WeatherData weatherData = new WeatherData();

        // Create and register display observers
        CurrentConditionsDisplay currentDisplay = new CurrentConditionsDisplay(weatherData);
        StatisticsDisplay statisticsDisplay = new StatisticsDisplay(weatherData);

        // Simulate new weather measurements (state changes in Subject)
        weatherData.setMeasurements(80, 65, 30.4f);
        weatherData.setMeasurements(82, 70, 29.2f);
        weatherData.setMeasurements(78, 90, 29.2f);

        // Remove one observer and see what happens
        System.out.println("\n--- Removing Statistics Display ---");
        weatherData.removeObserver(statisticsDisplay);
        weatherData.setMeasurements(75, 80, 29.8f); // Only CurrentConditionsDisplay will be notified
    }
}

මේ Example එකේදී, WeatherData (Subject) එකට CurrentConditionsDisplay සහ StatisticsDisplay කියන Observer ලා register වෙලා ඉන්නවා. WeatherData එකේ measurements වෙනස් වුණාම, එයාලා notifyObservers() method එකෙන් හැම Observer කෙනෙක්ටම update එකක් යවනවා. මේකෙන් වෙන්නේ, WeatherData class එකේ වෙනස්කම් කරනකොට, ඒකට depend වෙලා ඉන්න classes ගැන හිතන්න ඕනේ නෑ. ඒක "loose coupling" කියන concept එකේ හොඳම උදාහරණයක්.

කොයි වෙලාවටද පාවිච්චි කරන්නේ, කොයි වෙලාවටද නෑ? (When to use and NOT to use)

Design Patterns කියන්නේ අපේ code එක improve කරගන්න හොඳ tools වුණාට, හැමතැනම මේවා බලෙන්ම පාවිච්චි කරන්න හොඳ නෑ. "Over-engineering" කියන්නේ තේරුමක් නැතුව patterns දාලා code එක complicate කරන එක. පොඩි project එකකට Singleton එකක්, Factory Method එකක් වගේ දේවල් දැම්මහම අනවශ්‍ය විදියට code එක ලොකු වෙනවා, ඒ වගේම තේරුම් ගන්නත් අමාරු වෙනවා. ඒක හරියට පොඩි ඇණයක් තද කරන්න ලොකු මිටියක් පාවිච්චි කරනවා වගේ වැඩක්.

ඉතින්, pattern එකක් පාවිච්චි කරන්න කලින්, ඇත්තටම ඒකෙන් ගැටලුවකට විසඳුමක් ලැබෙනවද, නැත්නම් code එක තව complicate වෙනවද කියලා හිතලා බලන්න ඕනේ. හැමවිටම Simple solution එක තියෙනවා නම් ඒක පාවිච්චි කරන්න. Design Patterns යොදාගන්නේ තව දුරටත් simple methods වලින් ගැටලුව විසඳන්න බැරි වුණාම හෝ future scalability ගැන හිතලා.

Remember, simple is better, unless complexity is necessary!

අවසන් වශයෙන්...

Design Patterns කියන්නේ software development වලදී අත්‍යවශ්‍ය knowledge එකක්. මේවා ඉගෙන ගන්න එකෙන් ඔයාට හොඳ quality code එකක් ලියන්න, අනිත් developersලා එක්ක පහසුවෙන් වැඩ කරන්න, වගේම complex problems වලට smart solutions දෙන්න පුළුවන් වේවි. මේක ආරම්භයක් විතරයි, තව ගොඩක් patterns තියෙනවා (e.g., Strategy, Decorator, Adapter, Facade, Builder, Proxy, etc.). ඒ හැම එකක්ම අලුත් ගැටලුවකට අලුත් විසඳුමක් දෙනවා.

ඉතින්, ඔයාලා මේ patterns ගැන මොනවද හිතන්නේ? කලින් පාවිච්චි කරලා තියෙනවද? එහෙම නැත්නම් මේකෙන් අලුත් දෙයක් ඉගෙන ගත්තා නම්, පහළින් comment එකක් දාගෙන යන්න. ඔයාලගේ අදහස්, ප්‍රශ්න වගේම මේ patterns ගැන තියෙන experiencesත් share කරන්න අමතක කරන්න එපා. ඔයාලගේ comments අපිට ගොඩක් වටිනවා! අපි ඊළඟ post එකෙන් තවත් වටිනා Technical Article එකකින් හම්බවෙමු! සැමට සුභ දවසක්!