A che punto le classi immutabili diventano un peso?


16

Quando ho progettato classi per contenere il tuo modello di dati che ho letto, può essere utile creare oggetti immutabili, ma a che punto l'onere degli elenchi di parametri del costruttore e delle copie profonde diventa eccessivo e devi abbandonare l'immutabile restrizione?

Ad esempio, ecco una classe immutabile per rappresentare una cosa con nome (sto usando la sintassi C # ma il principio si applica a tutti i linguaggi OO)

class NamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    
    public NamedThing(NamedThing other)
    {
         this._name = other._name;
    }
    public string Name
    {
        get { return _name; }
    }
}

Le cose nominate possono essere costruite, interrogate e copiate in cose nuove ma il nome non può essere modificato.

Va tutto bene, ma cosa succede quando voglio aggiungere un altro attributo? Devo aggiungere un parametro al costruttore e aggiornare il costruttore della copia; che non è troppo lavoro ma i problemi iniziano, per quanto posso vedere, quando voglio rendere immutabile un oggetto complesso .

Se la classe contiene attributi e raccolte may, contenenti altre classi complesse, mi sembra che l'elenco dei parametri del costruttore diventerebbe un incubo.

Quindi a che punto una classe diventa troppo complessa per essere immutabile?


Mi sforzo sempre di rendere immutabili le lezioni nel mio modello. Se hai elenchi di parametri lunghi, lunghi, forse la tua classe è troppo grande e può essere suddivisa? Se anche i tuoi oggetti di livello inferiore sono immutabili e seguono lo stesso schema, i tuoi oggetti di livello superiore non dovrebbero soffrire (troppo). Trovo MOLTO più difficile cambiare una classe esistente per diventare immutabile che rendere immutabile un modello di dati quando sto iniziando da zero.
Nessuno il

1
Potresti guardare il modello Builder suggerito in questa domanda: stackoverflow.com/questions/1304154/…
Formica

Hai guardato MemberwiseClone? Non è necessario aggiornare il costruttore della copia per ogni nuovo membro.
Kevin Cline,

3
@Tony Se anche le tue raccolte e tutto ciò che contengono sono immutabili, non hai bisogno di una copia profonda, una copia superficiale è sufficiente.
mjcopple,

2
A parte questo, uso comunemente i campi "set-once" nelle classi in cui la classe deve essere "abbastanza" immutabile, ma non completamente. Trovo che questo risolva il problema di enormi costruttori, ma offre la maggior parte dei vantaggi di classi immutabili. (vale a dire, il codice della classe interna non deve preoccuparsi di una modifica del valore)
Earlz,

Risposte:


23

Quando diventano un peso? Molto rapidamente (specialmente se la tua lingua preferita non fornisce un supporto sintattico sufficiente per l'immutabilità).

L'immutabilità viene venduta come il proiettile d'argento per il dilemma multi-core e tutto il resto. Ma l'immutabilità nella maggior parte delle lingue OO ti costringe ad aggiungere artefatti e pratiche artificiali nel tuo modello e processo. Per ogni classe immutabile complessa devi avere un costruttore altrettanto complesso (almeno internamente). Indipendentemente da come lo si progetta, introduce comunque un accoppiamento forte (quindi è meglio avere una buona ragione per introdurli.)

Non è necessariamente possibile modellare tutto in piccole classi non complesse. Quindi per le classi e le strutture di grandi dimensioni, le partizioniamo artificialmente, non perché ciò abbia senso nel nostro modello di dominio, ma perché dobbiamo gestire la loro complessa istanza e i costruttori nel codice.

È ancora peggio quando le persone portano l'idea dell'immutabilità troppo lontano in un linguaggio generico come Java o C #, rendendo tutto immutabile. Quindi, di conseguenza, vedi persone che costringono costrutti di espressione-s in linguaggi che non supportano queste cose con facilità.

L'ingegneria è l'atto di modellare attraverso compromessi e compromessi. Rendere tutto immutabile per editto perché qualcuno legge che tutto è immutabile in un linguaggio funzionale X o Y (un modello di programmazione completamente diverso), questo non è accettabile. Questa non è una buona ingegneria.

Le cose piccole, forse unitarie, possono essere rese immutabili. Le cose più complesse possono essere rese immutabili quando ha senso . Ma l'immutabilità non è un proiettile d'argento. La capacità di ridurre i bug, aumentare la scalabilità e le prestazioni, non sono l'unica funzione dell'immutabilità. È una funzione di pratiche ingegneristiche adeguate . Dopotutto, le persone hanno scritto un software valido e scalabile senza immutabilità.

L'immutabilità diventa un onere molto veloce (aumenta la complessità accidentale) se viene fatta senza una ragione, quando viene fatta al di fuori di ciò che ha senso nel contesto di un modello di dominio.

Io, per esempio, cerco di evitarlo (a meno che non stia lavorando in un linguaggio di programmazione con un buon supporto sintattico per esso.)


6
luis, hai notato come le risposte ben scritte e pragmaticamente corrette scritte con una spiegazione di principi ingegneristici semplici ma solidi tendano a non ottenere tanti voti quanti quelli che usano mode di codifica all'avanguardia? Questa è un'ottima risposta.
Huperniketes,

3
Grazie :) Ho notato personalmente le tendenze di ripetizione, ma va bene. Fad fanboys churn code che in seguito potremo riparare a tariffe orarie migliori, hahah :) jk ...
luis.espinal

