jeudi 20 août 2020

C++ : Quel conteneur de données choisir?

Il existe plusieurs types de conteneurs de données dans la bibliothèque standard du C++. On peut penser rapidement au std::vector à la std::list ou au std::map mais il en existe plusieurs autres et il important de choisir le bon type de conteneur selon l'utilisation que l'on en fera. Nous allons voir ci-dessous chaque type de conteneur et leurs particularités.

tableau style-C

Le tableau style-C comme son nom l'indique est un tableau d'éléments provenant du langage C. Le nombre d'éléments d'un tableau est statique cela veut donc dire que la taille du tableau est fixe une fois créé. Si on veut ajouter ou retirer un élément, on devra recréer un nouveau tableau avec la nouvelle taille puis copier les éléments qu'on souhaite conserver. Les éléments d'un tableau sont contigus en mémoire. On peut donc accéder directement à un élément à partir de son index, c'est-à-dire sa position dans le conteneur. Lorsqu'on initialise un tableau, c'est en fait un pointeur vers le premier élément du tableau qui nous est retourné.


Exemple 1 : Création d'un tableau dont la taille est connue au moment de la compilation
int myNumbers[] { 3, 5, 7, 12, 18, 21 };
cout << "Premier élément: " << myNumbers[0] << "   Dernier élément: " << myNumbers[5] << "\n";

// Output : Premier élément: 3   Dernier élément: 21

Exemple 2: Création d'un tableau dont la taille est connu au moment de l'exécution
size_t nbElements;
cout << "Entrer le nombre d'éléments du tableau: ";
cin >> nbElements;

unique_ptr<int[]> myNumbers (new int[nbElements]);
for(size_t i=0; i<nbElements; i++) {
   myNumbers[i] = i; 
}
cout << "Premier élément: " << myNumbers[0] << "   "
        "Dernier élément: " << myNumbers[nbElements-1] << "\n";

// Input : 8
// Output : Premier élément: 0   Dernier élément: 7


On peut voir dans les exemples ci-dessus que les éléments sont accéder via l'opérateur d'indexation, mais il est également possible d'utiliser l'arithmétique des pointeurs pour accéder aux éléments. Tel qu'énoncé au départ, la variable utilisée pour accéder au tableau ne contient pas les éléments du tableau mais elle contient un pointeur sur le premier élément du tableau. 

Repartons l'exemple du code précédent en utilisant l'arithmétique des pointeurs pour afficher le premier et le troisième élément, c'est-à-dire les éléments aux index 0 et 2.

Exemple 3: Accès à un élément via l'arithmétique des pointeurs
int *ptrThirdElement = myNumbers.get() + 2;
cout << "Premier élément: " << myNumbers[0] << "   "
        "Troisième élément: " << *ptrThirdElement << "\n";

On peut voir dans l'image ci-dessous que la variable myNumbers est en fait un pointeur qui pointe à l'adresse mémoire 0x4176d0 et sachant que la taille d'un int sur mon système est de 4 octets alors on se déplace de 4 octets pour le deuxième élément donc 0x4176d4 puis encore de 4 octets pour le troisième élément alors 0x4176d8. Il faut bien sûr déréférencer le pointeur à l'aide l'opérateur de déréférencement (*) pour pouvoir afficher son contenu. 

L'opérateur ++ sur un pointeur n'incrémente pas l'adresse du pointeur de 1 mais bien d'une fois la taille de l'élément pointé.



En ce qui concerne la taille, c'est la responsabilité du développeur de connaitre et conserver la taille du tableau tout au long de son existence. Si par exemple on veut passer un tableau style-C dans une fonction il faut passer le pointeur du premier élément puis la taille du tableau.

Exemple 4: Passage d'un tableau style-C à une fonction
void printArray(const unique_ptr<int[]> &p_MyNumbers, size_t p_NbElements) 
{
    cout << "Premier élément: " << p_MyNumbers[0] << "   "
            "Dernier élément: " << p_MyNumbers[p_NbElements-1] << "\n";
}

int main(int argc, char *argv[])
{
    size_t nbElements;
    cout << "Entrer le nombre d'éléments du tableau: ";
    cin >> nbElements;

    unique_ptr<int[]> myNumbers (new int[nbElements]);
    for(size_t i=0; i<nbElements; i++) {
        myNumbers[i] = i; 
    }

    printArray(myNumbers, nbElements);

    return EXIT_SUCCESS;
}

Le fait de passer un pointeur et une taille comporte un risque d'erreur de la part du développeur alors pour les tableaux dont la taille est connue à la compilation il est conseillé d'utiliser un span. Vous pouvez utiliser la classe span de la bibliothèque GSL (Guidelines Support Library) si le standard que vous utilisez est antérieur à C++20 et directement la classe std::span si vous utilisez le standard C++20.

Exemple 5: Utilisation d'un gsl::span
void printArray(gsl::span<int> &p_MyNumbers) 
{
    cout << "Premier élément: " << p_MyNumbers[0] << "   "
            "Dernier élément: " << p_MyNumbers[p_MyNumbers.size()-1] << "\n";
}

int main(int argc, char *argv[])
{
    int myNumbers[] { 3, 5, 7, 12, 18, 21 };

    gsl::span<int> spanMyNumbers(myNumbers);
    printArray(spanMyNumbers);

    return EXIT_SUCCESS;
}

Tel qu'indiqué dans les C++ Core Guidelines il est préférable d'utiliser le std::array pour les conteneurs dont la taille est fixe plutôt qu'un tableau style-C. Nous verrons les avantages qu'offre le std::array dans la prochaine section.

Temps d'accès à un élément :  O(1)
Temps d'insertion/retrait d'un élément :  O(n)

std::array

#include <array>

Le nombre d'éléments d'un array est fixe, c'est-à-dire qu'il ne peut pas être changé une fois créé. Le std::array a l'avantage de connaître sa taille comparativement au tableau de style-C donc on n'a pas à passer un pointeur et une taille lors d'un appel de fonction, on peut simplement passer le tableau en question comme dans l'exemple ci-dessous :

Exemple 6: Passage d'un std::array par référence constante à une fonction
void printArray(const array<int, 6> &p_MyNumbers) 
{
    try {
        cout << "Premier élément: " << p_MyNumbers.at(0) << "   "
                "Dernier élément: " << p_MyNumbers.at(p_MyNumbers.size()-1) << '\n';
    }
    catch(out_of_range const& err) {
        cerr << "Élément out_of_range." << '\n';
    }
}

int main()
{
    array<int, 6> myNumbers { 3, 5, 7, 12, 18, 21 };
    printArray(myNumbers);
    return EXIT_SUCCESS;
}

Il est également possible de passer un std::array par valeur à une fonction contrairement au tableau style-C. Voir la publication https://jdumais.blogspot.com/2020/04/c-passage-darguments-par-valeur-par.html pour plus de détails sur les différents modes de passage d'arguments.

Comme vous pouvez voir dans l'exemple ci-dessus, un des avantages qu'offre le std::array est la vérification des bornes via la méthode at. Si on tente d'accéder à un élément qui se trouve en dehors du tableau une exception de type std::out_of_range sera déclenchée.

Il est également possible d'utiliser les itérateurs de la bibliothèque standard pour accéder aux différents éléments.

Exemple 7: Utilisation des itérateurs de la bibliothèque standard
void printArray(const array<int, 6> &p_MyNumbers) 
{
    //Afficher tous les éléments
    for(const auto &item : p_MyNumbers) {
        cout << item << '\n';
    }
}

int main()
{
    array<int, 6> myNumbers { 3, 5, 7, 12, 18, 21 };
    printArray(myNumbers);
    return EXIT_SUCCESS;
}


Temps d'accès à un élément :  O(1)
Temps d'insertion/retrait d'un élément :  O(n)

std::vector 

#include <vector>

Le vector est un conteneur dont les éléments sont contigus en mémoire. Il est donc possible d'accéder directement à un élément selon son index via l'opérateur d'indexation ou via la méthode at vérifie du même coup les limites tel que démontré dans la classe std::array.

Le nombre d'éléments d'un vecteur est dynamique autrement dit sa taille peut varier. Le vecteur a une taille et une capacité. La taille retournée par la méthode size() représente le nombre d'éléments présents dans le vecteur. La capacité quant à elle est retournée par la méthode capacity() et représente le nombre d'éléments que le vecteur pourra contenir avant d'être de nouveau redimensionné. Lorsque le vecteur doit être redimensionné pour y inclure un nouvel élément, la capacité est doublée.

vector<int> myNumbers { 3, 5, 7, 12, 18, 21 };
// myNumbers.size() égal 6 et myNumbers.capacity() égal 6
myNumbers.emplace_back(24);
// myNumbers.size() égal maintenant 7 et 
// myNumbers.capacity() égal maintenant 12, car la capacité a été doublée.

Dans les versions antérieures à C++11, les méthodes ajouter/insérer des éléments étaient insert et push_back. Il suffisait de construire l'élément à ajouter puis appeler la méthode. L'objet était donc créé puis déplacé ou copié dans le vecteur. Cela représente donc deux opérations. Avec les nouvelles méthodes emplace et emplace_back, il suffit de passer les arguments pour créer l'objet et le vecteur s'occupera lui-même d'appeler le constructeur et de créer l'objet directement en place. C'est plus efficace ainsi.

Exemple 8: Démonstration des méthodes push_back vs emplace_back
class Student {
public:
    Student(string firstName, string lastName)
        : firstName(firstName), lastName(lastName) 
    { 
        cout << "L'objet est créé.\n"; 
    }
    Student(Student&& other)
        : firstName(std::move(other.firstName)), lastName(std::move(other.lastName))
    {
        std::cout << "L'objet est déplacé.\n";
    }
    Student& operator=(const Student& other) = default;
    const string &getFirstName() const { return firstName; }
    const string &getLastName() const { return lastName; }
private:
    string firstName;
    string lastName;
};

int main()
{
    vector<Student> students;
    students.reserve(2);
    cout << "Ajout de l'étudiant Joe Blow\n";
    students.push_back(Student("Joe", "Blow"));
    cout << "Ajout de l'étudiante Jane Doe\n";
    students.emplace_back("Jane", "Doe");
    return EXIT_SUCCESS;
}

