domingo, 30 de marzo de 2025

De StackOverflow a ChatGPT: El Arte de Pegar sin Pensar

 Es curioso cómo apenas en Agosto del año pasado critiqué duramente la práctica de copiar código de páginas de internet para pegarlo dentro del proyecto y ahora parece haberse vuelto una práctica obsoleta en favor de generar el copiarlo de ChatGPT.

Esta práctica hereda muchos de los problemas que ya mencioné anteriormente, y dado que no quiero que me califiquen de holgazán por copiar y pegar algo de ChatGPT para una entrada de mi blog, voy a copiar y pegar algo que escribí en otra entrada de mi blog:

  • Copiar y pegar sin entender lo que se está haciendo puede ser una oportunidad desaprovechada para aprender algo y mejorar como desarrollador.
  • Es fácil pasar por alto varios detalles si simplemente se copia y pega, la mente es una experta en engañarnos haciéndonos creer que leyó algo cuando no fue así. El tener que teclearlos forza a asegurarse de que se está leyendo con detenimiento todo (un tip que me dieron en un grupo de escritores es que para revisar un escrito es buena idea leerlo en voz alta, así evitas saltarte cosas accidentalmente).
  • Si en tu programa hay código que no entiendes, en el momento que tengas que hacerle cambios no vas a saber qué hacer.
  • El código escrito dentro de respuestas a preguntas(como en Stackoverflow o incluso el que es escrito por bots de chats) tiene el propósito de ser un código fácil de entender y auto-contenido.
    No tiene como prioridad ser eficiente, ni versatil, ni mucho menos acoplarse al proyecto que estés desarrollando (es decir, seguir su estilo de código, usar las funciones que ya se hayan escrito, y separarse en varias funciones/clases si es pertinente).
    Sería como estar escribiendo una novela, preguntar en un chat de Whatsapp por inspiración, y copiar y pegar directamente los mensajes en lugar de entender lo que dicen y escribir esas ideas siguiendo el tono y narrativa presentes en la novela.
    No hay que olvidar que nuestro código está escrito para ser leido tanto por programas(compiladores/interpretes) como por humanos.
Afortunadamente, la lista de inconvenientes es más corta que la de copiarlos de una página. Sin embargo, esta vez no vengo a disuadirlos de usar LLMs para programar, sino que vengo a decirles cómo usarlos efectivamente para evitar caer en los inconvenientes antes mencionados.

El Contexto lo es Todo

Si simplemente le preguntas a un LLM cómo hacer tal o cual cosa, lo más probable es que te genere un fragmento de código bastante genérico y hasta parecido a un tutorial. Con todos los síntomas de código copiado de Stackoverlow (sin seguir las convenciones del proyecto, reinventando la rueda, haciendo muchas cosas de manera ineficiente, con un montón de comentarios redundantes, etc.).
También he escuchado a bastante gente decir de manera categórica que el código generado por LLMs es in-mantenible, y que para hacer código de calidad mejor mantenerse alejado de los LLMs.
La clave aquí es que el LLM puede generar buen código si sabes cómo pedirselo y para ello hay que darle contexto.
Recomiendo usar alguna extensión de VSCode como Github Copilot o similar, pero incluso sin extensión estos consejos van a servir:
  • Primero que nada, si tienes algún documento sobre linemientos de código en el proyecto, o un archivo de Lint, adjúntalo al prompt.
  • Si hay algún archivo dentro del proyecto que implemente algo parecido, también adjúntalo, e indica dentro del texto del prompt que se base en ése ejemplo.
  • Adjunta los principales archivos de dependencias dentro del proyecto que se puedan necesitar para la tarea en cuestión.
  • Sé muy específico en la tarea que estás pidiendo, recomiendo tener una biblioteca de prompts modificables para el proyecto en cuestión, con cada prompt de longitud de alrededor de media página.

Avanza en Pasos Pequeños

Muchos LLMs tratan de resolver todo el problema cuando se los planteas e implementan demasiados pasos en una sola respuesta.
Sé insistente en que hay que avanzar en pasos pequeños, primero pidiendo algo básico y luego conforme vaya funcionando irle agregando más capas/versatilidad, tal como lo harías si estuvieras escribiendo todo el código tú mismo.
Esto tiene exactamente el mismo efecto que al programar sin la asistencia de un LLM: si haces más cosas sin probar ni experimentar, hay más puntos en los que puede fallar.

No Olvides las Pruebas Unitarias

Una vez que tengas un código funcional, si lo escribiste con ayuda de un LLM es bastante probable que en la conversación tenga suficiente contexto para escribir pruebas unitarias.
Aprovecha eso y pídele pruebas unitarias, asegurate de escribir en las instrucciones que priorice verificar funcionalidad en lugar de implementación y que prefiera fakes en lugar de mocks.
También puedes ir un paso mas allá y preguntarle que fakes le serviría tener para las pruebas y pedirle que los genere(para posteriormente pedirle que genere las pruebas usando dsos fakes).
Al revisar las pruebas unitarias ten mucho cuidado de que no esté usando demasiados mocks y de que realmente esté probando algo (aplica la prueba de comentar bloques de código que debería estar probando a ver si falla alguna de las pruebas generadas).
Las pruebas unitarias para código generado por LLM son aún más importantes que las generadas para código escrito por uno mismo, debido a las razones que expondré a continuación.

Pídele Refactorizar

Una vez que el código esté funcionando, observalo con atención y pídele al LLM que refactorice las partes que no te gusten, si tienes pruebas unitarias esto se va a volver algo bastante sencillo. Sólo corre las pruebas luego de cada refactorización y observa que sigan funcionando.

Borra los Comentarios Innecesarios

