risorse | polimorfismo senza ereditarietà

Polimorfismo senza ereditarietà

Ho cercato di ricostruire i passaggi salienti di uno dei due interventi di Sean Parent a Going Native 2013[1], e che è ben riassunto in questa sua citazione:

There are no polymorphic types, only a polymorphic use of similar types.

Parent sostiene che il polimorfismo ottenuto tramite l'ereditarietà presenta diversi svantaggi, in particolare il fatto di richiedere l'uso di puntatori, con tutte le conseguenze del caso: la necessità di gestire il life-time degli oggetti puntati in primis, oltre ai vari problemi legati alla condivisione di strutture di dati – regolamentazione dell'accesso concorrente, non-località dei dati, ecc. A questo proposito, Parent dice:

A shared pointer is as good as a global variable.

L'ereditarietà inoltre è intrusiva, dato che può modificare la struttura interna dell'oggetto derivato, e introduce una dipendenza di compilazione dovuta all'inclusione di file.

shared_ptr

Una possibile soluzione è costituita da un'implementazione non intrusiva del polimorfismo dinamico per oggetti con value semantics, dove conta il contenuto di un oggetto e non la sua identità.

Intendo usare la tecnica illustrata da Parent per definire delle factory di oggetti polimorfici che non ritornino puntatori per non dover definire una politica di ownership che attribuisca la responsabilità di distruggere l'oggetto o forzare l'uso di un particolare tipo di smart pointer:

struct ObjectFactory {
  virtual ??? CreateObject(<params>); // what should return?
};

La situazione di partenza è il classico polimorfismo per ereditarietà:

#include "gut.h"

struct Object {
  virtual std::string f() = 0;
};

struct A : Object {
  virtual std::string f() {
    return "A";
  };
};

struct B : Object {
  virtual std::string f() {
    return "B";
  };
};

struct Factory {
  enum Type { e_a, e_b, };
  static std::shared_ptr<Object> create(Type type) {
    if (e_a == type)
      return std::make_shared<A>();
    else if (e_b == type)
      return std::make_shared<B>();
    throw "wrong type";
  }
};

TEST("factory") {
  auto obj_a = Factory::create(Factory::e_a);
  REQUIRE(obj_a->f() == "A");

  auto obj_b = Factory::create(Factory::e_b);
  REQUIRE(obj_b->f() == "B");
}

/* output:
 *
 * Test suite started...
 * factory: OK
 * Ran 1 test(s) in 0s.
 * OK - all tests passed.
 */

Per svincolarsi dall'ereditarietà si definisce l'interfaccia polimorfica in modo implicito, alla stregua di una policy alla Alexandrescu:

/* implements the implicit interface:
 *
 *    std::string f() const
 */
struct A {
  std::string f() const {
    return "A";
  }
};

L'interfaccia è stata resa costante, per enfatizzare la semantica del valore. Una prima implementazione, basata sul pimpl, è la seguente:

#include "gut.h"

struct A {
  std::string f() const {
    return "A";
  }
};

class Object {
  std::shared_ptr<const A> pimpl_;
public:
  Object(A a) : pimpl_(std::make_shared<A>(a)) { }
  std::string f() { return pimpl_->f(); }
};

struct Factory {
  enum Type { e_a, e_b, };
  static Object create(Type type) {
    if (e_a == type)
      return A();
    throw "wrong type";
  }
};

TEST("factory") {
  auto obj_a = Factory::create(Factory::e_a);
  REQUIRE(obj_a.f() == "A");
}

In questo modo si è ottenuto il disaccoppiamento tra l'implementazione A e l'interfaccia Object.

Nota: shared_ptr<const T> è assimilabile ad un valore perchè l'oggetto puntato non è accessibile attraverso nessun altro puntatore o referenza non costante.

Le cose si complicano – a livello implementativo – quando entra in gioco una seconda implementazione:

...

struct B {
  std::string f() const {
    return "B";
  };
};

...

struct Factory {
  enum Type { e_a, e_b, };
  static Object create(Type type) {
    if (e_a == type)
      return A();
    if (e_b == type)
      return B();
    throw "wrong type";
  }
};
...

TEST("factory") {
  auto obj_a = Factory::create(Factory::e_a);
  REQUIRE(obj_a.f() == "A");
  auto obj_b = Factory::create(Factory::e_b);
  REQUIRE(obj_b.f() == "B");
}

Non è sufficiente aggiungere un nuovo costruttore a Object, poichè il membro pimpl_ non è comunque in grado di ospitare un oggetto di tipo B. Non è nemmeno pensabile rendere Object parametrico rispetto al tipo di oggetto interno, sarebbe troppo vincolante (non sarebbe ad esempio possibile definire un contenitore di Object con implementazioni differenti):

template <typename T>
class Object {
  std::shared_ptr<const T> pimpl_;
  ...

Il problema del tipo parametrico si può facilmente risolvere con una tecnica di type-erasure, introducendo una classe base non parametrizzata, e specializzandola, questa volta sì parametricamente, per ognuna delle implementazioni; in letteratura, la classe base viene solitamente denotata col termine concept, le specializzazioni come model:

class Object {
...

private:
  struct Concept {
    virtual ~Concept() { };
    virtual std::string f() const = 0;
  };
...

Concept, dotata di distruttore virtuale in quanto classe base, esplicita l'interfaccia rimasta fin qui implicita per consentire a Object di accedere alle implementazioni concrete:

class Object {
  std::shared_ptr<const A> pimpl_;
...
public:
  Object(A a) : pimpl_(std::make_shared<A>(a)) { }
  template <typename T>
  Object(T obj) : pimpl_(std::make_shared<Model<T>>(obj)) { }
  std::string f() { return pimpl_->f(); }
...

private:
...

