La duplicazione del codice è un male necessario in C?


16

Sono piuttosto nuovo in C, e mi chiedo se la duplicazione del codice sia un male necessario quando si tratta di scrivere strutture di dati comuni e C in generale?

Potrei provare a scrivere un'implementazione generica hash mapper esempio, ma trovo sempre che il risultato finale sia disordinato. Potrei anche scrivere un'implementazione specializzata solo per questo caso d'uso specifico, mantenere il codice chiaro e facile da leggere ed eseguire il debug. Quest'ultimo porterebbe ovviamente a una duplicazione del codice.

Le implementazioni generiche sono una norma o scrivi implementazioni diverse per ciascun caso d'uso?


11
Il dilemma morale che stai descrivendo non è esclusivo di C. Aneddoticamente, trovo che la barra per la creazione di un'implementazione generica sia piuttosto elevata, a meno che tu non abbia un pubblico per questo. La quantità di sforzo richiesta per creare una buona implementazione generica supera di gran lunga la soluzione puntuale, l'IME.
Robert Harvey,


1
@kevincline: Beh, Java ha generici. Certo, è il tipo di generici "non prestare attenzione all'uomo dietro il sipario".
Robert Harvey,

1
E prima dei generici, avresti potuto passare in Object e in Float, Double, Integer e Long, rispettivamente.
Daniel Kaplan,

3
Deja vu è un controllo dell'odore del codice e una preferenza per DRY è alla radice di tutte le migliori pratiche che vale la pena prestare attenzione all'IMO. Ma mi sono sparato ai piedi in linguaggi orientati alla OOP, sforzandomi troppo per evitare la duplicazione per se stessa. Se non stai vincolando inutilmente preoccupazioni non correlate o distruggendo la leggibilità per il fatto che due funzioni simili diventano una cosa sola, andrei con l'istinto in quasi tutte le lingue.
Erik Reppen,

Risposte:


27

C rende difficile scrivere codice generico. A differenza di C ++, che offre modelli e funzioni virtuali, C ha solo 3 meccanismi per scrivere codice generico:

  1. void* puntatori
  2. Macro del preprocessore
  3. Puntatori a funzione

void* i puntatori sono tutt'altro che ideali, poiché si perde tutta la sicurezza del tipo fornita dal compilatore, il che può comportare comportamenti indefiniti difficili da eseguire il debug risultanti da cast di tipi non validi.

Le macro del preprocessore presentano degli svantaggi ben noti: l'espansione del preprocessore è fondamentalmente solo un meccanismo di ricerca / sostituzione che si verifica prima della fase di compilazione, il che può nuovamente causare errori difficili da eseguire il debug. L'esempio archetipico è qualcosa del tipo:, #define add(x) (x+x)dove xpuò essere incrementato due volte se chiami add(i++). È possibile scrivere codice generico in stile modello utilizzando le macro C, ma il risultato è davvero orribile e difficile da mantenere .

I puntatori a funzione forniscono un buon modo per scrivere codice generico, ma sfortunatamente non forniscono generalità di tipo - forniscono semplicemente la possibilità di polimorfismo di runtime (motivo per cui, ad esempio, la libreria standard qsortrichiede ancora una funzione che accetta void*puntatori.)

Puoi anche implementare le gerarchie di classi in C usando le strutture, come nella libreria GLib che fornisce una GObjectclasse base generica . Ma questo soffre di problemi simili all'utilizzo dei void*puntatori, poiché è ancora necessario fare affidamento su casting manuali potenzialmente non sicuri su up-cast e down-cast.

Quindi sì, C rende difficile scrivere codice sia generico che sicuro / di facile manutenzione, il che sfortunatamente può provocare la duplicazione del codice. I grandi progetti C spesso usano linguaggi di scripting per generare codice ripetitivo durante il processo di compilazione.


1
Si potrebbe anche generare un codice C ripetitivo usando linguaggi / strumenti di template esterni o uno script di shell o usando ant o ...
Giobbe

Cos'è questo URL russo offuscato da Google che hai inserito nella tua risposta? Facendo clic su di esso, viene visualizzato un prompt "aperto" nel mio browser. È sicuro?
Robert Harvey,

2
@RobertHarvey Le mie scansioni antivirus e di sicurezza pensano che vada bene. È un file di intestazione in linguaggio C, estensione .h. Sembra legittimo.
maple_shaft

@maple_shaft: OK. Ho rimosso la googlizzazione attorno al link.
Robert Harvey,

È solo un file .h (C-header) in testo normale
Charles Salvia,

15

