Visualizzazione post con etichetta open closed. Mostra tutti i post
Visualizzazione post con etichetta open closed. Mostra tutti i post

sabato 24 ottobre 2009

crash experiment in Open Closed, Liskov, Equals

In questi giorni ho scritto del codice di esempio per chiarirmi ancora certe idee sulle gerarchie tra le classi.
Ho preso un dominio semplice, quello degli interi positivi dotati di somma e sottrazione, e ne ho fatto una classe.
Il costruttore non accetta numeri negativi e dunque solleva una eccezione (runtime, per semplicità):



public Natural(int value) {

if (value<0) {
throw new RuntimeException("non vale il negativo");
this.value=value;
}
}





(In alternativa si sarebbe potuto ottenere, in congiunzione con un framework di design by contract, lo stesso effetto dell'eccezione per valori negativi, tramite una precondizione tipo:
@Pre("@value>=0"))

Nei prossimi esperimenti mi ripropongo di approfondire questo punto, selezionando un framework per il design by contract.

Essendo le istanze immutabili, allora ridefinisco anche la equals.
In pratica l'effetto di ridefinire la equals è che due istanze che confrontate restituiscono true vengono considerate come se fosse lo stesso oggetto da varie api di java, come quelle che gestiscono Set e mappe.

Cioè se ridefinisco la equals allora, aggiungendo ad un set vuoto due oggetti istanziati entrambi allo stesso modo (new Natural(1)), la numerosità del set sarà uno, non due.

Se l'oggetto fosse mutabile, ovvero potesse cambiare stato dopo essere istanziato, allora la ridefinizione della equals non va fatta, in quanto possono succedere stranezze, per esempio: un oggetto può venir aggiunto ad un insieme, cambiare stato e misteriosamente sparire virtualmente dall'insieme stesso.

Tra tutte le cose misteriose che con il codice possono succedere, questa è talmente bizzarra che si desidera evitarlo, e dunque la ridefinizione della equals per i soli immutable va accettata come regola. Fare diversamente è un tabù.

Quindi per le classi immutabili va ridefinito il metodo equals (e hashcode), mentre per le classi mutabili no.

Tornando alla classe Natural, ora pongo una questione.

Come deve essere una implementazione conforme al principio Open closed?

Più in dettaglio diciamo che il dominio verrà esteso, e vogliamo allo stesso modo che questo determini il dover "aggiungere del nuovo codice", in conformità a questa estension, ma non "modificare codice esistente".

L'estensione è che bisogna descrivere anche i numeri negativi.

Ribadendo che non posso fare modifiche (per quanto semplici possano essere) ma solo estensioni, allora posso per esempio fare una sottoclasse, il cui costruttore non solleva più l'eccezione se istanziata con un negativo, cioè come segue:



public Relative(int value) {
this.value = value;
}



(Per motivi di sintassi java la classe padre deve avere un costruttore a zero argomenti che serve solo alle sottoclassi, e quindi diciamo che è stato già definito in previsione di estensioni, come protected)

Ancora una volta, in termini di precondizioni, si tratta dell'equivalente del rilassare la precondizione della classe padre (Natural) da restrittiva @Pre("@value>=0"), a meno restrittiva
eliminando semplicemente il vincolo value>0.

