Duck Typing en C++

Image by svgrepo.com

Alberto Lorenzo Márquez

  • Ingeniero aeroespacial
  • Desarrollador de software (C++, Fortran, Python, Javascript)

Polimorfismo

  • Polimorfismo dinámico
  • Polimorfismo estático

Polimorfismo dinámico

El tradicional basado en herencia y las funciones virtuales.

In [2]:
class IFoo {
public:
    virtual void bar() const = 0;
    virtual ~IFoo() = default;
};
In [3]:
class Foo1 final : public IFoo {
public:
    void bar() const override {
        std::cout << "I'm a Foo1 instance" << std::endl;
    }
};

class Foo2 final : public IFoo {
public:
    void bar() const override {
        std::cout << "I'm a Foo2 instance" << std::endl;
    }
};
In [4]:
std::vector<std::unique_ptr<IFoo>> foo_ptrs;
foo_ptrs.emplace_back(std::make_unique<Foo1>(Foo1()));
foo_ptrs.emplace_back(std::make_unique<Foo2>(Foo2()));

for (auto const& foo : foo_ptrs)
    foo->bar();
I'm a Foo1 instance
I'm a Foo2 instance

¿Utilizar una clase que no pertenece a la jerarquía?

In [5]:
class Foo3 {
public:
    void bar() const { std::cout << "I'm a Foo3 instance" << std::endl; }
};

Principales inconvenientes

  • Semántica por referencia.
  • Requiere de código adicional para adaptar código de terceros.

Polimorfismo estático

Es decir, las plantillas.

In [6]:
template<typename... Args>
void call_bar(Args&&... foos) { (foos.bar(), ...); }
In [7]:
call_bar(Foo1(), Foo2(), Foo3());
I'm a Foo1 instance
I'm a Foo2 instance
I'm a Foo3 instance

¿Almacenar distantas instancias de clases de plantilla en un contenedor...

Tupla

... dinámico en tiempo de ejecución...

Variant

... para cualquier tipo que cumpla la interfaz?

¿Any?

Principal inconveniente

  • Comunicación entre distintas unidades de traducción sólo para tipos conocidos.

Repasando...

...la herencia permite utilizar tipos desconocidos a través de las interfaces.

...las plantillas permiten generar código de forma automática.

Combinando polimorfismos

In [8]:
class FooConcept {
public:
    virtual void bar() const = 0;
    virtual ~FooConcept() = default;
};

Utilizando la herencia proporcionando una clase base para las plantillas, se consigue que estas no tengan por qué ser conocidas entre distintas unidades de traducción.

In [9]:
template<typename T>
class FooModel final : public FooConcept {
    T self_;

public:
    template<typename U>
    FooModel(U&& self) : self_{ std::forward<U>(self) } {}

    void bar() const override { self_.bar(); }
};

Utilizando las plantillas, se consigue liberar al usuario de tener que escribir código para adaptarse a la interfaz.

Borrado de tipos

In [10]:
class Foo {
    std::unique_ptr<FooConcept> self_;

public:
    template<typename T>
    Foo(T&& self)
        : self_{ std::make_unique<FooModel<T>>(std::forward<T>(self)) }
    {}

    void bar() const { self_->bar(); }
};

Finalmente, ocultando las dos técnicas anteriores bajo un mismo tipo, se consigue proporcionar semántica por valor.

In [11]:
std::vector<Foo> foos;
foos.emplace_back(Foo1());  // Same hierarchy
foos.emplace_back(Foo2());  // Same hierarchy
foos.emplace_back(Foo3());  // Foreign class

for (auto const& foo : foos)
    foo.bar();
I'm a Foo1 instance
I'm a Foo2 instance
I'm a Foo3 instance

Duck Typing

When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.

James Whitcomb Riley

Algo de control sobre la memoria dinámica

Optimización del objeto pequeño

Recursos de memoria polimórficos

Tablas de métodos virtuales

Los mecanismos de implementación de la optimización del objeto pequeño y reserva de memoria de objetos con tipos borrados requieren de castings intensivos.

Las clases con métodos virtuales no poseen una disposición estándar (standard layout). Los castings directos de clase base a una localización de memoria donde se ha almacenado una clase derivada conducen a comportamiento indefinido.

De la herencia a las tablas de métodos... ¿cómo cambia el borrado de tipos?

