OOP in Java e C++: un confronto

OOP in Java e C++: un confronto

Java e C++ sono due linguaggi profondamente orientati agli oggetti, ma incarnano filosofie diverse riguardo a come la programmazione a oggetti debba essere realizzata. Java nasce con l'obiettivo di offrire un modello a oggetti uniforme, gestito e relativamente protetto da una macchina virtuale; C++ estende il C con un sistema di classi potente e a costo zero, lasciando al programmatore il pieno controllo della memoria e del ciclo di vita degli oggetti. In questo articolo confrontiamo i due linguaggi sui pilastri della OOP: definizione delle classi, incapsulamento, ereditarietà, polimorfismo, astrazione, gestione della memoria, generics e templates, fino agli aspetti più sottili come la copia degli oggetti e l'overloading degli operatori.

Definizione di una classe

La sintassi di base per dichiarare una classe è simile nei due linguaggi, ma le differenze emergono già nei dettagli. In C++ la dichiarazione di una classe termina con un punto e virgola, gli specificatori di accesso si applicano a blocchi di membri, e tipicamente si separa l'interfaccia (header) dall'implementazione (file sorgente). In Java tutto vive in un unico file, ogni membro porta esplicitamente il proprio modificatore di accesso e la classe pubblica deve corrispondere al nome del file.

// C++: dichiarazione tipica in un header
class Account {
public:
    Account(double initialBalance);
    void deposit(double amount);
    double getBalance() const;

private:
    double balance; // stato interno, non accessibile dall'esterno
};
// Java: la classe pubblica vive in Account.java
public class Account {
    private double balance; // ogni membro ha il proprio modificatore

    public Account(double initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        this.balance += amount;
    }

    public double getBalance() {
        return this.balance;
    }
}

In C++ l'implementazione dei metodi viene di norma collocata in un file separato, usando l'operatore di risoluzione di ambito per legarli alla classe:

// C++: implementazione in Account.cpp
#include "Account.h"

Account::Account(double initialBalance)
    : balance(initialBalance) // lista di inizializzazione dei membri
{
}

void Account::deposit(double amount) {
    balance += amount;
}

double Account::getBalance() const {
    return balance;
}

Si noti la lista di inizializzazione nel costruttore C++: i membri vengono inizializzati prima dell'esecuzione del corpo del costruttore, cosa essenziale per membri costanti, riferimenti o oggetti privi di costruttore di default. In Java questo concetto non esiste nella stessa forma: i campi vengono inizializzati con assegnamenti nel corpo del costruttore o in fase di dichiarazione.

Creazione degli oggetti e gestione della memoria

È qui che le due filosofie divergono in modo più netto. In Java ogni oggetto vive sull'heap e si crea sempre con new; le variabili sono riferimenti gestiti, e un garbage collector libera automaticamente la memoria quando l'oggetto non è più raggiungibile.

// Java: ogni oggetto è sull'heap, la memoria è gestita dalla JVM
Account account = new Account(100.0);
account.deposit(50.0);
// nessuna deallocazione esplicita: ci pensa il garbage collector

In C++ un oggetto può vivere sullo stack (durata automatica) oppure sull'heap (durata dinamica). La scelta ha conseguenze profonde su prestazioni e responsabilità.

// C++: oggetto sullo stack, distrutto automaticamente a fine scope
Account local(100.0);
local.deposit(50.0);

// C++: oggetto sull'heap con gestione manuale (sconsigliato)
Account* raw = new Account(100.0);
raw->deposit(50.0);
delete raw; // obbligatorio per evitare memory leak

Il C++ moderno evita la gestione manuale tramite gli smart pointer, che applicano il principio RAII: la risorsa viene liberata automaticamente quando lo smart pointer esce dallo scope.

// C++ moderno: smart pointer, deallocazione automatica e sicura
#include <memory>

auto owned = std::make_unique<Account>(100.0);
owned->deposit(50.0);
// nessun delete: la memoria viene liberata a fine scope

La differenza concettuale è fondamentale: Java affida la liberazione della memoria a un processo non deterministico (il GC interviene quando lo decide la JVM), mentre il RAII del C++ è deterministico, la distruzione avviene in un momento preciso e prevedibile. Questo rende il C++ più adatto a gestire risorse diverse dalla memoria, come file, socket o lock, perché il loro rilascio è legato in modo affidabile alla fine dello scope.

Costruttori e distruttori

Entrambi i linguaggi offrono i costruttori, ma solo il C++ possiede un distruttore deterministico, invocato automaticamente quando l'oggetto viene distrutto. Java non ha distruttori: il vecchio metodo finalize() è deprecato e inaffidabile, e il rilascio delle risorse si gestisce con costrutti espliciti.

// C++: il distruttore garantisce il rilascio delle risorse
class FileWriter {
public:
    FileWriter(const std::string& path) {
        handle = std::fopen(path.c_str(), "w"); // acquisizione risorsa
    }

    ~FileWriter() {
        if (handle) {
            std::fclose(handle); // rilascio garantito a fine scope
        }
    }

private:
    std::FILE* handle;
};

In Java il rilascio deterministico delle risorse si ottiene con l'interfaccia AutoCloseable e il costrutto try-with-resources, che chiude la risorsa al termine del blocco.

// Java: try-with-resources sostituisce il distruttore
public class FileWriterWrapper implements AutoCloseable {
    private final java.io.Writer writer;

    public FileWriterWrapper(String path) throws java.io.IOException {
        this.writer = new java.io.FileWriter(path); // acquisizione risorsa
    }

    @Override
    public void close() throws java.io.IOException {
        this.writer.close(); // chiamato automaticamente dal try
    }
}

// utilizzo: la risorsa viene chiusa al termine del blocco
try (FileWriterWrapper fw = new FileWriterWrapper("output.txt")) {
    // operazioni di scrittura
}

Incapsulamento e modificatori di accesso

L'incapsulamento è presente in entrambi i linguaggi, con alcune differenze. C++ offre tre livelli: public, protected e private, con private come impostazione predefinita per class (e public per struct). Java aggiunge un quarto livello, l'accesso package-private (nessun modificatore), e applica i modificatori membro per membro.

// Java: quattro livelli di accesso
public class Visibility {
    public int publicField;       // accessibile ovunque
    protected int protectedField; // classe, sottoclassi e stesso package
    int packageField;             // solo stesso package (default)
    private int privateField;     // solo dentro la classe
}

Una differenza notevole riguarda protected: in Java un membro protetto è accessibile anche dalle classi dello stesso package, mentre in C++ è limitato alla classe e alle sue derivate. C++ inoltre dispone del meccanismo friend, che concede a funzioni o classi specifiche l'accesso ai membri privati, un costrutto che Java non possiede.

// C++: friend concede accesso privilegiato ai membri privati
class Account {
public:
    explicit Account(double balance) : balance(balance) {}

private:
    double balance;
    friend class Auditor; // Auditor può leggere i membri privati
};

class Auditor {
public:
    double inspect(const Account& account) {
        return account.balance; // accesso consentito da friend
    }
};

Ereditarietà

La differenza più strutturale tra i due linguaggi sul fronte dell'ereditarietà è che C++ supporta l'ereditarietà multipla di classi, mentre Java la limita a una sola classe base, consentendo però l'implementazione di interfacce multiple. C++ richiede inoltre di specificare la modalità di ereditarietà (pubblica, protetta o privata), che influenza la visibilità dei membri ereditati.

// C++: ereditarietà pubblica singola
class Animal {
public:
    explicit Animal(const std::string& name) : name(name) {}
    virtual void speak() const = 0; // metodo virtuale puro
protected:
    std::string name;
};

class Dog : public Animal {
public:
    explicit Dog(const std::string& name) : Animal(name) {}
    void speak() const override {
        std::cout << name << " says woof\n";
    }
};
// Java: ereditarietà singola con extends
public abstract class Animal {
    protected final String name;

    protected Animal(String name) {
        this.name = name;
    }

    public abstract void speak(); // metodo astratto
}

public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void speak() {
        System.out.println(this.name + " says woof");
    }
}

L'ereditarietà multipla del C++ è potente ma introduce il celebre problema del diamante, quando una classe eredita da due basi che a loro volta derivano da una base comune. C++ lo risolve con l'ereditarietà virtuale, che garantisce una sola istanza condivisa della base comune.

// C++: ereditarietà virtuale per risolvere il problema del diamante
class Device {
public:
    int serialNumber;
};

class Printer : virtual public Device {};
class Scanner : virtual public Device {};

// una sola copia di Device grazie a virtual
class MultiFunction : public Printer, public Scanner {};

Java evita il problema alla radice non ammettendo l'ereditarietà multipla di classi, ma permettendo a una classe di implementare più interfacce. Dall'introduzione dei metodi default nelle interfacce, anche Java può ereditare comportamento da più fonti, risolvendo eventuali conflitti con regole esplicite.

Polimorfismo e metodi virtuali

Il polimorfismo a runtime è centrale in entrambi i linguaggi, ma il loro comportamento predefinito è opposto. In Java tutti i metodi di istanza sono virtuali per default: una chiamata viene risolta in base al tipo dinamico dell'oggetto, a meno che il metodo non sia final, static o private. In C++ un metodo è risolto staticamente a meno che non sia esplicitamente dichiarato virtual.

// C++: senza virtual la chiamata è risolta in modo statico
class Base {
public:
    virtual void render() const {   // virtual abilita il dispatch dinamico
        std::cout << "Base render\n";
    }
    virtual ~Base() = default;      // distruttore virtuale obbligatorio
};

class Derived : public Base {
public:
    void render() const override {  // override segnala l'intento
        std::cout << "Derived render\n";
    }
};

void draw(const Base& object) {
    object.render(); // chiama Derived::render se l'oggetto è un Derived
}

Un punto cruciale del C++: quando si elimina un oggetto attraverso un puntatore alla classe base, il distruttore della base deve essere virtuale, altrimenti il distruttore della classe derivata non viene invocato e si verifica un comportamento indefinito. Java non ha questo problema perché non possiede distruttori e il dispatch è sempre dinamico.

// Java: il polimorfismo è il comportamento predefinito
public class Base {
    public void render() {
        System.out.println("Base render");
    }
}

public class Derived extends Base {
    @Override
    public void render() {
        System.out.println("Derived render");
    }
}

// la chiamata si risolve sul tipo dinamico
Base object = new Derived();
object.render(); // stampa "Derived render"

Da notare anche il fenomeno dello slicing, esclusivo del C++: se un oggetto derivato viene copiato in una variabile della classe base passata per valore, la parte derivata viene "tagliata via". Java non soffre di slicing perché manipola sempre riferimenti, mai oggetti per valore.

// C++: il passaggio per valore provoca object slicing
void process(Base value) {  // copia per valore: perde la parte derivata
    value.render();         // chiama sempre Base::render
}

Derived d;
process(d); // slicing: viene copiata solo la sottoparte Base

Classi astratte e interfacce

Entrambi i linguaggi supportano l'astrazione, ma con strumenti differenti. C++ non possiede un costrutto dedicato per le interfacce: una classe astratta pura, con soli metodi virtuali puri, ne svolge il ruolo. Java distingue formalmente tra abstract class e interface.

// C++: una interfaccia è una classe con soli metodi virtuali puri
class Drawable {
public:
    virtual void draw() const = 0; // metodo virtuale puro
    virtual ~Drawable() = default;
};
// Java: interfaccia con metodo astratto e metodo default
public interface Drawable {
    void draw(); // implicitamente public e abstract

    default void describe() { // implementazione di default
        System.out.println("A drawable object");
    }
}

Le interfacce Java possono dichiarare costanti, metodi statici e metodi default, e una classe può implementarne quante ne servono, ottenendo una forma controllata di ereditarietà multipla di comportamento. In C++ lo stesso obiettivo si raggiunge combinando più classi base astratte.

Generics e templates

Java offre i generics, mentre C++ propone i templates: due meccanismi che risolvono problemi simili con tecniche radicalmente diverse. I generics Java operano per type erasure: i parametri di tipo esistono a tempo di compilazione per il controllo dei tipi, ma vengono rimossi nel bytecode, dove si lavora con il tipo Object e cast impliciti.

// Java: generics basati su type erasure
public class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return this.value;
    }
}

// uso con vincolo sul parametro di tipo
public <T extends Comparable<T>> T max(T first, T second) {
    return first.compareTo(second) >= 0 ? first : second;
}

I template C++ funzionano invece per instanziazione a tempo di compilazione: il compilatore genera codice specializzato per ogni tipo concreto usato. Non c'è erasure, non c'è boxing, e il codice generato è efficiente quanto una versione scritta a mano per quel tipo.

// C++: template instanziati a tempo di compilazione
template <typename T>
class Box {
public:
    void set(const T& value) {
        this->value = value;
    }

    const T& get() const {
        return value;
    }

private:
    T value;
};

// funzione template: il compilatore genera codice per ogni tipo
template <typename T>
T maxValue(const T& first, const T& second) {
    return first >= second ? first : second;
}

Le conseguenze pratiche sono importanti. I generics Java non possono essere usati con tipi primitivi senza autoboxing, non consentono di creare array del tipo parametrico né di conoscere il tipo a runtime. I template C++ sono molto più espressivi, supportano la metaprogrammazione, la specializzazione e i parametri non di tipo, ma producono tempi di compilazione più lunghi, messaggi di errore più complessi e maggiore dimensione del binario per via della duplicazione del codice.

Overloading degli operatori

Una capacità presente in C++ ma assente in Java è l'overloading degli operatori, che permette di ridefinire il significato di operatori come +, == o [] per i propri tipi. Questo consente di scrivere tipi numerici personalizzati che si comportano come tipi nativi.

// C++: overloading degli operatori per un tipo Vector2D
class Vector2D {
public:
    Vector2D(double x, double y) : x(x), y(y) {}

    Vector2D operator+(const Vector2D& other) const {
        return Vector2D(x + other.x, y + other.y);
    }

    bool operator==(const Vector2D& other) const {
        return x == other.x && y == other.y;
    }

private:
    double x;
    double y;
};

Vector2D sum = Vector2D(1, 2) + Vector2D(3, 4); // sintassi naturale

Java ha deliberatamente escluso questa funzionalità per evitare ambiguità e codice difficile da leggere; l'unica eccezione è l'operatore + sovraccaricato internamente per la concatenazione delle stringhe. In Java le stesse operazioni si esprimono con metodi espliciti, come add o equals.

// Java: nessun overloading, si usano metodi espliciti
public final class Vector2D {
    private final double x;
    private final double y;

    public Vector2D(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public Vector2D add(Vector2D other) {
        return new Vector2D(this.x + other.x, this.y + other.y);
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Vector2D other)) {
            return false;
        }
        return this.x == other.x && this.y == other.y;
    }
}

Vector2D sum = new Vector2D(1, 2).add(new Vector2D(3, 4));

Membri statici

Il concetto di membro statico, appartenente alla classe e non all'istanza, esiste in entrambi i linguaggi con sintassi simile. Una differenza riguarda l'inizializzazione: in C++ i membri statici non costanti vanno definiti in un file sorgente, mentre in Java l'inizializzazione avviene direttamente o tramite blocchi statici.

// C++: dichiarazione e definizione separata del membro statico
class Counter {
public:
    static int instanceCount; // dichiarazione nell'header
    Counter() { ++instanceCount; }
};