Además de los comentarios redundantes (que le deberías de pedir a los LLMs que los omitan salvo que estén escribiendo pruebas), los LLMs a veces dejan comentarios para decir qué partes del código cambiaron, y la gran mayoría de las veces no son algo que a tus compañeros de equipo les importe y mas que ayudarles los puede confundir, así que bórralos antes de subir el código al repositorio.

Genera Documentación

Una vez que el modelo ya entendió y generó lo que querías que generara, hay que aprovechar y pedirle que genere documentación en un markdown. El esfuerzo de realizar este último paso es casi nulo y sus beneficios son inmensos.
Además de ayudarte a ti mismo y al resto del equipo a entender el código, también vas a poder incluir el archivo de documentación en el contexto las proximas veces que le pidas al LLM que genere código.
Por último, después de pedirle que genere documentación, hay que pedirle que busque alucionaciones en la documentación recién generada. Es sorprendente lo bien que funciona pedirle esto último.

¿Hay que Entender el Código Generado?

Hay que entender a gran escala como funciona el proyecto, cuales son las responsabilidades de cada una de las clases y además asegurarse de que las clases no se están saliendo de las responsabilidades que les corresponden, pero hay algunos casos en los que no considero tan necesario conocer los detalles del código:
  • Pruebas unitarias. Mientras pasen la prueba de fallar si introduces intencionalmente errores y sigan los lineamientos, no veo ningún detrimento en obviar los detalles. Después de todo, las pruebas unitarias no son dependencia de nada.
  • Herramientas internas. Las fallas de herramientas internas no suelen ser tan catastróficas como la de código en producción, por lo cual a veces vale la pena asumir el riesgo de que haya errores. Sobre todo si se trata de cosas como un script escrito en un lenguaje con el que no estás familiarizado y que no vas a usar después.
  • Cierto tipo de Refactorizaciones. Así como en algunas refactorizaciones no es necesario entender todo el código (sólo estar seguro que la funcionalidad no cambia), lo mismo aplica al hacer refactorizaciones con asistencia de un LLM.
  • Prototipos que no van a llegar a producción. Igual que con las herramientas internas, si esto no va a llegar al cliente en ése estado, puede valer la pena sólo probarolo para prototipear.

¿Es esto Vive Coding?

Recién se volvió viral y hasta polémico este Tweet

There's a new kind of coding I call "vibe coding", where you fully give in to the vibes, embrace exponentials, and forget that the code even exists. It's possible because the LLMs (e.g. Cursor Composer w Sonnet) are getting too good. Also I just talk to Composer with SuperWhisper so I barely even touch the keyboard. I ask for the dumbest things like "decrease the padding on the sidebar by half" because I'm too lazy to find it. I "Accept All" always, I don't read the diffs anymore. When I get error messages I just copy paste them in with no comment, usually that fixes it. The code grows beyond my usual comprehension, I'd have to really read through it for a while. Sometimes the LLMs can't fix a bug so I just work around it or ask for random changes until it goes away. It's not too bad for throwaway weekend projects, but still quite amusing. I'm building a project or webapp, but it's not really coding - I just see stuff, say stuff, run stuff, and copy paste stuff, and it mostly works.

...y admito que de repente lo hago para generar front end de herramientas internas o para experimentos personales, pero como ya dije anteriormente, para código de producción no sería tan laxo.
Me hizo algo de gracia lo de "embrace exponentials", cuando estudiaba con matemáticos llegue a imaginar que bailaba con la función exponencial... sí... sé que suena raro, pero eso es lo que causa apreciar la belleza matemática. Aunque tengo que admitir que no me agrada del todo esta otra connotación, pero bueno, ¡abracemos exponenciales!







domingo, 16 de marzo de 2025

¿Por Qué Funcionan las Redes Neuronales?

Últimamente he visto unos cuantos malentendidos sobre las redes neuronales y también recuerdo que hace varios años la primera vez que me topé con ellas me parecieron un tanto misteriosas y no ayudaba mucho que la mayoría de textos introductorios que leí en aquel entonces no parecían mostrar razones muy sólidas para usarlas.

Lo más común que veía era una combinación de las siguientes razones:

  • Actúan parecido a las neuronas del cerebro, entonces no es de extrañar que funcionen.
  • Son bastante robustas al procesar información con ruido.
  • No necesitas entender el problema, nomás lanzar datos de entrenamiento y solas hacen todo.
  • Hay un teorema que díce que las redes neuronales pueden aprender cualquier función continúa
La verdad no me atraía la idea de estar usando una caja negra que probablemente sería menos eficiente que programar un algoritmo manualmente; y sobre el último punto, también hay teoremas que dicen que cualquier función continua se puede aproximar por polinomios, o que cualquier función continua periódica se puede aproximar usando senos.

...y si bien lo de procesar información con ruido sonaba bien, tampoco me quedaba claro por qué éso podría ser mejor que entrenar otros modelos de aprendizaje (de preferencia algunos que no fueran tan misteriosos).

Sin embargo, recientemente tras estudiar unas cuantas fuentes, ya soy capaz de entender el potencial que tienen estas herramientas y quería compartir ésto.

¿Imitan a la biología?

Primero que nada quiero desmentir un malentendido que se ha estado propagando bastante, y es la idea de que las redes neuronales imitan al cerebro humano. El único parecido que tienen(además del nombre) es que en ambos casos reciben información de conexiones con sus símiles, realizan una operación sencilla y producen una salida.

Mas allá de ésa similitud en la unidad más pequeña de ambas redes, no se parecen en nada más. Las conexiones de las redes neuronales artificiales se entrenan en base a un montón de ejemplos para que dadas ciertas entradas produzcan ciertas salidas, pero en ningún momento se intenta imitar la estructura de un cerebro.

Incluso, me atrevo a hacer notar que un transistor se parece más a una neurona biológica que una neurona de red neuronal artificial(porque tanto el transistor como la neurona biológica reciben señales eléctricas de entrada y producen señales eléctricas de salida, pero las redes neuronales artificiales son puro software en lugar de operar directamente con señales físicas).


Clasificadores Lineales

Esto podría parecer que no tiene nada que ver con las redes neuronales, pero prometo que tendrá sentido más adelante.

Los clasificadores lineales son un tipo de método de aprendizaje automático bastante clásico, la idea es que conocen 2 conjuntos de puntos, que pertenecen a 2 clases distintas, se espera que en el futuro se van a recibir más puntos, pero con estos otros va a ser necesario intentar identificar a qué clase pertenecen (es decir, la clase ya no va a ser conocida con los puntos provistos en el futuro).

Aunque nos vamos a imaginar a los puntos como coordenadas en un plano, en la práctica los puntos pueden representar cualquier conjunto de rasgos numéricos. Como las dimensiones de un terreno, datos demográficos de una persona o incluso los datos de los colores de los pixeles de imágenes.

Un clasificador lineal lo que hace es encontrar una recta que separe lo mejor posible a ambos conjuntos y luego asumir que todo lo que llegará de un lado de la recta pertenecerá a una clase y lo que llegue del otro lado pertenecerá a la otra clase.

En el siguiente ejemplo, la recta


Divide el plano en 2 mitades:



Otra forma de entenderlo, y que será importante más adelante, es que los puntos que están dentro de un semiplano los agrupa en una clase y los que están fuera del semiplano los agrupa en la otra clase. Suena que es una cuestión puramente semántica, pero tener presente el conjunto del semiplano será importante.

Cuando los datos de entrada contienen 3 variables, el problema se convierte en encontrar un plano que divida al espacio en 2 mitades, lo cual se puede ver como elegir los puntos que estén en la región del espacio que satisfaga:


Extrapolando la generalización a vectores de cualquier tamaño es bastante directa:


De esta manera, es posible generalizar los clasificadores lineales a vectores de cualquier tamaño. Existen varias maneras para entrenar estos clasificadores, pero no ahondaré en ello ya que lo que nos importa ahora es su relación con las redes neuronales.

Compuertas lógicas

Otro elemento importante en este contexto son las compuertas lógicas. Se trata de componentes electrónicos que implementan lógica básica en circuitos digitales.

Estas compuertas operan tomando señales eléctricas de entrada, con 2 posibles intensidades y transformándolas en una señal eléctrica de salida según las reglas de la lógica booleana.

Las compuertas más comunes son:
  • Compuerta AND: Produce un 1 solo si todas sus entradas son 1.


  • Compuerta OR: Produce un 1 si al menos una de sus entradas es 1.


  • Compuerta NOT (inversor): Invierte el valor de la entrada. Si la entrada es 0, la salida es 1, y viceversa.


  • Compuerta XOR (OR exclusivo): Produce un 1 si las entradas son diferentes



¿Por qué son tan importantes las compuertas lógicas?

Debido a que todo algoritmo, por complejo que sea, puede descomponerse en una serie de pasos lógicos y artiméticos. Estos pasos, a su vez, pueden expresarse en términos de operaciones booleanas, es decir, con valores verdadero o falso. Aquí es donde las compuertas lógicas entran en juego: son herramientas que nos permiten manipular estos valores binarios para realizar cálculos y tomar decisiones.

Hay un teorema conocido como el teorema de la completitud funcional, que implica que cualquier función lógica puede implementarse utilizando solo un conjunto pequeño de compuertas (como AND, OR y NOT). Esto incluye desde operaciones aritméticas básicas hasta algoritmos sofisticados.

Combinando Compuertas Lógicas y Clasificadores Lineales

¿Cómo se relacionan estas 2 herramientas que acabamos de examinar?, pues para averiguarlo habrá que revisitar los conceptos de unión, intersección y complemento de lógica y conjuntos.
  • La unión de 2 conjuntos A y B se refiere coloquialmente a tomar los elementos de A y luego agregarle los elementos de B. Lo interesante es que matemáticamente se define como: un elemento está en la unión de A y B, si está presente en A o está presente en B.



  • La intersección de 2 conjuntos A y B puede entenderse como lo que tienen en común ambos conjuntos, y se define matemáticamente como: un elemento está en la intersección de A y B, si está en A y además también está en B.

Como ya vimos anteriormente, un clasificador líneal clasifica los puntos dependiendo de si están dentro o fuera de un semiplano. Ahora, ¿qué pasa si queremos agrupar una clase como los puntos que estén dentro de uniones o interesecciones de semiplanos?, con éso podríamos hacer un clasificador con cualquier polígono, ¡por más lados que contenga!, incluso podríamos usar cualquier conjunto de polígonos aunque fueran disjuntos.

Por ejemplo, en el siguiente clasificador:


Podemos pensar en la clase 0 como: $$A \cup (B \cap C)$$

Donde tanto A, como B como C se pueden obtener a partir de un clasificador lineal.En cierta manera el clasificador mostrado en la imagen se podría expresar con el siguiente árbol:

Con estos mismos componentes podríamos hacer clasificadores más complicados, por ejemplo esto:


...podría lograrse con el siguiente clasificador:

Esta misma lógica se podría extender la clasificación a más variables. Incluso es posible demostrar que cualquier región de un espacio de cualquier dimensión delimitada por una frontera continua puede ser aproximada arbitrariamente por esta clase de composiciones de clasificadores lineales.

En este momento puede que estés pensando algo así como: "Los clasificadores lineales son útiles porque se pueden entrenar, pero, al combinarse con compuertas lógicas, ¿cómo se entrenan?, además, ¿tengo que saber de antemano qué compuertas lógicas usar y en qué configuración ponerlas antes de entrenarlas?"

...y es de mi agrado responder a éso con una buena noticia: ya existe un modelo que puede hacer todo lo que hacen esta combinación de compuertas lógicas y clasificadores lineales, y además puede entrenarse con decenso de gradiente sin necesidad de elegir explícitamente las compuertas lógicas.

