image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Les opérateurs sont des fonctions employant une syntaxe spécifique sur certains types d'opérandes. Un opérateur retourne un résultat en fonction des opérandes qui lui sont données. En C++, les opérateurs sont fixés : il n'est pas possible de créer de nouveaux opérateurs ; toutefois il est envisageable de surcharger des opérateurs existants afin de pouvoir supporter de nouveaux types d'opérandes. Ainsi par défaut l'opérateur + permet d'additionner des entiers ou des flottants ; si nous crééons un type matrice, il serait possible de surcharger cet opérateur afin d'additionner des matrices.

Chaque opérateur possède une priorité propre. Cette propriété conditionne la manière dont une expression est interprétée par l'analyseur syntaxique du compilateur. Ainsi l'expression ++a + 2 * 3 << 1 s'interprète ((++a) + (2 * 3)) << 1 ce qui équivaut à incrémenter a, ajouter 6 et décaler à gauche d'un bit (multiplication par 2) le tout. L'expression a donc pour valeur (a + 7) * 2. On pourra consulter cette table pour référence pour la priorité des opérateurs. Dans le doute, il est préférable de parenthéser ses expressions pour ne pas avoir de mauvaise surprise.

Les opérateurs peuvent utiliser 1, 2 ou 3 operandes et possèdent une notation infixe (avant l'opérande), suffixe (après l'opérande) ou infixe (entre les opérandes).

Les opérateurs arithmétiques

Parmi les opérateurs arithmétiques, nous avons l'opérateur unaire - (à une seule opérande) qui permet d'obtenir l'opposé d'un nombre entier ou flottant.

Nous listons maintenant les opérateurs binaires (à notation infixe) :

Ces opérateurs sont définis pour les types entiers et flottant (sauf modulo non disponible pour les flottants). Il est possible de les surcharger pour des types que l'on aura définis soi-même. Par exemple crééons une classe Matrix représentant une matrice d'entiers carrée de taille N (constante que l'on aura fixée) avec une surcharge de l'opérateur + pour additionner terme à terme deux matrices :

class Matrix
{
private:
	int content[N][N];
public:
	/** constructeur initialisant une matrice identité */
	Matrix()
	{
		for (int i = 0; i < N; i++)
			for (int j = 0; j < N; j++)
				content[i][j] = (i == j)?1:0;
	}
	
	int get(int i, int j) const { return content[i][j]; }
	void set(int i, int j, int value) { content[i][j] = value; }
	
	/** Surcharge de l'opérateur + 
	 *  L'objet courant est l'opérande gauche
	 */
	Matrix operator +(const Matrix& rightOperand) const
	{
		Matrix newMatrix;
		for (int i = 0; i < N; i++)
			for (int j = 0; j < N; j++)
				newMatrix.set(i, j, get(i, j) + rightOperand.get(i, j));
		return newMatrix;
	}
}

On peut sur le même modèle surcharger la soustraction et la multiplication.

La surcharge d'opérateur est également possible en dehors de la classe correspondant à l'opérande gauche. Cette pratique est d'ailleurs conseillée pour les opérateurs binaires :

/** Surcharge de l'opérateur + en dehors de la classe Matrix */
Matrix operator +(const Matrix& leftOperand, const Matrix& rightOperand)
{
	Matrix newMatrix;
	for (int i = 0; i < N; i++)
		for (int j = 0; j < N; j++)
			newMatrix.set(i, j, leftOperand.get(i, j) + rightOperand.get(i, j));
	return newMatrix;
}

Si la surcharge d'opérateur est externe à la classe, il peut quelquefois être utile d'autoriser l'opérateur à accéder aux membres privés de la classe par une déclaration d'amitié dans la classe :

class Matrix {
	...
	friend Matrix operator +(const Matrix& leftOperand, const Matrix& rightOperand);
}

Il n'est pas obligatoire d'avoir des opérandes du même type. Par exemple, on pourrait surcharger l'opérateur * pour réaliser une multiplication matricielle :

Matrix operator *(const Matrix& leftOperand, const Matrix& rightOperand)

Et l'on pourrait également surcharger l'opérateur * pour réaliser une multiplication par un scalaire (entier) :

Matrix operator *(const Matrix& leftOperand, const int& rightOperand)

Le compilateur choisira la fonction adaptée selon les opérandes utilisées. Normalement les opérandes doivent être notées const car elle ne sont pas censées être modifiées lors de l'opération (un nouvel élément est créé et retourné avec le résultat de l'opération).

