Pattern iteratore: perché è importante non esporre la rappresentazione interna?

22

Sto leggendo C # Design Pattern Essentials . Attualmente sto leggendo il pattern iteratore.

Capisco perfettamente come implementare, ma non capisco l'importanza o vedo un caso d'uso. Nel libro viene dato un esempio in cui qualcuno ha bisogno di ottenere una lista di oggetti. Avrebbero potuto farlo esponendo una proprietà pubblica, come IList<T> o Array .

Il libro scrive

The problem with this is that the internal representation in both of these classes has been exposed to outside projects.

Qual è la rappresentazione interna? Il fatto è che è un array o IList<T> ? Non capisco davvero perché questa sia una brutta cosa per il consumatore (il programmatore lo chiama) per sapere ...

Il libro dice che questo modello funziona esponendo la sua funzione GetEnumerator , quindi possiamo chiamare GetEnumerator() ed esporre la 'lista' in questo modo.

Presumo che questo modello abbia un posto (come tutti) in certe situazioni, ma non riesco a vedere dove e quando.

    
posta MyDaftQuestions 08.10.2015 - 13:08
fonte

6 risposte

54

Il software è un gioco di promesse e privilegi. Non è mai una buona idea promettere più di quanto tu possa offrire, o più di quanto il tuo collaboratore abbia bisogno.

Questo vale in particolare per i tipi. Il punto di scrivere una collezione iterabile è che il suo utente può scorrere su di esso - né più né meno. Esporre il tipo concreto Array di solito crea molte promesse aggiuntive, ad es. che puoi ordinare la collezione in base a una funzione di tua scelta, senza menzionare il fatto che una normale Array permetterà probabilmente al collaboratore di cambiare i dati che sono memorizzati al suo interno.

Anche se pensi che sia una buona cosa ("Se il renderer rileva che la nuova opzione di esportazione è mancante, può semplicemente correggerla correttamente! Neat!"), nel complesso questo diminuisce la coerenza della base di codice, rendendo è più difficile ragionare e rendere il codice facile da ragionare è l'obiettivo principale dell'ingegneria del software.

Ora, se il tuo collaboratore ha bisogno di accedere a un certo numero di cose cosicché sia garantito di non perdere nessuna di esse, implementa un'interfaccia Iterable ed espone solo quei metodi che questa interfaccia dichiara. In questo modo, l'anno prossimo quando nella tua libreria standard apparirà una struttura di dati massicciamente migliore e più efficiente, sarai in grado di cambiare il codice sottostante e trarne vantaggio senza correggere il codice client ovunque . Ci sono altri vantaggi nel non promettere più del necessario, ma questo da solo è così grande che in pratica non sono necessari altri.

    
risposta data 08.10.2015 - 13:19
fonte
17

Nascondere l'implementazione è un principio base di OOP e una buona idea in tutti i paradigmi, ma è particolarmente importante per gli iteratori (o qualunque sia il loro nome in quella specifica lingua) in linguaggi che supportano l'iterazione pigra.

Il problema con l'esposizione del tipo concreto di iterables - o anche di interfacce come IList<T> - non è negli oggetti che li espongono, ma nei metodi che li usano. Ad esempio, supponiamo che tu abbia una funzione per stampare un elenco di Foo s:

void PrintFoos(IList<Foo> foos)
{
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
    }
}

Puoi utilizzare questa funzione solo per stampare elenchi di foo, ma solo se implementano IList<Foo>

IList<Foo> foos = //.....
PrintFoos(foos);

Ma cosa succede se si desidera stampare ogni elemento di indice pari dell'elenco? Dovrai creare un nuovo elenco:

IList<Foo> everySecondFoo = new List<T>();
bool isIndexEven = true;
foreach (foo; foos)
{
    if (isIndexEven)
    {
        everySecondFoo.Add(foo);
    }
    isIndexEven = !isIndexEven;
}
PrintFoos(everySecondFoo);

Questo è piuttosto lungo, ma con LINQ possiamo farlo con un solo liner, che in realtà è più leggibile:

PrintFoos(foos.Where((foo, i) => i % 2 == 0).ToList());