Este modelo son las redes neuronales.

Neuronas como Clasificadores Lineales

Una red neuronal es un grafo acíclico dirigido de unas unidades que llamaremos neuronas. Podemos pensar en una neurona como un clasificador lineal pero que en lugar de regresar sólo 0 o 1 (dependiendo de si el punto se encuentra en el semi-plano/semi-espacio), puede regresar un número mayor que 0 y menor que 1 si se encuentra cerca de la frontera.

Es decir, para este clasificador:


En lugar de asignar 0 si \(x_1 + x_2 - 0.5 < 0\) y asignar 1 en otro caso, podríamos asignar clase 0.5 cuando \(x_1 + x_2 - 0.5 = 0\) y que conforme el valor de \(x_1 + x_2\) se vaya volviendo mucho mayor que 0, entonces la clase se va volviendo cercana a 1, y de manera análoga si el valor de \(x_1 + x_2\) es muy negativo entonces la clase se vuelve muy cercana a -1.

La ventaja de esta aproximación es que permite usar descenso de gradiente para entrenar este pequeño clasificador, y más importante aún ¡la salida del clasificador se puede usar como entrada de otros clasificadores!, y la composición de los clasificadores puede seguir funcionando con descenso de grandiente.

Hay muchas funciones que se pueden utilizar para este propósito, pero una muy popular es el sigmoide, o \(\sigma\).

$$\sigma(x) = \frac{1}{1 + e^{-x}}$$

...y su gráfica es la siguiente:


La neurona entonces consiste de un clasificador que recibe un vector \(x\) de entrada, y calcula un único número como salida dado por $$z = \sigma(x \cdot w - b)$$ ... donde w es un vector y b es un número, ambos dependen de la neurona y se ajustan durante el proceso de entrenamiento.

Este mismo clasificador lineal \(x_1+x_2 < 0.5\) puede expresarse con la siguiente neurona:


...sin embargo, tal vez puedas darte cuenta que como \(\sigma(-1)\) ≈ 0.22 , si interpretamos ése número como una probabilidad, el clasificador se estaría mostrando demasiado inseguro para puntos que están cerca de la recta. Quizá te gustaría que fuera más tajante y tener \(a<0.1\) si \(x_1+x_2-0.5 < 1\) , pues bien, afortunadamente éso se puede hacer.

Basta con tomar \(w_1=10\), \(w_2=10\) y \(b=-5\), es decir, multiplicar todos los parámetros por 10, para que así \(a=\sigma(-10)\) en lugar de \(\sigma(-1)\). Esto es el equivalente a cambiar la aplastar horizontalmente la función de activación:

...y la neurona quedaría así (nótese que es la misma desigualdad para clasificar, pero los valores de \(a\) convergen mas rápido a 0 o a 1):

Las Neuronas como Compuertas Lógicas

Como había mencionado anteriormente, las neuronas también se pueden usar como compuertas lógicas, aquí podemos ver cómo hacerlo (recuerda que basta con multiplicar los pesos y la constante b para hacer que los valores se parezcan más a los valores discretos cuando z está cerca de 0).

Recomiendo probar manualmente los casos para asimilar bien esta información, recuerda que se asume que la entrada son números entre 0 y 1, muy cercanos a 0 o a 1.


Por último, la compuerta lógica XOR también se puede representar con neuronas pero hace falta más de una neurona, ya que los puntos \((0, 1), (1, 0)\) no se pueden separar de los puntos \((1, 1), (0, 0)\) con una única recta.

Una posible manera de resolver este problema es usando 2 rectas en lugar de una sola:

Queremos incluir lo que esté por debajo de la recta azul o por encima de la recta verde.

Esto se puede lograr utilizando 2 neuronas \(h_1\) y \(h_2\) para representar los 2 clasificadores lineales y una neurona más para expresar el OR:


Redes Neuronales

Una red neuronal, como ya se había mencionado anteriormente, simplemente consiste en conectar varias neuronas sin formar ciclos, sin embargo, es bastante común organizar las redes neuronales en capas, de manera que cada capa sólo utilice como entrada las salidas de la capa anterior, algo así:


La ventaja de esta organización, es que los resultados de cada capa se pueden calcular a través de una multiplicación de matrices (y luego aplicar la función de activación).

El uso de GPUs ha facilitado bastante las operaciones con matrices, y esta es otra gran ventaja que tienen las redes neuronales, el hecho de que puedan aprovechar al máximo la potencia de los GPUs para poder entrenarse y ejecutarse.

Así que en resúmen, las redes neuronales son una herramienta de aprendizaje automático que permite aprender el comportamiento de:
  • Clasificadores lineales.
  • Compuertas lógicas
  • Composiciones de las anteriores
  • Clasificar cualquier región delimitada con fronteras continuas
  • Cualquier algoritmo dados los datos suficientes
...y además aprovechan al máximo los GPUs.

¿...y Cómo se Entrenan?

Ya mencioné anteriormente que con descenso de gradiente, 3Blue1Brown tiene algunos videos donde explica éso muy bien qué es éso:


...si de casualidad llegaste hasta aquí sin estar muy instruido en matemáticas, ¡pues felicidades por tu perseverancia!, y aquí hay una breve explicación de la idea base sobre cómo entrenarlas:

Inicialmente se eligen los valores de las w (es decir, los pesos de las conexiones) al azar, y dados los datos de entrenamiento (ejemplos de diferentes entradas con la salida que deberían producir), se mide que tan lejos está la red de producir los resultados deseados.

Luego se observa en qué dirección al mover los valores de las w el comportamiento de la red se acerca más a los resultados deseados y se mueven un poco los valores de las w en ésa dirección.

