Polimorfismo estático y dinámico en C++ / Sudo Null IT News

¡Hola Habr! Hasta la fecha, ya se han escrito muchos libros de texto y artículos sobre polimorfismo en general y su implementación en C++ en particular. Sin embargo, para mi sorpresa, al describir el polimorfismo, nadie (o casi nadie) menciona el hecho de que, además del polimorfismo dinámico, C++ también tiene una capacidad bastante poderosa para utilizar su hermano menor: el polimorfismo estático. Además, es uno de los conceptos centrales de STL, una parte integral de su biblioteca estándar.

Por ello, en este artículo me gustaría hablar al menos en términos generales sobre él y sus diferencias con el conocido polimorfismo dinámico. Espero que este artículo sea de interés para aquellos que recién han comenzado a estudiar los principios de la programación orientada a objetos y podrán mirar a su “tercer elefante” desde una nueva perspectiva.

Permítanme hacer una reserva de inmediato: a menudo el polimorfismo también incluye la sobrecarga de funciones, pero no tocaremos este tema. También en C++ existe una forma bastante interesante de implementar el polimorfismo estático utilizando macros, pero este aspecto merece un artículo aparte y, debido a las características específicas de su uso, tiene poco interés práctico.

Polimorfismo

El polimorfismo brinda la capacidad de combinar diferentes tipos de comportamiento usando una notación común, lo que generalmente significa usar funciones para manejar diferentes tipos de datos; considerado uno de los tres pilares de la programación orientada a objetos. Según las características de implementación, el polimorfismo se puede dividir en limitado e ilimitado, dinámico y estático.

El concepto acotado significa que las interfaces están completamente definidas de antemano, por ejemplo, mediante la construcción de la clase base. El polimorfismo ilimitado no impone restricciones de tipo, solo requiere la implementación de una determinada sintaxis.

Estático significa que la unión de interfaces se produce en la etapa de compilación, dinámica, en tiempo de ejecución.

El lenguaje de programación C++ proporciona dinámica limitada polimorfismo cuando se utiliza herencia y funciones virtuales y estática ilimitada – cuando se utilizan plantillas. Por lo tanto, en el marco de este artículo, se hará referencia a estos conceptos simplemente estático y dinámica polimorfismo. Sin embargo, en general, diferentes herramientas en diferentes idiomas pueden proporcionar diferentes combinaciones de tipos de polimorfismo.

Polimorfismo dinámico

Polimorfismo dinámico – la encarnación más común del polimorfismo en general. En C++, esta característica se implementa declarando capacidades generales utilizando la funcionalidad de funciones virtuales. En este caso, en el objeto de clase se almacena un puntero a una tabla de métodos virtuales (vtable), y se llama a un método desreferenciando el puntero y llamando a un método correspondiente al tipo con el que se creó el objeto. De esta manera, puede manipular estos objetos usando referencias o punteros a la clase base (sin embargo, no puede usar copiar ni mover).

Considere el siguiente ejemplo simple: supongamos que haya una clase abstracta Propiedad que describa una propiedad sujeta a impuestos con un único método puramente virtual. getTaxy campo worth, que contiene el costo; y tres clases: CountryHouse, Car, Apartmentque implementan este método determinando una tasa impositiva diferente:

Ejemplo
class Property
{
protected:
    double worth;
public:
    Property(double worth) : worth(worth) {}
    virtual double getTax() const = 0;
};
class CountryHouse :
    public Property
{
public:
    CountryHouse(double worth) : Property(worth) {}
    double getTax() const override { return this->worth / 500; }
};

class Car :
    public Property
{
public:
    Car(double worth) : Property(worth) {}
    double getTax() const override { return this->worth / 200; }
};

class Apartment :
    public Property
{
public:
    Apartment(double worth) : Property(worth) {}
    double getTax() const override { return this->worth / 1000; }
};


void printTax(Property const& p)
{
    std::cout << p.getTax() << "\n";
}

// Или так

void printTax(Property const* p)
{
    std::cout << p->getTax() << "\n";
}

int main()
{
    Property* properties(3);
    properties(0) = new Apartment(1'000'000);
    properties(1) = new Car(400'000);
    properties(2) = new CountryHouse(750'000);

    for (int i = 0; i < 3; i++)
    {
        printTax(properties(i));
        delete properties(i);
    }

    return 0;
}

Si miras “debajo del capó”, puedes ver que el compilador (en mi caso, gcc) agrega implícitamente un puntero a una tabla virtual al comienzo de la clase Propiedad e inicializa este puntero de acuerdo con el tipo deseado en el constructor. . Y así es como se ve un fragmento con una llamada al método getTax() en código desensamblado:

mov     rbp, QWORD PTR (rbx); В регистр rbp помещаем указатель на объект
mov     rax, QWORD PTR (rbp+0); В регистр rax помещаем указатель на vtable
call    (QWORD PTR (rax)); Вызываем функцию, адрес которой лежит по адресу, лежащему в rax (первое разыменование даёт vtable, второе – адрес функции.

Polimorfismo estático

Pasemos finalmente a la parte más interesante. En C++, las plantillas son un medio de polimorfismo estático. Sin embargo, esta es una herramienta muy poderosa que tiene una gran cantidad de aplicaciones, y su consideración y estudio detallado requerirán un curso de capacitación completo, por lo que a los efectos de este artículo nos limitaremos a una consideración superficial.

Reescribamos el ejemplo anterior usando plantillas y, al mismo tiempo, con fines de demostración, usaremos el hecho de que esta vez usamos polimorfismo ilimitado.

Ejemplo
class CountryHouse
{
private:
    double worth;
public:
    CountryHouse(double worth) : worth(worth) {}
    double getTax() const { return this->worth / 500; }
};

class Car
{
private:
    double worth;
public:
    Car(double worth) : worth(worth) {}
    double getTax() const { return this->worth / 200; }
};

class Apartment
{
private:
    unsigned worth;
public:
    Apartment(unsigned worth) : worth(worth) {}
    unsigned getTax() const { return this->worth / 1000; }
};

template <class T>
void printTax(T const& p)
{
    std::cout << p.getTax() << "\n";
}

int main()
{
    Apartment a(1'000'000);
    Car c(400'000);
    CountryHouse ch(750'000);

    printTax(a);
    printTax(c);
    printTax(ch);
    return 0;
}

Aquí he reemplazado el tipo de devolución. Apartment::GetTax(). Dado que gracias a la sobrecarga del operador >> la sintaxis (y, en este caso, la semántica) se mantuvo correcta, este código se compila con bastante éxito, mientras que el aparato de función virtual no nos perdonaría tal libertad.

En este caso, como se espera al usar plantillas, el compilador creó una instancia (es decir, creó a partir de una plantilla sustituyendo parámetros) tres funciones diferentes y sustituyó la necesaria en la etapa de compilación; por lo tanto, el polimorfismo basado en plantillas es estático.

Como señalé en la introducción, STL es un buen ejemplo del uso de polimorfismo estático. Por ejemplo, así es como se ve una implementación simple de la función: std::for_each:

template<class InputIt, class UnaryFunc>
constexpr UnaryFunc for_each(InputIt first, InputIt last, UnaryFunc f)
{
    for (; first != last; ++first)
        f(*first);
 
    return f; 
}

Al llamar a una función, solo necesitamos proporcionar objetos para los cuales la sintaxis de las operaciones presentes en el cuerpo de la función será correcta (además, dado que se pasan parámetros y se devuelve el resultado, se debe definir un constructor de copia (mover) para ellos por valor). Sin embargo, debe entenderse que la plantilla solo especifica la sintaxis, por lo que las inconsistencias entre la sintaxis aceptada y la semántica pueden conducir a resultados inesperados. Así, por ejemplo, es natural suponer que *first no cambia primero, aunque no existen restricciones sintácticas al respecto.

Conceptos

El aparato conceptual introducido en el estándar hace relativamente poco tiempo (comenzando con C++20) puede ayudar a fortalecer un poco los requisitos para los tipos en línea. En principio, se podría haber logrado un efecto similar antes de usar el principio SFINAE (el fallo de sustitución no es un error) y herramientas derivadas como std::enable_if, pero su sintaxis es bastante engorrosa y el código resultante no es muy agradable de leer. . El uso de conceptos, en particular, le permite obtener un mensaje de error mucho más transparente cuando intenta utilizar un tipo inapropiado.

En nuestro ejemplo simple, el concepto podría verse así:

template <class T>
concept Property = requires (T const& p) { p.getTax(); };

Y la declaración printTax:

template <Property T>
void printTax(T const& p);

Ahora, si intentamos pasar un int como parámetro, obtendremos un mensaje de error muy preciso:

<source>:46:13: error: no matching function for call to 'printTax(int)'
   46 |     printTax(5);
      |     ~~~~~~~~^~~
<source>:34:6: note: candidate: 'template<class T>  requires  Property<T> void printTax(const T&)'
   34 | void printTax(T const& p)
      |      ^~~~~~~~
<source>:34:6: note:   template argument deduction/substitution failed:
<source>:34:6: note: constraints not satisfied

Conclusión

Por supuesto, cada método es bueno en algunos aspectos y no tan bueno en otros. Así, el uso de plantillas permite ahorrar un poco de tiempo a la hora de ejecutar un programa, pero también tiene sus desventajas inherentes a las plantillas. Por lo tanto, cada instanciación de la misma plantilla con diferentes tipos crea una función separada, que puede aumentar significativamente el tamaño del código ejecutable. Además, el propio mecanismo de plantilla, tal como existe en el lenguaje, requiere que su código fuente esté disponible en la etapa de compilación, lo que también crea sus propios inconvenientes.

Eso es todo por hoy, espero que el lector haya aprendido algo nuevo de este artículo o haya actualizado uno antiguo ya olvidado.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *