Comment éviter facilement les déréférencements de pointeur nul et les fuites de mémoire en C++

Vous pouvez réagir à cet article en vous connectant sur le forum. 1 commentaire Donner une note à l'article (5)

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

En C++, la mauvaise utilisation des pointeurs peut conduire à des bogues, dont les suivants :

  • déréférencer un pointeur nul ;
  • oublier de désallouer une donnée allouée (fuite de mémoire) ;
  • déréférencer un pointeur vers une zone mémoire qui n'est plus utilisée.

Parmi ces trois bogues, l'article présent se focalise sur les deux premiers.

Pour éviter ces deux bogues, il existe plusieurs solutions.

Voici des solutions chronophages et peu efficaces :

  • alourdir la documentation pour préciser quels pointeurs peuvent être nuls et sur lesquels il faudra appeler delete ou delete[] quand on n'aura plus besoin de la donnée pointée. La documentation est alors plus longue à lire et à écrire. En outre, elle risque de ne pas être lue ;
  • alourdir le code avec des contrôles de non-nullité des pointeurs, par exemple assert(ptr != nullptr); ou if(ptr == nullptr) throw std::logic_error("ptr is null.");. Le code est alors plus long à lire et à écrire. En plus, quand les contrôles sont actifs, cela diminue les performances ;
  • augmenter le nombre de tests pour détecter plus de bogues à l'exécution. Lors des tests, utiliser des outils pour détecter à l'exécution les déréférencements de pointeur nul et les fuites de mémoire. Quand on doit lutter contre du code historique, c'est bien, mais le mieux aurait été d'éviter les bogues en amont ;
  • coder plus lentement en vérifiant, pour chaque pointeur, s'il peut être nul et s'il faudra appeler delete ou delete[] quelque part. Quand on doit lutter contre du code historique, c'est bien, mais le mieux aurait été d'avoir accès instantanément à ces informations, sans avoir besoin de vérifier.

L'article présent n'abordera pas davantage les solutions ci-dessus.

Voici d'autres solutions, plus efficaces, qui seront abordées dans cet article :

  • à la place d'un pointeur, utiliser un autre type en fonction du besoin, par exemple une référence ou un std::unique_ptr ;
  • allonger le nom des fonctions et des variables. Par exemple, soit une fonction Car const* Person::getCar() const noexcept. Si elle peut retourner un pointeur nul, alors on peut la renommer en getCarOrNull ;
  • fixer des conventions. Par exemple : « Sauf mention explicite du contraire, tout pointeur vers un type caractère (ex. : char const*) est non nul et représente une chaîne de caractères terminée par '\0'. »

II. Comment éviter les déréférencements de pointeur nul dans les cas simples

II-A. Problème

Quand un pointeur n'est jamais nul et que cela ne se voit pas au premier coup d'œil, cela fait perdre du temps aux développeurs qui maintiennent le code s'ils se rappellent qu'il faut s'assurer que le pointeur est bien non nul avant de le déréférencer. En outre, si la majorité des pointeurs ne sont jamais nuls, alors les développeurs s'y habituent et risquent de vérifier de moins en moins souvent quels pointeurs sont bien toujours non nuls. Alors, quand un pointeur peut être nul et que cela ne se voit pas au premier coup d'œil, il y a un grand risque que des développeurs déréférencent ce pointeur sans vérifier s'il est bien non nul.

Donc, pour tout pointeur, il faudrait qu'on puisse savoir au premier coup d'œil s'il peut être nul ou bien s'il n'est jamais nul.

Pour commencer, nous n'aborderons pas encore le cas de la gestion de la mémoire et celui des tableaux. Ces cas seront abordés plus loin dans l'article.

Appuyons-nous sur le bout de code suivant :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
//! \invariant m_car != nullptr
//! \remark    CarDriver does not handle the memory of the object
//!            pointed by m_car.
class CarDriver final {
public:
    Car const* getCar() const noexcept;
    // ...
private:
    Car const* const m_car;
    // ...
};

//! \invariant m_cars does not have any null pointer.
//! \remark    Garage does not handle the memory of the objects
//!            pointed by the pointers of m_cars.
class Garage final {
public:
    void addCar(Car const* car);
    // ...
private:
    std::vector<Car const*> m_cars;
    // ...
};

//! \invariant m_map does not have any null pointer.
//! \remark    CarToDriverMap does not handle the memory of the
//!            objects pointed by the pointers of m_map.
class CarToDriverMap final {
public:
    // ...
private:
    std::map<Car const*, CarDriver const*> m_map;
};

//! \remark m_car can be null.
//! \remark Person does not handle the memory of the object
//!         pointed by m_car.
class Person final {
public:
    Car const* getCar() const noexcept;
    // ...
private:
    Car const* const m_car;
    // ...
};

//! \remark m_parent can be null.
//! \remark TreeNode does not handle the memory of its parent.
class TreeNode final {
public:
    //! \remark node can be null.
    void setParent(TreeNode* node) noexcept;
    // ...
private:
    TreeNode* m_parent;
    // ...
};

Comment améliorer le code ci-dessus ? Il y a plusieurs solutions.

II-B. Solutions

Solution par le typage : quand des pointeurs ne sont jamais nuls, on les remplace par des références ou, quand c'est impossible, par des std::reference_wrapper :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
class CarDriver final {
public:
    Car const& getCar() const noexcept;
    // ...
private:
    Car const& m_car;
    // ...
};

class Garage final {
public:
    void addCar(Car const& car);
    // ...
private:
    std::vector<std::reference_wrapper<Car const>> m_cars;
    // ...
};

struct AdressComparator {
    template<class T>
    constexpr bool operator()(T lhs, T rhs) const {
        return &lhs.get() < &rhs.get();
    }
};

class CarToDriverMap final {
public:
    // ...
private:
    std::map<
        std::reference_wrapper<Car const>,
        std::reference_wrapper<CarDriver const>,
        AdressComparator
    > m_map;
};

Solution par le nommage explicite des pointeurs jamais nuls : quand des pointeurs ne sont jamais nuls, on renomme les variables et les fonctions correspondantes :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
class CarDriver final {
public:
    Car const* getNotNullCar() const noexcept;
    // ...
private:
    Car const* const m_notNullCar;
    // ...
};

class Garage final {
public:
    void addNotNullCar(Car const* notNullCar);
    // ...
private:
    std::vector<Car const*> m_notNullCars;
    // ...
};

class CarToDriverMap final {
public:
    // ...
private:
    std::map<Car const*, CarDriver const*> m_mapFromNotNullToNotNull;
};

Solution par le nommage explicite des pointeurs qui peuvent être nuls :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
class Person final {
public:
    Car const* getCarOrNull() const noexcept;
    // ...
private:
    Car const* const m_carOrNull;
    // ...
};

class TreeNode final {
public:
    void setParentOrNull(TreeNode* nodeOrNull) noexcept;
    // ...
private:
    TreeNode* m_parentOrNull;
    // ...
};

On peut aussi combiner plusieurs solutions. Exemple :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
class CarDriver final {
public:
    Car const& getCar() const noexcept;
    // ...
private:
    Car const& m_car;
    // ...
};

class Garage final {
public:
    void addCar(Car const& car);
    // ...
private:
    std::vector<std::reference_wrapper<Car const>> m_cars;
    // ...
};

class CarToDriverMap final {
public:
    // ...
private:
    std::map<Car const*, CarDriver const*> m_mapFromNotNullToNotNull;
};

class Person final {
public:
    Car const* getCarOrNull() const noexcept;
    // ...
private:
    Car const* const m_carOrNull;
    // ...
};

class TreeNode final {
public:
    void setParentOrNull(TreeNode* nodeOrNull) noexcept;
    // ...
private:
    TreeNode* m_parentOrNull;
    // ...
};

Pour raccourcir le code, en complément des solutions ci-dessus, on peut aussi poser des conventions selon lesquelles :

  • dans certaines circonstances, les pointeurs ne sont jamais nuls, sauf mention explicite du contraire ;
  • dans certaines circonstances, les pointeurs peuvent être nuls, sauf mention explicite du contraire.

Par exemple, on pourrait poser comme convention que, dans un conteneur de pointeurs, sauf mention explicite du contraire, les pointeurs sont toujours non nuls.
Alors, dans la classe CarToDriverMap, la variable membre m_map n'aurait besoin ni d'avoir un type à rallonge avec des std::reference_wrapper, ni d'avoir un nom à rallonge comme m_mapFromNotNullToNotNull.

II-C. Comparaison des solutions

Un avantage de la solution par le typage est qu'elle est standard. En effet, rappelons qu'une référence ne peut pas être nulle. Pour s'en convaincre, voici une citation de N4659 (C++17 final draft) (1) :
a null reference cannot exist in a well-defined program, because the only way to create such a reference would be to bind it to the “object” obtained by indirection through a null pointer, which causes undefined behavior.

La solution par le typage peut faciliter les optimisations du compilateur et le travail des outils d'analyse statique de code, qui peuvent partir de l'hypothèse que toute référence est non nulle.

En outre, les références ont une écriture concise.

Par contre, std::reference_wrapper est plus lourd à utiliser qu'une référence, surtout si on veut l'utiliser comme une clef d'un tableau associatif comme si c'était un pointeur.

De plus, quand du code est généré automatiquement par un outil, la solution par le typage n'est pas toujours disponible. En effet, certains outils génèrent du code avec des pointeurs jamais nuls.

La solution par le nommage explicite des pointeurs jamais nuls est plus lourde que d'utiliser des références, mais peut être moins lourde que d'utiliser des std::reference_wrapper.

Poser des conventions pour indiquer quels pointeurs ne sont jamais nuls ou peuvent être nuls dans quelles circonstances peut permettre d'écrire un code plus concis que quand on utilise la solution par le typage ou les solutions par le nommage explicite. Mais alors il faut s'assurer que les développeurs qui arrivent dans le projet connaissent les conventions en question avant de travailler sur le code du projet.

Poser comme convention que tout pointeur peut être nul sauf mention explicite du contraire permet d'éviter d'ajouter des « OrNull » dans les noms de variables et de fonctions. Cependant, je déconseille d'omettre les « OrNull » dans le nouveau code si le code historique est rempli de pointeurs dont la majorité ne sont jamais nuls, mais sans qu'on ne puisse identifier au premier coup d'œil qu'ils ne sont bien jamais nuls.

Remarque : chez Google, les paramètres out et inout doivent être passés par pointeurs.
Extrait du Google C++ Style Guide (2) : Input parameters are usually values or const references, while output and input/output parameters will be pointers to non-const.
Dans un tel contexte, poser comme convention que tout pointeur peut être nul sauf mention explicite du contraire ne serait pas pertinent non plus.

III. Comment éviter les fuites de mémoire

D'abord, nous nous pencherons sur les problèmes de la gestion de la mémoire sans nous préoccuper des risques de déréférencements de pointeur nul.

Ensuite, nous traiterons ces deux problèmes en même temps.

Le cas des tableaux sera abordé plus loin dans l'article.

III-A. Problème

Voici un code qui contient plusieurs erreurs de programmation, dont des fuites de mémoire :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
// CODE WITH BUGS!

//! \invariant m_children does not have any null pointer.
//! \remark    TreeNode handles the memory of its children.
class TreeNode final {
public:
    ~TreeNode()
    {
        for(TreeNode* node : m_children)
            delete node;
    }

    //! \remark *this handles the memory of the objet pointed
    //!         by node.
    void appendChild(TreeNode* node) {
        m_children.push_back(node);
    }

    //! \remark After the call, *this does not handle the
    //!         memory of the returned child anymore.
    TreeNode* releaseChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        TreeNode* result = m_children[childNodeIndex];
        m_children.erase(m_children.begin() + childNodeIndex);
        return result;
    }

    size_t getChildrenCount() const noexcept {
        return m_children.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

private:
    std::string            m_data;
    std::vector<TreeNode*> m_children;
};

namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        TreeNode* const childNode =
            dataTreeNode.releaseChild(childIndex);
        UpdateForArchive(*childNode);
        archiveTreeNode.appendChild(childNode);
    }
}

Voici une correction qui utilise des try-catch :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
//! \invariant m_children does not have any null pointer.
//! \remark    TreeNode handles the memory of its children.
class TreeNode final {
public:
    TreeNode() = default;

    ~TreeNode()
    {
        for(TreeNode* node : m_children)
            delete node;
    }

    // The default copy constructor and copy assignment make
    // swallow copy instead of deep copy.
    // So they must be either redefined or disabled.
    TreeNode(TreeNode const&)            = delete;
    TreeNode& operator=(TreeNode const&) = delete;

    TreeNode(TreeNode&& other) = default;
        // Remark: after the call, other.m_children.empty().

    TreeNode& operator=(TreeNode&& other) noexcept
    {
        assert(this != &other);
        for(TreeNode* node : m_children)
            delete node;
        m_data     = std::move(other.m_data);
        m_children = std::move(other.m_children);
        other.m_children.clear();
        return *this;
    }

    //! \remark *this handles the memory of the objet pointed
    //!         by node.
    void appendChild(TreeNode* node) {
        try {
            m_children.push_back(node);
        } catch(...) {
            delete node;
            throw;
        }
    }

    //! \remark After the call, *this does not handle the
    //!         memory of the returned child anymore.
    TreeNode* releaseChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        TreeNode* result = m_children[childNodeIndex];
        m_children.erase(m_children.begin() + childNodeIndex);
        return result;
    }

    size_t getChildrenCount() const noexcept {
        return m_children.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

private:
    std::string            m_data;
    std::vector<TreeNode*> m_children;
};

namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        TreeNode* const childNode =
            dataTreeNode.releaseChild(childIndex);
        try {
            UpdateForArchive(*childNode);
        } catch(...) {
            delete childNode;
            throw;
        }
        archiveTreeNode.appendChild(childNode);
    }
}

Dans le code de départ qui était faux, les erreurs étaient facilitées par les raisons suivantes :

  • quand les développeurs analysent du code, ils oublient parfois les cas où le code lance des exceptions ;
  • quand les développeurs créent des classes, ils oublient parfois que le compilateur peut générer automatiquement un constructeur de copie et une affectation de copie, même quand le destructeur est déclaré explicitement. (Par contre, la déclaration explicite du destructeur désactive bien la génération automatique du constructeur de mouvement et de l'affectation de mouvement.) ;
  • du côté de l'utilisateur de la classe TreeNode, si on ne lit pas la documentation ou le code source de la classe TreeNode, alors la gestion de la mémoire n'est pas claire : on ne devine pas forcément que TreeNode est responsable de la mémoire de ses enfants.

Ensuite, quand on a corrigé le code, le code est devenu lourd à cause des try-catch.

Comment écrire facilement du premier coup un code sans fuites de mémoire et sans risquer d'appeler plusieurs fois delete sur la même adresse ?

III-B. Solutions

Une solution est d'utiliser std::unique_ptr. Voici le code correspondant :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
class TreeNode final {
public:
    void appendChild(std::unique_ptr<TreeNode> node) {
        m_children.push_back(std::move(node));
    }

    std::unique_ptr<TreeNode>
    releaseChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        std::unique_ptr<TreeNode> result =
            std::move(m_children[childNodeIndex]);
        m_children.erase(m_children.begin() + childNodeIndex);
        return result;
    }

    size_t getChildrenCount() const noexcept {
        return m_children.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

private:
    std::string                            m_data;
    std::vector<std::unique_ptr<TreeNode>> m_children;
};

namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        std::unique_ptr<TreeNode> childNode =
            dataTreeNode.releaseChild(childIndex);
        UpdateForArchive(*childNode);
        archiveTreeNode.appendChild(std::move(childNode));
    }
}