Ora, hai notato .ToList() alla fine? Converte la query lazy in un elenco, quindi possiamo passarlo a PrintFoos . Ciò richiede l'assegnazione di un secondo elenco e due passaggi sugli elementi (uno sul primo elenco per creare il secondo elenco e un altro sul secondo elenco per stamparlo). Inoltre, cosa succede se abbiamo questo:

void Print6Foos(IList<Foo> foos)
{
    int counter = 0;
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
        ++ counter;
        if (6 < counter)
        {
            return;
        }
    }
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0).ToList());

Che cosa succede se foos ha migliaia di voci? Dovremo esaminarli tutti e assegnare una lista enorme solo per stamparne 6!

Inserisci Enumeratori - La versione di C # di The Iterator Pattern. Invece di fare in modo che la nostra funzione accetti un elenco, accettiamo Enumerable :

void Print6Foos(Enumerable<Foo> foos)
{
    // everything else stays the same
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0));

Ora Print6Foos può scorrere lentamente i primi 6 elementi dell'elenco e non è necessario toccarne il resto.

Non esporre la rappresentazione interna è la chiave qui. Quando Print6Foos ha accettato una lista, abbiamo dovuto dargli una lista - qualcosa che supporta l'accesso casuale - e quindi abbiamo dovuto allocare una lista, perché la firma non garantisce che la itererà solo su di essa. Nascondendo l'implementazione, possiamo facilmente creare un oggetto Enumerable più efficiente che supporti solo ciò di cui la funzione ha effettivamente bisogno.

    
risposta data 08.10.2015 - 15:34
fonte
6

Esporre la rappresentazione interna è praticamente mai una buona idea. Non solo rende il codice più difficile da ragionare, ma anche più difficile da mantenere. Immagina di aver scelto una rappresentazione interna - diciamo un IList<T> - e hai esposto questo interno. Chiunque usi il tuo codice può accedere all'Elenco e il codice può basarsi sulla rappresentazione interna come elenco.

Per qualsiasi motivo, decidi di cambiare la rappresentazione interna in IAmWhatever<T> in un secondo momento. Invece, semplicemente cambiando gli interni della tua classe, dovrai cambiare ogni linea di codice e metodo facendo affidamento sulla rappresentazione interna di tipo IList<T> . Questo è ingombrante e soggetto a errori nella migliore delle ipotesi, quando sei l'unico ad usare la classe, ma potrebbe violare il codice di altri popoli usando la tua classe. Se hai appena esposto una serie di metodi pubblici senza esporre alcun interno, avresti potuto modificare la rappresentazione interna senza che alcuna linea di codice all'esterno della tua classe prendesse nota, lavorando come se nulla fosse cambiato.

Ecco perché l'incapsulamento è uno degli aspetti più importanti di qualsiasi progetto di software non banale.

    
risposta data 08.10.2015 - 13:42
fonte
6

Meno dici, meno devi continuare a dire.

Meno lo chiedi, meno devi dirlo.

Se il tuo codice espone solo IEnumerable<T> , che supporta solo GetEnumrator() , può essere sostituito da qualsiasi altro codice che supporti IEnumerable<T> . Ciò aggiunge flessibilità.

Se il tuo codice utilizza solo IEnumerable<T> , può essere supportato da qualsiasi codice che implementa IEnumerable<T> . Di nuovo, c'è una maggiore flessibilità.

Tutti i linq-to-objects, ad esempio, dipendono solo da IEnumerable<T> . Sebbene affronti velocemente alcune implementazioni particolari di IEnumerable<T> , esegue questa operazione in modalità test-and-use che può sempre ricorrere al solo utilizzo di GetEnumerator() e dell'implementazione IEnumerator<T> restituita. Ciò gli conferisce molta più flessibilità rispetto a se fosse costruito su array o liste.

    
risposta data 08.10.2015 - 17:35
fonte
0

Una dicitura Mi piace usare il commento di mirrors @CaptainMan: "Per un consumatore è normale conoscere i dettagli interni. È male per un cliente che abbia bisogno di conoscere i dettagli interni . "