4
Proiettile d'argento? no. Vale la pena un po 'di imbarazzo in C # / Java (non è poi così male)? Assolutamente. Inoltre, il ruolo del multicore nell'immutabilità è piuttosto secondario ... il vero vantaggio è la facilità di ragionamento.
Mauricio Scheffer,

@Mauricio - se lo dici tu (l'immutabilità in Java non è poi così male). Avendo lavorato su Java dal 1998 al 2011, mi permetto di dissentire, non è banalmente esente in semplici basi di codice. Tuttavia, le persone hanno esperienze diverse e riconosco che il mio POV non è privo di soggettività. Mi dispiace, non posso essere d'accordo. Concordo, tuttavia, con la facilità di ragionamento che è la cosa più importante quando si tratta di immutabilità.
luis.espinal

13

Ho attraversato una fase di insistere sul fatto che le lezioni fossero immutabili, ove possibile. I costruttori di praticamente tutto, array immutabili, ecc. Ecc. Ho trovato la risposta alla tua domanda semplice: a che punto le classi immutabili diventano un peso? Molto velocemente. Non appena vuoi serializzare qualcosa, devi essere in grado di deserializzare, il che significa che deve essere mutabile; non appena si desidera utilizzare un ORM, la maggior parte di essi insiste sul fatto che le proprietà siano mutabili. E così via.

Alla fine ho sostituito quella politica con interfacce immutabili a oggetti mutabili.

class NamedThing : INamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    

    public NamedThing(NamedThing other)
    {
        this._name = other._name;
    }

    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
}

interface INamedThing
{
    string Name { get; }
}

Ora l'oggetto ha flessibilità ma puoi ancora dire al codice chiamante che non dovrebbe modificare queste proprietà.


8
Piccoli cavilli a parte, concordo sul fatto che gli oggetti immutabili possono diventare un dolore nel culo molto rapidamente quando si programma in un linguaggio imperativo, ma non sono davvero sicuro se un'interfaccia immutabile risolva davvero lo stesso problema o qualsiasi altro problema. Il motivo principale per utilizzare un oggetto immutabile è che puoi lanciarlo ovunque in qualsiasi momento e non devi mai preoccuparti che qualcun altro possa corrompere il tuo stato. Se l'oggetto sottostante è mutabile, allora non hai quella garanzia, specialmente se il motivo per cui l'hai tenuto mutabile era perché varie cose hanno bisogno di mutarlo.
Aaronaught,

1
@Aaronaught - Punto interessante. Immagino sia una cosa psicologica più che una vera protezione. Tuttavia, l'ultima riga ha una premessa errata. Il motivo per mantenerlo mutabile è più che varie cose devono istanziarlo e popolarlo tramite la riflessione, non mutare una volta istanziato.
pdr

1
@Aaronaught: allo stesso modo IComparable<T>garantisce che se X.CompareTo(Y)>0e Y.CompareTo(Z)>0, quindi X.CompareTo(Z)>0. Le interfacce hanno contratti . Se il contratto IImmutableList<T>prevede che i valori di tutti gli elementi e proprietà devono essere "fissati nella pietra" prima che qualsiasi istanza sia esposta al mondo esterno, tutte le implementazioni legittime lo faranno. Nulla impedisce a IComparableun'implementazione di violare la transitività, ma le implementazioni che lo fanno sono illegittime. Se un SortedDictionarymalfunzionamento quando viene dato un illegittimo IComparable, ...
supercat

1
@Aaronaught: Perché dovrei fidarmi che l'implementazione di IReadOnlyList<T>sarà immutabile, dato che (1) tale requisito non è indicato nella documentazione dell'interfaccia e (2) l'implementazione più comune List<T>, non è nemmeno di sola lettura ? Non sono del tutto chiaro cosa sia ambiguo nei miei termini: una raccolta è leggibile se i dati all'interno possono essere letti. È di sola lettura se può promettere che i dati contenuti non possono essere modificati a meno che qualche riferimento esterno sia detenuto da un codice che lo cambierebbe. È immutabile se può garantire che non può essere cambiato, punto.
supercat,

1
@supercat: Per inciso, Microsoft è d'accordo con me. Hanno rilasciato un pacchetto di raccolte immutabili e notano che sono tutti tipi concreti, perché non puoi mai garantire che una classe o un'interfaccia astratta sia veramente immutabile.
Aaronaught,

8

Non credo che ci sia una risposta generale a questo. Più una classe è complessa, più è difficile ragionare sui suoi cambiamenti di stato e più è costosa crearne nuove copie. Quindi, al di sopra di un livello (personale) di complessità, diventerà troppo doloroso rendere / mantenere immutabile una classe.

Si noti che una classe troppo complessa o un lungo elenco di parametri del metodo sono odori di progettazione in sé, indipendentemente dall'immutabilità.

Quindi di solito la soluzione preferita sarebbe quella di suddividere tale classe in più classi distinte, ognuna delle quali può essere resa mutabile o immutabile da sola. Se ciò non è possibile, può essere disattivato.


5

È possibile evitare il problema di copia se si memorizzano tutti i campi immutabili in un interno struct. Questa è sostanzialmente una variazione del modello di ricordo. Quindi quando vuoi fare una copia, copia il ricordo:

class MyClass
{
    struct Memento
    {
        public int field1;
        public string field2;
    }

    private readonly Memento memento;

    public MyClass(int field1, string field2)
    {
        this.memento = new Memento()
            {
                field1 = field1,
                field2 = field2
            };
    }

    private MyClass(Memento memento) // for copying
    {
        this.memento = memento;
    }

    public int Field1 { get { return this.memento.field1; } }
    public string Field2 { get { return this.memento.field2; } }

    public MyClass WithNewField1(int newField1)
    {
        Memento newMemento = this.memento;
        newMemento.field1 = newField1;
        return new MyClass(newMemento);
    }
}

Penso che la struttura interna non sia necessaria. È solo un altro modo di fare MemberwiseClone.
Codismo

@Codismo - sì e no. Ci sono momenti in cui potresti aver bisogno di altri membri che non vuoi clonare. Che cosa succede se si utilizza la valutazione lenta in uno dei getter e si memorizza nella cache il risultato in un membro? Se esegui MemberwiseClone, clonerai il valore memorizzato nella cache, quindi cambierai uno dei membri da cui dipende il valore memorizzato nella cache. È più pulito separare lo stato dalla cache.
Scott Whitlock,