// Output:
// Ajout de l'étudiant Joe Blow
// L'objet est créé.
// L'objet est déplacé.
// Ajout de l'étudiante Jane Doe
// L'objet est créé.

Comme on a pu le voir dans l'exemple précédent, le mot clé reserve permet d'augmenter la capacité d'un vecteur sans pour autant y ajouter d'éléments. Les emplacements pour les futurs sont donc réservés, mais non utilisés. Cela permet d'éviter de multiples redimensionnement, donc des coûts de copie en mémoire, lorsqu'on sait à l'avance quel sera le nombre d'éléments requis.

Les conteneurs dont les éléments sont contigus en mémoire peuvent bénéficier de la rapiditidé de la mémoire cache. Lorsqu'on demande à lire/écrire une donnée en mémoire, le processeur chargera non seulement le contenu de l'adresse mémoire dans ses différents niveaux de mémoire cache, mais également un bloc de données plus grand sachant fort bien que le logiciel risque d'avoir besoin de ces éléments connexes au cours des prochaines instructions. Cela peut avoir un très grand impact sur les performances d'un conteneur de données.

Comme le temps d'ajout/suppression d'un élément est en O(n) on sera alors tenté de regarder vers la std::list si on a vraiment beaucoup d'opérations de ce genre. 

Comme l'indique Bjarne Stroupstrup dans son livre "A Tour of C++", le vecteur devrait être votre choix de conteneur par défaut à moins d'avoir de solide raison d'utiliser un autre type de conteneur.

Temps d'accès à un élément :  O(1)
Temps d'insertion/retrait d'un élément :  O(n)

std::list

#include <list>

La std::list comme son nom l'indique est une liste d'éléments doublement chainée. Cela veut dire qu'on peut parcourir la liste dans les deux sens : du début vers la fin et de la fin vers le début, mais contrairement aux conteneurs précédents, on ne peut pas accéder à un élément directement.

Le temps d'insertion/suppression d'un élément est de O(1) ce qui est donc est un très gros avantage si la taille du conteneur est fréquemment modifiée.

Exemple 9: Démonstration de l'utilisation d'une liste (ajout au début, ajout à la fin et parcours)
int main(int argc, char *argv[])
{
    list<string> countries { "Canada", "France", "United States" };

    countries.emplace_front("Belgium");
    countries.emplace_back("Vanuatu");
    //Affichage des pays dans l'ordre
    for(const auto &country : countries) {
        cout << country << '\n';
    }

    //Affichage des pays den ordre inverse
    for(auto iter=countries.rbegin(); iter != countries.rend(); iter++) {
        cout << *iter << '\n';
    }
    
    //Affichage du troisième pays de la liste (France)
    size_t i {0};
    for(const auto &country : countries) {
        if (i == 2) {
            cout << country << '\n';
            break;
        }
        i++;
    }

    return EXIT_SUCCESS;
}

L'empreinte mémoire de chaque élément sera donc plus grande, car pour chaque élément on aura un pointeur vers l'élément précédent ainsi qu'un pointeur vers l'élément suivant.

Les éléments n'étant pas contigus en mémoire nous ne pourront pas profiter des différentes optimisations liées aux mémoires caches. Voir Principe de localité pour plus d'infos.

Temps d'accès à un élément :  O(n)
Temps d'insertion/retrait d'un élément :  O(1)


std::forward_list

#include <forward_list>

La std::forward_list est exactement comme la std::list à l'exception près qu'on peut seulement parcourir la liste en ordre. Chaque élément a seulement un pointeur vers l'élément suivant, mais pas vers l'élément précédent. La forward_list a donc une empreinte mémoire plus légère que la list.

Temps d'accès à un élément :  O(n)
Temps d'insertion/retrait d'un élément :  O(1)


std::map

#include <map>

Le std::map est un conteneur d'association entre une clé et une valeur. Ce conteneur est normalement implanté sous forme d'arbre binaire rouge et noir.  Les éléments du conteneur sont triés selon la clé alors si votre clé est une classe personnalisée vous devrez implémenter les opérateurs comparaisons. La clé dans un std::map doit pas contre être unique. Si on tente d'ajouter un élément avec la même clé, l'ajout sera ignoré sans retourner d'erreur.


Exemple 10: Démonstration de l'utilisation d'un std::map
int main(int argc, char *argv[])
{
    map<int, string> employees 
    {
        {320, "Joe Blow"},
        {543, "Jane Doe"},
        {735, "Aaron Black"}
    };

    employees.emplace(pair<int, string>(355, "John McClean"));

    //Affichage des employés dans l'ordre de la clé
    for(const auto &employee : employees) {
        cout << "ID: " << employee.first << " Name: " << employee.second << '\n'; 
    }
    //Output:
    //ID: 320 Name: Joe Blow
    //ID: 355 Name: John McClean
    //ID: 543 Name: Jane Doe
    //ID: 735 Name: Aaron Black

    //Trouver l'employé 543
    cout << "Employé 543: " << employees.at(543) << '\n';
    //Output:
    //Employé 543: Jane Doe
//Ajouter un employé via l'opérateur d'indexation. employees[777] = "Byron Joe"; //Vérifier si un employé existe auto iter = employees.find(777); if (iter != employees.end()) { cout << "Employé 777: " << employees.at(777) << '\n';
    }
    //Output:
    //Employé 777: Byron Joe