Se repite este proceso hasta que se deja de notar mejoría o se pasa cierto tiempo pre-definido.

martes, 3 de septiembre de 2024

Lo que no te dicen de aprender frameworks y lenguajes

 


Una pregunta muy común entre aquellos que están pensando aprender a programar es con qué lenguaje empezar o qué frameworks aprender.
Quizá una manera que parecería razonable para acercarse a esta pregunta es ver una lista de ofertas de trabajo y fijarse en cuales son los lenguajes y los frameworks con mayor demanda; pero esto desde mi punto de vista es un error.
¿Por qué un error? mas que nada por un hecho ineludible que me extraña lo poco que se menciona:

Los frameworks y los lenguajes de programación cambian todo el tiempo

... espero haberlo resaltado lo suficiente, y es que en parte me inspiré para escribir sobre este tema debido a un video de alguien que aprendió a programar de manera autodidacta usando Youtube y del montón de malos consejos y vendehumos que se encontró.
La realidad es que si empiezas a aprender a programar ahora mismo, es bastante posible que tardes varios años en poderte dedicar profesionalmente a programar y para ése entonces las herramientas de moda habrán cambiado.
Algunos podrán señalar que lenguajes como JavaScript, C++ y Java llevan ya décadas utilizandose ampliamente, pero ésa es una afirmación bastante engañosa, puesto que aquellos lenguajes han cambiado radicalmente a lo largo del tiempo.
Muchas cosas que eran prácticas comunes dentro de ésos lenguajes, ahora se han vuelto reliquias del pasado que todas las guías de estilo recomiendan evitar (de C++ puedo citar el new, el delete o el typedef entre otros, y de JavaScript recuerdo que antes ni clases tenía y lo que se hacía era crear copias de un objeto y pegarle nuevas funciones a las copias para simular herencia), debido a que los lenguajes han incorporado cosas nuevas que ya funcionan mejor.
... y hablando de Java, ése lenguaje originalmente era ampliamente utilizado para correr en el navegador, y actualmente es una pesadilla encontrarnos con una página web que requiere utilizar Java.
Además, también hay lenguajes que pasaron de ser muy populares a infames o a desaparecer, ejemplos: PHP y ActionScript.
Espero que ya haya dado suficientes ejemplos para convencerlos.
Creo entonces que la pregunta obligatoria debería de ser ¿qué conviene aprender en lugar de frameworks y lenguajes?, y la respuesta a ello creo que se puede dividir en varios puntos:
  • No es mala idea aprender lenguajes de programación, pero sólo dedica tiempo a aprender aquellos que estés usando frecuentemente.
    Si apenas vas a empezar a programar no importa tanto por cual empieces; si alguno te resulta muy complicado puedes pasar a otro más simple y reintentarlo cuando ya tengas más experiencia.
    Lo más importante es practicar, en este caso no vale la pena ser perfeccionista en qué lenguaje elegir para iniciar ni cual pueda ser el mejor libro.
  • Lo mismo aplica para los frameworks, aprende sólo los que vayas a utilizar activamente. Puedes iniciar proyectos personales y decidir qué frameworks aprender sobre la marcha.
    Mas allá del conocimiento de un framework en específico, la habilidad de aprender y adaptarse en crucial.
  • Para los que ya estén un poco curtidos en la programación, conviene también conocer patrones de diseño(hay varios libros que los describen, pero el clásico es Design Patterns: Elements of Reusable Object-Oriented Software), principios de código limpio(recomiendo leer Clean Code) y algoritmos (este es un tema muy amplio, pero en una publicación que subí hace poco menciono qué cosas de los algoritmos son más útiles).
...y sobre todo, hazte a la idea de que olvidarás muchas cosas de los que no uses y otras tantas quedarán desfazadas, y a estas alturas es humanamente imposible poder dominar todo y menos mantenerte al día.



jueves, 22 de agosto de 2024

"Programación Copy-Paste", ¿qué tan mala es?



"Programación Copy-Paste" es el nombre informal que en algún lugar se le da a copiar y pegar código encontrado en foros o en sitios de preguntas y repuestas(sobre todo Stackoverflow), y más recientemente, de bots de chat.
Me he encontrado unas cuantas veces con comentarios respecto a este estilo de programación, y todos los que he encontrado fallan en mencionar gran parte de los problemas que esto puede acarrear.
¿Cuales son estos problemas?, pues empezando por los más conocidos:
  • Copiar y pegar sin entender lo que se está haciendo puede ser una oportunidad desaprovechada para aprender algo y mejorar como desarrollador.
  • Es fácil pasar por alto varios detalles si simplemente se copia y pega, la mente es una experta en engañarnos haciéndonos creer que leyó algo cuando no fue así. El tener que teclearlos forza a asegurarse de que se está leyendo con detenimiento todo (un tip que me dieron en un grupo de escritores es que para revisar un escrito es buena idea leerlo en voz alta, así evitas saltarte cosas accidentalmente).
  • Si en tu programa hay código que no entiendes, en el momento que tengas que hacerle cambios no vas a saber qué hacer.
