Metodi e Funzioni in Java: Guida Completa con Esempi Pratici

Metodi e Funzioni in Java: guida tecnica completa

I metodi e funzioni Java rappresentano costrutti fondamentali dove tutto appartiene a una classe (non esistono funzioni come entità separate). In questa guida, esploreremo in dettaglio come dichiarare e utilizzare i metodi e funzioni Java, approfondendo parametri, tipi di ritorno, overloading, metodi statici vs di istanza, varargs, ricorsione, scope delle variabili, generics e alcune best practice, con esempi orientati a sviluppatori intermedi/avanzati.

metodi e funzioni Java - guida completa con esempi pratici

Dichiarazione di un metodo Java: sintassi e componenti

La forma generale di un metodo in Java (poiché in Java non esistono funzioni indipendenti, tutto si basa su metodi) è la seguente. Nello specifico, essa mostra la struttura completa che ogni metodo deve seguire:

// [modificatori] [tipo-di-ritorno] nomeMetodo([lista-parametri]) throws Eccezioni {
//     corpo
// }

public int somma(int a, int b) {
    return a + b;
}

Modificatori di accesso e altri modificatori dei metodi

I modificatori principali sono public, protected, private e il package-private (assenza di modificatore). Inoltre, altri modificatori utili sono static, final, abstract, synchronized, ecc.

public class EsempioMetodi {

    // Metodo pubblico di istanza
    public void esegui() {
        System.out.println("Esecuzione...");
    }

    // Metodo statico visibile solo nel package
    static void logInterno(String msg) {
        System.out.println("[DEBUG] " + msg);
    }

    // Metodo privato, non accessibile dall'esterno
    private int calcolaPunti(int base, int bonus) {
        return base + bonus;
    }
}

Tipo di ritorno e parola chiave void

Un metodo può restituire qualsiasi tipo valido, inclusi tipi primitivi, classi, array e tipi generici. Pertanto, se non restituisce nulla, si usa void. Di conseguenza, ogni percorso di esecuzione in un metodo non-void deve terminare con return che fornisce un valore compatibile col tipo dichiarato.

public String concatena(String a, String b) {
    if (a == null && b == null) {
        return ""; // restituisco stringa vuota
    }
    if (a == null) {
        return b;
    }
    if (b == null) {
        return a;
    }
    return a + b; // tutti i percorsi restituiscono qualcosa
}

Parametri nei metodi Java: passaggio per valore e immutabilità apparente

Java passa sempre i parametri per valore. Nello specifico, per i tipi primitivi viene copiato il valore; per gli oggetti viene copiata la reference. Di conseguenza, questo significa che:

  • modificare il riferimento dentro il metodo non cambia il riferimento del chiamante;
  • modificare lo stato dell’oggetto referenziato è visibile al chiamante.
class Contatore {
    int valore;
}

public class PassaggioParametri {

    static void incrementaPrimitivo(int x) {
        x++; // modifica solo la copia locale
    }

    static void incrementaOggetto(Contatore c) {
        c.valore++; // modifica lo stato, visibile al chiamante
    }

    static void riassegnaOggetto(Contatore c) {
        c = new Contatore(); // riassegnazione solo locale
        c.valore = 100;
    }

    public static void main(String[] args) {
        int a = 1;
        incrementaPrimitivo(a);
        System.out.println(a); // stampa 1

        Contatore c = new Contatore();
        c.valore = 1;
        incrementaOggetto(c);
        System.out.println(c.valore); // stampa 2

        riassegnaOggetto(c);
        System.out.println(c.valore); // continua a stampare 2
    }
}

Parametri opzionali nei metodi e funzioni Java: pattern comuni

Java non supporta parametri opzionali nativi nei metodi e funzioni Java; invece, si usano overloading, parametri null, builder o pattern come Optional. Pertanto, per API pubbliche è preferibile un builder o overloading mirato.

public class ClientConfig {
    private final int timeoutMs;
    private final boolean cacheAbilitata;

    private ClientConfig(Builder builder) {
        this.timeoutMs = builder.timeoutMs;
        this.cacheAbilitata = builder.cacheAbilitata;
    }

    public static class Builder {
        private int timeoutMs = 1000;       // default
        private boolean cacheAbilitata = true; // default

        public Builder timeoutMs(int timeoutMs) {
            this.timeoutMs = timeoutMs;
            return this;
        }

        public Builder cacheAbilitata(boolean cacheAbilitata) {
            this.cacheAbilitata = cacheAbilitata;
            return this;
        }

        public ClientConfig build() {
            return new ClientConfig(this);
        }
    }
}

Overloading dei metodi Java

L’overloading consente di definire più metodi con lo stesso nome ma firme diverse (lista parametri diversa). Di conseguenza, la scelta del metodo avviene a compile-time, non a runtime.

public class Formatter {

    public String formatta(String s) {
        return "[" + s + "]";
    }

    public String formatta(int n) {
        return String.format("%,d", n);
    }

    public String formatta(String pattern, Object... args) {
        return String.format(pattern, args);
    }

    public static void main(String[] args) {
        Formatter f = new Formatter();

        System.out.println(f.formatta("ciao"));              // usa formatta(String)
        System.out.println(f.formatta(123456));               // usa formatta(int)
        System.out.println(f.formatta("%s: %d", "valore", 1)); // usa formatta(String, Object...)
    }
}

Ambiguità e risoluzione dell’overloading

L’overloading può diventare ambiguo quando intervengono conversioni di tipo (widening, boxing, varargs). Pertanto, è importante evitare firme che rendano poco chiaro quale metodo verrà scelto dal compilatore.

public class OverloadAmbiguo {

    void m(int x) {
        System.out.println("int");
    }

    void m(long x) {
        System.out.println("long");
    }

    void m(Integer x) {
        System.out.println("Integer");
    }

    public static void main(String[] args) {
        OverloadAmbiguo o = new OverloadAmbiguo();

        o.m(1);        // preferisce m(int) (widening vs boxing vs varargs)
        o.m(1L);       // m(long)
        o.m(Integer.valueOf(1)); // m(Integer)

        // Attenzione alle ambiguità con null:
        // o.m(null); // ERRORE: chiamata ambigua tra m(Integer) e eventuali overload di riferimento
    }
}

Metodi e funzioni Java: statici vs di istanza

I metodi statici appartengono alla classe; invece, i metodi di istanza appartengono all’oggetto. Distinguere tra questi due tipi è fondamentale nella progettazione dei metodi e funzioni Java: di conseguenza, i metodi statici non possono accedere direttamente a campi o metodi di istanza, a meno di avere un riferimento valido a un oggetto.

public class Calcolatrice {

    private final double fattore;

    public Calcolatrice(double fattore) {
        this.fattore = fattore;
    }

    // Metodo di istanza: usa lo stato interno
    public double moltiplica(double x) {
        return x * fattore;
    }

    // Metodo statico: non dipende dallo stato
    public static double somma(double a, double b) {
        return a + b;
    }

    public static void main(String[] args) {
        double s = Calcolatrice.somma(2, 3); // invocazione statica

        Calcolatrice c = new Calcolatrice(10);
        double m = c.moltiplica(5);         // invocazione di istanza

        System.out.println("somma=" + s + ", moltiplica=" + m);
    }
}

Quando usare metodi statici

  • utility pure e senza stato (ad esempio java.util.Objects, java.lang.Math);
  • fabbriche statiche (of, valueOf, ecc.);
  • metodi di convenienza che delegano a istanze singleton (con attenzione a testabilità e accoppiamento).

Varargs nei metodi Java (parametri variabili)

I parametri varargs permettono di passare un numero variabile di argomenti dello stesso tipo, rappresentati come un array. Nello specifico, la sintassi usa ... e deve apparire solo sull’ultimo parametro.

public class Logger {

    public static void log(String livello, String messaggio, Object... dettagli) {
        StringBuilder sb = new StringBuilder();
        sb.append("[").append(livello).append("] ").append(messaggio);

        if (dettagli != null && dettagli.length > 0) {
            sb.append(" | ");
            for (int i = 0; i < dettagli.length; i++) {
                if (i > 0) sb.append(", ");
                sb.append(dettagli[i]);
            }
        }

        System.out.println(sb);
    }

    public static void main(String[] args) {
        Logger.log("INFO", "Avvio applicazione");
        Logger.log("DEBUG", "Parametri", "utente", "admin", 42);
    }
}

Varargs e performance

I varargs creano sempre un array, anche se si passa un solo argomento. Pertanto, per metodi ad alta frequenza di invocazione è possibile offrire overload espliciti per i casi comuni (0, 1, 2 parametri) e usare varargs solo come fallback.

public class MetricSender {

    public void invia(String nome) {
        inviaInterno(nome); // nessun array creato
    }

    public void invia(String nome, double valore) {
        inviaInterno(nome, valore);
    }

    public void invia(String nome, double valore, double valore2) {
        inviaInterno(nome, valore, valore2);
    }

    public void invia(String nome, double... valori) {
        inviaInterno(nome, valori); // fallback generico
    }

    private void inviaInterno(String nome, double... valori) {
        // implementazione
    }
}

Ricorsione nei metodi Java: pattern e accorgimenti

Un metodo è ricorsivo quando chiama se stesso (direttamente o indirettamente). Inoltre, è utile per problemi definibili in termini di sotto-problemi simili (alberi, grafi, combinatoria). In primo luogo, è essenziale definire un caso base che termini la ricorsione.

Esempio: fattoriale semplice e con memoizzazione

public class Fattoriale {

    // Versione ricorsiva semplice
    public static long fattoriale(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("n deve essere >= 0");
        }
        if (n == 0 || n == 1) {
            return 1L; // caso base
        }
        return n * fattoriale(n - 1); // passo ricorsivo
    }

    // Versione con memoizzazione (cache) per ridurre chiamate ripetute
    private static final Map<Integer, Long> CACHE = new HashMap<>();

    static {
        CACHE.put(0, 1L);
        CACHE.put(1, 1L);
    }

    public static long fattorialeMemo(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("n deve essere >= 0");
        }
        Long cached = CACHE.get(n);
        if (cached != null) {
            return cached;
        }
        long valore = n * fattorialeMemo(n - 1);
        CACHE.put(n, valore);
        return valore;
    }
}

Ricorsione su strutture dati ad albero

class Nodo {
    final String valore;
    final List<Nodo> figli;

    Nodo(String valore, List<Nodo> figli) {
        this.valore = valore;
        this.figli = figli;
    }
}

public class VisitatoreAlbero {

    public static void stampaPreOrder(Nodo nodo) {
        if (nodo == null) {
            return; // caso base
        }
        System.out.println(nodo.valore);
        if (nodo.figli != null) {
            for (Nodo figlio : nodo.figli) {
                stampaPreOrder(figlio); // passo ricorsivo
            }
        }
    }
}

Scope delle variabili nei metodi Java e durata di vita

Lo scope di una variabile definisce dove è accessibile nel codice. In particolare, in Java esistono variabili di istanza, di classe (statiche), locali al metodo e variabili dei parametri. Nello specifico, lo scope è delimitato dai blocchi {}.

public class ScopeEsempio {

    // Variabile di istanza
    private int stato;

    // Variabile di classe (statica)
    private static int contatoreIstanza = 0;

    public ScopeEsempio() {
        contatoreIstanza++;
    }

    public void metodo(int parametro) { // parametro ha scope all'interno del metodo
        int locale = 0;                 // variabile locale allo scope del metodo

        if (parametro > 0) {
            int dentroIf = parametro;   // visibile solo nel blocco if
            locale += dentroIf;
        }

        // dentroIf; // ERRORE: fuori dallo scope

        for (int i = 0; i < 10; i++) { // i visibile solo nel for
            stato += i;
        }

        // i; // ERRORE
    }
}

Effectively final e lambda

Le lambda e le classi anonime possono catturare solo variabili che sono final o effectively final (assegnate una sola volta). Di conseguenza, questo evita race condition e rende il codice più prevedibile.

public void processa(List<String> valori) {
    int offset = 10; // effectively final: mai riassegnata

    valori.stream()
          .map(s -> s + offset) // ok: offset è effectively final
          .forEach(System.out::println);

    // offset++; // se decommentata, offset non sarebbe più effectively final e la lambda non compila
}

Metodi con Generics

I metodi generici permettono di definire parametri di tipo indipendenti dai parametri di tipo della classe che li contiene. Nello specifico, la sintassi introduce i parametri di tipo tra <> subito prima del tipo di ritorno.

public class Utils {

    // Metodo generico statico
    public static <T> T primoNonNull(T a, T b) {
        if (a != null) return a;
        if (b != null) return b;
        throw new IllegalArgumentException("Entrambi null");
    }

    // Metodo generico con limitazione superiore (upper bound)
    public static <T extends Number> double somma(List<T> numeri) {
        double somma = 0.0;
        for (T n : numeri) {
            somma += n.doubleValue();
        }
        return somma;
    }

