Bridge connectava dues jerarquies; avui l'estructura creix cap endins. La carta d'un restaurant a PideYa no és una llista plana: és un arbre. Seccions ("Pizzes", "Begudes") que contenen subseccions ("Begudes > Refrescos", "Begudes > Vins") que contenen plats; i menús que agrupen plats —i de vegades altres menús— amb preu conjunt. Cada vegada que el codi pregunta "això és un plat o una secció?" abans d'operar, s'escriu un if que es repetirà en vint llocs. Composite elimina aquesta pregunta: fulles i grups comparteixen interfície, i l'arbre sencer es tracta igual que un sol objecte.

Contingut

  1. El problema a PideYa: la carta és un arbre
  2. Intenció i estructura del patró
  3. Implementació Java completa
  4. Recorreguts recursius
  5. Menús: quan el grup també es ven
  6. Transparència davant de seguretat
  7. Quan usar-lo i quan no
  8. Errors comuns, exercicis i conclusió

El problema a PideYa: la carta és un arbre

La carta de La Bella Napoli, un dels restaurants estrella de PideYa, té aquesta pinta:

Carta La Bella Napoli
├── Pizzes
│   ├── Classiques
│   │   ├── Margarita ........ 8,50 €
│   │   └── Prosciutto ....... 9,90 €
│   └── Especials
│       └── Tartufo .......... 13,50 €
├── Pastes
│   └── Carbonara ............ 10,50 €
└── Begudes
    ├── Aigua ................ 1,50 €
    └── Refrescos
        └── Cola ............. 2,20 €

Profunditat arbitrària: seccions dins de seccions, sense límit pactat. El primer model de l'equip va usar dues classes sense relació i col·leccions separades:

// Primer intent: dos mons separats. NO imitar.
public class Seccio {
    private String nom;
    private List<Seccio> subseccions;
    private List<Plat> plats;
}

I tot el codi que toca la carta va acabar infestat del mateix doble bucle amb pregunta:

// Per mostrar la carta... i una altra copia per a disponibilitat... i una altra per buscar...
void mostrar(Seccio seccio, int nivell) {
    imprimir(seccio.getNom(), nivell);
    for (Plat p : seccio.getPlats()) {          // branca 1: fulles
        imprimir(p.getNom() + " " + p.getPreu(), nivell + 1);
    }
    for (Seccio s : seccio.getSubseccions()) {  // branca 2: grups
        mostrar(s, nivell + 1);
    }
}

Cada operació nova (està disponible aquesta secció? quants plats sense gluten hi ha? què mostrar si la cuina tanca la graella?) duplica l'estructura de doble branca. I quan negoci demani que un menú contingui altres menús, les dues llistes paral·leles ja no donaran més de si. El símptoma, en net: una estructura part-tot recursiva tractada amb condicionals en lloc de amb una interfície comuna.

Intenció i estructura del patró

Intenció (GoF): compondre objectes en estructures d'arbre per representar jerarquies part-tot. Composite permet als clients tractar de manera uniforme tant els objectes individuals com les composicions d'objectes.

Les dues idees del patró, que convé separar:

  1. Una interfície comuna (Component) per a fulles i grups: les operacions que tenen sentit en tots dos (mostrar, estaDisponible...).
  2. El grup conté Components, no fulles ni grups concrets: per això la niada és il·limitada gratis — una secció que conté seccions que contenen plats és simplement un Composite amb fills Component.
classDiagram
    class ComponentCarta {
        <<interface>>
        +getNom() String
        +estaDisponible() boolean
        +comptarPlatsDisponibles() int
        +mostrar(nivell: int)
    }
    class Plat {
        -nom: String
        -preu: BigDecimal
        -disponible: boolean
        +estaDisponible() boolean
        +comptarPlatsDisponibles() int
        +mostrar(nivell: int)
    }
    class SeccioCarta {
        -nom: String
        -fills: List~ComponentCarta~
        +afegir(fill: ComponentCarta)
        +treure(fill: ComponentCarta)
        +estaDisponible() boolean
        +comptarPlatsDisponibles() int
        +mostrar(nivell: int)
    }
    class AppClient

    ComponentCarta <|.. Plat
    ComponentCarta <|.. SeccioCarta
    SeccioCarta *-- ComponentCarta : fills
    AppClient --> ComponentCarta : usa
Rol GoF A PideYa
Component (interfície comuna) ComponentCarta
Leaf (fulla, sense fills) Plat
Composite (grup, amb fills Component) SeccioCarta (i després Menu)
Client L'app, el cercador, el motor de disponibilitat

La fletxa clau del diagrama és la composició SeccioCarta *-- ComponentCarta — la mateixa relació de rombe ple que vam aprendre amb Comanda *-- LiniaComanda a la lliçó d'UML, i aquí a més recursiva: el composite conté elements de la seva pròpia interfície. Aquesta fletxa que torna és la firma visual del patró.

Implementació Java completa

El Component — només operacions amb sentit en fulles i en grups:

public interface ComponentCarta {
    String getNom();
    boolean estaDisponible();
    int comptarPlatsDisponibles();
    void mostrar(int nivell);
}

La fulla: un plat respon per si mateix, sense recursió:

public class Plat implements ComponentCarta {

    private final String nom;
    private final BigDecimal preu;
    private boolean disponible = true;   // la cuina pot exhaurir-lo

    public Plat(String nom, BigDecimal preu) {
        this.nom = nom;
        this.preu = preu;
    }

    @Override public String getNom() { return nom; }
    public BigDecimal getPreu() { return preu; }
    public void exhaurir() { this.disponible = false; }

    @Override
    public boolean estaDisponible() { return disponible; }

    @Override
    public int comptarPlatsDisponibles() { return disponible ? 1 : 0; }

    @Override
    public void mostrar(int nivell) {
        String sagnia = "  ".repeat(nivell);
        System.out.println(sagnia + nom + " ... " + preu + " €"
                + (disponible ? "" : " [EXHAURIT]"));
    }
}

El composite: una secció delega en els seus fills sense saber si són plats o subseccions:

public class SeccioCarta implements ComponentCarta {

    private final String nom;
    private final List<ComponentCarta> fills = new ArrayList<>();

    public SeccioCarta(String nom) { this.nom = nom; }

    public SeccioCarta afegir(ComponentCarta fill) {
        fills.add(fill);
        return this;              // fluid, a l'estil del Builder del modul 2
    }

    public void treure(ComponentCarta fill) { fills.remove(fill); }

    @Override public String getNom() { return nom; }

    /** Una seccio esta disponible si ALGUN fill ho esta. */
    @Override
    public boolean estaDisponible() {
        return fills.stream().anyMatch(ComponentCarta::estaDisponible);
    }

    /** Suma el que diguin els fills; la recursio es propaga sola. */
    @Override
    public int comptarPlatsDisponibles() {
        return fills.stream().mapToInt(ComponentCarta::comptarPlatsDisponibles).sum();
    }

    @Override
    public void mostrar(int nivell) {
        System.out.println("  ".repeat(nivell) + "== " + nom + " ==");
        for (ComponentCarta fill : fills) {
            fill.mostrar(nivell + 1);
        }
    }
}

Muntar l'arbre i usar-lo — el client no pregunta mai què té al davant:

SeccioCarta carta = new SeccioCarta("Carta La Bella Napoli");

SeccioCarta pizzes = new SeccioCarta("Pizzes");
pizzes.afegir(new SeccioCarta("Classiques")
                    .afegir(new Plat("Margarita", new BigDecimal("8.50")))
                    .afegir(new Plat("Prosciutto", new BigDecimal("9.90"))))
      .afegir(new SeccioCarta("Especials")
                    .afegir(new Plat("Tartufo", new BigDecimal("13.50"))));

carta.afegir(pizzes);
carta.afegir(new SeccioCarta("Pastes").afegir(new Plat("Carbonara", new BigDecimal("10.50"))));

// Tractament uniforme: tant li fa passar-li un plat, una seccio o la carta sencera.
carta.mostrar(0);
System.out.println("Plats disponibles: " + carta.comptarPlatsDisponibles());

Compara amb el doble bucle del primer intent: aquí no hi ha ni un sol if (esSeccio) en tot el sistema. La pregunta va desaparèixer perquè la interfície la va respondre d'una vegada per sempre: cada node sap operar-se a si mateix, i els grups saben delegar. Afegir una operació nova continua costant (un mètode més al Component i a cada classe), però afegir nivells o nodes a l'arbre costa zero codi.

Recorreguts recursius

