Dziedziczenie i kompozycja

Co z czym i dlaczego?

No dobra, dzisiaj zmierzymy się z tym, dlaczego najczęściej kojarzona przez początkujących część programowania obiektowego zwana dziedziczeniem jest tak rzadko używana w produkcyjnym kodzie. Przyznam się, że w ostatnim kwartale nie napisałem produkcyjnie ani jednej klasy, która wymagałaby dziedziczenia.

Dziedziczenie

Element programowania obiektowego pozwalający na tworzenie nowych klas będących rozszerzeniem klasy bazowej. Podstawową zasadą, jaką się tutaj kierujemy, jest relacja pomiędzy klasami. W przypadku dziedziczenia jest to relacja typu “IS-A”.
Przykładem może być tutaj aplikacja bankowa posiadająca klasę Account. Klasa account zawiera pola, które definiują podstawowe dane każdego konto. Każdy klient banku może założyć konto podstawowe PrimaryAccount oraz konto oszczędnościowe SavingAccount. Obydwa wymienione konta dziedziczą po klasie Account, czyli w praktyce są kontami (PrimaryAccount is an Account).
W przypadku dziedziczenia możemy użyć metod z klas, po której dziedziczymy. Jednak zazwyczaj nadpisujemy działanie metod, tak aby działały wg specyficznych przypadków. Na przykład metoda transfer() w PrimaryAccount będzie mogła wypłacać tylko w podanej walucie w obrębie jednego kraju, natomiast ta sama metoda w SavingAccount będzie mogła transferować pieniądze tylko do istniejącego konta podstawowego PrimaryAccount.
Tutaj duża uwaga!!! Wskutek silnego sprzężenia klasy matki i klasy dziedziczącej, zostaje naruszona enkapsulacja. Każda klasa dziedzicząca ulega zmianie, jeżeli zmianie ulega klasa matka.

Dziedziczenie przykład

Tutaj klasa Account (jw w tekscie):

public class Account {
    private Long accountId;
    private AccountType accountType;
    private Currency currency;
    private BigDecimal balance;
    private Owner owner;

    public Account(Long accountId, Currency currency, BigDecimal balance, Owner owner, AccountType accountType) {
        this.accountId = accountId;
        this.currency = currency;
        this.balance = balance;
        this.owner = owner;
        this.accountType = accountType;
    }

  // here getters, equals and hashcode

    public void debitAccount(BigDecimal amount) {
        this.balance = balance.subtract(amount);
    }

    public void creditAccount(BigDecimal amount) {
        this.balance = balance.add(amount);
    }

    public Long transfer(BigDecimal amount, Account account) {
        debitAccount(amount);
        account.creditAccount(amount);
        return account.getAccountId();
    }

    public boolean isPrimary() {
        return accountType.equals(AccountType.PRIMARY);
    }

    public BigDecimal getBalance() {
        return balance;
    }
}

Klasy Saving and Primary dziedizczące po Account (będące wyspecjalizowanymi wersjami Account):

public class PrimaryAccount extends Account {

    public PrimaryAccount(Long accountId, Currency currency, BigDecimal balance, Owner owner) {
        super(accountId, currency, balance, owner, AccountType.PRIMARY);
    }

    @Override
    public Long transfer(BigDecimal amount, Account account) {
        if (amount.compareTo(BigDecimal.valueOf(50.0)) < 0) {
            super.debitAccount(amount);
            account.creditAccount(amount);
            return account.getAccountId();
        }
        throw new RuntimeException("Local transfer is too big");
    }
}



public class SavingAccount extends Account {
    public SavingAccount(Long accountId, Currency currency, BigDecimal balance, Owner owner) {
        super(accountId, currency, balance, owner, AccountType.SAVING);
    }

    @Override
    public Long transfer(BigDecimal amount, Account account) {
        if (account.isPrimary()) {
            super.debitAccount(amount);
            account.creditAccount(amount);
            return account.getAccountId();
        }
        throw new RuntimeException("Transfer to other than primary account not allowed");
    }
}

Kompozycja