    // Metodo generico con wildcard
    public static void stampaTutti(List<?> lista) {
        for (Object o : lista) {
            System.out.println(o);
        }
    }
}

PECS: Producer Extends, Consumer Super

Per le wildcard, la regola pratica è PECS (Producer Extends, Consumer Super):

  • ? extends T per sorgenti di dati (producer) da cui si legge;
  • ? super T per destinazioni (consumer) su cui si scrive.
public class CollezioniUtils {

    // Copia da sorgente a destinazione rispettando PECS
    public static <T> void copia(List<? extends T> sorgente,
                                 List<? super T> destinazione) {
        for (T elemento : sorgente) {
            destinazione.add(elemento);
        }
    }
}

Best practice nella progettazione dei metodi e funzioni Java

1. Un solo livello di astrazione per metodo

Ogni metodo dovrebbe operare a un singolo livello di astrazione. Di conseguenza, non mescolare logica di alto livello (casi d’uso, orchestrazione) con dettagli di basso livello (parsing, formattazioni stringhe, I/O) nello stesso metodo.

// Cattivo: fa troppe cose
public void processaOrdine(Ordine ordine) {
    // 1) validazione
    // 2) calcolo sconti
    // 3) stampa log dettagliato
    // 4) persistenza su DB
    // 5) invio email
}

// Meglio: delega a metodi più piccoli
public void processaOrdine(Ordine ordine) {
    valida(ordine);
    calcolaSconti(ordine);
    salvaSuDatabase(ordine);
    inviaNotifiche(ordine);
}

2. Parametri pochi, tipizzati e con significato chiaro

  • Evita metodi con più di 3–4 parametri primitivi;
  • raggruppa parametri correlati in oggetti di richiesta (Request, Config, ecc.);
  • usa tipi specifici (es. Duration invece di long per un timeout).
// Peggio
public void configura(String host, int porta, int timeoutMs, boolean ssl) { ... }

// Meglio
public void configura(EndpointConfig config) { ... }

3. Evita effetti collaterali nascosti

I metodi dovrebbero avere effetti collaterali chiari e documentati. Pertanto, evita che un metodo apparentemente di lettura (es. getX()) modifichi lo stato interno o globale. Inoltre, usa nomi che indichino l’effetto (update, save, delete…).

4. Gestione robusta delle eccezioni

  • Non catturare eccezioni generali se non strettamente necessario (Exception, Throwable);
  • rispetta il contratto del metodo: documenta le eccezioni verificate (checked) con Javadoc;
  • usa eccezioni specifiche per i domini applicativi (InvalidOrderException, ecc.).
/**
 * Carica un utente per ID.
 *
 * @param id identificativo univoco utente
 * @return utente trovato
 * @throws UserNotFoundException se l'utente non esiste
 */
public Utente caricaUtente(UUID id) throws UserNotFoundException {
    return repository.findById(id)
                     .orElseThrow(() -> new UserNotFoundException(id));
}

5. Metodi piccoli, testabili e riusabili

Preferisci metodi piccoli, con dipendenze esterne iniettate (ad esempio via costruttore). Inoltre, evita di nascondere logica complessa all’interno di metodi privati statici difficili da testare in isolamento: in altre parole, spesso conviene estrarre la logica in classi dedicate.

Conclusioni

La progettazione dei metodi e funzioni Java è alla base di un buon design in Java. Di conseguenza, comprendere differenze tra metodi statici e di istanza, gestire correttamente parametri, overloading, generics, ricorsione e scope delle variabili permette di scrivere API più chiare, manutenibili e performanti. In sintesi, applicando le best practice descritte, il codice diventa più leggibile, testabile e robusto nel tempo. Per ulteriori dettagli, consulta la documentazione ufficiale Java.

Guide Java Correlate

📚 Continua a Imparare Java

Commenti

4 risposte a “Metodi e Funzioni in Java: Guida Completa con Esempi Pratici”

  1. […] In questa guida tecnica avanzata analizziamo in profondità i cicli in Java: for classico, for-each, while e do-while, insieme a break, continue, cicli annidati ed esempi pratici con array e collezioni. Per i concetti di base su tipi e identificatori puoi fare riferimento alla guida sulle variabili in Java, mentre per le strutture indicizzate consulta la guida sugli array in Java e per la logica riutilizzabile la guida su metodi e funzioni in Java. […]

  2. […] Metodi e Funzioni in Java […]

Lascia un commento