... si bien todo esto es cierto, desafortunadamente también he escuchado la opinión de que puede ser conveniente recurrir al famoso Ctrl+C, Ctrl+V (ó Ctrl + Insert, Shift + Insert para los nostálgicos) si la prioridad no es aprender sino entregar el proyecto rápido.
El problema con esta forma de ver las cosas es que ignora por completo la calidad del trabajo o incluso la posibilidad de implicaciones legales.
¿Cuales son estois otros problemas de copiar y pegar código de internet?, pues estos puntos son los que me gustaría que se mencionaran más:
  • El código escrito dentro de respuestas a preguntas(como en Stackoverflow o incluso el que es escrito por bots de chats) tiene el propósito de ser un código fácil de entender y auto-contenido.
    No tiene como prioridad ser eficiente, ni versatil, ni mucho menos acoplarse al proyecto que estés desarrollando (es decir, seguir su estilo de código, usar las funciones que ya se hayan escrito, y separarse en varias funciones/clases si es pertinente).
    Sería como estar escribiendo una novela, preguntar en un chat de Whatsapp por inspiración, y copiar y pegar directamente los mensajes en lugar de entender lo que dicen y escribir ésas ideas siguiendo el tono y narrativa presentes en la novela.
    No hay que olvidar que nuestro código está escrito para ser leido tanto por programas(compiladores/interpretes) como por humanos.
  • Mucho código de internet está licenciado, si se toma de un proyecto de software libre es posible que tenga en su licencia condiciones que exijan que sólo se use únicamente en proyectos de software libre.
    Si se toma de una publicación de Stackoverflow, existe el riesgo de que hayan copiado el código de otro lado y se trate de código licenciado.
    Incluso en teoría el que escribe algo es automáticamente dueño del copyright de lo que escriba, y salvo que ceda los derechos explícitamente a la comunidad, es posible meterse en problemas legales si se utiliza su código sin su permiso.
    Hay ocasiones en las cuales el autor da su permiso, sin embargo, si no lo hace en términos legales claros, es posible meterse en problemas debido a posibles ambigüedades del lenguaje.
    El pequeño esfuerzo de escribir el código por uno mismo es bastante menor que lo que supondría consultar con un abogado la posibilidad de copiar y pegar.
