Fonctionnement des pointeurs en C++

Le problème exposé dans ce sujet a été résolu.

Bonjour, Je suis en train d’écrire une petite librairie de manipulation de vecteur et matrice, et je voulais avoir un comportement proche du GLSL, c’est-à-dire pouvoir récupérer mes composantes de vecteur par « bloc » (par exemple sur un vec3 v je peux faire v.xz() pour manipuler uniquement la première et la troisième coordonnées). Pour pouvoir ensuite assigner directement les composantes par bloc, j’ai utilisé dans mes classes non pas des flottants, mais des pointeurs (sur des flottants). Cela donne pour vec2 (j’omets l’en-tête qui n’a rien d’intéressant) :

#include "math/vec2.hpp"



Vec2::Vec2(void) : m_x(new float(0)), m_y(new float(0)) {}

Vec2::Vec2(float x, float y) : m_x(new float(x)), m_y(new float(y)) {}

Vec2::Vec2(float* x, float* y) : m_x(x), m_y(y) {}

Vec2::~Vec2(void) {
    delete m_x;
    delete m_y;

    m_x = nullptr;
    m_y = nullptr;
}


float& Vec2::x(void) {
    return *m_x;
}

float& Vec2::y(void) {
    return *m_y;
}


std::ostream& Vec2::display(std::ostream& os) const {
    os << "(" << *m_x << " ; " << *m_y << ")";

    return os;
}


std::ostream& operator<<(std::ostream& os, const Vec2& vec) {
    return vec.display(os);
}

J’ai ensuite réalisé quelques tests :

#include <iostream>


#include "math/vec2.hpp"


int main(int, char**) {
    Vec2 v1 = Vec2(1.2, 2);

    {
        Vec2 v2 = Vec2(&(v1.x()), &(v1.y()));

        std::cout << "v1 = " << v1 << std::endl;
        std::cout << "v2 = " << v2 << std::endl;

        // Modifier l'un revient à modifier l'autre
        v2.y() = 3.141592;

        std::cout << "v1 = " << v1 << std::endl;
        std::cout << "v2 = " << v2 << std::endl;

        // Les composantes ont la même adresse
        std::cout << "@v1.x = " << &(v1.x()) << std::endl << "@v2.x = " << &(v2.x()) << std::endl;
    }
    
    std::cout << "v1 = " << v1 << std::endl;

    return 0;
}

Et là je m’attendais à avoir une erreur de segmentation, en effet la variable v2 à la fin du scope est détruite, donc son destructeur est appelé, ce qui détruit aussi les deux composantes m_x et m_y, tout du moins je pensais que c’est ce qui se passerait. Mais le vecteur v1 s’affiche correctement. Je me questionne donc sur le fonctionnement du new et du delete en C++ dans ce contexte, et est-ce qu’il y a un risque que la variable v1 puisse avoir un comportement indéterminé au fur et à mesure du code après une telle opération (comme si la place des composantes en mémoire pouvait être réallouée pour autre chose) ? Dans le cas ou ce ne serait pas le cas, j’aimerais savoir si cette façon de fonctionner des objets vec est bonne ou si il y aurait une meilleure alternative ?

Je vous remercie d’avance pour vos réponses.

Tu devrais passer ton programme dans un sanitizer. Les sources de problèmes sont telles que je ne sais par où commencer mon feedback: Plantage assuré sur copie et affectation, et autres doubles libérations sur le fait que même les vues verront leur éléments détruits.

Mélanger dynamiquement les rôles de "vue" et de "responsable" dans une même classe, c’est une source d’ennuis interminables. C’est compliqué. C’est pas efficace. C’est source de bugs.

Par où commencer?

  1. Virer tous les pointeurs, après tout on est en C++!
  2. On peut tricher pour n’écrire qu’un seul type qui fasse vue ou responsable de sa mémoire avec des templates.

Et si au passage on offre des possibilités d’optimisation supplémentaires avec des constexpr et des noexcept, cela va donner.

#include <ostream>
#include <type_traits>

template <typename T>
struct vec2
{
    using value_type = T;

    constexpr vec2(T x_, T y_) noexcept : m_x(x_), m_y(y_) {}
    constexpr T      & x()       noexcept { return m_x; }
    constexpr T const& x() const noexcept { return m_x; }
    constexpr T      & y()       noexcept { return m_y; }
    constexpr T const& y() const noexcept { return m_y; }

    template <typename U>
    constexpr vec2& operator+=(vec2<U> const& u) noexcept {
        m_x += u.x();
        m_y += u.y();
        return *this;
    }
    template <typename U>
    friend constexpr vec2<std::common_type_t<T,U>> operator+(vec2 const& lhs, vec2<U> const& rhs) noexcept {
        // A cause du common_type, on n'a pas le droit à l'élision de
        // copie mélangée avec un ami caché...
        return {lhs.x() + rhs.x(), lhs.y() + rhs.y()};
    }

