Estrazione del metodo rispetto alle ipotesi sottostanti

27

Quando divido grandi metodi (o procedure, o funzioni - questa domanda non è specifica per OOP, ma poiché lavoro nei linguaggi OOP il 99% delle volte, è la terminologia che sono più comodo con) in un sacco di piccoli, mi trovo spesso scontento dei risultati. Diventa più difficile ragionare su questi piccoli metodi rispetto a quando erano solo blocchi di codice nel grande, perché quando li estro, perdo un sacco di presupposti che derivano dal contesto del chiamante.

Più tardi, quando guardo questo codice e vedo i singoli metodi, non so da dove vengono chiamati, e li considero come normali metodi privati che possono essere chiamati da qualsiasi parte del file. Ad esempio, immagina un metodo di inizializzazione (costruttore o altro) diviso 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 vai dall'assunto che l'oggetto è già inizializzato ed è in uno stato valido.

L'unica soluzione che ho visto è la clausola where in Haskell, che consente di definire piccole funzioni che vengono utilizzate solo nella funzione "genitore". Fondamentalmente, sembra così:

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

Ma le altre lingue che uso 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 è - lo incontri, e vedi che questo è un problema? Se lo fai, come lo risolvi tipicamente, in particolare nei linguaggi OOP "mainstream", come Java / C # / C ++?

Modifica dei duplicati: come altri hanno notato, ci sono già delle domande che discutono sui metodi di divisione e sulle piccole domande che sono one-liner. Li ho letti e non discutono il problema delle ipotesi sottostanti che possono essere derivate dal contesto del chiamante (nell'esempio sopra, oggetto inizializzato). Questo è il punto della mia domanda, ed è per questo che la mia domanda è diversa.

Aggiornamento: se hai seguito questa domanda e la discussione in basso, potresti leggere questo articolo di John Carmack in materia , in particolare:

Besides awareness of the actual code being executed, inlining functions also has the benefit of not making it possible to call the function from other places. That sounds ridiculous, but there is a point to it. As a codebase grows over years of use, there will be lots of opportunities to take a shortcut and just call a function that does only the work you think needs to be done. There might be a FullUpdate() function that calls PartialUpdateA(), and PartialUpdateB(), but in some particular case you may realize (or think) that you only need to do PartialUpdateB(), and you are being efficient by avoiding the other work. Lots and lots of bugs stem from this. Most bugs are a result of the execution state not being exactly what you think it is.

    
posta Max Yankov 23.03.2015 - 11:14
fonte

6 risposte

29

For example, imagine an initialisation method split into a series of small ones: in the context of method itself, you clearly know that object's state is still invalid, but in an ordinary private method you probably go from assumption that object is already initialised and is in a valid state. The only solution I've seen for this is...

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

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

  • Produce un valore
  • Causa un effetto

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

Si noti che i metodi sono chiamati in un "contesto". Cos'è questo 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 è chiamato .

Chiamiamo le condizioni richieste prima che un corpo del metodo inizi per il metodo a produrre un risultato corretto le sue precondizioni e chiamiamo le condizioni che saranno prodotte dopo il metodo il corpo restituisce le sue postcondizioni .

Quindi in sostanza quello che stai sottolineando è: quando estraggo un blocco di codice nel suo metodo, sto perdendo informazioni contestuali sulle condizioni preliminari e sulle postcondizioni .

La soluzione a questo problema è rendere esplicite le precondizioni e le postcondizioni nel programma . In C #, ad esempio, potresti utilizzare Debug.Assert o Contratti di codice per esprimere le condizioni preliminari e le postcondizioni.

Ad esempio: lavoravo a un compilatore che si muoveva attraverso diverse "fasi" di compilazione. Prima il codice sarebbe stato lessicato, quindi analizzato, quindi i tipi sarebbero stati risolti, quindi le gerarchie di ereditarietà sarebbero state controllate per i 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 fosse ancora noto per essere aciclico! Quindi, quindi, ogni bit di codice ha chiaramente documentato le sue precondizioni. Vorremmo assert nel metodo che ha verificato la convertibilità di tipo che avevamo già superato il controllo "tipi base acylic", e poi è diventato chiaro al lettore dove si poteva chiamare il metodo e dove non poteva essere chiamato.

Naturalmente ci sono molti modi in cui un buon metodo di progettazione attenua il problema che hai identificato:

  • crea metodi utili per i loro effetti o il loro valore ma non entrambi
  • creare metodi che siano il più "puro" possibile; un metodo "puro" produce un valore che dipende solo dai suoi argomenti e non produce alcun effetto. Questi sono i metodi più facili da ragionare perché il "contesto" di cui hanno bisogno è molto localizzato.
  • ridurre al minimo la quantità di mutazione che si verifica nello stato del programma; le mutazioni sono punti in cui il codice diventa più difficile ragionare su
risposta data 23.03.2015 - 17:09
fonte
13

Lo vedo spesso e sono d'accordo che è un problema. Di solito lo risolvo creando un metodo object : una nuova classe specializzata i cui membri sono le variabili locali dal metodo originale, troppo grande.

La nuova classe tende ad avere un nome come "Esportatore" o "Tabulazione" e viene passata qualsiasi informazione necessaria per eseguire quella particolare attività dal contesto più ampio. Quindi è libero di definire frammenti di codice di helper ancora più piccoli che non rischiano di essere utilizzati per qualsiasi ma tabulazione o esportazione.

    
risposta data 23.03.2015 - 11:22
fonte
6

Molte lingue consentono di nidificare funzioni come Haskell. Java / C # / C ++ sono in realtà dei valori anomali relativi a tale riguardo. Sfortunatamente, sono così popolari che la gente arriva a pensare, "È che è una cattiva idea, altrimenti il mio linguaggio 'mainstream' preferito lo permetterebbe."

Java / C # / C ++ basti pensare che una classe dovrebbe essere l'unico raggruppamento di metodi di cui hai bisogno. Se hai così tanti metodi che non puoi determinare i loro contesti, ci sono due approcci generali da seguire: ordinali per contesto o dividili per contesto.

L'ordinamento per contesto è una raccomandazione fatta in Pulisci codice , in cui l'autore descrive uno schema di "paragrafi TO". In pratica, si tratta di mettere le funzioni di supporto immediatamente dopo la funzione che le chiama, in modo da poterle leggere come i paragrafi di un articolo di giornale, ottenendo ulteriori dettagli quanto più si legge. Penso che nei suoi video li indentri persino.

L'altro approccio è quello di dividere le tue classi. Questo non può essere preso molto lontano, a causa del fastidioso bisogno di istanziare gli oggetti prima di poter richiamare qualsiasi metodo su di essi, e dei problemi inerenti la decisione di quale delle più piccole classi dovrebbe possedere ogni pezzo di dati. Tuttavia, se hai già identificato diversi metodi che si adattano veramente solo in un contesto, probabilmente sono un buon candidato da considerare di inserire nella propria classe. Ad esempio, l'inizializzazione complessa può essere eseguita in un modello di creazione come builder.

    
risposta data 23.03.2015 - 13:28
fonte
4

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

I metodi di estrazione sono buoni, ma in una certa misura. Cerco sempre di pormi queste domande durante l'ispezione o prima di scrivere il codice:

  • Questo codice è utilizzato solo da questa classe / funzione? rimarrà lo stesso in futuro?
  • Se dovrò cambiare alcune delle implementazioni concrete, posso farlo facilmente?
  • Gli altri sviluppatori del mio team possono capire cosa ha fatto in questa funzione?
  • Lo stesso codice è usato da qualche altra parte in questa classe? dovresti evitare la duplicazione in quasi tutti i casi.

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

    
risposta data 23.03.2015 - 11:29
fonte
1

It becomes harder to reason about these small methods than when they were just blocks of code in the big one, because when I extract them, I lose a lot of underlying assumptions that come from the context of the caller.

Non mi ero reso conto di quanto fosse grosso il problema fino a quando non ho adottato un ECS che incoraggiava le funzioni di sistema più grandi e ad anello (con i sistemi che hanno le sole funzioni) e le dipendenze che scorrono verso dati grezzi , non astrazioni.

Che, con mia sorpresa, ha prodotto un codebase molto più facile da ragionare e mantenere rispetto ai codebase in cui ho lavorato in passato, durante il debugging dovevi tracciare tutti i tipi di piccole funzioni, spesso attraverso l'astrazione le funzioni chiamano attraverso interfacce pure che conducono a chissà dove fino a che non vieni tracciato, solo per generare una serie di eventi che portano a luoghi che non avresti mai pensato che il codice dovesse mai condurre.

Diversamente da John Carmack, il mio più grande problema con quei codebase non è stato il rendimento poiché non ho mai avuto quella richiesta di latenza ultra-stretta dei motori di gioco AAA e la maggior parte dei nostri problemi di prestazioni erano legati al throughput. Ovviamente puoi anche iniziare a rendere sempre più difficile ottimizzare gli hotspot quando lavori in spazi più ristretti e ristretti di funzioni e classi teenier e teenier senza che la struttura si intrometta (richiedendoti di fondere tutti questi piccoli pezzi a qualcosa di più grande prima ancora di poter iniziare ad affrontarlo efficacemente.

Eppure il problema più grande per me è stato l'incapacità di ragionare con sicurezza sulla correttezza complessiva del sistema nonostante tutti i test di passaggio. C'era troppo da prendere nel mio cervello e comprendere perché quel tipo di sistema non ti lasciava ragionare senza considerare tutti questi piccoli dettagli e interminabili interazioni tra minuscole funzioni e oggetti che stavano succedendo ovunque. C'erano troppi "che cosa succede se?", Troppe cose che dovevano essere chiamate al momento giusto, troppe domande su cosa accadrebbe se fossero chiamate il momento sbagliato (che inizia a essere sollevato fino al punto di paranoia quando avere un evento che innesca un altro evento che innesca un altro che porta a tutti i tipi di luoghi imprevedibili), ecc.

Ora mi piacciono le mie funzioni da 80 linee in qua e là, a patto che abbiano ancora una responsabilità singolare e chiara e non abbiano 8 livelli di blocchi annidati. Conducono a pensare che ci siano meno cose nel sistema da testare e comprendere, anche se le versioni più piccole 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 sentire che ci sono 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 sull'integrare rendendo tale funzionalità impossibile da chiamare altrove nel file sorgente. C'è qualcosa in questo senso quando hai una pila di chiamate più bassa e funzioni e oggetti più grandi, più carnosi ... un sistema "più piatto", non uno "più profondo".

La semplicità non sempre riduce la complessità a livello di immagine grande se l'opzione è tra una funzione carnosa e 12 più semplici che si richiamano con un complesso grafico di dipendenze. Alla fine della giornata devi spesso ragionare su ciò che accade al di là di una funzione, ragionare su ciò che queste funzioni sommano in definitiva, e può essere più difficile vedere quella grande immagine se devi dedurla dal pezzi di puzzle più piccoli.

Naturalmente un codice di tipo libreria molto generico e ben testato può essere esentato da questa regola, dal momento che un tale codice generico funziona e si comporta bene da solo. Inoltre tende ad essere teeny 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 al di là di una singola funzione o classe, tendo a trovarlo che aiuta ad avere funzioni più carnose per qualsiasi motivo. Trovo molto più facile lavorare con pezzi di puzzle più grandi nel tentativo di capire cosa sta succedendo con il quadro generale.

    
risposta data 13.12.2017 - 05:05
fonte
0

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

Questa soluzione ha lo svantaggio di non catturare l'ambito della funzione che aiuta. Idealmente le tue funzioni sono piccole, quindi spero che questo non porti a troppi parametri. Normalmente risolverai questo definendo nuove strutture o classi per raggruppare i parametri, ma la quantità di piastra di riscaldamento richiesta può essere più lunga dell'helper stesso, e poi sei tornato dove non hai avuto modo di associare la struttura con la funzione.

Hai già menzionato l'altra soluzione: definisci l'helper all'interno della funzione principale. Può essere un idioma un po 'raro in alcune lingue, ma non credo che sarebbe fonte di confusione (a meno che i tuoi coetanei non siano confusi da lambda in generale). Questo funziona solo se è possibile definire facilmente funzioni o oggetti simili a funzioni. Non vorrei provare questo in Java 7, ad esempio, dal momento che una classe anonima richiede l'introduzione di 2 livelli di nidificazione anche per la più piccola "funzione". Questo è il più vicino a una clausola let o where che puoi ottenere; puoi fare riferimento alle variabili locali prima della definizione e l'helper non può essere utilizzato al di fuori di tale ambito.

    
risposta data 23.03.2015 - 12:46
fonte

Leggi altre domande sui tag