martes, 3 de febrero de 2015

Aprendiendo a programar... otra vez

Durante el año pasado aprendí muchísimo sobre C++. Leí cuanto blog especializado me pasó cerca, y miré cuanta charla encontré en Internet de gente que está en la cresta de la ola. Entendí detalles muy finos que desconocía de C++03, traté de incorporar todo lo que pude del nuevo C++11, y hasta algunas cosas muy frescas de C++14. Y mi forma de encarar este nuevo contenido fue variando hasta dar un giro de 180 grados. Empecé pensando que sabía programar (con el viejo C++, y también hablando en general) y que el proceso consistiría en aprender solo otro lenguaje. Pero terminé dándome cuenta que en realidad aprendí a programar "otra vez". O mejor dicho, que recién ahora estoy aprendiendo a programar como se debe.

Siempre me gustó C++ porque soy de los que les gusta pensar en lo que ocurre a bajo nivel, detrás de las cortinas, lo que realmente hace el programa una vez compilado, y poder controlar hasta el más mínimo detalle (aún cuando es absolutamente innecesario en la mayoría de los casos). Y sin embargo, este aprendizaje me llevó a desviar el foco hacia niveles de abstracción mucho más altos, a plantear verdaderos problemas de ingeniería de software, y analizar códigos y diseños desde una perspectiva nueva y muy diferente, donde la eficiencia es solo una consecuencia, pero lo importante pasa por otro lado. Lo importante pasa por garantizar, por ejemplo, que una biblioteca sea fácil de utilizar bien, presente un interfaz intuitiva, autoexplicada, permita escribir de forma simple las operaciones simples, etc. Y sea además difícil o imposible de utilizar incorrectamente, siendo una de las principales tareas del compilador el hacer cierta esta última afirmación. Y todos los detalles finos de C++ y todas las cosas rebuscadas que permite hacer a bajo nivel solo sirven para garantizar que lo anterior se cumpla, y encima, para que todo funcione de la forma más eficiente posible.

Veamos un "caso de estudio" para dar un ejemplo concreto: los infames punteros inteligentes. La idea de un puntero inteligente es gestionar la memoria dinámica de una forma bastante automática, evitando algunas complicaciones y los errores que estas complicaciones suelen generar. Con un puntero de los viejos, uno puede pedir al sistema operativo un bloque de memoria para hacer algo (el new), pero haciéndose responsable de devolverlo más tarde (el delete). Olvidarse de devolverlo es un error grave. Un puntero inteligente, en principio tiene mecanismos para devolverlo automáticamente y evitar ese error. Por ejemplo, el puntero real se encapsula en un objeto no dinámico (que se aloja en el stack, y es el "puntero inteligente"), y que por ende será destruido automáticamente, liberando mediante su destructor la memoria de la cosa apuntada:
    template <class T>
    class PunteroInteligente {

        T *ptr; // el verdadero puntero, oculto

    public:
        PunteroInteligente(T *p) : ptr(p) {} // toma la dirección
        T * const operator->() const { return ptr; } // para poder usarlo
        ~PunteroInteligente() { delete ptr; } // hace el delete

    private: // bonus: imposible de copiar, para evitar el doble delete
        PunteroInteligente(const PunteroInteligente &) {}
        void operator=(const PunteroInteligente &) {}

    };

    void HaceAlgoConUnaCosaDinamica() {
        PunteroInteligente<Cosa> pi( new Cosa(...) ); // crear el objeto
        pi->HacerAlgo(); // usarlo para algo
    } // fin, no hay delete, el destructor de pi se encarga
Entonces empecé a ver que en C++11 aparecen algunas clases de este estilo, como unique_ptr y shared_ptr. Lo primero que pensé fue: bueno, veamos cómo se usa cada una y dónde difieren. El uso es muy simple y similar en ambas, y básicamente la primera se usa cuando queremos representar un objeto dinámico apuntado por un único puntero (simplificando mucho, similar al ejemplo anterior) y la otra para cuando queremos que lo puedan apuntar varios a la vez. En el segundo caso la lógica interna es más complicada que en el primero porque se necesita de alguna forma llevar una cuenta de cuantos punteros apuntan a cada objeto para que solo el último lo destruya realmente. Entre ambos, de alguna forma eliminan muchos de los casos donde antes se podía llegar a querer usar un recolector de basura (buh!) como tienen otros lenguajes, y con una solución digamos que más determinista.