Potrebbe valere la pena menzionare un altro vantaggio della struttura interna: rende facile per un oggetto copiare il suo stato su un oggetto in cui esistono altri riferimenti . Una fonte comune di ambiguità in OOP è se un metodo che restituisce un riferimento a un oggetto restituisce una vista di un oggetto che potrebbe cambiare al di fuori del controllo del destinatario. Se invece di restituire un riferimento a un oggetto, un metodo accetta un riferimento a un oggetto dal chiamante e ne copia lo stato, la proprietà dell'oggetto sarà molto più chiara. Un simile approccio non funziona bene con tipi liberamente ereditabili, ma ...
supercat

... può essere molto utile con i titolari di dati mutabili. L'approccio rende anche molto semplice avere classi mutabili e immutabili "parallele" (derivate da una base astratta "leggibile") e avere i loro costruttori in grado di copiare i dati gli uni dagli altri.
supercat,

3

Hai un paio di cose al lavoro qui. I set di dati immutabili sono ottimi per la scalabilità multithread. In sostanza, puoi ottimizzare un po 'la tua memoria in modo che un set di parametri sia un'istanza della classe - ovunque. Poiché gli oggetti non cambiano mai, non devi preoccuparti di sincronizzare l'accesso ai suoi membri. È una buona cosa. Tuttavia, come sottolineato, più complesso è l'oggetto, più è necessaria una certa mutabilità. Vorrei iniziare con il ragionamento in questo senso:

  • C'è qualche motivo commerciale per cui un oggetto può cambiare il suo stato? Ad esempio, un oggetto utente archiviato in un database è univoco in base al suo ID, ma deve essere in grado di cambiare stato nel tempo. D'altra parte, quando si cambiano le coordinate su una griglia, cessa di essere la coordinata originale e quindi ha senso rendere immutabili le coordinate. Lo stesso con le stringhe.
  • Alcuni degli attributi possono essere calcolati? In breve, se gli altri valori nella nuova copia di un oggetto sono una funzione di alcuni valori fondamentali passati, è possibile calcolarli nel costruttore o su richiesta. Ciò riduce la quantità di manutenzione poiché è possibile inizializzare tali valori allo stesso modo durante la copia o la creazione.
  • Quanti valori compongono il nuovo oggetto immutabile? Ad un certo punto la complessità della creazione di un oggetto diventa non banale e a quel punto avere più istanze dell'oggetto può diventare un problema. Gli esempi includono strutture ad albero immutabili, oggetti con più di tre parametri passati, ecc. Maggiore è il numero di parametri, maggiore è la possibilità di incasinare l'ordine dei parametri o annullare quello sbagliato.

Nei linguaggi che supportano solo oggetti immutabili (come Erlang), se c'è qualche operazione che sembra modificare lo stato di un oggetto immutabile, il risultato finale è una nuova copia dell'oggetto con il valore aggiornato. Ad esempio, quando aggiungi un elemento a un vettore / elenco:

myList = lists:append([[1,2,3], [4,5,6]])
% myList is now [1,2,3,4,5,6]

Questo può essere un modo sano di lavorare con oggetti più complicati. Ad esempio, quando si aggiunge un nodo ad albero, il risultato è un nuovo albero con il nodo aggiunto. Il metodo nell'esempio sopra restituisce un nuovo elenco. Nell'esempio di questo paragrafo tree.add(newNode)restituisce un nuovo albero con il nodo aggiunto. Per gli utenti diventa facile lavorare. Per gli scrittori di biblioteche diventa noioso quando la lingua non supporta la copia implicita. Tale soglia dipende dalla tua stessa pazienza. Per gli utenti della tua libreria, il limite più sano che ho trovato è di circa 3-4 parametri.


Se si fosse propensi a usare un riferimento a oggetto mutabile come valore [nel senso che nessun riferimento è contenuto nel suo proprietario e mai esposto], costruire un nuovo oggetto immutabile che contiene i contenuti "modificati" desiderati equivale a modificare direttamente l'oggetto, sebbene sia probabilmente più lento. Gli oggetti mutabili, tuttavia, possono anche essere usati come entità . Come si potrebbero fare cose che si comportano come entità senza oggetti mutabili?
supercat,

0

Se hai più membri della classe finale e non vuoi che siano esposti a tutti gli oggetti che devono crearlo, puoi utilizzare il modello del generatore:

class NamedThing
{
    private string _name;    
    private string _value;
    private NamedThing(string name, string value)
    {
        _name = name;
        _value = value;
    }    
    public NamedThing(NamedThing other)
    {
        this._name = other._name;
        this._value = other._value;
    }
    public string Name
    {
        get { return _name; }
    }

    public static class Builder {
        string _name;
        string _value;

        public void setValue(string value) {
            _value = value;
        }
        public void setName(string name) {
            _name = name;
        }
        public NamedThing newObject() {
            return new NamedThing(_name, _value);
        }
    }
}

il vantaggio è che puoi facilmente creare un nuovo oggetto con solo un diverso valore di un nome diverso.


Penso che il tuo costruttore sia statico non sia corretto. Un altro thread potrebbe modificare il nome statico o il valore statico dopo averli impostati, ma prima di chiamare newObject.
ErikE

Solo la classe builder è statica, ma non i suoi membri. Ciò significa che per ogni builder creato, hanno il proprio set di membri con valori corrispondenti. La classe deve essere statica, quindi può essere utilizzata e istanziata al di fuori della classe contenente ( NamedThingin questo caso)
Salandur,

Vedo quello che stai dicendo, immagino solo un problema con questo perché non porta uno sviluppatore a "cadere nella fossa del successo". Il fatto che utilizzi variabili statiche significa che se Builderviene riutilizzato, c'è il rischio reale che accada ciò che ho menzionato. Qualcuno potrebbe costruire molti oggetti e decidere che, poiché la maggior parte delle proprietà sono le stesse, per riutilizzare semplicemente il Builder, e in effetti, rendiamolo un singleton globale che viene iniettato dipendenza! Ops. Principali bug introdotti. Quindi penso che questo modello di istanze miste vs statiche sia negativo.
ErikE,

1
@Salandur Le classi interne in C # sono sempre "statiche" nel senso della classe interna Java.
Sebastian Redl,

0

Quindi a che punto una classe diventa troppo complessa per essere immutabile?

Secondo me non vale la pena preoccuparsi di rendere immutabili le piccole classi in lingue come quella che stai mostrando. Sto usando piccoli qui e non complessi , perché anche se aggiungi dieci campi a quella classe e fa davvero operazioni fantasiose su di essi, dubito che prenderà i kilobyte per non parlare dei megabyte per non parlare dei gigabyte, quindi qualsiasi funzione che utilizza istanze del tuo class può semplicemente fare una copia economica dell'intero oggetto per evitare di modificare l'originale se vuole evitare di causare effetti collaterali esterni.

Strutture di dati persistenti

Dove trovo un uso personale per l'immutabilità è per strutture di dati centrali e di grandi dimensioni che aggregano un mucchio di dati di adolescenti come istanze della classe che stai mostrando, come una che ne memorizza un milione NamedThings. Appartenendo a una struttura di dati persistente che è immutabile e si trova dietro un'interfaccia che consente solo l'accesso in sola lettura, gli elementi che appartengono al contenitore diventano immutabili senza che la classe element ( NamedThing) debba occuparsene.

Copie economiche

La struttura dei dati persistente consente alle regioni di essere trasformate e rese uniche, evitando modifiche all'originale senza dover copiare la struttura dei dati nella sua interezza. Questa è la vera bellezza. Se volessi scrivere in modo ingenuo funzioni che evitino effetti collaterali che introducono una struttura di dati che richiede gigabyte di memoria e modifica solo la memoria di un megabyte, allora dovresti copiare l'intera cosa spaventosa per evitare di toccare l'input e restituire un nuovo produzione. O copia gigabyte per evitare effetti collaterali o causare effetti collaterali in quello scenario, facendoti scegliere tra due scelte spiacevoli.

Con una struttura di dati persistente, ti permette di scrivere una tale funzione ed evitare di fare una copia dell'intera struttura di dati, richiedendo solo un megabyte di memoria aggiuntiva per l'output se la tua funzione ha trasformato solo un valore di memoria di un megabyte.

fardello

