Les classes

Une extension de la notion de type

Un type de données, ou simplement type, désigne la conjugaison de deux aspects fondamentaux de la programmation :

  1. un regroupement de données, on parle d’agrégation, et,

  2. des méthodes pour intialiser ces données et les manipuler.

Par exemple le type int décrit comment stocker un nombre entier signé sur 32 bits (les données) ainsi que les opérateurs communs pour manipulers ces nombes +, *, - … (les méthodes). Notez que le type int, n’existe pas en mémoire, c’est la variable de type int qui va déclencher la réservation d’une case mémoire pour son stockage. Le type int est unique mais permet la création de plusieurs variables de type int.

En dehors des types de base comme les types numériques, il vous est possible de créer ou d’utiliser d’autres types de données à l’intérieur de vos programmes. Ces types plus complexes seront désignés sous le terme de classes. Dit autrement, une classe est une description d’un nouveau type de données. Tout comme pour le type int, une classe est unique mais permet de créer (ou instancier), plusieurs objets (ou instances).

Attention

La classe et l’objet sont des concepts liés mais intrinsèquement différents. Par exemple, prenons un objet réel comme une voiture. Le modèle numérique de la voiture décrit sa forme, ses fonctions, comment elle doit être fabriquée. Ce modèle n’est pas une voiture. De la même manière une classe n’est pas un objet.

Ceci n'est pas une pipe.

René Magritte « La Trahison des Images »

Il y a 1 type pour 0 à n objets créés. A partir d’un plan, plusieurs pièces peuvent être créées, ou aucune aussi. Les pièces ainsi créées partagent les informations décrites dans le plan. Les pièces existent indépendamment. Une fois la pièce A intégrée dans une voiture, elle n’est plus en relation avec la pièce B assemblée dans un avion. Ces deux pièces sont identiques au sens où elles sont deux pièces du même modèle. Cependant elles sont différentes au sens où elles vont vivre indépendamment. Ainsi la pièce B pourra être remplacée car cassée au bout d’un an alors que l’autre pourra vivre dix ans.

Les objets peuvent avoir des attributs propres, dans ce cas, ses attributs doivent être décrits dans le plan. Par exemple, un autoradio contient une liste de stations préférées configurées par l’utilisateur. Le modèle de l’autoradio indique combien de canaux préférés peuvent être choisis cependant aucun n’est établi avant que l’utilisateur ne les choisissent. De même, une voiture C3 est éditée suivant plusieurs couleurs de carrosserie et plusieurs types de moteurs. Pourtant une C3 noire (on parle ici d’un objet) et une C3 blanche (on parle toujours d’un objet) reste des voitures du modèle C3. Il en va de même pour une C3 diesel et une C3 essence.

Note

Nous dirons parfois un objet cercle au lieu d”un objet de type Cercle.

La déclaration d’une nouvelle classe se fait grâce au mot clef class suivi d’un identificateur : le nom de la nouvelle classe. Le corp de la classe (son contenu) est placé entre accolades. En général, une classe A est déclarée directement dans un fichier portant le même nom A.cs (une classe par fichier). Par convention, les identificateur de classe utiliseront également la norme CamelCase avec la premier lettre en majuscule.

Syntaxe

class Identificateur{
        // corps de la classe
}

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

  • On ne peut instancier qu’un objet à partir d’une classe.

  • Une classe décrit un type de données.

  • Une classe permet de créer plusieurs objets de même caractéristiques.

  • Une classe peut porter plusieurs noms différents.

Note

Les recommendations de nommage pour les classes sont traitées ici .

Champs et méthodes d’instance

Une variable propre à un objet (la couleur, l’immatriculation de la voiture) est qualifiée de champ d’instance (ou variable d’instance, ou attribut ou instance field). Entre deux objets de type Voiture, ces paramètres peuvent avoir des valeurs différentes. Une fonction propre à un objet est qualifiée de méthode d’instance (method). Cette méthode doit être appelée depuis une instance particulière et n’influe que sur le comportement de cet objet : les différentes instances d’une même classe sont indépendantes.

Les champs et les méthodes d’instances d’une classe sont appellés les membres d’instance (instance members) de la classe. Chaque membre d’instance est précédé d’un qualificatif de visibilité qui donne les droits d’accès depuis l’extérieur, c’est-à-dire depuis du code ne se trouvant pas la classe courante (celle que l’on est en train d’écrire). Le qualificatif public ouvre l’accès sans restriction. Pour le moment, on utilisera le qualificatif public mais nous reviendrons là-dessus dans le chapitre Encapsulation.

La déclaration de champs et de méthodes d’instance se fait à l’intérieur de la classe:

Syntaxe

class Identificateur{

        // déclaration des champs d'instances
        public TypeChamp1 identificateurChamp1;
        public TypeChamp2 identificateurChamp2;
        ...

        // déclaration des méthodes d'instances
        public TypeDeRetour1 IdentificateurMethode1(TypeParametre11 identificateurParametre11, ...) {...}
        public TypeDeRetour2 IdentificateurMethode2(TypeParametre21 identificateurParametre21, ...) {...}
        ...
}

Exemple:

class Voiture{

        public int couleur ;
        public int puissance ;

        public void Demarrer() { ... }
        public void Arreter() { ... }
}

L’ordre de déclaration des membres d’une classe n’a pas d’importance. Par convention, on déclarera les champs d’instance en premier, puis les méthodes.

  • Tous les objets d’une même classe possèdent les mêmes champs et les mêmes méthodes d’instance.

  • Tous les champs des objets d’une même classe possèdent des valeurs identiques.

  • Lorsque je déclare un champ d’instance, le CLR va réserver de la mémoire pour ce champs au lancement du programme.

  • Variable d’instance est un synonyme de champ d’instance.

  • Les champs et les méthodes d’instance font partis des membres d’instance d’une classe.

  • Une classe doit contenir des champs d’instance.

  • Une classe doit contenir des méthodes d’instance.

Afin de facilement identifier le rôle d’un membre d’instance, on pourra s’en tenir aux conventions de nommage données dans le guide de programmation C#.

Création d’un objet

La création d’un objet, ou instanciation, est réalisé avec l’opérateur new qui retourne une référence vers le nouvel objet créé.

Syntaxe

identificateurVariable = new IdentificateurClasse(parametre1, parametre2, ...);

Il y a deux actions effectuées :

  1. Création d’un objet de type IdentificateurClasse en utilisant les paramètres éventuels : c’est la partie à droite du signe égale;

  2. Initialisation de la variable identificateurVariable avec la référence retournée par l’opérateur new.

Exemple:

/* déclaration d'une variable de type Bitmap
        et initialisation avec la référence vers un nouvel objet de type Bitmap avec les paramètre (400, 200)*/
Bitmap image = new Bitmap(400,200);
/* déclaration d'une variable de type Voiture
        et initialisation avec une référence vers un nouvel objet de type Voiture */
Voiture voiture = new Voiture();

Attention

Les classes font parties de la catégorie type référence. Cela signifie qu’une variable dont le type correspond à une classe ne contient pas directement un objet mais une référence vers un objet. Ce lien de référence est généralement représenté par une flêche et on dit que la variable référence l’objet ou pointe vers l’objet (en référence à la notion de pointeur en C/C++).

Par abus de langage, une variable de catégorie type référence sera simplement appelée référence. Ainsi, dans l’exemple précédent que image est une référence de type Bitmap.

Considérons le code :

class C{
        public int i;
        public bool b:
}

...

int v = 2;
C c = new C();

La mémoire contient 2 variables, v et c, et un objet de type C. Elle peut être représentée ainsi:

../_images/ex1.svg

Représentation schématique de l’état de la mémoire du programme.

En pratique, la mémoire de l’ordinateur est divisée en octets. Chaque octet reçoit un numéro d’identification unique allant de 0 jusqu’à la taille maximale physiquement disponible (la mémoire peut être vue comme un tableau d’octets). Ce numéro est l’adresse en mémoire. L’adresse identifie ainsi un endroit unique dans la mémoire. Pour stocker un objet, le système réserve un bloc de mémoire. Ainsi, un objet de 10 octets peut occuper les cellules contigües 80, 81, 82… 89. La référence va contenir l’adresse de la première cellule soit la valeur 80. Durant les exercices, plutôt que de créer des adresses fictives, il est plus simple de schématiser la référence comme une flêche vers une autre zone de la mémoire.

  • Si je crée trois références alors elles désignent trois objets différents.

  • Il peut exister plusieurs variables qui référencent un même objet.

  • Il existe trois catégories de variables : valeur, référence et objet.

  • La déclaration de variable A v; ou A est un identificateur de classe ne provoque pas la création d’un objet de type A.

  • Pour créer un nouvel objet à partir d’un objet existant, je déclare une variable que j’initialise avec une référence vers cet objet.

  • L’instruction Voiture v = v; est-elle valide ?

  • Les instructions Voiture v = new Voiture(); v=v; sont-elles valides ?

Opérateur d’accès

L”accès aux membres d’instances, champs et méthodes, se fait à travers l’utilisation d’une référence sur l’objet concerné et de l’opérateur d’accès point (dot) .

Syntaxe

// accès à un champ d'instance
reference.identificateurChamps

// appel d'une methode d'instance
reference.IdentificateurMethode(parametre1, parametre2, ...)

Exemple:

Voiture referenceVoiture = new Voiture();
referenceVoiture.Demarre();
int p = referenceVoiture.puissance;

Une référence qui contient la valeur spéciale null ne désigne aucun objet. Tenter d’appliquer l’opérateur d’accès . sur une référence null est une erreur de programmation qui provoque un crash du programme à l’exécution.

Instance courante

Nous venons de voir que toutes les méthodes d’instance sont appelées à partir d’une référence (à gauche de l’opérateur . ). Dans la méthode d’instance, cette référence est appelée instance courante et est désignée par le mot clé this. L’instance courante est une référence sur l’objet sur lequel on est en train de travailler.

Exemple:

class Compteur
{
        public int valeur;

        public void SetValeur(int nouvelleValeur)
        {
                this.valeur = nouvelleValeur;  // this.valeur désigne le champ valeur de l'objet sur lequel la méthode SetValeur est appelée
        }
}

Compteur compteur = new Compteur();
compteur.SetValeur(10);

Un objet de type Compteur est créé. Cet objet est référencé par la variable compteur. L’opérateur d’accès est appliqué sur la référence compteur. Il permet ainsi d’accéder à la fonction SetValeur() de l’objet concerné. L’opérateur d’accès fixe l’objet référencé comme instance courante lors de l’exécution de la fonction SetValeur() (this est égal à compteur).

Afin de simplifier l’écriture, on peut accéder aux membres d’instance de l’instance courante sans utiliser la référence this et l’opérateur .. Si on ne précise pas de référence avec l’opérateur . devant un nom d’identificateur qui correspond à un membre d’instance, le compilateur ajoutera automatiquement this. devant l’identificateur. Le code suivant est donc équivalent au précendent:

class Compteur
{
        public int valeur;

        public void SetValeur(int nouvelleValeur)
        {
                valeur = nouvelleValeur;  // équivalent à this.valeur = ...
        }
}

Note

A l’intérieur du code d’une méthode d’instance, l’accès aux membres de l’instance courante peut donc se faire directement sans passer par l’opérateur d’accès. Les normes d’éciture privilégient généralement cette facilité. Une exception notable apparait lorsqu’il est nécessaire de faire la différence entre un champ d’instance et une variable locale ayant le même identificateur.

class Compteur
{
        public int valeur;

        public void SetValeur(int valeur) // le paramètre à le même nom que le champ
        {
                this.valeur = valeur;  // this.valeur désigne le champ d'instance, alors que valeur désigne la variable locale
        }
}

Indépendance entre objets

Si l’on crée deux instances de la classe Compteur :

Compteur c1 = new Compteur();
Compteur c2 = new Compteur();

Deux zones mémoires sont réservées pour stocker chaque instance. Deux références c1 et c2 sont créées et associées à chaque objet. On peut représenter la mémoire de la façon suivante :

../_images/ex2a.svg

Si on exécute l’instruction:

c1.SetValeur(10);

L’opérateur d’accès . appliqué sur la référence c1 m’indique que l’instance courante this va être l’objet référencé par c1. Ainsi, c’est le champ valeur de cet objet qui va être modifié et changé pour la valeur 10. Le 2ème objet ne subit aucune modification et la mémoire devient:

../_images/ex2b.svg

Exercices

  • En modifiant une variable v de type référence, je ne modifie pas l’objet référencé par v.

  • Si je change la valeur d’un champ d’instance, l’objet n’est plus considéré comme appartenant à sa classe d’origine.

  • Une fois l’objet créé et ses variables d’instance initialisées, je ne peux plus les modifier.

  • Je peux accéder directement à un objet sans passer par une référence.

  • Si j’appelle une méthode depuis une référence et que cette référence a pour valeur null alors le programme plante.

  • Soient deux références r1 et r2 qui désignent le même objet, si je change r1 pour qu’elle réfère un autre objet, alors r2 désigne aussi ce nouvel objet.

  • Si aucune référence ne désigne un objet, alors je ne peux pas accéder aux attributs de cet objet.

  • Dans une méthode d’instance, this est toujours une référence vers un objet de la classe courante.

  • Dans une méthode d’instance, this.v et v peuvent désigner 2 choses différentes.

Les constructeurs

Chaque classe contient un ou plusieurs constructeurs. Cette fonction particulière est appelée lors de la création d’un objet par l’opérateur new. Le but du constructeur est d’initialiser les champs d’instances de l’objet. Un constructeur est déclaré comme une méthode sans type de retour et dont le nom est identique à celui de la classe :

Syntaxe

public IdentificateurDeClasse(TypeParametre1 identificateurParametre1, TypeParametre2 identificateurParametre2, ...)
{...}

Exemple :

class Cercle
{
        public int x;
        public int y;

        public Cercle() // constructeur sans argument
        {
                x = 10;
                y = 10;
        }

        public Cercle(int x, int y)  // constructeur paramétrique
        {
                this.x = x ;
                this.y = y ;
        }
}

Une même classe peut avoir plusieurs constructeurs qui doivent alors avoir des paramètres différents.

Attention

Si une classe ne contient aucun constructeur, le compilateur ajoutera automatiquement un constructeur sans argument.

Etapes de construction d’un objet

Avant l’appel au constructeur, le programme initialise de manière implicite tous les champs de l’instance en exécutant les initialiseurs de variables. Ces initialiseurs sont toujours exécutés en premier lors de l’instanciation. Ainsi les champs d’instances sont initialisés avant l’exécution du constructeur. Il n’est donc pas possible d’avoir un objet dont un champ n’aurait pas été initialisé. Voici les valeurs par défaut :

  • Numérique : 0

  • Booléen : false

  • Référence : null (la référence ne désigne aucun objet)

