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

Domaines d'utilisation du C++

Les commentaires

Les commentaires doivent être judicieusement utilisés pour faciliter la compréhension du programme. En C++, ils peuvent être exprimés de deux façons :

Les commentaires sont ignorés par le compilateur.

Les fonctions

En programmation procédurale (paradigme utilisé par les langages C et C++), on peut considérer la fonction comme une unité fondamentale de code. On l'appelle en lui transmettant des arguments, elle réalise des calculs puis retourne un résultat.

Une fonction possède donc :

Ecrivons par exemple une fonction chargée de tester si une année est bissextile ou non dans le calendrier grégorien :

bool isLeapYear(int year) {
	return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
}

Une fonction peut appeler d'autres fonctions et ainsi de suite : les appels de fonction s'organisent dans la pile d'appel. Une fonction peut aussi s'appeler elle-même directement ou indirectement (par le biais d'autres fonctions). On dit alors que la fonction est récursive.

Nous pouvons écrire une fonction récursive simple calculant une factorielle :

int fact(int n) {
	if (n <= 1) return n;
	return n * fact(n - 1);
}

Une fonction est écrite à l'intérieur d'une unité de compilation (module). Une fonction peut avoir une portée plus ou moins importante :

⚠ Par défaut toutes les fonctions sont considérées comme extern. Une fonction déclarée extern dans son module ne suffit pas pour pouvoir l'utiliser partout. Il faut aussi que le module qui l'utilise ait connaissance de son existence avec sa signature exacte. Cela explique l'utilisation de fichiers en-tête séparés qui ne contiennent que la déclaration des structures et fonctions. Ainsi si nous souhaitons pouvoir utiliser partout la fonction fact précédemment écrite (que l'on supposera écrite dans le module mathtools), nous devons :

1) Créer une fichier d'en-tête mathtools.hpp avec la déclaration de la fonction :

// Fichier d'en-tête mathtools.hpp
#ifndef _HELLOTELLER_HPP
#define _HELLOTELLER_HPP

extern int fact(int n);

#endif

2) Créer le module avec l'implantation de la fonction :

// Fichier mathtools.cpp

#include "mathtools.hpp"

int fact(int n) {
	if (n <= 1) return n;
	return n * fact(n - 1);
}

3) Utiliser le module mathtools depuis un module externe :

#include "mathtools.hpp"

#include <iostream>

using namespace std;

int main() {
	cout << "Enter a n to compute n!" << endl;
	int n = -1;
	cin >> n;
	cout << n << "! = " << fact(n);
}

Les variables

Les langages C et C++ permettent de définir des variables à l'échelle globale du programme ou locale à l'intérieur d'une fonction ou d'un bloc de code. Une variable locale possède une portée limitée à son bloc de déclaration.

Variables globales

Une variable globale est déclarée directement dans un module (unité de compilation). Deux types de variables globales existent (comme pour les fonctions) :

⚠ Conmme pour une fonction, pour qu'une variable globale extern soit utilisable depuis un autre module, celle-ci doit être redéclarée dans le module l'utilisant. Ceci peut être mis en oeuvre à l'aide d'un fichier d'en-tête avec la déclaration (sans l'affectation).

⚠ En pratique, l'usage des variables globales est très fortement déconseillé : cela rend le code généralement peu évolutif et pollue l'espace de nommage. Seules les constantes globales devraient être tolérées.

Variables locales

Les variables locales sont initialisées dans une fonction ou un bloc de code interne de la fonction (bloc de code associé à une structure if par exemple) avec une portée limitée au bloc.
En pratique les variables locales sont stockées dans la pile d'appel (avec les paramètres des fonctions qui peuvent aussi être considérés comme des variables locales). Le compilateur détermine la taille de la stack frame réservée pour chaque fonction dans la pile d'appel en fonction de l'ensemble des variables locales déclarées.

Contrairement au langage C où les variables locales devaient être obligatoirement déclarées en début de bloc, il est possible de déclarer à tout endroit du code une variable locale en C++. La déclaration peut être associée ou non à une affectation. Le type de la variable doit être obligatoirement indiqué sauf si une initialisation est réalisée en même temps que la déclaration : dans ce cas le mot-clé auto peut être utilisé pour adopter le type de l'initialiseur.

Voici des exemples de déclaration d'entiers :

int a = 42;
auto b = 43;

Le mot clé static peut accompagner la déclaration d'une variable locale de fonction pour indiquer que la valeur de la variable est conservée pour tous les appels de la fonction. La variable locale peut alors être considérée comme une sorte de variable globale uniquement accessible depuis le code de la fonction.

Ecrivons une fonction réalisant la somme de tous les diviseurs d'un nombre (i.e. tous les diviseurs d'un nombre, incluant lui-même) :

/** Nouvelle version de la fonction incluant aussi n lui-même en tant que diviseur */
int computeDividerSum(int n) {
	int result = 1;
	int i;
	for (i = 2; i * i < n; i++) 
		if (n % i == 0) // i est diviseur de n
			result += i + (n/i); // nous ajoutons le diviseur et n/diviseur (qui est aussi diviseur)
	if (i * i == n) // cas où n est un carré
		result += i;
	// rajoutons aussi n qui est diviseur de lui-même (si n > 1, si n == 1, il a déjà été pris en compte)
	if (n > 1)
		result += n;
	return result;
}

Nous notons ici l'usage d'une variable locale result de type int ainsi qu'une autre variable locale i dont la portée est limitée à la boucle for.

Il se peut que nous ayons besoin d'appeler plusieurs fois computeDividerSum avec un même paramètre : dans ce cas nous réalisons le calcul à plusieurs reprises. Pour éviter un recalcul si nous utilisons deux fois de suite le même paramètre, nous pouvons introduire une variable static pour réaliser un cache avec la dernière valeur calculée :

int computeDividerSumWithCache(int n) {
	static int lastParam = -1; // -1 peut être considéré comme une valeur interdite
	static int lastResult = -1;
	if (lastParam == n)
		return lastResult; // no need to recompute
	lastParam = n;
	lastResult = computeDividerSum(n);
	return lastResult;
}

⚠ L'approche de mise en cache de la dernière valeur calculée par une variable locale static ne fonctionne pas dans un contexte de programmation concurrente (avec l'utilisation de plusieurs threads et donc de plusieurs piles d'appel).

Les types fondamentaux

C++ dispose de types fondamentaux qui peuvent ensuite être assemblés entre eux dans des structures de données ou classes afin de créer des types plus complexes. Nous examinons ici les principaux types fondamentaux.

Les entiers

Pour représenter des entiers, principalement deux catégories de types coexistent :

Il existe d'autres catégories de types d'entier que nous ne décrivons pas ici par souci de brieveté.

Les flottants

Les types flottant permettent de représenter des valeurs numériques. Ils sont notamment utiles pour des calculs pour des applications de simulation numérique. Un nombre flottant est représenté en mémoire en utilisant le standard IEEE 754 implanté par la plupart des microprocesseurs. Ce standard stipule que le flottant est composé de différentes parties en mémoire :

La valeur du flottant de mantisse M , d'exposant e et de bit de signe s est v = M * 2 e * ( - 1 ) s (en réalité un décalage est utilisé sur l'exposant pour autoriser les valeurs d'exposant négative). Un type flottant permet ainsi de représenter un sous-ensemble des nombres rationnels (fraction dont le dénominateur est une puissance de 2). En règle générale, un réel arbitraire ne pourra être qu'approximé par une valeur flottante. C'est pourquoi il convient d'être prudent lorsque l'on utilise un type flottant en ce qui concerne les potentielles erreurs d'arrondis qui peuvent être plus ou moins acceptables selon l'application : avec certains calculs, une erreur d'arrondi peut s'amplifier. En particulier l'utilisation de flottants est à proscrire pour les applications financières pour lesquels un codage en base 2 n'est pas adapté.

Le standard C++ stipule que trois types flottant sont disponibles : float, double et long double. Le standard indique que sizeof(float) <= sizeof(double) <= sizeof(long double) mais rien n'interdit le compilateur d'utiliser la même taille de flottants pour ces trois types. En règle générale sizeof(float) == 32 (correspond au binary32 de la norme IEEE 754), sizeof(double) == 64 (correspond au binary64 de la norme IEEE 754) et sizeof(long double) == 80 (non normalisé).

La révision de 2008 de la norme IEEE 754 sur les flottants a introduit des flottants en base décimale (decimal32, decimal64...) qui sont pratiques pour certaines applications (valeurs monétaires notamment). Toutefois leur utilisation n'est pas normalisée en C++ ; le compilateur gcc supporte expérimentalement les types _Decimal32, _Decimal64 et _Decimal128.

Type bool

Le type bool représente un booléen pouvant prendre deux valeurs : true ou false. La taille occupée par un bool dépend de l'environnement de compilation (typiquement 1 octet est suffisant).

Les caractères

Les types caractères peuvent servir à représenter des octets ou des lettres encodées selon un jeu de caractères spécifique. Les types caractère suivants sont disponibles en C++ :

Récapitulatif

Voici un programme en C++ récapitulant les différents types examinés et qui permet de connnaître la taille occupée en mémoire.

#include <iostream>
#include <type_traits>

using namespace std;

template<class T>
void testType(const char * name) {
	cout << name << ", size=" << sizeof(T) << ", signed=" << is_signed<T>() << endl;
}

int main() {
	// Testons les types entier (sans taille fixée)
	testType<short int>("short int");
	testType<unsigned short int>("unsigned short int");
	testType<int>("int");
	testType<unsigned int>("unsigned int");
	testType<long int>("long int");
	testType<unsigned long int>("unsigned long int");
	testType<long long int>("long long int");
	testType<unsigned long long int>("unsigned long long int");

	// Testons les types entier (avec taille fixée)
	testType<int8_t>("int8_t");
	testType<uint8_t>("uint8_t");
	testType<int16_t>("int16_t");
	testType<uint16_t>("uint16_t");
	testType<int32_t>("int32_t");
	testType<uint32_t>("uint32_t");
	testType<int64_t>("int64_t");
	testType<uint64_t>("uint64_t");
	
	// Testons les types flottant
	testType<float>("float");
	testType<double>("double");
	testType<long double>("long double");
	
	// Testons les types flottant décimal (expérimental avec GCC, peut ne pas compiler avec d'autres compilateurs)
	/* testType<_Decimal32>("_Decimal32");
	testType<_Decimal64>("_Decimal64");
	testType<_Decimal128>("_Decimal128"); */
	
	// Testons le type bool
	testType<bool>("bool");

	// Testons les types caractères
	testType<char>("char"); // pas de suspens, invariablement de taille 1 octet
	testType<unsigned char>("unsigned char"); // pas de suspens, invariablement de taille 1 octet
	testType<wchar_t>("wchar_t");
	testType<char16_t>("char16_t");
	testType<char32_t>("char32_t");
}

☞ Notons qu'il est possible de définir des alias pour des types existants avec le mot-clé using. Par exemple si nous écrivons un logiciel manipulant des grandeurs physiques, il peut être agréable de définir un type pour chaque variété de grandeur manipulée :

using tension_t = float;
using intensity_t = float;
using resistance_t = float;

/** Calculons une tension en appliquant la loi d'Ohm */
tension_t computeTension(resistance_t r, intensity_t i) {
	return r * i;
}

Plus tard, il sera possible de changer la précision des flottants utilisés en modifiant les déclarations using sans avoir à modifier les fonctions implantées.

Les structures de contrôle

La programmation structurée utilise des structures afin de contrôler l'enchaînement des instructions du programme. Il s'agit d'un progrès par rapport à l'usage de labels et d'instructions de branchement (goto).
Afin de rendre un langage Turing-complet, une seule structure de contrôle est nécessaire : une boucle permettant d'exécuter une suite d'instructions tant qu'une condition est remplie (correspondant à une boucle while).
En pratique plusieurs structures sont introduites pour rendre les programmes écrits plus lisibles et faciliter la vie du développeur.

Structure conditionnelle

if/else

La structure conditionnelle if permet d'exécuter du code si une condition initiale (expression booléenne) est remplie. En C++, l'expression évaluée peut retourner un type int (différent de 0 pour être considéré vrai) ou un type bool. Un bloc else peut être ajouté pour exécuter une alternative si l'expression est évaluée à faux.

auto birthYear = 0;
cout << "what is your birth year?" << end;
cin >> birthYear;
if (birthYear % 2 == 0)
	cout << "You were born during an even year" << endl;
else
	cout << "You were born during an odd year" << endl;

On pourrait également écrire la structure if-else ainsi :

if (birthYear % 2) // true if birthYear is odd
	cout << "You were born during an odd year" << endl;
else
	cout << "You were born during an even year" << endl;

Si le bloc est composé de plusieurs instructions, il doit être entouré d'accolades :

if (birthYear % 2 == 0) {
	cout << "You were born during an even year" << endl;
	cout << "but it does not matter" << endl;
}

Notons que l'on peut chaîner des structures if les unes à la suite des autres :

/** Compare two integers */
int compare(int a, int b) {
	if (a < b)
		return -1;
	else if (a > b)
		return 1;
	else
		return 0;
}

switch-case

La structure switch-case est utile pour exécuter du code selon la valeur d'un entier ou d'une valeur d'énumération. Par exemple, nous pouvons écrire une fonction retournant le nom des jours de la semaine dans une langue donnée (en français ici) :

string getDayName(int dayNumber) {
	string name;
	switch (dayNumber) {
		case 0:
			name = "lundi";
			break;
		case 1:
			name = "mardi";
			break;
		case 2:
			name = "mercredi";
			break;
		case 3:
			name = "jeudi";
			break;
		case 4:
			name = "vendredi";
			break;
		case 5:
			name = "samedi";
			break;
		case 6:
			name = "dimanche";
			break;
		default:
			name = "undéfini";
			break;
	}
	return name;
}

/** Le jour indiqué est-il travaillé (jour ouvrable) ? */
bool isLabourDay(int dayNumber) {
	switch (dayNumber) {
		case 5:
		case 6:
			return false;
		default:
			return true;
	}
}

Cet exemple aurait pu également (sans doute de façon plus efficiente) être implanté en utilisant un type enum :

enum class WeekDay 
{
	Monday,
	Tuesday,
	Wednesday,
	Thursday,
	Friday,
	Saturday,
	Sunday
};

bool isLabourDay(WeekDay day) {
	return ! (day == WeekDay::Saturday || day == WeekDay::Sunday);
}

Quelques remarques sur la structure switch-case :

Opérateur conditionnel ternaire ?:

L'opérateur conditionnel ternaire permet de construire une expression prenant une valeur différente selon le résultat d'un test logique. Son usage permet d'obtenir un code plus concis que l'emploi d'une structure if (...) else avec des affectations dans une variable locale.

L'expression cond ? a : b s'évalue a si cond est vrai ou b si cond est faux.

Ecrivons par exemple une fonction calculant la différence en valeur absolue entre deux entiers avec une structure if else :

int absdiff(int a, int b) {
	if (a < b) return b - a;
	else return a - b;
}

Réécrivons cette fonction avec l'opérateur conditionnel ternaire :

int absdiff(int a, int b) {
	return (a < b) ? (b - a) : (a - b);
}

Boucles

Une boucle permet d'exécuter zéro, une ou plusieurs fois un bloc de code. En C comme en C++, différentes structures de boucles sont proposées :

Des instructions permettent de sortir prématurément du bloc de code d'une boucle :

Un programme utilisant un certain type de boucle peut facilement être réécrit avec un autre type.

Voici un exemple d'une fonction recherchant un caractère dans une chaîne de caractère et retournant son indice :

int findChar(char needle, const char * haystack) {
	int len = strlen(haystack);
	
	// Première version utilisant une boucle for
	for (int i = 0; i < len; i++)
		if (haystack[i] == needle)
			return i;
	return -1; // pas trouvé
			
	// Deuxième version avec une boucle while
	int i = 0;
	while (i < len) {
		if (haystack[i] == needle)
			return i;
		i++;
	}
	return -1; // pas trouvé
	
	// Troisième version (moins naturelle) avec do..while (nécessite de vérifier que haystack n'est pas une chaîne vide)
	int i = 0;
	if (len == 0) return -1; // pas trouvé, haystack est vide
	do {
		if (haystack[i] == needle)
			return i;
		i++;
	} while (i < len);
}

Voici un exemple d'utilisation de boucle for-each :

/** Calcule la somme des entiers d'un vecteur */
int sum(vector<int> tab) {
	int result = 0;
	for (int element: tab)
		result += element;
	return result;
}

Ecrivons maintenant un programme chargé de récupérer des flottants sur l'entrée standard puis de calculer des statistiques concernants ces nombres (minimum, maximum, somme et moyenne). Pour cela, nous utilisons une boucle afin de demander plusieurs flottants à l'utilisateur. Si l'utilisateur communique un flottant invalide, il n'est pas pris en compte. Au bout de deux flottants invalides communiqués, le programme se termine et les statistiques sont affichées.

#include <iostream>

using namespace std;

