PROFDINFO.COM

Votre enseignant d'informatique en ligne

Révision des concepts de base et application au C#

Dans ce premier module, nous réviserons (rapidement) les concepts à la base de la programmation orientée objet et nous (re?)verrons comment les utiliser en C#.

Dérivation et héritage

La dérivation est un concept qui permet la spécialisation.  Lorsqu'une classe dérive d'une autre classe, elle hérite automatiquement de tous les membres de sa classe parent – toutefois elle n'y a pas toujours nécessairement accès.

Normalement, on dérive une classe à partir d'une autre lorsque la nouvelle classe est une spécialisation de la première.  Elle peut tout faire ce que la première fait, et plus encore.  On peut alors utiliser la relation "est un" pour représenter la spécialisation (par exemple "un étudiant est une personne" ou "une chauve-souris est un mammifère").

La classe enfant hérite de tous les membres de la classe parent, toutefois elle n'y a pas toujours accès. Les membres publics sont bien entendus accessibles, tout comme les membres protégés (protected). Les membres privés (private) sont hérités mais inaccessibles (ce qui revient presque au même que pas hérités du tout, mais il y a tout de même une nuance).

En réalité, lorsque l'on crée un objet du type de la classe enfant, il y a un objet du type de la classe parent qui est créé à l'intérieur, donc l'enfant a en lui tout ce qui vient du parent (ce qui prend un espace en mémoire), par contre il ne peut pas accéder à ce qui est private.

Un exemple en C#...

public class Mammifère 
 {
       private double poids;
       private double taille;

	   public void Manger(Aliment al)
 	    {
 	    	poids += al.poids;
 	    }

       public void Bouger(int poidsPerdu)
 	    {
 	    	poids  -= poidsPerdu;
 	    }
}
public class  ChauveSouris:Mammifère
{
   	   public void Voler(int distance)
 	    {
 	    	// déplacement
 	    	poids  -= distance * 0.05;  // Perte de poids  due à l'exercice
 	    }
 }

La ligne qui fait perdre du poids à la chauve-souris n'est pas valide, puisque le poids est privé à Mammifère. Il faudra alors soit remplacer la ligne par this.Bouger(distance*0.05);  // Perte de poids due à l'exercice, changer le modificateur d'accès de poids à protected ou créer une propriété public ou protected permettant l'accès au membre poids.

La surcharge à travers l'héritage

Il est fort possible de redéfinir une méthode de notre classe parent pour en obtenir une version "spécialisée".  Dans ce cas, il est même possible de la redéfinir avec les mêmes paramètres (la même signature).  Pour ce faire, la fonction de base (dans la classe parent) doit être déclarée avec le mot clé virtual. Ceci indique qu'il est possible de surcharger cette méthode dans un but de polymorphisme.

Par exemple, soit la déclaration de la classe Personne suivante:

public class Personne
{
   	protected  double age;
 	protected  string name;
    public Personne(double age, string name)
 	 {
 	    this.age = age;
 	    this.name = name;
 	 }
    public virtual void BoireUnCafé()
 	 {
 	    Console.WriteLine("{0}  boit un café.  Glou glou.",name);
 	 }
    public virtual void SeDéplacer(double distance, double vitesse)
 	 {
 	    double  temps = (distance/vitesse) / (24*365);
 	    age  += temps;
 	    Console.WriteLine("{0}  s'est déplacé pendant {1} années de sa vie.",
 	    name,  temps);
 	 }
} 

Une personne a donc un nom et un âge et elle peut boire un café et se déplacer (ce qui incrémente son âge en fonction de la distance couverte et de la vitesse de déplacement – le déplacement prend du temps, quand on se déplace on vieillit (on n'y pense pas souvent à votre âge, mais c'est quand même une réalité)).

On peut définir une classe Professeur, qui dérive de Personne:

public  class Professeur :Personne
{
    private  string codeEmp;
    public Professeur(double age, string name,  string codeEmp) :base(age, name)
    {
 	    this.codeEmp  = codeEmp;
    }
    public override void SeDéplacer(double distance, double vitesse)
    {
 	    if  (vitesse > 10)
 	    {
 	    	Console.WriteLine("Un  pauvre prof à pied ne peut pas se déplacer à {0} km/h...",
 	    	vitesse);
 	    	Console.WriteLine("{0}  reste où il est.",name);
 	    }
 	    else
 	    {
 	    	double  temps = (distance/vitesse) / (24*365);
 	    	age  += temps;
 	    	Console.WriteLine("{0} met ses souliers",name);
 	    	Console.WriteLine("{0}  s'est déplacé pendant {1} années de sa vie.",
 	    	name, temps);
 	    }
 	 }
} 	    


Professeur redéfinit la méthode SeDéplacer.  Elle reçoit encore deux doubles (la distance et la vitesse), mais comme un Professeur se déplace à pied vu son inévitable manque d'argent, il n'est pas en mesure de se déplacer à plus de 10 km/h (et encore là, ça prend un professeur en forme!).

Remarquez au passage le constructeur du professeur, qui appelle explicitement le constructeur de personne (à l'aide du mot-clé base). En effet, le constructeur (nul) du parent est automatiquement appelé lors de la création d'un enfant. Toutefois, si on veut lui passer des valeurs, il faut l'appeler explicitement.

La méthode SeDéplacer() est donc redéfinie avec le mot-clé "override". Ainsi, on dit au compilateur SeDéplacer() est une méthode plus spécialisée qui raffine une méthode de la classe de base.

Le but de la spécialisation est l'utilisation du polymorphisme. Lorsque virtual et override sont utilisés, on sait que le compilateur va utiliser la version de la fonction la plus spécialisée possible, selon l'objet vers lequel une variable pointe et non pas selon le type utilisé à sa déclaration.

En effet, si on fait:

Personne etienne;
etienne = new Professeur(29,"Etienne","16");
etienne.SeDéplacer(100,50);
     

C'est la méthode du professeur qui sera appelée, même si etienne est ici déclaré comme une personne. Il pointe tout de même vers un professeur, ce qui est possible vu qu'un professeur est une personne.

     

Pouvez-vous prédire ce qui arrivera dans ce cas:

     
Professeur georges;
georges =  new Personne(59,"Georges");
georges.SeDéplacer(100,50);
     

Pour pousser plus loin, on pourrait également se définir un Étudiant, qui est aussi une personne et qui redéfinit la fonction SeDéplacer() pour utiliser une auto.

public class Étudiant :Personne
{
 	  private  string codePerm;
      public Étudiant(double age, string name, string codePerm) :base(age, name)
 	  {
	 	  this.codePerm = codePerm;
 	  }
      public override void SeDéplacer(double  distance, double vitesse)
 	  {
	 	  double temps = (distance/vitesse) / (24*365);
	 	  age += temps;
	 	  Console.WriteLine("{0}  démarre son auto",name);
	 	  Console.WriteLine("{0}  s'est déplacé pendant {1} années de sa vie.",
	 	  name, temps);
 	  }
} 

Tout comme le Professeur, l'Étudiant a un attribut de plus qu'une personne:  un code permanent.  Son constructeur fonctionne donc de la même façon que celui du Professeur.

La fonction SeDéplacer() de l'Étudiant est pratiquement la même que celle d'une Personne, à la différence qu'on spécifiera que l'Étudiant utilise son auto. 

Notez que l'on pourrait améliorer ces classes enfants en appelant la méthode SeDéplacer de la classe parent pour faire le vrai travail. En effet, tout ce que les versions override font c'est d'afficher un message puis d'appliquer le même algorithme. Pour ce faire, on pourrait simplement remplacer les lignes qui modifient l'âge par un simple base.SeDéplacer(distance, vitesse).

Les tableaux en C#

Pour pouvoir illustrer la beauté du polymorphisme, l'exemple typique consiste à utiliser un tableau d'objets généraux qu'on fait pointer vers des objets spécialisés.

Un tableau en C# est un objet à part entière, une instance de la classe System.Array. Toutefois il n'est pas permis d'instancier un Array directement (en réalité Array est une classe abstraite), on doit utiliser les éléments du langage prévu à cet effet. Par exemple:

int[] tableau; // Déclaration d'un tableau d'entiers. 
// À ce point-ci tableau pointe vers null.


tableau = new int[5]; // Instanciation du tableau.
// À ce point-ci, tableau pointe vers un tableau de 5 entiers non-initialisés, donc contenant des 0.

Les avantages de traiter un tableau comme un objet contenant une collection de données plutôt que comme un ensemble de données placées de façon contiguë en mémoire sont évidents. Premièrement, pas besoin de se casser la tête avec la gestion de la mémoire (et pas de risque de lire des données erronnées si on sort des bornes du tableau). Deuxièmement, notre objet tableau contient tout un tas de méthodes et de propriétés héritées de System.Array qui peuvent nous être fort utiles dans la gestion et la manipulation de nos données.

On peut initialiser les données du tableau à l'instanciation en faisant simplement:

tableau = new int[5] {1,3,45,-10,133};

(à ce moment on peut omettre l'indice dimensionnel du tableau qui sera ajusté au nombre de données.)

On accède à une case du tableau par son index, en faisant tableau[0] = 12 par exemple. Notez que l'indice dimensionnel donné à l'instanciation du tableau représente le nombre d'éléments et que ces éléments sont numérotés à partir de 0.

Et le polymorphisme?

En reprenant les classes données en exemple plus haut, on peut se déclarer un tableau de personnes ainsi:

Personne[] tableau;
tableau = new Personne[3];

Notez qu'à ce point-ci, tableau pointe vers un tableau de trois personnes, mais chacune de ces personnes (tableau[0], tableau[1] et tableau[2]) pointe vers null puisqu'elles n'ont pas encore été instanciées.

On peut les instancier en faisant:

tableau[0] = new Professeur(33,"Etienne",16);
tableau[1] = new Etudiant(18,"Georges","GEOR88110211");
tableau[2] = new Personne(45,"Roger");

Finalement, on peut faire une boucle pour demander à tout ce monde-là de se déplacer:

for (int i=0; i<tableau.GetLength(0); i++)

	tableau[i].SeDéplacer(10000,20);

Chaque objet du tableau se déplacera à sa façon. C'est là la puissance du polymorphisme. Ceci nous permet également de définir une fonction comme acceptant un objet général en paramètre, puis de lui passer un objet plus spécialisé sans problème. Comme les seuls membres qui pourront être accédés sont des membres de l'objet général, on est certain que tout fonctionnera et que si une méthode a été "overridée" la version la plus spécialisée sera utilisée automatiquement.

La classe abstraite

Une classe abstraite est une classe qui ne sert qu'à être dérivée.  Il est impossible de créer une instance d'une classe abstraite.  La classe abstraite n'est là que pour donner des attributs et des signatures de méthodes qui devront être implémentées par ses descendants. Elle représente en quelque sorte un contrat, stipulant que tous ses enfants implémenteront ces méthodes et propriétés (et hériteront de ces attributs). 

Quelques règles de base:

L'utilisation d'une classe abstraite n'a qu'un but de design.  Elle est utilisée lorsque l'on veut créer une généralisation qui ne sera jamais instanciée, qui nécessitera une spécialisation pour exister – mais une spécialisation faite selon des règles précises. Par exemple:

public abstract class GUIObject
 {
 	// Constructeur  acceptant deux paramètres
 	// pour positionner  l'objet sur la console
 	public GUIObject(int top, int left)
 	{
 		this.top = top;
 		this.left = left;
 	}
	// Simule le dessin  de l'objet
    // Remarquez:  aucune implémentation ici!
    abstract public void DrawGUIObject();
	// Attributs qui  seront passés aux spécialisations

    protected int top;
    protected int left;
}

Notez qu'une classe concrète qui hérite d'une classe abstraite peut avoir d'autres spécialisations qui n'implémenteront pas nécessairement les méthodes de la classe abstraite.  C'est la limite de l'abstraction en C#.

La base de tout objet:  la classe System.Object

En C#, on ne peut hériter que d'une seule classe.  Toutefois, il y a un héritage implicite qui est fait à toute classe, qu'elle hérite déjà d'une classe ou non:  celui de la classe System.Object (c'est le cas dans tous les langages .NET).

Object est en quelque sorte la racine de toutes les classes en C#.  C'est Object qui contient les méthodes Equals, GetHashCode, GetType et ToString qui sont présentes dans toutes les instances de n'importe quelle classe que vous créez.  Ces méthodes n'arrivent pas de nulle part:  elles arrivent par héritage d'Object.

Lorsque vous créez une classe complète, on vous recommande de redéfinir (par un override) ces quatre méthodes afin que votre classe soit polymorphique et puisse implémenter toutes les méthodes standard d'un objet. C'est évidemment facultatif mais c'est souvent une bonne idée, particulièrement ToString et Equals (on y reviendra).

La surcharge des opérateurs

Afin de maximiser les usages du polymorphisme, on peut également l'appliquer sur les opérateurs afin d'en définir une version appropriée à notre objet.  Les opérateurs les plus souvent surchargés sont les opérateurs de comparaison (==, !=, <, <=, >, >=).  On surchargera parfois aussi les opérateurs arithmétiques (+, -, *, /, %), particulièrement si on définit une classe avec des applications mathématiques.  Les opérateurs arithmétiques composés (+=, -=, *=, /= et %=) seront automatiquement surchargés lorsque leurs cousins simples le sont. 

Lorsque vous utilisez l'opérateur + pour concaténer deux strings, en réalité vous utilisez une surcharge du +.  En effet, par défaut le + additionne deux nombres de type primitif.  La surcharge dans System.String permet de l'utiliser pour la concaténation, ce qui rend son utilisation plus intuitive.

Vous pouvez également utiliser / pour diviser des nombres entiers ou décimaux - c'est un autre exemple de surcharge (certains langages (comme VB.NET) utilisent deux opérateurs différents (/ et \) pour remplir ces deux fonctions).

Il est conseillé lorsque l'on crée un objet de surcharger les opérateurs si leur nouvelle utilisation est intuitive.  On ne veut pas surcharger juste pour le plaisir.  On pourrait par exemple décider que l'opérateur ++, lorsqu'utilisé avec un objet Client (qui contient le dossier d'un client) ajoutera 1000$ à sa limite de crédit.  Toutefois c'est un usage assez mauvais de l'opérateur ++ et ça risquerait de causer plus de confusion que d'utilité.  Le bon sens est de mise!

Voyons quelques exemples. Soit la classe Fraction suivante, permettant de conserver une fraction sous forme 3/4 plutôt que sous forme décimale:

public class Fraction
 {
       	// constructeurs
       	public Fraction(int n, int d)
       	{
            numérateur  = n;
            dénominateur  = d;
       	}

    	// constructeur  copie
       	public Fraction (Fraction f)
       	{
       	    numérateur  = f.numérateur;
       	    dénominateur  = f.dénominateur;
       	}
    	
	public Fraction(int  n)
       	{
       	    numérateur  = n;
       	} 
		
	public Fraction()
	{
	}

      	public override string ToString()
       	{
             return(numérateur.ToString() + "/" + dénominateur.ToString());
       	}

    	public Fraction  Multiplication(Fraction f)
       	{
       	    return new  Fraction(numérateur*f.numérateur, 
       		dénominateur*f.dénominateur);
       	}
    
		public static Fraction  Multiplication(Fraction f1, Fraction f2)
       	{
       		return  new Fraction(f1.numérateur*f2.numérateur,
       		f1.dénominateur*f2.dénominateur);
       	}

	private int numérateur;
       	private int dénominateur = 1;
 }

Remarquez les différents constructeurs (tout le monde se souvenait de l'utilité d'un constructeur copie, n'est-ce pas?) et l'override de la méthode ToString.

Remarquez également la surcharge de la méthode multiplication, une version d'instance et une version statique (tout le monde se rappelle qu'une méthode statique est appelée à partir de la classe et non pas à partir d'une instance précise, n'est-ce pas?).

En plus de vous rafraîchir la mémoire sur certains de ces concepts de base, la Fraction est un excellent exemple à utiliser pour démontrer la surcharge des opérateurs.

La première chose à implémenter serait l'opérateur ==, nous permettant de tester rapidement si deux fractions sont égales. On peut décider que deux fractions sont égales lorsqu'elles ont le même numérateur et le même dénominateur. On peut aussi décider qu'elles sont égales si elles équivalent au même nombre décimal (par exemple 3/4 == 6/8). C'est en fait un choix de design qui pourrait se discuter, tout dépendra de notre vision de la chose et de l'application qu'on en fera.

Supposons que nous options pour la deuxième façon de faire. On fera alors:

public static bool operator == (Fraction f1, Fraction f2)
 {
       if  ((double) f1.numérateur/f1.dénominateur == 
            (double) f2.numérateur/f2.dénominateur)
           return true;
       else
           return  false;
 }
     

Notez que:

Pour que le programme accepte d'être compilé, il faudra également surcharger l'opérateur !=, question d'éviter toute confusion possible (ça aussi ça fait bien du sens quand on y réfléchit!). C'est tout simple, quand on a l'un, on a l'autre:

public static  bool operator != (Fraction f1, Fraction f2)
 {
      return  !(f1==f2);
 }	

L'égalité et le problème des objets null

Nous savons déjà que comme tout objet est de type référence, il n'est en fait rien d'autre qu'un pointeur sur un objet.  Nous savons aussi qu'il est toujours possible que ce pointeur pointe vers "null", lorsqu'il ne pointe nulle part en particulier.

Un problème survient une fois que l'on a redéfini l'opérateur == et que l'on tente de tester si un objet est null.  Si par exemple on fait quelque chose comme: if (fnull == null), tout compile mais le programme plante.

Pourquoi?  Rappelez-vous:  par défaut, deux références sont égales lorsqu'elles pointent au même endroit.  Si on fait pointer fnull vers null, fnull et null sont considérés égales. Le problème c'est qu'on a redéfini l'égalité donc lorsqu'on teste si fnull est égal à null, on utilise notre version.  Et notre version ne regarde pas où pointe la référence mais compare son contenu – contenu qui n'existe pas dans le cas d'une référence nulle, d'où erreur!

Il faudrait donc en tenir compte dans notre définition d'égalité.  Serait-il sage de simplement ajouter au début de notre opérateur == quelque chose comme if (f1 != null && f2 != null), selon vous?

Le réponse est non.  En effet, on ne règle pas le problème puisque pour s'assurer que les opérandes sont non nulles, on appellera l'opérateur !=.  Ce dernier donnera le contraire de ==, qui sera rappelé.  En entrant de nouveau dans ==, on appellera != pour s'assurer que nos opérandes sont non nulles...  Ainsi de suite jusqu'à ce que débordement de la pile d'exécution s'en suive.  Le problème n'est donc pas réglé, il est simplement modifié.

Ce qu'il nous faudra alors utiliser c'est la méthode ReferenceEquals(), qui est une méthode statique de System.Object.  Elle retourne vrai si les deux références passées en paramètres pointent vers la même chose, faux sinon.  C'est exactement ce dont on a besoin.

On fera donc (les ajouts sont en gras):
 	  public static  bool operator == (Fraction f1, Fraction f2)
 	  {
 	      if (ReferenceEquals(f1,null) &&  ReferenceEquals(f2,null))
 	          return  true;
 	      else
 	          if  (ReferenceEquals(f1,null) || ReferenceEquals(f2,null))
 	              return false;
 	          else
 	              if ((double) f1.numérateur/f1.dénominateur == 
 	                  (double) f2.numérateur/f2.dénominateur)
 	                  return true;
 	              else
 	                  return false;
 	  }

Ainsi, lorsque deux objets null sont comparés, on dira qu'ils sont égaux (ce qui règle notre problème).  Si seulement un des deux objets est null, on sait qu'ils ne sont pas égaux.  Si aucun des deux n'est null, on pourra alors vérifier leur valeur comme on faisait initialement.

La surcharge de la méthode Equals

La méthode Equals est hérité de System.Object et le compilateur nous donne un avertissement lorsque l'on redéfinit == et pas Equals (remarquez que tout compile et fonctionne tout de même normalement).

L'idée est que si on change la façon de déterminer l'égalité, on devrait également changer la méthode Equals pour refléter ce fait. En effet, Equals peut être appelée à partir de n'importe quel objet. Supposons qu'une variable o est déclarée comme étant un Object et qu'on la fait éventuellement pointer sur une Fraction. On aimerait bien à ce moment qu'un appel à o.Equals(o2) utilise notre verison de l'égalité plutôt que la version par défaut (qui compare les pointeurs tout simplement). À ce moment, une bonne idée est de la redéfinir (avec un simple override) pour qu'elle teste si l'objet reçu en paramètre est une fraction. Si oui, on peut appeler notre opérateur == pour les comparer, sinon on sait que ces deux objets ne sont pas égaux. Le code pour ce point est laissé à la discrétion de l'étudiant!

Surcharge d'un opérateur arithmétique

Vous aurez remarqué précédemment les deux versions de la fonction Multiplication de la Fraction. Bien que ces deux méthodes soient valables, pourquoi ne pas simplement surcharger l'opérateur * pour faire le même travail, de façon beaucoup plus intuitive? Voilà comment (c'est très simple):

public static Fraction operator *(Fraction f1, Fraction f2)
{
 	  return  new Fraction(f1.numérateur*f2.numérateur,
 	    f1.dénominateur*f2.dénominateur);
}
Remarquez:

Un peu de surcharge...

On peut très bien créer plusieurs versions de notre opérateur *, question de permettre à une Fraction d'être multipliée par autre chose qu'une autre Fraction. Par exemple, pour permettre la multiplication d'une Fraction par un entier, on peut faire:

public static Fraction operator *(Fraction  f1, int i)
{
 	return  new Fraction(f1.numérateur*i,
 	   f1.dénominateur);
} 

Si on voulait créer une Fraction arithmétiquement complète, que nous manquerait-il encore?

Les opérateurs de conversion

Lorsque vous faites ceci:

int i = 23;
long l = i;

i est converti automatiquement (et implicitement) en long, puisque C# sait qu'aucune donnée ne sera perdue dans l'opération.

À l'opposé, si vous faites ceci:

long  l = 23;
int i = (int) l;

vous devez faire une conversion explicite puisqu'un long sera tronqué pour pouvoir entrer dans un int – en utilisant la conversion explicite vous dites à C# que vous savez ce que vous faites et que cette opération n'affectera pas vos données, ce qui permet à votre code de compiler.