return EXIT_SUCCESS; }

Les conteneurs d'association sont très performants lors de la recherche par clé. 

Temps d'accès à un élément :  O(log n)
Temps d'insertion/retrait d'un élément :  O(log n)


std::multimap

#include <map>

Le std::multimap est pratiquement identique au std::map au détail près qu'on peut stocker plusieurs fois la même clé.

Exemple 11: Démonstration de recherche dans un std::multimap de tous les éléments d'une même clé
int main()
{
    multimap<unsigned int, string> studentsByAge {
        {12, "Joe Blow"},
        {13, "Jane Doe"},
        {12, "Aaron Black"}
    };

    const int AGETOFIND { 12 };
    auto iter = studentsByAge.find(AGETOFIND);
    cout << "Voici la liste de tous les étudiants de 12 ans:" << '\n';
while(iter != studentsByAge.end() && iter->first == AGETOFIND) {
cout << iter->second << '\n'; iter++; } return EXIT_SUCCESS; } //Output: //Joe Blow //Aaron Black

Temps d'accès à un élément :  O(log n)
Temps d'insertion/retrait d'un élément :  O(log n)


std::set

#include <set>

Le std::set est également un conteneur d'association, mais il contient seulement une clé et non une clé et une valeur comme le std::map. Dans l'exemple ci-dessous, nous allons simuler un tirage de 6 numéros au hasard entre 1 et 49 à l'aide d'un set.

Exemple 12: Démonstration de l'utilisation d'un std::set
int main()
{
    set<int> randomNumbers;
    srand(static_cast<unsigned int>(time(nullptr)));

    while(randomNumbers.size() != 6) {
        //Choose a random number between 1 and 49
        auto number = rand() % 49 + 1;
        if (randomNumbers.find(number) == randomNumbers.end()) {
            randomNumbers.emplace(number);
        }
    }

    for(const auto number : randomNumbers) {
        cout << number << " ";
    }
    cout << '\n';
    
    return EXIT_SUCCESS;
}

//Output example:
//2 10 17 24 34 40


Temps d'accès à un élément :  O(log n)
Temps d'insertion/retrait d'un élément :  O(log n)


std::multiset

#include <set>

Le std::multiset est pratiquement identique au std::set au détail près qu'on peut stocker plusieurs fois la même clé.

Temps d'accès à un élément :  O(log n)
Temps d'insertion/retrait d'un élément :  O(log n)


std::unordered_map, std::unordered_multimap, std::unordered_set et std::unordered_multiset

#include <unordered_map> et #include <unordered_set>

Les quatre implémentations de conteneur std::unordered_map, std::unordered_multimap, std::unordered_set et std::unordered_multiset sont implémentés sous forme de table de hachage au lieu d'arbres binaire rouge et noir.

Il est important de choisir une bonne fonction de hachage afin de ne pas causer trop de collisions sinon on perd vraiment l'avantage des tables de hachage. 

Exemple 13: Démonstration de l'utilisation d'un std::unordered_set en utilisation une fonction de hachage disponible dans la bibliothèque standard. La fonction de hachage calculera la position en fonction du prénom et nom de l'étudiant.
class Student {
public:
    Student(string firstName, string lastName)
        : firstName(firstName), lastName(lastName) {}
    bool operator==(const Student& rhs) const
    { 
        return this->firstName == rhs.firstName &&
               this->lastName == rhs.lastName; 
    }
    const string &getFirstName() const { return firstName; }
    const string &getLastName() const { return lastName; }
private:
    string firstName;
    string lastName;
};


class StudentHash
{
public:
    size_t operator()(const Student &item) const 
    {
        static hash<string> hashFunction;
        return hashFunction(item.getFirstName() + item.getLastName());
    }
};

int main()
{    
    unordered_set<Student, StudentHash> students;
    students.emplace("Joe", "Blow");
    students.emplace("Jane", "Doe");
    students.emplace("Aaron", "Black");

    for(const auto &student : students) {
        cout << student.getFirstName() << " " << student.getLastName() << '\n';
    }
    return EXIT_SUCCESS;
}

Temps d'accès à un élément : O(1) à O(n)
Temps d'insertion/retrait d'un élément :  O(1) à O(n)


std::stack

#include <stack>

Le std::stack est un conteneur qui permet d'ajouter des éléments sur une pile et de les dépiler dans l'ordre inverse où ils ont été ajoutés. C'est une structure de données LIFO (last-in, first-out). Un exemple vaut mille mots.

Exemple 14: Démonstration de l'utilisation d'une std::stack
int main()
{    
    stack<int> myNumbers;
    //On empiles les nombres 1, 2, 3, 4 et 5 dans l'ordre.
    for(int i=1; i<=5; i++) {
        myNumbers.emplace(i);
    }

    //On dépile!
    while(!myNumbers.empty()) {
        cout << myNumbers.top() << '\n';
        myNumbers.pop();
    }
    return EXIT_SUCCESS;
}

//Output:
//5
//4
//3
//2
//1

La méthode top() retourne l'élément sur le dessus de la pile tandis que la méthode pop() retire ce même élément de la pile.

Temps d'ajout/retrait d'un élément :  O(1)


std::queue

#include <queue>

Le std::queue est un conteneur qui permet d'ajouter des éléments dans une file et de les retirer dans l'ordre où ils ont été ajoutés. C'est une structure de données FIFO (first-in, first-out). Reprenons l'exemple du std::stack mais en utilisant une std::queue.

Exemple 15: Démonstration de l'utilisation d'une std::queue
int main()
{    
    queue<int> myNumbers;
    //On empiles les nombres 1, 2, 3, 4 et 5 dans l'ordre.
    for(int i=1; i<=5; i++) {
        myNumbers.emplace(i);
    }

    //On dépile!
    while(!myNumbers.empty()) {
        cout << myNumbers.front() << '\n';
        myNumbers.pop();
    }
    return EXIT_SUCCESS;
}

//Output:
//1
//2
//3
//4
//5


Temps d'ajout/retrait d'un élément :  O(1)


std::priority_queue

#include <queue>

Le std::priority_queue est un conteneur place les éléments ajoutés selon la fonction de comparaison sélectionnée. Par défaut, si on ne spécifie aucune fonction de comparaison, les valeurs seront triées selon la fonction de comparaison std::greater ce qui placera la plus petite valeur en première position.

Exemple 16: Démonstration de l'utilisation d'une std::priority_queue avec la fonction de comparaison less.
int main()
{    
    priority_queue<int, vector<int>, less<int>> myNumbers;
    for(int i : {12, 47, 55, 24, 36}) {
        myNumbers.emplace(i);
    }

    //On vide la file
    while(!myNumbers.empty()) {
        cout << myNumbers.top() << '\n';
        myNumbers.pop();
    }
    return EXIT_SUCCESS;
}

//Output:
//55
//47
//36
//24
//12


Temps d'accès à un élément : O(1)
Temps d'insertion/retrait d'un élément :  O(log n)


std::deque

#include <deque>

Le std::deque est une file qui peut grandir dans les 2 directions. Elle a l'avantage d'offrir des performances O(1) à l'insertion/suppression à ses 2 extrémités et un temps d'accès d'O(1) à ses éléments. Les éléments du deque ne sont pas nécessairement stockés de façon contiguë en mémoire, mais sont plutôt stockés sous forme de segments contigus de taille fixe. 

Dû à la gestion de ces différents segments de mémoire, le deque a le désavantage d'avoir une plus grande empreinte mémoire.

Temps d'accès à un élément : O(1)
Temps d'insertion/retrait d'un élément (tête et queue) :  O(1)
Temps d'insertion/retrait d'un élément (intérieur du deque) :  O(n)


Conclusion


En conclusion, il est important de bien comprendre les différents conteneurs avant de faire son choix. Chaque conteneur a ses avantages et ses désavantages. Plusieurs points sont à prendre en considération : 

  • Est-ce que la taille du conteneur sera fixe ou variable?
  • Est-ce que l'ordre des éléments est important?
  • Quel sera le moyen d'accéder aux éléments? Par leur position? Par leur clé?
  • Quel sera le taux d'accès versus le taux d'insertion/suppression? Grande fréquence de modification?
  • Les insertions/suppressions se feront au début, à la fin ou ailleurs dans le conteneur?
En espérant que cet article vous aide à faire les bons choix de conteneur dans vos prochains projets. :)

dimanche 19 avril 2020

C++ : Passage d'arguments par valeur, par référence et par pointeur

Le langage C++ offre des performances incroyables à condition bien sûr de bien comprendre ses subtilités. La façon de passer des arguments lors de l'appel d'une fonction ou d'une méthode peut faire une grande différence. Il en va de même pour le retour des valeurs des méthodes. On peut soit passer les arguments par valeur, par référence ou par pointeur.


Passage par valeur


Lorsqu'on passe un argument par valeur, on se trouve à fournir une copie de l'élément à la fonction ou la méthode.
Exemple :
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

using namespace std;

void printLine(string line);

int main(int argc, char* argv[])
{
    string my_line = "Line original";
    cout << "main function:\t\t" << my_line << endl;
    printLine(my_line);
    cout << "main function:\t\t" << my_line << endl;
    return 0;
}

void printLine(string line)
{
    line = "Line updated";
    cout << "printLine function:\t" << line << endl;
}

Trace de l'exécution

jed@jed-Ubuntu:~/Programming/test$ g++ main.cpp && ./a.out
main function:          Line original
printLine function:     Line updated
main function:          Line original

On peut voir ici que la variable my_line est passée en argument à la fonction printLine mais malgré que la variable line est modifiée à l'intérieur de la fonction, cela n'a aucun effet sur la variable my_line, car à la sortie de la fonction la méthode l'affiche à l'écran et elle a sa valeur originale.

Ce mode de passage devrait être préféré pour les types fondamentaux du C++ tel qu'énuméré ici : https://www.learncpp.com/cpp-tutorial/73-passing-arguments-by-reference/

La plupart de ces types ont une empreinte mémoire plus petite qu'un pointeur (4 octets sur les systèmes x86 et 8 octets pour les systèmes x64) alors il est préférable de les passer par valeur.


