Quando diventa dannoso il paradigma "Do One Thing"?


21

Per ragioni di argomento, ecco una funzione di esempio che stampa il contenuto di un determinato file riga per riga.

Versione 1:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  string line;
  while (std::getline(file, line)) {
    cout << line << endl;
  }
}

So che si raccomanda che le funzioni facciano una cosa ad un livello di astrazione. Per me, anche se il codice sopra fa praticamente una cosa ed è abbastanza atomico.

Alcuni libri (come il Clean Code di Robert C. Martin) sembrano suggerire di suddividere il codice sopra in funzioni separate.

Versione 2:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  printLines(file);
}

void printLines(fstream & file) {
  string line;
  while (std::getline(file, line)) {
    printLine(line);
  }
}

void printLine(const string & line) {
  cout << line << endl;
}

Capisco cosa vogliono ottenere (apri il file / leggi le righe / stampa la riga), ma non è un po 'eccessivo?

La versione originale è semplice e in un certo senso fa già una cosa: stampare un file.

La seconda versione porterà a un numero elevato di funzioni molto piccole che potrebbero essere molto meno leggibili rispetto alla prima versione.

Non sarebbe, in questo caso, meglio avere il codice in un unico posto?

A che punto il paradigma "Do One Thing" diventa dannoso?


13
Questo tipo di pratica di codifica si basa sempre caso per caso. Non esiste mai un solo approccio.
iammilind,

1
@Alex - La risposta accettata non ha letteralmente nulla a che fare con la domanda. Lo trovo davvero strano.
ChaosPandion,

2
Prendo atto che la tua versione refactored è capovolta, il che contribuisce alla mancanza di leggibilità. Leggendo giù il file, ci si aspetterebbe di vedere printFile, printLinese, infine printLine.
Anthony Pegram,

1
@Kev, ancora una volta non posso essere d'accordo, in particolare con quella categorizzazione. Non è pedanteria, è il punto! È l'OP che afferma specificamente che la seconda versione potrebbe non essere così leggibile. È l'OP che cita specificamente Clean Code come fonte d'ispirazione per la seconda versione. Il mio commento è essenzialmente che Clean Code non vorrebbe che scrivesse il codice in quel modo. L'ordine è in realtà importante per la leggibilità, leggi il file come se leggessi un articolo di giornale, ottenendo sempre più dettagli fino a quando non diventi sostanzialmente disinteressato.
Anthony Pegram,

1
Come se non ti aspettassi di leggere una poesia all'indietro, né ti aspetteresti di vedere il livello più basso di dettaglio come la prima cosa all'interno di una determinata classe. A tuo avviso, questo codice richiede poco tempo per ordinare rapidamente, ma suppongo che questo codice non sia l'unico codice che scriverà. Al mio punto, se sta per citare Clean Code, il minimo che potrebbe fare è seguirlo. Se il codice non funziona, sarà sicuramente meno leggibile rispetto al resto.
Anthony Pegram,

Risposte:


15

Naturalmente, questo pone solo la domanda "Qual è una cosa?" Leggere una riga è una cosa e scriverne una è un'altra? Oppure copiare una linea da un flusso all'altro è da considerare una cosa? O copiare un file?

Non esiste una risposta dura e obiettiva a questo. Tocca a voi. Puoi decidere. Si deve decidere. L'obiettivo principale del paradigma "fai una cosa" è probabilmente quello di produrre un codice il più semplice possibile da comprendere, in modo da poterlo utilizzare come linea guida. Sfortunatamente, neanche questo è oggettivamente misurabile, quindi devi fare affidamento sul tuo istinto e sul "WTF?" contare nella revisione del codice .

IMO una funzione che consiste in una sola riga di codice raramente vale mai la pena. Il tuo printLine()non ha alcun vantaggio rispetto all'uso di std::cout << line << '\n'1 direttamente. Se vedo printLine(), devo supporre che faccia quello che dice il suo nome, oppure cercare e controllare. Se vedo std::cout << line << '\n', so immediatamente cosa fa, perché questo è il modo canonico di emettere il contenuto di una stringa come una linea std::cout.

