Prima di iniziare a lanciare benchmark come coriandoli, rinfreschiamo la memoria su cosa distingue questi due concetti:

  • Classi Astratte: Il peso massimo della programmazione orientata agli oggetti. Possono avere stato, costruttori e metodi sia astratti che concreti.
  • Interfacce: Il contendente leggero. Tradizionalmente senza stato, ma da Java 8, hanno guadagnato muscoli con metodi di default e statici.

Ecco un rapido confronto per far girare gli ingranaggi:


// Classe astratta
abstract class AbstractVehicle {
    protected int wheels;
    public abstract void drive();
    public void honk() {
        System.out.println("Beep beep!");
    }
}

// Interfaccia
interface Vehicle {
    void drive();
    default void honk() {
        System.out.println("Beep beep!");
    }
}

Il Puzzle delle Prestazioni

Ora, potresti pensare, "Certo, sono diversi, ma conta davvero in termini di prestazioni?" Bene, mio curioso programmatore, è esattamente ciò che siamo qui per scoprire. Accendiamo JMH e vediamo cosa succede.

Entra JMH: Il Sussurratore di Benchmark

JMH (Java Microbenchmark Harness) è il nostro fidato compagno per questa indagine sulle prestazioni. È come un microscopio per il tempo di esecuzione del tuo codice, aiutandoci a evitare le insidie dei benchmark ingenui.

Per iniziare con JMH, aggiungi questo al tuo pom.xml:


<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.35</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.35</version>
</dependency>

Impostare il Benchmark

Creiamo un semplice benchmark per confrontare l'invocazione di metodi su classi astratte e interfacce:


import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Fork(value = 1, warmups = 2)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
public class AbstractVsInterfaceBenchmark {

    private AbstractVehicle abstractCar;
    private Vehicle interfaceCar;

    @Setup
    public void setup() {
        abstractCar = new AbstractVehicle() {
            @Override
            public void drive() {
                // Vrooom
            }
        };
        interfaceCar = () -> {
            // Vrooom
        };
    }

    @Benchmark
    public void abstractClassMethod() {
        abstractCar.drive();
    }

    @Benchmark
    public void interfaceMethod() {
        interfaceCar.drive();
    }
}

Eseguire il Benchmark

Ora, eseguiamo questo benchmark e vediamo cosa otteniamo. Ricorda, stiamo misurando il tempo medio di esecuzione in nanosecondi.


# Esegui il benchmark
mvn clean install
java -jar target/benchmarks.jar

I Risultati Sono Arrivati!

Dopo aver eseguito il benchmark (i risultati possono variare in base al tuo hardware specifico e alla JVM), potresti vedere qualcosa del genere:


Benchmark                                   Mode  Cnt   Score   Error  Units
AbstractVsInterfaceBenchmark.abstractClassMethod  avgt    5   2.315 ± 0.052  ns/op
AbstractVsInterfaceBenchmark.interfaceMethod      avgt    5   2.302 ± 0.048  ns/op

Bene, bene, bene... Cosa abbiamo qui? La differenza è... rullo di tamburi... praticamente trascurabile! Entrambi i metodi vengono eseguiti in circa 2,3 nanosecondi. È più veloce di quanto tu possa dire "ottimizzazione prematura"!

Cosa Significa Questo?

Prima di saltare a conclusioni, analizziamo:

  1. Le JVM moderne sono intelligenti: Grazie alla compilazione JIT e ad altre ottimizzazioni, la differenza di prestazioni tra classi astratte e interfacce è diventata minima per semplici invocazioni di metodi.
  2. Non si tratta sempre di velocità: La scelta tra classi astratte e interfacce dovrebbe essere basata principalmente su considerazioni di design, non su micro-ottimizzazioni.
  3. Il contesto è importante: Il nostro benchmark è super semplice. In scenari reali con gerarchie più complesse o chiamate frequenti, potresti vedere risultati leggermente diversi.

Quando Usare Cosa

Quindi, se le prestazioni non sono il fattore decisivo, come scegliere? Ecco una guida rapida:

Scegli le Classi Astratte Quando:

  • Hai bisogno di mantenere lo stato tra i metodi
  • Vuoi fornire un'implementazione di base comune per le sottoclassi
  • Stai progettando classi strettamente correlate

Scegli le Interfacce Quando:

  • Vuoi definire un contratto per classi non correlate
  • Hai bisogno di ereditarietà multipla (ricorda, Java non consente l'ereditarietà multipla di classi)
  • Stai progettando per flessibilità ed estensione futura

La Trama si Infittisce: Metodi di Default

Ma aspetta, c'è di più! Da Java 8, le interfacce possono avere metodi di default. Vediamo come si comportano:


@Benchmark
public void defaultInterfaceMethod() {
    interfaceCar.honk();
}

Eseguendo questo insieme ai nostri benchmark precedenti, potresti notare che i metodi di default sono leggermente più lenti dei metodi delle classi astratte, ma ancora una volta, stiamo parlando di nanosecondi. La differenza è improbabile che impatti significativamente le applicazioni nel mondo reale.

Consigli di Ottimizzazione

Mentre micro-ottimizzare tra classi astratte e interfacce potrebbe non valere il tuo tempo, ecco alcuni consigli generali per mantenere il tuo codice veloce:

  • Mantienilo semplice: Gerarchie di classi eccessivamente complesse possono rallentare le cose. Cerca un equilibrio tra eleganza del design e semplicità.
  • Attenzione ai problemi di diamante: Con i metodi di default nelle interfacce, puoi incorrere in problemi di ambiguità. Sii esplicito quando necessario.
  • Profilare, non indovinare: Misura sempre le prestazioni nel tuo caso d'uso specifico. JMH è ottimo, ma considera anche strumenti come VisualVM per una visione più ampia.

La Conclusione

Alla fine della giornata, la differenza di prestazioni tra classi astratte e interfacce non è il collo di bottiglia del tuo codice. Concentrati su buoni principi di design, leggibilità e manutenibilità. Scegli in base alle tue esigenze architetturali, non su nano-ottimizzazioni.

Ricorda, l'ottimizzazione prematura è la radice di tutti i mali (o almeno di una buona parte). Usa lo strumento giusto per il lavoro e lascia che la JVM si occupi di spremere quegli ultimi nanosecondi.

Cibo per la Mente

Prima di andare, rifletti su questo: Se stiamo dividendo i capelli sui nanosecondi, stiamo risolvendo i problemi giusti? Forse i veri guadagni di prestazioni stanno aspettando nei nostri algoritmi, nelle query del database o nelle chiamate di rete. Tieni a mente il quadro generale e che il tuo codice sia sempre performante!

Buona programmazione, e che le tue astrazioni siano sempre logiche e le tue interfacce nitide!