  std::shared_ptr<const Concept> pimpl_;

Il modello è una semplice classe parametrica che inoltra le chiamate all'implementazione concreta:

class Object {
...

private:
...

  template <typename T>
  struct Model : Concept {
    T obj_;
    Model(T obj) : obj_(obj) { }
    virtual std::string f() const { return obj_.f(); }
  };
...

Alcune chiamate a std::move evitano delle inutili copie:

#include "gut.h"

struct A {
  std::string f() const {
    return "A";
  }
};

struct B {
  std::string f() const {
    return "B";
  };
};

class Object {
public:
  template <typename T>
  Object(T obj) : pimpl_(std::make_shared<Model<T>>(std::move(obj))) { }
  std::string f() { return pimpl_->f(); }
private:
  struct Concept {
    virtual ~Concept() { };
    virtual std::string f() const = 0;
  };
  template <typename T>
  struct Model : Concept {
    T obj_;
    Model(T obj) : obj_(std::move(obj)) { }
    virtual std::string f() const { return obj_.f(); }
  };
  std::shared_ptr<const Concept> pimpl_;
};

struct Factory {
  enum Type { e_a, e_b, };
  static Object create(Type type) {
    if (e_a == type)
      return A();
    if (e_b == type)
      return B();
    throw "wrong type";
  }
};

TEST("factory") {
  auto obj_a = Factory::create(Factory::e_a);
  REQUIRE(obj_a.f() == "A");
  auto obj_b = Factory::create(Factory::e_b);
  REQUIRE(obj_b.f() == "B");
}

/* output:
 *
 * Test suite started...
 * factory: OK
 * Ran 1 test(s) in 0s.
 * OK - all tests passed.
 */

unique_ptr

Come cambia l'implementazione di Object nel caso in cui si volesse optare per l'uso del più efficiente e meno intrusivo unique_ptr? shared_ptr consente di copiare Object tra loro:

...

TEST("factory") {
  ...
  obj_a = obj_b;
  REQUIRE(obj_a.f() == "B");
}

/* output:
 *
 * Test suite started...
 * factory: OK
 * Ran 1 test(s) in 0s.
 * OK - all tests passed.
 */

unique_ptr, essendo sprovvisto di costruttore di copia, no:

...
class Object {
public:
  template <typename T>
  Object(T obj) : pimpl_(std::make_shared<Model<T>>(std::move(obj))) { }
  Object(T obj) : pimpl_(new Model<T>(std::move(obj))) { }

...
  std::shared_ptr<const Concept> pimpl_;
  std::unique_ptr<const Concept> pimpl_;
};

TEST("factory") {
  ...
  obj_a = obj_b; // <- does not compile!
  ...
}

Per ovviare a ciò si effettua un deep copy di Model, sia nel costruttore di copia che nell'operatore di assegnamento:

...

class Object {
public:
  template <typename T>
  Object(T obj) : pimpl_(new Model<T>(std::move(obj))) { }
  Object(const Object& obj) : pimpl_(obj.pimpl_->clone_()) { }
  Object& operator=(const Object& obj) {
    Object tmp(obj);
    *this = std::move(tmp);
    return *this;
  }
  std::string f() { return pimpl_->f(); }
private:
  struct Concept {
    virtual ~Concept() { };
    virtual Concept* clone_() const = 0;
    virtual std::string f() const = 0;
  };
  template <typename T>
  struct Model : Concept {
    T obj_;
    Model(T obj) : obj_(std::move(obj)) { }
    virtual Concept* clone_() const { return new Model(*this); }
    virtual std::string f() const { return obj_.f(); }
  };
  std::unique_ptr<const Concept> pimpl_;
};
...

Avendo definito il costruttore di copia e l'operatore di assegnamento è bene definire gli analoghi move constructor e move assignment operator; in caso contrario la chiamata all'operatore di assegnamento causerebbe uno stack overflow, essendo basato sul move assignment operator che il compilatore non genera in virtù della definizione esplicita dell'operator=(const Object&), e che verrebbe perciò risolto con una nuova chiamata all'operatore di assegnamento stesso:

...

class Object {
public:
  template <typename T>
  Object(T obj) : pimpl_(new Model<T>(std::move(obj))) { }
  Object(const Object& obj) : pimpl_(obj.pimpl_->clone_()) { }
  Object(Object&&) noexcept = default;
  Object& operator=(const Object& obj) {
    Object tmp(obj);
    *this = std::move(tmp);
    return *this;
  }
  Object& operator=(Object&&) noexcept = default;
  std::string f() { return pimpl_->f(); }
private:
  struct Concept {
    virtual ~Concept() { };
    virtual Concept* clone_() const = 0;
    virtual std::string f() const = 0;
  };
  template <typename T>
  struct Model : Concept {
    T obj_;
    Model(T obj) : obj_(std::move(obj)) { }
    virtual Concept* clone_() const { return new Model(*this); }
    virtual std::string f() const { return obj_.f(); }
  };
  std::unique_ptr<const Concept> pimpl_;
};
...

/* output:
 *
 * Test suite started...
 * factory: OK
 * Ran 1 test(s) in 0s.
 * OK - all tests passed.
 */

Riferimenti

  1. Parent, S. "Inheritance Is The Base Class of Evil". Going Native 2013. <http://channel9.msdn.com/Events/GoingNative/2013/Inheritance-Is-The-Base-Class-of-Evil>. Visitato il 24 Settembre 2013.

Pagina modificata il 30/01/2014