Composizione e delega

Questa breve nota presenta la composizione e la delega come possibili alternative all'ereditarietà.

La nota segue l'esposizione del Capitolo 4 (e in particolare dell'Item 18) del testo "Effective Java" (di Joshua Bloch).

Estendere o utilizzare?

Supponiamo di avere una classe Adder in grado di tenere traccia di somme tra interi (che possono essere svolte considerando un numero per volta oppure prelevando gli addendi da una lista), il cui codice è

public class Adder {

  private int result = 0;

  public void add(int x) {
    result += x;
  }

  public void add(List<Integer> l) {
    for (int x : l) add(x);
  }

  public int result() {
    return result;
  }
}

[sorgente]

Supponiamo ora di voler costruire una nuova classe in grado di tener traccia non solo della somma, ma anche dei valori di volta in volta sommati.

Dato che tale funzionalità sembra estendere quella della classe appena introdotta, sembra naturale procede per estensione, definendo la classe LogAdderExt come segue

public class LogAdderExt extends Adder {
  private final List<Integer> seen = new ArrayList<>();

  @Override
  public void add(int x) {
    seen.add(x);
    super.add(x);
  }

  @Override
  public void add(List<Integer> l) {
    seen.addAll(l);
    super.add(l);
  }

  public List<Integer> log() {
    return List.copyOf(seen);
  }
}

[sorgente]

Si osservi che (basandosi sulle specifiche del supertipo) appare assolutamente corretta l'idea di sovrascrivere entrambi i metodi add, anche quello evidenziato in giallo.

L'esecuzione di un semplice client di tali implementazioni

public static void main(String[] args) {
  Adder a = new Adder();
  a.add(List.of(1, 2, 3));
  System.out.println(a.result());

  LogAdderExt lae = new LogAdderExt();
  lae.add(List.of(1, 2, 3));
  System.out.println(lae.result());
  System.out.println(lae.log());
}

[sorgente]

mostra però un problema, poiché produce l'output

6
6
[1, 2, 3, 1, 2, 3]

Seppure le somme sono corrette in entrambi i casi, la lista dei valori aggiunti è evidentemente duplicata. Cosa è successo?

Per capirlo possiamo eseguire di nuovo il codice del client dopo aver instrumentato il codice dei metodi add delle classi Adder e LogAdderExt con cali, uno strumento che permette di aggiungere automaticamente codice ai metodi per tracciarne le invocazioni.

it.unimi.di.prog2.notes.ced.ec.Adder.add([1, 2, 3])
it.unimi.di.prog2.notes.ced.ec.Adder.add(1)
it.unimi.di.prog2.notes.ced.ec.Adder.add(2)
it.unimi.di.prog2.notes.ced.ec.Adder.add(3)
6
it.unimi.di.prog2.notes.ced.ec.LogAdderExt.add([1, 2, 3])
it.unimi.di.prog2.notes.ced.ec.Adder.add([1, 2, 3])
it.unimi.di.prog2.notes.ced.ec.LogAdderExt.add(1)
it.unimi.di.prog2.notes.ced.ec.Adder.add(1)
it.unimi.di.prog2.notes.ced.ec.LogAdderExt.add(2)
it.unimi.di.prog2.notes.ced.ec.Adder.add(2)
it.unimi.di.prog2.notes.ced.ec.LogAdderExt.add(3)
it.unimi.di.prog2.notes.ced.ec.Adder.add(3)
6
[1, 2, 3, 1, 2, 3]

Quel che accade è che l'implementazione del metodo add per la lista di LogAdderExt, dopo aver aggiunto alla lista gli interi passati come argomento, invoca il metodo del supertipo il quale, a sua volta, invoca il metodo add per il singolo intero; questo, a causa del fatto che il tipo concreto è LogAdderExt, risulta in una chiamata del suo metodo add per un singolo intero (che aggiunge di nuovo il numero alla lista) che infine chiama il metodo del supertipo che svolge la somma.

Osservate è molto improbabile basarsi sulle specifiche per evitare un problema del genere, dato che non è necessario che una specifica segnali che adopererà altri metodi della classe per svolgere il suo compito — a meno che la classe non sia esplicitamente specificata (e documentata) per essere estesa.

Composizione

Una soluzione semplice a questo problema è fare in modo che la classe che somma e tiene traccia dei numeri non estenda Adder ma lo adoperi, attraverso la composizione, come nella classe LogAdderComp

