web-dev-qa-db-fra.com

Façon idiomatique de créer une classe immuable et efficace en C ++

Je cherche à faire quelque chose comme ça (C #).

public final class ImmutableClass {
    public readonly int i;
    public readonly OtherImmutableClass o;
    public readonly ReadOnlyCollection<OtherImmutableClass> r;

    public ImmutableClass(int i, OtherImmutableClass o,
        ReadOnlyCollection<OtherImmutableClass> r) : i(i), o(o), r(r) {}
}

Les solutions potentielles et les problèmes associés que j'ai rencontrés sont:

1. Utilisation de const pour les membres de la classe , mais cela signifie que l'opérateur d'affectation de copie par défaut est supprimé.

Solution 1:

struct OtherImmutableObject {
    const int i1;
    const int i2;

    OtherImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
}

Problème 1:

OtherImmutableObject o1(1,2);
OtherImmutableObject o2(2,3);
o1 = o2; // error: use of deleted function 'OtherImmutableObject& OtherImmutableObject::operator=(const OtherImmutableObject&)`

EDIT: Ceci est important car je voudrais stocker des objets immuables dans un std::vector Mais recevoir error: use of deleted function 'OtherImmutableObject& OtherImmutableObject::operator=(OtherImmutableObject&&)

2. Utiliser des méthodes get et renvoyer des valeurs , mais cela signifie que les gros objets devraient être copiés, ce qui est une inefficacité que j'aimerais savoir comment éviter. Ce fil suggère la solution get, mais il ne traite pas de la façon de gérer les objets non primitifs sans copier l'objet d'origine.

Solution 2:

class OtherImmutableObject {
    int i1;
    int i2;
public:
    OtherImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
    int GetI1() { return i1; }
    int GetI2() { return i2; }
}

class ImmutableObject {
    int i1;
    OtherImmutableObject o;
    std::vector<OtherImmutableObject> v;
public:
    ImmutableObject(int i1, OtherImmutableObject o,
        std::vector<OtherImmutableObject> v) : i1(i1), o(o), v(v) {}
    int GetI1() { return i1; }
    OtherImmutableObject GetO() { return o; } // Copies a value that should be immutable and therefore able to be safely used elsewhere.
    std::vector<OtherImmutableObject> GetV() { return v; } // Copies the vector.
}

Problème 2: les copies inutiles sont inefficaces.

3. Utiliser des méthodes get et renvoyer des références const ou des pointeurs const, mais cela pourrait laisser des références ou des pointeurs suspendus. Ce fil parle des dangers que les références sortent de la portée des retours de fonctions.

Solution 3:

class OtherImmutableObject {
    int i1;
    int i2;
public:
    OtherImmutableObject(int i1, int i2) : i1(i1), i2(i2) {}
    int GetI1() { return i1; }
    int GetI2() { return i2; }
}

class ImmutableObject {
    int i1;
    OtherImmutableObject o;
    std::vector<OtherImmutableObject> v;
public:
    ImmutableObject(int i1, OtherImmutableObject o,
        std::vector<OtherImmutableObject> v) : i1(i1), o(o), v(v) {}
    int GetI1() { return i1; }
    const OtherImmutableObject& GetO() { return o; }
    const std::vector<OtherImmutableObject>& GetV() { return v; }
}

Problème 3:

ImmutableObject immutable_object(1,o,v);
// elsewhere in code...
OtherImmutableObject& other_immutable_object = immutable_object.GetO();
// Somewhere else immutable_object goes out of scope, but not other_immutable_object
// ...and then...
other_immutable_object.GetI1();
// The previous line is undefined behaviour as immutable_object.o will have been deleted with immutable_object going out of scope

Un comportement indéfini peut se produire en raison du renvoi d'une référence à partir de l'une des méthodes Get.

37
lachy
  1. Vous voulez vraiment des objets immuables d'un certain type plus une sémantique de valeur (car vous vous souciez des performances d'exécution et vous voulez éviter le tas). Définissez simplement un struct avec tous les membres de données public.

    struct Immutable {
        const std::string str;
        const int i;
    };
    

    Vous pouvez les instancier et les copier, lire les membres de données, mais c'est tout. Déplacer-construire une instance à partir d'une référence rvalue d'une autre copie toujours.

    Immutable obj1{"...", 42};
    Immutable obj2 = obj1;
    Immutable obj3 = std::move(obj1); // Copies, too
    
    obj3 = obj2; // Error, cannot assign
    

    De cette façon, vous vous assurez vraiment que chaque utilisation de votre classe respecte l'immuabilité (en supposant que personne ne fait de mal const_cast des choses). Des fonctionnalités supplémentaires peuvent être fournies via des fonctions gratuites, il est inutile d'ajouter des fonctions membres à une agrégation en lecture seule des membres de données.

  2. Vous voulez 1., toujours avec une sémantique de valeur, mais légèrement détendue (de sorte que les objets ne sont plus vraiment immuables) et vous êtes également préoccupé par le fait que vous avez besoin de déplacer-construction pour des performances d'exécution. Il n'y a aucun moyen de contourner private membres de données et fonctions de membre getter:

    class Immutable {
       public:
          Immutable(std::string str, int i) : str{std::move(str)}, i{i} {}
    
          const std::string& getStr() const { return str; }
          int getI() const { return i; }
    
       private:
          std::string str;
          int i;
    };
    

    L'utilisation est la même, mais la construction de déplacement se déplace vraiment.

    Immutable obj1{"...", 42};
    Immutable obj2 = obj1;
    Immutable obj3 = std::move(obj1); // Ok, does move-construct members
    

    Que vous souhaitiez que l'affectation soit autorisée ou non est sous votre contrôle maintenant. Juste = delete les opérateurs d'affectation si vous ne le souhaitez pas, sinon optez pour celui généré par le compilateur ou implémentez le vôtre.

    obj3 = obj2; // Ok if not manually disabled
    
  3. Vous ne vous souciez pas de la sémantique des valeurs et/ou des incréments du nombre de références atomiques sont corrects dans votre scénario. Utilisez la solution décrite dans @ réponse de NathanOliver .