Tuttavia, un altro obiettivo importante del paradigma è consentire il riutilizzo del codice, e questa è una misura molto più obiettiva. Ad esempio, nella tua seconda versione printLines() potrebbe essere facilmente scritto in modo che sia un algoritmo universalmente utile che copia le righe da uno stream a un altro:

void copyLines(std::istream& is, std::ostream& os)
{
  std::string line;
  while( std::getline(is, line) );
    os << line << '\n';
  }
}

Un tale algoritmo potrebbe essere riutilizzato anche in altri contesti.

È quindi possibile inserire tutto ciò che è specifico per questo caso d'uso in una funzione che chiama questo algoritmo generico:

void printFile(const std::string& filePath) {
  std::ifstream file(filePath.c_str());
  printLines(file, std::cout);
}

1 Nota che ho usato '\n'piuttosto che std::endl. '\n'dovrebbe essere la scelta predefinita per l'output di una nuova riga , std::endlè il caso dispari .


2
+1 - Sono principalmente d'accordo, ma penso che ci sia qualcosa di più del "sentimento di budello". Il problema è quando le persone giudicano "una cosa" contando i dettagli dell'implementazione. Per me, la funzione dovrebbe implementare (e il suo nome descrive) un'unica chiara astrazione. Non si dovrebbe mai nominare una funzione "do_x_and_y". L'implementazione può e dovrebbe fare diverse cose (più semplici) - e ognuna di quelle cose più semplici può essere scomposta in molte cose ancora più semplici e così via. È solo una scomposizione funzionale con una regola extra - che le funzioni (e i loro nomi) dovrebbero descrivere ciascuna un singolo concetto / compito / qualunque cosa chiara.
Steve314,

@ Steve314: non ho elencato i dettagli di implementazione come possibilità. Copiare le linee da un flusso all'altro è chiaramente un'astrazione unica . O è? Ed è facile da evitare do_x_and_y()nominando do_everything()invece la funzione . Sì, questo è un esempio sciocco, ma mostra che questa regola non riesce nemmeno a prevenire gli esempi più estremi di cattiva progettazione. IMO questa è una decisione sentimentale tanto quanto quella dettata dalle convenzioni. Altrimenti, se fosse obiettivo, potresti trovare una metrica per questo - che non puoi.
sabato

1
Non intendevo contraddire, solo per suggerire un'aggiunta. Immagino che ciò che ho dimenticato di dire è che, dalla domanda, la decomposizione in printLineecc è valida - ognuna di queste è una singola astrazione - ma ciò non significa necessario. printFileè già "una cosa". Sebbene sia possibile scomporlo in tre astrazioni separate di livello inferiore, non è necessario scomporre ad ogni possibile livello di astrazione. Ogni funzione deve fare "una cosa", ma non tutte le possibili "una cosa" devono essere una funzione. Spostare troppa complessità nel grafico delle chiamate può essere esso stesso un problema.
Steve314,

7

Avere una funzione fare solo "una cosa" è un mezzo per due fini desiderabili, non un comandamento di Dio:

  1. Se la tua funzione fa solo "una cosa", ti aiuterà a evitare la duplicazione del codice e il rigonfiamento dell'API perché sarai in grado di comporre funzioni per fare cose più complesse invece di avere un'esplosione combinatoria di funzioni di livello superiore e meno compostabili .

  2. Avere funzioni che fanno solo "una cosa" può rendere il codice più leggibile. Questo dipende dal fatto che tu possa ottenere più chiarezza e facilità di ragionamento disaccoppiando le cose di quanto perdi per la verbosità, l'irregolarità e l'overhead concettuale dei costrutti che ti consentono di disaccoppiare le cose.

Pertanto, "una cosa" è inevitabilmente soggettiva e dipende dal livello di astrazione rilevante per il tuo programma. Se printLinesè considerata un'unica, fondamentale operazione e l'unico modo per stampare linee che ti interessano o prevedi di prendertene cura, allora per i tuoi scopi printLinesfa solo una cosa. A meno che non trovi la seconda versione più leggibile (io no) la prima versione va bene.

Se inizi a aver bisogno di un maggiore controllo sui livelli più bassi di astrazione e finisci con una sottile duplicazione ed esplosione combinatoria (cioè un printLinesnome di file e un file completamente separato printLinesper fstreamoggetti, una printLinesconsole e un printLinesfile), allora printLinesstai facendo più di una cosa a livello di astrazione a cui tieni.


Vorrei aggiungere un terzo e cioè le funzioni più piccole vengono testate più facilmente. Dato che probabilmente ci sono meno input richiesti se la funzione fa solo una cosa, rende più semplice testarlo in modo indipendente.
PersonalNexus,

@PersonalNexus: concordo in qualche modo sul problema dei test, ma IMHO è sciocco testare i dettagli dell'implementazione. Per me un test unitario dovrebbe testare "una cosa" come definito nella mia risposta. Tutto ciò che è più fine è rendere fragili i tuoi test (perché la modifica dei dettagli di implementazione richiederà che i test cambino) e il tuo codice fastidiosamente dettagliato, indiretto, ecc. (Perché aggiungerai indiretto solo per supportare i test).
dsimcha,

6

A questa scala, non importa. L'implementazione a funzione singola è perfettamente ovvia e comprensibile. Tuttavia, l'aggiunta di un po 'più di complessità rende molto interessante dividere l'iterazione dall'azione. Ad esempio, supponiamo che tu abbia bisogno di stampare linee da un set di file specificato da un modello come "* .txt". Quindi separerei l'iterazione dall'azione:

printLines(FileSet files) {
   files.each({ 
       file -> file.eachLine({ 
           line -> printLine(line); 
       })
   })
}

Ora l'iterazione dei file può essere testata separatamente.

Divido le funzioni per semplificare i test o per migliorare la leggibilità. Se l'azione eseguita su ciascuna linea di dati fosse abbastanza complessa da giustificare un commento, allora lo dividerei sicuramente in una funzione separata.


4
Penso che tu abbia centrato il punto. Se abbiamo bisogno di un commento per spiegare una riga, allora è sempre il momento di estrarre un metodo.
Roger CS Wernersson,

5

Estrai i metodi quando senti la necessità di un commento per spiegare le cose.

Scrivi metodi che o fanno solo ciò che dice il loro nome in modo ovvio, o racconta una storia chiamando metodi con un nome intelligente.


3

Anche nel tuo caso semplice, ti mancano i dettagli secondo cui il principio di responsabilità singola ti aiuterebbe a gestire meglio. Ad esempio, cosa succede quando qualcosa va storto con l'apertura del file. Aggiungendo la gestione delle eccezioni per rafforzare i casi limite di accesso ai file si aggiungerebbero 7-10 righe di codice alla funzione.

Dopo aver aperto il file, non sei ancora al sicuro. Potrebbe essere strappato da te (specialmente se si tratta di un file in una rete), potresti esaurire la memoria, di nuovo possono accadere un certo numero di casi limite che vuoi indurire e gonfiare la tua funzione monolitica.

La linea di stampa a linea singola sembra abbastanza innocua. Ma man mano che nuove funzionalità vengono aggiunte alla stampante di file (analisi e formattazione del testo, rendering su diversi tipi di display, ecc.), Aumenterà e ti ringrazierai in seguito.

L'obiettivo di SRP è di permetterti di pensare a una singola attività alla volta. È come spezzare un grosso blocco di testo in più paragrafi in modo che il lettore possa comprendere il punto che stai cercando di superare. Ci vuole un po 'più di tempo per scrivere il codice che aderisce a questi principi. Ma così facendo rendiamo più facile leggere quel codice. Pensa a quanto sarà felice il tuo sé futuro quando dovrà rintracciare un bug nel codice e trovarlo ben suddiviso.


2
Ho votato a favore di questa risposta perché mi piace la logica anche se non sono d'accordo con essa! Fornire una struttura basata su un pensiero complesso su ciò che potrebbe accadere in futuro è controproducente. Fattorizza il codice quando è necessario. Non astrarre le cose fino a quando non è necessario. Il codice moderno è afflitto da persone che cercano di seguire con slancio le regole invece di scrivere semplicemente codice che funzioni e adattarlo con riluttanza . I bravi programmatori sono pigri .
Ancora il

Grazie per il commento. Nota: non sto sostenendo l'astrazione precoce, sto solo dividendo le operazioni logiche in modo che sia più facile farlo in seguito.
Michael Brown,

2

Personalmente preferisco quest'ultimo approccio, perché ti fa risparmiare lavoro in futuro e forza la mentalità "come farlo in modo generico". Nonostante ciò, nel tuo caso la Versione 1 è migliore della Versione 2 - solo perché i problemi risolti dalla Versione 2 sono troppo banali e specifici per il flusso. Penso che dovrebbe essere fatto nel modo seguente (inclusa la correzione di bug proposta da Nawaz):

Funzioni di utilità generiche:

void printLine(ostream& output, const string & line) { 
    output << line << endl; 
} 

void printLines(istream& input, ostream& output) { 
    string line; 
    while (getline(input, line)) {
        printLine(output, line); 
    } 
} 

Funzione specifica del dominio:

void printFile(const string & filePath, ostream& output = std::cout) { 
    fstream file(filePath, ios::in); 
    printLines(file, output); 
} 

Ora printLinese printLinepuò funzionare non solo con fstream, ma con qualsiasi flusso.


2
Non sono d'accordo. Quella printLine()funzione non ha valore. Vedere la mia risposta .
sabato

1
Bene, se manteniamo printLine () allora possiamo aggiungere un decoratore che aggiunge numeri di riga o colorazione della sintassi. Detto questo, non estrarrei questi metodi fino a quando non avessi trovato un motivo.
Roger CS Wernersson,

2

Ogni paradigma , (non solo quello che hai citato) da seguire richiede un po 'di disciplina e, quindi, la riduzione della "libertà di parola" - si traduce in un sovraccarico iniziale (almeno solo perché devi impararlo!). In questo senso, ogni paradigma può diventare dannoso quando il costo di tale overhead non è sovracompensato dal vantaggio che il paradigma è progettato per mantenere con se stesso.

La vera risposta alla domanda, quindi, richiede una buona capacità di "prevedere" il futuro, come:

  • Sono ora obbligato a fare AeB
  • Qual è la probabilità, in un prossimo futuro dovrò fare anche A-e B+(cioè qualcosa che assomiglia ad A e B, ma solo un po 'diverso)?
  • Qual è la probabilità in un futuro più lontano, che A + diventerà A*o A*-?

Se quella probabilità è relativamente alta, sarà una buona possibilità se, mentre penso ad A e B, penso anche alle loro possibili varianti, in modo da isolare le parti comuni in modo da poterle riutilizzare.

Se tale probabilità è molto bassa (qualsiasi variante intorno Aè essenzialmente nient'altro che Ase stessa), studiare come decomporre A molto probabilmente porterà a perdere tempo.

Solo per fare un esempio, lascia che ti racconti questa vera storia:

Durante la mia vita passata come insegnante, ho scoperto che -sulla la maggior parte dei progetti-studente praticamente tutti loro fornire la propria funzione per calcolare la lunghezza di una stringa C .

Dopo alcune indagini ho scoperto che, essendo un problema frequente, tutti gli studenti hanno avuto l'idea di utilizzare una funzione per questo. Dopo aver detto loro che esiste una funzione di libreria per quello ( strlen), molti di loro hanno risposto che dato che il problema era così semplice e banale, era più efficace per loro scrivere la propria funzione (2 righe di codice) che cercare il manuale della libreria C (era il 1984, ho dimenticato il WEB e google!) in rigoroso ordine alfabetico per vedere se c'era una funzione pronta per quello.

Questo è un esempio in cui anche il paradigma "non reinventare la ruota" può diventare dannoso, senza un efficace catalogo delle ruote!


2

Il tuo esempio va bene per essere utilizzato in uno strumento usa e getta che è necessario ieri per svolgere un compito specifico. O come strumento di amministrazione direttamente controllato da un amministratore. Ora rendilo robusto per essere adatto ai tuoi clienti.

Aggiungi la corretta gestione di errori / eccezioni con messaggi significativi. Forse hai bisogno di una verifica dei parametri, comprese le decisioni che devono essere prese, ad es. Come gestire file non esistenti. Aggiungi funzionalità di registrazione, magari con livelli diversi come informazioni e debug. Aggiungi commenti in modo che i colleghi del tuo team sappiano cosa sta succedendo lì. Aggiungi tutte le parti che di solito vengono omesse per brevità e lasciate come esercizio per il lettore quando fornisci esempi di codice. Non dimenticare i test unitari.

La tua piccola e carina funzione abbastanza lineare termina improvvisamente in un disordine complesso che chiede di essere diviso in funzioni separate.


2

L'IMO diventa dannoso quando si spinge così lontano che una funzione non fa quasi nulla se non delegare il lavoro a un'altra funzione, perché questo è un segno che non è più un'astrazione di nulla e la mentalità che porta a tali funzioni è sempre in pericolo di fare cose peggiori ...

Dal post originale

void printLine(const string & line) {
  cout << line << endl;
}

Se sei abbastanza pedante, potresti notare che printLine fa ancora due cose: scrivere la linea da tagliare e aggiungere un carattere "fine linea". Alcune persone potrebbero voler gestirlo creando nuove funzioni:

void printLine(const string & line) {
  reallyPrintLine(line);
  addEndLine();
}

void reallyPrintLine(const string & line) {
  cout << line;
}

void addEndLine() {
  cout << endl;
}

Oh no, ora abbiamo peggiorato ulteriormente il problema! Ora è addirittura OBVIOUS che printLine faccia DUE cose !!! 1! Non fa molta stupidità creare le "soluzioni" più assurde che si possano immaginare solo per sbarazzarsi di quell'inevitabile problema che stampare una linea consiste nel stampare la linea stessa e aggiungere un carattere di fine riga.

void printLine(const string & line) {
  for (int i=0; i<2; i++)
    reallyPrintLine(line, i);
}

void reallyPrintLine(const string & line, int action) {
  cout << (action==0?line:endl);
}

1

Risposta breve ... dipende.

Pensa a questo: cosa succederebbe se, in futuro, non volessi stampare solo sullo standard output, ma su un file.

So cos'è YAGNI, ma sto solo dicendo che potrebbero esserci casi in cui alcune implementazioni sono note per essere necessarie, ma rimandate. Quindi forse l'architetto o qualunque altra cosa sappia che la funzione deve essere in grado di stampare anche su un file, ma non vuole eseguire l'implementazione in questo momento. Quindi crea questa funzione extra, quindi, in futuro, devi solo modificare l'output in un unico posto. Ha senso?

Se tuttavia sei sicuro di aver bisogno solo dell'output nella console, non ha molto senso. Scrivere un "wrapper" cout <<sembra inutile.


1
Ma a rigor di termini, la funzione printLine non è un diverso livello di astrazione rispetto all'iterazione su linee?

@Petr Immagino di sì, motivo per cui ti suggeriscono di separare la funzionalità. Penso che il concetto sia corretto, ma è necessario applicarlo caso per caso.

1

L'intera ragione per cui ci sono libri che dedicano i capitoli alle virtù del "fai una cosa" è che ci sono ancora sviluppatori là fuori che scrivono funzioni lunghe 4 pagine e nidificano 6 livelli condizionali. Se il tuo codice è semplice e chiaro, l'hai fatto bene.


0

Come hanno commentato altri poster, fare una cosa è una questione di scala.

Vorrei anche suggerire che l'idea di One Thing è quella di impedire alle persone di scrivere codice per effetto collaterale. Ciò è esemplificato dall'accoppiamento sequenziale in cui i metodi devono essere chiamati in un ordine particolare per ottenere il risultato "giusto".

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.