Estructuras de Datos: Slices en Golang

GolangBeginner
Practicar Ahora

Introducción

En la sección anterior, analizamos los arrays en Go. Sin embargo, los arrays presentan limitaciones: una vez declarados e inicializados, su longitud no se puede modificar. Por esta razón, los arrays no se utilizan de forma masiva en la programación cotidiana. En cambio, los slices (rebanadas) son mucho más comunes, ya que proporcionan una estructura de datos más flexible.

Puntos de conocimiento:

  • Definición de un slice
  • Inicialización de un slice
  • Operaciones con slices: añadir, eliminar, modificar y buscar
  • Expansión de un slice
  • Truncamiento de slices
  • Slices multidimensionales
Este es un Laboratorio Guiado, que proporciona instrucciones paso a paso para ayudarte a aprender y practicar. Sigue las instrucciones cuidadosamente para completar cada paso y ganar experiencia práctica. Los datos históricos muestran que este es un laboratorio de nivel principiante con una tasa de finalización del 93%. Ha recibido una tasa de reseñas positivas del 96% por parte de los alumnos.

Qué es un Slice

Los slices son similares a los arrays; son contenedores que albergan elementos del mismo tipo de datos. No obstante, los arrays tienen restricciones: su longitud es fija tras la declaración. Aunque los arrays tienen sus casos de uso, carecen de flexibilidad. Por ello, los slices son la opción predilecta en el desarrollo diario.

En Go, los slices se implementan utilizando arrays como base. Un slice es, esencialmente, un array dinámico que puede cambiar de tamaño. Podemos realizar operaciones como añadir, eliminar, modificar y buscar elementos, acciones que no son posibles directamente con un array de tamaño fijo.

Definir un Slice

La sintaxis de inicialización de un slice es muy parecida a la de un array. La diferencia principal es que no es necesario especificar la longitud del elemento. Observa el siguiente código:

// Declare an array with a length of 5
var a1 [5]byte
// Declare a slice
var s1 []byte

Un Slice es una Referencia a un Array

Si declaramos un array de tipo int, el valor por defecto (zero value) de cada elemento será 0.

Sin embargo, si declaramos un slice, su valor por defecto será nil. Vamos a crear un archivo llamado slice.go para verificarlo:

touch ~/project/slice.go

Introduce el siguiente código:

package main

import "fmt"

func main() {
    var a [3]int
    var s []int

    fmt.Println(a[0] == 0) // true
    fmt.Println(s == nil)  // true
}
go run slice.go

Tras ejecutar el código, se mostrará la siguiente salida:

true
true

Hemos creado un array a y un slice s. Comparamos el primer elemento del array a con cero y comprobamos si el slice s es nil.

Como podemos observar, al declarar un slice, su valor inicial es nil. Esto se debe a que los slices no almacenan datos por sí mismos; solo referencian arrays. El slice apunta a una estructura de array subyacente.

Estructura de Datos de un Slice

Un slice es un tipo de dato compuesto, también conocido como estructura (struct). Es un tipo formado por campos de diferentes tipos. La estructura interna de un slice consta de tres elementos: un puntero, una longitud y una capacidad.

type slice struct {
    elem *type
    len  int
    cap  int
}

Como se mencionó anteriormente, la estructura referencia al array subyacente. El puntero elem apunta al primer elemento del array, y type es el tipo del elemento del array referenciado.

len y cap representan la longitud y la capacidad del slice, respectivamente. Puedes usar las funciones len() y cap() para obtener estos valores.

La siguiente imagen muestra que el slice referencia a un array subyacente de tipo int, con una longitud de 5 y una capacidad de 5:

slice referencing int array

Cuando defines un nuevo slice, el puntero elem se inicializa con el valor cero (es decir, nil). El concepto de punteros se introducirá en laboratorios posteriores. Por ahora, ten en cuenta que un puntero apunta a la dirección de memoria de un valor. En la imagen de arriba, el puntero elem apunta a la dirección del primer elemento del array subyacente.

Operaciones con Slices: Añadir, Eliminar, Modificar y Buscar

Truncamiento de Arrays o Slices

Dado que la estructura subyacente de un slice es un array, podemos extraer una longitud específica del array para usarla como referencia del slice. El siguiente segmento de código lo demuestra:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // Declare an empty slice
    var s1 []int

    fmt.Println("Slice s1 is empty:", s1 == nil)

    // Use array truncation to obtain the slice
    s1 = a[1:5]
    s2 := a[2:5]
    s3 := a[:]

    fmt.Println("Slice s1, s2, and s3 are empty:", s1 == nil, s2 == nil, s3 == nil)
    fmt.Println("Elements in array a:", a)
    fmt.Println("Elements in slice s1:", s1)
    fmt.Println("Elements in slice s2:", s2)
    fmt.Println("Elements in slice s3:", s3)
}

