jueves, 6 de noviembre de 2014

SFINAE: Magia con templates en C++ (parte 1)

SFINAE son las iniciales de "Substitution Failire Is Not An Error" (un fallo en una substitución no es un error). Así se llama informalmente a un conjunto de situaciones muy particulares en C++, donde un error que resultaría de intentar especializar un template es ignorado silenciosamente durante la compilación. Si combinamos esto con los mecanismos de sobrecarga y resolución de nombres, podemos lograr cosas muy raras, como preguntar si una clase existe, si tiene cierto método, o si se puede usar de tal forma. Con algunos pocos trucos adicionales podemos tener código que se ejecute solamente si es válido, pero si no lo es (y esto es lo interesante) no genere errores de compilación, sino que utilice un camino alternativo. Llegué a esto tratando de armar ejercicios de programación que se califiquen a sí mismos (para hacer evaluación continua sin perder mucho tiempo de clases), y como el resultado es técnicamente muy interesante, aprovecho la excusa de siempre para documentarlo.

Empecemos por esto de "sfinae". Podemos programar distintas sobrecargas (aún siendo genéricas) para una misma función. Al invocarla, el compilador selecciona de entre las versiones disponibles la que se ajuste mejor al tipo con que se la invoca. Sin entrar en detalles, "que se ajuste mejor" depende de si el tipo o su forma coincide exactamente, si requiere conversiones implícitas, si la función usa cosas raras como argumentos variables, si hay una implementación particular para ese tipo, etc... Hay una tabla en el estándar C++ con los detalles, pero no es necesario profundizar tanto, veamos mejor un ejemplo. Si tenemos:

    template<typename T> void foo(T x);
    template<typename T> void foo(T *x);
    void bar(int x) { foo(x); foo(&x); }


La primer llamada a foo solo puede resolverse con el primer template (con T=int), ya que el segundo tiene sí o sí un * y entonces no hay con qué reemplazar T para obtener un argumento de tipo int. Para la segunda llamada podrían especializarse cualquiera de los dos: T=int* para la primera, T=int para la segunda. El compilador utilizará la segunda porque en realidad ve a la segunda como una especialización parcial de la primera (un caso más específico, una versión particular solo para cuando recibe un puntero). Notemos que aquí, cuando en la primer llamada la segunda especialización falla (substitución es en realidad la palabra correcta) el compilador simplemente la ignora; no genera errores, porque ve que hay otra que sí funciona. De esto nos podemos aprovechar para hacer cosas raras pero útiles.

Supongamos que necesitamos una función que nos diga cuantos elementos hay en una estructura de datos, pero necesitamos que la estructura de datos pueda ser de diversos tipos. Por ejemplo, en un arreglo estático el tamaño es conocido (se puede obtener con sizeof), en un contenedor stl usamos el método size(), en una clase contenedora nuestra podemos haber definido un método VerCant(), etc. Empecemos por el contedor stl, la función sería:

    template <typename T> void foo(T &c)
        { cout<<c.size()<<endl; }


Obviamente, si el objeto no tiene un método size() esto no compila. Para el arreglo estático podemos hacer un truco del que me avivé hace muy poco, a pesar de que no tiene nana tan raro. Sabemos que el argumento del template no tiene que ser necesariamente un tipo genérico, sino que puede ser también una constante de algún tipo en particular (por ejemplo un int). Entonces, hacemos una función para arreglos estáticos de N elementos y ponemos ese N como argumento del template:

    template <typename T,int N> void foo(T (&c)[N])
        { cout<<N<<endl; }


Esta segunda versión es diferente de la primera, y con las reglas de "que se ajusta mejor" se elige solo cuando lo que le pasamos es un arreglo estático, por lo que no genera conflicto con la anterior. Pero ¿qué pasa con la versión para el contenedor propio y su método VerCant()?:

    template <typename T> void foo(T &c)
        { cout<<c.VerCant()<<endl; }


Esta versión sí entra en conflicto con la primera porque ambas tienen exactamente el mismo prototipo, así que no se podrán compilar juntas. Hay que modificar el prototipo para que allí diga que la clase debe tener tal o cual método, para que así puedan diferenciarse. Y es en el prototipo donde la diferencia debe estar, en la cantidad o en los tipos de los argumentos, y no en los valores por defecto que le podamos dar a los mismos. Para lograrlo ("colar" el requerimiento en el tipo de un argumento) hacemos un truco que empieza definiendo un struct auxiliar, también genérico, que se especializa con dos argumentos, un tipo y un entero. Adentro, el struct tiene un typedef que equivale al tipo:

    template<typename T, int N> struct Aux
        { typedef T tipo; };


Entonces ahora, en lugar de usar "float x" para declarar un real x, puedo usar "Aux<float,42>::tipo x". Es decir, especializar el struct Aux con float y pedirle el typedef. Entonces, podemos reemplazar el argumento "T &c" de nuestras funciones foo por "Aux<T,42>::tipo &c", y siguen siendo la misma cosa. Si me siguen hasta aquí, ya se habrán preguntado por ese int/42 extra en el template. Bien, vamos a cambiar el 42 por algo que falle cuando la clase no tenga lo que queremos. Por ejemplo, si necesitamos un método size(), podríamos obtener un puntero al mismo, y preguntar cuanto mide con sizeof. Toda la pregunta debe poder responderse en tiempo de compilación, y así, podemos usar ese supuesto resultado como entero para especializar Aux:

    template<typename T>
        void foo(typename Aux<T,sizeof(&T::size)>::tipo &c)
             { cout<<c.size()<<endl; }


Esto solo será válido con T=algo cuando es algo tenga un "::size". Podemos hacer lo mismo para el contenedor propio, y dejar el prototipo original para cuando no se sabe qué es:

    template<typename T>
        void foo(typename Aux<T,sizeof(&T::size)>::tipo &c)
            { cout<<c.size()<<endl; }
    template<typename T>
        void foo(typename Aux<T,sizeof(&T::VerCant)>::tipo &c)
            { cout<<c.VerCant()<<endl; }
    template <typename T,int N>
        void foo(T (&c)[N])
            { cout<<N<<endl; }
    template <typename T>
        void foo(T &c)
            { cout<<"No se que hacer"<<endl; }


El único inconveniente es que hay que explicitar el tipo al invocarlas, ya que deja de ser automática la deducción de T:

    vector<int> v1; foo< vector<int> >(v1); // usa .size()
    int v2[10]; foo(v2); // usa el 10
    Cont c; foo< Cont >(c); // usa .Cant()
    Otro o; foo< Otro >(o); // muestra el mensaje de error


Este truco es válido en C++99/03, pero en C++11 ya tenemos un equivalente a la clase Aux que se llama "enable_if". En el próximo post les cuento cómo pulir algunos detalles para usarlo fácilmente y de forma bastante prolija en el mundo de la educación, para armar ejercicios de programación que se califiquen automágicamente y  diagnostiquen sus propios errores desde el código, y ya no mediante errores de compilación.

No hay comentarios:

Publicar un comentario