Les opérateurs d'affectation

Opérateur d'affectation

L'opérateur d'affectation = affecte le résultat d'une expression située sur sa droite à une variable indiquée sur sa gauche (lvalue). Voici un exemple :

Matrix m1;
Matrix m2;
m1.set(0, 0, 42);
m2 = m1;

Un opérateur d'affectation = est implanté par défaut pour la classe Matrix (qui réalise le travail attendu, i.e. copier le contenu des champs de la classe). Nous n'avons donc pas besoin de surcharger nous-même cet opérateur. Cependant rien n'empêche de le faire :

Matrix& Matrix::operator= (const Matrix& rvalue)
{
	if (this == &rvalue)
        return *this; // si jamais on affecte this à lui-même
 
    // on copie
    for (int i = 0; i < N; i++)
		for (int j = 0; j < N; j++)
			set(i, j, rvalue.get(i, j));
			
	// on retourne l'objet lui-même
    return *this;
}

La surcharge de l'opérateur d'affectation est souvent indispensable dès lors que des allocations dynamiques somt impliquées ; en effet la copie par défaut ne copie que les pointeurs (ou références) sans réaliser de copie des objets alloués dynamiquement sur le tas.

Notons qu'il est également possible d'interdire l'affectation en supprimant l'opérateur dans la définition de la classe Matrix :

class Matrix
{
	...
	Matrix& operator= (const Matrix &rvalue) = delete; // pas de copie !
}

Un autre opérateur d'affectation peut cohabiter en prenant en paramètre une rvalue d'un type différent. Voici un exemple :

Matrix m3; // initialisation d'une matrice identité
m3 = 10; // met la valeur 10 sur les cellules de la diagonale de la matrice

Cela peut s'implanter ainsi :

Matrix& Matrix::operator= (const int rvalue)
{
	for (int i = 0; i < N; i++)
		for (int j = 0; j < N; j++)
			set(i, j, (i == j) ? rvalue : 0);
    return *this;
}

Opérateur de copie vs. opérateur d'affectation

L'opérateur d'affectation ne doit pas être confondu avec l'opération de copie réalisée lors de la déclaration d'une variable.

Ainsi écrivons le code suivant :

Matrix m1;
Matrix m2 = m1; // opérateur de copie
Matrix m3;
m3 = m1; // opérateur d'affectation

Ecrivons une classe Matrix simplifiée :

class Matrix
{
private:
	int content[N][N];
public:
	Matrix() { /* initialisation avec identité */ }
	
	Matrix& operator=(const Matrix& m) { cout << "Assignment operator used" << endl; /** Code réalisant l'affectation */ }
	
	Matrix(const Matrix& m) { cout << "Copy operator used" << endl; }
}

Dans le 1er cas, l'opérateur de copie est utilisé (implanté par un constructeur). Dans le 2nd cas, l'opérateur d'affectation est employé car m3 est déjà construit. Fondamentalement l'opérateur d'affectation et de copie réalisent le même travail ; on peut d'ailleurs implanter l'opérateur de copie en réutilisant l'opérateur d'affectation :

/** Implantation de l'opérateur de copie */
Matrix::Matrix(const Matrix& m)  { 
	cout << "Copy operator used" << endl;
	*this = m; // appelle operator=
}

/** Implantation de l'opérateur d'affectation */
Matrix& Matrix::operator=(const Matrix& m) {
	cout << "Assignment operator used" << endl;
	if (this != &m) { // Pour éviter de faire une copie inutile en cas d'affectation sur soi-même
		// Copie cellule par cellule
		for (int i = 0; i < N; i++)
			for (int j = 0; j < N; j++)
				content[i][j] = m.content[i][j];
	}
	return *this;
}

L'opérateur d'affectation doit toujours retourner une référence vers l'objet lui-même (*this). Cela permet de chaîner des affectations comme dans l'exemple qui suit qui permet d'affecter m1 sur m2 puis m2 sur m3 :

Matrix m1;
Matrix m2;
Matrix m3;
...
m3 = m2 = m1;

Opérateur d'affectation combinés

En plus de l'opérateur d'affectation simple, il existe des opérateurs d'affectation réalisant une opération arithmétique avec l'opérande de droite. Ces opérateurs sont notés @= (@ étant l'opération arithmétique réalisée). a @= b est généralement utilisé à la place de l'instruction a = a @ b ; cela permet d'éviter la création en mémoire d'un élément intermédiaire a @ b avant l'affectation dans a (on peut modifier directement a).

Voici les opérateurs d'affectation avec opération arithmétique supportés :

Surchargeons l'opérateur += sur le type Matrix pour additioner une matrice à la matrice courante :

Matrix& Matrix::operator+= (const Matrix& rvalue) {
	for (int i = 0; i < N; i++)
		for (int j = 0; j < N; j++)
			set(i, j, get(i, j) + rvalue.get(i, j));
    return *this;
}

Il est possible aussi de supporter l'addition d'un scalaire (int) en changeant le type du paramètre. Dans ce cas, on ajoute N fois la matrice identité (matrice avec diagonale à 1) :

Matrix& Matrix::operator+= (const int rvalue) {
	for (int i 0; i < N; i++)
			set(i, i, get(i, i) + rvalue);
}

L'opérateur ++ et -- permettent respectivement d'incrémenter ou décrémenter l'objet. Ils peuvent s'utiliser en notation préfixe : dans ce cas l'action d'incrémentation ou décrémentation est réalisée puis l'objet retourné après action. Avec une notation suffixe, une copie de la valeur initiale est d'abord retournée puis l'action est réalisée. ++a correspond à a += 1.

Voici un exemple d'implantation des opérateurs d'incrémentation pour la matrice rajoutant une unité sur la diagonale de la matrice (ajout ou soustraction de matrice identité) :

// Opérateur préfixe ++ : ++matrix
Matrix& Matrix::operator++() {
	*this += 1;
	return *this;
}

// Opérateur suffixe ++ : matrix++
// Retourner une copie initiale de la matrice est nécessaire
Matrix Matrix::operator++() {
	Matrix initialMatrix(*this); // appelle l'opérateur de copie
	++(*this); // appelle l'opérateur préfixe ++ déja implanté
	return initialMatrix;
}

Les opérateurs logiques

Les opérateurs logiques sont utilisés pour réaliser des opérations sur les booléens. Un booléen est un type qui ne peut prendre que deux valeurs : true ou false. Une expression booléenne est :

Les opérateurs logiques binaires utilisent un mode d'évaluation en "court-circuit". Cela signifie qu'à l'exécution du programme expr1 est évaluée : si la seule connaissance de la valeur de expr1 suffit à déduire la valeur de l'expression globale expr1 @ expr2 alors expr2 n'est pas évaluée. Outre des économies de calcul, cela peut avoir une importance sur la logique du programme.

Ecrivons par exemple une fonction testant si la première cellule d'une matrice passée sous la forme de pointeur est égale à value :

bool testFirstCell(Matrix * m, int value) {
	return m != NULL && m->get(0, 0) == value;
}

L'exécution de cette méthode fonctionne grâce au court-circuitage réalisé si m est un pointeur nul. Dans ce cas, la deuxième expression n'est pas évaluée et on retourne directement false.

Il est théoriquement possible de surcharger les opérateurs logiques pour travailler avec d'autres types que les booléens. En pratique, il s'agit d'une mauvaise idée source de confusion. Il est en effet impossible de supporter le court-circuitage en cas de surcharge : les deux expressions seront obligatoirement évaluées.

Les opérateurs de comparaison

Les opérateurs de comparaison permettent de comparer deux objets entre-eux afin de savoir si ceux-ci sont identiques, différents ou alors si l'un deux est plus grand que l'autre. Voici tous la déclaration de tous les opérateurs de comparaison que nous pourrions définir sur Matrix :

class Matrix {
	...
	bool operator==(const Matrix& other) const;
	bool operator!=(const Matrix& other) const;
	bool operator<(const Matrix& other) const;
	bool operator<=(const Matrix& other) const;
	bool operator>(const Matrix& other) const;
	bool operator>=(const Matrix& other) const;
	...
}

L'implantation de == peut être réalisée en comparant les valeurs de chacune des cellules de la matrice :

bool Matrix::operator==(const Matrix& other) const {
	for (int i = 0; i < N; i++)
		for (int j = 0; j < N; j++)
			if (get(i, j) != other.get(i, j)) return false; // deux cellules différentes
	return true;
}

L'implantation de l'opérateur de comparaison < est plus délicate : il n'y a pas de notion naturelle d'ordre entre des matrices. On pourrait par exemple déclarer qu'une matrice a est plus petite qu'une matrice b si a.get(0, 0) est plus petit que b.get(0, 0). S'il y a égalité, on compare a.get(0, 1) et b.get(0, 1)... et ainsi de suite ligne par ligne. Voici l'implantation de < avec cette définition d'ordre :

bool Matrix::operator<(const Matrix& other) const {
	for (int i = 0; i < N; i++)
		for (int j = 0; j < N; j++) {
			int a = get(i, j);
			int b = other.get(i, j);
			if (a < b) return true;
			else if (a > b) return false;
			// sinon a == b et nous testons la cellule suivante
		}
	return false; // cas d'égalité
}

A partir de l'opérateur == et <, nous pouvons implanter tous les autres opérateurs de comparaison. Pour nous faciliter la tâche et éviter d'écrire à la main ces opérateurs, nous pouvons utiliser rel_ops qui s'utilise ainsi :

#include <iostream>
#include <utility>

#include <mymatrix>

using namespace std::rel_ops; // pour activer les autres opérateurs de comparaison

int main() {
	Matrix m1;
	Matrix m2;
	m2++;
	cout << "m1 == m2 ?" << (m1 == m2) << endl;
	cout << "m1 != m2 ?" << (m1 != m2) << endl;
	cout << "m1 < m2 ?" << (m1 < m2) << endl;
	cout << "m1 <= m2 ?" << (m1 <= m2) << endl;
	cout << "m1 > m2 ?" << (m1 > m2) << endl;
	cout << "m1 >= m2 ?" << (m1 >= m2) << endl;
}

A partir de C++20, l'opérateur de comparaison trilatéral <=> sera supporté. Il permet d'implanter une unique méthode de comparaison avec génération automatique des autres méthodes de comparaison.

Les opérateurs de manipulation binaire

Les opérateurs de manipulation binaire sont originellement destinés à manipuler bit par bit des entiers. Il est néanmoins possible de surcharger ces opérateurs pour d'autres types. Voici les opérateurs disponibles :

// Opérateurs de manipulation binaire classiques
T operator&(const T& leftOperand, const T& rightOperand); // et binaire
T operator|(const T& leftOperand, const T& rightOperand); // ou binaire
T operator^(const T& leftOperand, const T& rightOperand); // ou exclusif binaire
T operator~(const T& operand); // négation binaire (inversion des bits)
T operator<<(const T& leftOperand, const int leftOperand); // opérateur de décalage à gauche
T operator>>(const T& leftOperand, const int rightOperand); // opérateur de décalage à droite

// Opérateurs de manipulation binaire avec affectation
T& operator&=(T& lvalue, const T& rvalue); // et binaire avec affectation
T& operator|=(T& lvalue, const T& rvalue); // ou binaire avec affectation
T& operator^=(T& lvalue, const T& rvalue); // ou exclusif binaire avec affectation
T& operator<<=(T& lvalue, int rvalue); // décalage à gauche avec affectation
T& operator>>=(T& lvalue, int rvalue); // décalage à droite avec affectation

Il est possible de surcharger ces opérateurs en dehors de l'usage classique de manipulation binaire (avec n'importe quel type d'arguments). Par exemple la STL utilise l'opérateur de décalage à gauche dans ostream pour écrire des données dans un flux :

class ostream {
	...
	ostream& operator<< (bool val);
	ostream& operator<< (short val);
	ostream& operator<< (unsigned short val);
	ostream& operator<< (int val);
	ostream& operator<< (unsigned int val);
	ostream& operator<< (long val);
	ostream& operator<< (unsigned long val);
	ostream& operator<< (float val);
	ostream& operator<< (double val);
	ostream& operator<< (long double val);
	ostream& operator<< (void* val);

	ostream& operator<< (streambuf* sb );

	ostream& operator<< (ostream& (*pf)(ostream&));
	ostream& operator<< (ios& (*pf)(ios&));
	ostream& operator<< (ios_base& (*pf)(ios_base&));
	...
}

On notera par exemple que si on appelle stdout << endl pour insérer une fin de ligne, ostream& operator<< (ostream& (*pf)(ostream&)) est appelé car endl est une fonction prenant en argument une référence d'ostream et insérant une fin de ligne.

Les opérateurs de membre ou de pointage

Certains opérateurs permettent d'accéder à des parties d'un objet ou réaliser certaines opérations de référencement ou de déréférencement. Nous examinons certains de ces opérateurs ici.

L'opérateur d'index : []

Par défaut l'opérateur [] est implanté pour les pointeurs et tableaux afin d'accéder à un élément d'un certain indice. Dans certaines circonstances (par exemple l'implantation d'un container d'éléments), surcharger cet opérateur fait sens. Prenons l'exemple de la classe Matrix. En utilisant l'opérateur [], nous pourrions souhaiter accéder à une ligne de la matrice. Pour cela, nous pouvons définir une nouvelle class MatrixRow représentant une rangée puis implanter l'opérateur [] sur Matrix pour retourner une rangée. MatrixRow ne copiera pas les éléments de la rangée mais contiendra uniquement un pointeur vers la matrice avec l'indice de la rangée.

class MatrixRow {
private:
	const Matrix& matrix;
	const int row;
	
	// le constructeur est privé
	// mais Matrix peut l'utiliser si une déclaration d'amitié avec MatrixRow est réalisée
	// en revanche les autres classes ne peuvent créer une MatrixRow
	MatrixRow(const Matrix& matrix, int row): matrix(matrix), row(row) {}
public:
	int operator[](int col) const {
		return matrix.get(row, col);
	}
};

MatrixRow Matrix::operator[](const Matrix& matrix, int row) {
	return MatrixRow(matrix, row);
}

Nous pouvons désormais utiliser le code suivant :

Matrix m;
m++;
cout << "Value of upper-left cell: " << m[0][0] << endl;

Toutefois l'usage de l'opérateur [] pour modifier la valeur d'une cellule n'est ici pas possible. Ainsi m[0][0] = 2 ne fonctionne pas. Si nous souhaitions rendre ceci possible, nous devons retourner une référence vers un entier depuis l'opérateur Les opérateurs en C++ de MatrixRow :

int& MatrixRow::operator[](int col) const {
	return matrix.content[i][j];
}

Les opérateurs de (dé)référencement

L'opérateur * est utilisé pour transformer une variable de type pointeur vers un objet en type objet : il s'agit d'un opérateur de déréférencement. L'opérateur & réalise l'opération inverse en réalisant une transformation en type pointeur (pour obtenir l'adresse mémoire de l'élément pointé). L'opérateur -> permet d'obtenir un membre d'un objet désigné par un pointeur.