Comment C# sait-il ce qui peut être converti sans problème et ce qui ne le peut pas?  Comment sait-il de quelle façon faire ses conversions?  Toutes ses réponses sont dans les opérateurs de conversion.

Il en existe deux:  "implicit" et "explicit".  Dans les deux cas, on n'utilise jamais l'opérateur en le nommant ou en le représentant avec un symbole, comme pour *.  L'opérateur "implicit" est appelé automatiquement lorsqu'on tente de convertir une donnée d'un type en un autre implicitement.  L'opérateur "explicit" est appelé lorsqu'on fait du "casting".

Conversion implicite

On peut en définir plusieurs versions pour un même objet afin de permettre sa conversion en différents types (ou la conversion d'autres objets en notre type).  Par exemple, on pourrait très bien définir un opérateur de conversion implicite de notre Fraction à un double, ainsi qu'un autre qui fait l'inverse.  On ferait alors:

public static implicit operator double(Fraction f)
{
 	return (double) f.numérateur/f.dénominateur;
}

À remarquer:

Maintenant que notre opérateur de conversion implicite existe, on peut utiliser une Fraction partout où on a le droit d'utiliser un double.  La conversion de l'un à l'autre se fera automatiquement.  Ça nous permet entre autres de faire:

Fraction f = new Fraction(3,4);
double d = f;
Console.WriteLine("f en double:  {0}",d);

On peut également passer une Fraction directement à la place d'un double, comme dans la fonction System.Math.Round, qui peut accepter un double comme nombre à arrondir et un entier pour la précision voulue:

Console.WriteLine("f arrondi:  {0}",Math.Round(f,1));

Bref, partout où on a besoin d'un double, on pourra maintenant utiliser une Fraction.

On pourrait également créé une surcharge de l'opérateur de conversion implicite qui accepterait un double et retournerait une Fraction (un autre exercice laissé à l'étudiant!).

Conversion explicite

On se définira un opérateur de conversion explicite lorsque la conversion effectuée risque de modifier la donnée initiale (par exemple en l'arrondissant ou en la tronquant).  Dans le cas de notre Fraction, une conversion en nombre entier doit être explicite, puisque la Fraction sera possiblement arrondie.  Ainsi, l'utilisateur qui désire passer de la Fraction à l'entier devra faire un "casting" s'il juge que sa donnée ne sera pas modifiée où si la modification ne lui dérange pas.  S'il ne le spécifie pas, C# refusera de compiler.

La déclaration de l'opérateur se fait exactement de la même façon, mais en utilisant "explicit" au lieu de "implicit":

public static explicit operator int(Fraction f)
{
 	 return  f.numérateur/f.dénominateur;
}

Cette fois-ci, on n'a pas besoin de convertir le résultat de notre division puisque par défaut elle retournera un résultat entier (arrondi). Maintenant que notre opérateur est défini, on peut l'utiliser en faisant:

int i = (int) f;
Console.WriteLine("f en  int:  {0}",i);

Remarquez le "casting", qui est obligatoire.

On peut aisément définir l'opérateur inverse, qui transforme un entier en Fraction.  Celui-ci peut être implicite puisque l'opération ne modifie pas la donnée initiale:

public static implicit operator Fraction(int i)
{
    return new Fraction(i,1);
}

Dans ce cas, l'opérateur de conversion implicite retourne une Fraction à partir d'un entier.  La fraction est simplement bâtie avec l'entier au numérateur et 1 au dénominateur.

Maintenant on peut passer un entier partout où on demande une Fraction, la conversion sera faite automatiquement. Cela peut nous éviter de définir des surcharges pour tous nos opérateurs comme on avait fait initialement pour la multiplication!