Non posso parlare per gli altri, ma nella mia esperienza personale con C, la duplicazione del codice non è stata un grosso problema. Non posso dire se ciò sia dovuto alle dimensioni del progetto o ad un set di campioni incantati. Tuttavia, seguo tre regole empiriche che ritengo applicabili. In nessun ordine particolare, sono ...

  1. Scrivi per ciò che è necessario. La generalità può venire più tardi SE è necessario.
  2. Se è necessaria la generalità, i puntatori vuoti, i puntatori a funzione e la dimensione della struttura possono essere preziosi. La routine qsort (), ad esempio, fa uso di tutti e tre.
  3. Dai peso al chiarimento del codice.

5

Vale la pena notare che potrebbe non essere necessaria una soluzione completamente generica per ridurre la duplicazione del codice. A volte il refactoring ordinario e un po 'di generalizzazione del metodo sono sufficienti.

Quando scrivi una soluzione generalizzata per un vasto pubblico, devi considerare:

  1. In quali nuovi modi il consumatore può usare il tuo codice e come lo accetti?
  2. Quali errori devo rilevare? Quali errori non dovrei rilevare ?
  3. Quanto deve essere solida l'API? Quanti sovraccarichi devo fornire?
  4. Quali misure di sicurezza devo attuare, in modo che il codice non possa essere utilizzato per scopi malvagi?

Eric Lippert ha scritto un intero articolo su una singola decisione di progettazione nel codice framework .NET. Tutto sommato, spesso è più semplice semplicemente il refactoring.


3

Di solito uso un'implementazione generica, come glib's , quindi se il casting finisce per essere troppo fastidioso, creo un piccolo wrapper specifico per il tipo. Tuttavia, ci si aspetta un sacco di casting in C, così come l'uso di un void*tipo generico, quindi quello che sarebbe considerato "disordinato" in un'altra lingua è semplicemente tipico C. Abbastanza scarso, sembrerà molto più naturale quando si ottenere più esperienza nella lingua.


2
"Ciò che sarebbe considerato 'disordinato' in un'altra lingua è semplicemente tipico C. In poche parole, sembrerà molto più naturale quando avrai più esperienza nella lingua." Hahaha! C è sempre stata una delle mie lingue preferite, ma è comunque piuttosto divertente!
GlenPeterson,

0

Il tuo problema di riusabilità non è esclusivo di C. Tuttavia, ci sono alcune implementazioni HashMap eccellenti e molto riutilizzabili su cui puoi basare il tuo. Uno che esiste da un decennio o due è in Java: http://docs.oracle.com/javase/7/docs/api/java/util/HashMap.html

Tutte le raccolte Java, incluso HashMap, vengono riscritte per Java 8 per eliminare l'idea di un iteratore e richiedono invece di passare una funzione alla raccolta in modo che possa iterare internamente. Questa è una grande vittoria per la scrittura di codice simultaneo: la raccolta può gestire la concorrenza internamente in modo che il cliente non debba farlo. Detto questo, la maggior parte dei programmatori non è abituata a passare i puntatori a funzioni. Questo fa un buon lavoro spiegando dove è diretto HashMap: http://cr.openjdk.java.net/~briangoetz/lambda/collections-overview.html

La sintassi di Scala può rendere difficile tradurre qualcosa del genere in C, ma ecco un esempio di una raccolta che funziona già come faranno le raccolte Java 8 - immutabile di default, fornendo vari metodi di trasformazione, facendo uso di puntatori a funzione e altro cose interessanti come un'applicazione parziale: http://www.scala-lang.org/api/current/index.html#scala.collection.Map

Se stai facendo un sacco di cose con HashMaps e altre strutture di dati più avanzate, potresti voler guardare una lingua che li supporti.

Tornando all'idea della duplicazione del codice in generale, è possibile programmare molto velocemente tagliando e incollando, ma produce un codice terribile. Direi che è accettabile nel codice usa e getta come i rapporti una tantum. Va bene anche a volte quando non c'è codice downstream, come il codice dell'interfaccia utente che crea solo uno schermo da cui nient'altro dipende, ma in realtà è un'area molto grigia al massimo.

Non penso che sia troppo una semplificazione eccessiva dire che ogni volta che ti ritrovi a duplicare il codice, dovresti fare una funzione. I soli bug che correggi in una copia del codice e non nell'altra sono sufficienti per creare una funzione.


1
- "Non penso che sia troppo una semplificazione eccessiva dire che ogni volta che ti ritrovi a duplicare il codice, dovresti fare una funzione" - In C questa è una semplificazione eccessiva. Prova a creare uno Stack in C per numeri interi, doppi e stringhe senza duplicare le funzioni push e pop per ciascun tipo di dati.
mike30,

@mike Non è possibile usare i puntatori vuoti?
sakisk,

@mike - buon punto. Ma un contrappunto sarebbe che potresti implementare Stack per ogni tipo di dati in modo tale da non dover implementare nuovamente Stack. Quindi l'obiettivo non è numberOfImplementations = 1, ma più come 5. Potresti riuscire a far funzionare l' intimplementazione per chars ... È passato tanto tempo da quando ho usato C che non ricordo.
GlenPeterson,