Tota operació sobre l'arbre segueix el mateix motlle, que convé interioritzar perquè l'escriuràs moltes vegades:

  • A la fulla: el cas base, resposta directa (un plat exhaurit compta 0).
  • Al composite: combinar les respostes dels fills (anyMatch, sum, allMatch, concatenar...), cridant la mateixa operació sobre cadascun. La recursió no es programa: emergeix de l'estructura.

El diagrama de seqüència de comptarPlatsDisponibles() sobre una carta petita ho fa visible:

sequenceDiagram
    participant App
    participant Carta as carta: SeccioCarta
    participant Pizzes as pizzes: SeccioCarta
    participant Marg as margarita: Plat
    participant Aigua as aigua: Plat

    App->>Carta: comptarPlatsDisponibles()
    Carta->>Pizzes: comptarPlatsDisponibles()
    Pizzes->>Marg: comptarPlatsDisponibles()
    Marg-->>Pizzes: 1
    Pizzes-->>Carta: 1
    Carta->>Aigua: comptarPlatsDisponibles()
    Aigua-->>Carta: 1
    Carta-->>App: 2

L'app va fer una crida; l'estructura va fer la resta. Quan al mòdul 4 vegem Iterator i Visitor, tornarem sobre aquest arbre per recórrer-lo de maneres més sofisticades (només menció de moment: Iterator externalitza el recorregut; Visitor externalitza les operacions).

Menús: quan el grup també es ven

La carta agrupa per mostrar; els menús agrupen per vendre: "Menú Migdia" (primer + segon + postres, 12,90 € en lloc de la suma), i el "Menú Familiar" que conté dos menús migdia i una beguda gran. És un altre part-tot recursiu —menús dins de menús—, però amb una operació nova: el preu, calculat recursivament amb descompte:

public class Menu implements ComponentCarta {

    private final String nom;
    private final BigDecimal descompte;              // p. ex. 0.15 = 15% sobre la suma
    private final List<ComponentCarta> elements = new ArrayList<>();

    public Menu(String nom, BigDecimal descompte) {
        this.nom = nom;
        this.descompte = descompte;
    }

    public Menu afegir(ComponentCarta element) { elements.add(element); return this; }

    /** Preu recursiu: suma dels preus dels elements, amb descompte. */
    public BigDecimal getPreu() {
        BigDecimal suma = elements.stream()
                .map(Menu::preuDe)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        return suma.multiply(BigDecimal.ONE.subtract(descompte))
                   .setScale(2, RoundingMode.HALF_UP);
    }

    private static BigDecimal preuDe(ComponentCarta c) {
        if (c instanceof Plat plat) return plat.getPreu();
        if (c instanceof Menu menu)  return menu.getPreu();   // recursio: menus niats
        throw new IllegalStateException("Un menu no pot contenir seccions: " + c.getNom());
    }

    /** Un menu nomes es pot demanar si TOTS els seus elements estan disponibles. */
    @Override
    public boolean estaDisponible() {
        return elements.stream().allMatch(ComponentCarta::estaDisponible);
    }

    @Override public String getNom() { return nom; }
    @Override public int comptarPlatsDisponibles() {
        return elements.stream().mapToInt(ComponentCarta::comptarPlatsDisponibles).sum();
    }
    @Override public void mostrar(int nivell) {
        System.out.println("  ".repeat(nivell) + "★ " + nom + " ... " + getPreu() + " €");
        elements.forEach(e -> e.mostrar(nivell + 1));
    }
}

Dos matisos amb suc:

  • Fixa't en el canvi de política de disponibilitat: la secció usa anyMatch (viva si queda alguna cosa per oferir) i el menú usa allMatch (si falten les postres, no hi ha menú). Mateix motlle recursiu, semàntica diferent per composite: el patró dóna l'estructura; el negoci, les regles.
  • Aquest instanceof a preuDe és la costura honesta del disseny: getPreu() no és a ComponentCarta perquè una secció no té preu ("Pizzes" no costa res: conté coses que costen). Podríem haver-lo pujat a la interfície i fer que les seccions llancessin una excepció... la qual cosa ens porta de dret al debat clàssic del patró.

Transparència davant de seguretat

Quines operacions van al Component? El GoF planteja el dilema amb els mètodes de gestió de fills (afegir/treure), i aplica igual a operacions com getPreu():

