Estrazione del metodo e ipotesi sottostanti


27

Quando divido grandi metodi (o procedure o funzioni) questa domanda non è specifica per OOP, ma poiché lavoro nelle lingue OOP il 99% delle volte, è la terminologia con cui mi trovo più a mio agio) in molti piccoli , Mi trovo spesso scontento dei risultati. Diventa più difficile ragionare su questi piccoli metodi rispetto a quando erano solo blocchi di codice in quello grande, perché quando li estraggo, perdo molti presupposti di base che provengono dal contesto del chiamante.

Più tardi, quando guardo questo codice e vedo i singoli metodi, non so immediatamente da dove vengono chiamati e penso a loro come normali metodi privati ​​che possono essere chiamati da qualsiasi parte del file. Ad esempio, immagina un metodo di inizializzazione (costruttore o altro) suddiviso in una serie di piccoli: nel contesto del metodo stesso, sai chiaramente che lo stato dell'oggetto non è ancora valido, ma in un normale metodo privato probabilmente passi dal presupposto che l'oggetto è già inizializzato ed è in uno stato valido.

L'unica soluzione che ho visto per questo è la whereclausola in Haskell, che consente di definire piccole funzioni che vengono utilizzate solo nella funzione "padre". Fondamentalmente, assomiglia a questo:

len x y = sqrt $ (sq x) + (sq y)
    where sq a = a * a

Ma altre lingue che utilizzo non hanno nulla di simile: la cosa più vicina è definire un lambda in un ambito locale, che è probabilmente ancora più confuso.

Quindi, la mia domanda è: ti capita di incontrare questo e vedi anche che questo è un problema? Se lo fai, come lo risolvi in ​​genere, in particolare nei linguaggi OOP "mainstream", come Java / C # / C ++?