Pero esta es una visión bastante incompleta y sesgada de la historia de los punteros "inteligentes", donde nos perdemos de vista lo principal: el nuevo puntero no solo simplifica el manejo de memoria, sino que me da una mecanismo para representar y expresar la "propiedad" de la misma, de forma que el compilador la conozca, y pueda entonces ayudarme a asegurar que nadie intente cambiarla de forma indebida. Por propiedad entiéndase quién es el dueño y, sobre todo, el responsable de dicha memoria. Por ejemplo, el unique_ptr, además de gestionar la liberación, me dice que ese objeto no es apuntado por nadie más, y se asegura de ello ocultando el puntero real y evitando la posibilidad de hacer copias, que cualquier intento de contradecir esa regla sobre su propiedad resultará en un error de compilación. Sumado a las move semantics (resumen bizarro: constructores que mueven los datos de un objeto a otro en lugar de copiarlos) me permite además reforzar requisitos sobre una interfaz.

Por ejemplo, si una función recibe un puntero común, yo como usuario (no autor) de la misma puedo preguntarme si esa función se va a hacer cargo de esos datos apuntados que le paso (y de hacer el delete), si solo va a consultarlos mientras se ejecuta, si va a guardarse una copia en algún lado y entonces yo ya no voy a poder hacer un delete tan ligeramente después de que retorne, etc. Son preguntas importantes, y la respuesta debería estar en la documentación de la función. Dependerá entonces de quien haga el programa cliente que la lea atentamente y no meta la pata. En cambio, con los punteros inteligentes que ofrece C++11, esas cosas quedan explícitas en el prototipo de la función. No hace falta buscar la documentación, ahora es obvio que, por ejemplo, si una función recibe un unique_ptr se va a hacer cargo sí o sí de esa memoria y de liberarla cuando ya no se use, porque no puede haber nadie más apuntándola desde afuera (por eso es unique). Y además de ser obvio con solo mirar el prototipo, lo cual es muy cómodo y rápido para el programador, ahora es también información tenida en cuenta por el compilador (un hurra por el tipado estático), que no va a dejar que le pasemos un puntero común, o una copia de otro unique_ptr, sino que solo va a dejar que movamos los datos desde un ptr del cliente hacia adentro de la función, dejando aún más explícito el cambio de propiedad:
    void FuncionQueSeQuedaConLosDatos(unique_ptr<Cosa> x);

    void Cliente() {

        auto p = make_unique<Cosa>(...);
        // la linea anterior equivale 100% a esta que sigue:
        // unique_ptr<Cosa> p ( new Cosa(...) );

        // ...hacer algo con la Cosa p... etc... etc...
        
        if (pasa_algo) {
            
            FuncionQueSeQuedaConLosDatos(p); // ERROR
            // no compila, no se permite copiar el puntero
            
            FuncionQueSeQuedaConLosDatos( move(p) ); 
            // ahora sí compila, y está claro que los datos se 
            // mueven desde p hacia adentro de la función
            // ahora p perdió la propiedad, y equivale a NULL

        }

    } // si no entró al if, acá p hace el delete, pero si había
      // entrado al if ya no es su responsabilidad (no hace nada)
