Fonctions avancées

Surcharge d’opérateurs

En C#, il est possible de redéfinir les opérateurs comme le signe + afin de pouvoir les utiliser avec d’autres types de données que ceux pris en charge par défaut (numériques et string). C’est un exemple de polymorphisme. Imaginons une classe Vecteur, sans redéfinition d’opérateur, on est obligé de passer par un appel de méthode pour additionner 2 vecteurs:

class Vecteur {
        double x, y;
        ...
        public Vecteur Add(Vecteur v)
        {
                return new Vecteur(x + v.x, y + v.y);
        }
}

...
Vecteur v1, v2, v3;
...
v3 = v1.Add(v2);

Avec la redéfinition d’opérateur on va pouvoir écrire les choses de manière plus intuitive:

class Vecteur {
        double x, y;
        ...
        public static Vecteur operator+(Vecteur v1, Vecteur v2)
        { return new Vecteur(v1.x + v2.x, v1.y + v2.y); }
}


Vecteur v1, v2, v3;
...
v3 = v1 + v2;

Seuls les opérateurs suivants peuvent être redéfinis:

  • Opérateurs unaires: +, -, !, ~, ++, --

  • Opérateurs binaires +, -, *, /, %, &, |, ^, <<, >>

  • Opérateurs de comparaisons (binaires) ==, !=, <, >, <=, >=

Une redéfinition d’opérateur est une méthode statique nommée operator suivie du symbole de l’opérateur à redéfinir qui doit respecter les règles suivantes :

  • Opérateur unaire : la méthode prend un unique argument du type de la classe dans laquelle l’opérateur est redéfini et renvoi une valeur du même type.

  • Opérateur binaire : la méthode prend deux arguments dont au moins un doit être du type de la classe dans laquelle l’opérateur est redéfini. Le type de retour est libre.

Notons que la commutativité des opérateurs n’est pas gérée par défaut. Par exemple, avec le code suivant qui redéfinit l’opérateur * entre un vecteur et un scalaire :

class Vecteur {
        double x, y;
        ...
        public static Vecteur operator * (Vecteur v1, double k)
        { return new Vecteur(v1.x * k, v1.y * k); }
}
...
Vecteur v1,v2;
v2 = v1 * 2.0; // ok Vecteur * double est défini
// v2 = 2.0 * v1;  ERREUR : double * Vecteur n'est pas défini

Pour cela il faut redéfinir une deuxième fois l’opérateur * en inversant les arguments:

class Vecteur {
        ...
        public static Vecteur operator * (double k, Vecteur v1)
        { return v1*k; } // redéfinition e1n utilisant la première définition
}

Dans la classe A (actuellement vide), quelles déclarations d’opérateur sont correctes:

  1. static A operator++(A a){...}

  2. static double operator++(A a){...}

  3. static A operator+(A a1, A a2){...}

  4. static double operator+(A a1, A a2){...}

  5. static A operator+(A a1, double d){...}

  6. static double operator+(A a1, double d){...}

Valeurs par défaut et arguments nommés

Il est possible de donner des valeurs par défaut aux paramètres d’une fonction, il n’est alors plus nécessaire de donner une valeur à ces arguments lors de l’appel à la fonction. Cette approche peut remplacer avantageusement la définition de fonctions polymorphes dans certains cas.

int F(int a = 1 , int b = 0){
        return a * a + b ;
}

F(2,3); // a = 2 , b = 3
F(2); // a = 2, b = 0 (valeur par défaut pour b)
F(); // a = 1 et b = 0 (valeurs par défaut pour a et b)

Les règles à respecter sont les suivantes :

  • Tous les arguments sans valeur par défaut doivent être à gauche des arguments avec valeur par défaut.

  • Il est interdit de sauter des arguments lors de l’appel de la fonction (on ne peut omettre que les derniers arguments).

Pour dépasser cette dernière limitation, on peut utiliser les arguments nommés. Les arguments nommés permettent d’appeler une fonction sans respecter l’ordre de déclaration des arguments en nommant explicitement les arguments:

F(a:2, b:3); // a = 2 , b = 3
F(b:2, a:3); // a = 3 , b = 2
F(a:2); // a = 2, b = 0 (valeur par défaut pour b)
F(b:3); // a = 1, b = 3 (valeur par défaut pour a)

En cas d’ambigüité sur la fonction à utiliser, c’est celle avec le moins d’argument déclaré qui est retenue.

public void F() { ... }
public void F(int a =0) { ... }
...
F(); // appel la 1ère définition, signature (F).

La classe A possède une unique méthode statique public static void G(int a, String s = "",int b = 1){}, quelles sont les instructions correctes ?

  1. A.G();

  2. A.G(2);

  3. A.G(1,"a",2);

  4. A.G(1,"a");

  5. A.G(,"a");

  6. A.G(,"a",2);

  7. A.G(b:2, a:3);

  8. A.G(a=1, b=3);

Arguments en nombre variable

Il est parfois impossible de connaitre à l’avance le nombre exacte d’arguments d’une fonction. Par exemple supposons que l’on veuille écrire une fonction Max qui calcule le maximum d’un ensemble de nombres entiers. On pourrait définir par polymorphisme une version avec 2 arguments, 3 arguments, 4 arguments et ainsi de suite.

int Max(int a, int b) {...}
int Max(int a, int b, int c) {...}
int Max(int a, int b, int c, int d) {...}

Cela n’est bien entendu pas très efficace et limité. Une autre solution serait de définir une version basée sur un tableau d’entier.

int Max(int [] t) {...}

Mais on est alors obligé de mettre les nombres dans un tableau pour pouvoir utiliser la fonction:

int a, b, c, d;
...
d = Max(new int[]{a,b,c,d});

Le principe des arguments variables permet de conserver le meilleur des deux mondes. Pour déclarer une méthode avec un nombre d’arguments variable, on utilise le mot clé params. A l’intérieur de la méthode l’argument est alors vu comme un tableau mais la fonction peut être utilisée comme si il y avait effectivement un nombre variable d’arguments.

int Max(params int[] t){
        int m = Int32.MinValue;
        for (int v in t)
                if(v>m)
                        m=v;
        return m;
}

Max(); // <=> max( new int[]{} ) tableau vide !
Max(1); // <=> max( new int[]{1} )
Max(1, 2, 3); // <=> max( new int[]{1,2,3} )
Max(3, 2, 4, 5, 3, 4, 3, 5, 3, 5); // ...
int [] t=...
Max(t);

Chaque méthode peut utiliser un seul argument de taille variable qui doit être le dernier argument déclaré.

void F(int a, string b, params char[] ct) { ... }
void G(params char[] ct, params string[] cs) { ... } // erreur 1 argument de longueur variable au plus
void H(params char[] ct, int a) { ... } // erreur l'argument de taille variable doit être en dernière position

Dans la classe A (actuellement vide), quelles déclarations de fonction sont correctes:

  1. void F(int a=2, int b){}

  2. int F(int b, float b=3){}

  3. F(int c=1, double b=1.0){}

  4. void F(params double [][] d){}

  5. void F(params double d){}

  6. void F(int a, params double [] d){}

  7. void F(params double d, int a=1){}

Soit le programme suivant:

1class A{
2        public static void F(){} // 1ère
3        public static int F(int i=0){return i;} // 2ème
4        public static double F(int i, int j){return i;} // 3ème
5        public static double F(int i, double j=0.0){return j;} // 4ème
6        public static double F(double i, int j=1){return i;}  // 5ème
7        public static void F(string b){} // 6ème
8        public static void F(string b, string a){} // 7ème
9}

Pour chaque chaque appel de fonction, indiquez quelle définition de F est utilisée ou « Erreur » si l’appel n’est pas valide.

Appel de fonction

Définition utilisée

Appel de fonction

Définition utilisée

F(1)

F("2", 1)

F(2.0)

F("", 2 + ".0")

F("" + 2.0)

F(true)

F(1, 2)

F("" + false)

F(1.0, 2)

F()

F(1.0, 2.0)

Indexeurs

Un indexeur permet d’accéder aux éléments d’un objet comme si il s’agissait d’un tableau. Ainsi, si l’on dispose d’une classe Matrice (tableau 2D de double avec des opérations mathématiques), sans indexeur on écrit des choses du genre :

Matrice m = ...
m.SetValeur(1,2, m.GetValeur(0,1) + 2.2);

La même chose avec indexeur :

Matrice m = ...
m[1,2] = m[0,1] + 2.2;

Mais les indexeurs sont beaucoup plus souples que ce que proposent les tableaux car il est possible de définir des indexeurs sur autre chose que des entiers. Par exemple, on peut définir des tableaux relationnels (indicé sur autre chose que des entiers, Dictionary en C#):

TabRel t = ...
t["foo"] = "bar";
string s = t["truc"];

Pour définir un indexeur: il faut définir une propriété dont le nom est this suivi entre entre crochets du type de l’indexeur, la définition du getter et du setter est alors classique (bien sûr un indexeur ne peut pas être une propriété auto-implémentée).

Exemple : la classe Annuaire permet de d’associer des noms à des numéros de téléphone. Elle offre deux indexeurs, le premier à partir d’un nombre entier permet de récupérer ou modifier le numéro du i-ème contact, le second permet de chercher modifier un numéro à partir d’un nom.

class Contact {
        public string nom, numero;
}

class Annuaire {
        List<Contact> list;

        public string this[int n]{ // indexeur sur des entiers
                get {   return list[n].numero ; }
                set {  list[n].numero = value ; }
        }
        public string this[string n]{ // indexeur sur des strings
                get { return list[Find(n)].numero ; }
                set {  list[Find(n)].numero = value ; }
        }

        private int Find(string n){
                for(int i=0;i<list.Count;i++)
                        if(list[i].nom.Equals(n))
                                return i;
                return -1;
        }
}

Pour chacune de ces affirmations, indiquez si elle est vraie ou fausse:

  1. Un indexeur est défini avec le mot clé indexer.

  2. Un indexeur peut être privé.

  3. Un indexeur peut être statique.

  4. Un indexeur est une propriété.

  5. Un indexeur peut utiliser des nombre réels comme indices.

  6. Une classe peut posséder plusieurs indexeurs.

En C# tous les tableaux sont indicés à partir de zéro. Néanmoins la possibilité de définir soit même des indexeurs permet de créer des objets mimant le comportement d’un tableau avec une plage d’indices décalée (commençant par exemple à -20 ou à 1).

On souhaite définir une classe TabInt représentant un tableau d’entiers avec des indices décalés qui s’utiliserait de la manière suivante:

TabInt t = new TabInt(11, -5); // tableau de 11 éléments commençant à l'indice -5
t[-5]=-5; // initialisation du 1er élément du tableau
for(int i=t.IStart+1; i<t.IStart+t.Length ; i++)
        t[i]=t[i-1]+i;
Console.WriteLine(t[t.IStart+T.Length-1]); // affichage du dernier élément t[5]

Qu’affiche ce programme ?

Dans un nouveau projet Console sous Visual Studio, définissez la classe TabInt contenant:

  • un champs privé de type tableau d’entiers;

  • une propriété IStart de type entier avec accès en lecture publique et accès en écriture privé qui représente l’indice de départ;

  • une propriété Length de type entier en lecture seule qui envoie le nombre d’élément dans le tableau;

  • un constructeur public prenant en argument : le nombre d’éléments dans le tableau et l’indice de départ;

  • un indexeur permettant d’accéder aux éléments du tableau (lecture et modification) en prenant compte du décalage d’indice.

  • une méthode public SetAll qui prend en arguments l’indice de départ et un nombre variable d’argument de type entier et qui place ces nombres dans le tableau à partir de l’indice indiqué. Par exemple:

    TabInt t = new TabInt(5, 1); // tableau de 5 entiers tous égaux à 0
                                 // commencant à l'indice 1
    t.SetAll(2,1,2,3); // Mets les éléments 1,2,3 à partir de l'indice 2
    // t contient maintenant 0 1 2 3 0
    

Délégués et fonctions anonymes

Un délégué est un objet qui permet d’appeler une ou plusieurs fonctions. L’intérêt étant qu’il est possible de changer dynamiquement la ou les fonctions qui seront appelées par le délégué. Les délégués sont énormément utilisés dans la programmation d’interface graphique qui consiste à associer des actions à des évènements. Lors d’un clic de souris, le système consulte le délégué associé à cet évènement et lui demande d’exécuter les fonctions que le développeur à assigner à cet évènement.

Par exemple, dans le fichier Form1.Designer.cs (généré automatiquement par le designer de Visual Studio lors de la création d’une IHM Win32), après avoir créé un bouton et définit une action en cas de clic de l’utilisateur, on trouve:

this.button1.Click += new System.EventHandler(this.button1Click);

C’est-à-dire, une affection de la propriété Click de la référence button1. La valeur affectée correspond à une référence vers un nouvel objet de type EventHandler (c’est un délégué) avec en paramètre la fonction button1Click.

Les délégués sont comparables (mais en mieux) aux pointeurs de fonctions du C/C++.

Type délégué

Un délégué est toujours associé à un prototype de fonction particulier qui sera le type du délégué. Ainsi, un délégué donné ne pourra référencer que des fonctions qui prennent les mêmes arguments et retournent le même type de donné.

La déclaration d’un type délégué se fait grâce au mot clef delegate. Par exemple:

delegate int TypeDelegue( double v );

déclare le type délégué nommé TypeDelegue et correspondant à une fonction prenant un paramètre de type double et retournant un int. On peut ensuite déclarer des objets de type TypeDelegue et leur assigner des fonctions. Puis l’objet de type délégué s’utilise comme une fonction:

static int F(double v){
        return (int)(v+0.5);
}

static void Main(String [] args){
        TypeDelegue d = new TypeDelegue(F); // d est un objet délégué pointant sur F
        int i = d(2.7); // appel de F à travers le délégué d
        Console.WriteLine(i); // 3
}

On peut changer la fonction associée à un objet délégué en cours d’exécution:

static double F(double v){ WriteLine(++v); return v; }
static double G(double v){ WriteLine(--v); return v; }

delegate double T( double v );

static void Main(string [] args)
{
        T d = new T(F); // d est un objet délégué pointant sur F
        d(1.0); // 2
        d = new T(G); // d est un objet délégué pointant sur G
        d(1.0); // 0
}

On peut également assigner des méthodes d’instance à un délégué:

class A {
        int x=2;
        public void F(){ WriteLine(x) ;}

        delegate void T();

        public static void main (string [] args)
        {
                A a = new A();
                T d = new T(a.F);
                d(); // affiche 2
                a.x++;
                d(); // affiche 3
        }
}

d référence la méthode d’instance F associée à l’objet référencé par a et accède à ses champs d’instance.

Un autre cas typique d’utilisation des délégués (en dehors des évènements) est la fonction Map qui sert à appliquer une fonction à tous les éléments d’une liste.

delegate double FonctionNumerique(double x); // type délégué sur des fonctions réelles

void Map ( double [] tab, FonctionNumerique f){ // une fonction qui prend une *fonction* en paramètre
        for(int i=0; i<tab.Length; i++)
                tab[i] = f(tab[i]);
}

double [] t = {1.1,-2.8,3.7} ;
Map(t, new FonctionNumerique(Math.Round));
// t => {1.0,-2.0,3.0}
Map(t, new FonctionNumerique(Math.Abs));
// t => {1.0,2.0,3.0}

Fonctions anonymes

Les fonctions que l’on associe à un objet délégué sont rarement utilisées autrement qu’à travers ce délégué. Comme une fonction n’est jamais appelée directement, il est inutile de la nommer. On peut alors recourir à une déclaration compacte avec le mot clé delegate. Exemple:

delegate int T (int n) ; // type délégué

T d = new T(
        delegate (int n){ return n++ ; } // fonction anonyme
        );

Ce qui revient à la déclaration longue :

delegate int T (int n) ; // type délégué

int MaFonction(int n){
        return n++;
}

T d = new T(MaFonction);

Il est bien sûr possible de mettre plusieurs instructions dans le corps de la fonction anonyme, de déclarer des variables, d’appeler d’autres fonctions…

double[] t = {5,50,500} ;
Map(t, new FonctionNumerique(
        delegate (double x){
                double tmp = x*2 ;
                return Math.Log10(tmp) ;
}
)) ; // t=> {1,2,3}

Expressions lambda

Les expressions lambda (vient de la notion de lambda calcul) sont une autre façon d’écrire des fonctions anonymes. Par exemple :

x => x + 10

est une fonction qui prend un paramètre x et renvoie la valeur de x + 10. Remarquez qu’il n’y aucune information de typage explicite.

On peut également mettre plusieurs instructions dans une lambda expression, dans ce cas il faut préciser le return:

x => {double y=x*x ; return Math.Log(y) ;}

ainsi que prendre plusieurs paramètres :

(a,b) => a<b
(a,b) => {a++, b--, return a*b ;}

On peut maintenant simplifier les exemples précédents:

delegate int T (int n) ; // type délégué
T d = n => n++;

Ou en reprenant l’exemple de la fonction Map :

double[] t = {5,50,500} ;
Map(t, x=>{double tmp = x*2 ; return Math.Log10(tmp) ;} ) ; // t=> {1,2,3}

Exercices

  1. Le mot clef delegate permet de déclarer un nouveau type de données.

  2. Le mot clef delegate permet de déclarer une fonction anonyme.

  3. Une variable de type délégué peut référencer une méthode statique.

  4. Une variable de type délégué peut référencer une méthode d’instance.

  5. Une fonction anonyme peut prendre plusieurs paramètres.

  6. Une fonction anonyme peut être composée de plusieurs instructions.

  7. Une fonction anonyme ne contient pas d’information de typage.

  8. Une lambda expression peut prendre plusieurs paramètres.

  9. Une lambda expression peut être composée de plusieurs instructions.

  10. Une lambda expression ne contient pas d’information de typage.

  11. Une lambda expression peut servir à initialiser une variable de type délégué.

Dans un nouveu projet Console, dans la classe Program:

  • Déclarez le type délégué IntAction qui prend un int en argument et ne retourne rien.

  • Déclarez une méthode statique PrintInt qui prend un argument de type entier, ne retourne rien et affiche l’entier reçu en paramètre sur la console.

  • Déclarez une méthode statique PrintIntSquare qui prend un argument de type entier, ne retourne rien et affiche le carré de l’entier reçu en paramètre sur la console.

  • Déclarez une méthode statique Perform prenant comme paramètre un élément de type IntAction, un nombre variables d’éléments de type int et qui applique le délégué IntAction à chacun des éléments entiers au moyen d’une boucle foreach.

  • Dans la méthode Main, réalisez les instructions suivantes:

    • Déclarez une variable act de type IntAction.

    • Affectez la méthode PrintInt à la variable act.

    • Appelez le délégué act avec la valeur 42.

    • Déclarez un tableau d’entiers tab contenant les valeurs {1,2,3,4}.

    • Appelez la méthode Perform avec pour paramètre la variable act et le tableau tab.

    • Appelez la méthode Perform avec pour paramètre la variable act et les valeurs 9,8,7,6,5 (sans déclarer de nouveau tableau).

    • Affectez la méthode PrintIntSquare au délégué act

    • Appelez la méthode Perform avec pour paramètre la variable act et le tableau tab: qu’affiche le programme?