Sebastian Gomez
Estructuras de datos en Go: Colas
Las colas son una de las estructuras de datos más comunes y fundamentales. Se trata de una estructura de datos lineal, en la cual los elementos se agregan y se quitan en un orden específico. Esta estructura se comporta como una fila real, en la cual los elementos se agregan a un "final" y se quitan desde el "comienzo". Esto significa que el primer elemento en ser agregado a la cola será el primero en ser removido. A este comportamiento lo llamamos FIFO, del inglés first in, first out, es decir, primero en entrar, primero en salir. Gracias a su simplicidad y a ese orden, una cola nos sirve para resolver muchos problemas de programación. Por ejemplo, podemos usar una cola para administrar una lista de trabajos en un sistema de cómputo, de modo que los trabajos que entran primero se procesan primero.
Un poco de historia sobre las colas
Más que tener un único inventor, la cola es un tipo abstracto de datos clásico que se fue formalizando a la par de la computación. Su comportamiento FIFO aparece de manera natural en la teoría de colas, un campo de las matemáticas aplicadas que el ingeniero danés A. K. Erlang impulsó a comienzos del siglo XX al modelar el tráfico de llamadas telefónicas. Más adelante, con la llegada de los sistemas operativos y la planificación de procesos, la cola se volvió una herramienta cotidiana para ordenar tareas en espera. Hoy es una de las estructuras de datos fundamentales y la usamos para resolver una gran variedad de problemas.
Algunas características
- Las colas son estructuras de datos lineales, en las cuales los elementos se agregan y se quitan en un orden específico.
- Los elementos se agregan al "final" de la cola y se quitan desde el "comienzo".
- La operación de inserción en una cola se conoce como "encolar" (Enqueue) y la operación de remover un elemento se conoce como "desencolar" (Dequeue).
- Las colas procesan los elementos en orden FIFO (primero en entrar, primero en salir). Esto las distingue de las pilas, que trabajan en orden LIFO (último en entrar, primero en salir).
- Las colas permiten que los elementos se procesen según el orden en que fueron agregados.
- Las colas se pueden implementar de manera estática (utilizando arreglos) o dinámica (utilizando listas ligadas).
- Las colas son muy usadas para la planificación de procesos concurrentes.
- También sirven para implementar un sistema de colas de trabajo, en el cual los trabajos se agregan a la cola y se procesan en orden.
- Se consideran una estructura de datos fundamental por su simplicidad y por la forma en que los elementos se agregan y se quitan en orden.
Entendiendo las colas mediante analogías
En un supermercado, los clientes se forman al final de la fila y se atienden desde el comienzo. Esta es una analogía perfecta de una cola, porque los elementos entran por el final y salen por el comienzo.
En un banco pasa lo mismo: los clientes se ubican al final de la fila y se atienden desde el comienzo, lo que permite atenderlos en el orden correcto.
En una atracción de un parque de diversiones, los visitantes se forman al final de la fila y avanzan desde el comienzo. Es otra buena manera de visualizar el comportamiento de una cola.
Colas en Go
¡Ahora vamos a Go! Escribamos una cola. Empecemos por la versión clásica usando []interface{} y, más abajo, la modernizaremos con genéricos. Para que el ejemplo sea ejecutable de principio a fin, incluimos el encabezado package main y el import "fmt" que vamos a necesitar.
package main
import "fmt"
// Declaramos una estructura Cola
type Cola struct {
elementos []interface{}
}
// Agregamos un método Enqueue para agregar elementos al final de la cola
func (c *Cola) Enqueue(elemento interface{}) {
c.elementos = append(c.elementos, elemento)
}
// Agregamos un método Dequeue para remover elementos desde el comienzo de la cola.
// Devolvemos también un bool que indica si la operación fue válida (la cola no estaba vacía).
func (c *Cola) Dequeue() (interface{}, bool) {
if len(c.elementos) == 0 {
return nil, false
}
elemento := c.elementos[0]
c.elementos = c.elementos[1:]
return elemento, true
}
// Agregamos un método Peek para mirar el elemento que se encuentra al comienzo de la cola,
// sin removerlo. El bool indica si había un elemento que mirar.
func (c *Cola) Peek() (interface{}, bool) {
if len(c.elementos) == 0 {
return nil, false
}
return c.elementos[0], true
}
// Agregamos un método Len para obtener el número de elementos que se encuentran en la cola
func (c *Cola) Len() int {
return len(c.elementos)
}
// Agregamos un método Clear para remover todos los elementos de la cola
func (c *Cola) Clear() {
c.elementos = []interface{}{}
}Fíjate en el detalle importante: Dequeue y Peek revisan primero len(c.elementos) == 0. Si no lo hiciéramos, al acceder a c.elementos[0] con la cola vacía el programa entraría en panic por índice fuera de rango. Devolver un par (valor, ok) es un patrón muy idiomático en Go y le deja claro a quien llama si el resultado es válido.
Algunos métodos adicionales
IsEmpty nos dice si la cola está vacía:
// Agregamos un método IsEmpty para verificar si la cola está vacía
func (c *Cola) IsEmpty() bool {
return len(c.elementos) == 0
}ToString nos arma una representación legible de la cola:
// Agregamos un método ToString para imprimir la cola
func (c *Cola) ToString() string {
resultado := "Cola: ["
for _, elemento := range c.elementos {
resultado += fmt.Sprintf("%v ", elemento)
}
resultado += "]"
return resultado
}Modernizando la cola con genéricos
La versión anterior funciona perfecto, pero usa []interface{}, lo que nos obliga a hacer type assertions cada vez que sacamos un elemento. Desde Go 1.18 contamos con genéricos, así que podemos escribir una cola con seguridad de tipos. De paso, recuerda que any es simplemente un alias de interface{}, así que puedes usar el que prefieras.
package main
import "fmt"
// Cola genérica: T es el tipo de los elementos que va a almacenar
type Cola[T any] struct {
elementos []T
}
func (c *Cola[T]) Enqueue(elemento T) {
c.elementos = append(c.elementos, elemento)
}
func (c *Cola[T]) Dequeue() (T, bool) {
var cero T
if len(c.elementos) == 0 {
return cero, false
}
elemento := c.elementos[0]
c.elementos = c.elementos[1:]
return elemento, true
}
func (c *Cola[T]) Peek() (T, bool) {
var cero T
if len(c.elementos) == 0 {
return cero, false
}
return c.elementos[0], true
}
func (c *Cola[T]) Len() int {
return len(c.elementos)
}
func (c *Cola[T]) IsEmpty() bool {
return len(c.elementos) == 0
}La ventaja es clara: si declaras var cola Cola[string], el compilador garantiza que solo entren y salgan string, y Dequeue te devuelve directamente un string, sin type assertions. Cuando la cola está vacía, devolvemos el valor cero del tipo (con var cero T) junto con false.
Usando la estructura
Veamos un ejemplo con una cola de tres personas, Sebas, Jill y Anastasia, y usemos cada uno de los métodos que creamos para mostrar cómo funciona. Usaremos la versión clásica para ilustrar también el manejo del ok.
package main
import "fmt"
func main() {
// Declarar una cola
var cola Cola
// Agregar elementos a la cola
cola.Enqueue("Sebas")
cola.Enqueue("Jill")
cola.Enqueue("Anastasia")
// Imprimir la cola
fmt.Println(cola.ToString()) // Cola: [Sebas Jill Anastasia ]
// Obtener información de la cola
// Primer elemento de la cola
if elemento, ok := cola.Peek(); ok {
fmt.Println(elemento) // Sebas
}
// Número de elementos en la cola
fmt.Println(cola.Len()) // 3
// Remover elementos de la cola
// Primer elemento
if elemento, ok := cola.Dequeue(); ok {
fmt.Println(elemento) // Sebas
}
// Nuevo número de elementos
fmt.Println(cola.Len()) // 2
// Nueva cola
fmt.Println(cola.ToString()) // Cola: [Jill Anastasia ]
// Verificar si la cola está vacía
fmt.Println(cola.IsEmpty()) // false
// Vaciar la cola
cola.Clear()
// Verificar si la cola está vacía
fmt.Println(cola.IsEmpty()) // true
}Conclusiones
Las colas son una de las estructuras de datos más comunes y fundamentales, y las usamos en una gran variedad de problemas de programación. Se comportan como una fila real, en la que los elementos entran por el "final" y salen desde el "comienzo", siguiendo el orden FIFO: el primer elemento en entrar es el primero en salir. Esto las diferencia de las pilas, que siguen el orden LIFO. Las colas también son una de las estructuras más usadas para la planificación de procesos concurrentes. Al ser tan simples y fáciles de usar, resultan ideales para resolver problemas en los que los elementos deben procesarse en orden.
Ejercicios propuestos
A continuación te dejo un par de ejercicios para que practiques la estructura Cola (Queue) en Go. Cuando termines, envíes la solución como un Pull request a este repositorio:
- Crea un programa en Go que simule la cola de un supermercado. El programa debe permitir a los usuarios encolarse, ver el elemento que se encuentra al comienzo de la cola y desencolarse. Además, debe informar cuántos usuarios hay en la cola.
- Crea un programa en Go que implemente un sistema de cola de trabajo para procesar archivos. El sistema debe permitir encolar archivos para su procesamiento, ver el archivo que se encuentra al comienzo de la cola y desencolar archivos una vez procesados. Además, debe informar cuántos archivos hay en la cola.
Resumen en 3 puntos
- Una cola es una estructura de datos lineal que procesa los elementos en orden FIFO: el primero en entrar es el primero en salir, a diferencia de la pila, que es LIFO.
- Las operaciones básicas son
Enqueue(encolar al final) yDequeue(desencolar desde el comienzo); conviene protegerDequeueyPeekpara que no fallen con la cola vacía, devolviendo un par(valor, ok). - En Go moderno conviene implementarla con genéricos (
type Cola[T any]) para tener seguridad de tipos y evitar type assertions.
Eso es todo, espero que este post te sea de utilidad y lo puedas aplicar a algún proyecto que tengas en mente. Déjame un comentario si te sirvió, si quieres añadir alguna opinión o si tienes alguna duda, y recuerda que si te gustó también puedes compartirlo usando los links a las redes sociales aquí abajo. ¡Buena suerte!
Sebastian Gomez
Creador de contenido principalmente acerca de tecnología.