Les problèmes liés à la mémoire sont corrigés.
Et en plus, le code est plus court que dans la version qui utilise les try-catch.

std::unique_ptr permet d'écrire du code juste de manière plus concise et assure de libérer les ressources même quand le développeur oublie de penser au cas où le code lance une exception, mais std::unique_ptr ne doit pas servir à arrêter de réfléchir au cas où une exception est lancée. Par exemple, dans l'implémentation actuelle de TransfertChildNodeFromDataToArchive, si UpdateForArchive lance une exception, alors l'objet childNode est détruit et dataTreeNode ne retourne pas dans son état initial. Ce n'est pas nécessairement un bogue, car dataTreeNode pourrait être une copie d'un arbre sur laquelle on effectue une succession d'opérations avant de remplacer l'arbre d'origine, si aucune erreur n'a été levée. Mais il faut rester conscient que l'exception safety de TransfertChildNodeFromDataToArchive n'est pas une garantie forte, seulement une garantie basique.

À présent, il faut aussi résoudre le problème suivant : comment distinguer les std::unique_ptr qui ne sont jamais nuls de ceux qui peuvent être nuls ?

Une première solution est de poser comme convention que, sauf mention explicite du contraire, un std::unique_ptr ne peut être nul, sauf si on le voit au premier coup d'œil, par exemple si on vient de faire un std::move dessus ou si on vient d'appeler une fonction comme std::unique_ptr::release.

Les corollaires sont alors :

  • quand une fonction retourne par valeur un std::unique_ptr, ce dernier est forcément non nul, sauf si la fonction a un nom qui indique explicitement qu'il peut être nul, par exemple :
    std::unique_ptr<MailSender> MailSenderFactory::constructMailSenderOrNull(std::string const& name) const
  • quand une fonction prend en paramètre un std::unique_ptr, ce dernier doit forcément être non nul, sauf si la fonction a un nom qui indique explicitement qu'il peut être nul ;
  • une fonction ne doit pas prendre en paramètre un std::unique_ptr par référence non constante puis le mettre à nul. À la place, il faut faire un passage par valeur. En effet, considérons la fonction TreeNode::appendChild. Si le paramètre était de type std::unique_ptr<TreeNode>& au lieu de std::unique_ptr<TreeNode> alors, dans la définition de UserCode::TransfertChildNodeFromDataToArchive, on aurait eu une instruction archiveTreeNode.appendChild(childNode); sans std::move. Alors, la présence d'un transfert de ressource n'aurait pas été explicite.

Une autre solution est de préciser explicitement quand les std::unique_ptr ne sont jamais nuls :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
class TreeNode final {
public:
    void appendNotNullChild(std::unique_ptr<TreeNode> notNullNode) {
        m_notNullChildren.push_back(std::move(notNullNode));
    }

    std::unique_ptr<TreeNode>
    releaseNotNullChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        std::unique_ptr<TreeNode> notNullResult =
            std::move(m_notNullChildren[childNodeIndex]);
        m_notNullChildren.erase(
            m_notNullChildren.begin() + childNodeIndex
        );
        return notNullResult;
    }

    size_t getChildrenCount() const noexcept {
        return m_notNullChildren.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

private:
    std::string                            m_data;
    std::vector<std::unique_ptr<TreeNode>> m_notNullChildren;
};

namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        std::unique_ptr<TreeNode> childNode =
            dataTreeNode.releaseNotNullChild(childIndex);
        UpdateForArchive(*childNode);
        archiveTreeNode.appendNotNullChild(std::move(childNode));
    }
}

Plus haut dans l'article, pour distinguer les pointeurs qui ne sont jamais nuls, l'une des solutions était de les remplacer par des références ou des std::reference_wrapper.

Dans le cas des std::unique_ptr, a-t-on une solution similaire ?

Revenons sur le code de UserCode::TransfertChildNodeFromDataToArchive :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        std::unique_ptr<TreeNode> childNode =
            dataTreeNode.releaseChild(childIndex);
        UpdateForArchive(*childNode);
        archiveTreeNode.appendChild(std::move(childNode));
    }
}

Dans ce code, lors de l'appel de UserCode::UpdateForArchive, on aimerait avoir la garantie que childNode est non nul. On pourrait alors envisager de changer le type de childNode en un autre type similaire à std::unique_ptr<TreeNode>, mais jamais nul. Appelons-le UniqueRef<TreeNode>.

Du côté de TreeNode::releaseChild, on pourrait changer le type de retour en UniqueRef<TreeNode>.

Mais, du côté de TreeNode::appendChild, on veut pouvoir transférer la ressource. Or, dans le code ci-dessus, après l'appel de TreeNode::appendChild, childNode devient nul.

Une solution serait de concevoir UniqueRef de telle sorte qu'il n'est jamais nul, sauf dans un moved-from state. Alors, après avoir fait un std::move sur un objet de type UniqueRef<T>, il faudra faire attention à ne plus utiliser cet objet.

Voici le code correspondant :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
//! \warning In a moved-from state, calling get() has an
//!          undefined behaviour.
template<class T>
class UniqueRef final {
public:
    explicit UniqueRef(std::unique_ptr<T> notNullParam) noexcept :
        m_notNull{std::move(notNullParam)}
    {
        assert(bool(m_notNull));
    }
    T const& get() const noexcept { return *m_notNull; }
    T&       get()       noexcept { return *m_notNull; }
private:
    std::unique_ptr<T> m_notNull;
};

class TreeNode final {
public:
    void appendChild(UniqueRef<TreeNode> node) {
        m_children.push_back(std::move(node));
    }

    UniqueRef<TreeNode>
    releaseChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        UniqueRef<TreeNode> result =
            std::move(m_children[childNodeIndex]);
        m_children.erase(m_children.begin() + childNodeIndex);
        return result;
    }

    size_t getChildrenCount() const noexcept {
        return m_children.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

private:
    std::string                      m_data;
    std::vector<UniqueRef<TreeNode>> m_children;
};

namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        UniqueRef<TreeNode> childNode =
            dataTreeNode.releaseChild(childIndex);
        UpdateForArchive(childNode.get());
        archiveTreeNode.appendChild(std::move(childNode));
    }
}

Une autre solution serait de créer un modèle de classe SharedRef qui serait similaire à std::shared_ptr, mais ne serait jamais nul.

Voici le code correspondant :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
template<class T>
class SharedRef final {
public:
    explicit SharedRef(std::shared_ptr<T> notNullParam) noexcept :
        m_notNull{std::move(notNullParam)}
    {
        assert(bool(m_notNull));
    }
    // The copy constructor and the copy assignment are
    // explicitly declared while the move constructor and the
    // move assignment are not, so that the copy operation
    // is chosen even when the SharedRef source is a rvalue.
    SharedRef(SharedRef const&)            = default;
    SharedRef& operator=(SharedRef const&) = default;
    T const& get() const noexcept { return *m_notNull; }
    T&       get()       noexcept { return *m_notNull; }
private:
    std::shared_ptr<T> m_notNull;
};

class TreeNode final {
public:
    void appendChildAndOwn(SharedRef<TreeNode> node) {
        m_children.push_back(node);
    }

    SharedRef<TreeNode>
    releaseChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        SharedRef<TreeNode> result = m_children[childNodeIndex];
        m_children.erase(m_children.begin() + childNodeIndex);
        return result;
    }

    size_t getChildrenCount() const noexcept {
        return m_children.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

private:
    std::string                      m_data;
    std::vector<SharedRef<TreeNode>> m_children;
};

namespace UserCode
{
    void UpdateForArchive(TreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        TreeNode& dataTreeNode,
        TreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(childIndex < dataTreeNode.getChildrenCount());
        SharedRef<TreeNode> childNode =
            dataTreeNode.releaseChild(childIndex);
        UpdateForArchive(childNode.get());
        archiveTreeNode.appendChildAndOwn(childNode);
    }
}

III-C. Comparaison des solutions

  • Poser comme convention que, sauf mention explicite du contraire, un std::unique_ptr ne peut être nul est une solution qui permet d'écrire du code concis. Mais alors il faut s'assurer que les développeurs qui travaillent sur le code la partagent. Autrement, le jour où quelqu'un écrira du code avec des std::unique_ptr qui peuvent être nuls sans que ce soit explicite, ce sera une source de bogues.
  • Préciser explicitement quand les std::unique_ptr ne sont jamais nuls est moins dangereux, mais alourdit le code.
  • UniqueRef évite les problèmes des deux solutions précédentes, mais ce n'est pas un type standard. En outre, comme std::unique_ptr, il peut devenir nul dans un moved-from state.
  • SharedRef garantit qu'il ne peut être nul, mais ne gère pas la mémoire de la même manière. En effet, tandis qu'un objet de type std::unique_ptr ou UniqueRef est l'unique responsable de la mémoire de l'objet sous-jacent, plusieurs objets de type SharedRef peuvent partager la même ressource.

III-D. Cas où la responsabilité de libérer la mémoire dépend d'un booléen

Après avoir lu tout ceci, un lecteur pourrait se demander : « Mais que faire quand la responsabilité de libérer la mémoire dépend d'un booléen ? » Exemple :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
// DON'T DO THAT!!!!!

//! \invariant m_children does not have any null pointer.
//! \remark    BadTreeNode may handle the memory of its children.
class BadTreeNode final {
public:
    explicit BadTreeNode(bool handleChildrenMemory) noexcept :
        m_data{},
        m_children{},
        m_handleChildrenMemory{handleChildrenMemory}
    {}

    ~BadTreeNode()
    {
        if(m_handleChildrenMemory)
            for(BadTreeNode* node : m_children)
                delete node;
    }

    BadTreeNode(BadTreeNode const&)            = delete;
    BadTreeNode& operator=(BadTreeNode const&) = delete;

    BadTreeNode(BadTreeNode&& other) = default;
        // Remark: after the call, other.m_children.empty().

    BadTreeNode& operator=(BadTreeNode&& other) noexcept
    {
        assert(this != &other);
        if(m_handleChildrenMemory)
            for(BadTreeNode* node : m_children)
                delete node;
        m_data     = std::move(other.m_data);
        m_children = std::move(other.m_children);
        other.m_children.clear();
        m_handleChildrenMemory = other.m_handleChildrenMemory;
        return *this;
    }

    void appendChild(BadTreeNode* node) {
        try {
            m_children.push_back(node);
        } catch(...) {
            if(m_handleChildrenMemory)
                delete node;
            throw;
        }
    }

    BadTreeNode* releaseChild(size_t childNodeIndex) noexcept {
        assert(childNodeIndex < getChildrenCount());
        BadTreeNode* result = m_children[childNodeIndex];
        m_children.erase(m_children.begin() + childNodeIndex);
        return result;
    }

    size_t getChildrenCount() const noexcept {
        return m_children.size();
    }

    std::string getData() const {
        return m_data;
    }

    void setData(std::string const& data) {
        m_data = data;
    }

    bool doesHandleChildrenMemory() const noexcept {
        return m_handleChildrenMemory;
    }

private:
    std::string               m_data;
    std::vector<BadTreeNode*> m_children;
    bool                      m_handleChildrenMemory;
};

namespace UserCode
{
    void UpdateForArchive(BadTreeNode& node);

    void TransfertChildNodeFromDataToArchive(
        BadTreeNode& dataTreeNode,
        BadTreeNode& archiveTreeNode,
        size_t childIndex)
    {
        assert(dataTreeNode.doesHandleChildrenMemory() ==
            archiveTreeNode.doesHandleChildrenMemory());
        assert(childIndex < dataTreeNode.getChildrenCount());
        BadTreeNode* const childNode =
            dataTreeNode.releaseChild(childIndex);
        try {
            UpdateForArchive(*childNode);
        } catch(...) {
            if(dataTreeNode.doesHandleChildrenMemory())
                delete childNode;
            throw;
        }
        archiveTreeNode.appendChild(childNode);
    }
}

Si vous êtes l'auteur de la classe dont la responsabilité de la gestion de la mémoire dépend d'une variable booléenne membre de la classe, mon conseil est : Changez la conception !

Dans le code ci-dessus, selon que TreeNode gère la mémoire de ses enfants ou non, l'utilisation des fonctions TreeNode::releaseChild et TreeNode::appendChild est différente.

Donc, si on veut avoir à la fois des arbres dont les nœuds gèrent la mémoire de leurs enfants et des arbres dont les nœuds ne gèrent pas la mémoire de leurs enfants, alors ces nœuds doivent avoir des types différents.

Sinon, cela facilitera les erreurs de programmation des utilisateurs de la classe !

Si vous être l'utilisateur d'une classe dont la responsabilité de la gestion de la mémoire dépend d'une variable membre booléenne de la classe et que vous ne pouvez pas changer le code de cette classe, alors bon courage. Il faudra être prudent lors de l'utilisation de cette classe.

IV. Tableaux

IV-A. Éviter les fuites de mémoire

Concentrons-nous sur les tableaux alloués dynamiquement.

Si on utilise new[] et delete[] sans prudence, on risque de se tromper.

Vous rappelez-vous de la première version de TreeNode qui avait des fuites de mémoire quand des exceptions étaient lancées et un double delete quand une opération de copie était appelée ?

Voici un code qui souffre des mêmes problèmes :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
// CODE WITH BUGS!

class MultilineText final {
public:
    MultilineText() :
        m_lines{nullptr}, m_lineCount{0}
    {
    }

    ~MultilineText()
    {
        for(size_t k = 0; k < m_lineCount; ++k)
            delete[] m_lines[k];
        delete[] m_lines;
    }

    void appendNotNullLine(char const* notNullNewLine) {
        size_t const newLineSize = strlen(notNullNewLine) + 1;
        for(size_t k = 0; k < newLineSize; ++k)
            assert(notNullNewLine[k] != '\n');
        char** linesAfter = new char*[m_lineCount + 1];
        for(size_t k = 0; k < m_lineCount; ++k)
            linesAfter[k] = m_lines[k];
        char* newLineCopy = new char[newLineSize];
        for(size_t k = 0; k < newLineSize; ++k)
            newLineCopy[k] = notNullNewLine[k];
        linesAfter[m_lineCount] = newLineCopy;
        ++m_lineCount;
        delete[] m_lines;
        m_lines = linesAfter;
    }