(Questo è coerente, tra l'altro, con le regole del dbc (design by contract), per cui le precondizioni in ereditarietà possono essere meno restritive, e non più restrittive, mentre il viceversa vale per le eventuali post-condizioni).

Un'altra precondizione che viene rilassata è quella relativa alla sottrazione, che per i naturali è valida se il sottraendo è minore o uguale al diminuendo, mentre tra i relativi non c'è questo vincolo.

A questo punto si pone il problema che abbiamo due domini regolati da due classi, ma che presentano oggetti assimililabili tra loro:
Natural è padre di Relative, e, insiemisticamente parlando, ne è un sottoinsieme.
Le operazioni di somma e sottrazione sui Natural, continuano ad essere valide per i Relative, con esiti che sono considerati uguali, dunque viene rispettato il principio di sostituibilità.

Dovremmo anche aspettarci che la equals possa ammettere con confronto altrettanto coerente tra istanza di Natural e istanze di Relative?

Secondo me sì per due ragioni. Uno è il principio di fare la scelta meno sorprendente. Sarebbe piuttosto sorprendente che l'istanza new Natural(1) e new Relative(1) vengano considerate diverse tra loro, visto che figurativamente parliamo di insiemi uno sottoinsieme dell'altro, dunque condividono alcuni elementi in comune che dovrebbero continuare ad essere gli stessi, non importa quale sia la classe concreta a cui appartengono.

Un altro motivo è reltivo alla definizione di sostituibilità. Devo poter sostituire, (ad una istanza della classe padre una opportuna istanza della classe figlia) e aspettarmi lo stesso risultato, per qualsiasi codice, e io per qualsiasi codice intendo anche la equals ovvero

(new Natural(1)).equals(new Relative(1)) restituisce true perchè lo fa anche
(new Natural(1)).equals(new Natural(1)).

Questo non è possibile farlo usando l'implementazione della equals basata sulla "getClass()" , ma è invece possibile usando quella basata sulla "instanceof".

Tuttavia questa implementazione potenzialmente può portare a risultati inconsistenti, per particolari classi estese (non in questo caso di estensione da Natural a Rational, comunque).

Per evitare questo ulteriore problema invece si può usare l'implementazione proposta nel seguente articolo: implementig equals() to allow slice comparison.

In conclusione: condizione necessaria affinché una classe che definisce oggetti non mutabili rispetti il principio open closed è che adotti la equals che permetta il "confronto a slices".

Una condizione secondaria, legata a questioni sintattiche di java, è che questa definisca un costruttore vuoto, a zero argomenti, di visibilità protected o superiore.


Note. Non posso dire di averli riletti recentemente, ma gli articoli di riferimento su questo argomento sono i seguenti

principio open closed: www.objectmentor.com/resources/articles/ocp.pdf
equals: http://www.artima.com/weblogs/viewpost.jsp?thread=4744
equals: http://www.artima.com/intv/bloch17.html
principio di sostituibilità di liskov: http://www.objectmentor.com/resources/articles/lsp.pdf
equals basata su confronto a slices (mixed type) : http://www.angelikalanger.com/Articles/JavaSolutions/SecretsOfEquals/Equals-2.html

sabato 29 dicembre 2007

Attributi per dichiarare le relazioni tra diverse rappresentazioni concrete di un tipo

Nel testo "Structure and Interpratations of Computer Programs", c'è una sezione che tratta di rappresentazioni multiple per dati astratti, con l'esempio delle due possibili rappresentazioni dei numeri complessi: forma polare e forma cartesiana, o rettangolare.

Vi è una corrispondenza biunivoca tra le due rappresentazioni, e la conversione da una rappresentazione all'altra è possibile sfruttando le seguenti relazioni:





Ho creato un progettino (in java) che investiga su questo argomento.

Una factory restituisce una rappresentazione concreta piuttosto che un'altra e lo fa reperendo i mapper che associano una rappresentazione ad un'altra.

Ogni costruttore di ogni implementazione, in modo dichiarativo, ovvero tramite attributi, mostra di essere mappabile in altri costruttori. Sa come trasformare la n-pla di parametri di inizializzazione nella equivalente n-pla usata da un altro costruttore e/o da un'altra implementazione.

Un complesso è definito come segue.

public interface ComplexNumber {
public double getReal();
public double getImg();
}

Il come costruirlo lo gestiremo nella factory.

Abbiamo le due seguenti implementazioni, Cartesiana e Polare:
public class ComplexNumberCartesian implements ComplexNumber {
protected double real;
protected double img;

@instanceConverter(instanceConverterMap = CartesianToPolarMapper.class)
public ComplexNumberCartesian(double real, double img) {
this.real=real;
this.img = img;
}

public double getReal() {
return real;
}

public double getImg() {
return img;
}

...


}





Polare:

public class ComplexNumberPolar implements ComplexNumber {
protected double magnitude;
protected Angle angle;

protected double real;
protected double img;

@instanceConverter (instanceConverterMap = PolarToCartesianMapper.class)
{
this.magnitude = magnitude;
this.angle = angle;

this.real = magnitude*Math.cos(angle.getValue());
this.img = magnitude*Math.sin(angle.getValue());
}

public double getReal() {
return real;
}

public double getImg() {
return magnitude*Math.sin(angle.getValue());
}


public boolean equals(Object object)
{
...
}

public int hashCode()
{

}
public String toString()
{
}
}

Nel costruttore abbiamo l'attributo @instanceConverter, che indica a sua volta la classe mapper, che associa a (real, img), l'equivalente coppia (ampiezza,angolo).

Ecco la definizione di questa annotation:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.CONSTRUCTOR)
public @interface instanceConverter {
Class instanceConverterMap() default IdentityToObject.class;
}

