jueves, 13 de marzo de 2014

El autocompletado y yo (capítulo 2)

Ya comenté hace mucho cómo funcionaba el sistema de autocompletado en ZinjaI, y cuales eran sus limitaciones. Básicamente, por un lado tengo a cbrowser, una herramienta que analiza los fuentes y me dice qué cosas se definen. Su parseo es mucho más rápido que el de un compilador, ya que no usa toda la información disponible, pero aún así no es tan rápido como para aplicar en tiempo real. Por eso solo se aplica cada vez que se guarda un archivo fuente, completando con su información el árbol de símbolos. Luego, como el autocompletado es necesario mientras estamos editando un fuente, tiene que haber otro mecanismo en tiempo real para compensar la falta de actualización de esa información. Y además, mientras lo editamos, el código no es válido, está a mitad escribir, y en ese caso el análisis de cbrowser tampoco funcionaría. Entonces tengo un mecanismo alternativo que analiza un fuente abierto, mirando solo ese fuente (y solo cerca de donde estemos editando), y aplicando una heurística propia y poco fiable, pero muy rápida y tolerante a errores de sintaxis. Esencialmente se encarga de dos cosas: tratar de averiguar de qué tipo es una variable mirando si hay algo que pueda ser su definición (sino se buscará en el árbol de símbolos), y averiguar qué representa hasta el momento una expresión a mitad escribir.

Hace un tiempo que me decidí a reescribir este último mecanismo. Lo quise reescribir porque estaba fragmentado en diferentes lugares de ZinjaI y entonces había algunas funcionalidades repetidas, y otras difíciles de reutilizar. Mi idea era mejorar el diseño para agregar algunas mejoras muy útiles, y de paso tratar de que todo se analice con el mismo código para simplificar el mantenimiento y acotar los problemas. En este artículo entonces voy a describir los problemas a los que me enfrento al hacer esto, y qué hay de nuevo para la próxima versión de ZinjaI.

Imaginen por ejemplo que el usuario ingresa un punto. Si lo que había antes es un objeto, tengo que mostrar la lista de métodos (si era parte de un flotante por ejemplo no). Entonces podría extraer la palabra que hay antes del punto, usar el primer mecanismo propio para averiguar su tipo y preguntarle al parser si conoce esa clase para que me diga sus métodos. Pero esa palabra podría ser un atributo de otra clase, entonces hay que ver qué hay antes de esa palabra, y así sucesivamente. En otros casos (no en el del punto), podría no ser un objeto, sino una clase, un puntero a función, el identificador de una macro de preprocesador, un namespace, etc. Pero esos casos también podrían identificarse, y seguir con esta idea de ir de atrás para adelante. Porque ir de atrás para adelante, aunque no da garantías de correctitud de ningún tipo, es mucho más rápido que analizar todo desde el comienzo.

El primer problema aparece con los operadores: corchetes y asteriscos, etc. Por ejemplo: ¿qué pasa cuando me encuentro un paréntesis o un signo de mayor? El paréntesis me puede hacer pensar que lo que había antes era una función, entonces busco la palabra antes del paréntesis para ver cual es. Pero también puede ser un constructor o un cast; o también puede ser simplemente una expresión entre paréntesis, y no importar lo que había antes. Con el signo mayor pasa algo similar: puedo indicar el final del argumento de un template, o el operador mayor, o el de redirección de flujo/desplazamiento de bits, etc. Y distinguir los casos del mayor es mucho más complicado que los casos de los paréntesis, porque involucra saber si lo que tiene al lado son operandos. Pero esos operando pueden ser expresiones, y las expresiones pueden involucrar casteos implícitos, argumentos por defecto en llamadas a funciones, sobrecarga de operadores, etc. Entonces el análisis se complica mucho mucho.

Ejemplo:
#define cadena string // macro simple (id=tipo)
#define saludo string("Hola Mundo") // macro compleja (id=expresion)
string bah() { ... } // función que retorna string
class Foo { // clase con operadores que retornan string

