martes, 17 de enero de 2023

Sobrecargando funciones según el tipo de retorno

En C++, la sobrecarga de funciones consiste en tener dos o más funciones con un mismo nombre pero diferentes parámetros (en tipo y/o en cantidad). Al hacer una llamada, el compilador analiza los argumentos y decide a cuál corresponde invocar. Notar que en esta definición no menciono al tipo/valor de retorno, y es porque no sirve para resolver la sobrecarga (¿qué haríamos por ej con cout<<foo()?). Pero a veces sería útil poder hacerlo. Me encontré con esta pseudo-necesidad en PSeInt, y vengo a comentar una solución que me funcionó muy bien.

 

Por completitud, comento que había visto alguna vez otra solución. Que la función retorne un objeto que actúe a modo de proxy entre ella y el destino final del valor de retorno. El objeto debe tener sobrecargados los operadores de conversión a diferentes tipos, y es en esas sobrecargas donde se decide qué hacer. Entonces la función que ve el cliente es siempre la misma, y solo crea el proxy, pero las conversiones (implícitas para el cliente, cuando asigna el valor de retorno a algo) son las que realmente hacen el trabajo diferenciado.

 

Pero en mi caso quería algo más. Algo como:

    auto &det = instruction.getDetails();

En el nuevo código de PSeInt tengo una clase Instruction representa una acción o estructura de control genérica, cualquiera. Todas tienen cosas como una linea de código fuente (std::string), y una referencia a dónde estaba esa linea en el programa (un par de ints), y un enum que dice qué instrucción es:

    enum InstructionType { IT_LEER, IT_ESCRIBIR, IT_ASIGNAR, ... };

Pero luego Instruction también tienen un std::variant con los detalles de la instrucción en particular. Si es un Leer, hay un vector de variables a leer; si es un Mientras hay una condición y una referencia a la posición del FinMientras; si es un Para hay un un contador, un paso, valores inicial y final, etc.

El pseudocódigo parseado se guarda en memoria como un std::vector<Instruction> y las funciones que lo procesan suelen tener un switch que según el atributo type deciden qué hacer:

    for (Instruction &inst : programa) {
        switch (inst.type) {
            case IT_LEER:
                ...
            case IT_ESCRIBIR:
                ...
 

Y lo que quiero en cada case es obtener los detalles:

    case IT_ASIGNAR: {
        const auto &det = inst.
getDetails(); // det guarda variable y valor a asignar
        memoria->Set(det.variable, evaluarExpresion(det.valor) );
        ...

Y es ese getDetails el problema. No puede el método retornar algo de diferente tipo en cada llamada.

 

Sin embargo, hay un dato importante: cuando le pido los detalles, ya se de qué instrucción se trata. Voy a usar eso para resolver la sobrecarga. La clave es que el valor de type es conocido en tiempo de compilación (y en el fondo es un entero), por lo que se puede usar como argumento de template:

  case IT_ASIGNAR: {
        const auto &det =
getDetails<IT_ASIGNAR>(inst);
         ...

Entonces getDetails pasa a ser una función libre genérica, wrapper de unos structs genéricos:

    template<int IT_ALGO> struct DetailsHelper { }; // el struct 

    template<int IT_ALGO> auto &getDetails(Instruccion &inst) { // la función wrapper
        _expects(inst.type==IT_ALGO); // verificar precondición (false=bug)
        return DetailslHelper<IT_ALGO>::get(inst); // método static del struct especializado
    }

Y las diferentes sobrecargas serán en realidad diferentes especializaciones explícitas de este struct, donde cambia el método estático que el wrapper necesita:

    template<> struct DetailsHelper<IT_LEER> { // especialización explícita
        static auto& get(Instruction &inst) {
            return std::get<Instruction::ILeer>(inst.details); // acceso al std::variant

        }
    };
    ...así para todos los posibles IT_ALGO...

DetailsHelper debe ser un struct/clase genérica, y no una función, para permitir el truco ¿Recuerdan por ej que std::vector<bool> es diferente a cualquier otra especialización de std::vector? Es la misma idea. Pero aquí el argumento del template no es un tipo, sino un valor (esto se puede para enteros). Y por eso la función wrapper, para esconder los structs auxiliares.

Quedó la "molestia" de tener que decirle a getDetails el valor del enum. Pero eso es en realidad una ventaja, ya que permite verificar que la instrucción pueda darme lo que quiero (el _expects) y deja claro en el código qué espero (para que sea más legible/entendible) sin tener que fijar el tipo de retorno (así la clase Instrucción puede variar más adelante y que a nadie le moleste).

 

Aunque este post se sale de la linea que venían teniendo los anteriores, me pareció suficientemente interesante y útil como para comentarlo. La versión real se puede ver en el repo git.


* Sí, ya se que hay una mezcla de idiomas en los nombres. Hay una mezcla de estilos horrible por todo el código. Cuando vuelva a ser más o menos estable me debería tomar un buen tiempo para definir convenciones coherentes en todo el proyecto y reformatearlo.

3 comentarios:

  1. Aguante mezclar idiomas para los nombres de variables

    ResponderEliminar
  2. No soy ingeniero en informática, pero si en electrónica donde programamos sistemas embebidos, en mi labor como docente dicto cursos básicos de programación computacional y siempre me he preguntado cual será el código resultante (maquina o intermedio) cuando se sobrecargan funciones. No se, si se generan subfunciones cada una que recibe parámetros específicos o una sola con un case interno que selecciona una operación especifica.
    Algún día llegar a un pseudocode que efectué sobrecarga de funciones seria maravilloso para exponer este tipo de operaciones muy útiles a los estudiantes, por ejemplo yo las uso cuando programo funciones matemáticas, un ejemplo función con símbolo (-) con dos argumentos resta de a con b: (a-b) con un solo argumento cambio de signo -(a)

    ResponderEliminar
    Respuestas
    1. Los compiladores de C++ generan diferentes funciones, que hasta tienen diferentes nombres, así que no hay costo extra por invocar a una sobrecarga (no hay switch ni nada parecido). De hecho, una de los principios básicos de c++ es que las abstracciones que ofrece deben tener el mínimo costo posible cuando se usan, y costo cero cuando no. Al compilar se agregan al identificador del código algunas letras más para diferenciar las versiones, y de esta forma dejan de tener el mismo nombre. Supongo que el motivo de esto (se conoce como "mangling") viene de buscar compatibilidad con bibliotecas de C (donde no había sobrecarga) y de querer utilizar un mismo linker.

      Eliminar