int main() {
	double sum = 0.0;
	int count = 0;
	double min;
	double max;
	
	int failCount = 0;
	
	while (true) {
		cout << "Enter a float number" << endl;
		double value;
		cin >> value;
		if (cin.fail()) {
			cout << "Bad float number" << endl;
			failCount++;
			cin.clear(); // clear the failbit
			cin.ignore(); // discard the entered value
			if (failCount < 2) 
				continue; // first failure, ask a new number
			else
				break; // second failure, exit from the loop
		} else {
			failCount = 0;
			sum += value;
			count++;
			if (count == 1) {
				min = max = value;
			} else {
				if (value < min) min = value;
				if (value > max) max = value;
			}
		}
	}
	
	// Print the stats
	cout << "Count: " << count << endl;
	if (count > 0) {
		cout << "Sum: " << sum << endl;
		cout << "Average: " << sum / count << endl;
		cout << "Min: " << min << endl;
		cout << "Max: " << max << endl;
	}
}

Exercices d'application

Fonctions rapides

Problèmes

Calculs calendaires

Ecrivons tout d'abord une fonction int getDaysInMonth(int month, int year) retournant le nombre de jours pour un mois d'une année donnée en utilisant le calendrier grégorien. On utilisera pour cela une structure switch-case (vous pouvez vous aider de la fonction bool isLeapYear(int year) précédemment écrite pour le mois de février).

Ecrivons maintenant un méthode int numberOfDaysBetween(int day1, int month1, int year1, int day2, int month2, int year2) calculant le nombre de jours entre deux dates. Un algorithme simple et efficace (mais pas forcément le plus optimisé) pour y parvenir consiste à avancer mois par mois entre les deux dates : on ajoute le nombre de jours pour parvenir à la fin du premier mois, on ajoute les jours de tous les mois entiers de la période, enfin on somme le nombre de jours du dernier mois partiel pour arriver à la date de fin. Nous utilisons pour nous aider la fonction précédente getDaysInMonth. On suppose que la deuxième date est ultérieure à la première. Si ce n'est pas le cas (ou alors si une date est impossible comme le 29 février d'une année non-bissextile), la fonction retourne -1.

Sachant que le premier jour du calendrier grégorien est le vendredi 15 octobre 1582 (les dates antérieures utilisent le calendrier julien que nous n'allons pas supporter ici), écrivez une fonction getWeekDay(int day, int month, int year) retournant le jour de la semaine de n'importe quelle date ultérieure au 15/10/1582.

Nous plaçons toutes les fonctions écrites dans un module dateutils.cpp. Nous crééons un nouveau module main.cpp avec une fonction main demandant à l'utilisateur de rentrer une date : nous affichons ensuite le jour de la semaine de cette date.

Emprunt

Nous empruntons une valeur v à un taux d'intérêt annuel i pour une durée de k années. Nous remboursons l'emprunt à l'échéance en versant le capital v ainsi que les intérêts. Ecrivez une fonction float computeInterest(float v, float i, int k) sachant que les intérêts sont calculés et capitalisables annuellement.

Ecrivez une deuxième version de la fonction en supposant cette fois que le capital est remboursé progressivement par part de v / k à la fin de chaque année.

Recherche de la plus longue séquence croissante

La plus longue séquence croissante d'une suite d'éléments s est la plus longue partie (allant de l'indice i à j) de cette suite telle que s[i] <= s[i+1] <= ... <= s[j-1] <= s[j].

Fixons un tableau d'entiers et déterminons sa plus longue séquence croissante :

int array[] = { 37, 5, 8, 10, 13, 11, 12, 0 };

Ici la plus longue séquence croissante est 5, 8, 10, 13.

Ecrivez une fonction de signature int findLongestRun(int * array, int n) déterminant la plus longue séquence croissante d'un tableau array de taille n. On retournera la longueur de cette séquence.

Suite Look and Say

Le terme de rang n de la suite Look and Say (référencée comme il se doit dans l'encyclopédie des suites d'entiers) est calculé en examinant le terme de rang n - 1 et en indiquant le nombre d'exemplaires de chacun de ses chiffres. Pour comprendre, le plus simple est de spécifier les premiers termes :

s[0] = 0
s[1] = 10 # il y a un 0
s[2] = 110 # il y a un 1 suivi de 1 zéro
s[3] = 2110 # il y a deux 1 suivis de un 0 
s[4] = 122110 # il y a un 2, suivi de deux 1 puis un 0
...

L'objectif est d'écrire une fonction C uint64_t lookAndSay(int a, int n) calculant le terme de rang n de la suite dont le premier terme est a (dans notre exemple a=0). On pourra réaliser un appel récursif à lookAndSay(a, n-1)

Pour cela, nous écrivons les fonctions suivantes qui pourront nous aider dans cette tâche :

Remarque : passé un certain rang, la capacité de uint64_t pourra être dépassée.