Voici un exemple d'utilisation :

int main() {
	int a = 42;
	int * ptrA = &a; // obtention d'un pointeur vers a
	int b = *ptrA; // nous avons a == b en valeur // déréférencement de a
	Matrix * m = new Matrix; // allocation dynamique de matrice
	cout << m->get(0, 0) << endl; // utilisation de -> pour accéder à un membre sur un pointeur
}

Il est possible de redéfinir les opérateurs *, & et -> mais ce n'est généralement pas conseillé excepté pour des usages très spécifiques (gestion de pointeurs intelligents, système de classes avec mandataires avec exposition de membres différents des membres réels...). Nous n'aborderons pas ici cette problématique.

L'opérateur d'appel de fonction ()

L'opérateur () permet de faire en sorte qu'un objet se comporte comme une fonction. On peut ainsi appeler l'objet avec des parenthèses en indiquant les arguments souhaités.

Ecrivons par exemple une classe avec un opérateur () retournant un nombre de Fibonacci d'un rang donné :

#include <iostream>

using namespace std;

class FiboComputer {
private:
	uint64_t fib0;
	uint64_t fib1;
public:
	FiboComputer(uint64_t fib0, uint64_t fib1): fib0(fib0), fib1(fib1) {}
	
	uint64_t operator()(int rank) const {
		auto a = fib0;
		auto b = fib1;
		if (rank == 0) return a;
		if (rank == 1) return b;
		for (int i = 1; i < rank; i++) {
			auto tmp = a;
			a = b;
			b += tmp;
		}
		return b;
	}
};