1
@faif. Sì, ma la necessità di utilizzare i puntatori vuoti rende il punto per C. Il cast dei puntatori è più lento e meno elegante delle alternative trovate in C ++. La duplicazione del codice si posiziona leggermente più in alto nell'elenco delle opzioni possibili quando si codifica in C.
mike30

@ mike30 Ci sono prove / riferimenti su: "Il cast dei puntatori in C è più lento delle alternative trovate in C ++?"
sakisk,

0

Mi chiedo se la duplicazione del codice sia un male necessario quando si tratta di scrivere strutture di dati comuni e C in generale?

In C, assolutamente per me, come qualcuno che rimbalza tra C e C ++. Duplico sicuramente cose più banali su base giornaliera in C che in C ++, ma deliberatamente, e non lo vedo necessariamente come "malvagio" perché ci sono almeno alcuni vantaggi pratici - penso che sia un errore considerare tutte le cose come strettamente "buono" o "cattivo" - quasi tutto è una questione di compromessi. Comprendere chiaramente questi compromessi è la chiave per non evitare decisioni spiacevoli con il senno di poi, e semplicemente etichettare le cose come "buone" o "cattive" generalmente ignora tutte queste sottigliezze.

Mentre il problema non è unico per C come hanno sottolineato altri, potrebbe essere considerevolmente più esacerbato in C a causa della mancanza di qualcosa di più elegante di macro o di puntatori vuoti per generici, imbarazzo di OOP non banale e il fatto che il La libreria standard C non viene fornita con nessun contenitore. In C ++, una persona che implementa il proprio elenco di link potrebbe ottenere una folla arrabbiata di persone che chiedono perché non stanno usando la libreria standard, a meno che non siano studenti. In C, inviteresti un mob arrabbiato se non riesci a implementare con sicurezza un'elegante implementazione dell'elenco collegato nel tuo sonno poiché un programmatore C dovrebbe essere almeno in grado di fare quel tipo di cose ogni giorno. E' s non a causa di una strana ossessione per gli elenchi collegati secondo cui Linus Torvalds ha utilizzato l'implementazione della ricerca e rimozione di SLL usando la doppia indiretta come criterio per valutare un programmatore che capisce la lingua e ha "buon gusto". È perché ai programmatori C potrebbe essere richiesto di implementare tale logica mille volte nella loro carriera. In questo caso per C, è come uno chef che valuta le abilità di un nuovo cuoco facendogli preparare alcune uova per vedere se almeno hanno la padronanza delle cose di base che dovranno fare tutto il tempo.

Ad esempio, ho probabilmente implementato questa struttura di dati di base "lista libera indicizzata" una dozzina di volte in C localmente per ogni sito che utilizza questa strategia di allocazione (quasi tutte le mie strutture collegate per evitare di allocare un nodo alla volta e dimezzare la memoria costi dei collegamenti su 64 bit):

inserisci qui la descrizione dell'immagine

Ma in C ci vuole solo una piccolissima quantità di codice in reallocun array coltivabile e si raccoglie un po 'di memoria da esso usando un approccio indicizzato a un elenco libero quando si implementa una nuova struttura di dati che utilizza questo.

Ora ho implementato la stessa cosa in C ++ e lì l'ho implementata una sola volta come modello di classe. Ma è un'implementazione molto, molto più complessa sul lato C ++ con centinaia di righe di codice e alcune dipendenze esterne che si estendono anche su centinaia di righe di codice. E il motivo principale per cui è molto più complicato è perché devo codificarlo in base all'idea che Tpotrebbe essere qualsiasi tipo di dati possibile. Potrebbe lanciare in qualsiasi momento (tranne quando lo distruggo, cosa che devo fare esplicitamente come con i contenitori della libreria standard), ho dovuto pensare al corretto allineamento per allocare memoria perT (anche se fortunatamente questo è reso più semplice in C ++ 11 in poi), potrebbe essere non banalmente costruibile / distruttibile (che richiede il posizionamento di invocazioni di nuovi e manuali manuali), devo aggiungere metodi che non tutto sarà necessario ma alcune cose avranno bisogno, e devo aggiungere iteratori, sia mutabili che di sola lettura (const) iteratori, e così via e così via.

Le matrici coltivabili non sono scienza missilistica

