"Quanto male" è il codice non correlato nel blocco try-catch-finally?


11

Questa è una domanda correlata D: L' uso della clausola finally per eseguire il lavoro dopo il ritorno in cattivo stile / pericoloso?

Nella Q referenziata, il codice finally è correlato alla struttura utilizzata e alla necessità del pre-recupero. La mia domanda è un po 'diversa e credo che sia pertinente per il vasto pubblico. Il mio esempio particolare è un'app winform C #, ma questo si applicherebbe anche all'utilizzo C ++ / Java.

Sto notando alcuni blocchi try-catch-finally in cui è presente un sacco di codice non correlato alle eccezioni e alla gestione / pulizia delle eccezioni sepolte all'interno del blocco. E ammetterò la mia propensione ad avere blocchi try-catch-finally molto serrati con il codice strettamente correlato all'eccezione e alla gestione. Ecco alcuni esempi di quello che sto vedendo.

I blocchi di prova avranno molte chiamate preliminari e variabili impostate che portano al codice che potrebbe essere lanciato. Le informazioni di registrazione verranno configurate ed eseguite anche nel blocco try.

Infine, i blocchi avranno chiamate di formattazione form / module / control (nonostante l'app stia per terminare, come espresso nel blocco catch), oltre a creare nuovi oggetti come i pannelli.

Circa:

    methodName (...)
    {
        provare
        {
            // Un sacco di codice per il metodo ...
            // codice che potrebbe generare ...
            // Molto più codice per il metodo e un ritorno ...
        }
        cattura (qualcosa)
        {// gestisce l'eccezione}
        finalmente
        {
            // un po 'di pulizia a causa di un'eccezione, chiusura delle cose
            // più codice per le cose che sono state create (ignorando che eventuali eccezioni avrebbero potuto essere lanciate) ...
            // forse crea altri oggetti
        }
    }

Il codice funziona, quindi ha un certo valore. Non è ben incapsulato e la logica è un po 'contorta. Conosco (dolorosamente) i rischi legati allo spostamento del codice e al refactoring, quindi la mia domanda si riduce a voler conoscere l'esperienza degli altri con un codice strutturato in modo simile.

Lo stile cattivo giustifica le modifiche? Qualcuno è stato gravemente bruciato da una situazione simile? Ti andrebbe di condividere i dettagli di quella brutta esperienza? Lasciarlo perché sto reagendo troppo e non è poi così male lo stile? Ottieni i vantaggi di manutenzione di mettere in ordine le cose?


Il tag "C ++" non appartiene qui, poiché C ++ non ha (e non ha bisogno) finally. Tutti i buoni usi sono coperti da RAII / RRID / SBRM (qualunque acronimo ti piaccia).
David Thornley,

2
@DavidThornley: a parte l'esempio in cui viene utilizzata la parola chiave "finally", il resto della domanda si applica perfettamente al C ++. Sono uno sviluppatore C ++ e quella parola chiave non mi ha davvero confuso. E considerando che ho conversazioni simili con il mio team su base semi-regolare, questa domanda è molto rilevante per ciò che facciamo con C ++
DXM,

@DXM: ho problemi a visualizzarlo (e so cosa finallyfa in C #). Qual è l'equivalente in C ++? Quello a cui sto pensando è il codice dopo il catch, e questo vale lo stesso per il codice dopo il C # finally.
David Thornley,

@DavidThornley: il codice in finalmente è il codice che è stato eseguito indipendentemente da cosa .
orlp,

1
@nightcracker, non è proprio corretto. Non verrà eseguito se lo fai Environment.FailFast(); potrebbe non essere eseguito se hai un'eccezione non rilevata. E diventa ancora più complicato se hai un blocco iteratore con finallycui fai l'iterazione manuale.
svick,

Risposte:


10

Ho vissuto una situazione molto simile quando ho dovuto fare i conti con un terribile codice Windows Form scritto da sviluppatori che chiaramente non sapeva cosa stavano facendo.

Prima di tutto, non stai esagerando. Questo è un codice errato. Come hai detto, il blocco di cattura dovrebbe riguardare l'interruzione e la preparazione all'arresto. Non è il momento di creare oggetti (specialmente pannelli). Non riesco nemmeno a spiegare perché questo è negativo.

Detto ciò...

Il mio primo consiglio è: se non è rotto, non toccarlo!

Se il tuo compito è mantenere il codice, devi fare del tuo meglio per non romperlo. So che è doloroso (ci sono stato) ma devi fare del tuo meglio per non interrompere ciò che sta già funzionando.

Il mio secondo consiglio è: se devi aggiungere più funzionalità, prova a mantenere il più possibile la struttura del codice esistente in modo da non rompere il codice.

Esempio: se c'è un'orribile affermazione sul caso che ritieni possa essere sostituita da un'eredità corretta, devi fare attenzione e pensarci due volte prima di decidere di iniziare a spostare le cose.

Troverai sicuramente situazioni in cui un refactoring è l'approccio giusto ma fai attenzione: il codice di refactoring ha maggiori probabilità di introdurre bug . Devi prendere quella decisione dal punto di vista dei proprietari dell'applicazione, non dal punto di vista dello sviluppatore. Quindi devi pensare se lo sforzo (denaro) necessario per risolvere il problema merita o meno un refactoring. Ho visto molte volte uno sviluppatore passare diversi giorni a riparare qualcosa che non è veramente rotto solo perché pensa "il codice è brutto".

Il mio terzo consiglio è: verrai bruciato se rompi il codice, non importa se è colpa tua o no.

Se sei stato assunto per dare la manutenzione, non importa se l'applicazione sta andando in pezzi perché qualcun altro ha preso decisioni sbagliate. Dal punto di vista dell'utente prima funzionava e ora lo hai rotto. L'hai rotto!

Joel mette molto bene nel suo articolo spiegando diversi motivi per cui non dovresti riscrivere il codice legacy.

http://www.joelonsoftware.com/articles/fog0000000069.html

Quindi dovresti sentirti davvero male per quel tipo di codice (e non dovresti mai scrivere nulla del genere) ma mantenerlo è un mostro completamente diverso.

Informazioni sulla mia esperienza: ho dovuto mantenere il codice per circa 1 anno e alla fine sono stato in grado di riscriverlo da zero, ma non tutto in una volta.

Quello che è successo è che il codice era così male che le nuove funzionalità erano impossibili da implementare. L'applicazione esistente presentava seri problemi di prestazioni e usabilità. Alla fine mi è stato chiesto di fare una modifica che mi avrebbe richiesto 3-4 mesi (soprattutto perché lavorare con quel codice mi ha richiesto molto più tempo del solito). Ho pensato di poter riscrivere l'intero pezzo (inclusa l'implementazione della nuova funzionalità desiderata) in circa 5-6 mesi. Ho portato questa proposta alle parti interessate e sono d'accordo a riscriverla (per fortuna per me).

Dopo aver riscritto questo pezzo, hanno capito che avrei potuto fornire cose molto migliori di quelle che avevano già. Quindi sono stato in grado di riscrivere l'intera applicazione.

Il mio approccio era di riscriverlo pezzo per pezzo. Innanzitutto ho sostituito l'intera UI (Windows Form), quindi ho iniziato a sostituire il livello di comunicazione (chiamate al servizio Web) e infine ho sostituito l'intera implementazione del server (era un tipo di applicazione client / server spessa).

Un paio di anni dopo e questa applicazione si è trasformata in un bellissimo strumento chiave utilizzato da tutta l'azienda. Sono sicuro al 100% che non sarebbe mai stato possibile se non avessi riscritto il tutto.

Anche se sono stato in grado di farlo, la parte importante è che le parti interessate lo hanno approvato e sono stato in grado di convincerli che valeva la pena. Quindi, mentre devi mantenere l'applicazione esistente fai del tuo meglio per non rompere nulla e se sei in grado di convincere i proprietari del vantaggio di riscriverlo, prova a farlo come Jack lo Squartatore: a pezzi.


1
+1. Ma l'ultima frase si riferisce a un serial killer. È davvero necessario?
MarkJ,

Mi piace una parte di questo, ma allo stesso tempo lasciare sul posto i progetti rotti e aggiungere cose spesso porta a un design peggiore. È meglio capire come risolverlo e risolverlo, anche se ovviamente in un approccio misurato.
Ricky Clarkson,

@RickyClarkson Sono d'accordo. È davvero difficile sapere quando stai esagerando (un refactoring non è davvero necessario) e quando stai davvero danneggiando l'applicazione aggiungendo modifiche.
Alex,

@RickyClarkson Sono così d'accordo che sono stato anche in grado di riscrivere quel progetto in cui ero coinvolto. Ma per molto tempo ho dovuto aggiungere attentamente roba cercando di causare il minimo danno possibile. A meno che la richiesta non sia quella di risolvere qualcosa che la causa principale è una cattiva decisione di progettazione (che di solito non è il caso), direi che il design dell'applicazione non dovrebbe essere toccato.
Alex,

@RickyClarkson È stata parte dell'esperienza della community a cui volevo attingere. Dichiarare tutto il codice legacy come cattivo è un anti-modello ugualmente cattivo. Tuttavia, ci sono cose in questo codice su cui sto lavorando che in realtà non dovrebbero essere fatte come parte di un blocco finally. Il feedback e la risposta di Alex sono ciò di cui stavo cercando di leggere.

2

Se non è necessario modificare il codice, lasciarlo così com'è. Ma se devi cambiare il codice perché devi correggere qualche bug o modificare alcune funzionalità, dovrai cambiarlo, se ti piace o no. IMHO è spesso più sicuro e meno soggetto a errori, se prima lo si trasforma in pezzi più piccoli e meglio comprensibili, quindi si aggiunge la funzionalità. Quando si hanno a portata di mano strumenti di refactoring automatici, questo può essere fatto in modo relativamente sicuro, con solo un rischio molto piccolo di introdurre nuovi bug. E ti consiglio di procurarti una copia del libro di Michael Feathers

http://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052

che ti darà preziosi suggerimenti su come rendere il codice più testabile, aggiungere test unitari ed evitare di rompere il codice quando si aggiungono nuove funzionalità.

Se lo fai nel modo giusto, si spera che il tuo metodo diventerà abbastanza semplice, il try-catch-finally non conterrà più alcun codice non correlato nei posti sbagliati.


2

Supponiamo di avere sei metodi da chiamare, quattro dei quali dovrebbero essere chiamati solo se il primo viene completato senza errori. Considera due alternative:

try {
     method1();  // throws
     method2();
     method3();
     method4();
     method5();
 } catch(e) {
     // handle error
 }
 method6();

vs

 try {
      method1();  // throws
 }
 catch(e) {
      // handle error
 }
 if(! error ) {
     method2();
     method3();
     method4();
     method5();
 }
 method6();

Nel secondo codice, stai trattando le eccezioni come i codici di ritorno. Concettualmente, questo è identico a:

rc = method1();
if( rc != error ) {
     method2();
     method3();
     method4();
     method5();
 }
 method6();

Se stiamo per racchiudere tutti i metodi che possono generare un'eccezione in un blocco try / catch, che senso ha avere eccezioni in una funzione del linguaggio? Non ci sono vantaggi e c'è più digitazione.

Il motivo per cui abbiamo eccezioni nelle lingue in primo luogo è che nei vecchi tempi dei codici di ritorno, spesso avevamo situazioni sopra le quali avevamo più metodi che potevano restituire errori e ogni errore significava interrompere completamente tutti i metodi. All'inizio della mia carriera, ai vecchi tempi di C, ho visto codice come questo dappertutto:

rc = method1();
if( rc != error ) {
     rc = method2();
     if( rc != error ) {
         rc = method3();
         if( rc != error ) {
             rc = method4();
             if(rc != error ) {
                 method5();
             }
         }
     }
 }
 method6();

Questo era uno schema incredibilmente comune, e uno che le eccezioni erano risolte in modo molto chiaro permettendoti di tornare al primo esempio sopra. Una regola di stile che dice che ogni metodo ha il proprio blocco di eccezioni lo butta completamente fuori dalla finestra. Perché preoccuparsi, allora?

Dovresti sempre cercare di fare in modo che il blocco try ceda l'insieme di codice che è concettualmente un'unità. Dovrebbe racchiudere il codice per il quale se si verifica un errore nell'unità di codice, non ha senso eseguire altri codici nell'unità di codice.

Tornando allo scopo originale delle eccezioni: ricorda che sono state create perché spesso il codice non può facilmente gestire gli errori proprio quando si verificano effettivamente. Il punto delle eccezioni è consentire all'utente di gestire gli errori in modo significativo più avanti nel codice o di aumentare lo stack. La gente se ne dimentica e in molti casi li cattura a livello locale quando starebbe meglio documentando semplicemente che il metodo in questione può gettare questa eccezione. C'è troppo codice al mondo che assomiglia a questo:

void method0() : throws MyNewException 
{
    try {
        method1();  // throws MyOtherException
    }
    catch(e) {
        if(e == MyOtherException)
            throw MyNewException();
    }
    method2();
}

Qui, stai solo aggiungendo livelli e i livelli aggiungono confusione. Basta fare questo:

void method0() : throws MyOtherException
{
    method1();
    method2();
}

Inoltre, la mia sensazione è che se segui quella regola e ti ritrovi con più di un paio a provare / catturare blocchi nel metodo, dovresti chiederti se il metodo stesso è troppo complesso e deve essere suddiviso in più metodi .

Il takeaway: un blocco try dovrebbe contenere l'insieme di codice che dovrebbe essere interrotto se si verifica un errore in qualsiasi punto del blocco. Non importa se questo set di codice è composto da una riga o da mille righe. (Anche se hai un migliaio di righe in un metodo, hai altri problemi.)


Steven - grazie per la risposta ben definita e sono pienamente d'accordo con i tuoi pensieri. Non ho problemi con codici di tipo ragionevolmente correlati o sequenziali che vivono in un singolo blocco try. Se fossi stato in grado di pubblicare un frammento di codice effettivo, avrebbe mostrato 5 - 10 chiamate completamente non correlate prima della prima chiamata da lanciare. Un blocco alla fine è stato molto peggio: molti nuovi thread sono stati scartati e sono state chiamate routine aggiuntive non di pulizia. E del resto, tutte le chiamate in quel blocco finalmente non erano correlate a tutto ciò che avrebbe potuto essere lanciato.

Un grave problema con questo approccio è che non fa distinzione tra i casi in cui method0potrebbe quasi aspettarsi che venga generata un'eccezione e quelli in cui non lo fa. Ad esempio, supponiamo method1che a volte possa essere lanciato un InvalidArgumentException, ma in caso contrario, metterà un oggetto in uno stato temporaneamente non valido che verrà corretto da method2, che dovrebbe riportare sempre l'oggetto in uno stato valido. Se method2 lancia un InvalidArgumentException, il codice che si aspetta di catturare tale eccezione method1lo catturerà, anche se ...
supercat

... lo stato del sistema corrisponderà alle aspettative del codice. Se un'eccezione generata da method1 deve essere gestita in modo diverso da quella generata da method2, come può essere sensibilmente realizzato senza racchiuderli?
supercat

Idealmente, le tue eccezioni sono abbastanza granulari che questa è una situazione rara.
Gort the Robot
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.