Enfocament Què va al Component Guanyes Pagues
Transparent Tot, inclòs afegir/treure Uniformitat total: el client mai no fa instanceof ni cast Les fulles tenen mètodes absurds (plat.afegir(...)) que han de llançar UnsupportedOperationException: errors en execució
Segur Només allò comú de debò; afegir/treure només al Composite El compilador impedeix plat.afegir(...): errors en compilació El client que construeix/modifica l'arbre necessita conèixer el tipus concret (o fer casts)

La nostra implementació va triar l'enfocament segur (afegir viu a SeccioCarta i Menu, no a la interfície; getPreu viu a Plat i Menu), i és la recomanació general en Java modern: els errors de compilació són més barats que els d'execució, i a la pràctica qui construeix l'arbre gairebé sempre sap què està construint (es construeix des de configuració, base de dades o un builder), mentre que qui el recorre no necessita mutar res. La transparència total té el seu territori: frameworks on el client manipula arbres genèrics sense conèixer els tipus (el DOM, sistemes de fitxers). Tria per qui pateix: el que construeix (segur li és igual, transparent no li aporta) o el que recorre (en té prou amb allò comú)?

Quan usar-lo i quan no

Usa'l quan:

  • El domini és un arbre part-tot: cartes amb seccions, menús niats, categories de restaurants, zones de repartiment amb subzones, grups de permisos... i vols operar igual sobre l'individu i sobre el grup.
  • Detectes el símptoma del doble tractament: el mateix if (esGrup) ... else ... repetint-se amb cada operació nova.
  • La profunditat de niada és arbitrària o creixerà: les llistes paral·leles (List<Seccio> + List<Plat>) no escalen a això.

No l'usis quan:

  • L'estructura és plana i continuarà sent-ho: una llista de plats sense seccions no necessita Component/Leaf/Composite; una List<Plat> és més simple i més clara (KISS).
  • Fulles i grups no comparteixen operacions significatives: si la interfície comuna queda buida o plena d'UnsupportedOperationException, el domini t'està dient que no hi ha tractament uniforme per capturar.
  • Necessites restriccions fortes sobre la forma de l'arbre (màxim dos nivells, tipus concrets per nivell): la uniformitat del Composite hi juga en contra; de vegades Carta → List<Seccio> → List<Plat> tipat a mà és exactament el que vols.

Relació amb altres patrons (només menció): Decorator és estructuralment un Composite d'un sol fill i sol compartir interfície Component amb ell — ho veurem a la pròxima lliçó amb els extres dels plats; Iterator i Visitor són els patrons de recorregut i operació externa sobre aquestes estructures; els arbres es construeixen còmodament amb Builder i es clonen com a plantilles amb Prototype — de fet el RegistrePlantillesCarta del mòdul 2 desa exactament arbres com aquest.

Errors Comuns i Consells

  • Retornar la llista de fills viva. Un getFills() que retorna la List interna permet a qualsevol mutar l'arbre per fora (carta.getFills().clear()). Retorna List.copyOf(fills) — la mateixa disciplina d'immutabilitat que vam aplicar al Builder de Comanda.
  • Cicles a l'"arbre". Res no impedeix seccioA.afegir(seccioB) i seccioB.afegir(seccioA): la primera recursió s'ho menjarà com un StackOverflowError. Si l'arbre el munten dades externes (un panell d'administració de restaurant), valida en afegir (rebutjar si el nou fill conté el pare).
  • Referència al pare "per si de cas". Afegir getPare() al Component sembla innocent i acobla cada node al seu context, complica moure subarbres i duplica la veritat estructural. Afegeix-la només amb un cas d'ús real (navegació ascendent) i mantén-la coherent a afegir/treure.
  • Ficar al Component allò que no és comú. Cada mètode que només té sentit en un tipus de node empeny cap a la transparència amb excepcions. Abans de pujar un mètode a la interfície, pregunta: què respon una fulla? i un grup? Si una de les dues respostes és "ni idea", a baix es queda.
  • Recursió sense cas degenerat. Seccions buides: estaDisponible() amb anyMatch sobre llista buida retorna false (raonable), però allMatch en un Menu buit retorna true (menú de zero plats disponible!). Decideix i testeja els casos buits explícitament.
  • Consell: escriu els tests contra ComponentCarta, no contra les classes concretes: un test que rep qualsevol subarbre i comprova invariants (comptar ≥ 0, mostrar no llança) documenta el tractament uniforme, que és la promesa del patró.

Exercicis

Exercici 1: plats aptes per a celíacs