La salida es la siguiente:

Slice s1 is empty: true
Slice s1, s2, and s3 are empty: false false false
Elements in array a: [0 1 2 3 4 5 6 7 8 9]
Elements in slice s1: [1 2 3 4]
Elements in slice s2: [2 3 4]
Elements in slice s3: [0 1 2 3 4 5 6 7 8 9]

En este programa, primero declaramos e inicializamos el array a, y luego usamos el truncamiento de arrays para asignar una parte del mismo al slice vacío s1. Al hacer esto, creamos un nuevo slice.

s1[1:5] representa la creación de un slice del array. El rango del slice va desde el índice 1 del array a hasta el índice 5, excluyendo el quinto elemento.

Nota: En los lenguajes de programación, el primer elemento es el 0, no el 1. Por lo tanto, el segundo elemento del array tiene el índice 1.

Usamos el operador := para asignar el array truncado directamente al slice s2. Lo mismo se aplica a s3, pero al no especificar rango, trunca todos los elementos del array.

La imagen de abajo representa el truncamiento. Observa que la parte verde del slice representa una referencia al array azul. En otras palabras, ambos comparten el mismo array subyacente, que es a.

slice truncation visualization

La sintaxis de truncamiento para slices es la siguiente:

[start:end]

Tanto start como end son argumentos opcionales. Cuando queremos obtener todos los elementos del array, podemos omitir ambos, como se demostró en s3 := a[:].

Si queremos recuperar todos los elementos a partir de un índice determinado, omitimos el parámetro end. Por ejemplo, a1[3:] recuperará todos los elementos desde el índice 3 hasta el final.

Para recuperar todos los elementos antes de un índice determinado, omitimos el parámetro start. Por ejemplo, a1[:4] extraerá todos los elementos desde el índice 0 hasta el 4, excluyendo el elemento en el índice 4.

Además de extraer un slice de un array, también podemos extraer un nuevo slice de uno ya existente. La operación es idéntica. Aquí tienes un ejemplo sencillo:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // Create the initial slice s1
    var s1 []int
    s1 = a[1:7]
    fmt.Printf("Slice s1: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    // Extract a new slice s2 from the initial slice s1
    s2 := s1[2:4]
    fmt.Printf("Slice s1: %d\tLength: %d\tCapacity: %d\n", s2, len(s2), cap(s2))
}

La salida es la siguiente:

Slice s1: [1 2 3 4 5 6] Length: 6 Capacity: 9
Slice s1: [3 4] Length: 2 Capacity: 7

En este programa, obtuvimos el slice s1 truncando el array a. El rango de s1 es del índice 1 al 7. Usando :=, extrajimos un nuevo slice s2 de s1. Como el truncamiento de s1 es contiguo, el nuevo slice s2 también lo será.

Notamos que la capacidad de un slice cambia al truncarlo. Las reglas son las siguientes:

Si truncamos un slice con una capacidad c, la longitud de s[i:j] será j-i, y la capacidad será c-i.

Para s1, el array subyacente es a, y la capacidad de a[1:7] es 9 (es decir, 10-1).

Para s2, el array subyacente es el mismo que el de s1. Dado que la parte truncada tenía una capacidad de 9 en el paso anterior, la capacidad de s1[2:4] se convierte en 7 (es decir, 9-2).

Las Modificaciones en los Slices Afectan Simultáneamente al Array Subyacente

Dado que el slice no almacena datos, sino que referencia a un array, modificar un valor en el slice cambiará simultáneamente el valor en el array subyacente. Vamos a demostrarlo:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[2:5]
    s2 := a[:]

    fmt.Println("Before Modification: ")
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)

    // Modify the value at index 2 of the slice s1 to 23
    s1[2] = 23

    fmt.Println("After Modification: ")
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)
}

La salida es la siguiente:

Before Modification:
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
Elements in array a:  [2 3 4]
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
After Modification:
Elements in array a:  [0 1 2 3 23 5 6 7 8 9]
Elements in array a:  [2 3 23]
Elements in array a:  [0 1 2 3 23 5 6 7 8 9]

En este programa, tanto el slice s1 como s2 referencian al array a. Cuando el slice s1 modifica el valor en su índice 2 a 23, los valores del array a y del slice s2 también se actualizan.

Podemos ver que el valor en el índice 4 del array se modifica a 23 (porque s1 := a[2:5] significa que s1[0] refiere a a[2], s1[1] a a[3] y s1[2] a a[4]). Esto resulta en la modificación del valor en el índice 4 tanto del array a como del slice s2.

Esto podría causar errores difíciles de depurar en el desarrollo de programas. Por lo tanto, en la programación diaria, debemos intentar evitar que múltiples slices referencien el mismo array subyacente en la medida de lo posible.

Añadir Elementos a un Slice

En esta sección, presentaremos la función append, que se utiliza para añadir elementos a un slice. La sintaxis es la siguiente:

func append(slice []Type, elems ...Type) []Type

El primer argumento es el slice slice, y los argumentos restantes son los elementos que se añadirán. El []Type al final indica que la función append devolverá un nuevo slice con el mismo tipo de datos que el original.

Los puntos suspensivos ... después de elems representan que este es un parámetro variádico, lo que significa que se pueden ingresar uno o más parámetros.

Aquí tienes un ejemplo del uso de la función append:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[1:7]
    fmt.Printf("Initial s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 12)
    fmt.Printf("Modified s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 14, 14)
    fmt.Printf("Modified s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))
}

La salida es la siguiente:

Initial s1 value: [1 2 3 4 5 6] Length: 6 Capacity: 9
Modified s1 value: [1 2 3 4 5 6 12] Length: 7 Capacity: 9
Modified s1 value: [1 2 3 4 5 6 12 14 14] Length: 9 Capacity: 9

En este programa, primero creamos el slice s1 truncando el array a. Usamos el operador := para asignar el valor de retorno de append a s1. Cuando añadimos un elemento usando append, la capacidad del slice puede cambiar. La capacidad de s1 se duplicará si el número de elementos excede la capacidad actual.

Las reglas de expansión para slices son:

  • Si el array subyacente puede acomodar los nuevos elementos, la capacidad no cambia.
  • Si el array subyacente no tiene espacio suficiente, Go creará un array más grande, copiará los valores del slice original al nuevo y luego añadirá el nuevo valor.

Nota: La expansión del slice no siempre consiste en duplicar el tamaño; depende del tamaño de los elementos, la cantidad de elementos añadidos y el hardware del equipo, entre otros factores. Para más información, consulta la sección avanzada sobre slices.

Eliminar Elementos de un Slice

Go no proporciona palabras clave o funciones específicas para eliminar elementos de un slice, pero podemos usar el truncamiento de arrays para lograr esta funcionalidad de manera potente.

El siguiente código elimina el elemento en el índice 5 del slice s y lo asigna a un nuevo slice s1:

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    // Print the slice `s` before deleting
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // Print the slices `s` and `s1` after deleting the element at index 5
    fmt.Printf("%d\n%d\n", s, s1)
}

La salida es la siguiente:

[0 1 2 3 4 5 6 7 8 9]
[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

La clave para eliminar un índice específico reside en la sentencia append. Esta añade los elementos posteriores al índice 6 a los elementos anteriores al índice 5.

Esta operación equivale a sobrescribir los valores. Por ejemplo, el elemento 5 es sobrescrito por el 6, el 6 por el 7, y así sucesivamente hasta el 9. Por lo tanto, el valor del array subyacente en este punto es [0 1 2 3 4 6 7 8 9 9].

Si comprobamos la longitud y capacidad de s1, veremos que la longitud es 9, pero la capacidad sigue siendo 10. Esto se debe a que s1 es una referencia al truncamiento de s y comparten el mismo array subyacente.

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // Check the length and capacity of s1
    fmt.Println(len(s1), cap(s1))
    fmt.Printf("\n%d\n%d\n\n", s, s1)

    // Modify the slice s
    s[3] = 22
    fmt.Printf("%d\n%d\n", s, s1)
}

La salida es la siguiente:

[0 1 2 3 4 5 6 7 8 9]
9 10

[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

[0 1 2 22 4 6 7 8 9 9]
[0 1 2 22 4 6 7 8 9]

Ejercicio

Además de eliminar elementos en posiciones específicas, también podemos eliminar un rango de elementos de un slice mediante el truncamiento. La operación es la misma que para un solo índice. Hagamos un pequeño ejercicio.

Crea un archivo slice1.go. Crea un slice a e inicialízalo como se muestra a continuación. Luego, usa el truncamiento para crear otro slice s que no incluya elementos mayores que 3 ni menores que 7. Finalmente, imprime el nuevo slice.

a := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}

Salida esperada:

[9 8 7 3 2 1 0]
  • Pista: Presta atención al índice inicial del slice (recuerda que el índice comienza en 0).
  • Requisitos: El archivo slice1.go debe ubicarse en el directorio ~/project.
✨ Revisar Solución y Practicar

Expansión de Slices

Un slice tiene dos atributos: len y cap. len representa el número de elementos actuales, mientras que cap representa el número máximo de elementos que el slice puede contener.

¿Qué sucede cuando el número de elementos añadidos supera su capacidad? Averigüémoslo:

package main

import "fmt"

func main() {
    s1 := make([]int, 3)
    s2 := make([]int, 3, 5)
    fmt.Println("Before Append in s1:", s1, "Length:", len(s1), "Capacity:", cap(s1))
    fmt.Println("Before Append in s2:", s2, "Length:", len(s2), "Capacity:", cap(s2))

    s1 = append(s1, 12)
    s2 = append(s2, 22)
    fmt.Println("After Append in s1:", s1, "Length:", len(s1), "Capacity:", cap(s1))
    fmt.Println("After Append in s2:", s2, "Length:", len(s2), "Capacity:", cap(s2))
}

Para ejecutar el programa, usa el siguiente comando:

go run slice.go

La salida es la siguiente:

Before Append in s1: [0 0 0] Length: 3 Capacity: 3
Before Append in s2: [0 0 0] Length: 3 Capacity: 5
After Append in s1: [0 0 0 12] Length: 4 Capacity: 6
After Append in s2: [0 0 0 22] Length: 4 Capacity: 5

Como podemos ver, cuando añadimos elementos y estos superan la capacidad original, la capacidad del slice aumenta automáticamente.

Las reglas para expandir un slice son:

  • Si el array subyacente puede albergar los nuevos elementos, la capacidad no cambia.
  • Si no puede, Go crea un array más grande, copia los valores y añade los nuevos elementos.

Nota: Duplicar la capacidad no es una regla fija; depende del tamaño de los elementos, la cantidad y el hardware. Para más detalles, consulta la sección avanzada.

Copiar Slices

Podemos usar la función copy para duplicar un slice en otro. La sintaxis es:

func copy(dst, src []Type) int

dst es el slice de destino, src es el de origen, y el int devuelto indica el número de elementos copiados, que será el mínimo entre len(dst) y len(src).

Nota: La función copy no añade elementos adicionales si el destino es más pequeño.

Aquí tienes un ejemplo:

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3}
    s2 := []int{8, 9}

    s3 := []int{0, 1, 2, 3}
    s4 := []int{8, 9}

    // Copy s1 to s2
    n1 := copy(s2, s1)
    // Copy s4 to s3
    n2 := copy(s3, s4)

    fmt.Println(n1, s1, s2)
    fmt.Println(n2, s3, s4)
}

Para ejecutar el programa:

go run slice.go

La salida es la siguiente:

2 [0 1 2 3] [0 1]
2 [8 9 2 3] [8 9]

En este programa, copiamos los valores de s1 a s2, y de s4 a s3. La función devuelve la cantidad de elementos copiados.

Observamos que en la primera operación, se copian s1[0, 1] a s2[8, 9]. Como la longitud mínima es 2, solo se copian 2 valores. El slice de destino s2 se modifica a [0, 1].

En la segunda, se copian s4[8, 9] a s3[0, 1, 2, 3]. Al ser la longitud mínima 2, s3 se modifica a [8, 9, 2, 3].

Recorrer Slices

Recorrer un slice es similar a recorrer un array. Todos los métodos de iteración de arrays son aplicables a los slices.

Ejercicio

En este ejercicio, pondremos a prueba y reforzaremos nuestra comprensión sobre cómo recorrer slices y arrays.

Crea un archivo slice2.go. Declara un array a1 y un slice s1, e inicialízalos como se indica. Luego, itera sobre los elementos de ambos e imprime sus índices y valores.

a1 := [5]int{1, 2, 3, 9, 7}
s1 := []int{1, 8, 12, 1, 3}

Salida esperada:

Element a1 at index 0 is 1
Element a1 at index 1 is 2
Element a1 at index 2 is 3
Element a1 at index 3 is 9
Element a1 at index 4 is 7
Element s1 at index 0 is 1
Element s1 at index 1 is 8
Element s1 at index 2 is 12
Element s1 at index 3 is 1
Element s1 at index 4 is 3
  • Requisitos: El archivo slice2.go debe estar en el directorio ~/project.
  • Pista: Puedes usar el formato range o el formato de índice para iterar.
✨ Revisar Solución y Practicar

Resumen

En esta sección, hemos aprendido sobre los slices y su uso. En comparación, los slices son más flexibles y versátiles que los arrays. Al trabajar con múltiples slices, debemos ser cautelosos para evitar operaciones inesperadas que afecten al array subyacente compartido.