A travers les exemples, plusieurs notions seront abordées de manières récurrentes. Ces notions sont importantes dans le langage C++.
La notion la plus complexe à appréhender est sans doute les références rvalue. Les références rvalue sont utilisées pour permettre le déplacement d'objet. En C++98, il existe une fonction std::swap
qui permet l'échange de valeur. Par exemple, int a[5] = { 1, 2, 3, 4, 5}, b[5] = {}; std::swap(a,b);
permet de déplacer le tableau a vers le tableau b. Cette fonction peut être surchargée pour tous types, elle évite une copie et elle permet de transférer un objet qui n'a pas volonté à être dupliqué (via une relation d'amitié friend
). A partir du C++11, l'idée de déplacer un objet a été généralisée et modernisée grâce à la notation &&
. Une référence rvalue s'accompagne toujours de la fonction std::move
. La fonction std::move
s'apparente à un cast en référence rvalue. Le déplacement est généralement implémenté pour des objets proposant des services de recopie de gros objets ou pour des objets détenant une ressource unique (mutex, thread, ...) à l'aide :
In [1]:
.rawInput
Out[1]:
In [2]:
#include <iostream>
#include <array>
#include <memory>
class Buffer
{
private:
using array_int5_t = std::array<int,5>;
array_int5_t tableau;
public:
// Constructeur prenant une référence lvalue
Buffer(const array_int5_t& a)
: tableau(a)
{
std::cout << "copie de tableau" << std::endl;
}
// Constructeur prenant une référence rvalue
Buffer(array_int5_t&& a)
: tableau(std::move(a))
{
std::cout << "déplacement de tableau" << std::endl;
}
// Constructeur par recopie
Buffer(const Buffer& b)
:tableau(b.tableau)
{
std::cout << "recopie de buffer" << std::endl;
}
// Constructeur par déplacement
Buffer(Buffer&& b)
:tableau(std::move(b.tableau))
{
std::cout << "déplacement de buffer" << std::endl;
}
};
Buffer createBuffer()
{
return {{1,2,3,4,5}};
}
Out[2]:
In [3]:
.rawInput
Out[3]:
In [4]:
std::array<int,5> a{1, 2, 3, 4, 5};
std::cout << "b1 ";
Buffer b1(a);
std::cout << "b2 ";
Buffer b2({1, 2, 3, 4 , 5});
std::cout << "b3 ";
Buffer b3(createBuffer()); // RVO : return value optimization
std::cout << "b4 ";
Buffer b4(b1);
std::cout << "b5 ";
std::make_unique<Buffer>(createBuffer()); // Déplacement du tableau dans un buffer, puis du buffer dans un pointeur.
Out[4]:
Remarque: Un objet est déplacé seulement si celui-ci est éligible au déplacement. Par exemple, un objet const
ou les anciennes classes implémentées en C++98 ne sont pas éligibles au déplacement. Une opération de copie est réalisée à la place.
Remarque 2: Un retour de fonction est toujours optimisé par le compilateur. Il ne faut jamais définir de rvalue sur les retours de fonction.
In [5]:
const Buffer createConstBuffer()
{
return {{1,2,3,4,5}};
}
std::cout << "b6 ";
std::make_unique<Buffer>(createConstBuffer());
Out[5]:
L'utilisation des template dans des concepts de métaprogrammation a fait émerger une nouvelle problématique : la transmission parfaite des arguments. Pour palier à cet problématique, la notation &&
pour un argument template ou pour une variable auto
signifie que la variable est une référence universelle. Celle-ci indique que la variable peut être soit une référence lvalue ou soit une référence rvalue. Le type de référence est défini par l'appelant. Une référence universelle s'accompagne toujours de la fonction std::forward
.
In [6]:
.rawInput
Out[6]:
In [7]:
int *m = nullptr;
// Plusieurs fonctions de mémorisation de données en fonction de son type
void memPtr(int& a)
{
m = &a;
}
void memVal(int a)
{}
// Une fonction template réalisant le lock de la données avant d'appeler la fonction de mémorisation
template <typename FctT, typename T>
void lockAndMem(FctT fct, T a)
{
// appel à la fonction de lock, puis appel à la fonction de mémorisation
fct(a);
}
// Même template mais avec une référence lvalue
template <typename FctT, typename T>
void lockAndMemRef(FctT fct, T& a)
{
// appel à la fonction de lock, puis appel à la fonction de mémorisation
fct(a);
}
// Même template mais avec une référence univeselle
template <typename FctT, typename T>
void lockAndMemUni(FctT fct, T&& a)
{
// appel à la fonction de lock, puis appel à la fonction de mémorisation
fct(std::forward<T>(a));
}
Out[7]:
In [8]:
.rawInput
Out[8]:
In [9]:
int i = 10;
lockAndMem(&memPtr, i);
if (m != &i) std::cout << "mauvaise sauvegarde : pointeur sur une variable locale" << std::endl;
// Cela ne fonctionne pas, ajoutons une référence au template
lockAndMemRef(&memPtr, i);
if (m == &i) std::cout << "bonne sauvegarde" << std::endl;
Out[9]:
In [10]:
//lockAndMemRef(&memVal, 10); // Echoue car 10 est une rvalue.
Out[10]:
In [11]:
// Fonctionne dans tous les cas
lockAndMemUni(&memPtr, i);
if (m == &i) std::cout << "bonne sauvegarde" << std::endl;
lockAndMemUni(&memVal, 10);
Out[11]:
Le mot clé auto
indique que l'on laisse le compilateur déduire le type. Le livre Programmer efficacement en C++, ScottMeyers donne le conseil de préférer l'utilisation de auto
aux déclarations de type explicite. Afin de ne pas nuire à la lisibilité du code, il semble raisonnable de dire "préférer l'utilisation de auto
aux déclarations de type explicite pour les types complexes". Une variable auto
nécessite d'être initialisée (normal puisque le compilateur doit déduire son type). Outre l'avantage de forcer le developpeur à initialiser ses variables avec une valeur, les avantages sont :
In [12]:
.rawInput
Out[12]:
In [13]:
#include <iostream>
#include <typeinfo>
#include <memory>
#include <unordered_map>
#include <functional>
#include <cxxabi.h>
std::string demangle( const char* mangled_name )
{
std::size_t len = 0 ;
int status = 0 ;
std::unique_ptr< char, decltype(&std::free) > ptr(
__cxxabiv1::__cxa_demangle( mangled_name, nullptr, &len, &status ), &std::free ) ;
return ptr.get() ;
}
struct example
{
int val;
example(int a)
: val(a)
{}
example(const example& o)
: val(o.val)
{
std::cout << "copie" << std::endl;
}
~example()
{
std::cout << "destruction" << std::endl;
}
bool operator==(const example& o) const
{
return val == o.val;
}
};
namespace std
{
template <>
struct hash<example>
{
size_t operator()(const example& x) const
{
return hash<int>()(x.val);
}
};
}
Out[13]:
In [14]:
.rawInput
Out[14]:
In [15]:
auto base = 0;
auto lambda = [&base](int a, int b) { return (a + b) % base;};
std::cout << "Type lambda : " << demangle(typeid(lambda).name()) << std::endl << std::endl;
std::unordered_map<example,std::string> umap;
umap.insert(std::make_pair(21, "Hello"));
umap.insert(std::make_pair(28, "World"));
// Transmission parfaite du type contenu dans umap
for (auto&& elem : umap)
{
std::cout << "Type du contenu dans umap : " << demangle(typeid(elem).name()) << std::endl;
}
std::cout << std::endl << "Conversion implicite dûe à une erreur de type : " << std::endl;
// le type de elem est std::pair<const example, std::string>
// de manière naïve, on aurait pu écrire :
for (const std::pair<example, std::string>& elem : umap)
{
// Récupération d'un pointeur sur une variable locale alors que l'on
// croit récupérer un pointeur sur l'élément contenu
const std::pair<example, std::string>* ptr = &elem;
}
Out[15]:
In [16]:
.rawInput
Out[16]:
In [17]:
template <typename FctT, typename T>
void logAndCall(FctT fct, T&& ptr)
{
// appel à la fonction de log, puis appel à la fonction en paramètre
fct(std::forward<T>(ptr));
}
void deletePtr(int* ptr)
{}
Out[17]:
In [18]:
.rawInput
Out[18]:
In [19]:
logAndCall(deletePtr, nullptr);
//logAndCall(deletePtr, NULL); //échoue car NULL n'est pas un pointeur
Out[19]:
Les fonctions membres spéciales sont les fonctions générées automatiquement par le compilateur. On en connaît déjà 4 :
Le C++11 ajoute aux 4 premières :
Le comportement par défaut pour ces deux opérations est un déplacement membre à membre de la classe. Cependant, les règles de génération se sont un peu plus durcies par rapport à C++98 pour ces deux nouveaux. Pour les 4 premiers, la génération est indépendante, c'est à dire que si on déclare un destructeur, le constructeur de recopie et l'opérateur de copie sont quand même générés. Souvent, ce comportement n'est pas souhaité car si l'on déclare un destructeur, c'est qu'une ressource est détenue par la classe et nécessite une implémentation spéciale pour sa copie. Ce qui à engendré la règle de codage que tous le monde connaît qui est d'implémenter le destructeur, le constructeur de recopie et l'opérateur de copie lorsque la classe alloue une ressource. Avec les deux nouveaux, le problème ne se pose pas car elle ne sont pas générés si l'un des points suivants est vérifié :
Pour le dernier point, cela s'explique par le fait que s'il existe une implémentation pour le constructeur de déplacement, alors la génération du comportement par défaut pour l'opérateur de déplacement ne convient pas. Le raisonnement inverse est identique.
De plus, si une opération de déplacement est déclarée, les opérations de recopie ne sont pas générées.
Ces règles sont importantes. Prenons l'exemple que l'on crée un destructeur pour pouvoir tracer les appels, cette pratique va avoir un effet indésirable car à la place d'utiliser les opérations de déplacement parfaitement valides, le compilateur utilise les opérations de recopie pouvant être beaucoup plus coûteuses. Pour éviter cela, il est possible d'utiliser le mot clé default
pour signifier que le comportement de déplacement par défaut est valide.
Une autre utilisation du mot clé default
est l'utilisation pour le destructeur virtuel des classes de base. En effet, une règle du C++ impose de déclarer un destructeur virtuel de la classe de base pour permettre la désallocation complète de l'objet lors de l'utilisation de polymorphisme dynamique. Ce destructeur ne fait rien et le comportement par défaut est adapté au besoin.
In [20]:
class BufferCopie
{
public:
BufferCopie()
{}
BufferCopie(const BufferCopie& b)
{
std::cout << "recopie de buffer" << std::endl;
}
};
class BufferDeplacement
{
public:
BufferDeplacement()
{}
BufferDeplacement(const BufferDeplacement& b)
{
std::cout << "recopie de buffer" << std::endl;
}
BufferDeplacement(BufferDeplacement&& b) = default;
};
// Pas de génération du constructeur de déplacement car le constructeur de recopie est défini.
// Appel au constructeur de recopie
std::cout << "b7 ";
std::make_unique<BufferCopie>(BufferCopie());
// Le constructeur de recopie est défini et le constructeur de déplacement est défini avec le comportement par défaut.
// Appel au constructeur de déplacement
std::cout << "b8 ";
std::make_unique<BufferDeplacement>(BufferDeplacement());
// Définition d'une classe interface
class INotifier
{
public:
// Déclaration du destructeur virtuel pour l'appel au destructeur de la classe héritée lors de l'utilisation du
// polymorphisme dynamique
virtual ~INotifier() = default;
virtual void Notify() = 0;
};
Out[20]:
La génération des fonctions membres spéciales est parfois non souhaitée. Par exemple, la copie de classes de gestion d'entrées/sorties n'est pas vraiment un concept définissable : le port d'entrée doit-il être réinitialisé ? ou partagé ? Il est plus simple de d'interdire la copie de telles classes. Le mot-clé delete
permet d'indiquer qu'une fonction est interdite.
In [21]:
class CopieInterdite
{
public:
CopieInterdite()
{}
CopieInterdite(const CopieInterdite&) = delete;
CopieInterdite& operator=(const CopieInterdite&) = delete;
};
CopieInterdite A, B;
//CopieInterdite C(A);
//B = A;
Out[21]:
Le langage C++ introduit deux nouveaux mots-clés override
et final
pour indiquer explicitement une redéfinition d'une fonction membre virtuelle d'une classe de base. Une erreur de redéfinition provoquera une erreur de compilation. Déclarer les fonctions membres redéfinies à l'aide de ces deux mots-clés est donc une bonne pratique pour repérer les erreurs le plus tôt possible.
In [22]:
class Base
{
public:
virtual void operation()
{
std::cout << "Base" << std::endl;
}
};
class DeriveFaux : public Base
{
public:
virtual void operation() const // override // l'ajout du mot-clé provoque une erreur de compilation
{
std::cout << "DeriveFaux" << std::endl;
}
};
class Derive : public Base
{
public:
virtual void operation() override
{
std::cout << "Derive" << std::endl;
}
};
class DeriveFinal : public Base
{
public:
virtual void operation() final // ne peut plus être redéfinie dans une classe fille
{
std::cout << "DeriveFinal" << std::endl;
}
};
std::unique_ptr<Base> ptrBase = std::make_unique<DeriveFaux>();
ptrBase->operation(); // Sans le mot-clé override, il faut tester toutes les classes dérivées
// si la signature de la classe de base change.
ptrBase = std::make_unique<Derive>();
ptrBase->operation();
ptrBase = std::make_unique<DeriveFinal>();
ptrBase->operation();
Out[22]:
Le mot-clé constexpr
peut s'appliquer aux variables et aux fonctions. Une variable constexpr
signifie que la variable est constante et connue à la compilation. Elle sera allouée dans la mémoire en lecture seule. Une fonction constexpr
signifie que la valeur de retour sera connue à la compilation si toutes les valeurs en paramètre de la fonction sont connues lors de la compilation.
In [23]:
.rawInput
Out[23]:
In [24]:
class Tool
{
public:
static std::string DecodeString(const unsigned char* trame, const unsigned int start, const unsigned end)
{
return std::string(trame + start, trame + end);
}
};
class Trame
{
protected:
static constexpr unsigned int STX_SIZE = 1U;
static constexpr unsigned int ETX_SIZE = 1U;
static constexpr unsigned int TYPE_SIZE = 1U;
public:
static constexpr char GetType(const unsigned char* trame) noexcept
{
return trame[STX_SIZE];
}
};
class TextMsg : public Trame
{
public:
static constexpr unsigned int GetContentStart() noexcept
{
return STX_SIZE + TYPE_SIZE;
}
static constexpr unsigned int GetContentEnd(const unsigned int trameSize) noexcept
{
return trameSize - ETX_SIZE;
}
};
constexpr unsigned char HELLO_TRAME[] = {0x02, 0x54, 0x48, 0x45, 0x4C, 0X4C, 0x4F ,0x03};
// Constantes définies à la compilation
constexpr char HELLO_TYPE = Trame::GetType(HELLO_TRAME); // = 'T'
constexpr unsigned int HELLO_START = TextMsg::GetContentStart(); // = 2
constexpr unsigned int HELLO_END = TextMsg::GetContentEnd(sizeof(HELLO_TRAME));// = 7
// Si la trame change, par exemple avec l'ajout d'un champ au début de la trame, seules les fonctions sont à modifiées.
// Les constantes seront automatiquement mis à jour à la compilation.
Out[24]:
In [25]:
.rawInput
Out[25]:
In [26]:
// Trace
std::cout << "Emission de la trame : " << HELLO_TYPE << " " << Tool::DecodeString(HELLO_TRAME, HELLO_START, HELLO_END) << std::endl;
// Simulation de la réception d'une trame (trame et taille non déclarée constexpr ou const)
unsigned char test[] = {0x02, 0x54, 0x54, 0x45, 0x53, 0X54, 0x03};
unsigned int lg = sizeof(test);
// Appel des fonctions constexpr à l'éxecution
std::cout << "Réception de la trame : " << Trame::GetType(test) << " " << Tool::DecodeString(test, TextMsg::GetContentStart(), TextMsg::GetContentEnd(lg)) << std::endl;
Out[26]:
Remarque: En C++11, une fonction constexpr
est limitée à une seule instruction return (pas de condition, pas de boucle). Cette limitation a été levée en C++14.
Le fait de déclarer une fonction noexcept
indique au developpeur appelant que la fonction n'émet pas d'exception. Les avantages de déclarer une fonction noexcept
sont :
La connaissance de la sureté d'une fonction face aux exceptions est intéressante notamment pour les opérations de déplacement. Contrairement aux opérations de recopie, si une exception intervient lors d'un déplacement d'un objet, il n'est pas possible de revenir en arrière : une partie des attributs ont été déplacée dans une autre zone mémoire. Il est donc indispensable d'appeler des fonctions noexcept
dans les opérations de déplacement.
In [27]:
class DeplacementNoExcept
{
public:
DeplacementNoExcept()
{}
DeplacementNoExcept(const DeplacementNoExcept&)
{
std::cout << "recopie DeplacementNoExcept" << std::endl;
}
DeplacementNoExcept(DeplacementNoExcept&&) noexcept
{
std::cout << "deplacement DeplacementNoExcept" << std::endl;
}
};
class Deplacement
{
public:
Deplacement()
{}
Deplacement(const Deplacement&)
{
std::cout << "recopie Deplacement" << std::endl;
}
Deplacement(Deplacement&&)
{
std::cout << "deplacement Deplacement" << std::endl;
}
};
std::vector<DeplacementNoExcept> v1;
DeplacementNoExcept dne;
v1.push_back(dne);
v1.shrink_to_fit();
std::cout << std::endl;
std::cout << "Réallocation du vecteur : déplacement des éléments précédents si l'opération de déplacement ne renvoie pas d'exception : " << std::endl;
v1.push_back(dne);
std::cout << std::endl << std::endl;
std::vector<Deplacement> v2;
Deplacement d;
v2.push_back(d);
v2.shrink_to_fit();
std::cout << std::endl;
std::cout << "Réallocation du vecteur : déplacement des éléments précédents si l'opération de déplacement ne renvoie pas d'exception : " << std::endl;
v2.push_back(d);
Out[27]:
Remarque 1: Les conteneurs de la STL tirent partie de la déclaration noexcept
sur les opérations de déplacement pour optimiser leur gestion.
Remarque 2: Il est possible de déclarer une fonction noexcept
qui appelle des fonctions qui ne sont pas déclarées noexcept
pour conserver la compatibilité avec les fonctions de la librairie C ou les anciennes fonctions C++98. Le compilateur ne générera pas d'erreur.
Remarque 3: Déclarer une fonction noexcept
qui émet/laisse passer une exception provoquera la terminaison du programme. L'exception ne peut pas être catchée par le code appelant.
Remarque 4: Les destructeurs sont déclarés noexcept
par nature (règle du langage). Il est inutile de les déclarer noexcept
.
In [28]:
.rawInput
Out[28]:
In [29]:
#include <queue>
#include <stack>
// typedef
typedef int (*fonction_ptr_t1)(int, int);
template <typename T>
class container_types
{
public:
typedef std::queue<T> fifo_t;
typedef std::stack<T> lifo_t;
};
// using
using fonction_ptr_t2 = int (*)(int, int);
template <typename T>
using fifo_t = std::queue<T>;
template <typename T>
using lifo_t = std::stack<T>;
Out[29]:
In [30]:
.rawInput
Out[30]:
In [31]:
int fonction(int, int)
{
return 0;
}
fonction_ptr_t1 fct1 = &fonction;
fonction_ptr_t2 fct2 = &fonction;
// typedef
typename container_types<int>::fifo_t file1;
// using
fifo_t<int> file2;
Out[31]:
Le mot-clé decltype
renvoie presque toujours le type de l'expression passée en paramètre. (presque car si par exemple, l'expression en paramètre est une expression complexe, le type retourné peut-être une lvalue c'est-à-dire type&).
Le mot-clé decltype
est principalement utilisé avec le mot-clé auto
pour le retour des fonctions template ou les expressions lambdas.
In [32]:
int CONSTANTE = 0;
// Pas de surprise, decltype renvoie le type de l'expression
using constante_t = decltype(CONSTANTE); // int
using fonction_ptr_t3 = decltype(&fonction); // int (*)(int, int)
Out[32]:
In [33]:
.rawInput
Out[33]:
In [34]:
template <typename ContainerT, typename IndexT>
//typename std::decay<ContainerT>::type::value_type AccessWithIntegrityCheck1(ContainerT&& container, IndexT index) // C++11
auto AccessWithIntegrityCheck1(ContainerT&& container, IndexT index) // syntaxe simplifié du C++14
{
//CheckIntegrity(std::forward<ContainerT>(container));
return std::forward<ContainerT>(container)[index];
}
template <typename ContainerT, typename IndexT>
//typename std::decay<ContainerT>::type::reference AccessWithIntegrityCheck2(ContainerT&& container, IndexT index) // C++11
auto& AccessWithIntegrityCheck2(ContainerT&& container, IndexT index) // syntaxe simplifié du C++14
{
//CheckIntegrity(std::forward<ContainerT>(container));
return std::forward<ContainerT>(container)[index];
}
template <typename ContainerT, typename IndexT>
//auto AccessWithIntegrityCheck3(ContainerT&& container, IndexT index) -> decltype(std::forward<ContainerT>(container)[index]) //C++11
decltype(auto) AccessWithIntegrityCheck3(ContainerT&& container, IndexT index) // syntaxe simplifié du C++14
{
//CheckIntegrity(std::forward<ContainerT>(container));
return std::forward<ContainerT>(container)[index];
}
Out[34]:
In [35]:
.rawInput
Out[35]:
In [36]:
std::vector<int> ids = {1, 2, 3, 4, 5};
//AccessWithIntegrityCheck1(ids, 2) = 6; // Ne compile pas car le retour n'est pas une lvalue
AccessWithIntegrityCheck2(ids, 2) = 6;
AccessWithIntegrityCheck3(ids, 2) = 6;
std::vector<bool> flags = {false, false, true, false, false};
bool f1 = AccessWithIntegrityCheck1(flags, 2);
//bool f2 = AccessWithIntegrityCheck2(flags, 2); // Ne compile pas car le type vector<bool> est optimisé
// au niveau mémoire pour occuper qu'un seul bit par valeur.
// L'operator[] renvoit une rvalue dans ce cas présent.
bool f3 = AccessWithIntegrityCheck3(flags, 2);
Out[36]:
Un problème des enumérations en C++ est la pollution de l'espace de nom. On distingue maintenant les énumérations non délimités (enum en C++98) et les énumérations délimités déclarées avec les mots-clés enum class
. Les énumérations délimités ne sont accessibles qu'à l'intérieur de leur espace de nom. En C++11, il est également possible de déclarer le type sous-jacent pour les enumérations délimitées et non délimitées permettant d'optimiser l'espace mémoire. Le cast des énumérations délimitées est limité à son type sous-jacent et doit être fait de manière explicite.
In [37]:
enum class EtatSysteme : std::uint8_t // place en mémoire = 1 octet à la place de 4 octets
{
Ok,
EnPanne
};
enum class EtatCommunication : uint8_t
{
Ok,
EnPanne
};
// Il n'y a pas de doublon de valeurs, les valeurs sont déclarées dans des espaces de nom différents
EtatSysteme sys = EtatSysteme::Ok;
EtatCommunication com = EtatCommunication::EnPanne;
Out[37]:
In [38]:
#include <bitset>
template <typename E>
constexpr auto enum_cast(E enumerator) noexcept
{
return static_cast<std::underlying_type_t<E>>(enumerator);
}
enum class ErrorEnum : uint8_t
{
MotorFailure = 0,
CableDeconnection = 1,
CameraFailure = 2,
MicroProgCorrupted = 3
};
Out[38]:
In [39]:
constexpr std::size_t FlagSize = 8U * sizeof(ErrorEnum);
std::bitset<FlagSize> ErrorFlag;
ErrorFlag.set(enum_cast(ErrorEnum::CameraFailure)); // Cast explicite pour obtenir la valeur sous-jacente
std::cout << ErrorFlag << std::endl;
Out[39]:
In [40]:
// initialiser un vecteur
std::vector<std::string> strVector = {"un", "deux", "trois"};
// initialiser un tableau
std::array<int,3> intArray({1, 2, 3});
// Attention à la déduction de type
auto values = {1, 2, 3};
auto expr = [](auto&& container, std::size_t index) { return std::forward<decltype(container)>(container)[index]; };
std::cout << expr(intArray, 0) << std::endl;
std::cout << expr(static_cast<std::vector<int>>(values), 0) << std::endl;
Out[40]:
Remarque: Le type d'un objet {}
est std::initializer_list<T>
. La déduction de type auto
ou template
necessitera parfois d'expliciter le type cible pour obtenir le bon type.