Translate

domingo, 5 de enero de 2025

Concurrencia en Erlang parte 5


No hay una gran ventaja para los procesos y actores si son solo funciones con mensajes. Para solucionar esto, tenemos que poder mantener el estado en un proceso.

Primero, creemos una función en un nuevo módulo kitchen.erl que permitirá que un proceso actúe como un refrigerador. El proceso permitirá dos operaciones: almacenar alimentos en el refrigerador y sacar alimentos del refrigerador. Solo debería ser posible sacar alimentos que se hayan almacenado de antemano. La siguiente función puede actuar como base para nuestro proceso:


-module(kitchen).

-compile(export_all).


fridge1() ->

    receive

        {From, {store, _Food}} ->

            From ! {self(), ok},

            fridge1();

        {From, {take, _Food}} ->

            %% uh....

            From ! {self(), not_found},

            fridge1();

        terminate ->

            ok

    end.


Algo anda mal. Cuando pedimos almacenar la comida, el proceso debería responder ok, pero no hay nada que almacene la comida; se llama a fridge1() y luego la función comienza desde cero, sin estado. También puedes ver que cuando llamamos al proceso para sacar comida del refrigerador, no hay estado del cual sacarla y, por lo tanto, lo único que se debe responder es not_found. Para almacenar y sacar alimentos, necesitaremos agregar estado a la función.

Con la ayuda de la recursión, el estado de un proceso puede entonces mantenerse completamente en los parámetros de la función. En el caso de nuestro proceso de refrigerador, una posibilidad sería almacenar toda la comida como una lista y luego buscar en esa lista cuando alguien necesite comer algo:


fridge2(FoodList) ->

    receive

        {From, {store, Food}} ->

            From ! {self(), ok},

            fridge2([Food|FoodList]);

        {From, {take, Food}} ->

            case lists:member(Food, FoodList) of

                true ->

                    From ! {self(), {ok, Food}},

                    fridge2(lists:delete(Food, FoodList));

                false ->

                    From ! {self(), not_found},

                    fridge2(FoodList)

            end;

        terminate ->

            ok

    end.


Lo primero que hay que notar es que fridge2/1 toma un argumento, FoodList. Puedes ver que cuando enviamos un mensaje que coincide con {From, {store, Food}}, la función agregará Food a FoodList antes de continuar. Una vez que se realiza esa llamada recursiva, será posible recuperar el mismo elemento. De hecho, lo implementé allí. La función usa lists:member/2 para verificar si Food es parte de FoodList o no. Dependiendo del resultado, el elemento se envía de vuelta al proceso de llamada (y se elimina de FoodList) o se envía not_found de vuelta en caso contrario:


1> c(kitchen).

{ok,kitchen}

2> Pid = spawn(kitchen, fridge2, [[baking_soda]]).

<0.51.0>

3> Pid ! {self(), {store, milk}}.

{<0.33.0>,{store,milk}}

4> flush().

Shell got {<0.51.0>,ok}

ok

Parece que almacenar los alimentos en el frigorífico funciona. Probaremos con más cosas y luego intentaremos sacarlas del frigorífico.


5> Pid ! {self(), {store, bacon}}.

{<0.33.0>,{store,bacon}}

6> Pid ! {self(), {take, bacon}}.

{<0.33.0>,{take,bacon}}

7> Pid ! {self(), {take, turkey}}.

{<0.33.0>,{take,turkey}}

8> flush().

Shell got {<0.51.0>,ok}

Shell got {<0.51.0>,{ok,bacon}}

Shell got {<0.51.0>,not_found}

ok


Como era de esperar, podemos sacar el tocino del frigorífico porque lo hemos puesto allí primero (junto con la leche y el bicarbonato de sodio), pero el proceso del frigorífico no encuentra ningún pavo cuando lo solicitamos. Por eso recibimos el último mensaje {<0.51.0>,not_found}.


sábado, 4 de enero de 2025

Actores en Elixir


Elixir, construido sobre la Máquina Virtual de Erlang (BEAM), es conocido por su capacidad para manejar concurrencia y tolerancia a fallos de manera elegante. Una de las piezas clave detrás de esta potencia es el modelo de actores.

El modelo de actores es un paradigma de concurrencia donde las entidades llamadas actores:

  • Reciben mensajes.
  • Procesan esos mensajes.
  • Pueden responder, enviar mensajes a otros actores o crear nuevos actores.

En Elixir, los actores se implementan como procesos ligeros gestionados por la BEAM, lo que permite manejar miles o incluso millones de ellos simultáneamente. Las caracteristicas más importantes son: 

  1. Aislamiento completo: Cada actor tiene su propio estado y no comparte memoria con otros actores.
  2. Comunicación mediante mensajes: Los mensajes entre a ctores son asíncronos y pasan a través de colas de mensajes.
  3. Tolerancia a fallos: Si un actor falla, su supervisor puede reiniciarlo, manteniendo la estabilidad del sistema.

En Elixir, los procesos se crean con spawn, y se comunican usando send para enviar mensajes y receive para manejarlos. Veamos un ejemplo: 


defmodule ActorExample do

  def start do

    spawn(fn -> listen() end)

  end


  defp listen do

    receive do

      {:greet, name} ->

        IO.puts("¡Hola, #{name}!")

        listen()

      :stop ->

        IO.puts("Proceso detenido.")

      _ ->

        IO.puts("Mensaje no reconocido.")

        listen()

    end

  end

end


# Crear el actor

pid = ActorExample.start()


# Enviar mensajes al actor

send(pid, {:greet, "Mundo"})

send(pid, :stop)


Elixir hereda de Erlang una rica tradición de más de tres décadas en sistemas concurrentes y distribuidos. Esto lo convierte en una elección ideal para aplicaciones modernas como:

  • Sistemas distribuidos.
  • Aplicaciones web con alta concurrencia.
  • Sistemas en tiempo real.


viernes, 3 de enero de 2025

Concurrencia en Erlang parte 4


Vamos a ver las tres primitivas necesarias para la concurrencia en Erlang: generar nuevos procesos, enviar mensajes y recibir mensajes. En la práctica, se requieren más mecanismos para crear aplicaciones realmente confiables, pero por ahora esto será suficiente.

Me he saltado mucho el tema y todavía tengo que explicar qué es realmente un proceso. De hecho, no es más que una función. Eso es todo. Ejecuta una función y, una vez que termina, desaparece. Técnicamente, un proceso también tiene algún estado oculto (como un buzón para mensajes), pero las funciones son suficientes por ahora.

Para iniciar un nuevo proceso, Erlang proporciona la función spawn/1, que toma una sola función y la ejecuta:


1> F = fun() -> 2 + 2 end.

#Fun<erl_eval.20.67289768>

2> spawn(F).

<0.44.0>


El resultado de spawn/1 (<0.44.0>) se denomina Identificador de proceso, que la comunidad suele escribir simplemente PID, Pid o pid. El identificador de proceso es un valor arbitrario que representa cualquier proceso que exista (o pueda haber existido) en algún momento de la vida de la máquina virtual. Se utiliza como una dirección para comunicarse con el proceso.

Notarás que no podemos ver el resultado de la función F. Solo obtenemos su pid. Esto se debe a que los procesos no devuelven nada. ¿Cómo podemos ver entonces el resultado de F? Bueno, hay dos formas. La más fácil es simplemente mostrar lo que obtenemos:


3> spawn(fun() -> io:format("~p~n",[2 + 2]) end).

4

<0.46.0>


Esto no es práctico para un programa real, pero es útil para ver cómo Erlang distribuye los procesos. Afortunadamente, usar io:format/2 es suficiente para permitirnos experimentar. Iniciaremos 10 procesos muy rápido y pausaremos cada uno de ellos por un tiempo con la ayuda de la función timer:sleep/1, que toma un valor entero N y espera N milisegundos antes de reanudar el código. Después del retraso, se muestra el valor presente en el proceso.


4> G = fun(X) -> timer:sleep(10), io:format("~p~n", [X]) end.

#Fun<erl_eval.6.13229925>

5> [spawn(fun() -> G(X) end) || X <- lists:seq(1,10)].

[<0.273.0>,<0.274.0>,<0.275.0>,<0.276.0>,<0.277.0>,

 <0.278.0>,<0.279.0>,<0.280.0>,<0.281.0>,<0.282.0>]

2   

1   

4   

3   

5   

8   

7   

6   

10  

9   


El orden no tiene sentido. Bienvenido al paralelismo. Debido a que los procesos se ejecutan al mismo tiempo, el orden de los eventos ya no está garantizado. Esto se debe a que la máquina virtual Erlang utiliza muchos trucos para decidir cuándo ejecutar un proceso u otro, asegurándose de que cada uno tenga una buena parte del tiempo. Muchos servicios de Erlang se implementan como procesos, incluido el shell en el que está escribiendo. Sus procesos deben equilibrarse con los que necesita el propio sistema y esta podría ser la causa del orden extraño.

Los resultados son similares independientemente de si el multiprocesamiento simétrico está habilitado o no. Para comprobarlo, puede probarlo iniciando la máquina virtual Erlang con $ erl -smp disable

Para ver si su máquina virtual Erlang se ejecuta con o sin soporte SMP en primer lugar, inicie una nueva máquina virtual sin ninguna opción y busque la salida de la primera línea. Si puede ver el texto [smp:2:2] [rq:2], significa que está ejecutando con SMP habilitado y que tiene 2 colas de ejecución (rq o programadores) ejecutándose en dos núcleos. Si solo ve [rq:1], significa que está ejecutando con SMP deshabilitado.

Si desea saberlo, [smp:2:2] significa que hay dos núcleos disponibles, con dos programadores. [rq:2] significa que hay dos colas de ejecución activas. En versiones anteriores de Erlang, podía tener varios programadores, pero con solo una cola de ejecución compartida. Desde R13B, hay una cola de ejecución por programador de manera predeterminada; esto permite un mejor paralelismo.

Para demostrar que el shell en sí está implementado como un proceso regular, usaré el BIF self/0, que devuelve el pid del proceso actual:


6> self().

<0.41.0>

7> exit(self()).

** exception exit: <0.41.0>

8> self().

<0.285.0>


Y el pid cambia porque el proceso se ha reiniciado. Los detalles de cómo funciona esto se verán más adelante. Por ahora, hay cosas más básicas que cubrir. La más importante en este momento es averiguar cómo enviar mensajes, porque nadie quiere quedarse atascado con la salida de los valores resultantes de los procesos todo el tiempo y luego ingresarlos manualmente en otros procesos (al menos yo sé que no).

El siguiente primitivo necesario para realizar el paso de mensajes es el operador !, también conocido como el símbolo bang. En el lado izquierdo toma un pid y en el lado derecho toma cualquier término de Erlang. Luego, el término se envía al proceso representado por el pid, que puede acceder a él:


9> self() ! hello.

hello


El mensaje se ha colocado en el buzón del proceso, pero aún no se ha leído. El segundo saludo que se muestra aquí es el valor de retorno de la operación de envío. Esto significa que es posible enviar el mismo mensaje a muchos procesos haciendo lo siguiente:


10> self() ! self() ! double.

double


Lo cual es equivalente a self() ! (self() ! double). Una cosa a tener en cuenta sobre el buzón de un proceso es que los mensajes se guardan en el orden en que se reciben. Cada vez que se lee un mensaje, se saca del buzón. 

Para ver el contenido del buzón actual, puede usar el comando flush() mientras está en el shell:


11> flush().

Shell got hello

Shell got double

Shell got double

ok

Esta función es simplemente un atajo que muestra los mensajes recibidos. Esto significa que todavía no podemos vincular el resultado de un proceso a una variable, pero al menos sabemos cómo enviarlo de un proceso a otro y verificar si se ha recibido.

Enviar mensajes que nadie leerá es tan útil como escribir poesía emo; no mucho. Por eso necesitamos la declaración de recepción. En lugar de jugar demasiado tiempo en el shell, escribiremos un programa corto sobre los delfines para aprender sobre ellos:


-module(dolphins).

-compile(export_all).


dolphin1() ->

    receive

        do_a_flip ->

            io:format("How about no?~n");

        fish ->

            io:format("So long and thanks for all the fish!~n");

        _ ->

            io:format("Heh, we're smarter than you humans.~n")

    end.


Como puede ver, la sintaxis de receive es similar a la de case ... of. De hecho, los patrones funcionan exactamente de la misma manera, excepto que vinculan variables que provienen de mensajes en lugar de la expresión entre caso y de. Los patrones de recepción también pueden tener guards:

receive

    Pattern1 when Guard1 -> Expr1;

    Pattern2 when Guard2 -> Expr2;

    Pattern3 -> Expr3

end

Ahora podemos compilar el módulo anterior, ejecutarlo y comenzar a comunicarnos con los delfines:

11> c(dolphins).

{ok,dolphins}

12> Dolphin = spawn(dolphins, dolphin1, []).

<0.40.0>

13> Dolphin ! "oh, hello dolphin!".

Heh, we're smarter than you humans.

"oh, hello dolphin!"

14> Dolphin ! fish.                

fish

15> 

Aquí presentamos una nueva forma de generar con spawn/3. En lugar de tomar una sola función, spawn/3 toma el módulo, la función y sus argumentos como sus propios argumentos. Una vez que la función se está ejecutando, ocurren los siguientes eventos:

  1. La función llega a la declaración de recepción. Dado que el buzón del proceso está vacío, nuestro delfín espera hasta que recibe un mensaje;
  2. Se recibe el mensaje "oh, hello dolphin!". La función intenta hacer coincidir el patrón con do_a_flip. Esto falla, por lo que se intenta el patrón fish y también falla. Finalmente, el mensaje cumple con la cláusula catch-all (_) y coincide.
  3. El proceso genera el mensaje "Heh, we're smarter than you humans.".

Entonces, debe notarse que si el primer mensaje que enviamos funcionó, el segundo no provocó ninguna reacción del proceso <0.40.0>. Esto se debe al hecho de que una vez que nuestra función generó "Heh, we're smarter than you humans.", finalizó y también lo hizo el proceso. Necesitaremos reiniciar el delfín:

8> f(Dolphin).    

ok

9> Dolphin = spawn(dolphins, dolphin1, []).

<0.53.0>

10> Dolphin ! fish.

So long and thanks for all the fish!

fish

Y esta vez el mensaje fish funciona. ¿No sería útil poder recibir una respuesta del delfín en lugar de tener que usar io:format/2? Por supuesto que sí (¿por qué lo pregunto?). La única manera de saber si un proceso ha recibido un mensaje es enviar una respuesta. Nuestro proceso delfín necesitará saber a quién responder. Esto funciona como lo hace con el servicio postal. Si queremos que alguien sepa que debe responder a nuestra carta, necesitamos agregar nuestra dirección. En términos de Erlang, esto se hace empaquetando el pid de un proceso en una tupla. El resultado final es un mensaje que se parece un poco a {Pid, Message}. Creemos una nueva función delfín que acepte dichos mensajes:

dolphin2() ->

    receive

        {From, do_a_flip} ->

            From ! "How about no?";

        {From, fish} ->

            From ! "So long and thanks for all the fish!";

        _ ->

            io:format("Heh, we're smarter than you humans.~n")

    end.


Como puede ver, en lugar de aceptar do_a_flip y buscar mensajes, ahora necesitamos una variable From. Ahí es donde irá el identificador del proceso.


11> c(dolphins).

{ok,dolphins}

12> Dolphin2 = spawn(dolphins, dolphin2, []).

<0.65.0>

13> Dolphin2 ! {self(), do_a_flip}.          

{<0.32.0>,do_a_flip}

14> flush().

Shell got "How about no?"

ok


Parece funcionar bastante bien. Podemos recibir respuestas a los mensajes que enviamos (necesitamos agregar una dirección a cada mensaje), pero aún necesitamos iniciar un nuevo proceso para cada llamada. La recursión es la forma de resolver este problema. Solo necesitamos que la función se llame a sí misma para que nunca finalice y siempre espere más mensajes. Aquí hay una función dolphin3/0 que pone esto en práctica:

dolphin3() ->

    receive

        {From, do_a_flip} ->

            From ! "How about no?",

            dolphin3();

        {From, fish} ->

            From ! "So long and thanks for all the fish!";

        _ ->

            io:format("Heh, we're smarter than you humans.~n"),

            dolphin3()

    end.


Aquí, la cláusula catch-all y la cláusula do_a_flip se repiten con la ayuda de dolphin3/0. Tenga en cuenta que la función no hará estallar la pila porque es recursiva de cola. Mientras solo se envíen estos mensajes, el proceso dolphin se repetirá indefinidamente. Sin embargo, si enviamos el mensaje fish, el proceso se detendrá:


15> Dolphin3 = spawn(dolphins, dolphin3, []).

<0.75.0>

16> Dolphin3 ! Dolphin3 ! {self(), do_a_flip}.

{<0.32.0>,do_a_flip}

17> flush().

Shell got "How about no?"

Shell got "How about no?"

ok

18> Dolphin3 ! {self(), unknown_message}.     

Heh, we're smarter than you humans.

{<0.32.0>,unknown_message}

19> Dolphin3 ! Dolphin3 ! {self(), fish}.

{<0.32.0>,fish}

20> flush().

Shell got "So long and thanks for all the fish!"

ok

Y eso debería ser todo para dolphins.erl. Como puede ver, respeta nuestro comportamiento esperado de responder una vez por cada mensaje y seguir adelante después, excepto por el llamado con el mensaje fish. El delfín se hartó de nuestras locas payasadas humanas y nos dejó para siempre.


jueves, 2 de enero de 2025

Cómo Utilizar ANTLR en Java?


ANTLR (Another Tool for Language Recognition) es una herramienta potente para generar analizadores léxicos y sintácticos.

Vamos a ver como podemos usarla en java

Creamos el archivo `build.gradle


   plugins {

       id 'java'

       id 'antlr' version '1.0.1'

   }


   repositories {

       mavenCentral()

   }


   dependencies {

       implementation 'org.antlr:antlr4-runtime:4.9.3'

       antlr 'org.antlr:antlr4:4.9.3'

   }


   sourceSets {

       main {

           java {

               srcDirs = ['build/generated-src/antlr/main']

           }

       }

   }


   antlr {

       arguments += ['-no-listener', '-visitor']

   }


Creamos el archivo de gramática .g4 de ANTLR en src/main/antlr4. Por ejemplo, Hello.g4:


   grammar Hello;


   r  : 'hello' ID ;

   ID : [a-zA-Z]+ ;

   WS : [ \t\r\n]+ -> skip ;


Construimos el Proyecto para esto ejecutamos el siguiente comando para construir el proyecto y generamos los archivos necesarios:


   gradle build


Ejecutamos el proyecto, asegúranodonos que el archivo Main.java esté en `src/main/java`:


   import org.antlr.v4.runtime.CharStreams;

   import org.antlr.v4.runtime.CommonTokenStream;


   public class Main {

       public static void main(String[] args) {

           String input = "hello world";

           HelloLexer lexer = new HelloLexer(CharStreams.fromString(input));

           CommonTokenStream tokens = new CommonTokenStream(lexer);

           HelloParser parser = new HelloParser(tokens);

           parser.r();  // Inicia el análisis sintáctico a partir de la regla 'r'

       }

   }



Ejecutamos la aplicación con:


   gradle run


Hemos explorado cómo configurar y utilizar ANTLR en un proyecto Java usando Gradle. Aprendimos a definir una gramática, generar el lexer y el parser, y utilizamos estos componentes en una aplicación Java. ANTLR es una herramienta flexible que te permite construir analizadores personalizados para tus necesidades.

lunes, 30 de diciembre de 2024

Feliz Navidad y buen año para todos!!


Como todos los años les deseo una feliz navidad y un buen 2025. 

Gracias por leerme! 

Concurrencia en Erlang parte 3


La dificultad de obtener un escalado lineal no se debe al lenguaje en sí, sino a la naturaleza de los problemas a resolver. A menudo se dice que los problemas que escalan muy bien son vergonzosamente paralelos. Si buscas problemas vergonzosamente paralelos en Internet, es probable que encuentres ejemplos como el trazado de rayos (un método para crear imágenes en 3D), búsquedas de fuerza bruta en criptografía, predicción meteorológica, etc.

De vez en cuando, aparecen personas en canales de IRC, foros o listas de correo preguntando si Erlang podría usarse para resolver ese tipo de problema, o si podría usarse para programar en una GPU. La respuesta es casi siempre "no". La razón es relativamente simple: todos estos problemas suelen tratarse de algoritmos numéricos con mucho procesamiento de datos. Erlang no es muy bueno en esto.

Los problemas vergonzosamente paralelos de Erlang están presentes en un nivel superior. Por lo general, tienen que ver con conceptos como servidores de chat, conmutadores telefónicos, servidores web, colas de mensajes, rastreadores web o cualquier otra aplicación donde el trabajo realizado se pueda representar como entidades lógicas independientes (actores). Este tipo de problema se puede resolver de manera eficiente con un escalamiento casi lineal.

Muchos problemas nunca mostrarán tales propiedades de escalamiento. De hecho, solo se necesita una secuencia centralizada de operaciones para perderlo todo. Su programa paralelo solo va tan rápido como su parte secuencial más lenta. Un ejemplo de ese fenómeno se puede observar cada vez que va a un centro comercial. Cientos de personas pueden estar comprando a la vez, sin que rara vez interfieran entre sí. Luego, una vez que llega el momento de pagar, se forman colas tan pronto como hay menos cajeros que clientes listos para irse.

Sería posible agregar cajeros hasta que haya uno para cada cliente, pero luego necesitaría una puerta para cada cliente porque no podrían entrar o salir del centro comercial todos a la vez.

Dicho de otro modo, aunque los clientes pudieran elegir cada uno de sus artículos en paralelo y básicamente tardar tanto tiempo en comprar como si estuvieran solos o miles en la tienda, igualmente tendrían que esperar para pagar. Por lo tanto, su experiencia de compra nunca puede ser más corta que el tiempo que les lleva esperar en la cola y pagar.

Una generalización de este principio se denomina Ley de Amdahl. Indica cuánta aceleración puede esperar que tenga su sistema cada vez que le añada paralelismo, y en qué proporción:

Según la Ley de Amdahl, el código que es 50% paralelo nunca puede volverse más rápido que el doble de lo que era antes, y teóricamente se puede esperar que el código que es 95% paralelo sea aproximadamente 20 veces más rápido si se añaden suficientes procesadores. Lo interesante es que deshacerse de las últimas partes secuenciales de un programa permite una aceleración teórica relativamente grande en comparación con la eliminación de la misma cantidad de código secuencial en un programa que no es muy paralelo para empezar.

El paralelismo no es la respuesta a todos los problemas. En algunos casos, el uso del paralelismo incluso ralentizará su aplicación. Esto puede suceder siempre que su programa sea 100% secuencial, pero aún utilice múltiples procesos.

Uno de los mejores ejemplos de esto es el benchmark de anillo. Un benchmark de anillo es una prueba en la que muchos miles de procesos pasarán un fragmento de datos uno tras otro de manera circular. Piense en ello como un juego de teléfono si lo desea. En este benchmark, solo un proceso a la vez hace algo útil, pero la máquina virtual Erlang aún dedica tiempo a distribuir la carga entre los núcleos y a darle a cada proceso su parte de tiempo.

Esto juega en contra de muchas optimizaciones de hardware comunes y hace que la máquina virtual dedique tiempo a hacer cosas inútiles. Esto suele provocar que las aplicaciones puramente secuenciales se ejecuten mucho más lentamente en muchos núcleos que en uno solo. En este caso, deshabilitar el multiprocesamiento simétrico ($ erl -smp deshabilitar) puede ser una buena idea.

jueves, 26 de diciembre de 2024

Que es LLM?


Un LLM (Large Language Model) o Modelo de Lenguaje de Gran Escala es un sistema de inteligencia artificial entrenado para procesar, comprender y generar texto en lenguaje humano. Estos modelos son capaces de realizar tareas complejas de procesamiento de lenguaje natural (NLP) gracias a su enorme tamaño y capacidad para aprender patrones del lenguaje.


¿Qué hace un LLM?

Un LLM puede:

  • Responder preguntas y entablar conversaciones (como los chatbots).
  • Generar texto coherente y creativo, desde artículos hasta poesía.
  • Traducir idiomas.
  • Resumir documentos largos.
  • Ayudar en tareas de programación escribiendo o corrigiendo código.


Características Clave de un LLM

1. Entrenamiento con Grandes Volúmenes de Datos: Son entrenados con cantidades masivas de texto, que pueden incluir libros, artículos, páginas web, y más.

2. Tamaño del Modelo:  Los LLMs tienen miles de millones de parámetros (variables internas que ajustan su comportamiento). Por ejemplo:

   - GPT-3: 175 mil millones de parámetros.

   - GPT-4: Información específica no divulgada, pero aún más grande.

3. Adaptabilidad:  Son altamente generalistas. Pueden realizar tareas para las que no fueron explícitamente diseñados, gracias a su habilidad para generalizar el conocimiento aprendido.


¿Cómo funcionan los LLMs?

1. Base Matemática: Los LLMs son redes neuronales profundas, generalmente del tipo transformer. Este diseño fue introducido en el artículo de Google "Attention is All You Need" (2017).

2. Preentrenamiento: Aprenden patrones del lenguaje analizando secuencias de texto. Por ejemplo:

   - Entrada: "La capital de Francia es..."

   - Modelo aprende: "París."

3. Fine-tuning: En algunos casos, después del preentrenamiento, los LLMs se ajustan con datos específicos para tareas concretas, como servicio al cliente o generación de código.

4. Inferencia: Durante el uso, el modelo genera texto basado en un *prompt* (instrucción o entrada del usuario). Esto implica predecir la palabra o secuencia más probable.


Ventajas de los LLMs

- Versatilidad: Una sola arquitectura puede abordar múltiples tareas.

- Eficiencia: Automatizan tareas que antes requerían intervención humana intensiva.

- Personalización: Pueden ajustarse a contextos específicos.


Limitaciones de los LLMs

1. Costo Computacional: Entrenar y usar un LLM requiere recursos computacionales significativos.

2. Falta de Comprensión Real: Aunque generan texto coherente, no "entienden" el mundo como los humanos.

3. Sesgos: Pueden reproducir sesgos presentes en los datos con los que fueron entrenados.

4. Actualización Dinámica: No tienen conocimiento en tiempo real; los LLMs tradicionales no pueden aprender nueva información tras su entrenamiento.

lunes, 23 de diciembre de 2024

Concurrencia en Erlang parte 2


En su día, el desarrollo de Erlang como lenguaje fue extremadamente rápido y recibía comentarios frecuentes de los ingenieros que trabajaban en los conmutadores telefónicos de Erlang. Estas interacciones demostraron que la concurrencia basada en procesos y el paso asincrónico de mensajes eran una buena forma de modelar los problemas a los que se enfrentaban. Además, el mundo de la telefonía ya tenía cierta cultura que favorecía la concurrencia antes de que Erlang existiera. Esto se heredó de PLEX, un lenguaje creado anteriormente en Ericsson, y de AXE, un conmutador desarrollado con él. Erlang siguió esta tendencia e intentó mejorar las herramientas disponibles anteriormente.

Erlang tenía algunos requisitos que satisfacer antes de ser considerado bueno. Los principales eran poder escalar y dar soporte a muchos miles de usuarios en muchos conmutadores, y luego lograr una alta confiabilidad, hasta el punto de no detener nunca el código.

Escalabilidad: Me centraré primero en la escalabilidad. Algunas propiedades se consideraron necesarias para lograr la escalabilidad. Como los usuarios se representarían como procesos que solo reaccionan ante ciertos eventos (por ejemplo, recibir una llamada, colgar, etc.), un sistema ideal soportaría procesos que realizan pequeños cálculos y cambian entre ellos muy rápidamente a medida que se producen los eventos. Para que sea eficiente, tenía sentido que los procesos se iniciaran muy rápidamente, se destruyeran muy rápidamente y se pudieran cambiar realmente rápido. Para lograr esto era obligatorio que fueran livianos. También era obligatorio porque no querías tener cosas como grupos de procesos (una cantidad fija de procesos entre los que dividir el trabajo). En cambio, sería mucho más fácil diseñar programas que pudieran usar tantos procesos como necesiten.

Otro aspecto importante de la escalabilidad es poder eludir las limitaciones de tu hardware. Hay dos formas de hacer esto: mejorar el hardware o agregar más hardware. La primera opción es útil hasta cierto punto, después del cual se vuelve extremadamente costosa (por ejemplo, comprar una supercomputadora). La segunda opción suele ser más barata y requiere que agregues más computadoras para hacer el trabajo. Aquí es donde puede ser útil tener la distribución como parte de tu lenguaje.

De todos modos, para volver a los procesos pequeños, debido a que las aplicaciones de telefonía necesitaban mucha fiabilidad, se decidió que la forma más limpia de hacer las cosas era prohibir que los procesos compartieran memoria. La memoria compartida podía dejar las cosas en un estado inconsistente después de algunos fallos (especialmente en los datos compartidos entre diferentes nodos) y tenía algunas complicaciones. En cambio, los procesos deberían comunicarse enviando mensajes donde se copian todos los datos. Esto podría ser más lento, pero más seguro.

Tolerancia a fallos: Esto nos lleva al segundo tipo de requisitos para Erlang: la fiabilidad. Los primeros escritores de Erlang siempre tuvieron en cuenta que los fallos son comunes. Puedes intentar evitar los errores todo lo que quieras, pero la mayoría de las veces algunos de ellos seguirán ocurriendo. En el caso de que los errores no ocurran, nada puede detener los fallos de hardware todo el tiempo. La idea es, por tanto, encontrar buenas formas de manejar los errores y los problemas en lugar de intentar evitarlos todos.

Resulta que adoptar el enfoque de diseño de múltiples procesos con paso de mensajes fue una buena idea, porque el manejo de errores se podía incorporar en él con relativa facilidad. Tomemos como ejemplo los procesos livianos (creados para reinicios y apagados rápidos). Algunos estudios demostraron que las principales fuentes de tiempo de inactividad en sistemas de software a gran escala son errores intermitentes o transitorios (fuente). Luego, existe un principio que dice que los errores que corrompen los datos deben hacer que la parte defectuosa del sistema muera lo más rápido posible para evitar propagar errores y datos incorrectos al resto del sistema. Otro concepto aquí es que existen muchas formas diferentes para que un sistema termine, dos de las cuales son apagados limpios y fallas (que terminan con un error inesperado).

Aquí, el peor caso es obviamente la falla. Una solución segura sería asegurarse de que todas las fallas sean iguales a los apagados limpios: esto se puede hacer a través de prácticas como la no compartición de datos y la asignación única (que aísla la memoria de un proceso), evitar bloqueos (un bloqueo podría no desbloquearse durante una falla, lo que evitaría que otros procesos accedan a los datos o dejaría los datos en un estado inconsistente) y otras cosas que no cubriré más, pero que eran todas parte del diseño de Erlang. La solución ideal en Erlang es, por tanto, matar los procesos lo más rápido posible para evitar la corrupción de datos y los errores transitorios. Los procesos ligeros son un elemento clave en esto. Otros mecanismos de manejo de errores también forman parte del lenguaje para permitir que los procesos monitoreen otros procesos, con el fin de saber cuándo mueren los procesos y decidir qué hacer al respecto.

Suponiendo que reiniciar los procesos muy rápido sea suficiente para lidiar con los fallos, el siguiente problema que se presenta son los fallos de hardware. ¿Cómo se asegura de que su programa siga funcionando cuando alguien patea la computadora en la que se está ejecutando?  La idea es simplemente que el programa se ejecute en más de una computadora a la vez, algo que era necesario para escalar de todos modos. Esta es otra ventaja de los procesos independientes sin canal de comunicación fuera del paso de mensajes. Puedes hacer que funcionen de la misma manera ya sea que estén en una computadora local o en otra, lo que hace que la tolerancia a fallas a través de la distribución sea casi transparente para el programador.

El hecho de estar distribuido tiene consecuencias directas en cómo los procesos pueden comunicarse entre sí. Uno de los mayores obstáculos de la distribución es que no puedes asumir que, porque un nodo (una computadora remota) estaba allí cuando realizaste una llamada de función, seguirá estando allí durante toda la transmisión de la llamada o que incluso la ejecutará correctamente. Si alguien se tropieza con un cable o desenchufa la máquina, tu aplicación se quedará colgada. O tal vez se bloqueará. ¿Quién sabe?

Bueno, resulta que la elección del paso de mensajes asincrónico también fue una buena elección de diseño. En el modelo de procesos con mensajes asincrónicos, los mensajes se envían de un proceso a otro y se almacenan en un buzón dentro del proceso receptor hasta que se extraen para leerlos. Es importante mencionar que los mensajes se envían sin siquiera verificar si el proceso receptor existe o no, porque no sería útil hacerlo. Como se implica en el párrafo anterior, es imposible saber si un proceso se bloqueará entre el momento en que se envía y se recibe un mensaje. Y si se recibe, es imposible saber si se actuará en consecuencia o, nuevamente, si el proceso receptor morirá antes de eso. Los mensajes asincrónicos permiten llamadas de funciones remotas seguras porque no se supone qué sucederá; el programador es quien debe saberlo. Si necesita tener una confirmación de entrega, debe enviar un segundo mensaje como respuesta al proceso original. Este mensaje tendrá la misma semántica segura, y también la tendrá cualquier programa o biblioteca que cree sobre este principio.

Implementación: Bien, se decidió que los procesos livianos con paso de mensajes asincrónicos eran el enfoque a adoptar para Erlang. ¿Cómo hacer que esto funcione? En primer lugar, no se puede confiar en el sistema operativo para que se encargue de los procesos. Los sistemas operativos tienen muchas formas diferentes de gestionar los procesos y su rendimiento varía mucho. La mayoría, si no todos, son demasiado lentos o demasiado pesados ​​para lo que necesitan las aplicaciones estándar de Erlang. Al hacer esto en la máquina virtual, los implementadores de Erlang mantienen el control de la optimización y la fiabilidad. Hoy en día, los procesos de Erlang ocupan unas 300 palabras de memoria cada uno y se pueden crear en cuestión de microsegundos, algo que no se puede hacer en los principales sistemas operativos de la actualidad.

Las colas de ejecución de Erlang en los núcleos: Para gestionar todos estos procesos potenciales que podrían crear sus programas, la máquina virtual inicia un hilo por núcleo que actúa como programador. Cada uno de estos programadores tiene una cola de ejecución, o una lista de procesos de Erlang en los que dedicar una parte del tiempo. Cuando uno de los programadores tiene demasiadas tareas en su cola de ejecución, algunas se migran a otra. Es decir, cada máquina virtual de Erlang se encarga de realizar todo el equilibrio de carga y el programador no tiene que preocuparse por ello. Se realizan otras optimizaciones, como limitar la velocidad a la que se pueden enviar mensajes en procesos sobrecargados para regular y distribuir la carga.

Todo el material pesado está ahí, administrado. Eso es lo que hace que sea fácil trabajar en paralelo con Erlang. Trabajar en paralelo significa que el programa debería funcionar el doble de rápido si agrega un segundo núcleo, cuatro veces más rápido si hay 4 más y así sucesivamente, ¿cierto? Depende. Este fenómeno se denomina escalamiento lineal en relación con la ganancia de velocidad en comparación con la cantidad de núcleos o procesadores. En la vida real, no existe nada gratis.




sábado, 21 de diciembre de 2024

Concurrencia en Erlang


Antes que nada, creo que es importante definir la diferencia entre concurrencia y paralelismo. En muchos lugares, ambas palabras se refieren al mismo concepto. A menudo se usan como dos ideas diferentes en el contexto de Erlang. Para muchos erlangeros, la concurrencia se refiere a la idea de tener muchos actores ejecutándose de forma independiente, pero no necesariamente todos al mismo tiempo. El paralelismo es tener actores ejecutándose exactamente al mismo tiempo. Diré que no parece haber ningún consenso sobre tales definiciones en varias áreas de la informática, pero las usaré de esta manera en este texto. No se sorprenda si otras fuentes o personas usan los mismos términos para referirse a cosas diferentes.

Esto quiere decir que Erlang tenía concurrencia desde el principio, incluso cuando todo se hacía en un procesador de un solo núcleo en los años 80. Cada proceso de Erlang tenía su propio tiempo para ejecutarse, de forma muy similar a las aplicaciones de escritorio antes de los sistemas multinúcleo.

El paralelismo todavía era posible en ese entonces; todo lo que se necesitaba hacer era tener una segunda computadora ejecutando el código y comunicándose con la primera. Incluso entonces, solo se podían ejecutar dos actores en paralelo en esta configuración. Hoy en día, los sistemas multinúcleo permiten el paralelismo en una sola computadora (algunos chips industriales tienen muchas docenas de núcleos) y Erlang aprovecha al máximo esta posibilidad.

La distinción entre concurrencia y paralelismo es importante, porque muchos programadores creen que Erlang estaba listo para las computadoras multinúcleo años antes de que realmente lo estuviera. Erlang se adaptó al multiprocesamiento simétrico verdadero recién a mediados de la década de 2000 y recién logró implementar la mayor parte de la información correctamente con la versión R13B del lenguaje en 2009. Antes de eso, a menudo era necesario deshabilitar SMP para evitar pérdidas de rendimiento. Para obtener paralelismo en una computadora multinúcleo sin SMP, se debían iniciar muchas instancias de la máquina virtual.

Un hecho interesante es que, debido a que la concurrencia de Erlang se basa en procesos aislados, no fue necesario ningún cambio conceptual a nivel de lenguaje para lograr un paralelismo verdadero en el lenguaje. Todos los cambios se realizaron de manera transparente en la máquina virtual, fuera de la vista de los programadores.


viernes, 20 de diciembre de 2024

Dropwizard: Servicios RESTful en Java


Dropwizard es un framework para construir servicios web RESTful en Java de forma rápida y eficiente. Este marco combina varias bibliotecas maduras como Jetty, Jersey, Jackson y Metrics en un único paquete cohesivo, eliminando la complejidad de configuraciones manuales.

¿Por qué usar Dropwizard?

  1. Configuración Simplificada: Usa una estructura de proyecto preconfigurada.
  2. Eficiencia: Basado en Jetty, ofrece un servidor web de alto rendimiento.
  3. Monitorización: Incluye herramientas para métricas y monitoreo listas para usar.
  4. Integración: Compatible con Jersey para manejar endpoints RESTful y Jackson para JSON.
  5. Producción-Ready: Diseñado para entornos de producción con soporte para configuración YAML y validaciones.

Para comenzar, necesitas agregar Dropwizard a tu proyecto Maven:


<dependency>

    <groupId>io.dropwizard</groupId>

    <artifactId>dropwizard-core</artifactId>

    <version>2.1.4</version>

</dependency>


Un proyecto típico de Dropwizard tiene tres componentes principales:

  • Aplicación (`MyApplication`): Configura el servicio y los recursos.
  • Configuración (`MyConfiguration`): Define las opciones de configuración en YAML.
  • Recursos (`MyResource`): Implementa los endpoints RESTful.


Veamos un ejemplo, primero empezamos configurando: 


import io.dropwizard.Application;

import io.dropwizard.setup.Bootstrap;

import io.dropwizard.setup.Environment;


public class HelloWorldApplication extends Application<HelloWorldConfiguration> {


    public static void main(String[] args) throws Exception {

        new HelloWorldApplication().run(args);

    }


    @Override

    public void initialize(Bootstrap<HelloWorldConfiguration> bootstrap) {

        // Inicialización si es necesaria

    }


    @Override

    public void run(HelloWorldConfiguration configuration, Environment environment) {

        final HelloWorldResource resource = new HelloWorldResource(configuration.getDefaultName());

        environment.jersey().register(resource);

    }

}


Crea un archivo config.yml:


defaultName: "Mundo"

server:

  applicationConnectors:

    - type: http

      port: 8080

  adminConnectors:

    - type: http

      port: 8081


Por ultimo hacemos nuestros servicios: 


import javax.ws.rs.GET;

import javax.ws.rs.Path;

import javax.ws.rs.Produces;

import javax.ws.rs.core.MediaType;


@Path("/hello")

@Produces(MediaType.APPLICATION_JSON)

public class HelloWorldResource {


    private final String defaultName;


    public HelloWorldResource(String defaultName) {

        this.defaultName = defaultName;

    }


    @GET

    public String sayHello() {

        return String.format("Hola, %s!", defaultName);

    }

}


Y la configuración va ser: 


import io.dropwizard.Configuration;

import com.fasterxml.jackson.annotation.JsonProperty;

import javax.validation.constraints.NotEmpty;


public class HelloWorldConfiguration extends Configuration {


    @NotEmpty

    private String defaultName;


    @JsonProperty

    public String getDefaultName() {

        return defaultName;

    }


    @JsonProperty

    public void setDefaultName(String defaultName) {

        this.defaultName = defaultName;

    }

}


Y luego levantamos el server y vamos a http://localhost:8080/hello donde veremos nuestro "hello!" 


Dropwizard es una opción excelente para construir servicios RESTful en Java con un enfoque minimalista. La verdad no me queda claro porque eligiría dropwizard y no spring boot. Si alguien sabe escriba en los comentarios. 


Dejo link: https://www.dropwizard.io/en/stable/

martes, 17 de diciembre de 2024

Colas en Erlang

 


El módulo de queue implementa una cola FIFO (First In, First Out) de dos extremos.

El módulo de queue básicamente tiene diferentes funciones en una separación mental en 3 interfaces (o API) de complejidad variable, llamadas API original, API extendida y API de Okasaki:

La API original contiene las funciones en la base del concepto de colas, incluyendo: new/0, para crear colas vacías, in/2, para insertar nuevos elementos, out/1, para eliminar elementos, y luego funciones para convertir a listas, invertir la cola, ver si un valor particular es parte de ella, etc.

La API extendida principalmente agrega algo de poder de introspección y flexibilidad: le permite hacer cosas como mirar el frente de la cola sin eliminar el primer elemento (ver get/1 o peek/1), eliminar elementos sin preocuparse por ellos (drop/1), etc. Estas funciones no son esenciales para el concepto de colas, pero aún así son útiles en general.

La API de Okasaki es un poco rara. Se deriva de las estructuras de datos puramente funcionales de Chris Okasaki. La API proporciona operaciones similares a las que estaban disponibles en las dos API anteriores, pero algunos de los nombres de las funciones están escritos al revés y todo el asunto es relativamente peculiar. A menos que sepas que quieres esta API, no me molestaría en usarla.

Generalmente querrás usar colas cuando necesites asegurarte de que el primer elemento ordenado sea de hecho el primero en procesarse.

veamos un ejemplo: 

-module(queue_demo).

-export([demo/0]).


demo() ->

    % Crear una cola vacía

    Q0 = queue:new(),

    io:format("Cola inicial: ~p~n", [Q0]),


    % Agregar elementos a la cola

    Q1 = queue:in(1, Q0),

    Q2 = queue:in(2, Q1),

    Q3 = queue:in(3, Q2),

    io:format("Cola después de agregar elementos: ~p~n", [Q3]),


    % Sacar un elemento de la cola

    {ok, Element, Q4} = queue:out(Q3),

    io:format("Elemento removido: ~p~n", [Element]),

    io:format("Cola después de remover un elemento: ~p~n", [Q4]),


    % Revisar el elemento al frente sin sacarlo

    {value, PeekElement} = queue:peek(Q4),

    io:format("Elemento al frente de la cola: ~p~n", [PeekElement]),


    % Verificar si la cola está vacía

    IsEmpty = queue:is_empty(Q4),

    io:format("¿La cola está vacía? ~p~n", [IsEmpty]),


    % Obtener el tamaño de la cola

    QueueSize = queue:len(Q4),

    io:format("Tamaño de la cola: ~p~n", [QueueSize]),


    % Convertir la cola a una lista

    ListRepresentation = queue:to_list(Q4),

    io:format("Cola como lista: ~p~n", [ListRepresentation]),


    % Agregar un elemento al frente de la cola

    Q5 = queue:in_r(0, Q4),

    io:format("Cola después de agregar al frente: ~p~n", [Q5]).


Y si ejecutamos esto vamos a tener este resultado: 

Cola inicial: {[], []}
Cola después de agregar elementos: {[], [3,2,1]}
Elemento removido: 1
Cola después de remover un elemento: {[], [3,2]}
Elemento al frente de la cola: 2
¿La cola está vacía? false
Tamaño de la cola: 2
Cola como lista: [2,3]
Cola después de agregar al frente: {[0], [3,2]}



sábado, 14 de diciembre de 2024

LangChain4j: IA Generativa en Java


LangChain4j es un poderoso framework para construir aplicaciones de inteligencia artificial generativa en Java. Inspirado en el popular LangChain para Python y JavaScript, este marco permite integrar modelos de lenguaje como OpenAI GPT, Llama, o Hugging Face con herramientas avanzadas, flujos de trabajo y procesamiento dinámico.

LangChain4j proporciona herramientas para manejar modelos de lenguaje de manera modular y escalable. Algunas de sus características destacadas incluyen:

  • Integración de Modelos de Lenguaje (LLMs): Interactúa fácilmente con GPT-4, GPT-3, o modelos personalizados.
  • Encadenamiento de Operaciones: Diseña flujos complejos combinando varios modelos y herramientas.
  • Contexto Extendido: Utiliza almacenamiento vectorial para manejar contextos largos.
  • Herramientas de Razonamiento y Extracción: Construye aplicaciones como chatbots avanzados, asistentes de búsqueda, o sistemas de recomendación.

Antes de comenzar, agrega la dependencia en tu proyecto Maven o Gradle:


Veamos la dependencia de maven:

<dependency>

    <groupId>com.langchain4j</groupId>

    <artifactId>langchain4j-core</artifactId>

    <version>1.0.0</version>

</dependency>


o gradle:


implementation 'com.langchain4j:langchain4j-core:1.0.0'


Veamos un cliente básico para interactuar con OpenAI GPT:


import com.langchain4j.LangChain;

import com.langchain4j.llm.OpenAiClient;


public class LangChainExample {

    public static void main(String[] args) {

        OpenAiClient client = OpenAiClient.builder()

                .apiKey("tu-api-key")

                .build();


        String response = client.chat("¿Qué es LangChain4j?");

        System.out.println("Respuesta: " + response);

    }

}


Este ejemplo muestra cómo enviar una consulta y recibir una respuesta usando OpenAI GPT.

LangChain4j soporta herramientas como cadenas de procesamiento (`Chains`) para flujos más complejos.


Veamos un ejemplo de un Flujo con Memoria:


import com.langchain4j.chain.ConversationChain;

import com.langchain4j.memory.InMemoryMemory;


public class ConversationExample {

    public static void main(String[] args) {

        ConversationChain conversation = ConversationChain.builder()

                .llm(OpenAiClient.builder().apiKey("tu-api-key").build())

                .memory(new InMemoryMemory())

                .build();


        System.out.println(conversation.chat("Hola, ¿quién eres?"));

        System.out.println(conversation.chat("¿Recuerdas mi nombre?"));

    }

}


Aquí, InMemoryMemory permite que el modelo recuerde las interacciones previas.


LangChain4j admite almacenamiento vectorial, útil para aplicaciones de búsqueda semántica o contextos largos.


import com.langchain4j.vector.PineconeVectorStore;


public class VectorStoreExample {

    public static void main(String[] args) {

        PineconeVectorStore vectorStore = PineconeVectorStore.builder()

                .apiKey("tu-api-key")

                .environment("us-west1-gcp")

                .build();


        vectorStore.add("doc1", "Este es un ejemplo de texto.");

        System.out.println(vectorStore.search("texto relacionado", 1));

    }

}


LangChain4j extiende las capacidades de los modelos de lenguaje para aplicaciones empresariales en Java. Con su enfoque modular, herramientas avanzadas, y soporte para almacenamiento vectorial, se posiciona como una opción clave para proyectos de IA generativa en el ecosistema Java.

Y falto decir que se puede integrar con spring y otros frameworks, pero eso lo voy a dejar para otro post... 

Dejo link: https://docs.langchain4j.dev/


viernes, 13 de diciembre de 2024

Grafos dirigidos en Erlang


Los grafos dirigidos en Erlang se implementan como dos módulos, digraph y digraph_utils. El módulo digraph básicamente permite la construcción y modificación de un grafo dirigido: manipular aristas y vértices, encontrar caminos y ciclos, etc. Por otro lado, digraph_utils permite navegar por un grafo (postorder, preorder), probar ciclos, arborescencias o árboles, encontrar vecinos, etc.

En Erlang, los grafos dirigidos se implementan utilizando los módulos digraph y digraph_utils, que forman parte de la librería estándar. Aquí tienes ejemplos de cómo usarlos:

El módulo digraph permite crear y manipular grafos. Veamos un ejemplo básico:


-module(digraph_example).

-export([create_graph/0, add_nodes_and_edges/1, print_graph/1]).


create_graph() ->

    digraph:new().


add_nodes_and_edges(Graph) ->

    % Agregar nodos

    Node1 = digraph:add_vertex(Graph, node1, "Nodo 1"),

    Node2 = digraph:add_vertex(Graph, node2, "Nodo 2"),

    Node3 = digraph:add_vertex(Graph, node3, "Nodo 3"),

    

    % Agregar aristas dirigidas

    digraph:add_edge(Graph, Node1, Node2),

    digraph:add_edge(Graph, Node2, Node3),

    digraph:add_edge(Graph, Node1, Node3),

    Graph.


print_graph(Graph) ->

    Vertices = digraph:vertices(Graph),

    Edges = digraph:edges(Graph),

    io:format("Nodos: ~p~n", [Vertices]),

    io:format("Aristas: ~p~n", [Edges]).


Veamos como podemos usarlo: 

1> c(digraph_example).

2> Graph = digraph_example:create_graph().

3> Graph = digraph_example:add_nodes_and_edges(Graph).

4> digraph_example:print_graph(Graph).

Nodos: [#Ref<0.0.0.247>,#Ref<0.0.0.248>,#Ref<0.0.0.249>]

Aristas: [#Ref<0.0.0.250>,#Ref<0.0.0.251>,#Ref<0.0.0.252>]


El módulo digraph_utils provee funciones para analizar y transformar grafos, como encontrar caminos, ciclos, o calcular componentes conexas.


find_path_example() ->

    Graph = digraph:new(),

    Node1 = digraph:add_vertex(Graph, node1),

    Node2 = digraph:add_vertex(Graph, node2),

    Node3 = digraph:add_vertex(Graph, node3),

    digraph:add_edge(Graph, Node1, Node2),

    digraph:add_edge(Graph, Node2, Node3),

    Path = digraph_utils:path(Graph, Node1, Node3),

    io:format("Camino de Node1 a Node3: ~p~n", [Path]),

    digraph:delete(Graph).



1> c(digraph_example).

2> digraph_example:find_path_example().

Camino de Node1 a Node3: [node1,node2,node3]


Para detectar ciclos en un grafo dirigido:


detect_cycle_example() ->

    Graph = digraph:new(),

    Node1 = digraph:add_vertex(Graph, node1),

    Node2 = digraph:add_vertex(Graph, node2),

    digraph:add_edge(Graph, Node1, Node2),

    digraph:add_edge(Graph, Node2, Node1),

    Cycles = digraph_utils:strong_components(Graph),

    io:format("Ciclos detectados: ~p~n", [Cycles]),

    digraph:delete(Graph).


1> digraph_example:detect_cycle_example().

Ciclos detectados: [[node1,node2]]


digraph es eficiente para representar grafos con un gran número de nodos y aristas.

digraph_utils complementa digraph con funciones analíticas, como encontrar componentes fuertes o caminos mínimos.

Debido a que los grafos dirigidos están estrechamente relacionados con la teoría de conjuntos, el módulo sofs contiene algunas funciones que permiten convertir familias en dígrafos y dígrafos en familias.


miércoles, 11 de diciembre de 2024

Tipos de Polimorfismo en C++


El polimorfismo es uno de los pilares fundamentales de la programación orientada a objetos (POO) y permite a los objetos comportarse de diferentes maneras según el contexto. En C++, C# y Java y casi cualquier lenguaje orientado a objetos, se manifiesta principalmente de dos formas: en tiempo de compilación y en tiempo de ejecución.

Polimorfismo en Tiempo de Compilación (Static Polymorphism): Este tipo de polimorfismo ocurre cuando la decisión sobre qué función ejecutar se toma en tiempo de compilación. En C++, se logra a través de sobrecarga de funciones y plantillas (o template).

Se define más de una función con el mismo nombre pero diferentes parámetros.


#include <iostream>

using namespace std;


void imprimir(int x) {

    cout << "Número entero: " << x << endl;

}


void imprimir(double x) {

    cout << "Número decimal: " << x << endl;

}


int main() {

    imprimir(10);       // Llama a la versión para enteros

    imprimir(3.14);     // Llama a la versión para dobles

    return 0;

}


Las plantillas permiten definir funciones o clases genéricas que trabajan con diferentes tipos de datos.


#include <iostream>

using namespace std;


template <typename T>

void imprimir(T x) {

    cout << "Valor: " << x << endl;

}


int main() {

    imprimir(10);       // Entero

    imprimir(3.14);     // Decimal

    imprimir("Hola");   // Cadena de texto

    return 0;

}


Polimorfismo en Tiempo de Ejecución (Dynamic Polymorphism): Este tipo de polimorfismo ocurre cuando la decisión sobre qué función ejecutar se toma en tiempo de ejecución. En C++, esto se logra mediante herencia y métodos virtuales.

Los métodos virtuales permiten que una clase derivada sobrescriba el comportamiento de una función definida en la clase base.


#include <iostream>

using namespace std;


class Forma {

public:

    virtual void dibujar() {

        cout << "Dibujar una forma genérica" << endl;

    }

};


class Circulo : public Forma {

public:

    void dibujar() override {

        cout << "Dibujar un círculo" << endl;

    }

};


class Cuadrado : public Forma {

public:

    void dibujar() override {

        cout << "Dibujar un cuadrado" << endl;

    }

};


int main() {

    Forma* forma;


    Circulo circulo;

    Cuadrado cuadrado;


    forma = &circulo;

    forma->dibujar();   // Llama al método de Circulo


    forma = &cuadrado;

    forma->dibujar();   // Llama al método de Cuadrado


    return 0;

}


Si una clase tiene al menos un método virtual puro, se convierte en una clase abstracta y no puede ser instanciada directamente.


#include <iostream>

using namespace std;


class Forma {

public:

    virtual void dibujar() = 0; // Método virtual puro

};


class Triangulo : public Forma {

public:

    void dibujar() override {

        cout << "Dibujar un triángulo" << endl;

    }

};


int main() {

    Forma* forma = new Triangulo();

    forma->dibujar(); // Llama al método de Triangulo

    delete forma;

    return 0;

}


El polimorfismo es una herramienta esencial en C++, que ofrece flexibilidad y reutilización de código. El polimorfismo estático es ideal para optimizar el rendimiento, mientras que el polimorfismo dinámico es crucial para diseños extensibles y adaptables.


martes, 10 de diciembre de 2024

Set en Erlang


Hay 4 módulos principales para tratar con conjuntos en Erlang. Esto es un poco raro al principio, pero tiene más sentido una vez que te das cuenta de que se debe a que los implementadores acordaron que no había una "mejor" manera de construir un conjunto. Los cuatro módulos son ordsets, sets, gb_sets y sofs (conjuntos de conjuntos):

ordsets

Los ordsets se implementan como una lista ordenada. Son principalmente útiles para conjuntos pequeños, son el tipo de conjunto más lento, pero tienen la representación más simple y legible de todos los conjuntos. Hay funciones estándar para ellos como ordsets:new/0, ordsets:is_element/2, ordsets:add_element/2, ordsets:del_element/2, ordsets:union/1, ordsets:intersection/1, y muchas más.

sets

Sets (el módulo) se implementa sobre una estructura muy similar a la que se usa en dict. Implementan la misma interfaz que ordsets, pero se escalarán mucho mejor. Al igual que los diccionarios, son especialmente buenos para manipulaciones de lectura intensiva, como verificar si algún elemento es parte del conjunto o no.

gb_sets

Los gb_sets en sí se construyen sobre una estructura de árbol general equilibrado similar a la que se usa en el módulo gb_trees. gb_sets es a los conjuntos lo que gb_tree es a dict; una implementación que es más rápida al considerar operaciones diferentes a la lectura, lo que le permite tener más control. Si bien gb_sets implementa la misma interfaz que sets y ordsets, también agrega más funciones. Al igual que gb_trees, tiene funciones inteligentes contra ingenuas, iteradores, acceso rápido a los valores más pequeños y más grandes, etc.

sofs

Los conjuntos de conjuntos (sofs) se implementan con listas ordenadas, pegadas dentro de una tupla con algunos metadatos. Son el módulo que se debe usar si desea tener control total sobre las relaciones entre conjuntos, familias, imponer tipos de conjuntos, etc. Son realmente lo que desea si necesita un concepto matemático en lugar de "solo" grupos de elementos únicos.


Si bien tal variedad puede verse como algo grandioso, algunos detalles de implementación pueden ser francamente frustrantes. Como ejemplo, gb_sets, ordsets y sofs usan el operador == para comparar valores: si tiene los números 2 y 2.0, ambos terminarán siendo vistos como el mismo.

Sin embargo, sets (el módulo) utiliza el operador =:=, lo que significa que no necesariamente puedes cambiar de implementación como desees. Hay casos en los que necesitas un comportamiento preciso y, en ese punto, puedes perder el beneficio de tener múltiples implementaciones.

Es un poco confuso tener tantas opciones disponibles. Björn Gustavsson, del equipo Erlang/OTP y programador de Wings3D, sugiere principalmente usar gb_sets en la mayoría de las circunstancias, usar ordset cuando necesitas una representación clara que quieres procesar con tu propio código y 'sets' cuando necesitas el operador =:= (fuente).

En cualquier caso, al igual que para los almacenes de valores clave, la mejor solución suele ser realizar una evaluación comparativa y ver qué se adapta mejor a tu aplicación.