Per quanto riguarda l'onere, ce n'è uno immediato almeno nel mio caso. Ho bisogno di quei costruttori di cui la gente parla o "transitori" come li chiamo per essere in grado di esprimere efficacemente le trasformazioni di quella massiccia struttura di dati senza toccarlo. Codice come questo:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
     // Transform stuff in the range, [first, last).
     for (; first != last; ++first)
          transform(stuff[first]);
}

... quindi deve essere scritto in questo modo:

ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
     // Grab a "transient" (builder) list we can modify:
     TransientList<Stuff> transient(stuff);

     // Transform stuff in the range, [first, last)
     // for the transient list.
     for (; first != last; ++first)
          transform(transient[first]);

     // Commit the modifications to get and return a new
     // immutable list.
     return stuff.commit(transient);
}

Ma in cambio di quelle due righe di codice in più, la funzione è ora sicura per chiamare attraverso thread con lo stesso elenco originale, non provoca effetti collaterali, ecc. Inoltre rende molto semplice rendere questa operazione un'azione dell'utente annullabile dal annulla può semplicemente archiviare una copia superficiale economica della vecchia lista.

Eccezione per la sicurezza o il recupero degli errori

Non tutti potrebbero trarre beneficio tanto quanto ho fatto dalle strutture di dati persistenti in contesti come questi (ho trovato un grande uso per loro nei sistemi di annullamento e nella modifica non distruttiva che sono concetti centrali nel mio dominio VFX), ma una cosa applicabile a quasi tutti da considerare sono la sicurezza delle eccezioni o il recupero degli errori .

Se si desidera rendere la funzione di muting originale sicura per le eccezioni, è necessaria una logica di rollback, per la quale l'implementazione più semplice richiede la copia dell'intero elenco:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
    // Make a copy of the whole massive gigabyte-sized list 
    // in case we encounter an exception and need to rollback
    // changes.
    MutList<Stuff> old_stuff = stuff;

    try
    {
         // Transform stuff in the range, [first, last).
         for (; first != last; ++first)
             transform(stuff[first]);
    }
    catch (...)
    {
         // If the operation failed and ran into an exception,
         // swap the original list with the one we modified
         // to "undo" our changes.
         stuff.swap(old_stuff);
         throw;
    }
}

A questo punto la versione mutabile a prova di eccezione è ancora più computazionalmente costosa e probabilmente anche più difficile da scrivere correttamente rispetto alla versione immutabile che utilizza un "builder". E molti sviluppatori C ++ trascurano semplicemente la sicurezza delle eccezioni e forse va bene per il loro dominio, ma nel mio caso mi piace assicurarmi che il mio codice funzioni correttamente anche in caso di un'eccezione (anche scrivendo test che deliberatamente generano eccezioni per testare l'eccezione sicurezza), e questo lo rende quindi devo essere in grado di ripristinare qualsiasi effetto collaterale che una funzione provoca a metà della funzione se qualcosa viene lanciato.

Quando si desidera essere eccezionalmente sicuri e ripristinare gli errori con garbo senza che l'applicazione si blocchi e venga masterizzata, è necessario ripristinare / annullare gli effetti collaterali che una funzione può causare in caso di errore / eccezione. E lì il costruttore può effettivamente risparmiare più tempo del programmatore di quanto costa insieme al tempo di calcolo perché: ...

Non devi preoccuparti del rollback degli effetti collaterali in una funzione che non provoca alcun effetto!

Quindi torniamo alla domanda fondamentale:

A che punto le classi immutabili diventano un peso?

Sono sempre un peso per le lingue che ruotano più sulla mutabilità che sull'immutabilità, motivo per cui penso che dovresti usarle laddove i benefici superano significativamente i costi. Ma a un livello sufficientemente ampio per strutture di dati abbastanza grandi, credo che ci siano molti casi in cui è un degno compromesso.

Anche nel mio, ho solo alcuni tipi di dati immutabili, e sono tutte enormi strutture di dati destinate a memorizzare un numero enorme di elementi (pixel di un'immagine / trama, entità e componenti di un ECS e vertici / spigoli / poligoni di una maglia).

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.