Afegeix l'operació comptarSenseGluten() que retorna quants plats sense gluten hi ha sota qualsevol node. Suposa que Plat guanya un atribut booleà senseGluten. Escriu el mètode a les tres classes (Plat, SeccioCarta, Menu) i indica quin és el cas base i quin el pas recursiu.

Exercici 2: el menú impossible

La cuina exhaureix la Carbonara, que és l'únic "primer" del Menú Migdia; el Menú Familiar conté dos Menús Migdia i una beguda disponible. Sense executar codi, raona què retornen menuMigdia.estaDisponible() i menuFamiliar.estaDisponible(), i per què la propagació és automàtica.

Exercici 3: transparència amb conseqüències

Un company proposa pujar afegir(ComponentCarta) a la interfície ComponentCarta "per poder muntar arbres sense casts", fent que Plat.afegir llanci UnsupportedOperationException. Escriu (a) un exemple de codi que avui no compila i amb la seva proposta compilaria i fallaria en execució, i (b) un context en què la seva proposta seria, tot i això, la correcta.

Solucions

Solució 1:

// A Plat: CAS BASE, resposta directa
@Override
public int comptarSenseGluten() { return (disponible && senseGluten) ? 1 : 0; }

// A SeccioCarta: PAS RECURSIU, combinar fills
@Override
public int comptarSenseGluten() {
    return fills.stream().mapToInt(ComponentCarta::comptarSenseGluten).sum();
}

// A Menu: mateix pas recursiu sobre els seus elements
@Override
public int comptarSenseGluten() {
    return elements.stream().mapToInt(ComponentCarta::comptarSenseGluten).sum();
}

I la firma s'afegeix a ComponentCarta (és genuïnament comuna: tota fulla i tot grup saben respondre-la). El motlle és sempre el mateix: fulla = cas base, composite = combinació de fills.

Solució 2: menuMigdia.estaDisponible() → la Carbonara retorna false, l'allMatch del menú troba un element no disponible → false. menuFamiliar.estaDisponible() → el seu allMatch pregunta a cada element; els dos Menús Migdia retornen false (per la recursió anterior) → false, encara que la beguda estigui disponible. Ningú no va escriure lògica de propagació "cap amunt": cada node va aplicar la seva regla local (allMatch) i la composició recursiva va fer emergir el resultat global. Un plat exhaurit a la fulla apaga menús a qualsevol profunditat.

Solució 3: (a) Avui ComponentCarta margarita = new Plat(...); margarita.afegir(unaAltraCosa); no compila (la interfície no té afegir): l'error es detecta gratis. Amb la proposta, compila i rebenta en execució amb UnsupportedOperationException — potser en producció, potser en un cas rar no testejat. (b) Seria raonable en un editor genèric de cartes (un panell d'administració que manipula nodes ComponentCarta arrossegant i deixant anar, sense conèixer tipus concrets): allà la uniformitat de manipulació val més que la seguretat estàtica, que és exactament el territori de la variant transparent.

Conclusió

Composite converteix una jerarquia part-tot en una estructura operable amb una sola interfície: fulles que responen directament, grups que combinen les respostes dels seus fills, i clients que fan una crida sense preguntar mai què tenen al davant. A PideYa va quedar instal·lat a la carta (ComponentCarta, Plat, SeccioCarta) i als menús niats (Menu, amb preu recursiu i la seva pròpia regla de disponibilitat), i va quedar decidida —amb criteri, no per inèrcia— la variant segura davant de la transparent.

Ara mira el plat que acabem de deixar a la carta: la Margarita. Un client la vol amb doble formatge; un altre, sense gluten; un altre, en ració gran amb doble formatge. Subclasses MargaritaDobleFormatge, MargaritaSenseGlutenDobleFormatge...? Això és una altra explosió combinatòria, i la resposta no és heretar sinó embolcallar: objectes que envolten el plat afegint-li preu i descripció, capa a capa, com qui vesteix una ceba a l'inrevés. Ens veiem a Decorator.

Curs de Patrons de Disseny de Programari

Mòdul 1: Introducció als Patrons de Disseny

Mòdul 2: Patrons Creacionals

Mòdul 3: Patrons Estructurals

Mòdul 4: Patrons de Comportament

Mòdul 5: Aplicació de Patrons de Disseny

Mòdul 6: Patrons de Disseny Avançats

Mòdul 7: Recursos Addicionals i Conclusió

© Copyright 2026. Tots els drets reservats