1. Single Responsibility Principle (SRP) – Zasada jednej odpowiedzialności
Klasa powinna mieć tylko jedną odpowiedzialność, czyli powinna zajmować się jednym, konkretnym aspektem systemu. Jeśli klasa ma wiele odpowiedzialności, zmiana jednej z nich może wpłynąć na inne części kodu, co zwiększa ryzyko błędów.
Przykład kodu bez SRP
class Invoice {
private String customerName;
private double amount;
public Invoice(String customerName, double amount) {
this.customerName = customerName;
this.amount = amount;
}
// Odpowiedzialność 1: Obliczanie podatku
public double calculateTax() {
return amount * 0.2; // Załóżmy 20% VAT
}
// Odpowiedzialność 2: Generowanie faktury w formacie PDF
public void generateInvoicePDF() {
System.out.println(„Generating PDF for customer: ” + customerName);
// Kod generujący PDF
}
// Odpowiedzialność 3: Wysyłanie faktury e-mailem
public void sendInvoiceEmail() {
System.out.println(„Sending invoice to: ” + customerName);
// Kod wysyłania e-maila
}
}
W tej chwili klasa Invoice ma wiele odpowiedzialności: obliczanie podatku, generowanie PDF-a i wysyłanie e-maila. Jeśli zmienisz sposób generowania PDF-a, możesz przypadkowo wpłynąć na inne części klasy, co łamie zasadę SRP.
Implementacja zgodna z zasadą SRP:
class Invoice {
private String customerName;
private double amount;
public Invoice(String customerName, double amount) {
this.customerName = customerName;
this.amount = amount;
}
public String getCustomerName() {
return customerName;
}
public double getAmount() {
return amount;
}
}
// Klasa odpowiedzialna za obliczanie podatku
class TaxCalculator {
public double calculateTax(Invoice invoice) {
return invoice.getAmount() * 0.2; // Załóżmy 20% VAT
}
}
// Klasa odpowiedzialna za generowanie faktury w formacie PDF
class InvoicePDFGenerator {
public void generatePDF(Invoice invoice) {
System.out.println(„Generating PDF for customer: ” + invoice.getCustomerName());
// Kod generujący PDF
}
}
// Klasa odpowiedzialna za wysyłanie faktury e-mailem
class EmailSender {
public void sendInvoiceEmail(Invoice invoice) {
System.out.println(„Sending invoice to: ” + invoice.getCustomerName());
// Kod wysyłania e-maila
}
}
// Przykład użycia
public class Main {
public static void main(String[] args) {
Invoice invoice = new Invoice(„John Doe”, 1000.0);
TaxCalculator taxCalculator = new TaxCalculator();
InvoicePDFGenerator pdfGenerator = new InvoicePDFGenerator();
EmailSender emailSender = new EmailSender();
System.out.println(„Tax: ” + taxCalculator.calculateTax(invoice));
pdfGenerator.generatePDF(invoice);
emailSender.sendInvoiceEmail(invoice);
}
}
Wnioski
- Każda klasa ma jedną odpowiedzialność:
- Invoice przechowuje dane faktury.
- TaxCalculator oblicza podatek.
- InvoicePDFGenerator generuje PDF-a.
- EmailSender wysyła fakturę e-mailem.
Zmiana jednej klasy nie wpływa na pozostałe. Na przykład, jeśli zmiana sposobu generowania PDF-a, nie zmienia kodu odpowiedzialnego za obliczanie podatku czy wysyłanie e-maila.
2. Open/Closed Principle (OCP) – Zasada otwarte-zamknięte
Klasa powinna być otwarta na rozszerzenia, ale zamknięta na modyfikacje. Zamiast edytować istniejącą klasę, aby obsłużyć nową funkcjonalność, lepiej jest dodać nową klasę lub metodę. Dzięki temu nie wprowadzamy błędów w działającym kodzie.
Przykład kodu bez zasadę OCP:
class DiscountCalculator {
public double calculateDiscount(String customerType, double amount) {
if (customerType.equals(„Regular”)) {
return amount * 0.1; // 10% dla regularnych klientów
} else if (customerType.equals(„Premium”)) {
return amount * 0.2; // 20% dla klientów premium
} else if (customerType.equals(„VIP”)) {
return amount * 0.3; // 30% dla VIP
}
return 0;
}
}
Każdorazowe dodanie nowego typu klienta wymaga edycji istniejącej klasy DiscountCalculator. To zwiększa ryzyko wprowadzenia błędów i łamie zasadę OCP.
Implementacja zgodna z zasadą OCB:
Rozwiązanie z użyciem interfejsu i polimorfizmu
// Interfejs dla strategii obliczania rabatu
interface DiscountStrategy {
double calculateDiscount(double amount);
}
// Klasa dla regularnych klientów
class RegularCustomerDiscount implements DiscountStrategy {
public double calculateDiscount(double amount) {
return amount * 0.1; // 10% rabatu
}
}
// Klasa dla klientów premium
class PremiumCustomerDiscount implements DiscountStrategy {
public double calculateDiscount(double amount) {
return amount * 0.2; // 20% rabatu
}
}
// Klasa dla klientów VIP
class VIPCustomerDiscount implements DiscountStrategy {
public double calculateDiscount(double amount) {
return amount * 0.3; // 30% rabatu
}
}
// Główna klasa, która używa strategii
class DiscountCalculator {
private DiscountStrategy discountStrategy;
public DiscountCalculator(DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}
public double calculateDiscount(double amount) {
return discountStrategy.calculateDiscount(amount);
}
}
// Przykład użycia
public class Main {
public static void main(String[] args) {
double amount = 1000.0;
// Regularny klient
DiscountCalculator regularCalculator = new DiscountCalculator(new RegularCustomerDiscount());
System.out.println(„Regular discount: ” + regularCalculator.calculateDiscount(amount));
// Klient premium
DiscountCalculator premiumCalculator = new DiscountCalculator(new PremiumCustomerDiscount());
System.out.println(„Premium discount: ” + premiumCalculator.calculateDiscount(amount));
// Klient VIP
DiscountCalculator vipCalculator = new DiscountCalculator(new VIPCustomerDiscount());
System.out.println(„VIP discount: ” + vipCalculator.calculateDiscount(amount));
}
}
Wnioski:
- Otwarta na rozszerzenia: Jeśli chce się dodać nowy typ klienta, wystarczy utworzyć nową klasę implementującą DiscountStrategy. Nie trzeba modyfikować istniejącego kodu.
- Zamknięta na modyfikacje: Istniejące klasy (RegularCustomerDiscount, PremiumCustomerDiscount, VIPCustomerDiscount) pozostają niezmienione, co minimalizuje ryzyko wprowadzenia błędów.
- Łatwość testowania: Każda strategia rabatowa jest niezależna i może być testowana osobno.
Przykład dodania nowej klasy:
Dodanie nowych „Gold” klientów z 25% rabatem:
class GoldCustomerDiscount implements DiscountStrategy {
public double calculateDiscount(double amount) {
return amount * 0.25; // 25% rabatu
}
}
Nie trzeba dotykać istniejącego kodu.
3. Liskov Substitution Principle (LSP) – Zasada podstawienia Liskov
Obiekty klas dziedziczących powinny być wymienialne na obiekty klas bazowych bez zmiany poprawności programu. Np. jeśli klasa Rectangle ma metodę setWidth i setHeight, klasa dziedzicząca Square powinna działać zgodnie z zasadami klasy bazowej. Jeśli Square zmienia logikę, np. ustawia wysokość i szerokość jednocześnie, łamie LSP.
Przykład kodu bez LSP:
// Klasa bazowa: prostokąt
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getArea() {
return width * height;
}
}
// Klasa dziedzicząca: kwadrat (naruszenie LSP)
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // Wymusza, że szerokość i wysokość są równe
}
@Override
public void setHeight(int height) {
this.height = height;
this.width = height; // Wymusza, że szerokość i wysokość są równe
}
}
// Test programu
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setWidth(4);
rectangle.setHeight(5);
System.out.println(„Rectangle area: ” + rectangle.getArea()); // 20
Rectangle square = new Square();
square.setWidth(4);
square.setHeight(5); // To nie powinno być możliwe dla kwadratu
System.out.println(„Square area: ” + square.getArea()); // Niepoprawny wynik
}
}
Klasa Square narusza LSP, ponieważ wymusza zależność między szerokością a wysokością.
W przypadku użycia Square zamiast Rectangle, wynik metody getArea() będzie niezgodny z oczekiwaniami.
Kod klienta zakłada, że może ustawić szerokość i wysokość niezależnie, ale w przypadku Square to założenie jest błędne.
Implementacja zgodna z zasadą LSP:
Oddziel klasy Rectangle i Square — dziedziczenie nie jest tutaj potrzebne:
// Klasa prostokąta
class Rectangle {
protected int width;
protected int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getArea() {
return width * height;
}
}
// Klasa kwadratu (nie dziedziczy po Rectangle)
class Square {
private int side;
public Square(int side) {
this.side = side;
}
public void setSide(int side) {
this.side = side;
}
public int getSide() {
return side;
}
public int getArea() {
return side * side;
}
}
// Test programu
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(4, 5);
rectangle.setWidth(4);
rectangle.setHeight(5);
System.out.println(„Rectangle area: ” + rectangle.getArea()); // 20
Square square = new Square(4);
square.setSide(5); // Dla kwadratu ustawiamy jeden bok
System.out.println(„Square area: ” + square.getArea()); // 25
}
}
Wnioski
- Klasy Rectangle i Square nie są połączone relacją dziedziczenia, co eliminuje ryzyko naruszenia LSP.
- Kwadrat i prostokąt są reprezentowane niezależnie, zgodnie z ich właściwościami geometrycznymi.
- Kod zachowuje swoje założenia i działa poprawnie dla obu klas.
4. Interface Segregation Principle (ISP) – Zasada segregacji interfejsów
Klient nie powinien być zmuszany do implementowania metod, których nie używa. Np.zamiast jednego dużego interfejsu Bird, który wymaga metod fly() i swim(), lepiej stworzyć dwa mniejsze: FlyingBird i SwimmingBird. Pingwin implementuje tylko SwimmingBird, a nie musi znać metody fly().
Przykład kodu bez ISP:
W tym przykładzie mamy duży interfejs Bird, który wymaga implementacji metod fly() i swim(). Jednak nie wszystkie ptaki potrafią latać i pływać (np. pingwin nie lata, a orzeł nie pływa).
// Naruszenie ISP: Jeden duży interfejs z metodami niepotrzebnymi dla wszystkich klas
interface Bird {
void fly();
void swim();
}
// Klasa implementująca niepotrzebne metody
class Penguin implements Bird {
@Override
public void fly() {
// Pingwin nie lata, ale musi zaimplementować tę metodę
throw new UnsupportedOperationException(„Penguins can’t fly!”);
}
@Override
public void swim() {
System.out.println(„Penguin is swimming!”);
}
}
// Klasa implementująca niepotrzebne metody
class Eagle implements Bird {
@Override
public void fly() {
System.out.println(„Eagle is flying!”);
}
@Override
public void swim() {
// Orzeł nie pływa, ale musi zaimplementować tę metodę
throw new UnsupportedOperationException(„Eagles can’t swim!”);
}
}
public class Main {
public static void main(String[] args) {
Bird penguin = new Penguin();
penguin.swim();
penguin.fly(); // Rzuci wyjątek
Bird eagle = new Eagle();
eagle.fly();
eagle.swim(); // Rzuci wyjątek
}
}
Implementacja zgodna z zasadą ISP:
Zamiast jednego dużego interfejsu Bird, tworzymy mniejsze interfejsy FlyingBird i SwimmingBird, aby każda klasa implementowała tylko te metody, których potrzebuje.
// Rozdzielone interfejsy
interface FlyingBird {
void fly();
}
interface SwimmingBird {
void swim();
}
// Pingwin implementuje tylko interfejs SwimmingBird
class Penguin implements SwimmingBird {
@Override
public void swim() {
System.out.println(„Penguin is swimming!”);
}
}
// Orzeł implementuje tylko interfejs FlyingBird
class Eagle implements FlyingBird {
@Override
public void fly() {
System.out.println(„Eagle is flying!”);
}
}
public class Main {
public static void main(String[] args) {
SwimmingBird penguin = new Penguin();
penguin.swim();
// penguin.fly(); // Błąd kompilacji – pingwin nie implementuje FlyingBird
FlyingBird eagle = new Eagle();
eagle.fly();
// eagle.swim(); // Błąd kompilacji – orzeł nie implementuje SwimmingBird
}
}
Wnioski
- Mniejsze interfejsy: FlyingBird i SwimmingBird są bardziej precyzyjne i opisują tylko konkretne zdolności (latanie lub pływanie).
- Unikamy implementacji zbędnych metod: Klasy implementują tylko te interfejsy, które są dla nich istotne. Pingwin nie musi implementować fly(), a orzeł swim().
- Poprawa czytelności i łatwości użycia: Kod staje się bardziej zrozumiały i łatwiejszy w utrzymaniu. Nie ma potrzeby rzucania wyjątków w metodach, które nie mają sensu dla danej klasy.
5. Dependency Inversion Principle (DIP) – Zasada odwrócenia zależności
Moduły wyższego poziomu nie powinny zależeć od modułów niższego poziomu. Oba powinny zależeć od abstrakcji. Np. zamiast w klasie OrderProcessor tworzyć obiekt klasy SQLDatabase, używamy interfejsu Database. Dzięki temu można podmieniać implementacje bazy danych, np. NoSQLDatabase, bez zmiany OrderProcessor.
Przykład kodu bez DIP:
W tym przykładzie klasa OrderProcessor bezpośrednio zależy od klasy SQLDatabase. Powoduje to silne powiązanie, przez co zmiana implementacji bazy danych (np. na NoSQLDatabase) wymaga modyfikacji kodu OrderProcessor.
// Klasa konkretna reprezentująca bazę danych
class SQLDatabase {
public void saveOrder(String order) {
System.out.println(„Order saved in SQL Database: ” + order);
}
}
// Klasa wyższego poziomu, która bezpośrednio zależy od SQLDatabase
class OrderProcessor {
private SQLDatabase database;
public OrderProcessor() {
this.database = new SQLDatabase(); // Bezpośrednie zależności
}
public void processOrder(String order) {
System.out.println(„Processing order: ” + order);
database.saveOrder(order);
}
}
public class Main {
public static void main(String[] args) {
OrderProcessor orderProcessor = new OrderProcessor();
orderProcessor.processOrder(„Order123”);
}
}
- OrderProcessor jest mocno zależny od konkretnej klasy SQLDatabase.
- Podmiana implementacji bazy danych wymaga zmiany kodu w OrderProcessor.
- Trudniej przetestować OrderProcessor, ponieważ nie można łatwo podmienić zależności na np. mock.
Implementacja zgodna z zasadą DIP:
W tym przykładzie zarówno moduł wyższego poziomu (OrderProcessor), jak i niższego poziomu (SQLDatabase, NoSQLDatabase) zależą od abstrakcji Database. Dzięki temu można łatwo podmienić implementację bazy danych bez zmiany kodu OrderProcessor.
// Abstrakcja: interfejs reprezentujący bazę danych
interface Database {
void saveOrder(String order);
}
// Konkretna implementacja bazy SQL
class SQLDatabase implements Database {
@Override
public void saveOrder(String order) {
System.out.println(„Order saved in SQL Database: ” + order);
}
}
// Konkretna implementacja bazy NoSQL
class NoSQLDatabase implements Database {
@Override
public void saveOrder(String order) {
System.out.println(„Order saved in NoSQL Database: ” + order);
}
}
// Klasa wyższego poziomu, która zależy od abstrakcji Database
class OrderProcessor {
private Database database;
// Zależność wstrzykiwana przez konstruktor
public OrderProcessor(Database database) {
this.database = database;
}
public void processOrder(String order) {
System.out.println(„Processing order: ” + order);
database.saveOrder(order);
}
}
public class Main {
public static void main(String[] args) {
// Możemy podmienić implementację bazy danych w zależności od potrzeb
Database sqlDatabase = new SQLDatabase();
Database noSqlDatabase = new NoSQLDatabase();
// Użycie SQLDatabase
OrderProcessor orderProcessor1 = new OrderProcessor(sqlDatabase);
orderProcessor1.processOrder(„Order123”);
// Użycie NoSQLDatabase
OrderProcessor orderProcessor2 = new OrderProcessor(noSqlDatabase);
orderProcessor2.processOrder(„Order456”);
}
}
Wnioski
- Zależności od abstrakcji: Klasa OrderProcessor zależy od interfejsu Database, a nie od konkretnej implementacji (SQLDatabase lub NoSQLDatabase).
- Łatwość rozszerzenia: Dodanie nowej implementacji bazy danych (np. InMemoryDatabase) wymaga tylko stworzenia nowej klasy implementującej interfejs Database.
- Testowanie: Można łatwo podmienić Database na mock w testach jednostkowych, co czyni kod bardziej testowalnym.
- Brak bezpośrednich powiązań: OrderProcessor nie musi się martwić, jaka implementacja bazy danych jest używana. Wystarczy, że korzysta z abstrakcji Database.