In C ++ le persone fanno sembrare che std::vectorsia il lavoro di uno scienziato missilistico, ottimizzato fino alla morte, ma non ha prestazioni migliori di un array C dinamico codificato rispetto a un tipo di dati specifico che utilizza semplicemente , potenzialmente tratta i POD in modo diverso utilizzando tratti di tipo, ecc. ecc. ecc. A quel punto, in effetti, è necessaria un'implementazione molto complessa solo per rendere un array dinamico coltivabile, ma solo perché sta cercando di gestire ogni possibile caso d'uso mai immaginabile. Tra i lati positivi, puoi ottenere un sacco di chilometraggio da tutto quello sforzo extra se hai davvero bisogno di archiviare sia POD e UDT non banali, utilizzare gli algoritmi basati su iteratori generici che funzionano su qualsiasi struttura di dati conforme, beneficiare della gestione delle eccezioni e RAII, almeno a volte ignorarerealloc per aumentare la capacità dell'array sui push back con un dozzina di righe di codice. La differenza è che ci vuole un'implementazione molto complessa per rendere solo una sequenza di accesso casuale coltivabile pienamente conforme allo standard, evitare di invocare agenti su elementi non inseriti, sicuro dalle eccezioni, fornire iteratori ad accesso casuale sia costanti che non costanti, utilizzare il tipo tratti per chiarire ambedue i riempitori dai range range per alcuni tipi integrali diTstd::allocator con il proprio allocatore personalizzato, ecc. ecc. Sicuramente paga nella libreria standard se si considera quanto beneficiostd::vector ha avuto su tutto il mondo delle persone che l'hanno usato, ma questo è per qualcosa implementato nella libreria standard progettata per soddisfare le esigenze di tutto il mondo.

Implementazioni più semplici che gestiscono casi d'uso molto specifici

Come risultato della semplice gestione di casi d'uso molto specifici con la mia "lista libera indicizzata", nonostante l'implementazione di questa lista libera una dozzina di volte sul lato C e la duplicazione di un codice insignificante, probabilmente ho scritto meno codice totale in C per implementarlo una dozzina di volte rispetto a quando dovevo implementarlo una sola volta in C ++, e ho dovuto dedicare meno tempo a mantenere quelle dozzine di implementazioni in C rispetto a quelle che dovevo mantenere in quella C ++. Uno dei motivi principali per cui il lato C è così semplice è che di solito lavoro con i POD in C ogni volta che utilizzo questa tecnica e generalmente non ho bisogno di più funzioni diinsert eerasenei siti specifici in cui lo implemento localmente. Fondamentalmente posso solo implementare il sottoinsieme più giovane delle funzionalità fornite dalla versione C ++, dato che sono libero di fare molte più ipotesi su ciò che faccio e non ho bisogno del design quando lo sto implementando per un uso molto specifico Astuccio.

Ora la versione C ++ è molto più bella e sicura da usare, ma era ancora una PITA importante da implementare e rendere conforme alle regole di iteratore sicuro per le eccezioni e bidirezionale, ad esempio, in modi in cui trovare un'implementazione generale riutilizzabile probabilmente costa più tempo di quanto effettivamente risparmi in questo caso. E gran parte di quel costo per implementarlo in modo generalizzato viene sprecato non solo in anticipo, ma ripetutamente sotto forma di cose come i tempi di costruzione intensificati pagati più volte ogni giorno.

Non è un attacco al C ++!

Ma questo non è un attacco al C ++ perché amo il C ++, ma quando si tratta di strutture di dati, sono arrivato a privilegiare il C ++ principalmente per le strutture di dati davvero non banali che voglio dedicare molto tempo in più per implementare in anticipo un modo molto generalizzato, rendere eccezionalmente sicuro contro tutti i possibili tipi di T, rendere conforme agli standard e iterabile, ecc., dove quel tipo di costo iniziale paga davvero sotto forma di tonnellate di chilometraggio.

Tuttavia, ciò promuove anche una mentalità progettuale molto diversa. In C ++ se voglio fare un Octree per il rilevamento delle collisioni, ho la tendenza a volerlo generalizzare all'ennesima potenza. Non voglio solo fargli memorizzare maglie triangolari indicizzate. Perché dovrei limitarlo a un solo tipo di dati con cui può funzionare quando ho un meccanismo di generazione di codice super potente a portata di mano che elimina tutte le penalità di astrazione in fase di esecuzione? Voglio che memorizzi sfere procedurali, cubi, voxel, superfici di NURB, nuvole di punti, ecc. Ecc. E provi a renderlo buono per tutto, perché è allettante voler progettarlo in quel modo quando hai modelli a portata di mano. Potrei anche non voler limitarlo al rilevamento delle collisioni - che ne dici di raytracing, picking, ecc.? Il C ++ lo rende inizialmente "abbastanza facile" generalizzare una struttura di dati all'ennesima potenza. Ed è così che ho usato per progettare tali indici spaziali in C ++. Ho cercato di progettarli per gestire le esigenze della fame di tutto il mondo, e quello che ho ottenuto in cambio era in genere un "tuttofare" con un codice estremamente complesso per bilanciarlo con tutti i possibili casi immaginabili.

