C++ – Méthodes et modalités de passage de paramètres

Ce qui suit est un bref survol de quelques concepts hygiéniques de base du langage C++.

Ce document présume que, sur le plan du vocabulaire, vous savez ce qu'est une classe, une instance d'une classe, de même que ce que sont les constructeur et les destructeurs. Notez aussi que j'utiliserai des struct et des class selon les circonstances, privilégiant struct quand tous les membres sont publics et class dans les autres cas.

Bref survol des bases de l'encapsulation

Le principe d'encapsulation consiste à contrôler l'accès aux données membres, ou attributs, d'un objet de manière à lui permettre de garantir son intégrité. On énoncera ce principe comme suit : l'objet est responsable de son intégrité, et ce du début à la fin de son existence. Une forme équivalente serait d'exprimer que l'encapsulation signifie que l'objet garantit le respect de ses propres invariants.

Ce principe est en partie soutenu en C++ par la notion de membres privés, protégés et publics (qualifications private, protected et public, respectivement). L'accès aux attributs y est généralement restreint et contrôlé par un passage à travers des fonctions membres de l'objet, ou méthodes, et certaines méthodes sont exposées au grand public alors que d'autres auront des restrictions plus strictes d'accès.

Les propriétés de .NET et de Delphi (entre autres) constituent une couche spécifique corespondant à certaines méthodes d'accès primitif aux données, mais ne permettent pas de couvrir tous les types d'accès primitifs possibles sur les attributs. Pour fonctionner dans le monde .NET, il faut donc à la fois assimiler les méthodes et les propriétés.

Certaines méthodes serviront au contrôle d'accès primitif en lecture aux attributs de l'objet – on dira d'elles qu'elles sont les accesseurs des attributs. En contrepartie, les méthodes donnant un accès primitif en écriture aux attributs seront nommées mutateurs. Certains préfixent les accesseurs de mots reconnaissables comme Get ou de get (il n'est pas rare qu'on parle de getters, informellement), et les mutateurs de Set ou de set (il n'est pas rare qu'on parle de setters, informellement), mais cette pratique n'est pas universelle. En pratique, les mutateurs devraient être plus rares que les accesseurs, et rien n'oblige à associer ou accesseur ou un mutateur à un état – réfléchissez avant d'agir.

Les méthodes donnant un accès primitif aux données devront normalement être écrites de manière très optimisée, du fait que ces méthodes se veulent fondamentales et sont vouées à un usage fréquent. Dans certains langages, dont C++ et C#, il est possible de faire en sorte que ces accès soient faits à coût zéro (ou presque, dans le cas de C#). Maintenir une barrière entre l'utilisation faite d'un objet par le code client et sa représentation interne renforce le caractère générique d'une classe, ce qui la rend plus réutilisable et lui permet de mieux survivre au passage du temps.

Dans l'exemple ci-dessous, les attributs (privés) d'une instance de la classe (très simple) Eleve sont nom_ et age_. Une instance classe Eleve permet de consulter son nom et son âge par les accesseurs nom() et age() et n'offre pas de mutateurs pour les modifier.

Exemple
#ifndef ELEVE_H
#define ELEVE_H
#include <string>
class Eleve
{
   std::string nom_;
   unsigned short age_;
public:
   Eleve(const std::string &nom, unsigned short age)
      : nom_{nom}, age_{age}
   {
   }
   string nom() const
      { return nom_; };
   unsigned short age() const noexcept
      { return age_; }
};
#endif

Méthodes inline

Définir certaines méthodes – dans l'exemple de la classe Eleve ci-dessus, ce sont même toutes les méthodes – à même le corps de la classe permet au compilateur de générer inline les appels vers celles-ci.

Ceci a pour effet de remplacer (après validation des paramètres) l'appel du sous-programme par sa définition, et améliore la vitesse d'exécution. Le coût (en taille) de cette stratégie est minime dans la mesure où les fonctions en question sont très petites (une ou deux lignes, idéalement).

L'alternative est de séparer la déclaration d'une méthode de sa définition :

Déclarations (typiquement : Eleve.h)Définitions (typiquement : Eleve.cpp)
#ifndef ELEVE_H
#define ELEVE_H
#include <string>
class Eleve
{
   std::string nom_;
   unsigned short age_;
public:
   Eleve(const std::string&, unsigned short);
   string nom() const;
   unsigned short age() const noexcept;
};
#endif
#include "Eleve.h"
#include <string>
using std::string;
Eleve::Eleve(const string &nom, unsigned short age)
   : nom_{nom}, age_{age}
{
}
string Eleve::nom() const
   { return nom_; };
unsigned short Eleve::age() const noexcept
   { return age_; }
};

Ceci réduit la complexité des fichiers et sépare clairement les éléments clés d'une classe, mais a pour effet que l'appel par le code client d'une méthode d'une instance de la classe Eleve devra être résolu en appel de fonction à part entière, ce qui peut entraîner des coûts.

L'inlining peut grossir un programme et en ralentir l'exécution si on y a recours de manière abusive ou impropre, et peut en réduire la taille et l'accélérer si on s'en sert convenablement. Tout est question de doigté.

Paramètres const

Un sous-programme devrait toujours spécifier const tous les paramètres pour lesquels il peut garantir ne jamais les modifier. On parle alors de const-correctness, une saine pratique d'hygiène de code.

Ceci garantit à l'appelant que le compilateur assurera la protection des données au moment de la compilation. Si le sous-programme contrevient à son contrat et modifie un paramètre const[2], alors le code ne compilera tout simplement pas.

Le mot clé const utilisé au sens de déclaration de constante est une particularité du langage C++ que le langage C ne partageait pas jusqu'à tout récemment. En retour, les paramètres const, eux, remontent au langage C.

Soyez prudents si vous devez mêler code C et code C++ dans un même programme. Il y a de subtiles nuances dans le sens du mot const apposé à une donnée dans ces deux langages qui compliquent leur interopérabilité.

string illegal_a(const string &s)
{
   // illégal: s est const!
   s = s + "Ceci est illégal";
   return s;
}
string legal_a(const string &s)
{
   // ok, ne modifie pas s
   return s + "Ceci est légal";
}
int illegal_b(int i)
{
   // illégal: i est const!
   i = i + 2;
   return i;
}
int legal_b(int i)
{
   // ok, ne modifie pas i
   return i + 2;
}

Paramètres passés par référence

Passer un paramètre par référence est utile pour plusieurs raisons. L'une d'entre elles est son impact sur la vitesse d'un programme.

En effet, le passage de paramètre par référence fait en sorte que seule l'adresse de l'objet original soit passée lors de l'appel (pas besoin d'empiler une copie du paramètre sur la pile d'exécution). Ainsi, on accélère grandement les appels de sous-programmes auxquels on passe des objets arbitrairement complexes (comme le sont, au fond, tous les objets).

Essayez, avec les procédures suivantes, d'appeler lent() avec une variable (pas une constante) de type std::string contenant un texte relativement long, et d'appeler rapide() avec la même chaîne de caractères. Faites-le pour une chaîne de 100K caractères, et effectuez 1000K appels, pour voir.

#include <string>
#include <chrono>
#include <iostream>
using namespace std;
using namespace std::chrono;
template <class T>
   void unused(const T&)
      { }
void lent(string s)
{
   string temp = s;
   unused(temp);
}
void rapide(const string &s)
{
   string temp = s;
   unused(temp);
}
void lire_une_touche()
{
   cout << "Pressez une touche puis <ENTER>...";
   char c;
   cin >> c;
}
string creer_chaine(char c)
{
   string::size_type n;
   cout << "Nb. caractères de la chaîne: ";
   cin >> n;
   return string{n, c};
}
int main()
{
   int ntests;
   auto gros_texte = creer_chaine('a');
   cout << "Nb. d'appels à faire: ";
   cin >> ntests;
   cout << "Quand vous serez pret(e)..." << endl;
   lire_une_touche();
   // Test de lent(string)
   {
      auto avant = system_clock::now();
      for (int i = 0; i < ntests; ++i)
         lent(gros_texte);
      auto apres = system_clock::now();
      cout << "Nb tours: " << ntests << ", taille chaîne: " << gros_texte.size()
           << ", temps: " << duration_cast<milliseconds>(apres - avant).count() << " ms."
           << endl;
      lire_une_touche();
   }
   // Test de rapide(const string&)
   {
      auto avant = system_clock::now();
      for (int i = 0; i < ntests; ++i)
         rapide(gros_texte);
      auto apres = system_clock::now();
      cout << "Nb tours: " << ntests << ", taille chaîne: " << gros_texte.size()
           << ", temps: " << duration_cast<milliseconds>(apres - avant).count() << " ms."
           << endl;
      lire_une_touche();
   }
}

Au bureau, cela donne ce qui suit. Le premier temps est pour lent(), le second est pour rapide()) :

Nb. caractères de la chaîne: 1000
Nb. d'appels à; faire: 1000
Quand vous serez pret(e)...
Pressez une touche puis <ENTER>...t
Nb tours: 1000, taille chaîne: 1000, temps: 30 ms.
Pressez une touche puis <ENTER>...t
Nb tours: 1000, taille chaîne: 1000, temps: 10 ms.
Pressez une touche puis <ENTER>...

Ce petit test ne se veut qu'informatif; ce n'est pas une mesure stricte de vitesse d'exécution.

Pour être capable de spécifier de façon raisonnable que l'un des deux sous-programmes est plus rapide que l'autre, il faudrait reprendre le test plusieurs fois, tirer des moyennes de performance, et vérifier les meilleurs cas comme les pires, pour se débarrasser des cas d'exception et des petites anomalies ponctuelles pouvant, à l'occasion, se glisser dans les mesures.

Surtout, il faut réaliser les tests sur du code optimisé (compilé en mode Release). Avec le code de test proposé ici, le compilateur constaterait alors que les appels de fonctions sont inutiles et le temps total serait zéro. Ce n'est donc pas du code de test pertinent, du moins pas pour des tests sérieux. Les appels à unused() sont placés là pour éviter que le compilateur ne considère la variable temp comme inutile et s'en débarrasse.

Paramètres passés en références-sur-const

Une constante ne peut être passée par référence à un sous-programme ne la protégeant pas :

// Paramètre passé par valeur : le paramètre original est protégé
void p0(string);
// Paramètre passé par référence : peut modifier le param. original
void p1(string&);
// Paramètre passé par référence, mais const : le paramètre
// original est protégé, mais ça va vite!
void p2(const string&);
// Quelques exemples d'appels valides et invalides
int main()
{
   const string CONSTANTE = "Constante";
   string variable = "Variable";
   p0(variable);  // légal
   p0(CONSTANTE); // légal
   p1(variable);  // légal
   p1(CONSTANTE); // illégal : passage par référence; la constante pourrait être modifiée par le sous-programme!
   p2(variable);  // légal
   p2(CONSTANTE); // légal : pasage par référence-vers-const; aucun risque.
}

Comment le compilateur fait-il pour assurer la protection des paramètres const? Simple: si un sous-programme accède à un paramètre spécifié const de toute façon pouvant potentiellement en altérer la valeur, il se produira une erreur à la compilation.

Quelques actions rendues illégales lorsqu'on cherche à les appliquer à un paramètre const :

  • Un accès en écriture, pur et simple (utiliser l'opérateur = pour affecter une valeur à l'objet, par exemple). On comprendra évidemment que l'une des premières vocations d'un paramètre constant est d'être... constant, alors une violationd'intégrité aussi flagrante n'est clairement pas acceptable

L'illustration à droite utilise un paramètre par valeur, pour lequel l'apposition de la qualification const ne fait que permettre certaines optimisations de bas niveau. Avoir recours à un paramètre constant est plus fréquent – et plus utile – sur des pointeurs ou des références.

void f(const int i)
{
   i = 3; // boum!
}
  • Un appel à un autre sous-programme qui prendra en paramètre par référence ou par adresse notre paramètre, mais sans en garantir le caractère constant

Ramener le support au mot const à un niveau local permet de vérifier immédiatement, lors de la compilation d'un sous-programme, le respect des contraintes. Si un sous-programme reçoit un paramètre const puis agit de manière à en compromettre l'intégrité, par exemple en le passant par référence ou par adresse à un autre sous-programme, alors il contrevient clairement à ses propres règles et est illégal.

void danger(int&);
void g(const int i)
{
   danger(i); // boum!
}
  • Recevoir par référence un objet const et appeler une de ses méthodes qui n'est pas elle-même spécifiée const (voir plus bas pour des détails à ce sujet)

La beauté du modèle est que ces problèmes sont tous détectés à la compilation, ce qui évite des erreurs à l'exécution. Ceci solidifie les programmes et donne au compilateur des outils pour mieux assister les développeuses et les développeurs dans leurs tâches.

struct X
{
   void danger(); // n'est pas const
};
void h(const X &x)
{
   x.danger(); // boum!
}

Méthodes const

Une méthode peut être spécifiée const si elle ne modifie en rien l'objet auquel elle appartient. C'est le cas de la plupart des accesseurs.

Si une méthode const retourne une référence à une propriété de son objet (ou l'adresse d'une telle propriété), il faut que cette référence (ou cette adresse) soit elle aussi spécifiée const (sinon, l'appelant pourrait s'en servir de manière à altérer l'objet, ce qui contreviendrait au principe d'une méthode const).

Quelques exemples de méthodes const légales et illégales sont proposés ci-dessous.

class X
{
   int i;
public:
   int GetV0() const
      { return i; }; // légal
   int& GetV1() const
      { return i; }; // illégal!
   // légal
   const int& GetV2() const
      { return i; };
   int* GetV3() const
      { return &i; }; // illégal
   // légal
   const int* GetV4() const
      { return &i; };
};

Les méthodes const sont essentielles en C++, puisque une méthode d'un objet const ne peut être utilisée que si elle-même s'avère const.

Évidemment, les mutateurs ne sont à peu près jamais const.

Les langages de la gamme .NET et Java n'offrent pas le concept de constante pour les objets. Pour rédiger des méthodes donnant un accès de première ligne aux attributs dans ces langages, il faut être extrêmement prudent, et (au choix) utiliser comme attributs ce qu'on nomme dans ces langages des classes immuables.

Faute de pouvoir concevoir des instances constantes, la constance doit être assuré sur la base des classes qui, comme la classe String des modèles proposés par Java et .NET, n'offrent aucune méthode permettant de modifier l'état d'un objet suite à sa construction – conséquence: il devient nécessaire de construire de nouveaux objets sans arrêt, ce qui mène à du code très lent si les programmeurs sont imprudents... Heureusement, il existe des solutions, mais elles sont sous-utilisées et le code écrit dans ces langages est fréquemment moins efficace qu'il ne le devrait.

Une autre possibilité pour contourner ce problème serait de cloner les attributs avant de les retourner pour que les appelant des méthodes n'obtiennent jamais les attributs eux-mêmes mais bien de copies qui, si l'appelant les modifie, ne comprometront pas l'intégrité de l'appelé.


Valid XHTML 1.0 Transitional

CSS Valide !