33
lubgr

Vous pouvez essentiellement obtenir ce que vous voulez en tirant parti d'un std::unique_ptr ou std::shared_ptr. Si vous ne souhaitez utiliser qu'un seul de ces objets, mais autorisez son déplacement, vous pouvez utiliser un std::unique_ptr. Si vous souhaitez autoriser plusieurs objets ("copies") qui ont tous la même valeur, vous pouvez utiliser un std::shared_Ptr. Utilisez un alias pour raccourcir le nom et fournir une fonction d'usine et cela devient assez indolore. Cela donnerait à votre code l'apparence suivante:

class ImmutableClassImpl {
public: 
    const int i;
    const OtherImmutableClass o;
    const ReadOnlyCollection<OtherImmutableClass> r;

    public ImmutableClassImpl(int i, OtherImmutableClass o, 
        ReadOnlyCollection<OtherImmutableClass> r) : i(i), o(o), r(r) {}
}

using Immutable = std::unique_ptr<ImmutableClassImpl>;

template<typename... Args>
Immutable make_immutable(Args&&... args)
{
    return std::make_unique<ImmutableClassImpl>(std::forward<Args>(args)...);
}

int main()
{
    auto first = make_immutable(...);
    // first points to a unique object now
    // can be accessed like
    std::cout << first->i;
    auto second = make_immutable(...);
    // now we have another object that is separate from first
    // we can't do
    // second = first;
    // but we can transfer like
    second = std::move(first);
    // which leaves first in an empty state where you can give it a new object to point to
}

Si le code est modifié, utilisez un shared_ptr à la place, vous pourriez faire

second = first;

puis les deux objets pointent vers le même objet, mais aucun ne peut le modifier.

22
NathanOliver

L'immuabilité en C++ ne peut pas être directement comparée à l'immuabilité dans la plupart des autres langages populaires en raison de la sémantique de la valeur universelle de C++. Vous devez comprendre ce que vous voulez dire "immuable".

Vous voulez pouvoir affecter de nouvelles valeurs aux variables de type OtherImmutableObject. Cela a du sens, car vous pouvez le faire avec des variables de type ImmutableObject en C #.

Dans ce cas, la façon la plus simple d'obtenir la sémantique souhaitée est

struct OtherImmutableObject {
    int i1;
    int i2;
};

Il peut sembler que c'est mutable. Après tout, vous pouvez écrire

OtherImmutableObject x{1, 2};
x.i1 = 3;

Mais l'effet de cette deuxième ligne est (sans tenir compte de la concurrence ...) exactement le même que l'effet de

x = OtherImmutableObject{3, x.i2};

donc si vous voulez autoriser l'affectation à des variables de type OtherImmutableObject, cela n'a aucun sens de ne pas autoriser l'affectation directe aux membres, car elle ne fournit aucune garantie sémantique supplémentaire; il ne fait que ralentir le code de la même opération abstraite. (Dans ce cas, la plupart des compilateurs d'optimisation généreront probablement le même code pour les deux expressions, mais si l'un des membres était un std::string ils ne sont peut-être pas assez intelligents pour le faire.)

Notez que c'est le comportement de pratiquement tous les types standard en C++, y compris int, std::complex, std::string, etc. Ils sont tous mutables dans le sens où vous pouvez leur assigner de nouvelles valeurs, et tous immuables dans le sens où la seule chose que vous pouvez faire (abstraitement) pour les changer est de leur assigner de nouvelles valeurs, un peu comme immuable types de référence en C #.

Si vous ne voulez pas de cette sémantique, votre seule autre option est d'interdire l'affectation. Je conseillerais de le faire en déclarant vos variables comme const, pas en déclarant tous les membres du type comme const, car cela vous donne plus d'options sur la façon dont vous pouvez utiliser la classe. Par exemple, vous pouvez créer une instance initialement modifiable de la classe, y créer une valeur, puis la "geler" en utilisant uniquement des références const par la suite - comme la conversion d'un StringBuilder en un string, mais sans la surcharge de le copier.

(Une raison possible pour déclarer tous les membres comme const pourrait être qu'il permet une meilleure optimisation dans certains cas. Par exemple, si une fonction obtient un OtherImmutableObject const&, et le compilateur ne peut pas voir le site d'appel, il n'est pas sûr de mettre en cache les valeurs des membres à travers les appels vers un autre code inconnu, car l'objet sous-jacent peut ne pas avoir le qualificatif const. Mais si les membres réels sont déclarés const, je pense qu'il serait prudent de mettre les valeurs en cache.)

12
benrg

Pour répondre à votre question, vous ne créez pas de structures de données immuables en C++ car consting références à l'objet entier fait l'affaire. Les violations de la règle sont rendues visibles par la présence de const_casts.

Si je peux me référer à "Penser en dehors du quadrant de synchronisation" de Kevlin Henney, il y a deux questions à poser sur les données:

  • Une structure est-elle immuable ou mutable?
  • Est-il partagé ou non partagé?

Ces questions peuvent être organisées en un tableau Nice 2x2 avec 4 quadrants. Dans un contexte concurrent, un seul quadrant a besoin de synchronisation: les données mutables partagées.

En effet, les données immuables n'ont pas besoin d'être synchronisées car vous ne pouvez pas y écrire, et les lectures simultanées sont très bien. Les données non partagées n'ont pas besoin d'être synchronisées, car seul le propriétaire des données peut y écrire ou y lire.

Il est donc normal qu'une structure de données soit modifiable dans un contexte non partagé, et les avantages de l'immuabilité ne se produisent que dans un contexte partagé.

IMO, la solution qui vous donne le plus de liberté est de définir votre classe à la fois pour la mutabilité et l'immuabilité, en utilisant la constance uniquement là où cela a du sens (données qui sont initalisées puis jamais modifiées):

/* const-correct */ class C {
   int f1_;
   int f2_;

   const int f3_; // Semantic constness : initialized and never changed.
};

Vous pouvez ensuite utiliser les instances de votre classe C comme mutables ou immuables, bénéficiant de constness-where-it-sense-sens dans les deux cas.

Si vous souhaitez maintenant partager votre objet, vous pouvez le placer dans un pointeur intelligent vers const:

shared_ptr<const C> ptr = make_shared<const C>(f1, f2, f3);

En utilisant cette stratégie, votre liberté s'étend sur les 3 quandrants non synchronisés tout en restant en toute sécurité hors du quadrant de synchronisation. (donc limiter le besoin de rendre votre structure immuable)

5
Laurent LA RIZZA

Je dirais que la façon la plus idiomatique serait que:

struct OtherImmutable {
    int i1;
    int i2;

    OtherImmutable(int i1, int i2) : i1(i1), i2(i2) {}
};

Mais ... ça n'est pas immuable ??

En effet, mais vous pouvez le transmettre comme une valeur:

void frob1() {
    OtherImmutable oi;
    oi = frob2(oi);
}

auto frob2(OtherImmutable oi) -> OtherImmutable {
    // cannot affect frob1 oi, since it's a copy
}

Encore mieux, les endroits qui n'ont pas besoin de muter localement peuvent définir ses variables locales comme const:

auto frob2(OtherImmutable const oi) -> OtherImmutable {
    return OtherImmutable{oi.i1 + 1, oi.i2};
}
4
Guillaume Racicot

C++ n'a pas tout à fait la capacité de prédéfinir une classe comme immuable ou const.

Et à un moment donné, vous arriverez probablement à la conclusion que vous ne devriez pas utiliser const pour les membres de classe en C++. Cela ne vaut tout simplement pas les ennuis, et honnêtement, vous pouvez vous en passer.

Comme solution pratique, j'essaierais:

typedef class _some_SUPER_obtuse_CLASS_NAME_PLEASE_DONT_USE_THIS { } const Immutable;

pour décourager quiconque d'utiliser autre chose que Immutable dans son code.

1
user541686

Les objets immuables fonctionnent beaucoup mieux avec la sémantique des pointeurs. Écrivez donc un pointeur intelligent immuable:

struct immu_tag_t {};
template<class T>
struct immu:std::shared_ptr<T const>
{
  using base = std::shared_ptr<T const>;

  immu():base( std::make_shared<T const>() ) {}

  template<class A0, class...Args,
    std::enable_if_t< !std::is_base_of< immu_tag_t, std::decay_t<A0> >{}, bool > = true,
    std::enable_if_t< std::is_construtible< T const, A0&&, Args&&... >{}, bool > = true
  >
  immu(A0&& a0, Args&&...args):
    base(
      std::make_shared<T const>(
        std::forward<A0>(a0), std::forward<Args>(args)...
      )
    )
  {}
  template<class A0, class...Args,
    std::enable_if_t< std::is_construtible< T const, std::initializer_list<A0>, Args&&... >{}, bool > = true
  >
  immu(std::initializer_list<A0> a0, Args&&...args):
    base(
      std::make_shared<T const>(
        a0, std::forward<Args>(args)...
      )
    )
  {}

  immu( immu_tag_t, std::shared_ptr<T const> ptr ):base(std::move(ptr)) {}
  immu(immu&&)=default;
  immu(immu const&)=default;
  immu& operator=(immu&&)=default;
  immu& operator=(immu const&)=default;

  template<class F>
  immu modify( F&& f ) const {
    std::shared_ptr<T> ptr;
    if (!*this) {
      ptr = std::make_shared<T>();
    } else {
      ptr = std::make_shared<T>(**this);
    }
    std::forward<F>(f)(*ptr);
    return {immu_tag_t{}, std::move(ptr)};
  }
};

Cela tire parti de shared_ptr Pour la plupart de sa mise en œuvre; la plupart des inconvénients de shared_ptr ne sont pas un problème avec les objets immuables.

Contrairement au ptr partagé, il vous permet de créer directement l'objet et crée par défaut un état non nul. Il peut toujours atteindre un état nul en étant déplacé. Vous pouvez en créer un dans un état nul en procédant comme suit:

immu<int> immu_null_int{ immu_tag_t{}, {} };

et un int non nul via:

immu<int> immu_int;

ou

immu<int> immu_int = 7;

J'ai ajouté une méthode utilitaire utile appelée modify. Modifier vous donne une instance mutable du T à passer à un lambda pour le modifier avant qu'il ne soit retourné emballé dans un immu<T>.

L'utilisation concrète ressemble à:

struct data;
using immu_data = immu<data>;
struct data {
  int i;
  other_immutable_class o;
  std::vector<other_immutable_class> r;
  data( int i_in, other_immutable_class o_in, std::vector<other_immutable_class> r_in ):
    i(i_in), o(std::move(o_in)), r( std::move(r_in))
  {}
};

Utilisez ensuite immu_data.

L'accès aux membres nécessite -> Et non ., Et vous devriez vérifier les immu_data S nuls si vous les avez passés.

Voici comment vous utilisez .modify:

immu_data a( 7, other_immutable_class{}, {} );
immu_data b = a.modify([&](auto& b){ ++b.i; b.r.emplace_back() });

Cela crée un b dont la valeur est égale à a, sauf que i est incrémenté de 1, et il y a un other_immutable_class Supplémentaire dans b.r (construit par défaut). Notez que a n'est pas modifié en créant b.

Il y a probablement des fautes de frappe ci-dessus, mais j'ai utilisé le design.

Si vous voulez avoir de la fantaisie, vous pouvez faire en sorte que immu supporte la copie sur écriture ou la modification sur place si elle est unique. C'est plus difficile qu'il n'y paraît.

1

En C++, il n'y a tout simplement pas * besoin de faire ceci:

class ImmutableObject {
    const int i1;
    const int i2;
}
ImmutableObject o1:
ImmutableObject o2;
o1 = o2; // Doesn't compile because immutable objects are not mutable.

Si vous voulez une référence mutable à un objet immuable/const, vous utilisez un pointeur, un pointeur intelligent ou un reference_wrapper . À moins que vous ne vouliez réellement avoir une classe dont le contenu peut être modifié par n'importe qui à tout moment, ce qui est l'opposé d'une classe immuable.

* Bien sûr, C++ est un langage où "non" n'existe pas. Dans ces précieuses circonstances vraiment exceptionnelles, vous pouvez utiliser const_cast.

1
Peter