Kompozycja inaczej niż dziedziczenie stanowi rodzaj relacji typu “HAS-A”. Która wskazuje, że klasa bazowa zawiera inną klasę. Na przykład klasa MoneyTransferHandler zawiera w sobie klasę MoneyLock będącą serwisem obsługującym blokowanie środków finansowych np.: w razie oczekiwania na asynchroniczne potwierdzenie sukcesu operacji.
Można wobec tego powiedzieć, że klasa MoneyTransferHandler zawiera (HAS-A) MoneyLock, dzięki czemu może korzystać z wybranych metod tej klasy (np.: lock() i unlock()). Widać także, że dzięki temu MoneyTransferHandler nie staje się podtypem klasy MoneyLock. Oczywiście można by użyć dziedziczenia i rozszerzyć klasę MoneyLock o klasę MoneyTransferHandler. Jednak wymusiłoby to dziedziczenie wszystkich metod, które dostarcza MoneyLock (na potrzeby tego ćwiczenia umysłowego załóżmy, że klasa MoneyLock posiada 40 metod). Ponadto każda zmiana w klasie bazowej (tutaj MoneyLock) powodowałaby zmiany w naszym serwisie obsługującym płatności. Można więc zadać pytanie, “Ile zasad SOLID zostało złamanych w tym podejściu??”. Odpowiedź zostawiam Ci i zachęcam do lektury i przemyślenia tematu.
Single responsibility
Open/Closed
Liskov substitution
Interface segregation
Dependency inversion

Kompozycja przykład

Klasa Money Lock jak w opisie powyżej.

public class MoneyLock {

    private LockRepository repository;

    private Ledger ledger;

    public MoneyLock(LockRepository repository, Ledger ledger) {
        this.repository = repository;
        this.ledger = ledger;
    }

    public void unlock(TransactionId transactionId, BigDecimal amount) {
        // here implementation logic
    }

    public void lock(TransactionId transactionId, BigDecimal amount) {
        // here implementation logic
        // post entry to ledger
        // add entry to lock repo
    }

    public void unlockDueToPassingAntiLaundryMechanism(TransacionId transacionId, BigDecimal amount, ALReferenceId id) {
        // here implementation logic
    }


    /// 37 more different implementations
}

Klasa MoneyTransferHandler, która zawiera w sobie “HAS-A” MoneyLock.

public class MoneyTransferHandler {

    private MoneyLock moneyLock;

    private TransactionHistory transactionHistory;

    public MoneyTransferHandler(MoneyLock moneyLock, TransactionHistory transactionHistory) {
        this.moneyLock = moneyLock;
        this.transactionHistory = transactionHistory;
    }

    public void handle(Transaction transaction) {
        if (transaction.isDebit()) {
            moneyLock.lock(transaction.transactionId, transaction.amount);
            transactionHistory.postMessage(transaction);
        }
        if (transaction.isCredit()) {
            transactionHistory.postMessage(transaction);
        }
        if(transaction.isReversal()) {
            moneyLock.unlock(transaction.transactionId, transaction.amount);
            transactionHistory.postMessage(transaction);
        }
        throw new RuntimeException("Transaction can not be processed")!
    }
}

Kiedy dziedziczenie, a kiedy kompozycja?

  • Dziedziczenia użyjmy wtedy, gdy Jesteśmy pewni, że dziedziczenie powoduje powstanie klas będących podklasami klasy bazowe. Innymi słowy, klasa dziedzicząca jest wyspecjalizowaną wersją klasy bazowej.
  • Dziedziczenie powinno dotyczyć sytuacji, gdy obydwie klasy (dziedzicząca i klasa matka) pochodzą z tej samej części logiki domeny.
  • Jeżeli chcemy zmieniać wersję klasy w runtime, powinniśmy użyć kompozycji i wskazać Typ zmienianej klasy. W przypadku dziedziczenia musimy zdefiniować, którą klasę rozszerzamy i nie można tego zmienić w runtimie.
  • Kompozycja pozwala na reużycie klasy oznaczonej jako final.
    Dzięki mock’owaniu klasy zbudowane na podstawie kompozycje są łatwo testowalne.
  • Używanie dziedziczenia czasami może być niezbędne, jednak za każdym razem, gdy planujemy użyć tego podejścia, należy dobrze przemyśleć konsekwencje.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *