1. Introduzione
Allora, abbiamo capito che le interfacce sono contratti potenti. Ma che cosa significa programmare a livello di interfacce? È una filosofia di progettazione che dice: "Dipendi dalle astrazioni, non dalle implementazioni concrete."
Facciamo di nuovo un'analogia. Vuoi preparare un caffè. Compri una macchina del caffè. Non ti interessa se è il modello concreto "Super-Puper-Macchina V3000" o "Mega-Automat Barista 5000", giusto? Ti interessa che questa macchina sappia "fare il caffè" – cioè, implementa il "contratto" ICoffeeMaker. Premi semplicemente il pulsante "Fai il caffè" e lei lo fa. Come lo fa – con chicchi macinati, capsule o altro – a te, come utente, non importa più di tanto.
Nel codice questo significa che invece di richiedere nei tuoi metodi un tipo concreto come Document o Image, richiederai IPrintable. Il tuo codice funzionerà con qualsiasi oggetto che implementa questa interfaccia, senza sapere il suo tipo concreto.
Il contratto di interfaccia è una garanzia che ogni classe che implementa l'interfaccia fornirà sicuramente le definizioni di tutti i membri dell'interfaccia (metodi, proprietà, eventi). Dal punto di vista del programma, qualsiasi oggetto che implementa l'interfaccia supporta un certo "set di funzionalità", e su questo puoi contare, anche senza sapere come è implementato dentro.
Supponiamo che tu abbia concordato con un collega di incontrarvi sempre vicino al divano rosso nell'atrio. Come ci arriva – in ascensore, a piedi o correndo sui muri – non importa. L'importante è che lui sia lì. Ecco, l'interfaccia è il "divano rosso" per il codice.
Esempio
public interface IPrintable
{
void Print();
}
Contratto: chiunque implementa IPrintable deve saper stampare se stesso a schermo. Come – sono dettagli.
2. Il contratto come punto di interazione tra parti del sistema
Come funziona nella pratica
Un sistema può essere composto da decine di classi, create da persone diverse, in tempi diversi, con scopi diversi. Ma se tutte rispettano lo stesso contratto (cioè implementano la stessa interfaccia), allora puoi usarle in modo uniforme.
Esempio da un'app reale: immagina un database di clienti. Classe Customer, classe Employee, classe Contractor. Ognuna memorizza le info a modo suo, ma se tutte implementano, per esempio, l'interfaccia IContactInfo, che richiede i metodi GetEmail() e GetPhoneNumber(), allora per il codice esterno non importa a che tipo appartiene l'oggetto – l'importante è che puoi ottenere email e telefono.
public interface IContactInfo
{
string GetEmail();
string GetPhoneNumber();
}
public class Customer : IContactInfo
{
public string Email { get; set; }
public string Phone { get; set; }
public string GetEmail() => Email;
public string GetPhoneNumber() => Phone;
}
// Uguale per Employee e Contractor...
Ora, se devi stampare i dati di tutti quelli con cui la tua azienda ha un contatto (non importa chi siano), semplicemente passi nella lista di IContactInfo, chiami i metodi giusti – e tutto funziona.
3. Programmare "a livello di interfacce"
Programmare a livello di interfacce significa scrivere codice che dipende non da classi concrete, ma solo da interfacce (cioè contratti). Una classe che implementa un'interfaccia può essere qualsiasi cosa, basta che rispetti i requisiti dell'interfaccia.
Perché è importante?
- Scalabilità: puoi aggiungere nuovi tipi facilmente, senza cambiare il codice esistente.
- Testabilità: puoi facilmente sostituire gli oggetti con dei mock nei test.
- Flessibilità: l'implementazione può cambiare ogni giorno, l'interfaccia resta stabile.
- Pulizia dell'architettura: i tuoi moduli sono poco collegati tra loro, puoi riutilizzarli.
Esempio — Programma noto: "Conto bancario"
Negli esempi precedenti avevamo una classe astratta BankAccount con un metodo astratto Withdraw(), e i tipi concreti (SavingsAccount, CheckingAccount) implementavano i dettagli.
Ora aggiungiamo un'interfaccia – per esempio, per mostrare le info sul saldo:
public interface IBalanceReporter
{
void ReportBalance();
}
public abstract class BankAccount : IBalanceReporter
{
public double Balance { get; set; }
public abstract void Withdraw(double amount);
public void ReportBalance()
{
Console.WriteLine($"Saldo attuale: {Balance} euro");
}
}
Ora chiunque lavori con IBalanceReporter può chiamare ReportBalance() senza preoccuparsi del tipo concreto di account.
4. Gestione generale e polimorfismo tramite contratto
Esempio: handler generico
Appena lavori con un'interfaccia, puoi creare metodi generici che non dipendono dal tipo dell'oggetto:
static void PrintAllBalances(IBalanceReporter[] accounts)
{
foreach (var reporter in accounts)
{
reporter.ReportBalance();
}
}
In questa lista possono esserci oggetti di qualsiasi tipo che implementano IBalanceReporter: SavingsAccount, CheckingAccount, anche un MockAccountForTesting. E non c'è nessuna magia, funziona tutto onestamente.
Schematizzato: cosa dà il contratto di interfaccia
+-------------------+ implementa +-------------------+
| BankAccount | <---------------- | IBalanceReporter |
| (SavingsAccount) | |-------------------|
| (CheckingAccount)| | + ReportBalance() |
+-------------------+ +-------------------+
| ^
| |
+-----------+-----------------------+
|
Qualsiasi altra classe che implementa il contratto
5. Contratti, business logic e scrittura dell'architettura
Il contratto di interfaccia separa chiaramente cosa deve saper fare una classe da come lo implementa. Per questo gli architetti software amano progettare la logica dei sistemi proprio tramite le interfacce. Spesso prima si sviluppa l'interfaccia (il contratto), e la sua implementazione arriva dopo.
Esempio dalla vita reale: sistemi di pagamento
Wallet elettronici, carte, PayPal, criptovalute... Ognuno ha i suoi dettagli, ma se ti astrai e fai un'interfaccia IPaymentProvider:
public interface IPaymentProvider
{
void Pay(decimal amount);
bool Refund(decimal amount);
}
Il codice che interagisce con questa interfaccia non si preoccupa se paga con carta o conto. È comodo sia per l'architettura che per la vita: puoi aggiungere nuovi sistemi di pagamento senza toccare il resto del codice.
6. Estrarre la business logic nel contratto
Il contratto permette di portare le regole di business principali a livello di interfaccia, lasciando i dettagli (tipo controllo limiti, cashback ecc.) alle singole classi.
Un altro esempio
public interface ILogger
{
void LogInfo(string message);
void LogError(string message);
}
public class ConsoleLogger : ILogger
{
public void LogInfo(string message) => Console.WriteLine($"INFO: {message}");
public void LogError(string message) => Console.WriteLine($"ERRORE: {message}");
}
public class FileLogger : ILogger
{
public void LogInfo(string message) => /* Scrittura su file */;
public void LogError(string message) => /* Scrittura su file */;
}
Poi il codice client è così (non importa con quale logger lavori):
void DoWork(ILogger logger)
{
logger.LogInfo("Lavoro iniziato.");
// ... qualche lavoro ...
logger.LogError("Il lavoro è andato storto.");
}
Questo è proprio programmare a livello di interfacce: il codice client dipende solo dal contratto (interfaccia), non dall'implementazione concreta.
7. Errori e trappole tipiche
I principianti spesso fanno l'errore di legare il loro codice a classi concrete invece che alle interfacce. Questo porta a un sacco di problemi:
- Difficile sostituire o testare qualcosa – devi riscrivere tanto codice.
- Non riesci ad aggiungere nuovi tipi senza modificare molte parti.
- I moduli finiscono per essere troppo "incollati" tra loro.
Al contrario – se costruisci il sistema sulle interfacce fin dall'inizio e passi i parametri tramite esse, ottieni bassa dipendenza e flessibilità.
Il consiglio principale: cerca di pensare all'interazione tra moduli come interazione tra contratti, non tra implementazioni concrete.
GO TO FULL VERSION