POO
Els conceptes i patrons d’aquest document són aplicables a qualsevol llenguatge orientat a objectes. S’inclouen notes específiques per a Java i C# perquè són dos dels llenguatges OOP amb tipatge estàtic més utilitzats, i tot i compartir els mateixos fonaments, difereixen en aspectes sintàctics i idiomàtics que val la pena conèixer.
Conceptes de POO
Abstracció
L’abstracció amaga la complexitat a l’usuari i només mostra la informació rellevant. Permet centrar-nos en el “què” en lloc del “com”. Els detalls d’un mètode abstracte són implementats de forma separada per cada classe. Per exemple, un Compte bancari pot declarar un mètode abstracte calculaInteres(): sabem què fa (retornar l’interès), però cada tipus de compte decideix com el calcula.
Objectius: reusabilitat de codi, flexibilitat d’implementació, herència múltiple.
Java: classes abstractes (abstracció parcial) i interfícies (abstracció total). Sintaxi: abstract (classes abstractes) i interface, implements (interfícies).
C#: classes abstractes i interfícies, igual que Java. Sintaxi: abstract (classes abstractes) i interface, : (interfícies). Tant Java (des de Java 8) com C# (des de C# 8) permeten implementació per defecte en interfícies (default interface methods).
Encapsulació
L’encapsulació lliga les dades i els mètodes relacionats dins d’una classe. També protegeix les dades (estat) fent els camps privats i donant accés a ells només mitjançant els mètodes relacionats que implementen les regles de negoci i invariants. Per exemple, un Compte manté el saldo com a camp privat i només el deixa modificar a través de diposita(), que garanteix que mai quedi en un estat invàlid.
Objectius: protecció de dades (estat), llegibilitat del codi.
Java: camps privats i mètodes públics (només els exposats). Sintaxi: private, setX(), getX().
C#: camps privats i propietats públiques amb accessors. Sintaxi: private, public Type Name { get; set; }. C# té suport natiu de propietats, evitant la necessitat de mètodes getters/setters explícits.
El contrast entre getters/setters i propietats es veu així:
public class Compte {
private double saldo; // estat amagat
public void diposita(double quantitat) {
if (quantitat <= 0) throw new IllegalArgumentException();
saldo += quantitat; // la invariant es garanteix aquí
}
public double getSaldo() { return saldo; } // accés només de lectura
}
public class Compte {
public double Saldo { get; private set; } // propietat amb set privat
public void Diposita(double quantitat) {
if (quantitat <= 0) throw new ArgumentException();
Saldo += quantitat;
}
}
Herència
Amb l’objectiu de reusar codi, l’herència permet que una classe fill hereti les característiques (camps i mètodes) d’una altra classe pare. La relació es diu is-a. L’herència s’utilitza quan sabem que:
- Les dues classes es troben en el mateix domini lògic.
- La superclasse és un contracte que no canviarà (interfície o classe abstracta).
- Les millores realitzades per la subclasse són principalment additives.
Per exemple, un CompteEstalvi és un Compte (relació is-a): hereta diposita() i només afegeix el seu propi càlcul d’interès.
L’alternativa és la composició.
Objectius: reusabilitat de codi, llegibilitat del codi.
Java: Classe pare i class fill. Alternativa vàlida: patrons de composició i delegació. Sintaxi: extends.
C#: mateixa mecànica que Java (herència simple de classes). Sintaxi: : (tant per herència com per implementació d’interfícies). C# també ofereix la paraula clau sealed (equivalent a final en Java) per evitar l’herència.
Composició
La composició és una alternativa de l’herència per a reusar codi. La relació es diu has-a. Es pot aconseguir utilitzant instàncies d’altres objectes. És una tècnica preferible a la herència, ja que redueix l’acoblament del codi. Per exemple, un Compte conté un Historial de moviments (relació has-a): el compte el crea i n’és propietari.
Un tipus específic de composició és l’agregació, que no implica la propietat dels objectes interns i, per tant, tampoc la destrucció d’aquests quan l’objecte contenidor es destrueix. Per exemple, el Titular d’un compte n’és una agregació: existeix independentment i pot seguir viu encara que el compte es tanqui.
Objectius: reusabilitat de codi.
Java: variables d’instància d’altres classes. Sintaxi: private ClassName instanceName.
C#: idèntic a Java. Sintaxi: private ClassName instanceName;. Es pot combinar amb propietats per exposar funcionalitat: public ClassName Name { get; }.
Polimorfisme
El polimorfisme permet que un mateix missatge tingui comportaments diferents segons l’objecte que el rep. Existeixen dos tipus: el polimorfisme estàtic (sobrecàrrega de mètodes, resolt en temps de compilació) i el polimorfisme dinàmic (sobreescriptura de mètodes, resolt en temps d’execució mitjançant dynamic binding), que permet substituir i estendre la funcionalitat d’objectes que comparteixen una mateixa interfície. Per exemple, si cridem calculaInteres() sobre una variable de tipus Compte que en realitat conté un CompteEstalvi, s’executa la versió d’aquest darrer.
Objectius: llegibilitat del codi, flexibilitat del codi.
Java: sobreescriptura de mètode (dinàmic), menys rellevant: sobrecàrrega de mètode (estàtic). Sintaxi: myMethod(), myMethod(int x), myMethod(int x, String y) (estàtic) i ParentClass.myMethod(), ChildClass.myMethod() (dinàmic).
C#: mateixa mecànica. Diferència clau: en C# cal marcar explícitament els mètodes com a virtual a la classe pare i override a la classe fill per habilitar el polimorfisme dinàmic (en Java tots els mètodes no estàtics són virtuals per defecte). Sintaxi: virtual, override, new (per amagar mètodes).
El dynamic binding fa que la crida es resolgui segons el tipus real de l’objecte, no el declarat:
class Compte {
double calculaInteres() { return 0; }
}
class CompteEstalvi extends Compte {
@Override double calculaInteres() { return getSaldo() * 0.02; }
}
Compte c = new CompteEstalvi();
c.calculaInteres(); // s'executa la versió de CompteEstalvi (2% del saldo)
class Compte {
public virtual double CalculaInteres() => 0;
}
class CompteEstalvi : Compte {
public override double CalculaInteres() => Saldo * 0.02;
}
Relacions entre objectes
A més dels conceptes anteriors (herència, composició i agregació), existeixen altres tipus de relacions entre objectes. Es poden ordenar segons com d’acoblats deixen els objectes, des de la dependència puntual fins a la composició, en què una part no pot viure sense el seu tot:
- Associació: un objecte manté una referència a un altre com a part de la seva estructura (habitualment un camp), de manera duradora.
- Dependència: un objecte només fa servir un altre de forma puntual dins d’una operació (com a paràmetre, variable local o valor de retorn), sense guardar-ne cap referència.
El diagrama següent mostra totes les relacions, incloent les ja explicades. S’utilitzen com a exemple classes de la biblioteca estàndard de Java (Number, Double, Comparable), que tenen equivalents directes en C# (Object, Double, IComparable).
Cada relació que hi apareix es llegeix així:
- Herència:
Doubleés unNumber, i per tant n’hereta els camps i mètodes. Al diagrama, un triangle buit apunta de la subclasse cap a la superclasse.Doubletambé implementa (realitza) la interfícieComparable, dibuixada amb el mateix triangle però amb línia discontínua: es compromet a oferir-ne les operacions. - Associació:
Parent1iChild1es coneixen i es referencien mútuament, sense cap extrem privilegiat (línia simple, sense punta). Tot i els noms, aquí no hi ha herència sinó una relació de domini: pare i fill s’associen. - Associació navegable: com l’anterior, però amb un únic sentit.
Child2pot accedir aParent2, però no a l’inrevés; una punta de fletxa oberta n’indica la direcció. - Agregació:
Cares compon de parts comWheel, però aquestes poden existir per separat: una roda es pot treure del cotxe i seguir sent útil. Es dibuixa amb un rombe buit al costat del tot (Car). - Composició:
Humanposseeix les seves parts; si desapareix, se les emporta. UnHeartno té sentit fora d’unHuman. Es dibuixa amb un rombe ple al costat del tot. - Dependència:
Object1només fa servirObject2de manera puntual (per exemple, rebent-lo com a paràmetre d’un mètode), sense guardar-ne cap referència. És l’acoblament més feble i es dibuixa amb una fletxa discontínua.
Tipus d’objectes
Un cop entesos els conceptes i les relacions, podem classificar els objectes segons les seves característiques. Un objecte és una entitat que pot integrar comportament i estat. La pràctica més acceptada és la d’encapsular l’estat darrere del comportament, perquè formen part de la implementació, que és convenient amagar.
Una primera classificació dels objectes es pot fer en funció de dues característiques: si tenen estat (stateful) o no (stateless), i en el cas que en tinguin, si són immutables o no (mutables):
- Un objecte té estat si el seu comportament depèn d’interaccions prèvies. Els objectes sense estat tenen un comportament esperable que només depèn dels paràmetres de les crides, com passa a la programació funcional.
- Un objecte immutable té un estat que no pot canviar-se després de ser creat. Permeten escriure codi més fàcil d’entendre i segurs quan hi ha concurrència.
Segons el seu ús de les dades, podem fer una classificació dels tipus d’objectes que podem trobar més freqüentment en llenguatges orientats a objectes com Java i C#:
- Abstract Data Types (ADT): són objectes amb comportament i estat encapsulat, i habitualment mutables. Cal definir detalladament el seu comportament, i evitar retornar referències a objectes mutables del seu estat.
- Estructures de dades: són objectes amb només estat, i poden ser immutables. Permeten transferir dades o representar un estat. En Java s’utilitzen
record(Java 16+); en C# s’utilitzenrecord(C# 9+) ostruct. - Serveis: són objectes amb només comportament que poden operar sobre dades. També es poden anomenar utilitats o helpers.
Aquestes són propietats a l’hora de classificar classes d’objectes:
| Propietat | Valors | Explicació |
|---|---|---|
| State | Stateless / Stateful | Si té estat o no |
| Mutability | Mutable / Immutable | Si és modificable un cop creat o no |
| Lifecycle | One-shot / Reusable | Si es pot reutilitzar un cop feta la seva funció |
| Concurrency | Thread-safe / Not | Si es pot compartir entre fils d’execució |
| Timing | Synchronous / Asynchronous | Si s’executa immediatament o ho fa després concurrentment |
| Purity | Pure / Impure | Si no té efectes secundaris i retorna sempre el mateix resultat per als mateixos paràmetres |
| Ownership | Owns Resources / Not | Si és responsable de netejar recursos quan acaba |
| Extensibility | Open / Closed | Si el disseny permet o no estendre la classe |
Patrons de disseny
Aplicant els conceptes, relacions i classificacions anteriors, podem identificar solucions recurrents als problemes de disseny. Un patró de disseny és una solució general a un problema comú i recurrent en el disseny de programari. Molts patrons implementen els principis de disseny com ara SOLID. Un patró de disseny no és un disseny acabat que es pot transformar directament en codi; és una descripció o plantilla per resoldre un problema que es pot utilitzar en moltes situacions diferents.
Tenim algunes categories generals:
- De comportament: identifiquen patrons de comunicació entre objectes.
- Estructurals: faciliten el disseny quan s’han d’establir relacions entre entitats.
- Creacionals: relacionats amb mecanismes de creació d’objectes de la forma més adient per cada cas.
- Concurrència: tracten el paradigma de programació multifil.
A continuació es mostren alguns patrons importants.
Patrons de comportament
Command
El patró command encapsula una sol·licitud com a objecte, de manera que us permetrà parametrizar altres objectes amb diferents peticions, cues o peticions de registre i donar suport a operacions reversibles.
Tenim dos actors principals: el Client crea el ConcreteCommand i assigna el seu Receiver. L’Invoker té Commands que pot executar.
En Java i C# la implementació és idèntica. En C#, el patró es pot simplificar utilitzant Action o Func<T> com a commands lleugers en lloc de crear classes dedicades.
Un ús típic desacobla qui demana l’acció de qui la fa:
Command ordre = new EngegarLlum(llum); // encapsula acció i receptor
invoker.setCommand(ordre);
invoker.run(); // l'Invoker executa sense saber què fa
ICommand ordre = new EngegarLlum(llum); // encapsula acció i receptor
invoker.SetCommand(ordre);
invoker.Run(); // l'Invoker executa sense saber què fa
Iterator
El patró iterator proporciona una manera d’accedir als elements d’un objecte agregat seqüencialment sense exposar la seva representació subjacent.
Exemples Java: java.util.Iterator i java.util.Enumeration
Exemples C#: IEnumerator<T> i IEnumerable<T>. C# també ofereix yield return per crear iteradors de forma simplificada.
Cada llenguatge amaga l’iterador darrere la seva sintaxi idiomàtica:
for (String nom : noms) { // el for-each usa Iterator per sota
System.out.println(nom);
}
foreach (var nom in noms) { // el foreach usa IEnumerator per sota
Console.WriteLine(nom);
}
Observer
El patró observer defineix una dependència entre molts objectes de manera que quan un objecte canvia d’estat, tots els seus dependents són notificats i actualitzats automàticament.
Exemples Java: java.util.EventListener
Exemples C#: C# implementa el patró observer de forma nativa amb event i delegate, fent innecessària la implementació manual del patró en la majoria de casos.
La diferència es nota: en Java cal mantenir la llista d’observadors a mà, mentre que en C# un event ho fa de forma nativa:
class Compte {
private final List<Consumer<Double>> observadors = new ArrayList<>();
public void afegeix(Consumer<Double> o) { observadors.add(o); } // subscripció
public void diposita(double q) { saldo += q; observadors.forEach(o -> o.accept(q)); } // notifica
}
compte.afegeix(q -> System.out.println("Nou moviment: " + q)); // un observador s'hi subscriu
class Compte {
public event Action<double> SaldoCanviat; // el subject
public void Diposita(double q) { Saldo += q; SaldoCanviat?.Invoke(q); } // notifica
}
compte.SaldoCanviat += q => Console.WriteLine($"Nou moviment: {q}"); // un observador s'hi subscriu
State
El patró state permet a un objecte alterar el seu comportament quan canvia el seu estat intern. L’objecte semblarà canviar de classe.
El Context pot tenir un nombre de States. Quan cridem request(), el que fem es cridar el handle() corresponent del State actual. Un Client no coneix el funcionament intern dels States.
La implementació és equivalent en Java i C#. En C#, es pot combinar amb pattern matching (switch amb patrons de tipus) per simplificar la lògica de transició d’estats.
El propi estat decideix a quin estat es passa, i el context només delega:
class Semafor {
private Estat estat = new Vermell();
public void seguent() { estat = estat.seguent(); } // l'estat actual tria el següent
}
class Semafor {
private Estat estat = new Vermell();
public void Seguent() => estat = estat.Seguent(); // l'estat actual tria el següent
}
Strategy
El patró strategy s’utilitza quan tenim diversos algorismes per a una tasca específica i el client decideix que s’utilitzi la implementació real en temps d’execució.
És molt similar a State, però és el Client qui habitualment escull la Strategy.
Exemples Java: Collections.sort() amb el paràmetre Comparator.
Exemples C#: List<T>.Sort() amb el paràmetre IComparer<T>, o bé utilitzant lambdes Func<T, TResult> directament com a estratègies.
Sovint l’estratègia és simplement una lambda passada com a paràmetre:
List<String> noms = obtenirNoms();
noms.sort(Comparator.comparing(String::length)); // estratègia: ordenar per longitud
var noms = ObtenirNoms();
noms.Sort((a, b) => a.Length - b.Length); // estratègia: ordenar per longitud
Dependency Injection
El patró de Dependency Injection permet a un objecte rebre les instàncies d’altres objectes de què depèn. Això permet que el client no s’hagi de referir a instàncies concretes, seguint el principi d’inversió de dependència. S’utilitza per implementar el principi de la inversió de control (IoC) mitjançant un contenidor.
Hi ha tres tipus d’injecció: Constructor, Setter i Interface.
- Constructor: li diem al contenidor quina implementació s’utilitza per als paràmetres del constructor, que poden ser interfícies.
- Setter: li diem al contenidor quines propietats (amb setters) cal instanciar d’un cert objecte.
- Interface: li diem al contenidor que el nostre client rebrà les dependències mitjançant un mètode (del tipus
setService).
Per utilitzar-los, cal configurar el contenidor. Es pot fer programàticament o mitjançant un arxiu de configuració.
L’objecte que injecta les dependències es diu injector, i els objectes de què depèn solen ser serveis.
En Java, els contenidors més comuns són Spring i CDI. En C#, el framework .NET inclou un contenidor de DI integrat (Microsoft.Extensions.DependencyInjection) amb suport natiu a ASP.NET Core.
La forma més habitual és la injecció per constructor, que deixa la dependència explícita:
class ServeiPagament {
private final PasarelaBanc banc;
ServeiPagament(PasarelaBanc banc) { this.banc = banc; } // el contenidor decideix la implementació
}
class ServeiPagament {
private readonly PasarelaBanc banc;
public ServeiPagament(PasarelaBanc banc) => this.banc = banc; // el contenidor decideix la implementació
}
Service Locator
El Service Locator és una alternativa al Dependency Injector, amb la particularitat que el codi client té una dependència del contenidor, que utilitza per localitzar tots els serveis que necessita. És a dir, no es produeix la injecció automàtica.
No es tracta d’una Factory, ja que no crea els serveis cada cop, només si cal. Es tracta més bé d’un registre.
A diferència de la injecció, aquí és el client qui demana el servei al localitzador:
PasarelaBanc banc = ServiceLocator.get(PasarelaBanc.class); // el client depèn del localitzador
var banc = ServiceLocator.Get<PasarelaBanc>(); // el client depèn del localitzador
Template method
El patró template method defineix l’esquelet d’un algorisme d’un mètode, diferint alguns passos a subclasses. Aquest patró permet que les subclasses redefineixin certs passos d’un algorisme sense canviar l’estructura de l’algorisme.
El templateMethod() fa ús del subMethod(). La implementació és idèntica en Java i C#: una classe abstracta amb un mètode virtual o abstract que les subclasses sobreescriuen.
Exemple: construcció d’una casa de fusta o de vidre
L’esquelet queda fix a la classe pare i només varien els passos marcats:
abstract class Joc {
final void jugar() { iniciar(); tornada(); acabar(); } // esquelet invariable
abstract void tornada(); // pas que varia per joc
}
abstract class Joc {
public void Jugar() { Iniciar(); Tornada(); Acabar(); } // esquelet invariable (no virtual)
protected abstract void Tornada(); // pas que varia per joc
}
Patrons estructurals
Els patrons estructurals següents s’implementen de forma pràcticament idèntica en Java i C#, ja que depenen de conceptes comuns (interfícies, composició, herència).
Adapter
El patró adapter converteix la interfície d’una classe en una altra interfície que els clients esperen. L’adaptador permet que les classes treballin conjuntament, tot i tenir interfícies incompatibles.
El ConcreteAdapter està composat amb l’Adaptee, que li permet una operació addicional: adaptedOperation().
Exemple: un capità que només pot utilitzar barques de rem i no pot navegar
El client treballa sempre amb la interfície esperada, i l’adaptador tradueix per sota:
Adapter a = new ConcreteAdapter(adaptee); // embolcalla l'Adaptee incompatible
a.operation(); // el client només parla amb Adapter
IAdapter a = new ConcreteAdapter(adaptee); // embolcalla l'Adaptee incompatible
a.Operation(); // el client només parla amb IAdapter
Composite
El patró composite permet compondre objectes en estructures d’arbre per representar jerarquies parcials. Composite permet als clients tractar objectes individuals i composicions d’objectes de manera uniforme.
Tant els objectes individuals com els composats poden ser tractats igual amb operation().
El client crida la mateixa operació sobre una fulla que sobre tot un arbre:
Composite carpeta = new Composite();
carpeta.add(new Fitxer("a.txt")); // una fulla
carpeta.add(subCarpeta); // un altre composite
carpeta.operation(); // recorre tot l'arbre uniformement
var carpeta = new Composite();
carpeta.Add(new Fitxer("a.txt")); // una fulla
carpeta.Add(subCarpeta); // un altre composite
carpeta.Operation(); // recorre tot l'arbre uniformement
Decorator
El patró decorator atribueix dinàmicament responsabilitats addicionals a un objecte. Els decorators proporcionen una alternativa flexible a la subclasse per ampliar la funcionalitat.
Podem afegir un comportament al ConcreteDecorator.
Exemple: trol decorat amb una porra
Les biblioteques estàndard en són l’exemple canònic: cada decorator embolcalla l’anterior sense canviar-ne la interfície.
Reader r = new BufferedReader(new FileReader("dades.txt"));
// FileReader llegeix el fitxer; BufferedReader hi afegeix memòria intermèdia
Stream s = new BufferedStream(File.OpenRead("dades.txt"));
// FileStream llegeix el fitxer; BufferedStream hi afegeix memòria intermèdia
Facade
El patró facade proporciona una interfície unificada i més senzilla a un conjunt d’interfícies d’un subsistema. La façana defineix una interfície de més alt nivell que facilita la utilització del subsistema, seguint el principi del “Least knowledge”.
Exemple: treballadors de la mina d’or
Una sola crida a la façana amaga tota l’orquestració del subsistema:
// sense façana caldria coordinar estoc, pagament i enviament a mà
comanda.tramita(cistell); // la façana ho encapsula tot
// sense façana caldria coordinar estoc, pagament i enviament a mà
comanda.Tramita(cistell); // la façana ho encapsula tot
Proxy
El patró de proxy proporciona un substitut per a un altre objecte per controlar-ne l’accés.
Qualsevol Client pot tractar el Proxy com el RealSubject, ja que implementen Subject.
Exemple: els tres mags que entren a la torre
Un proxy pot, per exemple, endarrerir la càrrega del recurs real fins que cal:
Imatge img = new ImatgeProxy("foto.png"); // encara no llegeix el fitxer
img.mostra(); // el proxy carrega la imatge real ara
IImatge img = new ImatgeProxy("foto.png"); // encara no llegeix el fitxer
img.Mostra(); // el proxy carrega la imatge real ara
Patrons creacionals
Els patrons creacionals estan relacionats amb mecanismes de creació d’objectes de la forma més adient per a cada cas, abstraient el procés d’instanciació. La implementació d’aquests patrons és equivalent en Java i C#.
Builder
El patró builder separa la construcció d’un objecte complex de la seva representació, de forma que el mateix procés de construcció pot generar diferents representacions.
A la pràctica, sol oferir una API fluida que encadena les parts i acaba amb build():
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://jg5.dev"))
.header("Accept", "application/json")
.GET()
.build();
IConfiguration cfg = new ConfigurationBuilder()
.AddJsonFile("app.json")
.AddEnvironmentVariables()
.Build();
Factory method
Factory method defineix una interfície per crear un objecte, però permet que les subclasses decideixin quina classe s’inicia. Permet diferir la instància de classe a subclasses.
El mètode factoryMethod() és abstracte, i s’encarrega de crear Products. Permet seguir el principi de “Dependency inversion”: evitar dependències de tipus concrets.
Exemple: el regne que necessita tres objectes temàtics
El client obté un producte sense conèixer-ne la classe concreta:
Boto boto = fabrica.crearBoto(); // pot ser BotoWindows, BotoMac...
boto.pinta();
IBoto boto = fabrica.CrearBoto(); // pot ser BotoWindows, BotoMac...
boto.Pinta();
Singleton
El patró de Singleton assegura que una classe només té una instància i li proporciona un punt d’accés global.
Exemple: només pot haver una torre d’ivori
La forma clàssica té el constructor privat i exposa una única instància estàtica:
public class Config {
private static final Config INSTANCE = new Config();
private Config() { } // ningú més pot instanciar
public static Config getInstance() { return INSTANCE; }
}
public sealed class Config {
public static Config Instance { get; } = new Config(); // instància única
private Config() { } // ningú més pot instanciar
}