Como todos los años les deseo una feliz navidad y un buen 2025.
Gracias por leerme!
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.
¿Qué hace un LLM?
Un LLM puede:
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.
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.
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.
¿Por qué usar Dropwizard?
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:
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/
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]).
LangChain4j proporciona herramientas para manejar modelos de lenguaje de manera modular y escalable. Algunas de sus características destacadas incluyen:
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/
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.
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.
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.
1. Inicializando el Proyecto
Para empezar, crea un nuevo proyecto con mix:
mix new mi_libreria --module MiLibreria
Esto generará una estructura básica con carpetas como `lib` (para el código) y `mix.exs` (configuración del proyecto).
En el archivo `mix.exs`, ajusta la metadata básica, como nombre y descripción, para que sea más descriptiva:
def project do
[
app: :mi_libreria,
version: "0.1.0",
elixir: "~> 1.15",
description: "Una librería simple para manipular cadenas",
start_permanent: Mix.env() == :prod
]
end
2. Implementando la Funcionalidad
Agregamos lógica en lib/mi_libreria.ex. Por ejemplo, una función para convertir cadenas a mayúsculas:
defmodule MiLibreria do
@moduledoc """
MiLibreria es una colección de funciones útiles para cadenas.
"""
@doc """
Convierte una cadena en mayúsculas.
## Ejemplo
iex> MiLibreria.convertir_mayusculas("hola")
"HOLA"
"""
def convertir_mayusculas(cadena) when is_binary(cadena) do
String.upcase(cadena)
end
end
La documentación (`@moduledoc` y `@doc`) ayuda a otros desarrolladores a entender y usar tu librería.
3. Generando Documentación con ExDoc
Añade `ExDoc` al archivo `mix.exs` para generar documentación en HTML:
{:ex_doc, "~> 0.29", only: :dev, runtime: false}
Instala las dependencias:
mix deps.get
Genera la documentación:
mix docs
Esto crea una carpeta `doc` con una versión navegable de tu documentación.
4. Publicando en Hex
Crea una cuenta en Hex.pm:
Tenemos que registrarnos en Hex.pm y genera una clave de autenticación con:
mix hex.user register
Completa la Metadata en mix.exs:
def project do
[
app: :mi_libreria,
version: "0.1.0",
description: "Librería para manipulación de cadenas",
package: package_info()
]
end
defp package_info do
[
maintainers: ["Tu Nombre"],
licenses: ["MIT"],
links: %{"GitHub" => "https://github.com/tu_usuario/mi_libreria"}
]
end
Publica la librería:
mix hex.publish
Crear una librería en Elixir es sencillo y gratificante. Con estas herramientas y pasos, puedes compartir tu trabajo con la comunidad y contribuir al crecimiento del ecosistema.
Las matrices de Erlang, al contrario de sus contrapartes imperativas, no pueden tener cosas como inserción o búsqueda en tiempo constante. Debido a que suelen ser más lentas que las de los lenguajes que admiten la asignación destructiva y que el estilo de programación realizado con Erlang no se presta necesariamente demasiado bien a las matrices y matrices, rara vez se utilizan en la práctica.
En general, los programadores de Erlang que necesitan realizar manipulaciones de matrices y otros usos que requieren matrices tienden a utilizar conceptos llamados Puertos para dejar que otros lenguajes hagan el trabajo pesado, o C-Nodos, Linked in drivers y NIF (Experimental, R13B03+).
Las matrices también son extrañas en el sentido de que son una de las pocas estructuras de datos que tienen un índice 0 (al contrario de las tuplas o listas), junto con el índice en el módulo de expresiones regulares.
Veamos un ejemplo:
%% Create a fixed-size array with entries 0-9 set to 'undefined'
A0 = array:new(10).
10 = array:size(A0).
%% Create an extendible array and set entry 17 to 'true',
%% causing the array to grow automatically
A1 = array:set(17, true, array:new()).
18 = array:size(A1).
%% Read back a stored value
true = array:get(17, A1).
%% Accessing an unset entry returns the default value
undefined = array:get(3, A1).
%% Accessing an entry beyond the last set entry also returns the
%% default value, if the array does not have fixed size
undefined = array:get(18, A1).
%% "sparse" functions ignore default-valued entries
A2 = array:set(4, false, A1).
[{4, false}, {17, true}] = array:sparse_to_orddict(A2).
%% An extendible array can be made fixed-size later
A3 = array:fix(A2).
%% A fixed-size array does not grow automatically and does not
%% allow accesses beyond the last set entry
{'EXIT',{badarg,_}} = (catch array:set(18, true, A3)).
{'EXIT',{badarg,_}} = (catch array:get(18, A3)).
La estructura básica es:
for (declaración : contenedor) {
// cuerpo del ciclo
}
Imaginemos un vector de enteros. Con foreach, iteramos de esta forma:
#include <iostream>
#include <vector>
int main() {
std::vector<int> numeros = {1, 2, 3, 4, 5};
for (int numero : numeros) {
std::cout << numero << " ";
}
return 0;
}
Salida: 1 2 3 4 5
Si deseas modificar los elementos dentro del bucle, usa una referencia (`&`):
#include <iostream>
#include <vector>
int main() {
std::vector<int> numeros = {1, 2, 3, 4, 5};
for (int& numero : numeros) {
numero *= 2; // Duplicar cada valor
}
for (int numero : numeros) {
std::cout << numero << " ";
}
return 0;
}
Salida: 2 4 6 8 10
En un std::map, cada elemento es un par clave-valor (std::pair). Puedes acceder a ellos directamente:
#include <iostream>
#include <map>
int main() {
std::map<std::string, int> edades = {{"Alice", 30}, {"Bob", 25}, {"Charlie", 35}};
for (const auto& [nombre, edad] : edades) {
std::cout << nombre << ": " << edad << "\n";
}
return 0;
}
Salida:
Alice: 30
Bob: 25
Charlie: 35
Como limitaciones tenemos:
int numeros[] = {1, 2, 3, 4};
for (int numero : numeros) {
std::cout << numero << " ";
}
El foreach en C++ simplifica la iteración y mejora la legibilidad del código.
Ecto es un toolkit para bases de datos en Elixir que se compone de tres partes principales:
Para empezar debemos añadir las dependencias en mix.exs:
defp deps do
[
{:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"} # Adaptador para PostgreSQL
]
end
Luego debemos configurar el repositorio en config/config.exs:
config :mi_app, MiApp.Repo,
database: "mi_app_db",
username: "usuario",
password: "contraseña",
hostname: "localhost"
config :mi_app, ecto_repos: [MiApp.Repo]
Crear el repositorio:
mix ecto.gen.repo -r MiApp.Repo
Para luego ejecutar migraciones iniciales:
mix ecto.create
Creamos un módulo para tu esquema, que representa una tabla en la base de datos:
defmodule MiApp.Usuario do
use Ecto.Schema
schema "usuarios" do
field :nombre, :stringfield :email, :string
field :edad, :integer
timestamps()
end
end
Y ahora hagamos consultas básicas con Ecto.Query. Ecto ofrece una sintaxis declarativa para construir consultas SQL. Ejemplo:
import Ecto.Query
# Obtener todos los usuarios
query = from u in "usuarios", select: u
MiApp.Repo.all(query)
# Filtrar usuarios mayores de 18 años
query = from u in MiApp.Usuario, where: u.edad > 18, select: u.nombre
MiApp.Repo.all(query)
Los cambios en los datos se gestionan con Ecto.Changeset, lo que facilita aplicar validaciones.
defmodule MiApp.Usuario do
use Ecto.Schema
import Ecto.Changeset
schema "usuarios" do
field :nombre, :string
field :email, :string
field :edad, :integer
timestamps()
end
def cambios_usuario(usuario, attrs) do
usuario
|> cast(attrs, [:nombre, :email, :edad])
|> validate_required([:nombre, :email])
|> validate_format(:email, ~r/@/)
|> validate_number(:edad, greater_than_or_equal_to: 0)
end
end
Ecto permite definir relaciones como has_many, belongs_to y many_to_many. Veamos un ejemplo:
defmodule MiApp.Usuario do
use Ecto.Schema
schema "usuarios" do
field :nombre, :string
has_many :posts, MiApp.Post
timestamps()
end
end
defmodule MiApp.Post do
use Ecto.Schema
schema "posts" do
field :titulo, :string
belongs_to :usuario, MiApp.Usuario
timestamps()
end
end
Consultas relacionales:
query = from u in MiApp.Usuario, preload: [:posts]
usuarios = MiApp.Repo.all(query)
Ahora actualización de registros:
usuario = MiApp.Repo.get(MiApp.Usuario, 1)
cambioset = MiApp.Usuario.cambios_usuario(usuario, %{nombre: "Nuevo Nombre"})
MiApp.Repo.update(cambioset)
Y Borramos:
usuario = MiApp.Repo.get(MiApp.Usuario, 1)
MiApp.Repo.delete(usuario)
Las migraciones permiten gestionar cambios en el esquema de la base de datos.
Crear una migración:
mix ecto.gen.migration crea_usuarios
Editar la migración:
defmodule MiApp.Repo.Migrations.CreaUsuarios do
use Ecto.Migration
def change do
create table(:usuarios) do
add :nombre, :string
add :email, :string
add :edad, :integer
timestamps()
end
end
end
Ejecutar migración:
mix ecto.migrate
Ecto transforma la interacción con bases de datos en una experiencia declarativa, segura y extensible. Ya sea que estés manejando datos simples o esquemas complejos, Ecto te da las herramientas para hacerlo de manera eficiente.
mkdir helloworld
cd helloworld
¿Importa el nombre del directorio? Sí, importa. ¡Es el nombre de tu programa! De manera predeterminada, cuando tu programa es compilado, el binario ejecutable resultante tendrá el mismo nombre que el directorio en el que se encuentra tu programa. También puedes establecer el nombre usando las opciones –bin-name o -b en la línea de comandos.
Luego, crea un archivo en ese directorio llamado main.pony.
¿Importa el nombre del archivo? No para el compilador, no. A Pony no le importan los nombres de archivo, excepto que terminen en .pony. ¡Pero podría importarte a ti! Al darle buenos nombres a los archivos, puede ser más fácil encontrar el código que estás buscando más tarde.
En el archivo, pongamos el siguiente código:
actor Main
new create(env: Env) =>
env.out.print("¡Hola, mundo!")
Ahora compílarlo:
$ ponyc
Building .
Building builtin
Generating
Optimising
Writing ./helloworld.o
Linking ./helloworld
¡Mira eso! Compiló el directorio actual, ., más las cosas que están incorporadas en Pony, builtin, generó algo de código, lo optimizó, creó un archivo de objeto (no te preocupes si no sabes qué es eso) y lo vinculó a un ejecutable con las bibliotecas que se necesitaban. Si eres un programador de C/C++, todo esto tendrá sentido para ti, de lo contrario, probablemente no lo tenga, pero no importa, puedes ignorarlo.
Espera, ¿también se vinculó? Sí. No necesitarás un sistema de compilación (como make) para Pony. Se encarga de eso por ti (incluso de manejar el orden de las dependencias cuando te vinculas a bibliotecas de C, pero llegaremos a eso más adelante).
Ahora podemos ejecutar el programa:
$ ./helloworld
¡Hola, mundo!
¡Felicitaciones, hemos escrito nuestro primer programa Pony!
ExUnit viene incluido con Elixir, por lo que no necesitas instalar dependencias adicionales. Solo asegúrate de que tu entorno de desarrollo esté configurado para ejecutarlas.
ExUnit.start()
Los archivos de pruebas suelen estar en el directorio test/ y deben tener el sufijo _test.exs.
Veamos un ejemplo:
defmodule MiApp.MiModuloTest do
use ExUnit.Case
test "una prueba simple" do
assert 1 + 1 == 2
end
end
Para ejecutar las pruebas, utiliza:
mix test
Las aserciones son fundamentales para comprobar el comportamiento esperado. ExUnit ofrece varias:
- assert: Verifica que una condición sea verdadera.
- refute: Verifica que una condición sea falsa.
- assert_raise: Verifica que se lance una excepción específica.
Veamos algunos ejemplos:
assert String.length("Hola") == 4
refute String.contains?("Hola", "mundo")
assert_raise ArgumentError, fn -> String.to_integer("no_numero") end
ExUnit permite convertir ejemplos en la documentación en pruebas automáticas.
defmodule MiModulo do
@doc """
Duplica un número.
## Ejemplo
iex> MiModulo.duplicar(2)
4
"""
def duplicar(n), do: n * 2
end
defmodule MiModuloTest do
use ExUnit.Case
doctest MiModulo
end
Para organizar tus pruebas, podemos usar describe:
defmodule MiApp.MiModuloTest do
use ExUnit.Case
describe "función suma/2" do
test "suma números positivos" do
assert MiApp.MiModulo.suma(2, 3) == 5
end
test "suma números negativos" do
assert MiApp.MiModulo.suma(-2, -3) == -5
end
end
end
Y podemos usar setup para definir configuraciones comunes:
defmodule MiApp.MiModuloTest do
use ExUnit.Case
setup do
{:ok, numero: 42}
end
test "usa datos de setup", %{numero: numero} do
assert numero == 42
end
end
Elixir soporta pruebas concurrentes por defecto. Si necesitas pruebas asincrónicas, podemos indícarlo:
defmodule MiApp.AsyncTest do
use ExUnit.Case, async: true
test "prueba concurrente" do
Task.async(fn -> :ok end)
|> Task.await()
|> assert == :ok
end
end
ExUnit facilita capturar salidas a consola y logs:
import ExUnit.CaptureIO
test "captura salida de IO.puts" do
salida = capture_io(fn -> IO.puts("Hola, mundo!") end)
assert salida == "Hola, mundo!\n"
end
import ExUnit.CaptureLog
test "captura logs" do
salida = capture_log(fn -> Logger.info("Esto es un log") end)
assert salida =~ "Esto es un log"
end
Para ejecutar pruebas individuales, podemos usar:
mix test test/mi_modulo_test.exs:10
ExUnit es una herramienta flexible y poderosa para escribir pruebas en Elixir. Desde las pruebas más básicas hasta configuraciones avanzadas, te ayuda a mantener un código confiable y fácil de mantener.