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.
Para determinar si un tipo de colección tiene algún elemento, es más eficiente y claro usar las propiedades Length, Count o IsEmpty (si es posible) que llamar al método Enumerable.Any.
Any(), que es un método de extensión, usa consultas integradas en lenguaje (LINQ). Es más eficiente confiar en las propiedades propias de la colección y también aclara la intención.
Otro tema es que esta regla es similar a CA1827: No use Count()/LongCount() cuando se pueda usar Any(). Sin embargo, esa regla se aplica al método Count() de Linq, mientras que esta regla sugiere usar la propiedad Count.
Veamos un poco de código:
bool HasElements(string[] strings)
{
return strings.Any(); // esto esta mal.
}
bool HasElements(string[] strings)
{
return strings.Length > 0; // esto esta bien.
}
Lo que me dejo pensando es: entiendo que sea más performante no utilizar Any() siempre si tenemos propiedades como Count, Length o IsEmpty. Pero esto no es una desventaja a la hora de cambiar el tipo de colección?
Podemos concluir que no esta bueno que un lenguaje nos ofrezca 2 o 3 formas de hacer lo mismo y luego nos este retando porque elegimos una.
Dejo link:
https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1860
Las listas por comprensión (o list comprehensions) son una característica poderosa y expresiva de Python que permite construir listas nuevas a partir de iterables existentes, todo ello en una sola línea de código. Son legibles, concisas y, a menudo, más eficientes que los bucles tradicionales.
Son una forma de crear listas en Python utilizando una sintaxis compacta basada en una expresión, un iterador y (opcionalmente) una condición.
Con la forma : [nueva_expresión for elemento in iterable if condición]
Convertir una lista de números en sus cuadrados:
numeros = [1, 2, 3, 4, 5]
cuadrados = [n**2 for n in numeros]
print(cuadrados)
# Salida: [1, 4, 9, 16, 25]
Seleccionar solo los números pares antes de calcular sus cuadrados:
numeros = [1, 2, 3, 4, 5]
cuadrados_pares = [n**2 for n in numeros if n % 2 == 0]
print(cuadrados_pares)
# Salida: [4, 16]
Puedes llamar funciones dentro de la expresión:
nombres = ["Ana", "Bernardo", "Carla", "Diego"]
longitudes = [len(nombre) for nombre in nombres]
print(longitudes)
# Salida: [3, 8, 5, 5]
Crear combinaciones de elementos con múltiples iteradores:
colores = ["rojo", "verde", "azul"]
tamaños = ["pequeño", "mediano", "grande"]
combinaciones = [(color, tamaño) for color in colores for tamaño in tamaños]
print(combinaciones)
# Salida: [('rojo', 'pequeño'), ('rojo', 'mediano'), ..., ('azul', 'grande')]
Usar listas por comprensión con otras estructuras, como diccionarios por comprensión
nombres = ["Ana", "Bernardo", "Carla"]
diccionario = {nombre: len(nombre) for nombre in nombres}
print(diccionario)
# Salida: {'Ana': 3, 'Bernardo': 8, 'Carla': 5}
Conjuntos:
numeros = [1, 2, 2, 3, 4, 4]
pares = {n for n in numeros if n % 2 == 0}
print(pares)
# Salida: {2, 4}
Para listas grandes, usa generadores para ahorrar memoria:
numeros = (n**2 for n in range(10))
print(list(numeros))
# Salida: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Aunque son poderosas, a veces es mejor optar por un bucle tradicional:
- Cuando la lógica es demasiado compleja y afecta la legibilidad.
- Si necesitas manejar excepciones o realizar múltiples pasos intermedios.
Las listas por comprensión son una herramienta esencial para escribir código Python limpio y eficiente. Con práctica, dominarás su uso y aprovecharás al máximo su flexibilidad. ¿Te atreves a crear tus propias transformaciones?
Notarás que no hay ninguna función para agregar o actualizar un elemento de la lista. Esto muestra cuán vagamente definidas están las proplists como estructura de datos. Para obtener estas funcionalidades, debes construir tu elemento manualmente ([NewElement|OldList]) y usar funciones como lists:keyreplace/4. Usar dos módulos para una pequeña estructura de datos no es lo más claro, pero debido a que las proplists están definidas de manera tan vaga, a menudo se usan para tratar con listas de configuración y descripciones generales de un elemento determinado. Las proplists no son exactamente estructuras de datos completas. Son más bien un patrón común que aparece cuando se usan listas y tuplas para representar algún objeto o elemento; el módulo proplists es una especie de caja de herramientas para este patrón.
Si desea un almacén de clave-valor más completo para pequeñas cantidades de datos, el módulo orddict es lo que necesita. Los orddicts (diccionarios ordenados) son proplists con un gusto por la formalidad. Cada clave puede estar allí una vez, la lista completa está ordenada para una búsqueda promedio más rápida, etc. Las funciones comunes para el uso de CRUD incluyen orddict:store/3, orddict:find/2 (cuando no sabe si la clave está en los diccionarios), orddict:fetch/2 (cuando sabe que está allí o que debe estar allí) y orddict:erase/2.
Los orddicts son un compromiso generalmente bueno entre complejidad y eficiencia hasta aproximadamente 75 elementos. Después de esa cantidad, deberías cambiar a diferentes almacenes de clave-valor.
Básicamente, existen dos estructuras/módulos de clave-valor para manejar mayores cantidades de datos: dicts y gb_trees. Los diccionarios tienen la misma interfaz que los orddicts: dict:store/3, dict:find/2, dict:fetch/2, dict:erase/2 y todas las demás funciones, como dict:map/2 y dict:fold/2 (¡bastante útiles para trabajar en toda la estructura de datos!). Por lo tanto, los dicts son muy buenas opciones para escalar los orddicts cuando sea necesario.
Los árboles balanceados generales, por otro lado, tienen muchas más funciones que te dejan un control más directo sobre cómo se debe usar la estructura. Básicamente, existen dos modos para gb_trees: el modo en el que conoces tu estructura al dedillo (lo llamo el "modo inteligente") y el modo en el que no puedes asumir mucho sobre ella (lo llamo el "modo ingenuo"). En el modo ingenuo, las funciones son gb_trees:enter/3, gb_trees:lookup/2 y gb_trees:delete_any/2. Las funciones inteligentes relacionadas son gb_trees:insert/3, gb_trees:get/2, gb_trees:update/3 y gb_trees:delete/2. También existe gb_trees:map/2, que siempre es una buena opción cuando la necesitas.
La desventaja de las funciones "ingenuas" sobre las "inteligentes" es que, como los gb_trees son árboles equilibrados, siempre que insertes un nuevo elemento (o elimines un montón), es posible que el árbol deba equilibrarse a sí mismo. Esto puede llevar tiempo y memoria (incluso en comprobaciones inútiles solo para asegurarse). Todas las funciones "inteligentes" suponen que la clave está presente en el árbol: esto te permite omitir todas las comprobaciones de seguridad y da como resultado tiempos más rápidos.
¿Cuándo deberías usar gb_trees en lugar de diccionarios? Bueno, no es una decisión clara. Como lo mostrará el módulo de referencia que he escrito, gb_trees y dicts tienen rendimientos algo similares en muchos aspectos. Sin embargo, la referencia demuestra que dicts tienen las mejores velocidades de lectura mientras que gb_trees tienden a ser un poco más rápidos en otras operaciones. Puede juzgar en función de sus propias necesidades cuál sería el mejor.
Ah, y otra cosa a tener en cuenta que mientras que dicts tienen una función de plegado, gb_trees no: en su lugar, tienen una función de iteración, que devuelve un fragmento del árbol en el que puede llamar a gb_trees:next(Iterator) para obtener los siguientes valores en orden. Lo que esto significa es que necesita escribir sus propias funciones recursivas sobre gb_trees en lugar de usar un genric fold. Por otro lado, gb_trees te permite tener un acceso rápido a los elementos más pequeños y más grandes de la estructura con gb_trees:smallest/1 y gb_trees:largest/1.
Por lo tanto, diría que las necesidades de tu aplicación son las que deberían determinar qué almacén de clave-valor elegir. Diferentes factores, como la cantidad de datos que tienes que almacenar, lo que necesitas hacer con ellos y demás, tienen su importancia. Mide, perfila y compara para asegurarte.
Existen algunos almacenes de clave-valor especiales para manejar recursos de diferentes tamaños. Estos almacenes son las tablas ETS, las tablas DETS y la base de datos mnesia. Sin embargo, su uso está fuertemente relacionado con los conceptos de múltiples procesos y distribución.
A partir de la versión 17.0, el lenguaje admite un nuevo tipo de datos de clave-valor nativo, descrito en Postscript: Maps.
Se declaran como atributos de módulo de la siguiente manera:
-module(records).
-compile(export_all).
-record(robot, {name,
type=industrial,
hobbies,
details=[]}).
Aquí tenemos un registro que representa robots con 4 campos: nombre, tipo, pasatiempos y detalles. También hay un valor predeterminado para el tipo y los detalles, industrial y [], respectivamente. A continuación, se muestra cómo declarar un registro en el módulo records:
first_robot() ->
#robot{name="Mechatron",
type=handmade,
details=["Moved by a small man inside"]}.
Y ejecutando el código:
1> c(records).
{ok,records}
2> records:first_robot().
{robot,"Mechatron",handmade,undefined,
["Moved by a small man inside"]}
Los registros de Erlang son simplemente azúcar sintáctico sobre tuplas. Afortunadamente, hay una forma de mejorarlo. El shell de Erlang tiene un comando rr(Module) que le permite cargar definiciones de registros desde Module:
3> rr(records).
[robot]
4> records:first_robot().
#robot{name = "Mechatron",type = handmade,
hobbies = undefined,
details = ["Moved by a small man inside"]}
Esto hace que sea mucho más fácil trabajar con registros de esa manera. Notarás que en first_robot/0, no habíamos definido el campo de pasatiempos y no tenía un valor predeterminado en su declaración. Erlang, por defecto, establece el valor como indefinido.
Para ver el comportamiento de los valores predeterminados que establecimos en la definición del robot, compilemos la siguiente función:
car_factory(CorpName) ->
#robot{name=CorpName, hobbies="building cars"}.
Y ejecutalo:
5> c(records).
{ok,records}
6> records:car_factory("Jokeswagen").
#robot{name = "Jokeswagen",type = industrial,
hobbies = "building cars",details = []}
Y tenemos un robot industrial al que le gusta pasar el tiempo construyendo coches.
La función rr() puede tomar más que un nombre de módulo: puede tomar un comodín (como rr("*")) y también una lista como segundo argumento para especificar qué registros cargar.
Hay algunas otras funciones para manejar registros en el shell: rd(Name, Definition) le permite definir un registro de una manera similar a la función -record(Name, Definition) utilizada en nuestro módulo. Puede usar rf() para "descargar" todos los registros, o rf(Name) o rf([Names]) para deshacerse de definiciones específicas.
Puede usar rl() para imprimir todas las definiciones de registros de una manera que pueda copiar y pegar en el módulo o usar rl(Name) o rl([Names]) para restringirlo a registros específicos.
Por último, rp(Term) le permite convertir una tupla en un registro (siempre que exista la definición).
Escribir registros por sí solo no hará mucho. Necesitamos una manera de extraer valores de ellos. Básicamente, hay dos maneras de hacer esto. El primero tiene una "sintaxis de punto" especial. Suponiendo que tiene cargada la definición de registro para robots:
5> Crusher = #robot{name="Crusher", hobbies=["Crushing people","petting cats"]}.
#robot{name = "Crusher",type = industrial,
hobbies = ["Crushing people","petting cats"],
details = []}
6> Crusher#robot.hobbies.
["Crushing people","petting cats"]
No es una sintaxis muy bonita. Esto se debe a la naturaleza de los registros como tuplas. Como son solo una especie de truco del compilador, tienes que mantener las palabras claves para definir qué registro va con qué variable, de ahí la parte #robot de Crusher#robot.hobbies. Es triste, pero no hay forma de evitarlo. Peor aún, los registros anidados se vuelven bastante feos:
7> NestedBot = #robot{details=#robot{name="erNest"}}.
#robot{name = undefined, type = industrial,
hobbies = undefined,
details = #robot{name = "erNest",type = industrial,
hobbies = undefined,details = []}}
8> (NestedBot#robot.details)#robot.name.
"erNest"
Para mostrar aún más la dependencia de los registros en las tuplas, veamos :
9> #robot.type.
3
Lo que esto genera es qué elemento de la tupla subyacente es.
Una característica de ahorro de los registros es la posibilidad de usarlos en los encabezados de funciones para hacer coincidir patrones y también en los guards. Declare un nuevo registro de la siguiente manera en la parte superior del archivo y luego agregue las funciones debajo:
-record(user, {id, name, group, age}).
%% use pattern matching to filter
admin_panel(#user{name=Name, group=admin}) ->
Name ++ " is allowed!";
admin_panel(#user{name=Name}) ->
Name ++ " is not allowed".
%% can extend user without problem
adult_section(U = #user{}) when U#user.age >= 18 ->
%% Show stuff that can't be written in such a text
allowed;
adult_section(_) ->
%% redirect to sesame street site
forbidden.
Esto nos permite ver que no es necesario hacer coincidir todas las partes de la tupla o incluso saber cuántas hay al escribir la función: solo podemos hacer coincidir la edad o el grupo si es lo que se necesita y olvidarnos del resto de la estructura. Si utilizáramos una tupla normal, la definición de la función podría tener que parecerse un poco a function({record, _, _, ICareAboutThis, _, _}) -> .... Entonces, cada vez que alguien decida agregar un elemento a la tupla, alguien más (probablemente enojado por todo esto) tendría que ir y actualizar todas las funciones donde se usa esa tupla.
La siguiente función ilustra cómo actualizar un registro (de lo contrario, no serían muy útiles):
repairman(Rob) ->
Details = Rob#robot.details,
NewRob = Rob#robot{details=["Repaired by repairman"|Details]},
{repaired, NewRob}.
Y luego:
16> c(records).
{ok,records}
17> records:repairman(#robot{name="Ulbert", hobbies=["trying to have feelings"]}).
{repaired,#robot{name = "Ulbert",type = industrial,
hobbies = ["trying to have feelings"],
details = ["Repaired by repairman"]}}
Y puedes ver que mi robot ha sido reparado. La sintaxis para actualizar registros es un poco especial aquí. Parece que estamos actualizando el registro en su lugar (Rob#robot{Field=NewValue}) pero todo es un truco del compilador para llamar a la función subyacente erlang:setelement/3.
Una última cosa sobre los registros. Debido a que son bastante útiles y la duplicación de código es molesta, los programadores de Erlang comparten registros con frecuencia entre módulos con la ayuda de archivos de encabezado. Los archivos de encabezado de Erlang son bastante similares a su contraparte de C: no son más que un fragmento de código que se agrega al módulo como si estuviera escrito allí en primer lugar. Crea un archivo llamado records.hrl con el siguiente contenido:
%% this is a .hrl (header) file.
-record(included, {some_field,
some_default = "yeah!",
unimaginative_name}).
Para incluirlo en records.erl, simplemente agregue la siguiente línea al módulo:
-include("records.hrl").
Y luego la siguiente función para probarlo:
included() -> #included{some_field="Some value"}.
Ahora, pruébalo como siempre:
18> c(records).
{ok,records}
19> rr(records).
[included,robot,user]
20> records:included().
#included{some_field = "Some value",some_default = "yeah!",
unimaginative_name = undefined}
Eso es todo sobre los registros; son feos pero útiles. Su sintaxis no es bonita, no son gran cosa, pero son relativamente importantes para la capacidad de mantenimiento de su código.
A menudo verá software de código abierto que utiliza el método que se muestra aquí de tener un archivo .hrl para todo el proyecto para los registros que se comparten entre todos los módulos. Si bien me sentí obligado a documentar este uso, recomiendo enfáticamente que mantenga todas las definiciones de registros locales, dentro de un módulo. Si desea que algún otro módulo observe las entrañas de un registro, escriba funciones para acceder a sus campos y mantenga sus detalles lo más privados posible. Esto ayuda a prevenir conflictos de nombres, evita problemas al actualizar el código y, en general, mejora la legibilidad y la capacidad de mantenimiento de su código.