public class LogAdderComp {
  private final Adder adder = new Adder();
  private final List<Integer> seen = new ArrayList<>();

  public void add(int x) {
    seen.add(x);
    adder.add(x);
  }

  public void add(List<Integer> l) {
    seen.addAll(l);
    adder.add(l);
  }

  public int result() {
    return adder.result();
  }

  public List<Integer> log() {
    return List.copyOf(seen);
  }
}

[sorgente]

In questo caso, dato che adder e seen sono oggetti distinti, non è possibile alcuna commistione tra le chiamate dei loro metodi e, come mostrato dal client seguente, l'errore di prima non si presenta

public static void main(String[] args) {
  Adder a = new Adder();
  a.add(List.of(1, 2, 3));
  System.out.println(a.result());

  LogAdderComp lac = new LogAdderComp();
  lac.add(List.of(1, 2, 3));
  System.out.println(lac.result());
  System.out.println(lac.log());
}

[sorgente]

6
6
[1, 2, 3]

Si può fare meglio

La soluzione precendente presenta però un limite: i due tipi non sono in alcuna relazione, per cui non è possibile sostiuire facilmente uno all'altro. L'ideale sarebbe avere una interfaccia AdderInterface come la seguente

public interface AdderInterface {

  public void add(int x);

  public void add(List<Integer> l);

  public int result();
}

[sorgente]

che consenta di scrivere codice come

public static void use(AdderInterface a) {
  a.add(List.of(1, 2, 3));
  System.out.println(a.result());
}

[sorgente]

consentendo di decidere in un secondo momento se usare un semplice Adder (il cui codice è identico a quello presentato in precedenza, fatta eccezione per il fatto che nell'intestazione della classe aggiungeremo implements AdderInterface) che tenga traccia della somma, o una variante di esso in grado di tener traccia anche degli addendi.

Ma non vogliamo incorrere nell'errore precedente.

Per questa ragione astraiamo l'idea di usare il supertipo in una classe che implementi l'interfaccia di cui sopra, delegando (forward) ogni metodo al supertipo. Questa classe si comporta un po' come LogAdderComp, ma non altera alcun comportamento; si occupa solo di "girare" al supertipo tutte le chiamate.

public class AdderForwarder implements AdderInterface {

  private final AdderInterface adder;

  public AdderForwarder(AdderInterface adder) {
    this.adder = adder;
  }

  @Override
  public void add(int x) {
    adder.add(x);
  }

  @Override
  public void add(List<Integer> l) {
    adder.add(l);
  }

  @Override
  public int result() {
    return adder.result();
  }
}

[sorgente]

Utilizzando tale "guscio" è molto semplice ottenere il risultato cercato:

  • ottenendo un sottotipo della medesima interfaccia che soddisfa Adder,
  • non incorrendo nel rischio di un rimbalzo tra i metodi del supertipo e del sottotipo.
public class LogAdder extends AdderForwarder {
  private final List<Integer> seen = new ArrayList<>();

  public LogAdder(AdderInterface adder) {
    super(adder);
  }

  @Override
  public void add(int x) {
    seen.add(x);
    super.add(x);
  }

  @Override
  public void add(List<Integer> l) {
    seen.addAll(l);
    super.add(l);
  }

  public List<Integer> log() {
    return List.copyOf(seen);
  }
}

[sorgente]

Osservate che, tra l'altro, in questa classe è sufficiente sovrascrivere solo i metodi add perché a implementare le funzionalità del metodo result ha già provvedduto il forward contenuto nella classe AdderForwarder; ovviamente va aggiunto il metodo log relativo alla nuova funzionalità provvista dal sottotipo.

Ora è del tutto banale poter invocare use sul sommatore, o sul sommatore che tenga traccia degli addendi.

public class MainEF {

  public static void use(AdderInterface a) {
    a.add(List.of(1, 2, 3));
    System.out.println(a.result());
  }

  public static void main(String[] args) {
    AdderInterface a = new Adder();
    use(a);

    LogAdder la = new LogAdder(new Adder());
    use(la);
    System.out.println(la.log());
  }
}

[sorgente]

Osservate che c'è un vantaggio in più: LogAdder può scegliere nel suo costruttore a che implementazione di AdderInterface fare riferimento; se in futuro ci fossero implementazioni migliori, LogAdder, non essendo vincolato a essere un sottotipo di Adder, potrebbe essere utilizzato con una qualunque di tali implementazioni.

L'esecuzione del codice client mostra il comportamento desiderato

6
6
[1, 2, 3]