Qui est à blâmer pour cette gamme basée sur une référence au temporaire?

15

Le code suivant semble plutôt inoffensif à première vue. Un utilisateur utilise la fonction bar()pour interagir avec certaines fonctionnalités de la bibliothèque. (Cela peut même avoir fonctionné pendant longtemps depuis qu'il a bar()renvoyé une référence à une valeur non temporaire ou similaire.) Maintenant, cependant, il renvoie simplement une nouvelle instance de B. Ba à nouveau une fonction a()qui renvoie une référence à un objet de type itératif A. L'utilisateur souhaite interroger cet objet, ce qui entraîne une erreur de segmentation, car l' Bobjet temporaire renvoyé par bar()est détruit avant le début de l'itération.

Je ne sais pas qui (bibliothèque ou utilisateur) est à blâmer pour cela. Toutes les classes fournies par la bibliothèque me semblent propres et ne font certainement rien de différent (renvoyer des références aux membres, renvoyer des instances de pile, ...) que tant d'autres codes. L'utilisateur ne semble pas faire de mal non plus, il itère simplement sur un objet sans rien faire concernant la durée de vie de cet objet.

(Une question connexe pourrait être: Devrait-on établir la règle générale selon laquelle le code ne doit pas "plage basée sur itération" sur quelque chose qui est récupéré par plusieurs appels chaînés dans l'en-tête de la boucle, car l'un de ces appels peut renvoyer un rvalue?)

#include <algorithm>
#include <iostream>

// "Library code"
struct A
{
    A():
        v{0,1,2}
    {
        std::cout << "A()" << std::endl;
    }

    ~A()
    {
        std::cout << "~A()" << std::endl;
    }

    int * begin()
    {
        return &v[0];
    }

    int * end()
    {
        return &v[3];
    }

    int v[3];
};

struct B
{
    A m_a;

    A & a()
    {
        return m_a;
    }
};

B bar()
{
    return B();
}

// User code
int main()
{
    for( auto i : bar().a() )
    {
        std::cout << i << std::endl;
    }
}
hllnll
la source
6
Lorsque vous avez déterminé qui blâmer, quelle sera la prochaine étape? Lui crier dessus?
JensG
7
Non, pourquoi le ferais-je? Je suis en fait plus intéressé de savoir où le processus de réflexion pour développer ce "programme" n'a pas réussi à éviter ce problème à l'avenir.
hllnll
Cela n'a rien à voir avec les valeurs r, ou basées sur une plage pour les boucles, mais avec l'utilisateur ne comprenant pas correctement la durée de vie de l'objet.
James
Remarque sur le site: il s'agit du CWG 900 qui a été fermé comme étant pas un défaut. Peut-être que le procès-verbal contient une discussion.
dyp
8
Qui est à blâmer pour cela? Bjarne Stroustrup et Dennis Ritchie, d'abord et avant tout.
Mason Wheeler

Réponses:

14

Je pense que le problème fondamental est une combinaison de fonctionnalités de langage (ou leur absence) de C ++. Le code de la bibliothèque et le code client sont tous deux raisonnables (comme en témoigne le fait que le problème est loin d'être évident). Si la durée de vie du temporaire Bétait prolongée (jusqu'à la fin de la boucle), il n'y aurait pas de problème.

Rendre la vie temporaire juste assez longue, et non plus, est extrêmement difficile. Pas même un ad-hoc plutôt "tous les temporels impliqués dans la création de la gamme pour une gamme basée sur le live jusqu'au bout de la boucle" ne seraient sans effets secondaires. Prenons le cas du B::a()retour d'une plage indépendante de l' Bobjet par valeur. Ensuite, le temporaire Bpeut être jeté immédiatement. Même si l'on pouvait identifier avec précision les cas où une extension de la durée de vie est nécessaire, car ces cas ne sont pas évidents pour les programmeurs, l'effet (destructeurs appelés beaucoup plus tard) serait surprenant et peut-être une source de bogues tout aussi subtile.

Il serait plus souhaitable de simplement détecter et interdire de telles absurdités, forçant le programmeur à s'élever explicitement bar()à une variable locale. Ce n'est pas possible en C ++ 11, et ce ne sera probablement jamais possible car cela nécessite des annotations. Rust fait cela, où la signature de .a()serait:

fn a<'x>(bar: &'x B) -> &'x A { bar.a }
// If we make it as explicit as possible, or
fn a(&self) -> &A { self.a }
// if we make it a method and rely on lifetime elision.

Voici 'xune variable ou une région de durée de vie, qui est un nom symbolique pour la période de temps pendant laquelle une ressource est disponible. Franchement, les durées de vie sont difficiles à expliquer - ou nous n'avons pas encore trouvé la meilleure explication - je vais donc me limiter au minimum nécessaire pour cet exemple et référer le lecteur incliné à la documentation officielle .

Le vérificateur d'emprunt remarquerait que le résultat des bar().a()besoins doit vivre aussi longtemps que la boucle s'exécute. Formulé comme une contrainte sur la durée de vie 'x, nous écrivons: 'loop <= 'x. Il convient également de noter que le destinataire de l'appel de méthode,, bar()est temporaire. Les deux pointeurs sont associés à la même durée de vie, d'où 'x <= 'tempune autre contrainte.

Ces deux contraintes sont contradictoires! Nous avons besoin de 'loop <= 'x <= 'tempmais 'temp <= 'loop, qui capture le problème assez précisément. En raison des exigences contradictoires, le code de bogue est rejeté. Notez qu'il s'agit d'une vérification à la compilation et que le code Rust donne généralement le même code machine que le code C ++ équivalent, vous n'avez donc pas besoin de payer un coût d'exécution pour cela.

Néanmoins, c'est une grande fonctionnalité à ajouter à un langage, et ne fonctionne que si tout le code l'utilise. la conception des API est également affectée (certaines conceptions qui seraient trop dangereuses en C ++ deviennent pratiques, d'autres ne peuvent pas être conçues pour fonctionner correctement avec des durées de vie). Hélas, cela signifie qu'il n'est pas pratique d'ajouter rétroactivement au C ++ (ou à n'importe quel langage). En résumé, la faute est à l'inertie des langages à succès et au fait que Bjarne en 1983 n'avait pas la boule de cristal et la prévoyance pour intégrer les leçons des 30 dernières années de recherche et d'expérience C ++ ;-)

Bien sûr, cela n'est pas du tout utile pour éviter le problème à l'avenir (sauf si vous passez à Rust et n'utilisez plus jamais C ++). On pourrait éviter des expressions plus longues avec plusieurs appels de méthode chaînés (ce qui est assez limitatif et ne résout même pas à distance tous les problèmes de la vie). Ou on pourrait essayer d'adopter une politique de propriété plus disciplinée sans l'aide du compilateur: documenter clairement que les barrendements en valeur et que le résultat B::a()ne doit pas survivre à celui Bsur lequel a()est invoqué. Lorsque vous modifiez une fonction pour revenir en valeur au lieu d'une référence à longue durée de vie, sachez qu'il s'agit d'un changement de contrat . Toujours sujette aux erreurs, mais peut accélérer le processus d'identification de la cause lorsqu'elle se produit.


la source
14

Pouvons-nous résoudre ce problème en utilisant les fonctionnalités C ++?

C ++ 11 a ajouté des qualificatifs de fonction membre, ce qui permet de restreindre la catégorie de valeur de l'instance de classe (expression) sur laquelle la fonction membre peut être appelée. Par exemple:

struct foo {
    void bar() & {} // lvalue-ref-qualified
};

foo& lvalue ();
foo  prvalue();

lvalue ().bar(); // OK
prvalue().bar(); // error

Lors de l'appel de la beginfonction membre, nous savons que très probablement nous devrons également appeler la endfonction membre (ou quelque chose comme size, pour obtenir la taille de la plage). Cela nécessite que nous opérions sur une valeur l, car nous devons l'adresser deux fois. Vous pouvez donc faire valoir que ces fonctions membres doivent être qualifiées par lvalue-ref.

Cependant, cela pourrait ne pas résoudre le problème sous-jacent: l'aliasing. La fonction membre beginet endalias l'objet ou les ressources gérées par l'objet. Si nous remplaçons beginet endpar une seule fonction range, nous devons en fournir une qui peut être appelée sur rvalues:

struct foo {
    vector<int> arr;

    auto range() & // C++14 return type deduction for brevity
    { return std::make_pair(arr.begin(), arr.end()); }
};

for(auto const& e : foo().range()) // error

Cela peut être un cas d'utilisation valide, mais la définition ci-dessus de l' rangeinterdit. Comme nous ne pouvons pas traiter le temporaire après l'appel de la fonction membre, il pourrait être plus raisonnable de renvoyer un conteneur, c'est-à-dire une plage propriétaire:

struct foo {
    vector<int> arr;

    auto range() &
    { return std::make_pair(arr.begin(), arr.end()); }

    auto range() &&
    { return std::move(arr); }
};

for(auto const& e : foo().range()) // OK

Appliquer cela au cas du PO et légère révision du code

struct B {
    A m_a;
    A & a() { return m_a; }
};

Cette fonction membre modifie la catégorie de valeur de l'expression: B()est une valeur, mais B().a()est une valeur. D'un autre côté, B().m_aest une valeur r. Commençons donc par rendre cela cohérent. Il y a deux façons de faire ça:

struct B {
    A m_a;
    A &  a() &  { return m_a; }

    A && a() && { return std::move(m_a); }
    // or
    A    a() && { return std::move(m_a); }
};

La deuxième version, comme indiqué ci-dessus, résoudra le problème dans l'OP.

De plus, nous pouvons restreindre Bles fonctions membres de:

struct A {
    // [...]

    int * begin() & { return &v[0]; }
    int * end  () & { return &v[3]; }

    int v[3];
};

Cela n'aura aucun impact sur le code de l'OP, car le résultat de l'expression après la :dans la boucle for basée sur la plage est lié à une variable de référence. Et cette variable (en tant qu'expression utilisée pour accéder à ses fonctions membres beginet end) est une valeur l.

Bien sûr, la question est de savoir si la règle par défaut doit être "les fonctions membres d'alias sur rvalues ​​doivent renvoyer un objet qui possède toutes ses ressources, sauf s'il y a une bonne raison de ne pas le faire" . L'alias qu'il renvoie peut être utilisé légalement, mais il est dangereux dans la façon dont vous le ressentez: il ne peut pas être utilisé pour prolonger la durée de vie de son "parent" temporaire:

// using the OP's definition of `struct B`,
// or version 1, `A && a() &&;`

A&&      a = B().a(); // bug: binds directly, dangling reference
A const& a = B().a(); // bug: same as above
A        a = B().a(); // OK

A&&      a = B().m_a; // OK: extends the lifetime of the temporary

En C ++ 2a, je pense que vous êtes censé contourner ce problème (ou un problème similaire) comme suit:

for( B b = bar(); auto i : b.a() )

au lieu des PO

for( auto i : bar().a() )

La solution de contournement spécifie manuellement que la durée de vie de best le bloc entier de la boucle for.

Proposition qui a introduit cette déclaration d'init

Démo en direct

dyp
la source