Translate

viernes, 17 de enero de 2025

Concurrencia en Erlang parte 8


El concepto de 'flushing' permite implementar una recepción selectiva que puede dar prioridad a los mensajes que recibe mediante la anidación de llamadas:


important() ->

    receive

        {Priority, Message} when Priority > 10 ->

            [Message | important()]

    after 0 ->

        normal()

    end.


normal() ->

    receive

        {_, Message} ->

            [Message | normal()]

    after 0 ->

        []

    end.


Esta función creará una lista de todos los mensajes, comenzando primero con aquellos con una prioridad superior a 10:


1> c(multiproc).

{ok,multiproc}

2> self() ! {15, high}, self() ! {7, low}, self() ! {1, low}, self() ! {17, high}.       

{17,high}

3> multiproc:important().

[high,high,low,low]


Como utilicé el bit posterior a 0, se obtendrán todos los mensajes hasta que no quede ninguno, pero el proceso intentará capturar todos aquellos con una prioridad superior a 10 antes incluso de considerar los demás mensajes, que se acumulan en la llamada normal/0.

Si esta práctica le parece interesante, tenga en cuenta que a veces no es segura debido a la forma en que funcionan las recepciones selectivas en Erlang.

Cuando se envían mensajes a un proceso, se almacenan en el buzón hasta que el proceso los lee y coinciden con un patrón allí. Los mensajes se almacenan en el orden en que se recibieron. Esto significa que cada vez que coincide con un mensaje, comienza por el más antiguo.

Luego, ese mensaje más antiguo se prueba con cada patrón de la recepción hasta que uno de ellos coincide. Cuando lo hace, el mensaje se elimina del buzón y el código del proceso se ejecuta normalmente hasta la próxima recepción. Cuando se evalúa esta próxima recepción, la máquina virtual buscará el mensaje más antiguo que se encuentre actualmente en el buzón (el siguiente al que eliminamos), y así sucesivamente.

Cuando no hay forma de encontrar una coincidencia con un mensaje determinado, se lo coloca en una cola de guardado y se intenta con el siguiente mensaje. Si el segundo mensaje coincide, el primero se vuelve a colocar en la parte superior del buzón para volver a intentarlo más tarde.

Esto le permite preocuparse únicamente por los mensajes que son útiles. Ignorar algunos mensajes para manejarlos más tarde de la manera descrita anteriormente es la esencia de las recepciones selectivas. Si bien son útiles, el problema con ellas es que si su proceso tiene muchos mensajes que nunca le interesan, leer los mensajes útiles en realidad llevará cada vez más tiempo (y los procesos también crecerán en tamaño).

Imagine que queremos el mensaje n.° 367, pero los primeros 366 son basura ignorada por nuestro código. Para obtener el mensaje n.° 367, el proceso debe intentar hacer coincidir los 366 primeros. Una vez que haya terminado y todos se hayan colocado en la cola, se saca el mensaje n.° 367 y los primeros 366 se vuelven a colocar en la parte superior del buzón. El siguiente mensaje útil podría estar mucho más escondido y tardar aún más en encontrarlo.

Este tipo de recepción es una causa frecuente de problemas de rendimiento en Erlang. Si su aplicación se ejecuta con lentitud y sabe que hay muchos mensajes circulando, esta podría ser la causa.

Si estas recepciones selectivas están causando una ralentización masiva de su código, lo primero que debe hacer es preguntarse por qué recibe mensajes que no desea. ¿Se envían los mensajes a los procesos correctos? ¿Son correctos los patrones? ¿Los mensajes tienen un formato incorrecto? ¿Está utilizando un proceso cuando debería haber muchos? Responder a una o varias de estas preguntas podría resolver su problema.

Debido a los riesgos de que los mensajes inútiles contaminen el buzón de un proceso, los programadores de Erlang a veces toman una medida defensiva contra tales eventos. Una forma estándar de hacerlo podría ser la siguiente:


receive

    Pattern1 -> Expression1;

    Pattern2 -> Expression2;

    Pattern3 -> Expression3;

    ...

    PatternN -> ExpressionN;

    Unexpected ->

        io:format("unexpected message ~p~n", [Unexpected])

end.


Lo que esto hace es asegurarse de que cualquier mensaje coincida con al menos una cláusula. La variable Unexpected coincidirá con cualquier cosa, sacará el mensaje inesperado del buzón y mostrará una advertencia. Dependiendo de su aplicación, es posible que desee almacenar el mensaje en algún tipo de servicio de registro donde podrá encontrar información sobre él más adelante: si los mensajes van al proceso equivocado, sería una pena perderlos para siempre y tener dificultades para encontrar por qué ese otro proceso no recibe lo que debería.

En el caso de que necesite trabajar con una prioridad en sus mensajes y no pueda usar una cláusula de captura general, una forma más inteligente de hacerlo sería implementar un min-heap o usar el módulo gb_trees y volcar todos los mensajes recibidos en él (asegúrese de poner el número de prioridad primero en la clave para que se use para ordenar los mensajes). Luego, puede simplemente buscar el elemento más pequeño o más grande en la estructura de datos según sus necesidades.

En la mayoría de los casos, esta técnica debería permitirle recibir mensajes con una prioridad de manera más eficiente que las recepciones selectivas. Sin embargo, podría ralentizarlo si la mayoría de los mensajes que recibe tienen la máxima prioridad posible. Como siempre, el truco es perfilar y medir antes de optimizar.

Desde R14A, se agregó una nueva optimización al compilador de Erlang. Simplifica las recepciones selectivas en casos muy específicos de comunicaciones de ida y vuelta entre procesos. Un ejemplo de una función de este tipo es optimized/1 en multiproc.erl.

-module(multiproc).

-compile([export_all]).


sleep(T) ->

    receive

    after T -> ok

    end.


flush() ->

    receive

        _ -> flush()

    after 0 ->

        ok

    end.


important() ->

    receive

        {Priority, Message} when Priority > 10 ->

            [Message | important()]

    after 0 ->

        normal()

    end.


normal() ->

    receive

        {_, Message} ->

            [Message | normal()]

    after 0 ->

        []

    end.


%% optimized in R14A

optimized(Pid) ->

    Ref = make_ref(),

    Pid ! {self(), Ref, hello},

    receive

        {Pid, Ref, Msg} ->

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

    end.

Para que funcione, se debe crear una referencia (make_ref()) en una función y luego enviarla en un mensaje. En la misma función, se realiza una recepción selectiva. Si ningún mensaje puede coincidir a menos que contenga la misma referencia, el compilador se asegura automáticamente de que la máquina virtual omitirá los mensajes recibidos antes de la creación de esa referencia.

Tenga en cuenta que no debe intentar forzar su código para que se ajuste a dichas optimizaciones. Los desarrolladores de Erlang solo buscan patrones que se usan con frecuencia y luego los hacen más rápidos. Si escribe código idiomático, las optimizaciones deberían venir a usted. No al revés.

Con estos conceptos entendidos, el siguiente paso será realizar el manejo de errores con múltiples procesos.


martes, 14 de enero de 2025

El Poder del Operador |> en Elixir: Elegancia y Legibilidad


La programación funcional se centra en la composición de funciones para resolver problemas de manera clara y concisa. Uno de los operadores más representativos de este paradigma es el operador pipe |>, que permite encadenar llamadas a funciones de forma fluida y natural.

El operador |> (pipe) se utiliza para pasar el resultado de una expresión como el primer argumento de la siguiente función en la cadena.


value |> function1() |> function2()


Esto es equivalente a:


function2(function1(value))


Como ventajas podemos nombrar: 

  1. Legibilidad Mejorada: El flujo de datos se representa de forma secuencial, como si leyeras un proceso paso a paso.
  2. Eliminación de Paréntesis Anidados: Reduce la complejidad visual de funciones anidadas.
  3. Facilita el Refactoring: Reordenar o agregar pasos en el flujo es más sencillo.


Esto :

String.upcase(String.trim(" hola "))


Se puede escribir así:

" hola "

|> String.trim()

|> String.upcase()


Ambos códigos producen el mismo resultado: `"HOLA"`, pero la versión con |> es más legible.

El operador |> no se limita a funciones de la librería estándar; también puedes usarlo con tus propias funciones.


defmodule Math do

  def square(x), do: x * x

  def double(x), do: x * 2

end


5

|> Math.square()

|> Math.double()

Resultado: 50


Veamos ejemplos de uso : 


[1, 2, 3, 4]

|> Enum.map(&(&1 * 2))

|> Enum.filter(&(&1 > 4))

Resultado: [6, 8]


Otro ejemplo con estructuras más complejas: 


%{name: "John", age: 30}

|> Map.put(:country, "USA")

|> Map.update!(:age, &(&1 + 1))

Resultado: %{name: "John", age: 31, country: "USA"}


El operador `|>` es una herramienta fundamental en Elixir que no solo mejora la legibilidad del código, sino que también alienta un diseño funcional y modular. Al adoptarlo, puedes construir pipelines claros y efectivos que hagan que tu código sea más expresivo y fácil de mantener.


El Operador ?? y ??= en C#


En C#, el manejo de valores nulos es crucial para escribir código robusto y limpio. Dos herramientas poderosas que el lenguaje nos ofrece son los operadores ?? y ??=. Estos operadores no solo mejoran la legibilidad, sino que también reducen el código repetitivo.

El operador de coalescencia nula ?? se utiliza para proporcionar un valor alternativo en caso de que una expresión sea null.


var result = value ?? defaultValue;


  • Si value no es null, result será igual a value.
  • Si value es null, result tomará el valor de defaultValue.


Veamos un ejemplo: 


string? name = null;

string displayName = name ?? "Usuario por defecto";

Console.WriteLine(displayName); // Salida: Usuario por defecto


El operador ??= fue introducido en C# 8.0, el operador de asignación de coalescencia nula simplifica el proceso de asignar un valor a una variable solo si esta es null.


variable ??= value;

Seria como : 


variable = (variable == null) ? value : variable


  • Si variable es null, se le asignará value.
  • Si variable ya tiene un valor, no se realiza ninguna acción.


Veamos un ejemplo: 


List<int>? numbers = null;

numbers ??= new List<int>();

numbers.Add(42);


Console.WriteLine(string.Join(", ", numbers)); // Salida: 42


Los operadores `??` y `??=` son herramientas esenciales para trabajar con valores nulos de manera elegante y eficiente en C#. Su uso adecuado no solo mejora la legibilidad del código, sino que también ayuda a prevenir errores comunes relacionados con referencias nulas.


domingo, 12 de enero de 2025

Concurrencia en Erlang parte 7


Probemos algo con la ayuda del comando pid(A,B,C), que nos permite convertir los 3 números enteros A, B y C en un pid. Aquí le daremos deliberadamente a kitchen:take/2 uno falso:


20> kitchen:take(pid(0,250,0), dog).


Ups. El shell está congelado. Esto sucedió debido a la forma en que se implementó take/2. Para entender lo que sucede, primero revisemos lo que sucede en el caso normal:


  1. Un mensaje para tomar comida se envía desde el shell al proceso del refrigerador;
  2. Su proceso cambia al modo de recepción y espera un nuevo mensaje;
  3. El refrigerador retira el artículo y lo envía a su proceso;
  4. Su proceso lo recibe y continúa con su vida.


Y esto es lo que sucede cuando el shell se congela:


  1. Un mensaje para tomar comida se envía desde el shell a un proceso desconocido;
  2. Su proceso cambia al modo de recepción y espera un nuevo mensaje;
  3. El proceso desconocido no existe o no espera tal mensaje y no hace nada con él;
  4. Su proceso de shell está bloqueado en modo de recepción.

Eso es molesto, especialmente porque no hay manejo de errores posible aquí. No sucedió nada ilegal, el programa solo está esperando. En general, cualquier cosa que tenga que ver con operaciones asincrónicas (que es como se hace el paso de mensajes en Erlang) necesita una forma de abandonar el proceso después de un cierto período de tiempo si no obtiene señales de recibir datos. Un navegador web lo hace cuando una página o imagen tarda demasiado en cargarse, usted lo hace cuando alguien tarda demasiado en responder el teléfono o llega tarde a una reunión. Erlang ciertamente tiene un mecanismo apropiado para eso, y es parte de la construcción de recepción:


receive

    Match -> Expression1

after Delay ->

    Expression2

end.


La parte entre recibir y después es exactamente la misma que ya conocemos. La parte después se activará si se ha pasado tanto tiempo como Delay (un entero que representa milisegundos) sin recibir un mensaje que coincida con el patrón Match. Cuando esto sucede, se ejecuta Expression2.

Escribiremos dos nuevas funciones de interfaz, store2/2 y take2/2, que actuarán exactamente como store/2 y take/2 con la excepción de que dejarán de esperar después de 3 segundos:


store2(Pid, Food) ->

    Pid ! {self(), {store, Food}},

    receive

        {Pid, Msg} -> Msg

    after 3000 ->

        timeout

    end.


take2(Pid, Food) ->

    Pid ! {self(), {take, Food}},

    receive

        {Pid, Msg} -> Msg

    after 3000 ->

        timeout

    end.


Ahora puedes descongelar el shell con ^G y probar las nuevas funciones de la interfaz:


User switch command

 --> k 

 --> s

 --> c

Eshell V5.7.5  (abort with ^G)

1> c(kitchen).

{ok,kitchen}

2> kitchen:take2(pid(0,250,0), dog).

timeout


Y ahora funciona.

After no solo toma milisegundos como valor, en realidad es posible usar el átomo infinito. Si bien esto no es útil en muchos casos (podría simplemente eliminar la cláusula after por completo), a veces se usa cuando el programador puede enviar el tiempo de espera a una función donde se espera recibir un resultado. De esa manera, si el programador realmente quiere esperar eternamente, puede hacerlo.

Existen usos para estos temporizadores además de darse por vencidos después de demasiado tiempo. Un ejemplo muy simple es cómo funciona la función timer:sleep/1 que hemos usado antes. Aquí se muestra cómo se implementa (pongámosla en un nuevo módulo multiproc.erl):


sleep(T) ->

    receive

    after T -> ok

    end.


En este caso específico, nunca se encontrará ningún mensaje en la parte de recepción de la construcción porque no hay ningún patrón. En cambio, se llamará a la parte posterior de la construcción una vez que haya transcurrido el retraso T.

Otro caso especial es cuando el tiempo de espera es 0:


flush() ->

    receive

        _ -> flush()

    after 0 ->

        ok

    end.


Cuando esto sucede, la máquina virtual Erlang intentará encontrar un mensaje que se ajuste a uno de los patrones disponibles. En el caso anterior, todo coincide. Mientras haya mensajes, la función flush/0 se llamará a sí misma de forma recursiva hasta que el buzón esté vacío. Una vez hecho esto, se ejecuta la parte after 0 -> ok del código y la función retorna.

sábado, 11 de enero de 2025

Quicksort en prolog


Un Algoritmo que me gusta mucho es el quicksort, porque es un algoritmo por demás claro. Ya he escrito lo fácil que es implementarlo en scalaerlangrusthaskell , F# y lisp.

Ahora le toca a prolog. Básicamente el algoritmo toma un pivot y agrupa los menores del pivot al principio y los mayores al final y aplica quicksort a estos 2 grupos. Y si la lista es vacía o tiene un elemento, ya esta ordenada. 

Vamos al código:  


quicksort([], []).

quicksort([X], [X]).

quicksort([Pivot|Rest], Sorted) :-

    partition(Rest, Pivot, Smaller, Greater),

    quicksort(Smaller, SortedSmaller),

    quicksort(Greater, SortedGreater),

    append(SortedSmaller, [Pivot|SortedGreater], Sorted).


partition([], _, [], []).

partition([X|Rest], Pivot, [X|Smaller], Greater) :-

    X =< Pivot,

    partition(Rest, Pivot, Smaller, Greater).

partition([X|Rest], Pivot, Smaller, [X|Greater]) :-

    X > Pivot,

    partition(Rest, Pivot, Smaller, Greater).


Vamos a probarlo: 


?- quicksort([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5], Sorted).

Sorted = [1, 1, 2, 3, 3, 4, 5, 5, 5, 6, 9].



viernes, 10 de enero de 2025

Programación Reactiva con Spring WebFlux y Cassandra


Vamos a crear un proyecto webflux utilizando Cassndra. 

Primero creamos un proyecto Spring Boot y agregamos las dependencias necesarias:

  • spring-boot-starter-webflux
  • spring-boot-starter-data-cassandra-reactive


Si utilizamos maven el archivo pom.xml tendria estas dependencias :


<dependencies>

    <dependency>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-webflux</artifactId>

    </dependency>

    <dependency>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-data-cassandra-reactive</artifactId>

    </dependency>

    <dependency>

        <groupId>com.datastax.oss</groupId>

        <artifactId>java-driver-core</artifactId>

    </dependency>

</dependencies>

Si usamos gradle seria algo así : 

        implementation("org.springframework.boot:spring-boot-starter-data-cassandra-reactive")

implementation("org.springframework.boot:spring-boot-starter-webflux")


Agregamos la configuración de Cassandra en application.yml:


spring:

  data:

    cassandra:

      contact-points: [localhost]

      port: 9042

      keyspace-name: demo_keyspace

      schema-action: create-if-not-exists

o en el properties: 

spring.cassandra.contact-points=127.0.0.1

spring.cassandra.port=9042

spring.cassandra.keyspace-name=demo_keyspace

spring.cassandra.schema-action= create-if-not-exists

spring.cassandra.local-datacenter=datacenter1


Ahora vamos a definir una entidad para guardar y recuperar:


import org.springframework.data.annotation.Id;

import org.springframework.data.cassandra.core.mapping.PrimaryKey;

import org.springframework.data.cassandra.core.mapping.Table;


@Table("products")

public class Product {

    @Id

    @PrimaryKey

    private String id;

    private String name;

    private double price;


    // Getters y setters

}


Crea un repositorio usando ReactiveCassandraRepository:


import org.springframework.data.cassandra.repository.ReactiveCassandraRepository;

import org.springframework.stereotype.Repository;


@Repository

public interface ProductRepository extends ReactiveCassandraRepository<Product, String> {

}


Ahora hacemos el servicio: 


import org.springframework.stereotype.Service;

import reactor.core.publisher.Flux;

import reactor.core.publisher.Mono;


@Service

public class ProductService {


    private final ProductRepository productRepository;


    public ProductService(ProductRepository productRepository) {

        this.productRepository = productRepository;

    }


    public Flux<Product> getAllProducts() {

        return productRepository.findAll();

    }


    public Mono<Product> getProductById(String id) {

        return productRepository.findById(id);

    }


    public Mono<Product> createProduct(Product product) {

        return productRepository.save(product);

    }


    public Mono<Void> deleteProduct(String id) {

        return productRepository.deleteById(id);

    }

}


Ahora creamos un controlador REST con WebFlux:


import org.springframework.web.bind.annotation.*;

import reactor.core.publisher.Flux;

import reactor.core.publisher.Mono;


@RestController

@RequestMapping("/products")

public class ProductController {


    private final ProductService productService;


    public ProductController(ProductService productService) {

        this.productService = productService;

    }


    @GetMapping

    public Flux<Product> getAllProducts() {

        return productService.getAllProducts();

    }


    @GetMapping("/{id}")

    public Mono<Product> getProductById(@PathVariable String id) {

        return productService.getProductById(id);

    }


    @PostMapping

    public Mono<Product> createProduct(@RequestBody Product product) {

        return productService.createProduct(product);

    }


    @DeleteMapping("/{id}")

    public Mono<Void> deleteProduct(@PathVariable String id) {

        return productService.deleteProduct(id);

    }

}


Por ultimo tenemos que agregar las anotaciones @EnableReactiveCassandraRepositories y @Push a nuestro application : 


@SpringBootApplication

@EnableReactiveCassandraRepositories

@Push

class ApplicationDemo : AppShellConfigurator


fun main(args: Array<String>) {

runApplication<ApplicationDemo>(*args)

}


Y ahora podemos probar nuestros servicios. 

miércoles, 8 de enero de 2025

Concurrencia en Erlang parte 6


Algo molesto del ejemplo del post anterior es que el programador que va a utilizar el frigorífico tiene que conocer el protocolo que se ha inventado para ese proceso. Es una carga inútil. Una buena forma de solucionarlo es abstraer los mensajes con la ayuda de funciones que se ocupen de recibirlos y enviarlos:


store(Pid, Food) ->

    Pid ! {self(), {store, Food}},

    receive

        {Pid, Msg} -> Msg

    end.


take(Pid, Food) ->

    Pid ! {self(), {take, Food}},

    receive

        {Pid, Msg} -> Msg

    end.


Ahora la interacción con el proceso es mucho más limpia:


9> c(kitchen).

{ok,kitchen}

10> f().

ok

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

<0.73.0>

12> kitchen:store(Pid, water).

ok

13> kitchen:take(Pid, water).

{ok,water}

14> kitchen:take(Pid, juice).

not_found


Ya no tenemos que preocuparnos por cómo funcionan los mensajes si se necesita enviar self() o un átomo preciso como take o store: todo lo que se necesita es un pid y saber qué funciones llamar. Esto oculta todo el trabajo sucio y facilita la creación del proceso de la nevera.

Una cosa que queda por hacer sería ocultar toda esa parte sobre la necesidad de generar un proceso. Nos ocupamos de ocultar los mensajes, pero aún esperamos que el usuario se encargue de la creación del proceso. Veamos la siguiente función start/1:


start(FoodList) ->

    spawn(?MODULE, fridge2, [FoodList]).


Aquí, ?MODULE es una macro que devuelve el nombre del módulo actual. No parece que haya ventajas en escribir una función de este tipo, pero realmente las hay. La parte esencial sería la coherencia con las llamadas a take/2 y store/2: todo lo relacionado con el proceso de refrigerador ahora lo maneja el módulo de cocina. Si tuviera que agregar un registro cuando se inicia el proceso de refrigerador o iniciar un segundo proceso (por ejemplo, un congelador), sería muy fácil hacerlo dentro de nuestra función start/1. Sin embargo, si se deja que el usuario realice la generación a través de spawn/3, entonces cada lugar que inicie un refrigerador ahora debe agregar las nuevas llamadas. Eso es propenso a errores y los errores son una mierda.

Veamos cómo se usa esta función:

15> f().

ok

16> c(kitchen).

{ok,kitchen}

17> Pid = kitchen:start([rhubarb, dog, hotdog]).

<0.84.0>

18> kitchen:take(Pid, dog).

{ok,dog}

19> kitchen:take(Pid, dog).

not_found


¡Hurra! ¡El perro ha salido del frigorífico y nuestra abstracción es total!


martes, 7 de enero de 2025

Implementación Explícita de Interfaces en C#


La implementación explícita de interfaces en C# es una característica poderosa que permite definir miembros de una interfaz de manera que solo sean accesibles a través de la referencia de la interfaz, no directamente desde una instancia de la clase que la implementa. Esto resulta útil para resolver conflictos de nombres y mejorar la encapsulación. 

Cuando una clase implementa una interfaz, normalmente los miembros de esta interfaz se definen como públicos en la clase. Sin embargo, en algunos casos puede ser deseable que los miembros de la interfaz solo estén disponibles cuando se utiliza una referencia a la interfaz, y no desde la clase directamente. Esto es lo que permite la implementación explícita.

La sintaxis consiste en especificar el nombre de la interfaz antes del nombre del método o propiedad que se está implementando:


void NombreInterfaz.Metodo()


Veamos un ejemplo simple:


interface IA

{

    void B();

}


class MiClase : IA

{

    // Implementación explícita

    void IA.B()

    {

        Console.WriteLine("Método IA.B implementado explícitamente.");

    }

}


class Program

{

    static void Main()

    {

        MiClase obj = new MiClase();


        // No puedes llamar directamente a obj.B()

        // obj.B(); // Esto generaría un error de compilación


        // Debes convertir a la interfaz

        IA interfaz = obj;

        interfaz.B(); // Ahora funciona

    }

}


Un uso común de la implementación explícita es cuando una clase implementa varias interfaces que contienen miembros con el mismo nombre.


interface IA { void B(); }

interface IB { void B(); }


class MiClase : IA, IB

{

    void IA.B()

    {

        Console.WriteLine("IA.B llamado");

    }


    void IB.B()

    {

        Console.WriteLine("IB.B llamado");

    }

}


class Program

{

    static void Main()

    {

        MiClase obj = new MiClase();


        // Acceso explícito a través de cada interfaz

        ((IA)obj).B(); // IA.B llamado

        ((IB)obj).B(); // IB.B llamado

    }

}


Las ventajas de usar esto tenemos:

  1. Evitar conflictos de nombres: Permite implementar métodos con el mismo nombre en diferentes interfaces sin ambigüedades.
  2. Encapsulación: Los miembros de la interfaz solo son accesibles cuando la instancia de la clase se trata como una referencia a la interfaz.
  3. Separación de responsabilidades: Permite mantener lógicas separadas y claras cuando se implementan varias interfaces.

La implementación explícita de interfaces es una herramienta valiosa en C#, especialmente en escenarios donde se requiere mayor encapsulación o resolución de conflictos de nombres. Si bien puede parecer menos intuitiva al principio, ofrece flexibilidad y control sobre cómo se exponen los miembros de una interfaz.

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.