Modifica dei duplicati: come altri hanno notato, ci sono già domande che parlano di metodi di divisione e piccole domande che sono una linea. Li ho letti e non discutono della questione delle ipotesi sottostanti che possono essere derivate dal contesto del chiamante (nell'esempio sopra, oggetto in fase di inizializzazione). Questo è il punto della mia domanda, ed è per questo che la mia domanda è diversa.

Aggiornamento: se hai seguito questa domanda e discussione sotto, potresti apprezzare questo articolo di John Carmack sull'argomento , in particolare:

Oltre alla consapevolezza dell'attuale codice in esecuzione, le funzioni di allineamento hanno anche il vantaggio di non consentire di chiamare la funzione da altri luoghi. Sembra ridicolo, ma c'è un punto. Man mano che la base di codice cresce nel corso degli anni di utilizzo, ci saranno molte opportunità di prendere una scorciatoia e chiamare semplicemente una funzione che fa solo il lavoro che pensi debba essere fatto. Potrebbe esserci una funzione FullUpdate () che chiama PartialUpdateA () e PartialUpdateB (), ma in alcuni casi particolari potresti rendersi conto (o pensare) che devi solo eseguire PartialUpdateB () e sei efficiente evitando l'altro opera. Molti e molti bug derivano da questo. La maggior parte dei bug è il risultato dello stato di esecuzione che non è esattamente quello che pensi sia.




@gnat la domanda che hai collegato discute se estrarre o meno le funzioni, mentre io non le metto in discussione. Invece, metto in dubbio il metodo più ottimale per farlo.
Max Yankov,

2
@gnat ci sono altre domande correlate collegate da lì, ma nessuna di queste discute sul fatto che questo codice può basarsi su ipotesi specifiche che sono valide solo nel contesto del chiamante.
Max Yankov,

1
@Doval nella mia esperienza, lo fa davvero. Quando ci sono fastidiosi metodi di aiuto in giro come descrivi, se ne occupa l' estrazione di una nuova classe coesa
moscerino

Risposte:


29

Ad esempio, immagina un metodo di inizializzazione suddiviso in una serie di piccoli: nel contesto del metodo stesso, sai chiaramente che lo stato dell'oggetto non è ancora valido, ma in un normale metodo privato probabilmente passi dal presupposto che l'oggetto sia già inizializzato ed è in uno stato valido. L'unica soluzione che ho visto per questo è ...

La tua preoccupazione è fondata. C'è un'altra soluzione.

Fai un passo indietro. Qual è fondamentalmente lo scopo di un metodo? I metodi fanno solo una delle due cose:

  • Produrre un valore
  • Causa un effetto

O, sfortunatamente, entrambi. Cerco di evitare metodi che fanno entrambi, ma molti lo fanno. Diciamo che l'effetto prodotto o il valore prodotto è il "risultato" del metodo.

Si nota che i metodi vengono chiamati in un "contesto". Qual è quel contesto?

  • I valori degli argomenti
  • Lo stato del programma al di fuori del metodo

In sostanza ciò che stai sottolineando è: la correttezza del risultato del metodo dipende dal contesto in cui viene chiamato .

Chiamiamo le condizioni richieste prima che un corpo del metodo inizi affinché il metodo produca un risultato corretto le sue condizioni preliminari e chiamiamo le condizioni che saranno prodotte dopo che il corpo del metodo restituirà le sue postcondizioni .

Quindi essenzialmente ciò che stai sottolineando è: quando estraggo un blocco di codice nel suo metodo, sto perdendo informazioni contestuali su precondizioni e postcondizioni .

La soluzione a questo problema è rendere espliciti i precondizioni e le postcondizioni nel programma . In C #, ad esempio, è possibile utilizzare Debug.Asserto Contratti di codice per esprimere precondizioni e postcondizioni.

Ad esempio: lavoravo su un compilatore che passava attraverso diverse "fasi" della compilazione. Prima il codice verrebbe lasciato in sospeso, quindi analizzato, quindi i tipi sarebbero stati risolti, quindi le gerarchie di ereditarietà sarebbero state controllate per cicli e così via. Ogni parte del codice era molto sensibile al suo contesto; sarebbe disastroso, ad esempio, chiedere "questo tipo è convertibile in quel tipo?" se il grafico dei tipi di base non era ancora noto per essere aciclico! Quindi ogni bit di codice ha chiaramente documentato i suoi presupposti. Vorremmo assertnel metodo che controllava la convertibilità dei tipi che avevamo già superato il controllo "acylic di tipi di base", e quindi divenne chiaro al lettore dove poteva essere chiamato il metodo e dove non poteva essere chiamato.

Naturalmente ci sono molti modi in cui una buona progettazione del metodo mitiga il problema che hai identificato:

  • creare metodi utili per i loro effetti o il loro valore, ma non per entrambi
  • creare metodi il più "puri" possibile; un metodo "puro" produce un valore che dipende solo dai suoi argomenti e non produce alcun effetto. Questi sono i metodi più semplici su cui ragionare perché il "contesto" di cui hanno bisogno è molto localizzato.
  • minimizzare la quantità di mutazione che si verifica nello stato del programma; le mutazioni sono punti in cui il codice diventa più difficile da ragionare

+1 per essere la risposta che spiega il problema in termini di precondizioni / postcondizioni.
Domanda

5
Vorrei aggiungere che spesso è possibile (e una buona idea!) Delegare il controllo delle condizioni pre e post al sistema dei tipi. Se si dispone di una funzione che accetta una stringe la salva nel database, si rischia l'iniezione di SQL se si dimentica di pulirla. Se, d'altra parte, la tua funzione accetta un SanitisedString, e l'unico modo per ottenere un SantisiedStringè chiamando Sanitise, allora hai escluso i bug di iniezione SQL per costruzione. Mi ritrovo sempre più alla ricerca di modi per fare in modo che il compilatore rifiuti codice errato.
Benjamin Hodgson,

+1 Una cosa importante da notare è che vi è un costo per suddividere un metodo grande in blocchi più piccoli: in genere non è utile a meno che le condizioni preliminari e postcondizioni non siano più rilassate di quanto sarebbero state in origine, e si può finire per dover pagare i costi rifacendo i controlli che altrimenti avresti già fatto. Non è un processo di refactoring completamente "gratuito".
Mehrdad,

"Cos'è quel contesto?" solo per chiarire, intendevo principalmente lo stato privato dell'oggetto su cui questo metodo è chiamato. Immagino sia incluso nella seconda categoria.
Max Yankov,

Questa è una risposta eccellente e stimolante, grazie. (Per non dire che le altre risposte sono in qualche modo cattive, ovviamente). Non segnerò la domanda come una risposta ancora, perché mi piace molto la discussione qui (e tende a cessare quando la risposta è contrassegnata come risposta) e ho bisogno di tempo per elaborarla e pensarci.
Max Yankov,

13

Lo vedo spesso e concordo sul fatto che si tratta di un problema. Di solito lo risolvo creando un oggetto metodo : una nuova classe specializzata i cui membri sono le variabili locali del metodo originale troppo grande.

La nuova classe tende ad avere un nome come "Esportatore" o "Tabulazione", e viene passata qualsiasi informazione sia necessaria per svolgere quel particolare compito dal contesto più ampio. Quindi è libero di definire frammenti di codice helper ancora più piccoli che non corrono il rischio di essere utilizzati per altro che tabulare o esportazione.


Mi piace molto questa idea, più ci penso. Può essere una classe privata all'interno della classe pubblica o interna. Non ingombrare il tuo spazio dei nomi con le classi che ti interessano solo localmente, ed è un modo per contrassegnare che si tratta di "aiutanti del costruttore" o "aiutanti di analisi" o altro.
Mike supporta Monica il

Di recente mi sono trovato in una situazione che sarebbe l'ideale per questo dal punto di vista dell'architettura. Ho scritto un renderer software con una classe di rendering e un metodo di rendering pubblico, che aveva MOLTO contesto che utilizzava per chiamare altri metodi. Ho pensato di creare una classe RenderContext separata per questo, tuttavia, mi è sembrato enormemente dispendioso allocare e deallocare questo progetto in ogni frame. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov

6

Molte lingue ti consentono di annidare funzioni come Haskell. Java / C # / C ++ sono in realtà valori anomali relativi a tale riguardo. Purtroppo, sono così popolari che la gente viene a pensare, "E deve essere una cattiva idea, altrimenti il mio 'corrente principale' lingua preferita avrebbe permesso."

Java / C # / C ++ fondamentalmente pensa che una classe dovrebbe essere l'unico raggruppamento di metodi di cui hai mai bisogno. Se hai così tanti metodi che non riesci a determinare i loro contesti, ci sono due approcci generali da adottare: ordinarli per contesto o dividerli per contesto.

L'ordinamento per contesto è una raccomandazione fatta in Clean Code , in cui l'autore descrive uno schema di "paragrafi TO". Questo in pratica mette le tue funzioni di aiuto immediatamente dopo la funzione che le chiama, in modo da poterle leggere come paragrafi in un articolo di giornale, ottenendo maggiori dettagli più leggi. Penso che nei suoi video li indentasse persino.

L'altro approccio è quello di dividere le tue classi. Questo non può essere portato molto lontano, a causa della fastidiosa necessità di creare un'istanza di oggetti prima che tu possa richiamare qualsiasi metodo su di essi e problemi intrinseci nel decidere quale delle diverse classi minuscole dovrebbe possedere ogni pezzo di dati. Tuttavia, se hai già identificato diversi metodi che si adattano davvero solo a un contesto, sono probabilmente un buon candidato da prendere in considerazione per entrare nella loro classe. Ad esempio, l'inizializzazione complessa può essere eseguita in uno schema di creazione come builder.


Funzioni di nidificazione ... non è ciò che le funzioni lambda raggiungono in C # (e Java 8)?
Arturo Torres Sánchez,

Stavo pensando più come una chiusura definita con un nome, come questi esempi di pitone . Le lambda non sono il modo più chiaro per fare qualcosa del genere. Sono più per espressioni brevi come un predicato di filtro.
Karl Bielefeldt,

Questi esempi di Python sono certamente possibili in C #. Ad esempio, il fattoriale . Possono essere più prolissi, ma sono possibili al 100%.
Arturo Torres Sánchez,

2
Nessuno ha detto che non era possibile. L'OP ha anche menzionato l'uso di lambda nella sua domanda. È solo che se si estrae un metodo per motivi di leggibilità, sarebbe bello se fosse più leggibile.
Karl Bielefeldt,

Il tuo primo paragrafo sembra implicare che non è possibile, specialmente con la tua citazione: "Deve essere una cattiva idea, altrimenti il ​​mio linguaggio 'mainstream' preferito lo permetterebbe."
Arturo Torres Sánchez,

4

Penso che la risposta nella maggior parte dei casi sia il contesto. Come sviluppatore che scrive codice, dovresti supporre che il tuo codice verrà modificato in futuro. Una classe potrebbe essere integrata con un'altra classe, potrebbe sostituire il suo algoritmo interno o potrebbe essere suddivisa in più classi per creare l'astrazione. Queste sono cose che gli sviluppatori principianti di solito non prendono in considerazione, causando la necessità di soluzioni alternative disordinate o revisioni complete in seguito.

L'estrazione di metodi è buona, ma in una certa misura. Cerco sempre di farmi queste domande quando ispeziono o prima di scrivere il codice:

  • Questo codice è utilizzato solo da questa classe / funzione? rimarrà lo stesso in futuro?
  • Se avrò bisogno di cambiare parte dell'implementazione concreta, posso farlo facilmente?
  • Gli altri sviluppatori del mio team possono capire cosa viene fatto in questa funzione?
  • Lo stesso codice è utilizzato da qualche altra parte in questa classe? dovresti evitare la duplicazione in quasi tutti i casi.

In ogni caso, pensa sempre alla singola responsabilità. Una classe dovrebbe avere una responsabilità, le sue funzioni dovrebbero servire un unico servizio costante e, se fanno un numero di azioni, quelle azioni dovrebbero avere le loro funzioni, quindi è facile differenziarle o modificarle in seguito.


1

Diventa più difficile ragionare su questi piccoli metodi rispetto a quando erano solo blocchi di codice in quello grande, perché quando li estraggo, perdo molti presupposti di base che provengono dal contesto del chiamante.

Non mi ero reso conto di quanto fosse grave questo problema fino a quando non ho adottato un ECS che incoraggiava funzioni di sistema più grandi e complesse (con i sistemi che sono i soli ad avere funzioni) e dipendenze che fluivano verso dati grezzi , non astrazioni.

Che, con mia sorpresa, ha prodotto una base di codice molto più facile da ragionare e mantenere rispetto alle basi di codice in cui ho lavorato in passato in cui, durante il debug, dovevi rintracciare tutti i tipi di piccole funzioni, spesso attraverso funzioni astratte interfacce pure che portano a chissà dove fino a quando non ci rintracci, solo per generare una cascata di eventi che portano a luoghi che non avresti mai pensato che il codice dovesse mai condurre.

A differenza di John Carmack, il mio più grande problema con quelle codebase non era la prestazione poiché non avevo mai avuto quella richiesta di latenza ultra stretta dei motori di gioco AAA e la maggior parte dei nostri problemi di prestazioni riguardava più il throughput. Ovviamente puoi anche iniziare a rendere sempre più difficile l'ottimizzazione degli hotspot quando lavori in spazi sempre più ristretti di funzioni e classi sempre più giovani senza che tale struttura si frapponga (richiedendo di fondere tutti questi pezzi di adolescenti) a qualcosa di più grande prima che tu possa persino iniziare ad affrontarlo efficacemente).

Tuttavia, il problema più grande per me è stato l'incapacità di ragionare con fiducia sulla correttezza generale del sistema nonostante tutti i test superati. C'era troppo da prendere nel mio cervello e da capire perché quel tipo di sistema non ti lasciava ragionare su di esso senza prendere in considerazione tutti questi piccoli dettagli e interazioni infinite tra minuscole funzioni e oggetti che stavano accadendo ovunque. C'erano troppi "what ifs?", Troppe cose che dovevano essere chiamate al momento giusto, troppe domande su cosa sarebbe successo se fossero state chiamate al momento sbagliato (che iniziano a diventare al punto di paranoia quando tu fai in modo che un evento attivi un altro evento che attivi un altro che ti porta in tutti i tipi di luoghi imprevedibili), ecc.

Ora mi piacciono le mie funzioni da 80 linee del culo grosso qua e là, purché continuino a svolgere una responsabilità singolare e chiara e non abbiano 8 livelli di blocchi nidificati. Portano la sensazione che ci siano meno cose nel sistema da testare e comprendere, anche se le versioni più piccole e ridotte di queste funzioni più grandi erano solo dettagli di implementazione privati ​​che non potevano essere chiamati da nessun altro ... comunque, in qualche modo, tende a pensare che ci siano meno interazioni in corso in tutto il sistema. Mi piace persino una duplicazione del codice molto modesta, purché non sia una logica complessa (diciamo solo 2-3 righe di codice), se significa meno funzioni. Mi piace il ragionamento di Carmack in merito all'allineamento che rende impossibile quella funzionalità da chiamare altrove nel file sorgente. Là'

La semplicità non riduce sempre la complessità a livello di grande immagine se l'opzione è tra una funzione carnosa rispetto a 12 semplicissime che si chiamano a vicenda con un grafico complesso di dipendenze. Alla fine della giornata devi spesso ragionare su ciò che accade al di là di una funzione, devi ragionare su ciò che queste funzioni si sommano per fare alla fine, e può essere più difficile vedere quel quadro generale se devi dedurlo dal i più piccoli pezzi del puzzle.

Naturalmente un codice di tipo di libreria molto generico e ben testato può essere esentato da questa regola, poiché tale codice di uso generale spesso funziona e si regge da solo. Inoltre tende ad essere un po 'più piccolo rispetto al codice un po' più vicino al dominio della tua applicazione (migliaia di righe di codice, non milioni), e così ampiamente applicabile che inizia a diventare parte del vocabolario quotidiano. Ma con qualcosa di più specifico per la tua applicazione in cui gli invarianti a livello di sistema che devi mantenere vanno ben oltre una singola funzione o classe, tendo a trovarlo aiuta ad avere funzioni più scarse per qualsiasi motivo. Trovo molto più facile lavorare con pezzi di puzzle più grandi nel cercare di capire cosa sta succedendo con il quadro generale.


0

Non penso che sia un grosso problema, ma sono d'accordo che sia problematico. Di solito inserisco l'helper immediatamente dopo il suo beneficiario e aggiungo un suffisso "Helper". Quello più lo privatespecificatore di accesso dovrebbe chiarire il suo ruolo. Se c'è qualche invariante che non regge quando viene chiamato l'helper, aggiungo un commento nell'helper.

Questa soluzione ha lo svantaggio sfortunato di non catturare l'ambito della funzione che aiuta. Idealmente le tue funzioni sono piccole, quindi spero che questo non comporti troppi parametri. Normalmente risolveresti questo definendo nuove strutture o classi per raggruppare i parametri, ma la quantità di plateplate richiesta per questo può facilmente essere più lunga dell'helper stesso, e poi sei di nuovo tornato da dove hai iniziato senza un modo ovvio di associarti la struttura con la funzione.

Hai già menzionato l'altra soluzione: definire l'helper all'interno della funzione principale. Potrebbe essere un linguaggio un po 'insolito in alcune lingue, ma non penso che sarebbe confuso (a meno che i tuoi coetanei non siano confusi dai lambda in generale). Funziona solo se puoi definire facilmente funzioni o oggetti simili a funzioni. Non lo proverei in Java 7, ad esempio, poiché una classe anonima richiede l'introduzione di 2 livelli di annidamento anche per la "funzione" più piccola. Questo è il più vicino possibile a una clausola leto where; puoi fare riferimento alle variabili locali prima della definizione e l'helper non può essere utilizzato al di fuori di tale ambito.

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.