    ...
    string operator()(int,float) { ... }
    string operator[](int x) { ... }
};
template<class T> struct Templ { // clase templatizada

    ...
    T t1;
};
int mee(string *r, string &bla, int i) {
    Foo f(1,2.5); // para sobrecarga de operadores
    Templ<string> temp; // clase genérica
    vector<string> v(10,"A"); // vetor stl

    cadena cad; // cadena es una macro, equivale a string
    // al final de todas estas lineas debiera mostrarse el 

    // autocompletado con los métodos de string
       // las que ya funcionaban y siguen funcionando
    string::               // operador de scope
    bla.                   // objeto
    cad.                   // macro simple
    r->                    // puntero
    r[2].                  // arreglo
    (*r+i).                // arreglo
    (bla+"hola").          // expresión entre paréntesis 
       // las que no funcionaban y ahora sí funcionan en casos simples
    bah().                 // llamada a función 
    bla.replace(1,3,"*").  // llamada a método 
    string("adfsd").       // constructor 
    f().                   // sobrecarga del ()
    f[3].                  // sobrecarga del []
      // las que siguen sin funcionar 
    saludo.                // macro compleja
    temp.t1.               // template 
    v[i].                  // template
    Templ<string>.t1.      // template 
    ...
}
Entre estos ejemplos hay 7 casos que ya se resolvían correctamente, 5 casos en los que
el sistema de autocompletado ha mejorado, y 4 que siguen sin funcionar.

ZinjaI ahora tiene un código para cuando cree que hay un tipo de dato, otro para cuando cree que hay una función o método, otro para cuando espera un objeto, y todos ellos tienen tareas comunes y repetidas. Por eso quiero unificar, y hacer un sólo código que me diga qué hay, sin suponer nada (porque muchas veces las suposiciones son falsas). Pero hacerlo bien implica lo que dije antes, que nos llevaría a parsear todo (hasta el contenido de los includes), y sería lento y complicado. Tan complicado que sería mejor dejárselo a un compilador. Pero esto tampoco es bueno. Alguna vez mencioné que podía usar clang para que me cante la justa. Pero eso solo funciona si el código es correcto, y mientras el usuario edita el programa, no es correcto. Van a faltar includes, puntos y comas, va a haber errores de tipeo, etc. Por eso insisto con una heurística propia. Al no poder ni querer manejar tanta complejidad, el autocompletado mentirá o fallará en casos complicados. Pero al basarse en reglas heurísticas, al guiarse por las apariencias, será más tolerante a errores y muchísimo más rápido que las otras opciones.

Ese siempre fue el objetivo. Solo que "casos complicados" abarcaba demasiadas cosas. Siempre pienso en el estudiante. Si el autocompletado le miente a un programador experimentado, este se da cuenta y lo ignora. Si el autocompletado le miente a un estudiante, lo confunde. Aunque por otro lado, el estudiante no intenta hacer construcciones tan raras adrede, por lo que podría conformarme con que ande bien en los casos no tan "complicados". Pero hasta ahora había casos muy comunes para el usuario que para el autocompletado eran complicados. Algunos son difíciles de evitar, a pesar de ser increíblemente comunes. Por ejemplo, si tengo un "vector<string> v", al escribir "v[i]." debería autocompletar con los métodos de la clase string, pero detectar eso implica ser consiente del template, de su especialización y de la sobrecarga del operador []. Tres cosas que ZinjaI hasta ahora no sabía (por limitaciones tanto propias como de cbrowser). A partir de ahora, por lo menos entederá una de esas tres, la sobrecarga del operador. De forma similar ocurre con las funciones que devuelven objetos. Si hay sobrecargas, parámetros por defecto, conversiones implícitas en las llamadas, etc, la cosa se complica. Pero si hay una sola, o todas devuelven lo mismo por ejemplo, todo ese análisis no es necesario, y la respuesta es bastante fácil.

En definitiva, estos son los casos que van a mejorar en la próxima versión. Ya tengo algunos implementados y los estoy probando para luego publicarlos. ZinjaI va a poder determinar los tipos de retorno de las llamadas a funciones y la utilización de sobrecarga de algunos operadores particulares en los casos más simples. Por suerte esos son casos mucho más frecuentes que los complicados, y me consta que pueden marcar una buena diferencia. El gran talón de Aquiles que resta por mejorar está en el uso de templates (algo para nada menor), y que con la evolución de C++11 se ha vuelto todavía más útil y todavía más complicado. Por esa clase de cosas, no reestructuré por completo el mecanismo actual tanto como planeaba, sino que me detuve a analizar otras opciones muy diferentes que quedarán para discutir en la parte 3.

No hay comentarios:

Publicar un comentario