Passage par référence (symbole &)

Le passage par référence n'utilise pas de ressource supplémentaire comparativement au passage par valeur qui doit créer une copie de la variable à chaque appel. Dans le cas d'une fonction récursive ou dans le cas où l'argument aurait une empreinte mémoire assez imposante, ça peut vite affecter la performance de l'exécution. Dans le but d'optimiser les ressources utilisées, il est dans la plupart des cas préférable d'utiliser le passage par référence.

Exemple :
#include <iostream>

using namespace std;

void printLine(string &line);

int main(int argc, char* argv[])
{
    string my_line = "Line original";
    cout << "main function:\t\t" << my_line << endl;
    printLine(my_line);
    cout << "main function:\t\t" << my_line << endl;
    return 0;
}

void printLine(string &line)
{
    line = "Line updated";
    cout << "printLine function:\t" << line << endl;
}

Trace de l'exécution

jed@jed-Ubuntu:~/Programming/test$ g++ main.cpp && ./a.out
main function:          Line original
printLine function:     Line updated
main function:          Line updated

On peut voir dans cet exemple que la variable my_line est passée en argument à la fonction printLine et que la modification qui est apportée à l'argument line à l'intérieur de la fonction a aussi eu pour effet de modifier la variable my_line. On le voit bien à la sortie de la fonction lorsqu'on affiche à l'écran le contenu de la variable my_line et qu'elle a la valeur "Line updated". L'argument line était donc une référence qui pointait vers l'espace mémoire de la variable my_line.



Passage par référence constante

Heureusement on peut obtenir le meilleur des deux mondes en passant la variable par référence constante. De cette façon, aucune ressource supplémentaire n'est utilisée, on ne pourra pas modifier l'argument et on s'assure de n'avoir aucun effet de bord.

Si on reprend l'exemple précédent voici la syntaxe pour le passage en référence constante:

void printLine(const string &line);

Si on roule le code ci-dessous, on peut voir que la modification de la valeur de l'argument line est interdite.

void printLine(const string &line)
{
    line = "Line updated";
    cout << "printLine function:\t" << line << endl;
}

Trace de l'exécution

jed@jed-Ubuntu:~/Programming/test$ g++ main.cpp && ./a.out
main.cpp: In function ‘void printLine(const string&)’:
main.cpp:18:12: error: passing ‘const string {aka const std::__cxx11::basic_string<char>}’ as ‘this’ argument discards qualifiers [-fpermissive]
     line = "Line updated";
            ^~~~~~~~~~~~~~

Le mode de passage par référence (constant ou non) devrait être utilisé pour tous les types personnalisés (classes et struct) qui auront toujours une valeur (non nulle).



Passage par pointeur

Il est toujours possible d'utiliser la bonne vieille méthode de passage par pointeur mais celle-ci est un peu plus complexe. Nous en reparlerons plus loin. Voyons pour l'instant comment utiliser notre exemple vu plus haut, mais en passage par pointeur.

Exemple :
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

using namespace std;

void printLine(string *line); 

int main(int argc, char * argv[]) 
{
    string my_line = "Line original";
    cout << "main function:\t\t" << my_line << endl;
    printLine(&my_line);
    cout << "main function:\t\t" << my_line << endl;
    return 0;
}

void printLine(string *line)
{
    *line = "Line updated";
    cout << "printLine function:\t" << *line << endl;
}

Trace de l'exécution

jed@jed-MS-7593:~/Programming/test$ g++ src/main.cpp && ./a.out
main function:          Line original
printLine function:     Line updated
main function:          Line updated

On obtient exactement le même résultat que le passage par référence. Aucune copie de valeur n'a été effectuée, en fait ce que nous avons passé en argument c'est une copie du pointeur de la variable my_line qui continent l'adresse mémoire d'où la valeur "Line original" est stockée.

Remarquer à la ligne 18 que l'on doit utiliser l'opérateur de déréférencement * pour assigner "Line updated" dans l'espace mémoire pointé par le pointeur. Même principe à la ligne 19 pour récupérer les données de l'adresse mémoire en question.

Voici ce qui survient si l'on n'utilise pas l'opérateur de déréférencement * à la ligne 19 :

jed@jed-MS-7593:~/Programming/test$ g++ src/main.cpp && ./a.out
main function:          Line original
printLine function:     0x7ffc1d2856d0
main function:          Line updated

C'est l'adresse mémoire contenue par le pointeur qui est affiché au lieu de la valeur de ce qui est contenu à cette adresse.



Passage par pointeur constant

Il y a deux façons de faire:

  • En utilisant la notation const string * line on s'assure que le contenu du pointeur ne pourra pas être changé.
  • En utilisant la notation string * const line on s'assure que l'adresse du pointeur ne pourra pas être changée.
1
2
3
4
5
void printLine(const string * line)
{
    *line = "Line updated"; //Erreur on ne peut pas changer la valeur contenue par le pointeur
    cout << "printLine function:\t" << *line << endl;
}

1
2
3
4
5
6
void printLine(string * const line)
{
    string lineUpdated = "Line updated";
    line = &lineUpdated; //Erreur on ne peut pas changer l'adresse du pointeur
    cout << "printLine function:\t" << *line << endl;
}