Y así, vemos que los detalles de implementación de unique_ptr y shared_ptr están allí no solo para ser eficientes, sino también para garantizar que el compilador haga respetar las reglas que rigen la propiedad sobre el objeto. Las función es ahora muchísimo menos propensa a errores, más autodocumentada, e igual de eficiente que antes. Se podrían proponer ejemplos análogos con shared_ptr, la idea es la misma. Pero aquí hay una ventaja más. Verán que en el ejemplo anterior usé make_unique en lugar de hacer explícito el new, y auto para no repetir lo de "unique" dos veces. Hay aquí varias funcionalidades nuevas de C++11 y C++14: la clase unique_ptr, la función make_unique, y los variadic templates y el perfect forwarding, los últimos invisibles pero necesarios para que make_unique funcione con cualquier clase y sin perder eficiencia en comparación con un new común.
    extern Foo *otra_clase_que_anda_por_ahi; // objeto global (buh!)

    // Función que "también" podría apuntarle a la Cosa
    void Func(shared_ptr<Cosa> x) {
        // ...blah blah...
        if (algo) {
            // ...blah blah...
            otra_clase_que_anda_por_ahi->TomaUnaCosa(x); 
            // supongamos que esta otra clase se queda con una copia
            //  del puntero (obviamente también recibe un shared_ptr)
        }
        // ...blah blah...
    }
    
    void Cliente() {
        auto p = make_shared<Cosa>(...); // creo la Cosa
        // ...blah blah...
        Func(p); // la función se copia el puntero, tal 
                 // vez lo mantenga o tal vez lo tire
        // ...blah blah...
    } // si la función no guardó una copia, la Cosa se destruye aquí...
      // pero si la función Func entró en el if(algo) entonces no...
      // en ese caso la destruirá la otra_clase_que_anda_por_ahi
En el primer caso, usar el make_unique o el new daba lo mismo 100%. Pero se recomienda el make_unique. En el caso del shared_ptr, usar make_shared o no da "casi" lo mismo. Si usamos el new estamos haciendo en realidad dos news, uno es el que explicitamos para la nueva Cosa, y otro que hace por dentro en su constructor el shared_ptr (lo necesita para su lógica interna). Si usamos make_unique, se hace un solo new que reserva en una sola operación memoria contigua para ambas cosas. Entonces, la nueva forma de escribir lo mismo, no es solo para ser "fancy" y hacerse el moderno, sino que es objetivamente mejor en términos de rendimiento (nos ahorramos un new, y los new son caros; lo mismo pasa con el delete, además de acomodar las cosas de forma cache-friendly). Es fascinante analizar todos estos detalles ocultos de bajo nivel que hacen estos trucos no solo posibles, sino también súper-eficientes. Pero la verdadera revelación está en las abstracciones que en consecuencia me permiten expresar con tranquilidad. Antes las podía pensar y gestionar con mucho cuidado yo por mi cuenta, pero ahora las puedo escribir, de forma que cualquier otro programador las entienda, y en especial que el compilador las entienda, las garantice, y hasta las aproveche para generar mejores ejecutables.

Y entonces digo que aprendí a programar otra vez, porque ahora al escribir una biblioteca no pienso solo en resolver un problema, sino en todo lo que el lenguaje me permite expresar, y todos los mecanismos que tengo para hacer que esa biblioteca sea más fácil de utilizar y reutilizar, de generalizar, de aprender, de extender, de analizar y modificar, etc, etc, etc. Y es culpa de C++, de su historia y evolución, que contribuyeron para que mi interés se despertara y mi cabeza se abra de a poco. Pero la idea debería aplicarse a cualquier lenguaje que permita hacer algo medianamente complejo. En definitiva, sigo pudiendo escribir los mismos algoritmo que escribía antes, pero el código ahora es otro, mucho más expresivo y con menos errores. Ahora analizo patrones de diseño con otros ojos, encaro de otra forma los nuevos proyectos, y hasta organizo diferente mi código, dándole más valor a estas cuestiones de legibilidad y al poder recibir mucha más ayuda del compilador, cosas que se pagan solas a largo plazo. Y todo sabiendo además que C++ en particular me da las herramientas necesarias para que ese aumento en el nivel de abstracción no se pague con eficiencia de ningún tipo. Porque aunque la superficie se vea tan diferente, debajo del capot, el motor sigue funcionando tan rápido como siempre, incluso a veces más.

No hay comentarios:

Publicar un comentario