Stranamente, però, ho ottenuto più riutilizzo dagli indici spaziali che ho implementato in C nel corso degli anni, e senza colpa di C ++, ma solo il mio in ciò che il linguaggio mi tenta di fare. Quando scrivo qualcosa come un ottetto in C, ho la tendenza a farlo funzionare solo con punti ed essere contento di questo, perché il linguaggio rende troppo difficile persino tentare di generalizzarlo all'ennesima potenza. Ma a causa di queste tendenze, nel corso degli anni ho avuto la tendenza a progettare cose che sono in realtà più efficienti, affidabili e davvero adatte a determinati compiti da svolgere, dal momento che non si preoccupano di essere generali all'ennesima potenza. Diventano assi in una categoria specializzata anziché un tuttofare. Ancora una volta ciò non è in difetto del C ++, ma semplicemente delle tendenze umane che ho quando lo sto usando al contrario di C.

Ad ogni modo, adoro entrambe le lingue ma ci sono tendenze diverse. In CI hanno la tendenza a non generalizzare abbastanza. In C ++ ho la tendenza a generalizzare troppo. L'uso di entrambi mi ha aiutato a bilanciarmi.

Le implementazioni generiche sono una norma o scrivi implementazioni diverse per ciascun caso d'uso?

Per cose banali come elenchi indicizzati a 32 bit collegati singolarmente usando nodi da un array o un array che rialloca se stesso (equivalente analogico std::vectorin C ++) o, diciamo, un octree che memorizza solo punti e mira a non fare più nulla, io non non preoccuparti di generalizzare al punto di archiviare qualsiasi tipo di dati. Li implemento per memorizzare un tipo di dati specifico (anche se in alcuni casi può essere astratto e utilizzare i puntatori a funzione, ma almeno più specifico della tipizzazione anatra con polimorfismo statico).

E sono perfettamente felice con un po 'di ridondanza in quei casi, a condizione che lo collaudo a fondo. Se non eseguo il test unitario, la ridondanza inizia a sentirsi molto più a disagio, perché potresti avere un codice ridondante che potrebbe duplicare errori, ad es. Quindi anche se è improbabile che il tipo di codice che stai scrivendo abbia mai bisogno di modifiche di progettazione, potrebbe ancora aver bisogno di modifiche perché è rotto. Tendo a scrivere test unitari più approfonditi per il codice C che scrivo come motivo.

Per cose non banali, di solito è quando raggiungo il C ++, ma se dovessi implementarlo in C, prenderei in considerazione l'uso di soli void*puntatori, forse accetterei una dimensione del tipo per sapere quanta memoria assegnare per ogni elemento e possibilmente i copy/destroypuntatori di funzione copiare in profondità e distruggere i dati se non sono banalmente costruibili / distruttibili. Il più delle volte non mi preoccupo e non uso così tanto C per creare le strutture dati e gli algoritmi più complessi.

Se si utilizza una struttura di dati abbastanza frequentemente con un particolare tipo di dati, è anche possibile racchiudere una versione di tipo sicuro rispetto a una che funziona solo con bit e byte e puntatori a funzione e void*, ad esempio, per reimpostare la sicurezza del tipo tramite il wrapper C.

Potrei provare a scrivere un'implementazione generica per una mappa hash, ad esempio, ma trovo sempre che il risultato finale sia disordinato. Potrei anche scrivere un'implementazione specializzata solo per questo caso d'uso specifico, mantenere il codice chiaro e facile da leggere ed eseguire il debug. Quest'ultimo porterebbe ovviamente a una duplicazione del codice.

Le tabelle hash sono un po 'iffy poiché potrebbe essere banale da implementare o davvero complesso a seconda di quanto siano complesse le tue esigenze rispetto a hash, rehash, se hai bisogno di far crescere automaticamente la tabella da sola in modo implicito o puoi anticipare le dimensioni della tabella in avanzare, sia che utilizzi l'indirizzamento aperto o il concatenamento separato, ecc. Ma una cosa da tenere a mente è che se hai adattato una tabella di hash perfettamente alle esigenze di un sito specifico, spesso non sarà così complessa nell'attuazione e spesso vincerà essere così ridondante quando è su misura proprio per quelle esigenze. Almeno questa è la scusa che mi do se implemento qualcosa localmente. Altrimenti potresti semplicemente usare il metodo sopra descritto con void*e puntatori a funzioni per copiare / distruggere cose e generalizzarle.

Spesso non ci vuole molto sforzo o molto codice per battere una struttura di dati molto generalizzata se l'alternativa è estremamente strettamente applicabile al tuo caso d'uso esatto. Ad esempio, è assolutamente banale battere le prestazioni dell'utilizzo mallocper ogni singolo nodo (al contrario di unire un sacco di memoria per molti nodi) una volta per tutte con il codice che non devi mai rivedere per un caso d'uso molto, molto esatto anche se mallocvengono fuori nuove implementazioni . Potrebbe volerci una vita per batterlo e codificare non meno complesso che devi dedicare una grande parte della tua vita al mantenimento e all'aggiornamento se vuoi abbinare la sua generalità.