class FooWrapper {
public:
    using allocator_type = std::pmr::polymorphic_allocator<std::byte>;

private:
    static std::size_t constexpr BUFFER_SIZE = 16;

    allocator_type allocator_;

    FooConcept const* dispatcher_;
    union Storage {
        std::aligned_storage_t<BUFFER_SIZE> local;
        void* remote;
    } storage_;

    // ...
};

Conceptos como tablas virtuales de punteros

struct FooConcept {
    void(*bar)(void* self);
    void(*destroy)(void* self, void* alloc);
};

Implementando la tabla de punteros para los objetos pequeños...

template<typename T, typename U>
FooConcept const FooDispatcherSBO{
    [](void* self) {         // bar
        auto storage = reinterpret_cast<T*>(&static_cast<U*>(self)->local);
        storage->bar();
    },
    [](void* self, void*) {  // destroy
        auto storage = reinterpret_cast<T*>(&static_cast<U*>(self)->local);
        std::destroy_at(storage);
    }
};

Implementando la tabla de punteros para los objetos utilizando memoria dinámica...

template<typename T, typename U>
FooConcept const FooDispatcherPMR{
    [](void* self) {               // bar
        auto storage = reinterpret_cast<T*>(static_cast<U*>(self)->remote);
        remote->bar();
    },
    [](void* self, void* alloc) {  // destroy
        auto allocator = detail::rebind_function<T>(alloc);
        auto storage = reinterpret_cast<T*>(static_cast<U*>(self)->remote);
        if (storage)
            std::destroy_at(storage);
        allocator.deallocate(storage, 1);
    }
};

Borrado de tipos

class FooWrapper {
public:
    template<typename T>
    explicit FooWrapper(T&& obj, allocator_type alloc={})
        : allocator_{ alloc }
    {
        std::pmr::polymorphic_allocator<T> allocator{ alloc };
        T* storage;

        if constexpr (sizeof(T) <= BUFFER_SIZE) {
            dispatcher_ = &FooDispatcherSBO<T, Storage>;
            storage = reinterpret_cast<T*>(&storage_.local);
        }
        else {
            dispatcher_ = &FooDispatcherPMR<T, Storage>;
            storage_.remote = allocator.allocate(1);
            storage = reinterpret_cast<T*>(storage_.remote);
        }
        allocator.construct(storage, std::forward<T>(obj));
    }

El desctructor y el método bar son despachados a través de la tabla de punteros:

FooWrapper::~FooWrapper() {
    dispatcher_->destroy(&storage_, &allocator_);
}
void FooWrapper::bar() const {
    dispatcher_->bar(&const_cast<Storage&>(storage_));
}

Ejemplo final: utilizando un bloque de memoria local

Sea un objeto pequeño:

class SmallFoo {
public:
    void bar() const { std::cout << "I am a small foo!" << std::endl; }
};

Sea un objeto que requiera de memoria dinámica:

class BigFoo {
    char str_[24];

public:
    BigFoo() noexcept { std::strcpy(str_, "I am a big foo!"); }
    BigFoo(BigFoo const& other) noexcept : BigFoo{} {}
    BigFoo(BigFoo&& other) noexcept : BigFoo{} {}

    void bar() const { std::cout << str_ << std::endl; }
};

Ejemplo envolviendo ambos objetos en un vector que se aprovecha de un bloque de memoria local:

std::array<std::byte, 256> buffer;
std::pmr::monotonic_buffer_resource pool{ buffer.data(), buffer.size() };

std::pmr::vector<foo::FooWrapper> foos{ &pool };
foos.reserve(2);  // No move-constructor defined yet
foos.emplace_back(SmallFoo());
foos.emplace_back(BigFoo());

for (auto const& foo : foos)
    foo.bar();

Demostrado está, en C++ es posible un polimorfismo...

  • Con semántica por valor.
  • Sin necesidad de aportar código adicional.
  • Con control sobre la gestión de la memoria.

... por eso nos gusta C++!

Código fuente

El ejemplo correcto y completo de borrado de tipos aquí presentado, utilizando optimización del objeto pequeño y recursos de memoria polimórficos (incluyendo los constructores de copia y movimiento, así como los operadores de asignación), puede encontrarse aquí.

Referencias

Sean Parent, Polymorphic Task