On peut aussi utiliser la combinaison des deux méthodes afin d'avoir la totale : const string * const line.

1
2
3
4
5
6
7
void printLine(const string * const line)
{
    string lineUpdated = "Line updated";
    line = &lineUpdated; //Erreur on ne peut pas changer l'adresse du pointeur
    *line = lineUpdated; //Erreur on ne peut pas changer la valeur contenue par le pointeur
    cout << "printLine function:\t" << *line << endl;
}

Le mode de passage par pointeur (constant ou non) devrait être utilisé pour tous les types personnalisés (classes et struct) lorsqu'ils peuvent être nul. Si par exemple on veut passer une instance d'une classe que nous allons appeler Employé mais qu'il est possible que l'instance soit nulle alors on doit utiliser un passage par pointeur. Ce mode peut aussi être très pratique lorsqu'on veut modifier le pointeur rȩcu pour le faire pointer sur un autre objet.



Conclusion

Tout au long du développement d'une application on peut avoir à travailler avec les 3 modes, il est donc important de connaître l'utilité de chacun des modes et quand on devrait les utiliser.

dimanche 23 avril 2017

Détection de memory leaks avec Microsoft Visual Studio 2015 (C++)

Cette semaine je cherchais un moyen de vérifier si le projet sur lequel je travail contient des "memory leaks".

Après une petite recherche, je suis tombé sur la page "CRT Debugging Techniques" du site MSDN (https://msdn.microsoft.com/en-us/library/zh712wwf.aspx).
La page contient une section qui se nomme "Finding Memory Leaks using the CRT Library".

Qu'est que la CRT Library? En français, la bibliothèque runtime C (CRT) est la partie de la bibliothèque C++ standard qui incorpore la bibliothèque ISO C99 standard. Cette bibliothèque contient entres autres plusieurs macros/fonctions permettant d'aider au débogage de code natif. Il suffit d'inclure dans votre code quelques macros et l'appel d'une fonction pour vérifier à la fin de l'exécution si votre code contient des memory leaks.

Prenons un code assez simple en exemple :
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string* chaineTresLongue = new string("blablabla...");
 
    string* cloneChaine = new string(*chaineTresLongue);
    delete cloneChaine;

    return 0;
}


Lorsqu'on exécute de code en mode debug l'application se termine normalement. Aucune fuite de mémoire à l'horizon.
Sortie de la fenêtre Output module Debug
Il y a pourtant une fuite de mémoire car nous n'avons jamais supprimer le pointeur de chaîne de caractères chaineTresLongue. Bon évidemment dans un petit projet comme celui là nous aurions pu détecter la fuite de mémoire rapidement à l’œil mais dans un gros projet qui contient des milliers de lignes de code, c'est beaucoup plus difficile.

Pour activer la détection il suffit d'inclure le fichier d'entête crtdbg.h et d'appeler la fonction _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF); au début de la fonction principale du programme.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>
#include <string>
#include <crtdbg.h>

using namespace std;

int main()
{
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
    string* chaineTresLongue = new string("blablabla...");
    string* cloneChaine = new string(*chaineTresLongue);

    delete cloneChaine;
    return 0;
}

On voit maintenant qu'un memory leak a bel et bien été détecté.
Sortie de la fenêtre Output module Debug
Petit détail par contre, il serait pratique de connaitre le numéro de la ligne d'où l'espace mémoire non libéré a été créé pour la première fois. Pour ce faire, il suffit d'ajouter quelques macros sur le fichier source principal.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <string>
#include <crtdbg.h>
#ifdef _DEBUG
#define DEBUG_NEW new(_NORMAL_BLOCK, __FILE__, __LINE__)
#define new DEBUG_NEW
#endif

using namespace std;

int main()
{
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
    string* chaineTresLongue = new string("blablabla...");
    string* cloneChaine = new string(*chaineTresLongue);

    delete cloneChaine;
    return 0;
}

Voilà! Un autre outil aider à créer du code natif sans fuite de mémoire.

Sortie de la fenêtre Output module Debug





mardi 3 janvier 2017

Nouveaux projets C++

Voilà un bon bout de temps que je n'ai pas bloggé mais le développement a quand même continuer durant tout ce temps.

J'ai développé un logiciel pour le département d'ingénierie de la mine Canadian Malartic qui permet de gérer la planification du forage et du sautage.




J'ai aussi créé MineComm, un logiciel de diagnostic et de performance pour les réseau mesh de Rajant. Lien vers MineComm



J'ai ensuite créé mon site web jedsharpsoftware.com.



Tout ces derniers projets étaient en C# mais depuis quelques mois je me suis relancé dans le langage de programmation que je préfère par dessus tout, le C++.

J'ai donc créé deux petites librairies, histoire de me remettre dans le bain.

La première s'agit d'une librairie de gestion de dates à la .NET en C++ et la deuxième est un client SMTP pour l'envoi de courriel.

Vous trouverez les 2 librairies sur mon GitHub. Elles sont encore en développement donc libre à vous de collaborer pour les faire évoluer. :)

https://github.com/jeremydumais