Por último, cabe abordar el caso de copiar y pegar código dentro del propio proyecto (en lugar de tomarlo desde internet). En este caso me parece súmamente útil para las pruebas unitarias, pero fuera de las pruebas unitarias hay que tener algo de cuidado, ya que hay que seguir el principio DRY(don't repeat yourself).
Yo no soy nadie para decirles que es malo copiar y pegar tu propio código para producción, e incluso yo mismo lo hago a veces. Cada quien tiene su manera de escribir código y de organizarse y es algo enteramente personal.
Mi intención con decir que hay que tener cuidado y seguir el principio DRY no se trata de decirles que no escriban código repetido sino que libren a sus compañeros (o a sus yos del futuro) de tener que leer código repetido.
No importa si su procedimiento para escribir código implica repetición, lo que importa es el código que llega al repositorio al final del día, y ése código debe de evitar la repetición (salvo para tapar un mal mayor).

"Los buenos programadores copian"
"... los grandes pegan"

miércoles, 14 de agosto de 2024

7 Usos de los Algoritmos en la Vida Real

 Me he encontrado varias veces con esta pregunta "¿Los algoritmos sirven en la vida real?", principalmente por 2 fuentes diametralmente opuestas:

  1. Gente de programación competitiva que se pregunta si todo éso le va a servir en su vida profesional, al sentirse un tanto intimidados por la cantidad de cosas que desconocen.
  2. Gente enojada porque dicen que hay demasiados algoritmos en las entrevistas técnicas de programación.
Respuesta corta: Sí sirven.
Respuesta larga:

Aunque sí sirvan, dista mucho de ser como en la programación competitiva:
  • Hay algunas cosas de la algoritmia que sirven más que otras; las cosas más útiles en la programación competitiva no siempre son tan útiles de conocer profesionalmente (en particular cosas relacionadas al multi-threading, y optimizaciones en la compilación resultan altamente efectivas a pesar de que en programación competitiva son casi ignoradas)
  • La gran mayoría de las optimizaciones aplicables a la vida real utilizan ideas mucho más sencillas que cualquier problema de IOI
  • Con la mayoría de los algoritmos más avanzados que pudieran ser útiles, resulta que es mejor usar la implementación de alguien más en lugar de reimplementarlos.
...y sobre los problemas de entrevista, yo pienso que su grado de complejidad es el correcto: aunque nunca programemos listas enlazadas o árboles binarios directamente, recorrer todos los campos de un objeto json o de una estructura de directorios son tareas comunes, y creo más razonable esperar que el candidato se pueda ajustar a usar una estructura super genérica en lugar de usar un framework en específico, que seguramente sería más complicado (además de que no es necesario llegar a la solución optima del problema, lo que se evalúa es mayoritariamente otras cosas).

Ahora, seguramente con esta información se están preguntando, si sí son útiles los algoritmos pero no de la forma de programación competitiva, entonces, ¿de qué manera son útiles?. Pues aquí les traigo unos cuantos ejemplos (los intentaré ordenar por qué tan frecuente/útil aparece el ejemplo, no por qué tan emocionante sea).

1. Análisis de Complejidad

Si bien esto no es saberse un algoritmo en particular, el análisis de complejidad es fundamental y hay que estarlo usando todo el tiempo.
No es raro que quienes no estén muy familiarizados con él, inadvertidamente produzcan cuellos de botella. A veces con cosas tan simples como utilizar un array.find dentro de un bucle y a veces con cosas menos obvias, como realizar demasiadas consultas a una base de datos, o saltarse la parte de la documentación de una biblioteca donde menciona el rendimiento.
En general siempre que se está planenado un proyecto hay que preguntarse cuáles van a ser los requerimientos y elegir el algoritmo que se ajuste mejor a dichos requerimientos.
Generalmente será un algoritmo que ya fue implementado por alguien más, pero poder elegir desde un momento temprano qué framework o bibliotecas usar puede ser determinante para saber si el proyecto será un éxito o no y éso requiere análisis de complejidad.

2. Memorización

El truco de memorizar las llamadas computacionalmente pesadas para no tener que recalcularlas es súmamente útil, y al combinarse con el análisis de complejidad es posible saber donde aplicarlo para obtener resultados palpables.
La gran diferencia es que la memorización suele utilizarse menos para evitar repetir cálculos grandes y más para evitar repetir llamadas a red o lecturas de archivos.

3. Recursividad

La recursividad es especialmente útil cuando se requiere recorrer árboles, y en programación hay árboles en todos lados: un objeto Json es un árbol, el sistema de archivos es otro árbol, los mismos documentos HTML y los CSS son árboles y también en los videojuegos hay muchos árboles involucrados (desde las estructuras de componentes, hasta la estructura jerárquica para simular cinemática directa o calcular cinemática inversa).

4. Programación Asíncrona

Es común que varios de los procesos con los que se trabaja sean asíncronos (es decir, no se realizan de manera inmediata sino que requieren de esperar y durante ése tiempo el programa puede continuar ejecutándose).
Prácticamente todos los lenguajes de programación modernos tienen cierto soporte para asincronía.
La manera en la que se cordinan dichas tareas puede tener un gran impacto en el rendimiento final del programa.
Para poder realizar todas estas tareas efectivamente hay que poder analizar los algoritmos y saber en qué momentos es conveniente esperar a una respuesta, y cómo ir procesando los resultados a medida que llegan.

5. Multi-threading

Esto también es parte de la programación asíncrona pero va un paso mas allá, en este caso varios hilos corren a la vez, cada uno ejecutando su propio pedazo de código y es necesario ser capaz de idear maneras de que los hilos trabajen juntos y no produzcan bugs en el momento que interactúan con esturcturas de datos que comparten.
Esto requiere un diseño minucioso de estructuras de datos para ver cómo evitar lo más posible bloquear hilos sin sacrificar mucho rendimiento.
También está el detalle de que todo lo que es sólo lectura es seguro para multi-threading, pero actualizar una estructura que es sólo lectura sin necesidad de copiar la estructura completa requiere técnicas que se utilizan bastante en la persistencia.

6. Geometría Computacional

Tanto en mi trabajo desarrollando videojuegos como en mi trabajo en un buscador de hoteles necesité usar geometría computacional extensivamente.

7. Máscaras de Bits

A veces resultan útiles para ahorrar memoria o para hacer algunas operaciones con las que normalmente se usarían arreglos booleanos pero de una forma más simple y además más eficiente.  También varias APIs las usan extensivamente para evitar pasar demasiados parámetros booleanos.

miércoles, 31 de julio de 2024

El Patron Observador(Eventos y Suscriptores) y su Relación con Redux

El patrón observador es otro patrón de diseño que nos encontramos muy a menudo, y para bien o para mal, muchas veces tenemos que usarlo de manera forzosa en muchos APIs (sobre todo en aquellos que utilizan funciones de red).

¿De qué se trata este patrón?, pues en resumen consta de 3 elementos principales:

  • Eventos. Se trata de objetos que contienen una etiqueta (generalmente por medio de una cadena de caracteres) y además de éso pueden contener cualquier información (la etiqueta suele utilizarse para diferenciar unos eventos de otros, casi como el tipo de una estructura lo hace).
    Algunos sistemas de eventos requieren que la información dentro del evento sea serializable, pero no siempre es el caso.
  • Emisores.Los emisores son objetos que pueden emitir eventos. Es decir, tienen una función para emitir que recibe un evento.
  • Suscriptores. Los suscriptores son callbacks que se añaden a los emisores, y son llamados cada que un emisor emite un evento con una etiqueta específica.

Por ejemplo, en un servidor HTTP se podría recibir un evento cuando un cliente intenta visitar una URL dada; en una aplicación de escritorio se podría emitir un evento cuando el usuario presiona un botón; o en un videojuego se puede recibir un evento una vez que se terminaron de cargar las texturas.


Por si no quedó del todo claro, la idea es que cada evento represente un suceso del cual alguna parte de la aplicación debe de encargarse. Se utiliza un emisor para notificar cuando algún evento sucede mientras que los suscriptores son los encargados de tomar acción una vez que el evento ya sucedió.


Un uso no tan obvio, pero que me parece bastante útil (valga la redundancia) es evitar el antipatrón de efectos secundarios. Ya que es bastante común que parte de los requerimientos del proyecto incluyan realizar una acción B, cada que una acción A se realiza.

Por ejemplo, en un juego RPG, podría ser inmovilizar al personaje cada que se muestra un cuadro de dialogo.

MAL ❌
export function showDialog(text : string, sytle : DialogueStyle) : void {
    drawMessageBackground(style.backgroundStyle);
    drawText(text, style.textStyle);
    freezeCharacter();
}
...
showDialog("It's dangerous to go alone, take this");
Este código tiene el problema de que si alguien mira el lugar donde se manda llamar showDialog, no hay indicio alguno de que el presonaje se vaya a inmovilizar.
Por otro lado, si se optara por mandar llamar a freezeCharacter cada que vez que se manda llamar la función showDialog, rompería con el principio DRY y en consecuencia haría el código más propenso a errores(a alguien se le podría olvidar mandar llamar a freezeCharacter, o borrarlo por accidente o por un malentendido) y más difícil de mantener.
Por el lado de las pruebas, podría complicar las cosas ya que toda prueba unitaria que involucre mostrar un diálogo debería de configurar las dependencias para incluir al personaje también.
Por último, si se creara una función llamada showDialogAndFreezeCharacter , se rompería el principio de que una función debe hacer una y sólo una cosa.

MEJOR✅

export function showDialog(text : string, sytle : DialogueStyle) : void {
    drawMessageBackground(style.backgroundStyle);
    drawText(text, style.textStyle);
    events.emit(DIALOG_DISPLAYED);
}
...
events.on(DIALOG_DISPLAYED, freezeCharacter);
...
showDialog("It's dangerous to go alone, take this");
Esta implementación es más coherente porque la función showDialog, en sí misma no está haciendo otra cosa distinta a mostar el dialogo, mientras que la acción de "cada vez que se muestre un diálogo congela al personaje", está claramente descrita en un lugar a parte y el bajo nivel de acoplamiento permite escribir pruebas unitarias más fácilmente.

Ventajas

  • Un bajo nivel de acoplamiento. Dado que para utilizar un emisor no es necesario conocer información alguna sobre quienes van a ser los sucriptores y los suscriptores tampoco necesitan saber la información de quien emitió los eventos, el acoplamiento es extremadamente bajo.
    Lo cual se traduce en facilidad para crear pruebas automáticas (una prueba puede emitir eventos sin necesidad de crear un fake o un mock) y facilidad de modificar la implementación de una clase sin romper otras clases.

  • Facilidad para crear bitácoras. Es posible poner en una bitácora cada que se emite un evento y de ésa manera tener una idea de qué está sucediendo.

Desventajas

  • Cambiar el esquema de un evento puede ser muy costoso.

  • El stack pierde su utilidad. Normalmente basta con ver la pila de llamadas para poder saber cual fue la secuencia de pasos que llevaron a suceder una situación en particular (sobre todo cuando hay una excepción no cachada). El problema con esta arquitectura de eventos es que es bastante común que el stack muestre una clase encargada de desencolar los eventos del emisor, sin dejar pista alguna de cómo fue que se llegó a emitir ése evento.

  • Las llamadas asíncronas se vuelven más problemáticas en las pruebas. Cuando se quiere probar un procedimiento asíncrono que inicia con la emisión de un evento, no es trivial saber cuándo termina para poder verificar que el resultado fue correcto (a veces hay que crear funciones específicamente para que le notifiquen a la prueba cuando el resultado ya está listo).


Eventos y Redux

El resto del post habla únicamente de Redux, les aviso a quienes no estén familiarizados o no estén interesados en Redux, que probablemente ya leyeron todo lo que les puede interesar de esta publicación.

En la guía de estilo de Redux podemos leer esto:
De manera que idealmente cada acción de redux debería de describir algo que ocurrió en la aplicación, por ejemplo: el usuario hizo click en un botón, el usuario presionó cierta tecla, o incluso cosas más abstractas como que se pidió que una ventana se cerrara, o cambió el nombre de un objeto.

... en lugar de que las acciones de redux sean peticiones para cambiar el estado global (esto comparte los mismos problemas graves que dan las variables globales), las acciones simplemente deben de describir algo que ya ocurrió y dejar que sean los reducers los que se encarguen de cambiar el estado interno y los selectores los que se encarguen de leer el estado interno (el resto del proyecto no debería de necesitar siquiera saber cómo está estructurado el estado de Redux).

Todo muy bonito, pero hay un problema...

No le dedicaría un espacio tan grande en este post a simplemente regurgitar lo que ya dice la guía de estilo de Redux, y es que hace un tiempo me percaté de que Redux Toolkit parece tener construidos los slices de manera que hace imposible poder usar las acciones como eventos.

Redux Toolkit posee la función createSlice , a la cual se le pasa un estado inicial y una lista de reducers, y para cada reducer crea una acción para llamarlo. ¡Prácticamente crea un montón de variables globales con funciones para cambiarlas!, y para hacer las cosas peor, cada slice aisla su estado interno del resto del estado, de manera que no puede ser cambiado desde afuera(esto esto es malo porque pareciera que es imposible que una acción cambie el contenido de varios slices, impidiendo su funcionamiento como eventos).

Así que parece todo perdido pero...

¡Esto tiene una solución!

Naturalmente, los creadores de Redux Toolkit no diseñaron el framework pensando en romper la guía de estilos de Redux, sin embargo, me sorprendió lo mucho que me costó encontrar la solución para esto.

Resulta que createSlice tiene un parámetro opcional llamado extraReducers. Utilizando ése parámetro se pueden crear reducers que en lugar de que reaccionen a su propia acción creada por el slice, reaccionen a una acción definida en otro lado.

Su syntaxis es un poco rara, pero no es dificil de usar: extraReducers recibe un callback, y el callback recibe como parámetro un objeto que llamaremos armador, este armador tiene un método llamado addCase, el cual recibe como parámetro una acción ya existente y el reducer con el cual debería de reaccionar.

Suena muy enrevesado, pero viéndolo en acción no lo es tanto:

createSlice({
	name : "fanboy",
    initialState: {
    	name : "Juanito",
        surname : "Perez",
        emotion: "indiferent",
        usd: 100
    },
    reducers: {
    	changedHisName : (state, action: PayloadAction<{name: string, surname: string}>) => {
        	state.name = action.name;
            state.surname = action.surname;
        },
        earnedMoney : (state, action: PayloadAction<number>) => {
        	state.usd += action.payload;
            state.emotion = "happy";
        }
    },
    extraReducers: (builder) => {
    	builder.addCase(NINTENDO_RELEASED_GAME, (state, action: Payload<{metacritic_score : number}>) => {
        	if (action.payload.metacritic_score < 80) {
            	state.emotion = "angry";
                return;
            }
            if (action.payload.metacritic_score < 90) {
            	state.emotion = "indiferent";
                return;
            }
            if (state.usd < 60) {
            	state.emotion = "sad";
                return;
            }
            state.usd -= 60;
            state.emotion = "happy";
        }).addCase(SONY_RELEASED_GAME, (state, action: Payload<{metacritic_score : number}>) => {
        	if (action.payload.metacritic_score > 90) {
            	state.emotion = "angry";
            }
        });
    }
});
Por si no se entiende por el contexto, en este caso NINTENDO_RELEASED_GAME y SONY_RELEASED_GAME son acciones definidas fuera del slice, pero que una vez despachadas cambian el contenido del slice.

De StackOverflow a ChatGPT: El Arte de Pegar sin Pensar

 Es curioso cómo apenas en Agosto del año pasado critiqué duramente la práctica de copiar código de páginas de internet para pegarlo dentro ...