Identity è il caso banale appunto dell'identità.

Noi sfruttiamo il mapper dalla forma cartesiana (o rettangolare) a quella polare:

public class CartesianToPolarMapper implements WrapperToObject{
public Wrapper getWrapperForClassConstructorWithParameters(
Class targetClass,
Class targetParamsTypes[],
Class[] originParamsTypes)
{

if (targetClass.equals(ComplexNumberPolar.class)) {
return new CartesianToPolar();
}
throw new RuntimeException("wrapper not defined for the target class");
}
}

In teoria la conversione specifica dipende anche dal tipo di costruttore della classe target invocato, e quindi si specifica anche il targetParamsType che è un array di classi (che consente di reperire il costruttore che accetta quelle classi come parametri).

Questo non viene considerato nel nostro caso perché abbiamo un solo costruttore per Polar.

Viene restituito un "wrapper", che è il seguente:

public class CartesianToPolar implements Wrapper {
public Object[] wrap(Object[] object) {
return new Object[] {Math.sqrt((Math.pow((Double) object[0],2.0))+Math.pow((Double) object[1],2.0)),new Angle(Math.atan2((Double) object[1], (Double) object[0]))};
}
}


Il wrapper inverso, cioè da Polare a Cartesiano, che può essere reperito tramite l'annotazione associata al costruttore della implementazione polare, è il seguente:


public class PolarToCartesian implements Wrapper{
public Object[] wrap(Object[] object) {
return new Object[]{(Double)object[0]*
Math.cos(((Angle)object[1]).getValue()),
(Double)object[0]*Math.sin(((Angle)object[1]).getValue())};
}
}


La factory sfrutta queste informazioni:


public class ComplexNumbersFactory {
....
public static ComplexNumber getComplexFromCartesianPar(double first ,double second)
{
if (CARTESIAN.equals(implementation))
{
return new ComplexNumberCartesian(first,second);
}

if (POLAR.equals(implementation))
{

ComplexNumber converted = (ComplexNumber) Utilities.getInstanceOfThisActualGivenConstructorOfOther(
ComplexNumberPolar.class,ComplexNumberCartesian.class,
new Object[]{first,second},
new Class[]{double.class,double.class},
new Class[]{double.class,Angle.class});
return converted;
}
throw new RuntimeException("unadmitted implementation mode "+implementation);
}

}

Questo è il codice che reperisce il tutto reperisce il codice di conversione, esegue la conversione, e restituisce l'equivalente istanza nell'oggetto target:


public static Object getInstanceOfThisActualGivenConstructorOfOther(
Class targetClass,
Class originClass,
Object[] instanceOriginCompatible,
Class[] instanceOriginClasses,
Class[] instTargetClass)
{
try {
Constructor constructor = originClass.getConstructor(instanceOriginClasses);
WrapperToObject wrapper = (WrapperToObject) constructor.getAnnotation(instanceConverter.class).instanceConverterMap().newInstance();
Object[] convertedPars = ((wrapper.getWrapperForClassConstructorWithParameters(targetClass, instTargetClass,instanceOriginClasses).wrap(instanceOriginCompatible)));
Constructor targetConstructor = targetClass.getConstructor(instTargetClass);
Object convertedObject = targetConstructor.newInstance(convertedPars);
return convertedObject;

} catch (Exception e) {
throw new RuntimeException(e);
}
}


Se la factory è settata in modo cartesiano, restituisce l'implementazione cartesiana senza nessuna conversione.

Se la factory è settata in modo polare, allora essa utilizza una funzione di conversine interrogando la classe ComplexNumberCartesian. Cioè chiede al suo costruttore, di fornire un wrapper in grado di eseguire il mapping tra parametri di istanza per il tipo concreto Cartesian, a parametri di istanza per il tipo concreto Polar.


Eventuali nuove estensioni non implicano cambiamenti al codice che ne faccia uso (salvo che eventualmente dover settare una proprietà), ma solo nella factory, purché queste nuove implementazioni rispettino il vincolo di dichiarare come mapparsi nelle implementazioni preesistenti (e viceversa).

La conversione, dovrebbe anche rispettare il principio che la conversione B->A applicata alla conversione A->B dovrebbe essere l'identità.

Verifichiamo con junit la creazione di due diverse implementazioni dello stesso numero, e ne testiamo l'uguaglianza:

     ComplexNumber first = new ComplexNumberCartesian(1.0,1.0);
ComplexNumbersFactory.setImplementation(ComplexNumbersFactory.POLAR);
ComplexNumber second= ComplexNumbersFactory.getComplexFromCartesianPar(1.0,1.0);

assertEquals(((ComplexNumberPolar)second).getAngle(),new Angle(Math.PI/4));
assertEquals(((ComplexNumberPolar)second).getMagnitude(),Math.sqrt(2.0));

assertEquals(first,second);


Il tipo concreto restituito dopo che abbiamo settato la factory in modo polar è appunto polare, e quindi il cast non da eccezione, ed inoltre sfruttiamo dei metodi aggiuntivi che solo il polar mette a disposizione, che sono getAngle e getMagnitude che restituiscono i valori che ci aspettiamo coerenti per il numero 1+i.


Riassumendo

Rispetto a diversi scopi un tipo concreto piuttosto che un altro può avere vantaggi di efficienza in casi particolari, ma bisogna nascondere la rappresentazione concreta per evitare che il programma chiamante dipenda da queste nuove implementazioni.

Usiamo attributi per rafforzare il legame che c'è tra classi imparentate tra loro.

"Extends" o "implements" garantisco che sintatticamente possono essere applicati a metodi deifiniti in termini della loro classe astratta (o interfaccia) ma niente di più.

Il dover mettere anche questa metainformazione dichiarativa può significare: ehi... se stai creando una nuova implementazione dovresti anche occuparti di dichiarare come fare a rendere possibile sostituire la tua implementazione al posto di quelle preesistenti, garantendo che tutto funzioni allo stesso modo di prima.


(nota: il codice versionato è stato rifattorizzato rispetto a quanto scritto in questo post, quindi potrebbe non corrispondere in nomi di classi packages).


Saluti e Buon Anno!

T.

Informazioni personali

La mia foto
I have been coding from the old C64 times. Studied Computer Sciences at Milan University. I also worked there in technical operations. Many years of experiences in coding Java and C#, desktop and web applications, with practices like unit testing. I used to play with 3d graphics in architecture recently with Blender 3d. Now I look for support related to some projects I am working on, oriented in automation in tourism related services, using functional programming framework, specifically F# and Suave.IO. email
tonyx1 (at) gmail.com github https://github.com/tonyx