Voici l’ordre des opérations effectuées par l’opérateur new:

  1. Réservation d’une zone physique en mémoire pour stocker le nouvel objet,

  2. Initialisation des champs d’instance,

  3. Appel d’un constructeur : constructeur sans argument ou constructeur paramétrique,

  4. Retour d’une référence vers l’objet construit.

Chaînage des constructeurs

Lorsqu’une classe contient plusieurs constructeurs, le contenu de ces derniers est souvent très proche. Par exemple dans le cas de la classe Cercle, les 2 constructeurs sont identiques à l’exceptions des valeurs à droite des signes égals. Pour éviter ce genre de duplication de code (copier/coller de portion de code), il faut chaîner les constructeurs, c’est à dire appeler un constructeur à partir d’un autre constructeur.

En C#, le chaînage se fait lors de la déclaration du constructeur avec le mot clé this:

Syntaxe

public IdentificateurDeClasse(TypeParametre1 identificateurParametre1, TypeParametre2 identificateurParametre2, ...) : this(parametre1, parametre2, ...)
{...}

Pour la classe Cercle, cela donne:

class Cercle
{
        public int x;
        public int y;

        public Cercle() : this(0, 0) // Le constructeur sans argument est chaîné avec le constructeur paramétrique
        { } // tout est fait par l'autre constructeur

        public Cercle(int x, int y)  // constructeur paramétrique
        {
                this.x = x ;
                this.y = y ;
        }
}

Note

Eviter la duplication de code est un principe important de programmation qui facilite la maintenance et permet d’éviter des erreurs. On parle de DRY principle pour Don’t Repeat Yourself en anglais.

Exercices

  • Une classe peut avoir plusieurs constructeurs.

  • Une classe a toujours un constructeur.

  • Un constructeur peut avoir un type de retour s’il s’agit d’une référence.

  • L’opérateur return ne peut pas être utilisé dans un constructeur.

  • Si le développeur ne déclare pas de constructeur sans argument alors le compilateur en ajoute un automatiquement.

  • Lors de l’appel à l’opérateur new, le constructeur sans argument est toujours appelé.

  • Lors de la création d’un objet, les champs d’instance sont initialisés avant la 1ère instruction du constructeur.

  • A la déclaration d’un constructeur, celui-ci est implicitement chainé avec le constructeur sans argument.

  • Je peux réinitialiser un objet grâce au constructeur.

  • La référence vers l’objet courant this n’existe pas dans un constructeur.

  • Un constructeur doit avoir le même nom que la classe où il est déclaré.

  • On doit initialiser la valeur des champs dans un constructeur.

Soit la classe:

class A{
        public int b;
        public string a;
        public A(int b){
                this.b = b;

}

Quelles instructions sont correctes :

  • A a;

  • A a = new A();

  • A a = new A(2);

  • A a = new A(2,"toto");

Soit la classe A:

class A{
        public int i;
        public bool b;
        public A r;
}

Combien la classe A possède-t-elle de constructeurs ?

Soit l’objet créé par l’instruction : new A(), donnez la valeur de ces 3 attributs:

  • i

  • b

  • r

Soit la classe B:

class B{
        int i;
        double d;
        bool b;

        public B(int i, double d)       {
                this.b = (b == false);
                this.i = i;
                this.d = d;
        }

        public B(int i) : this(i, i*2.0)
        {}
}

Combien la classe B possède-t-elle de constructeurs ?

Soit l’objet créé par l’instruction : new B(1), donnez la valeur de ces 3 attributs:

  • i

  • d

  • b

Encapsulation

L’encapsulation consiste à cacher le fonctionnement interne d’un composant pour ne laisser apparaitre que les éléments utils à l’utilisateur.

Pour reprendre l’exemple de la voiture, un conducteur est intéressé par certaines fonctions comme démarrer la voiture, accélerer, freiner… Le fait de savoir qu’en appuyant sur la pédale de frein, cela va directement comprimer le liquide du circuit de freinage ou bien enclencher un compresseur auxiliaire n’a pas d’importance de son point de vue. En fait il est même probablement souhaitable que l’utilisateur ne puisse pas interférer avec ces éléments de mécanique interne. L’utilisateur se voit donc présenter une méthode simple pour freiner (appuyer sur la pédale de frein) et les détails de fonctionnement du mécanisme de freinage sont cachés/encapsulés sous le capot.

En programmation, l’encapsulation va consister à choisir les éléments que l’on laisse apparent pour l’utilisateur, on parle de l’interface, et ceux que l’on cache. Les principaux avantages de cette approche sont :

  • L’utilisabilité : on présente à l’utilisateur une interface claire et épurée qui ne contient que des fonctions utiles à son niveau.

  • La souplesse : les éléments de fonctionnement interne étant cachés, on peut les faire évoluer sans toucher à l’interface présentée à l’utilisateur.

  • La fiabilité : en empêchant l’utilisateur d’interférer avec le fonctionnement interne du programme, on l’empêche également de mal l’utiliser.

  • La clareté du code : en se forçant à identifier les éléments de l’interface et les éléments de fonctionnement interne, on arrive généralement à un code mieux structuré et de meilleure qualité.

Visibilité

En programmation orientée objet, le principal mécanisme de l’encapsulation repose sur les qualificatifs de visibilité. Ces qualificatifs permettent de contrôler si une méthode ou un champ peut être utilisé par une autre classe. Nous avons déjà vu le qualitificatif public qui signifie qu’un membre est accessible sans restriction à tout le monde (il fait parti de l’interface de l’objet). L’opposé du qualificatif public est le qualitificatif private qui signifie qu’un membre n’est accessible que depuis la classe courante, donc caché au monde extérieur. Essayer d’accéder à un membre privé depuis une autre classe génère une erreur de compilation. Par défaut en C#, un membre dont la visibilité n’est pas précisée est privé.

Par exemple:

class A{
        public int champPublic = 1;
        private int  champPrive = 2;

        public void MethodePublique(){}
        private void MethodePrivee(){}

        public void Test()
        {
                A a = new A();
                champPublic++;
                champPrive++;      // ok car nous sommes dans la classe A
                a.champPrive++;    // on peut accéder aux membres prives d'un autre objet de type A
                MethodePublique();
                MethodePrivee();    // ok car nous sommes dans la classe A
                a.MethodePrivee();  // on peut accéder aux membres prives d'un autre objet de type A
        }
}

class B{
        public void Test()
        {
                A a = new A();
                a.champPublic++;        // ok car champPublic est public
                // a.champPrive++;      ERREUR car nous sommes dans la classe B
                a.MethodePublique();
                // a.MethodePrivee();   ERREUR car nous sommes dans la classe B

        }
}

Attention

Le mot clé private n’est pas un élément de sécurité: c’est un guide fort pour les développeurs mais un utilisateur mal intentionné pourra toujours le contourner !

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

  • Les membres d’une classe dont la visibilité n’est pas précisée sont considérés comme privés.

  • Les seuls membres privés auxquels je peux accéder sont ceux de l’instance courante this.

  • Une classe B peut accéder aux membres privés d’une classe A si elles sont déclarées dans le même fichier.

  • Dans une même classe, je ne peux pas accéder à un membre privé depuis un membre public.

  • Les règles de visibilité sont vérifiées à la compilation.

Les propriétés

La problématique qui nous intéresse ici concerne l’intégrité des données. Par exemple, dans un programme de gestion d’un magasin en ligne, on a une classe Article avec un champ prix. Comment peut-on faire pour assurer que le prix d’un article est toujours strictement positif ? Si on déclare le champ prix public alors une autre personne pourrait, par inadvertance ou par malhonnêteté, assigner un prix négatif à un objet.

La solution adoptée dans certains langage (Java/C++) est de déclarer le champ privé avec le mot clé private et de déclarer des méthodes dites accesseurs :

  • getter pour récupérer la valeur du champ, et

  • setter pour modifier sa valeur.

class Article{
        private double prix=10;                  // champ privé

        public void SetPrix(double nouveauPrix){ // Setter
                if(nouveauPrix>0) // contrôle de validité
                        prix = nouveauPrix;
        }

        public double GetPrix(){                 // Getter
                return prix; // pas de contrôle ici
        }
}

...

Article a = new Article();
a.SetPrix(20);                                     // Passage par le setter obligatoire
WriteLine("Prix de l'article : " + a.GetPrix());   // Passage par le getter obligatoire
a.SetPrix(-10);                                    // Sans effet grâce au contrôle effectué par le setter

L’approche par accesseurs comportent des inconvénients:

  • cela alourdit le code,

  • la maintenance est compliquée car il est difficile de transformer un champ public en un champ privé avec accesseurs dans un programme (le champ public fait parti de l’interface du programme: toute modification sur l’interface peut casser les programmes qui utilise cette classe),

  • le nommage des accesseurs n’est pas parfaitement standardisé.

La solution proposée en C# sont les propriétés. Une propriété est une nouvelle catégorie de membre qui s’utilise comme un champ mais génère automatiquement des appels vers des fonctions accesseurs.

Avec une propriété, l’exemple de la classe Article s’écrit :

class Article{
        double prix ; // champ privé qui contient le prix de base

        // début de la définition de la propriété
        public double Prix {
                get {  // définition du getter
                        return prix;
                }
                set {  // définition du setter
                        if(value>0) // value est un mot clé qui désigne le paramètre du setter
                                prix = value;
                }
        }
        // fin de la définition de la propriété
}

...

Article a = new Article();
a.Prix = 20;                                // génère un appel à set avec value=20
WriteLine("Prix de l'article : " + a.Prix); // génère un appel à get

La syntaxe générale pour déclarer une proriété est la suivante:

Syntaxe

Visibilite TypeDeDonnees IdentificateurDePropriete {
        get {
                ...
                return valeurDeLaPropriete;
        }
        set {
                ... = value;
        }
}

Notez que l’on peut déclarer une propriété sans getter ou sans setter.

Par convention, on donnera toujours le même nom à la propriété et au champ privé qu’elle cache, en faisant commencer le nom du champ par une minuscule et celui de la propriété par une majuscule.

Les propriétés allègent le code et résolvent les problèmes de nommage, mais elles opacifient le code avec des appels de méthodes implicites.

En générale les propriétés ont une visibilité publique, leur but étant de fournir une interface vers l’extérieur de la classe. Néanmoins, on peut modifier individuellement la visibilité des accesseurs. Par exemple, pour avoir une propriété en lecture/écriture pour la classe mais en lecture seule pour les autres classes :

private double prix ;
public double Prix {
        get {...}          // getter public
        private set {...}  // setter privé
}

Dans le cas ou une propriété n’a rien d’autre à faire que de retourner ou affecter directement la valeur d’un champ (sans modification, ni contrôle), on peut utiliser une forme simplifiée dite propriété auto-implémentée.

Ainsi la définition:

public double Prix { get ; private set ; } // propriété auto-implémentée avec modificateur privé

est équivalente à (à la différence qu’aucun champ prix n’est accessible même en privé):

private double prix ;
public double Prix {
        get { return prix ; }
        private set { prix = value ; }
}

Note

Il n’est pas toujours évident de choisir le bon type de membre pour ajouter une nouvelle fonctionnalité dans une classe : attribut ? méthode ? propriété ? constructeur ?… Les recommendation de conception du guide de programmation C# peuvent vous aider à choisir l’approche la plus appropriée.

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

  • Une propriété peut être de visibilité public.

  • Une propriété peut être de visibilité private.

  • Une propriété est toujours associée à un champ.

  • Une propriété doit posséder un get.

  • Une propriété doit posséder un set.

  • Dans le setter d’une propriété, le mot clef value désigne la nouvelle valeur à affecter.

  • Dans le getter d’une propriété, il n’est pas obligatoire de retourner une valeur.

  • Dans le getter d’une propriété, je n’ai pas le droit de modifier les valeurs des champs de la classe.

Une classe A contient le membre suivant : public string Name {get; set;}.

Pour chacune de ces instructions, qu’on suppose écrite dans une méthode de la classe A, indiquez si elle est correcte ou non:

  • Name.set("foo");

  • Name.Set = "foo";

  • Name.set = "foo";

  • Name = "foo";

  • SetName("foo");

  • set("foo");

Une classe possède une propriété P, si je lis 2 fois de suite la valeur de cette propriété, j’obtiendrai :

  • deux fois la même valeur

  • deux valeurs différentes

  • on ne peut pas savoir

Membres statiques

Le qualificateur static permet de déclarer des membres de classe, aussi appelés membres statiques, qui ne dépendront pas d’une instance particulière de la classe. Par exemple la fonction racine carrée, la constante \(\pi\), la fonction Main (première fonction exécutée du programme) ne dépendent pas d’un objet particulier; elles existent même si aucun objet n’est créé.

class A{
        public static int x;                       // champ statique
        public static void Bar(){};                // méthode statique
        public static double UnNombre {get; set;}  // propriétée statique
}

Les champs de classe sont initialisés avant la première utilisation de la classe où ils sont définis.

Les membres de classes peuvent être appelés n’importe quand et n’importe où dans votre programme même si aucun objet du type concerné n’a été créé. Pour accéder à un champ statique ou à une méthode statique, on utilise l’opérateur d’accès . préfixé par le nom de la classe (contrairement aux membres d’instance que l’on préfixe par la référence sur un objet). Lors de l’appel à une méthode statique il n’y a donc pas de référence vers une instance courante : le mot clef this n’est pas défini. Il n’est donc pas possible d’accéder aux membres d’instances.

Un exemple classique d’utilisation d’un champ statique est la définition d’un compteur d’instances créées pour un classe:

class A
{
        double valeur;
        public static int compteur = 0; // nombre d'objets de type A créés

        public A(double valeur){
                this.valeur = valeur;
                A.compteur++;              // on vient de créer un nouvel objet
        }

        public static void Test()       {  // une méthode statique
                        A a1 = new A(3.1);
                        WriteLine(A.compteur); // 1
                        A a2 = new A(3.2);
                        WriteLine(A.compteur); // 2
                        WriteLine(a1.valeur);  // 3.1 accès à un champ d'instance à partir d'une référence
                        //WriteLine(this.valeur); ERREUR la référence this n'est pas défini dans une méthode statique
                        //WriteLine(a1.compteur); ERREUR accès à partir une référence à un champ statique
        }
}

A la fin de l’exécution de la méthode Test, on peut représenter la mémoire de la façon suivante :

../_images/exStatic.svg

Afin de simplifier l’écriture, on peut accéder aux membres statiques de la classe courrante sans utiliser le nom de la classe et l’opérateur .. Autrement dit, si on ne précise pas de nom de classe avec l’opérateur . devant un nom d’identificateur qui correspond à un membre statique, le compilateur ajoutera automatiquement NomDeLaClasse. devant l’identificateur. Le code suivant est donc valide:

class A
{
        public static int valeur;

        public void Test()
        {
                valeur = 3;  // équivalent à A.valeur = 3
        }
}

Note

Le mot statique est associé à tout ce qui est calculé au moment de la compilation. Son opposé est le mot dynamique qui désigne ce qui est calculé pendant l’exécution du programme.

class A{
        int i = 3;
        static int si = 2;

        void M(){
                i *= 2;
        }

        void static SM(){
                si *= 2;
        }

        void Test()     {
                M();
                SM();
                WriteLine(i);
                WriteLine(si);
        }

        static void Main(string [] args)        {
                A a1 = new A();
                A a2 = new A();
                a1.Test();
                a2.Test();
        }
}

Après exécution de la méthode Main, ce programme a affiché les 4 lignes de texte suivantes :

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

  1. Une classe peut contenir plusieurs membres statiques.

  2. On peut accéder à un membre statique d’une classe sans avoir créé d’objet de cette classe.

  3. Une méthode statique peut accéder à un champ d’instance à travers la référence de l’objet courant this.

  4. Une méthode d’instance peut accéder à un champ statique à travers la référence de l’objet courant this.

  5. On peut initialiser un champ statique en utilisant les valeurs des champs d’instances.

  6. On peut initialiser un champ d’instance en utilisant les valeurs des champs statiques.

class B{
        static double a;
        int b;
        void F(){
                float c;
        }
}

Pour chacune des variables a, b et c dites si il s’agit d’une variable d’instance et/ou de classe et/ou locale.

Variable

Instance

Classe

Locale

a

b

c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class A{
        public static int a;
        public int b;
}

class B{
        public static void Main(){
                A.a++;
                A.b++;
                A obj= new A();
                obj.a++;
                obj.b++;
        }
}
  • La ligne 8 est correcte.

  • La ligne 9 est correcte.

  • La ligne 11 est correcte.

  • La ligne 12 est correcte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class A{
        static int a;
        int b;

        public void F1(){
                a++;
                b++;
        }

        public static void F2(){
                a++;
                b++;
        }
}
  • La ligne 6 est correcte.

  • La ligne 7 est correcte.

  • La ligne 11 est correcte.

  • La ligne 12 est correcte.

Exercices

Soit la classe C et la fonction Test

class C
{
        public int a, b;
        public C bb;
        public C(int _a, int _b) { a=_a; b=_b; }
}

...

Test()
{
        C[] T = new C[5];
        for (int i = 0 ; i < 5 ; i++)
        {
                T[i] = new C(i,i);
                i++;
        }
}

Dessinez l’état de la mémoire à la fin de la fonction Test (un papier et un crayon reste la façon la plus simple de dessiner !)

Sous Visual Studio, créez un nouveau projet de type Console:

  1. Créez une classe Rectangle: clique droit sur le nom du projet dans l’explorateur de solution, menu Ajouter -> Classe, renseignez le nom de la classe et cliquez sur OK.

    ../_images/creerClasse.png
  2. Ajoutez des attributs à la classe : sa largeur largeur, sa hauteur hauteur en pixel et sa position à l’écran (positionX,positionY) en pixel.

  3. Ajoutez un constructeur paramétrique qui initialise la taille et la position de l’objet.

  4. Ajoutez une méthode d’instance Aire (sans argument) qui retourne l’aire du rectangle.

  5. Dans la méthode Main de la classe Program :

    1. Créez une nouvelle instance de la classe Rectangle de dimension 20 par 30 à la position (15,20).

    2. Affichez les valeurs des attributs de l’objet créé.

    3. Affichez l’aire de l’objet créé (utilisez la méthode Aire) afin de la calculer.

    4. Exécutez votre programme et vérifiez que tout fonctionne correctement (ajoutez l’instruction Console.Read(); à la fin du Main pour éviter que la fenêtre ne se ferme tout de suite).

  6. Ajoutez un constructeur sans argument qui initialise la forme comme un carré de coté 10 et de position (0,0). Respectez le principe DRY : utilisez le chainage de constructeurs pour que le corps de votre constructeur ne contienne aucune instruction !

  7. Ajoutez une méthode DoublerTaille qui multiplie la largeur et la longueur du rectangle par 2.

  8. Dans la méthode Main de la classe Program :

    1. Créez une nouvelle instance de la classe Rectangle avec votre constructeur sans argument.

    2. Doublez la taille du rectangle créé avec la méthode DoublerTaille.

    3. Affichez les dimensions du rectangle pour vérifier que tout a bien fonctionné.