mardi 2 juillet 2013

TypeScript

Il y a quelques temps que j'avais entendu parler de ce nouveau langage qui se nomme TypeScript. TypeScript est un langage typé, contrairement au Javascript, qui à la compilation, génère du code Javascript. Il met à la disposition des développeurs web les concepts de la programmation orienté objet. À cette époque, qui n'est pas si lointaine soit dit en passant, je développais principalement en ASP.net et j'avais peu recours au Javascript donc je ne m'y était pas trop attardé.

Un peu plus tard, j'ai dû développer un module sur un Extranet. Le back-end étant un serveur PHP, et moi qui est un néophyte en ce qui concerne le PHP, j'ai donc décidé de coder le maximum du module en javascript et de laisser seulement les opérations BD et la sécurité au serveur PHP. Ce fût un projet assez complexe, j'ai donc dû créer d'énormes fichiers Javascript avec multitudes de variables, prototype, fonctions etc. Bref au final, le résultat fût beaucoup de code éparpillé et dur à maintenir.

TypeScript met donc revenu en tête. Ce langage est conçu pour développer des applications de grande envergure qui permet de regrouper le code sous forme de classes, namespaces, interfaces etc. Offrant les concepts de l'orienté objet, il supporte bien entendu l'héritage. La dernière version 0.9 offre même le support des Generic.

La courbe d'apprentissage

Une des ses forces à mon avis est que la courbe d'apprentissage peut-être vraiment être graduelle. En fait, on peut créer un fichier TypeScript et écrire du code 100% Javascript et ça fonctionne. On peut donc utiliser du code Javascript d'un projet existant et typer certaines variables ou recoder certaines classes. Il est possible de profiter des forces qu'offre TypeScript sans avoir à tout ré-écrire.

Les librairies incontournables

L'utilisation des librairies tel que JQuery, Knockout, LINQ etc. fonctionnent en TypeScript à condition de référencer les fichiers de définitions de la librairie. Ces fichiers décrivent les objets de la librairie, ce qui permet à TypeScript de valider que l'appel à ces objets est bien valide. Les fichiers de définitions des différentes librairies peuvent être trouvé sur Definitely Types ou via NuGet.

Conclusion

Évidemment tout ce qu'on peut faire avec TypeScript, on peut également le faire en javascript car comme je le disait plus haut, la compilation d'un fichier TypeScript (.ts) créer un fichier Javascript. Son but est d'offrir au développeur une syntaxe beaucoup plus simple et le langage typé permet de détecter des erreurs à la compilation au lieu de les découvrir au runtime. Je ne pourrai plus m'en passer en ce qui me concerne! ;)

http://www.typescriptlang.org/

dimanche 18 novembre 2012

NDateTime (DateTime du .NET porté pour JavaScript)

Voilà ma dernière création, une librairie JavaScript qui regroupe les fonctionnalités de base d'un objet DateTime en .NET

Plus besoin de se battre avec les dates JavaScript! :)

Pour tous les développeurs web.
Si vous avez des idées (Ajouts/Commentaires) n'hésitez pas!



http://ndatetime.codeplex.com

dimanche 19 février 2012

Comment ouvrir une nouvelle fenêtre en javascript à la suite d'un postback (ASP.NET)

Il peut paraître assez simple à première vue d'ouvrir une fenêtre en javascript à la suite d'un postback. On aurait tendance à ce dire qu'il suffit d'enregistrer un script avec la commande window.open. Le problème c'est que les navigateurs sont maintenant munis de "Pop-up blocker". Les navigateurs empêchent donc toutes ouvertures de fenêtre en javascript sauf si l'action provient d'un clic sur un lien ou un bouton.

Heureusement, il y a une façon de contourner ce problème. La solution est d'ouvrir la fenêtre sur l'évènement onclick côté client et de lui assigner un nom unique. Ensuite, lorsque vous atteignez l'évènement buttonclick côté serveur, il suffit d'utiliser la commande window.open avec le même nom spécifié côté client. Le navigateur va donc utiliser la même fenêtre ouverte précédemment côté client. De cette façon, on respecte les règles d'ouvertures de fenêtre en javascript des navigateurs.

Voici un exemple très simple, je vous l'accorde, mais qui démontre la solution : Voici le bouton du bouton dans notre fichier aspx
<asp:Button ID="ButtonOpenGoogle" Text="Cliquer pour ouvrir Google" /> OnClick="ButtonOpenGoogle_Click" /> OnClientClick="window.open('about:blank', 'windowPopupGoogle');" />
Voici le code de l'évènement click du bouton côté serveur
Dans l'exemple ci-dessus la fenêtre est identifiée de façon unique avec le nom 'windowPopupGoogle'.

Il peut arriver aussi qu'une fois côté serveur, on veule annuler l'ouverture de la fenêtre, il suffit donc de récupérer le handler de la fenêtre et d'effectuer une window.close telle que dans l'exemple ci-dessous.
 protected void ButtonOpenGoogle_Click(object sender, EventArgs e)
{
ScriptManager.RegisterStartupScript(this, typeof(Page), "windowPopupGoogleScript", "var win = window.open('about:blank','windowPopupGoogle');win.close();", true);
}
Voilà!