Come altro esempio, ho spesso trovato estremamente facile implementare soluzioni 10 volte più veloci o più delle soluzioni VFX offerte da Pixar o Dreamworks. Posso farlo nel sonno. Ma questo non è perché le mie implementazioni sono superiori, tutt'altro. Sono decisamente inferiori per la maggior parte delle persone. Sono superiori solo per i miei casi d'uso molto, molto specifici. Le mie versioni sono molto, molto meno generalmente applicabili di quelle di Pixar o Dreamwork. È un confronto ridicolmente ingiusto poiché le loro soluzioni sono assolutamente geniali rispetto alle mie soluzioni stupide e semplici, ma è questo il punto. Il confronto non deve essere equo. Se tutto ciò di cui hai bisogno sono alcune cose molto specifiche, non è necessario che una struttura di dati gestisca un elenco infinito di cose che non ti servono.

Bit e byte omogenei

Una cosa da sfruttare in C dal momento che ha una tale mancanza intrinseca di sicurezza del tipo è l'idea di memorizzare le cose in modo omogeneo in base alle caratteristiche di bit e byte. C'è più di una sfocatura lì come risultato tra l'allocatore di memoria e la struttura dei dati.

Ma conservare un mucchio di cose di dimensioni variabili, o anche cose che potrebbero semplicemente essere di dimensioni variabili, come un polimorfico Doge Cat, è difficile da fare in modo efficiente. Non si può supporre che possano essere di dimensioni variabili e archiviarli contigui in un semplice contenitore ad accesso casuale perché il passo da passare da un elemento all'altro potrebbe essere diverso. Di conseguenza, per memorizzare un elenco che contiene sia cani che gatti, potrebbe essere necessario utilizzare 3 istanze separate di struttura / allocatore dei dati (una per i cani, una per i gatti e una per un elenco polimorfico di puntatori di base o puntatori intelligenti, o peggio , allocare ogni cane e gatto contro un allocatore per tutti gli usi e disperderli su tutta la memoria), che diventa costoso e comporta la sua quota di errori di cache moltiplicati.