    friend std::ostream & operator<<(std::ostream & os, const vec2 & v)
    {
        return os << "[ " << v.x() << ", " << v.y() << " ]";
    }
    
private:
    T m_x;
    T m_y;
};

template <typename T>
struct vec3 : private vec2<T>
{
    constexpr vec3(T x_, T y_, T z_) noexcept : vec2<T>{x_, y_}, m_z(z_) {}
    using vec2<T>::x;
    using vec2<T>::y;
    constexpr T      & z()       noexcept { return m_z; }
    constexpr T const& z() const noexcept { return m_z; }

    constexpr vec2<T&> xy() noexcept { return {x(), y()}; }
    constexpr vec2<T&> yz() noexcept { return {y(), z()}; }
    constexpr vec2<T&> xz() noexcept { return {x(), z()}; }

    constexpr vec2<T const&> xy() const noexcept { return {x(), y()}; }
    constexpr vec2<T const&> yz() const noexcept { return {y(), z()}; }
    constexpr vec2<T const&> xz() const noexcept { return {x(), z()}; }

    friend std::ostream & operator<<(std::ostream & os, const vec3 & v)
    {
        return os << "[ " << v.x() << ", " << v.y() << ", " << v.z() << " ]";
    }
    
private:
    T m_z;
};


#include <iostream>

int main()
{
    vec3<float> v3{1.f, 2.f, 3.f};
    std::cout << v3 << "\n";

    auto xz = v3.xz();
    xz.x() /= 4.f;
    xz.y() /= 4.f;
    xz += vec2<float>{ 42, 12 };
    std::cout << v3 << "\n";

    auto v2 = xz + vec2<double>{2, 5};
    static_assert(std::is_same<double, decltype(v2)::value_type>::value);
    std::cout << v2 << "\n";

    vec3<double> const c3{1., 2., 3.};
    auto cxz = c3.xz();
    // c3.x() *= 5.;
    // cxz += vec2<double>{ 42, 12 };
}

Reste à introduire un opérateur d’accès indexés, mais avec cette histoire de vue vers des zones disjointes, je ne suis pas inspiré pour faire ça simplement.

EDIT: quoique… avec un switch et sans supposer que les éléments sont contigus ensuite alors que nous avons cette garantie en temps normal.

template <typename T>
struct vec2
{
...
    constexpr T& operator[](std::uint_fast8_t idx) noexcept {
        switch (idx) {
            case 0: return x();
            case 1: return y();
            default: assert(!"index shall be < 2");
        }
    }
    