  9. Ajoutez une méthode TestePlusPetit qui prend en paramètre un rectangle r et retourne vrai si l’aire de r est plus petite que l’aire de l’instance courante.

  10. Dans la méthode Main de la classe Program :

    1. Testez la méthode TestePlusPetit avec les 2 références de Rectangle déjà créées.

  11. Créez le constructeur de recopie : constructeur qui reçoit en paramètre une référence sur un objet du même type et qui initialise à l’identique l’objet en cours de création. Respectez le principe DRY : utilisez le chainage de constructeurs pour que le corps de votre constructeur ne contienne aucune instruction !

  12. Dans la méthode Main de la classe Program :

    1. Créez une copie du premier rectangle créé.

    2. Modifiez la largeur de la copie.

    3. Vérifiez que la largeur du premier rectangle n’a pas changé.

Soit la classe C et la fonction Test

class C
{
        public int a,b;
        public C(int _a, int _b) { a=_a; b=_b; }
}

...

Test()
{
        C[] T = new C[5];
        for (int i = 0 ; i < 5 ; i++)
        {
                T[i] = new C(i,i+1);
        }
        T[2] = T[4];
        for (int i = 0 ; i < 5 ; i++)
        {
                T[i].a += T[i].b;
        }
}

Dessinez l’état de la mémoire à la fin de la fonction Test (un papier et un crayon reste la façon la plus simple de dessiner !)

Une pratique courante est de proposer dans chaque classe un constructeur par copie, c’est-à-dire un constructeur qui prend en paramètre un objet du même type que celui à construire et qui construit une copie profonde (deep copy) de cet objet. Pour réaliser une copie profonde d’objet, il faut non seulement dupliquer l’objet mais également réaliser une copie profonde de tous les objets référencés par cet objet : l’objet d’origine et l’objet copié sont complètement indépendants. On parle de clonage.

Téléchargez et ouvrez cette solution Visual Studio.

  1. Ajoutez à la classe Point un constructeur prenant en paramètre un objet de type Point et qui construit une copie profonde de ce point.

  2. Ajoutez à la classe Polygone un constructeur prenant en paramètre un objet de type Polygone et qui construit une copie profonde de ce polygone.

  3. Décommentez le contenu du Main et exécutez le programme pour vérifier que tout fonctionne.

Donnez l’état de la mémoire à la fin de la fonction suivante (un papier et un crayon reste la façon la plus simple de dessiner !):

Point p1 = new Point(1,1);
Point p2 = p1;
Point p3 = new Point(p1);
Point [] pts = {p1, p2, p3};
Polygone po1 = new Polygone(pts);
Polygone po2 = new Polygone(pts);
Polygone po3 = new Polygone(po1);
p1.x=2;
p2.x=3;
p3.x=4;
po1.points[0].y=5;
po2.points[1].y=6;
po3.points[2].y=7;

Dans un nouveau projet Console sous Visual Studio, écrivez une classe Duree qui devra stocker une durée avec une précision à la seconde. La classe devra posséder 2 propriétés DureeSeconde et DureeMinute qui permettent de lire et modifier la durée avec des valeurs exprimées respectivement en secondes et en minutes. Une durée ne sera jamais négative. Pour exprimer une durée en minutes, on arrondira toujours à la valeur supérieure.

Exemple d’utilisation:

Duree d = new Duree(70); // durée de 70 secondes
WriteLine(d.DureeSeconde); // 70
d.DureeMinute = -2;
WriteLine(d.DureeSeconde); // 70
WriteLine(d.DureeMinute); // 2
d.DureeMinute = 2;
WriteLine(d.DureeSeconde); // 120

Dans un nouveau projet Console sous Visual Studio:

  1. Créez une classe Lapin qui contient les propriétés suivantes: Surnom (String), Age (int), Position (int). Vous implémenterez complètement les propriétés Surnom et Age et vous utiliserez une propriété auto-implémentée pour Position.

  2. Ecrivez un constructeur permettant d’initialiser les propriétés Surnom et Age (Position sera toujours initialisée à 0).

  3. Ajoutez un champ statique nombreDeLapins qui compte le nombre de lapins créés. Modifiez le constructeur pour que ce champ soit automatiquement incrémenté lors de la création d’un nouveau lapin.

  4. Ajoutez une méthode statique NombreDeLapins() qui retourne le nombre lapins créé depuis le lancement du programme.

  5. Ajoutez une méthode void Avancer() à la classe Lapin. Cette méthode ajoute une valeur aléatoire comprise entre 0 et 5 à la position du lapin. La méthode d’instance Next de la classe Random génère un nombre (pseudo) aléatoire.

  6. Dans la méthode Main de la classe Program, créez trois lapins avec des noms et des ages différents.

  7. Créez un tableau de lapins et rangez-y les trois lapins créés précédemment.

  8. Appelez la méthode Avancer() 100 fois sur chacun des 3 lapins.

  9. Le lapin qui a gagné est celui qui a la position la plus grande à la fin. Affichez le nom du lapin qui a gagné.