    char const* getNotNullLine(size_t lineIndex) const noexcept {
        assert(lineIndex < getLineCount());
        return m_lines[lineIndex];
    }

    size_t getLineCount() const noexcept {
        return m_lineCount;
    }

private:
    char** m_lines;
    size_t m_lineCount;
};

À la place de new[] et delete[], il vaut mieux utiliser une classe dont le destructeur libère automatiquement la mémoire. Par exemple, voici une correction avec std::vector<std::string> :

 
CacherSélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
class MultilineText final {
public:
    void appendLine(std::string newLine) {
        assert(newLine.find('\n') == std::string::npos);
        m_lines.push_back(std::move(newLine));
    }

    std::string_view getLine(size_t lineIndex) const noexcept {
        assert(lineIndex < getLineCount());
        return static_cast<std::string_view>(m_lines[lineIndex]);
    }

    size_t getLineCount() const noexcept {
        return m_lines.size();
    }

private:
    std::vector<std::string> m_lines;
};

En plus, le code devient plus simple.

IV-B. Éviter les déréférencements de pointeur nul

Un tableau dont les éléments sont contigus en mémoire peut être représenté de différentes manières, dont les suivantes :

  • un conteneur, par exemple std::array<T> ou std::vector<T> ;
  • un couple (pointeur vers le premier élément, nombre d'éléments) ;
  • un couple (pointeur vers le premier élément, pointeur de fin) ;
  • un pointeur vers le premier élément d'un tableau dont la fin est délimitée par une valeur sentinelle, par exemple '\0' dans le cas des chaînes de caractères.

Le dernier cas peut poser des soucis si des développeurs décident que le pointeur vers le premier élément peut être nul. En effet, on a alors des risques de déréférencements de pointeur nul décrits vers le début de l'article. Il y a alors plusieurs solutions.

Une première solution consiste à changer la représentation du tableau. Par exemple, dans le cas d'une chaîne de caractères, à la place de char const*, on peut utiliser std::string ou std::string_view.

Une deuxième solution consiste à poser comme convention que, quand un pointeur représente l'adresse du premier élément d'un tableau, ce pointeur ne peut jamais être nul, sauf mention explicite du contraire.
De toute façon, on a rarement besoin de distinguer un tableau absent d'un tableau vide.

Une troisième solution consiste à nommer explicitement les pointeurs jamais nuls, même ceux qui représentent l'adresse du premier élément d'un tableau.

V. Est-ce que suivre ces conseils est suffisant ?

Si tout le code d'un programme suit les conseils évoqués dans l'article présent, a-t-on la garantie que ce programme n'aura ni fuite de mémoire ni déréférencement de pointeur nul ?

Concernant la gestion de la mémoire dynamique, si, pour toute donnée allouée dans la mémoire dynamique, il existe un objet dont la destruction libère automatiquement la mémoire de cette donnée, alors les seuls cas où il peut y avoir une fuite de mémoire sont ceux où une donnée qui aurait dû avoir une durée de vie limitée a été associée à un objet qui ne sera détruit qu'à la fermeture du programme.

De tels cas devraient être assez rares.

Concernant les déréférencements de pointeur nul, si les pointeurs qui peuvent être nuls sont facilement identifiables et si les développeurs qui travaillent sur le code ont acquis le bon réflexe de tester systématiquement la nullité d'un tel pointeur quand ils ont besoin de le déréférencer, alors les déréférencements de pointeur nul devraient se limiter à quelques erreurs d'étourderie comme celle-ci :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
std::string Meeting::asEnglishPrintableString() const
{
    std::string result = m_title;
    if(m_presidentOrNull != nullptr || m_roomOrNull != nullptr)
        result += '\n';
    if(m_presidentOrNull != nullptr)
        result += "\nPresided by: " + m_presidentOrNull->asString();
    if(m_roomOrNull != nullptr)
        result += "\nRoom: " + m_presidentOrNull->asString();
                               // oops, wrong pointer
    return result;
}

Dans le code fictif ci-dessus, le développeur a bien pensé à vérifier que m_roomOrNull != nullptr. Mais, par étourderie, il a écrit m_presidentOrNull->asString() au lieu de m_roomOrNull->asString().

Idéalement, il aurait fallu que ce genre de problème soit détecté directement à la compilation. Il existe bien un moyen de le faire, mais la syntaxe est lourde.

La solution en question est que, quand on sait qu'un pointeur, nu ou intelligent, peut être nul, on s'interdit de le déréférencer directement via les opérateurs * et ->. À la place, on utilise le modèle de fonction suivant :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
//! For example, let data be a naked pointer, a smart pointer
//! or a std::optional.
//! - If data is neither null nor empty, then whenDataHasValue
//!   will be called with *data.
//! - If data is null or empty, then whenDataIsEmpty will be
//!   called.
template<class T, class Callable, class FunctionObject>
constexpr decltype(auto) visitOptional(
    T&&              data,
    Callable&&       whenDataHasValue,
    FunctionObject&& whenDataIsEmpty)
{
    bool const dataHasValue = static_cast<bool>(data);
    return
        dataHasValue
        ?
        std::invoke(
            std::forward<Callable>(whenDataHasValue),
            *std::forward<T>(data))
        :
        std::forward<FunctionObject>(whenDataIsEmpty)();
}

Voici comment on l'aurait utilisé dans le code précédent :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
std::string Meeting::asEnglishPrintableString() const
{
    std::string result = m_title;
    if(m_presidentOrNull != nullptr || m_roomOrNull != nullptr)
        result += '\n';
    visitOptional(
        m_presidentOrNull,
        [&](Person const& president){
            result += "\nPresided by: " + president.asString();
        },
        [](){}
    );
    visitOptional(
        m_roomOrNull,
        [&](Room const& room){
            result += "\nRoom: " + room.asString();
        },
        [](){}
    );
    return result;
}

Dans le code ci-dessus, dans le deuxième appel de visitOptional, si on avait écrit president.asString() au lieu de room.asString(), alors on aurait eu une erreur de compilation.

L'avantage de cette solution est que les contrôles à la compilation sont forts. L'inconvénient est que, en C++, elle réduit la lisibilité du code.

VI. Pour aller plus loin

Avant de conclure :

  • présentons le RAII, qui a été utilisé dans cet article sans être cité ;
  • évoquons très brièvement le document C++ Core Guidelines, qui s'est aussi penché sur les déréférencements de pointeur nul et les fuites de mémoire.

VI-A. RAII

Le RAII (Resource Acquisition Is Initialization) (3) est une technique selon laquelle, pour gérer une ressource, par exemple une allocation dynamique de mémoire, au lieu d'appeler manuellement une opération pour acquérir cette ressource, par exemple new, et d'appeler manuellement une opération pour libérer la ressource, par exemple delete, on lie cette ressource à la durée de vie d'un objet : le constructeur de cet objet acquiert cette ressource et le destructeur la libère.

Cela a deux conséquences :

  • tant que l'objet existe, on a la garantie que la ressource est acquise. Donc, si on veut accéder à ladite ressource, on n'a pas besoin de faire un test explicite qui vérifie que la ressource n'a pas déjà été libérée ;
  • quand l'objet est détruit, on a la garantie qu'on essaie de libérer la ressource.

La ressource en question n'est pas forcément une allocation dynamique de mémoire. Il peut aussi s'agir :

  • d'un fichier qu'on ouvre et qu'on ferme ;
  • d'une connexion qu'on ouvre et qu'on ferme ;
  • d'un mutex qu'on bloque et qu'on débloque ;
  • d'un thread qu'on lance et dont on attend la fin de l'exécution ;
  • etc.

Un objet de type std::unique_ptr, quand il n'est jamais nul, respecte le concept de RAII.

Un objet de type std::unique_ptr qui peut être nul, par contre, ne respecte pas entièrement le concept de RAII, car on n'a pas l'invariant selon lequel la ressource est acquise tant que l'objet existe. Par contre, quand l'objet est détruit, on a la garantie que, si la ressource était encore acquise, alors on la libère. Et c'est ce point qui est important quand on veut éviter les fuites de mémoire.

VI-B. C++ Core Guidelines et GSL

Le document C++ Core Guidelines (4) est écrit par Bjarne Stroustrup et Herb Sutter. Il contient un ensemble de règles de codage qui visent à augmenter la qualité du code écrit en C++. Beaucoup de ces règles ont été pensées pour pouvoir mettre en place des outils d'analyse statique de code qui vérifient si ces règles sont respectées.

Ce document introduit aussi de nouveaux types dans l'espace de nom gsl. Certains d'entre eux ont pour but de clarifier le rôle des pointeurs. En voici quelques-uns :

  • gsl::owner : indique qu'un pointeur possède la ressource pointée ;
  • gsl::not_null : indique qu'un pointeur n'est pas nul et le vérifie à l'exécution, si l'utilisateur le souhaite ;
  • gsl::span : représente un tableau (ou une portion de tableau) sans en gérer la mémoire ;
  • gsl::zstring : synonyme de char*, mais indique qu'il s'agit d'un pointeur vers une chaîne terminée par '\0' et non pas d'un pointeur vers un seul caractère ;
  • gsl::czstring : synonyme de char const*, mais indique qu'il s'agit d'un pointeur vers une chaîne terminée par '\0' et non pas d'un pointeur vers un seul caractère.

Si vous voulez en savoir plus, consultez le document C++ Core Guidelines, dont le lien est donné en bas de l'article.

VII. Conclusion

Pour éviter facilement les déréférencements de pointeur nul et les fuites de mémoire en C++, l'article présent a abordé plusieurs solutions, parfois complémentaires. À vous de choisir celles qui correspondent le mieux à vos besoins.

VIII. Remerciements

Merci à Luc Hermitte, LittleWhite et ClaudeLELOUP pour la relecture de l'article.

IX. Références

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+