Quindi una strategia da utilizzare in C, sebbene si tratti di ricchezza e sicurezza di tipo ridotto, è quella di generalizzare a livello di bit e byte. Potresti essere in grado di supporre che Dogse Catsrichiedere lo stesso numero di bit e byte, avere gli stessi campi, lo stesso puntatore a una tabella di puntatori a funzione. Ma in cambio puoi quindi codificare un numero minore di strutture dati, ma altrettanto importante, archiviare tutte queste cose in modo efficiente e contiguo. In questo caso stai trattando cani e gatti come unioni analogiche (o potresti semplicemente usare un'unione).

E questo ha un costo enorme per la sicurezza. Se c'è una cosa che mi manca più di ogni altra cosa in C, è la sicurezza del tipo. Si sta avvicinando al livello dell'assemblaggio in cui le strutture indicano solo la quantità di memoria allocata e come ogni campo di dati è allineato. Ma questo è in realtà il mio numero uno dei motivi per usare C. Se stai davvero cercando di controllare i layout di memoria e dove tutto è allocato e dove tutto è archiviato l'uno rispetto all'altro, spesso aiuta solo a pensare alle cose a livello di bit e byte e quanti bit e byte sono necessari per risolvere un problema specifico. Lì il muto del sistema di tipo C può effettivamente diventare benefico piuttosto che un handicap. In genere ciò si tradurrà in un numero molto inferiore di tipi di dati da gestire,

Duplicazione illusoria / apparente

Ora sto usando la "duplicazione" in senso lato per cose che potrebbero non essere nemmeno ridondanti. Ho visto persone distinguere termini come duplicazione "incidentale / apparente" da "duplicazione effettiva". A mio modo di vedere, in molti casi non esiste una chiara distinzione. Trovo la distinzione più simile a "potenziale unicità" rispetto a "potenziale duplicazione" e può andare in entrambi i modi. Spesso dipende da come desideri che i tuoi progetti e le tue implementazioni si evolvano e da come saranno perfettamente su misura per un caso d'uso specifico. Ma ho spesso scoperto che quella che potrebbe sembrare una duplicazione del codice in seguito risulta non essere più ridondante dopo diverse iterazioni di miglioramenti.

Prendi una semplice implementazione di array coltivabile usando realloc, l'equivalente analogico di std::vector<int>. Inizialmente potrebbe essere ridondante, diciamo, usando std::vector<int>in C ++. Ma potresti scoprire, attraverso la misurazione, che potrebbe essere utile preallocare in anticipo 64 byte per consentire l'inserimento di sedici numeri interi a 32 bit senza richiedere un'allocazione dell'heap. Ora non è più ridondante, almeno non con std::vector<int>. E poi potresti dire: "Ma potrei semplicemente generalizzare questo in un nuovo SmallVector<int, 16>, e potresti. Ma allora diciamo che trovi utile perché questi sono per array molto piccoli e di breve durata per quadruplicare la capacità dell'array su allocazioni di heap invece di aumentando di 1,5 (all'incirca la quantità che moltivectorle implementazioni usano) mentre si lavora sul presupposto che la capacità dell'array sia sempre una potenza di due. Ora il tuo contenitore è davvero diverso e probabilmente non esiste un contenitore simile. E forse potresti provare a generalizzare tali comportamenti aggiungendo sempre più parametri di modello per personalizzare il preallocazione pesante, personalizzare il comportamento di riallocazione, ecc. Ecc., Ma a quel punto potresti trovare qualcosa di veramente ingombrante da usare rispetto a una dozzina di linee di semplice C codice.

E potresti persino raggiungere un punto in cui hai bisogno di una struttura di dati che alloca memoria allineata e imbottita a 256 bit, archiviando esclusivamente POD per le istruzioni AVX 256, preallocazione di 128 byte per evitare allocazioni di heap per dimensioni di input di dimensioni normali, raddoppiando la capacità quando pieno e consente sovrascritture sicure di elementi finali che superano le dimensioni dell'array ma non superano la capacità dell'array. A quel punto, se stai ancora cercando di generalizzare una soluzione per evitare di duplicare una piccola quantità di codice C, che gli dei programmatori abbiano pietà della tua anima.

Quindi ci sono anche momenti come questo in cui ciò che inizialmente inizia a sembrare ridondante inizia a crescere, mentre si adatta una soluzione per adattarsi sempre meglio a un determinato caso d'uso, in qualcosa di completamente unico e per nulla ridondante. Ma questo è solo per le cose in cui puoi permetterti di adattarle perfettamente a un caso d'uso specifico. A volte abbiamo solo bisogno di una cosa "decente" generalizzata per il nostro scopo, e lì traggo il massimo beneficio da strutture di dati molto generalizzate. Ma per cose eccezionali fatte perfettamente per un caso d'uso particolare, l'idea di "scopo generale" e "fatta perfettamente per il mio scopo" iniziano a diventare troppo incompatibili.

POD e primitivi

Ora in C, trovo spesso scuse per archiviare POD e soprattutto primitivi in ​​strutture di dati ogni volta che è possibile. Potrebbe sembrare un anti-pattern, ma in realtà l'ho trovato inavvertitamente utile nel migliorare la manutenibilità del codice rispetto ai tipi di cose che facevo più spesso in C ++.

Un semplice esempio è l'internamento di stringhe brevi (come in genere accade con le stringhe utilizzate per le chiavi di ricerca - tendono ad essere molto brevi). Perché preoccuparsi di occuparsi di tutte queste stringhe di lunghezza variabile le cui dimensioni variano in fase di esecuzione, il che implica una costruzione e una distruzione non banali (dal momento che potremmo aver bisogno di ammassare e liberare)? Che ne dici di archiviare queste cose in una struttura di dati centrale, come una tabella trie o hash thread-safe progettata solo per internare le stringhe, e quindi fare riferimento a quelle stringhe con un semplice vecchio int32_to:

struct IternedString 
{
    int32_t index;
};

... nelle nostre tabelle hash, alberi rosso-neri, salta le liste, ecc., se non abbiamo bisogno dell'ordinamento lessicografico? Ora tutte le altre nostre strutture di dati che abbiamo codificato per funzionare con numeri interi a 32 bit ora possono memorizzare queste chiavi di stringa internate che sono effettivamente solo a 32 bit ints. E ho trovato almeno nei miei casi d'uso (potrebbe essere solo il mio dominio poiché lavoro in aree come raytracing, elaborazione mesh, elaborazione di immagini, sistemi di particelle, associazione a linguaggi di scripting, implementazioni di kit di GUI multithread a basso livello, ecc. - cose di basso livello ma non di basso livello come un sistema operativo), per coincidenza che il codice per caso diventa più efficiente e più semplice semplicemente archiviando indici in cose come questa. Questo lo rende quindi lavoro spesso, diciamo il 75% delle volte, con solo int32_tefloat32 nelle mie strutture di dati non banali, o semplicemente archiviando oggetti della stessa dimensione (quasi sempre a 32 bit).

E naturalmente, se questo è applicabile al tuo caso, puoi evitare di avere una serie di implementazioni della struttura dei dati per diversi tipi di dati, poiché in primo luogo lavorerai con così pochi.

Test e affidabilità

Un'ultima cosa che offrirei, e potrebbe non essere per tutti, è favorire la scrittura di test per tali strutture di dati. Rendili davvero bravi in ​​qualcosa. Assicurati che siano ultra affidabili.

Alcune duplicazioni minori di codice diventano molto più perdonabili in questi casi, poiché la duplicazione del codice è solo un onere di manutenzione se si devono apportare modifiche a cascata al codice duplicato. Elimina uno dei motivi principali per cui tale codice ridondante cambia, assicurandoti che sia ultra affidabile e davvero adatto a ciò che sta cercando di fare.

Il mio senso estetico è cambiato nel corso degli anni. Non mi irrito più perché vedo una libreria implementare il punto prodotto o qualche banale logica SLL che è già implementata in un'altra. Mi irrito solo quando le cose sono scarsamente testate e inaffidabili, e ho scoperto che una mentalità molto più produttiva. Mi sono sinceramente occupato delle basi di codice che hanno duplicato i bug attraverso il codice duplicato e ho visto i casi peggiori di codifica copia-e-incolla che hanno trasformato quella che avrebbe dovuto essere una banale modifica in una posizione centrale trasformandola in una variazione a cascata soggetta a errori. Eppure molte di quelle volte, è stato il risultato di scarsi test, del codice che non è diventato affidabile e bravo in quello che stava facendo in primo luogo. Prima quando stavo lavorando in basi di codici legacy buggy, la mia mente associava tutte le forme di duplicazione del codice ad avere un'alta probabilità di duplicare i bug e richiedere modifiche a cascata. Tuttavia una libreria in miniatura che fa una cosa estremamente bene e in modo affidabile troverà pochissime ragioni per cambiare in futuro, anche se ha un codice dall'aspetto ridondante qua e là. Le mie priorità erano fuori allora quando la duplicazione mi irritava più della scarsa qualità e della mancanza di test. Queste ultime cose dovrebbero essere la massima priorità.

Duplicazione di codice per minimalismo?

Questo è un pensiero divertente che mi è venuto in mente, ma considera un caso in cui potremmo incontrare una libreria C e C ++ che fa all'incirca la stessa cosa: entrambi hanno all'incirca la stessa funzionalità, la stessa quantità di gestione degli errori, uno non è significativamente più efficiente dell'altro, ecc. E, soprattutto, entrambi sono implementati con competenza, ben collaudati e affidabili. Sfortunatamente devo parlare ipoteticamente qui poiché non ho mai trovato nulla vicino a un perfetto confronto fianco a fianco. Ma le cose più vicine che abbia mai trovato in questo confronto fianco a fianco spesso avevano la libreria C molto più, molto più piccola dell'equivalente C ++ (a volte 1/10 della sua dimensione del codice).

E credo che la ragione di ciò sia perché, ancora una volta, risolvere un problema in modo generale che gestisca la più ampia gamma di casi d'uso anziché un caso d'uso esatto potrebbe richiedere da centinaia a migliaia di righe di codice, mentre quest'ultimo potrebbe richiedere solo una dozzina. Nonostante la ridondanza, e nonostante il fatto che la libreria standard C sia spaventosa quando si tratta di fornire strutture di dati standard, spesso finisce per produrre meno codice in mani umane per risolvere gli stessi problemi, e penso che ciò sia dovuto principalmente alle differenze nelle tendenze umane tra queste due lingue. Uno promuove la risoluzione delle cose rispetto a un caso d'uso molto specifico, l'altro tende a promuovere soluzioni più astratte e generiche contro la più ampia gamma di casi d'uso, ma il risultato finale di questi non lo fa

L'altro giorno stavo guardando il raytracer di qualcuno su github ed era implementato in C ++ e richiedeva così tanto codice per un raytracer giocattolo. E non ho trascorso molto tempo a guardare il codice, ma c'erano un sacco di strutture per scopi generali che gestivano il modo, molto più di quello di cui un raytracer avrebbe bisogno. E riconosco quello stile di codifica perché usavo C ++ allo stesso modo in una sorta di modalità super bottom-up, concentrandomi sulla creazione di una libreria completa di strutture di dati molto generici che vanno ben al di là dell'immediato problema a portata di mano e quindi affrontare il problema reale secondo. Ma mentre quelle strutture generali potrebbero eliminare un po 'di ridondanza qua e là e godere di un sacco di riutilizzo in nuovi contesti, in cambio gonfiano enormemente il progetto scambiando un po 'di ridondanza con un carico di codice / funzionalità non necessari, e quest'ultimo non è necessariamente più facile da mantenere rispetto al primo. Al contrario, trovo spesso più difficile da mantenere, dal momento che è difficile mantenere una progettazione di qualcosa di generale che deve mettere in equilibrio le decisioni di progettazione con la più ampia gamma di esigenze possibile.

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.