Metodi di estensione

Scritto da  Marco Amendola il giovedì 17 giugno 2010
Linguaggio: C#   •  Framework: 3.0,3.5,4.0   •  Livello: 200

Download sorgenti


I metodi di estensione (extension methods) sono costrutti introdotti intorno al novembre 2006 nel linguaggio C# (versione 3.0) e in Visual Basic (versione 9.0). Si tratta di una sintassi in grado di "aggiungere" metodi addizionali a classi o interfacce già esistenti.

Diversamente da alcuni linguaggi dinamici come Ruby o Javascript, questi metodi di estensione non possono essere aggiunti a run-time ma solamente in fase di codifica; pur conservando le caratteristiche di type safety (controllo rigido dei tipi in fase di compilazione), su cui gli utilizzatori fanno affidamento, questo costrutto aumenta notevolemente le potenzialità espressive del linguaggio.

Il meccanismo su cui si basano i metodi di estensione non è quello dell'ereditarietà (come vedremo in seguito più in dettaglio); in altre parole non stiamo definendo una sottoclasse derivata ma semplicemente aggiungendo dei metodi "esterni" ai membri di una classe esistente.

A riprova di questo è interessante notare che i metodi di estensione sono supportati, in particolare, anche per i tipi sealed, ovvero le classi esplicitamente definite come non derivabili. Il risultato finale, dal punto di vista dell'utilizzatore del metodo aggiuntivo, è comunque sostanzialmente identico a quello di un membro "proprio" della classe estesa.

Sintassi

La forma con cui vengono definiti i metodi di estensione è la seguente:

C#

namespace Utils
{
public static class StringExtensions
{
public static string Quote(this string target)
{
return string.Concat("\"", target, "\"");
}
}
}

VB

Imports System.Runtime.CompilerServices
Namespace Utils
Public Module StringExtensions
<Extension()>
Public Function Quote(ByVal target As String) As String
Return String.Concat("""", target, """")
End Function
End Module
End Namespace

 

Come si vede, la struttura somiglia alla semplice definizione di una semplice funzione di libreria; l'unica differenza è la keyword this nel codice C# e l'attributo Extension con cui è decorato il metodo in VB (evidenziati in grassetto).

Il risultato di questa dichiarazione è la possibilità di utilizzare il nuovo metodo come se appartenesse alla classe string:

C#

using Utils;
public class Program
{
static void Main(string[] args)
{
string text = "Una stringa non quotata";
Console.WriteLine(text.Quote());

Console.ReadLine();
}
}

VB

Imports Utils

Module Program

Sub Main()
Dim text As String = "Una stringa non quotata"
Console.WriteLine(text.Quote())

Console.ReadLine()
End Sub
End Module

 

Durante la scrittura del metodo abbiamo inoltre il consueto supporto dell'Intellisense, che rileva la presenza del metodo aggiuntivo segnalandolo con un simbolo leggermente differente:

fig1

Analizziamo le varie parti di codice più in dettaglio:

  • namespace Utils: è il namespace in cui viene inclusa l'estensione; la scelta del namespace è estremamente importante in quanto costituisce il meccanismo attraverso il quale è possibile "attivare" o disattivare la presenza del metodo aggiuntivo nell'oggetto che desideriamo estendere.
    I metodi aggiuntivi, infatti, sono visibili solo se il relativo namespace viene importato nella classe in cui si intende utilizzarli;
  • public static class StringExtensions/Public Module StringExtensions: è la dichiarazione della classe contenente uno o più metodi di estensione; in C# è essenziale che questo "contenitore" sia una classe static, mentre in VB è necessario utilizzare un modulo.
    Il nome del modulo, invece, è del tutto ininfluente, ma è pratica comune denominare il modulo contenitore con il suffisso "Extension" o "Extensions"; vale la pena puntualizzare che il contenitore dei metodi di estensione non ha alcuna attinenza con il tipo che viene esteso;
  • public static string Quote(…)/Public Function Quote(…): è il nome del metodo di estensione. In questo particolare esempio si tratta di un metodo che restituisce un valore, ma è altrettanto possibile definire il metodo come void (sub in VB) se non si tratta di una funzione. Nel codice C# si noti che il metodo viene dichiarato static (l'assenza della clausola static darebbe luogo comunque ad un errore di compilazione);
  • …(this string target)/ByVal target As String: è la parte che specifica quale classe stiamo estendendo. La forma è identica a quella di un normale parametro ma per convenzione il primo parametro del metodo di estensione specifica il tipo della classe che viene estesa; in C# questa convenzione viene rafforzata dalla presenza della clausola this appena prima del tipo;
  • <Extension()>: in VB occorre precisare esplicitamente che si tratta di un metodo di estensione decorando la dichiarazione con l'attributo Extension (contenuto nel namespace System.Runtime.CompilerServices).

Il parametro (negli esempi denominato "target") verrà valorizzato in fase di invocazione con l'istanza su cui invochiamo il metodo di estensione; è opportuno precisare che il nome scelto per il parametro è del tutto ininfluente ai fini della corretta dichiarazione del metodo.

Oltre al primo (obbligatorio), i metodi di estensione supportano la presenza di ulteriori parametri, i quali si comportano in maniera del tutto usuale.

Come funzionano?

Il codice presente nel corpo del metodo di estensione si comporta esattamente come in qualunque metodo tradizionale. Come già evidenziato, nel parametro target sarà passata l'istanza su cui il metodo viene invocato; facendo riferimento all'esempio precedente, alla chiamata di

text.Quote()

si otterrà l'invocazione del metodo Quote con il passaggio della variabile text come valore del parametro target.

Questo comportamento risulta ancora più evidente se si considera l'effettivo meccanismo con cui sono realizzati i metodi di estensione "dietro le quinte"; essi non sono, infatti, che un mero "abbellimento" della sintassi (syntactic sugar) per l'invocazione di un metodo statico.

Durante una fase preliminare della compilazione, infatti, i metodi di estensione sono risolti in invocazioni tradizionali e successivamente trattati dal compilatore come tali.

L'esempio riportato verrebbe riconvertito in qualcosa del genere:

C#

namespace Utils
{
public static class StringExtensions
{
public static string Quote(string target)
{
return string.Concat("\"", target, "\"");
}
}
}

...

using Utils;
public class Program
{
static void Main(string[] args)
{
string text = "Una stringa non quotata";
Console.WriteLine(StringExtensions.Quote(text));
Console.ReadLine();
}
}

VB

Namespace Utils

Public Module StringExtensions

Public Function Quote(ByVal target As String) As String
Return String.Concat("""", target, """")
End Function

End Module

End Namespace
...
Imports DomusDotNet.Articoli.MetodiDiEstensione.Utils

Module Program

Sub Main()
Dim text As String = "Una stringa non quotata"
Console.WriteLine(Quote(text))

Console.ReadLine()
End Sub

End Module

 

Per verificare che il codice effettivamente prodotto dal compilatore equivalga a quanto riportato, occorre sbirciare nell'assembly compilato ed analizzare direttamente l'IL prodotto:

fig2

Utilizzo con istanze nulle

Un aspetto che può sorprendere è il fatto che i metodi di estensione possano essere invocati su istanze null (nothing in VB), mentre con un metodo "normale" questo non avrebbe senso e scatenerebbe un'eccezione a runtime.
La possibilità di essere chiamati in corrispondenza di istanze nulle è, in effetti, un ampliamento della semantica di invocazione caratteristica degli extension methods.

Considerando il modo in cui i metodi di estensione vengono convertiti dal compilatore, si intuisce che questa casistica non crea problemi nella fase di invocazione, quanto piuttosto nel corso dell'esecuzione del metodo, se non si tiene conto del fatto che il parametro target può contenere valori nulli. Vediamo il seguente esempio (che utilizza anche un parametro aggiuntivo oltre a quello di istanza):

C#

namespace Utils
{
public static class StringExtensions
{
public static string ClipTo(this string target, int maxLenght)
{
if (target.Length <= maxLenght)
return target;
else
return target.Substring(0, maxLenght);
}
}
}
...
string longText = "Una stringa abbastanza lunga che eccede i 20 caratteri";
string nullText = null;
Console.WriteLine(longText.ClipTo(20));
Console.WriteLine(nullText.ClipTo(20));

VB

Namespace Utils

Public Module StringExtensions
<Extension()>
Public Function ClipTo(ByVal target As String, ByVal maxLenght As Integer) As String

If (target.Length <= maxLenght) Then
Return target
Else
Return target.Substring(0, maxLenght)
End If
End Function


End Module

End Namespace
...
Dim longText As String = "Una stringa abbastanza lunga che eccede i 20 caratteri"
Dim nullText As String = Nothing
Console.WriteLine(longText.ClipTo(20))
Console.WriteLine(nullText.ClipTo(20))

 

Esaminiamo la seconda chiamata, quella relativa alla variabile nullText: quando il codice viene eseguito, il metodo ClipTo viene correttamente invocato; tuttavia esso causa immediatamente un errore poiché viene acceduta la proprietà Lenght sul parametro target, che è nullo.

fig3

Una versione migliore dovrebbe verificare il parametro:

C#

public static string ClipTo(this string target, int maxLenght)
{
if (target == null || target.Length <= maxLenght)
return target;
else
return target.Substring(0, maxLenght);
}

VB

<Extension()>
Public Function ClipTo(ByVal target As String, ByVal maxLenght As Integer) As String

If target Is Nothing OrElse target.Length <= maxLenght Then
Return target
Else
Return target.Substring(0, maxLenght)
End If
End Function

 

Questa caratteristica degli extension methods può essere utilmente sfruttata in diverse circostanze, ad esempio per snellire il codice dai test di nullità.
Vediamo in pratica un metodo con questa finalità:

C#

public static class StringExtensions
{
public static string ToSafeString(this object target)
{
string result = null;
if (target != null)
{
result = target.ToString();
}
if (result == null)
{
result = String.Empty;
}
return result;
}
}
...
//con test di nullità
string result = SomeFunction();
if (result != null){
result = result.ToString().ToUpper();
}
Console.WriteLine("Il risultato, in maiuscolo, è: {0}", result);

//versione concisa
Console.WriteLine("Il risultato, in maiuscolo, è: {0}", SomeFunction().ToSafeString().ToUpper());

VB

Public Module StringExtensions
<Extension()>
Public Function ToSafeString(ByVal target As Object) As String
Dim result As String = Nothing
If target IsNot Nothing Then
result = target.ToString()
End If
If result Is Nothing Then
result = String.Empty
End If
Return result

End Function
End Module
...
'con test di nullità
Dim result As String = SomeFunction()
If (result IsNot Nothing) Then
result = result.ToString().ToUpper()
End If
Console.WriteLine("Il risultato, in maiuscolo, è: {0}", result)
'versione concisa
Console.WriteLine("Il risultato, in maiuscolo, è: {0}", SomeFunction().ToSafeString().ToUpper())

 

Come si vede, utilizzando il metodo di estensione ToSafeString viene evitato un test esplicito rendendo il codice decisamente più conciso e leggibile.

Risoluzione degli overload sul tipo esteso

Un altro aspetto interessante riguarda la risoluzione dei metodi di estensione in caso di overload, in particolare quando si varia proprio il parametro che rappresenta il tipo esteso. Prendiamo ad esempio il seguente codice:

C#

namespace Utils
{
public static class LoggingExtensions
{

public static void LogTo(this string target, TextWriter writer)
{
writer.WriteLine("Il contenuto di questa stringa è: {0}", target);
}

public static void LogTo(this int target, TextWriter writer)
{
writer.WriteLine("Questo intero è uguale a: {0}", target);
}

public static void LogTo(this object target, TextWriter writer)
{
writer.WriteLine("Il valore di questo oggetto è: {0}", target);
}


}
}
...
int someNumber = 3;
string someString ="Questa è una stringa";
DateTime someDate = DateTime.Now;
object someObject = "Questa è una stringa contenuta in una variabile di tipo object";
someNumber.LogTo(Console.Out); //Questo intero è uguale a: 3
someString.LogTo(Console.Out); //Il contenuto di questa stringa è: Questa è una stringa
someDate.LogTo(Console.Out); //Il valore di questo oggetto è: 17/06/2010 12.00.00
someObject.LogTo(Console.Out); //Il valore di questo oggetto è: Questa è una stringa contenuta in una variabile di tipo object

VB

Namespace Utils


Public Module LoggingExtensions

<Extension()>
Public Sub LogTo(ByVal target As String, ByVal writer As TextWriter)
writer.WriteLine("Il contenuto di questa stringa è: {0}", target)
End Sub

<Extension()>
Public Sub LogTo(ByVal target As Integer, ByVal writer As TextWriter)
writer.WriteLine("Questo intero è uguale a: {0}", target)
End Sub

<Extension()>
Public Sub LogTo(ByVal target As Object, ByVal writer As TextWriter)
writer.WriteLine("Il valore di questo oggetto è: {0}", target)
End Sub


End Module
End Namespace
...
Dim someNumber As Integer = 3
Dim someString As String = "Questa è una stringa"
Dim someDate As Date = DateTime.Now
Dim someObject As Object = "Questa è una stringa contenuta in una variabile di tipo object"
someNumber.LogTo(Console.Out) 'Questo intero è uguale a: 3
someString.LogTo(Console.Out) 'Il contenuto di questa stringa è: Questa è una stringa
someDate.LogTo(Console.Out) 'Il valore di questo oggetto è: 12/06/2010 21.39.47
someObject.LogTo(Console.Out) 'System.MissingMemberException: Public member 'LogTo' on type 'String' not found.

 

Come si vede, viene applicato il normale algoritmo di risoluzione dell'overload di metodi, che prevede che venga presa la "versione" meglio corrispondente al parametro passato; più esattamente il "binding" con l'overload corretto viene fatto in base al tipo della variabile che viene passata e non in base all'istanza effettiva a run-time.
Nell'esempio, le prime due chiamate vengono risolte nei metodi con parametro tipizzato rispettivamente string e int, mentre la terza chiamata viene risolta nel metodo con parametro object, non essendo presente un overload specifico con datetime. Anche la quarta chiamata, in C#, segue la stessa regola.

Nel quarto caso in VB si verifica una particolarità: viene scatenata un'eccezione MissingMethodException (metodo mancante). In VB, infatti, i metodi di estensione non hanno effetto sul late binding e in caso di variabili dichiarate come Object vengono ignorati.

Questo comportamento è spiegato dal fatto che in VB le variabili dichiarate come Object supportano il late-binding, ovvero la caratteristica di verificare solo a runtime l'effettiva presenza del metodo; in questo contesto, la semplice presenza di un extension method attribuibile a tutte le variabili Object avrebbe potuto interrompere il funzionamento di codice già esistente nel caso di sovrapposizione dei nomi dei metodi.

Sovrapposizione con i metodi "propri"

Possiamo verificare meglio l'interazione degli extension methods con i metodi già esistenti nei tipi estesi aggiungendo una classe al precedente esempio:

C#

public class Point {
public int X { get; set; }
public int Y { get; set; }

public void LogTo(TextWriter writer) {
writer.WriteLine("Le coordinate di questo punto sono: {0},{1}", X, Y);
}
}
...
public static class LoggingExtensions
{
public static void LogTo(this Point target, TextWriter writer)
{
writer.WriteLine("Le coordinate di questo punto, riportate dal metodo di estensione, sono: {0},{1}", target.X, target.Y);
}

}
...
Point somePoint = new Point { X = 1, Y = 2 };
object someObject = new Point { X = 1, Y = 2 };
somePoint.LogTo(Console.Out); //Le coordinate di questo punto sono: 1,2
someObject.LogTo(Console.Out); //Il valore di questo oggetto è: DomusDotNet.Articoli.MetodiDiEstensione.Point

VB

Public Class Point
Public Property X As Integer
Public Property Y As Integer

Public Sub LogTo(ByVal writer As TextWriter)
writer.WriteLine("Le coordinate di questo punto sono: {0},{1}", X, Y)
End Sub

End Class
...
Public Module LoggingExtensions
<Extension()>
Public Sub LogTo(ByVal target As Point, ByVal writer As TextWriter)
writer.WriteLine("Le coordinate di questo punto, riportate dal metodo di estensione, sono: {0},{1}", target.X, target.Y)
End Sub
End Module
...
Dim somePoint As Point = New Point With {.X = 1, .Y = 2}
someObject = New Point With {.X = 1, .Y = 2}
somePoint.LogTo(Console.Out) 'Le coordinate di questo punto sono: 1,2
someObject.LogTo(Console.Out) 'Le coordinate di questo punto sono: 1,2

 

Come si vede, il comportamento nei due linguaggi è differente ma segue la stessa logica: garantire che la semplice aggiunta del namespace contenente i metodi di estensione non interrompa in maniera silenziosa il funzionamento di classi già esistenti.

Osservando la prima delle due chiamate (somePoint.LogTo) in entrambi i linguaggi il metodo di estensione di Point viene ignorato, in quanto esiste già un metodo proprio identico che non deve essere "mascherato"; nella fase di "binding" i metodi propri dell'oggetto hanno la precedenza su quelli di estensione.

Per quanto riguarda la seconda invocazione (effettuata sulla variabile someObject):

  • VB utilizza il late-binding, quindi invoca il metodo LogTo di Point sulla variabile è di tipo Object perché ispeziona a run-time l'istanza effetivamente contenuta;
  • C#, invece, utilizza sempre un early-bnding (ovvero decide l'overload in fase di compilazione) e quindi associa la chiamata con il metodo di estensione del generico Object.

Risoluzione degli overload sugli altri parametri

Per completare i casi possibili, occorre verificare che la regola degli overload sia valida anche quando la differenza fra le versioni riguarda gli altri parametri del metodo, e non quello rappresentante l'istanza estesa. Per fortuna in questo scenario non ci sono sorprese o comportamenti irregolari.

Arricchiamo l'esempio precedente:

C#

public class Point
{
...
public bool CanInteractWith(object other)
{
if (other is Point)
return true;
else
return false;
}

}
...
public static class PointExtensions
{
public static bool CanInteractWith(this Point target, int value)
{
return true;
}
}
...
Console.WriteLine("somePoint può interagire con someString? {0}", somePoint.CanInteractWith(someString)); //somePoint può interagire con someString? False
Console.WriteLine("somePoint può interagire con someNumber? {0}", somePoint.CanInteractWith(someNumber)); //somePoint può interagire con someString? False

VB

Public Class Point
...
Public Function CanInteractWith(ByVal other As Object)
If TypeOf other Is Point Then
Return True
Else
Return False
End If
End Function
End Class
...
Public Module PointExtensions
<Extension()>
Public Function CanInteractWith(ByVal target As Point, ByVal value As Integer)
Return True
End Function
End Module
...
Console.WriteLine("somePoint può interagire con someString? {0}", somePoint.CanInteractWith(someString)) 'somePoint può interagire con someNumber? False
Console.WriteLine("somePoint può interagire con someNumber? {0}", somePoint.CanInteractWith(someNumber)) 'somePoint può interagire con someNumber? False

 

In questo caso a cambiare è il parametro value; come anticipato, la risoluzione segue le regole già evidenziate. Nonostante il metodo di estensione con parametro int sia in effetti disponibile (e ben visibile attraverso Intellisense)

fig4

la chiamata somePoint.CanInteractWith(someNumber) non attiva questa versione, ma il metodo originale della classe con parametro object, anche se più "lasco".

Anche in questo caso il compilatore sta privilegiando i metodi propri della classe per non cambiare il comportamento esistente; solo in mancanza di un binding adeguato passerà a considerare eventuali metodi di estensione.


Tags: extension methods

 
x