// definizione obbligatoria in un file sorgente
int Counter::instanceCount = 0;
// Java: il membro statico si inizializza direttamente
public class Counter {
    public static int instanceCount = 0; // inizializzazione inline

    public Counter() {
        instanceCount++;
    }
}

Copia degli oggetti

La semantica della copia è uno degli aspetti che meglio illustra la distanza tra i due modelli. In Java l'assegnamento di una variabile oggetto copia solo il riferimento: entrambe le variabili puntano allo stesso oggetto. Per ottenere una copia reale occorre clonare esplicitamente o usare un costruttore di copia scritto a mano.

// Java: l'assegnamento copia il riferimento, non l'oggetto
Account a = new Account(100.0);
Account b = a;       // a e b puntano allo stesso oggetto
b.deposit(50.0);     // modifica visibile anche tramite a

In C++ l'assegnamento di un oggetto per valore ne crea una copia indipendente, invocando il costruttore di copia o l'operatore di assegnamento. Per i tipi che gestiscono risorse è essenziale rispettare la cosiddetta "regola del tre" (o "del cinque" nel C++ moderno, che include la semantica di spostamento).

// C++: la copia produce un oggetto indipendente
class Buffer {
public:
    explicit Buffer(std::size_t size)
        : size(size), data(new int[size]) {}

    // costruttore di copia: copia profonda dei dati
    Buffer(const Buffer& other)
        : size(other.size), data(new int[other.size]) {
        std::copy(other.data, other.data + size, data);
    }

    // operatore di assegnamento per copia
    Buffer& operator=(const Buffer& other) {
        if (this != &other) {        // protezione da auto-assegnamento
            delete[] data;
            size = other.size;
            data = new int[size];
            std::copy(other.data, other.data + size, data);
        }
        return *this;
    }

    ~Buffer() {
        delete[] data; // rilascio della memoria posseduta
    }

private:
    std::size_t size;
    int* data;
};

Il C++ moderno aggiunge la semantica di spostamento (move semantics), che permette di trasferire le risorse da un oggetto temporaneo a un altro senza copia, migliorando notevolmente le prestazioni. Java non ha un concetto equivalente, perché la gestione dei riferimenti e del garbage collector rende superflua questa distinzione.

Tabella riassuntiva

Aspetto Java C++
Gestione della memoria Garbage collector automatico Manuale o RAII tramite smart pointer
Distruttore Assente (try-with-resources) Deterministico e virtuale quando serve
Ereditarietà multipla Solo di interfacce Di classi, con ereditarietà virtuale
Metodi virtuali Virtuali per default Solo se dichiarati virtual
Interfacce Costrutto dedicato interface Classe astratta pura
Programmazione generica Generics con type erasure Templates instanziati a compilazione
Overloading operatori Non supportato Pienamente supportato
Semantica della copia Copia del riferimento Copia profonda e move semantics
Allocazione degli oggetti Sempre sull'heap Stack o heap

Conclusione

Java e C++ realizzano la programmazione a oggetti con priorità diverse. Java privilegia la sicurezza, l'uniformità e la produttività: un modello a oggetti coerente, la gestione automatica della memoria e l'assenza di costrutti pericolosi rendono il linguaggio più facile da padroneggiare e meno soggetto a errori legati alla memoria. C++ privilegia il controllo e le prestazioni: il programmatore decide dove vivono gli oggetti, quando vengono distrutti e come vengono copiati, ottenendo un'astrazione a costo zero al prezzo di una maggiore responsabilità.

Conoscere entrambi i modelli arricchisce la comprensione della OOP nel suo insieme. Il determinismo del RAII insegna a pensare al ciclo di vita delle risorse, mentre la disciplina di Java mostra il valore di un modello protetto e prevedibile. Più che stabilire quale linguaggio sia superiore, il confronto evidenzia che la programmazione a oggetti non è un'unica idea monolitica, ma un insieme di principi che ciascun linguaggio interpreta secondo i propri obiettivi di progetto.