int main() {
	FiboComputer fib(1, 1);
	for (int i = 0; i < 10; i++) {
		cout << "fib(" << i << ")= " << fib(i) << endl;
	}
}

Les opérateurs de conversion (cast)

Les opérateurs de cast permettent de convertir un élément d'un type vers un autre.

Supposons que nous souhaitons convertir un objet de type Matrix vers un objet de type string représentant la matrice sous forme de chaîne de caractères. Nous pouvons à cet effet définir l'opérateur de conversion operator string() :

class Matrix {
...
	operator string() const {
		string str = ""; // empty string to start
		for (int i = 0; i < N; i++) {
			if (i > 0) s += "\n";
			str += "["; // start of row
			for (int j = 0; j < N; j++) {
				if (j > 0) str += ",";
				str += get(i, j);
			}
			str += "]";
		}
		return str;
	}
}

Nous pouvons désormais convertir une matrice en chaîne de caractères :

int main() {
	Matrix m;
	m.set(0, 0) = 42;
	string s = static_cast<string>(m);
	cout << s << endl;
}

Il est possible d'implanter plusieurs opérateurs de conversion afin de permettre la conversion d'un type vers plusieurs types. Il serait par exemple possible de convertir la matrice sous forme d'un vector d'entiers :

class Matrix {
...
	operator vector<int>() const {
		vector<int> v;
		for (int i = 0; i < N; i++)
			for (int j = 0; j < N; j++)
				v.push_back(get(i, j));
		return v;
	}
}

Exercices

Matrice dynamique

  1. Implantez une classe Matrix de scalaires entiers avec une allocation dynamique du contenu. Ainsi le constructeur doit recevoir pour arguments la taille de la matrice. On suppose la matrice carrée (même nombre de lignes et colonnes).
  2. Surchargez ensuite autant d'opérateurs que vous pouvez en imaginant une utilisation possible pour certains opérateurs dont l'usage n'est pas naturel (<<, >>, &...). Par exemple l'opérateur << peut réduire la taille de la matrice, >> l'augmenter...