Se un cliente deve digitare array o IList<T> nel proprio codice, quando tali tipi erano dettagli interni, il consumatore deve ottenerli correttamente. Se hanno torto, il consumatore potrebbe non compilare o ottenere risultati errati. Ciò produce gli stessi comportamenti delle altre risposte di "nascondere sempre i dettagli di implementazione perché non dovresti esporli", ma inizia a scavare ai vantaggi di non esporre. Se non esporti mai un dettaglio di implementazione, il tuo cliente non potrà mai mettersi in una situazione vincolante usandolo!

Mi piace pensarlo in questa luce perché apre il concetto di nascondere l'implementazione a sfumature di significato, piuttosto che un draconiano "nascondi sempre i dettagli della tua implementazione". Ci sono un sacco di situazioni in cui esporre l'implementazione è totalmente valida. Si consideri il caso dei driver di periferica in tempo reale, in cui la possibilità di ignorare gli strati precedenti di astrazione e colpire i byte nei punti giusti può essere la differenza tra il cronometraggio o meno. Oppure prendi in considerazione un ambiente di sviluppo in cui non hai tempo per creare "buone API" e devi utilizzare immediatamente le cose. Esponendo spesso le API sottostanti in tali ambienti può essere la differenza tra fare una scadenza o meno.

Tuttavia, se si lasciano i casi speciali alle spalle e si guardano i casi più generali, inizia a diventare sempre più importante nascondere l'implementazione. L'esposizione dei dettagli di implementazione è come una corda. Se usato correttamente, puoi fare ogni sorta di cose con esso, come scalare montagne alte. Usato in modo improprio e può legarti in un cappio. Meno sai come verrà utilizzato il tuo prodotto, più devi stare attento.

Il case study che vedo ripetutamente è quello in cui uno sviluppatore fa un'API che non sta molto attenta a nascondere i dettagli di implementazione. Un secondo sviluppatore, utilizzando quella libreria, scopre che all'API mancano alcune funzionalità chiave che vorrebbero. Piuttosto che scrivere una richiesta di funzione (che potrebbe avere un tempo di risposta di mesi), decidono invece di abusare del loro accesso ai dettagli di implementazione nascosti (che richiede pochi secondi). Questo non è male di per sé, ma spesso lo sviluppatore dell'API non ha mai programmato di far parte dell'API. Successivamente, quando migliorano l'API e cambiano i dettagli sottostanti, scoprono di essere incatenati alla vecchia implementazione, perché qualcuno lo ha usato come parte dell'API. La versione peggiore di questo è dove qualcuno ha usato la tua API per fornire una funzionalità incentrata sul cliente, e ora lo stipendio della tua azienda è legato a questa API imperfetta! Semplicemente esponendo i dettagli di implementazione quando non ne sapevi abbastanza, i tuoi clienti hanno dimostrato di essere abbastanza corda da legare un cappio intorno alla tua API!

    
risposta data 09.10.2015 - 02:21
fonte
0

Non è necessariamente necessario sapere quale sia la rappresentazione interna dei dati, o che potrebbe diventare in futuro.

Supponete di avere un'origine dati che rappresentate come una matrice, potreste iterarla su di essa con un ciclo for normale.

Ora dì che vuoi refactoring. Il tuo capo ha chiesto che l'origine dati sia un oggetto di database. Inoltre vorrebbe avere l'opzione per estrarre i dati da un'API, una hashmap o una lista collegata, o un tamburo magnetico rotante, o un microfono, o un conteggio in tempo reale dei trucioli di yak trovati in Mongolia esterna.

Bene, se hai solo un ciclo for puoi modificare facilmente il tuo codice per accedere all'oggetto dati nel modo in cui è previsto l'accesso.

Se hai 1000 classi che tentano di accedere all'oggetto dati, ora hai un grosso problema di refactoring.

Un iteratore è un modo standard per accedere a un'origine dati basata su elenchi. È un'interfaccia comune. Usarlo renderà agnostico il tuo codice sorgente dati.

    
risposta data 09.10.2015 - 14:55
fonte

Leggi altre domande sui tag