Principio di segregazione dell'interfaccia: cosa fare se le interfacce presentano una sovrapposizione significativa?


9

Dallo sviluppo software agile, principi, modelli e pratiche: Pearson New International Edition :

A volte, i metodi invocati da diversi gruppi di client si sovrappongono. Se la sovrapposizione è piccola, le interfacce per i gruppi dovrebbero rimanere separate. Le funzioni comuni dovrebbero essere dichiarate in tutte le interfacce sovrapposte. La classe server erediterà le funzioni comuni da ciascuna di quelle interfacce, ma le implementerà una sola volta.

Zio Bob, parla del caso in cui si verificano piccole sovrapposizioni.

Cosa dovremmo fare in caso di sovrapposizione significativa?

Di 'che abbiamo

Class UiInterface1;
Class UiInterface2;
Class UiInterface3;

Class UiIterface : public UiInterface1, public UiInterface2, public UiInterface3{};

Cosa dovremmo fare se si verifica una sovrapposizione significativa tra UiInterface1e UiInterface2?


Quando mi imbatto in interfacce fortemente sovrapposte, creo un'interfaccia padre, che raggruppa i metodi comuni e quindi eredita da questo comune per creare specializzazioni. MA! Se non vuoi mai che nessuno usi l'interfaccia comune senza la specializzazione, allora devi effettivamente cercare la duplicazione del codice, perché se introduci l'interfaccia comune parrent, le persone potrebbero usarla.
Andy,

La domanda è un po 'vaga per me, si potrebbe rispondere con molte soluzioni diverse a seconda dei casi. Perché è cresciuta la sovrapposizione?
Arthur Havlicek,

Risposte:


1

getto

Questo sarà quasi sicuramente una tangente completa all'approccio del libro citato, ma un modo per conformarsi meglio all'ISP è quello di abbracciare una mentalità di casting in un'area centrale della base di codice usando un QueryInterfaceapproccio in stile COM.

Molte delle tentazioni di progettare interfacce sovrapposte in un contesto di pura interfaccia spesso derivano dal desiderio di rendere le interfacce "autosufficienti" più che eseguire una precisa responsabilità simile a un cecchino.

Ad esempio, potrebbe sembrare strano progettare funzioni client come questa:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `position` and `parenting` parameters should point to the 
// same object.
Vec2i abs_position(IPosition* position, IParenting* parenting)
{
     const Vec2i xy = position->xy();
     auto parent = parenting->parent();
     if (parent)
     {
         // If the entity has a parent, return the sum of the
         // parent position and the entity's local position.
         return xy + abs_position(dynamic_cast<IPosition*>(parent),
                                  dynamic_cast<IParenting*>(parent));
     }
     return xy;
}

... oltre che abbastanza brutto / pericoloso, dato che stiamo perdendo la responsabilità di eseguire il casting soggetto a errori sul codice client utilizzando queste interfacce e / o passando lo stesso oggetto di un argomento più volte a più parametri dello stesso funzione. Quindi finiamo spesso per voler progettare un'interfaccia più diluita che consolidi le preoccupazioni di IParentinge IPositionin un posto, simile IGuiElemento qualcosa del genere che diventa quindi suscettibile di sovrapporsi con le preoccupazioni delle interfacce ortogonali che saranno anche tentate di avere più funzioni membro per la stessa ragione dell '"autosufficienza".

Mescolare le responsabilità contro il casting

Quando si progettano interfacce con una responsabilità totalmente distinta e ultra-singolare, la tentazione sarà spesso quella di accettare alcune operazioni di downcasting o di consolidare interfacce per adempiere a responsabilità multiple (e quindi calpestare sia l'ISP che l'SRP).

Usando un approccio in stile COM (solo la QueryInterfaceparte), giochiamo all'approccio di downcasting ma consolidiamo il casting in un posto centrale nella base di codice e possiamo fare qualcosa di più simile a questo:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `obj` should implement `IPosition` and optionally `IParenting`.
Vec2i abs_position(Object* obj)
{
     // `Object::query_interface` returns nullptr if the interface is
     // not provided by the entity. `Object` is an abstract base class
     // inherited by all entities using this interface query system.
     IPosition* position = obj->query_interface<IPosition>();
     assert(position && "obj does not implement IPosition!");
     const Vec2i xy = position->xy();

     IParenting* parenting = obj->query_interface<IParenting>();
     if (parenting && parenting->parent()->query_interface<IPosition>())
     {
         // If the entity implements IParenting and has a parent, 
         // return the sum of the parent position and the entity's 
         // local position.
         return xy + abs_position(parenting->parent());
     }
     return xy;
}

... ovviamente si spera con involucri sicuri e tutto ciò che puoi costruire centralmente per ottenere qualcosa di più sicuro dei puntatori non elaborati.

Con ciò, la tentazione di progettare interfacce sovrapposte viene spesso mitigata al minimo assoluto. Ti consente di progettare interfacce con responsabilità molto singolari (a volte solo una funzione membro all'interno) che puoi mescolare e abbinare tutto ciò che ti piace senza preoccuparti dell'ISP e ottenere la flessibilità della digitazione pseudo-anatra in fase di esecuzione in C ++ (anche se ovviamente con il compromesso delle penalità di runtime per interrogare gli oggetti per vedere se supportano una particolare interfaccia). La parte di runtime può essere importante, ad esempio, in un'impostazione con un kit di sviluppo software in cui le funzioni non avranno in anticipo le informazioni di compilazione dei plug-in che implementano queste interfacce.

Modelli

Se i template sono una possibilità (abbiamo in anticipo le informazioni necessarie in fase di compilazione che non si perdono nel momento in cui otteniamo un oggetto, ad esempio), allora possiamo semplicemente farlo:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `obj` should have `position` and `parent` methods.
template <class Entity>
Vec2i abs_position(Entity& obj)
{
     const Vec2i xy = obj.xy();
     if (obj.parent())
     {
         // If the entity has a parent, return the sum of the parent 
         // position and the entity's local position.
         return xy + abs_position(obj.parent());
     }
     return xy;
}

... ovviamente in tal caso, il parentmetodo dovrebbe restituire lo stesso Entitytipo, nel qual caso probabilmente vorremmo evitare le interfacce in modo definitivo (poiché spesso vorranno perdere le informazioni sul tipo a favore del lavoro con i puntatori di base).

Sistema Entity-Component

Se inizi a perseguire ulteriormente l'approccio in stile COM da un punto di vista della flessibilità o delle prestazioni, spesso finirai con un sistema di componenti entità simile a quello che i motori di gioco applicano nel settore. A quel punto andrai completamente perpendicolare a molti approcci orientati agli oggetti, ma l'ECS potrebbe essere applicabile al design della GUI (un posto in cui ho contemplato l'uso dell'ECS al di fuori di un focus orientato alla scena, ma lo ho considerato troppo tardi dopo accontentarsi di un approccio in stile COM per provare lì).

Si noti che questa soluzione in stile COM è completamente disponibile per quanto riguarda la progettazione di toolkit GUI e ECS sarebbe ancora di più, quindi non è qualcosa che sarà supportato da molte risorse. Tuttavia, ti consentirà sicuramente di mitigare le tentazioni di progettare interfacce che hanno responsabilità sovrapposte al minimo assoluto, rendendolo spesso non preoccupante.

Approccio pragmatico

L'alternativa, ovviamente, è rilassare un po 'la guardia o progettare interfacce a livello granulare e quindi iniziare a ereditarle per creare interfacce più grossolane che usi, come quelle IPositionPlusParentingche derivano da entrambi IPositioneIParenting(si spera con un nome migliore di quello). Con interfacce pure, non dovrebbe violare l'ISP tanto quanto quegli approcci monolitici di profonda gerarchia comunemente applicati (Qt, MFC, ecc.) In cui la documentazione spesso sente la necessità di nascondere membri irrilevanti dato l'eccessivo livello di violazione dell'ISP con questi tipi di disegni), quindi un approccio pragmatico potrebbe semplicemente accettare alcune sovrapposizioni qua e là. Tuttavia, questo tipo di approccio in stile COM evita la necessità di creare interfacce consolidate per ogni combinazione che userete mai. La preoccupazione di "autosufficienza" è completamente eliminata in questi casi, e ciò spesso eliminerà la fonte ultima di tentazione di progettare interfacce che hanno responsabilità sovrapposte che vogliono combattere sia con SRP che con ISP.


11

Questa è una richiesta di giudizio che devi fare, caso per caso.

Prima di tutto, ricorda che i principi SOLIDI sono proprio questi ... principi. Non sono regole. Non sono un proiettile d'argento. Sono solo principi. Questo non toglie loro l'importanza, dovresti sempre inclinarti a seguirli. Ma nel momento in cui introducono un livello di dolore, dovresti abbandonarli fino a quando non ne hai bisogno.

Con questo in mente, pensa innanzitutto al motivo per cui stai separando le tue interfacce. L'idea di un'interfaccia è dire "Se questo codice di consumo richiede una serie di metodi da implementare sulla classe che viene consumata, devo impostare un contratto sull'implementazione: se mi fornisci un oggetto con questa interfaccia, posso lavorare con esso."

Lo scopo dell'ISP è di dire "Se il contratto che richiedo è solo un sottoinsieme di un'interfaccia esistente, non dovrei imporre l'interfaccia esistente su eventuali classi future che potrebbero essere passate al mio metodo."

Considera il seguente codice:

public interface A
{
    void X();
    void Y();
}

public class Foo
{
     public void ConsumeX(A a)
     {
         a.X();
     }
}

Ora abbiamo una situazione in cui, se vogliamo passare un nuovo oggetto a ConsumeX, deve implementare X () e Y () per adattarlo al contratto.

Quindi dovremmo cambiare il codice, proprio ora, per assomigliare al prossimo esempio?

public interface A
{
    void X();
    void Y();
}

public interface B
{
    void X();
}

public class Foo
{
     public void ConsumeX(B b)
     {
         b.X();
     }
}

L'ISP suggerisce che dovremmo, quindi dovremmo appoggiarci a quella decisione. Ma, senza contesto, è difficile esserne sicuri. È probabile che estenderemo A e B? È probabile che si estenderanno indipendentemente? È probabile che B implementerà mai metodi che A non richiede? (Altrimenti, possiamo far derivare A da B.)

Questa è la chiamata di giudizio che devi fare. E, se davvero non hai abbastanza informazioni per effettuare quella chiamata, probabilmente dovresti prendere l'opzione più semplice, che potrebbe essere il primo codice.

Perché? Perché è facile cambiare idea in seguito. Quando hai bisogno di quella nuova classe, crea semplicemente una nuova interfaccia e implementale entrambe nella tua vecchia classe.


1
"Prima di tutto, ricorda che i principi SOLIDI sono solo quei ... principi. Non sono regole. Non sono un proiettile d'argento. Sono solo principi. Non è per togliere la loro importanza, dovresti sempre appoggiarti per seguirli. Ma nel momento in cui introducono un livello di dolore, dovresti abbandonarli finché non ne avrai bisogno ". Questo dovrebbe essere nella prima pagina di ogni libro di modelli / principi di progettazione. Dovrebbe apparire anche ogni 50 pagine come promemoria.
Christian Rodriguez,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.