    constexpr T const& operator[](std::uint_fast8_t idx) const noexcept {
        switch (idx) {
            case 0: return x();
            case 1: return y();
            default: assert(!"index shall be < 2");
        }
    }
...

Et pareil pour vec3.

Merci pour votre réponse, j’ai tenté de passer le programme dans un sanitizer comme vous le suggériez, cela m’a permis de me rendre compte qu’effectivement le comportement du programme n’est pas normal. À l’origine, sous Windows, en apparence le vecteur v1 de mon précédent exemple semble s’en sortir après la destruction de v2, mais sous Linux (en WSL) ce n’est déjà plus le cas. Comme je compile avec gcc et g++ sous Windows, je n’ai pas réussi à passer le paramètre -fsanitize qui n’est, après recherche, pas supporté sous Windows. Je suis donc passé sous WSL et le sanitizer me montre bien une erreur d’accès à la mémoire.

Merci pour votre explication, cependant je crains ne pas avoir le niveau pour tout comprendre, notamment ce que sont les vues et responsables, auriez-vous une référence (fr ou en) où je pourrais me renseigner là-dessus ? Si je comprends bien, les vues sont liées aux objets vec<T> et les responsables aux vec<T&>, ce qui me donne une certaine intuition de ce que sont les vues et responsables.

Avec cet enchevêtrement de types et de références, je me demande si ce n’est pas trop compliqué de vouloir imiter le comportement du GLSL plutôt que d’avoir des fonctions plus classiques comme setXZ(float x, float z), et peut-être laisser un accès direct uniquement pour chaque composante seule. Pour un exercice de programmation cela peut être intéressant, mais je comptais intégrer ces classes dans un projet plus gros, j’ai peur que ce ne soit pas pratique à terme notamment pour le debugage (d’autant qu’il va falloir que je revois tout mon code, le nombre de référence se compte sur les doigts de la main, alors que les pointeurs… :euh: ).

Le GLSL doit certainement résoudre ses accès par groupes de composantes lors de la compilation, est-ce qu’une extension du langage serait une potentielle solution ? Je ne compte pas m’engager là-dedans, mais je me pose la question par curiosité.

Merci pour votre explication, cependant je crains ne pas avoir le niveau pour tout comprendre, notamment ce que sont les vues et responsables, auriez-vous une référence (fr ou en) où je pourrais me renseigner là-dessus ? Si je comprends bien, les vues sont liées aux objets vec<T> et les responsables aux vec<T&>, ce qui me donne une certaine intuition de ce que sont les vues et responsables.

Une vue sera un objet qui permet de voir quelque chose qui est stocké ailleurs. Si "vue" est un terme consacré, "responsable" est le mot que j’ai pris faute d’inspiration pour désigner des objets responsables de ressources comme de la mémoire allouée.

En temps général, cela va plutôt désigner des capsules RAII telles que les classes standards que sont std::string ou std::vector. Les vues seront std::string_view (C++17) ou std::span (C++20). La vue n’est qu’un machin qui regarde. Si le machin cesse d’exister, alors que l’on continue à manipuler la vue, il y a alors un problème majeur.

Ici, dans mon exemple, la vue sera vec2<double&> (elle regarde via une référence), alors que l’objet qui contient vraiment des éléments sera le vec2<double>. Idéalement, l’utilisateur final ne devra directement manipuler que les derniers. Les vues on ne les créerait que via .xy() — il y a moyen d’en interdire la création hasardeuse par l’utilisateur final. De plus, à aucun moment dans le modèle que je propose un objet peut passer de l’état responsable à l’état vue ou le contraire. C’est déterminé en dur dans le code et le compilateur nous le garantira. Les objets duaux c’est … la merde!

alors que les pointeurs

Si le projet est un projet en C++, vu l’utilisation précédente des pointeurs je ne peux qu’inviter à faire une petite pause pour lire le tutoriel/cours de ce site. Toute présente explicite de delete hors destructeur (ou opérateurs d’affectation), ou en nombre > 1 par destructeur est un signe fort de programme avec risques de fuite de mémoire — sans parler qu’une classe valeur (définition dans le cours!) qui possède une ressource (pointeur ici) doit respecter certaines règles élémentaires que la classe vec originale ne respectait pas. Dans un programme robuste et maintenable, les présences de new/delete seront extrêmement peu nombreuses.

Le GLSL doit certainement résoudre ses accès par groupes de composantes lors de la compilation, est-ce qu’une extension du langage serait une potentielle solution ?

Je ne sais pas comment elle procède. Le code que j’ai fourni résout tout ce qu’il peut à la compilation. Ou du moins il donne toutes les billes qu’il faut au compilateur. Cela pourra se vérifier sur godbolt.

avoir des fonctions plus classiques comme setXZ(float x, float z)

Le repacking/dispatching à trous, je trouve ça un peu bizarre. Et effectivement, fournir une vue vers des éléments disjoints peut-être risqué. Une recommandation, d’autant plus importante en C++, et de définir des API/bibliothèque simples à utiliser correctement mais difficiles à utiliser incorrectement. Et v3.xz().y(), c’est quand même furieusement bizarre. v3.yz().y() ne sera possiblement pas ce que l’on pense faire, et à la relecture, bonne chance pour être sûr de nous des mois plus tard. Bref, c’est quelque chose de vraiment trop facile à utiliser incorrectement.

Est-ce vraiment un besoin identifié dans le projet de plus haut niveau? Mes intuitions mathématiques sont plutôt que l’on passe de la 3D à la 2D avec des projections, pas juste en isolant des composantes — même si j’avoue à régulièrement avoir à basculer entre (lat, lon) et (lat, lon, hgt), mais… je ne fais pas du (lat, hgt)…

Mon projet est effectivement complètement en C++ (hormis la SDL et gl3w), je l’ai globalement mis en pause avec la reprise des cours, mais je passerai sur un ou deux tutos revoir tout ça avant de reprendre.

Je n’ai pas tant de new et de delete que ça dedans, c’est surtout que je passe beaucoup d’objets par pointeur plutôt que par référence. J’avais déjà prévu de revoir la totalité du code, quand j’ai commencé à coder, je cherchais surtout à avoir un truc fonctionnel rapidement et je n’étais plus trop dans le C++ (notamment j’avais oublié l’existence des références). Mais c’est vrai que j’ai été étonné plus d’une fois de ne pas avoir d’erreur de mémoire, mais je sais maintenant que certains soucis nécessite un sanitizer pour être détectés. Je recompilerai le tout sur ma Debian pour vérifier ça.

Pour les classes vec, j’ai surtout voulu testé rapidement de coder sous Visual Studio (alors que j’étais sous vim et notepad++), et je me suis dit que ça pourrait être intéressant d’imiter glsl, mais en soit, ce sera des calculs de matrice pour la 3D, il y a peu de chances de devoir agir sur certaines composantes et pas d’autres. Ce qui m’a amené à poster c’est surtout le comportement du programme de test, je pensais avoir une erreur à l’exécution et au final, j’ai eu un comportement en apparence correct. Cela m’a aussi permis de confirmer que la structure initiale des classes était mauvaise.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte