Saltar al contenido
K
Swift: el lenguajePaso a paso28 min de lectura · 10 min de máquina Intermedio

Equatable y Hashable

Al terminar: Hacer que tus tipos se puedan comparar y usar como claves con Equatable y Hashable.

Ya tienes el reglamento como protocolo. Falta aprovechar dos cosas más de Swift: las extensiones sobre tipos que ya existen (Jugador, Equipo, hasta un Int) y los protocolos Equatable y Hashable, que dicen cuándo dos valores son «el mismo» y permiten usarlos como clave de un diccionario. Al terminar, torneo-cli imprimirá la etiqueta de Ana, el capitán de Toros y cuántos goles marcó Ana contando con un diccionario.

Temas: extensiones de un tipo, Equatable, Hashable y Dictionary(grouping:by:).

Los ejemplos y el ejercicio se escriben en tu Playground, el de Tu primer paquete de Swift: reutiliza el mismo y borra lo anterior.

Extensiones de un tipo que ya existe

Una extensión (extension) añade métodos y propiedades calculadas a un tipo desde fuera: el tipo puede ser tuyo (escrito en otro fichero) o de Swift, como Int o String. No puede añadir propiedades guardadas (con memoria propia). Y lo private de un tipo solo lo ve una extensión que esté en el mismo fichero que el tipo; una extensión escrita en otro fichero recibe 'x' is inaccessible due to 'private' protection level.

ejemplo mínimo: extensiones de un tipo propio y de Int (con comentarios)
struct Libro {
    let titulo: String
    let paginas: Int
}
 
// Una extensión de un tipo tuyo: añade un método sin tocar la declaración de Libro.
extension Libro {
    func ficha() -> String { "\(titulo) (\(paginas) págs.)" }
}
 
// Una extensión de un tipo de Swift: añade una propiedad CALCULADA a todos los Int.
extension Int {
    var enEuros: String { "\(self) €" }     // `self` es el número sobre el que se llama
}
 
let dune = Libro(titulo: "Dune", paginas: 600)
print(dune.ficha())
print(12.enEuros)

Al ejecutarlo, verás:

Dune (600 págs.)
12 €

Equatable y Hashable: dos protocolos que ya usas

Equatable significa «se puede comparar con ==». Hashable significa «se puede convertir en un número de identificación (hash)»; sin él, un valor no puede ser clave de un Dictionary (el Map de Kotlin: una colección de pares clave → valor) ni entrar en un Set (una colección sin repetidos).

TipoEquatable / Hashable
struct (como Jugador)Se escribe : Equatable, Hashable y el compilador escribe == y el hash comparando todas las propiedades
enumIgual que el struct; un enum sin valores asociados ya lo es sin escribir nada
class (como Equipo)No se sintetiza: hay que escribir == y hash(into:)

Un struct sin Equatable no se puede comparar (pruébalo en tu Playground):

ejemplo mínimo: sin Equatable no hay ==
struct Fila { let nombre: String }
let a = Fila(nombre: "a"), b = Fila(nombre: "a")
print(a == b)

El compilador responde error: binary operator '==' cannot be applied to two 'Fila' operands. Escribe struct Fila: Equatable y compila.

Una clase no tiene == hasta que lo escribes. Equipo (el extension Equipo: Hashable que pegaste en struct frente a class) lo define con === (la misma referencia) y para el hash usa ObjectIdentifier(self), un identificador único de cada objeto. Es lo que Kotlin hace por defecto con == en una clase normal.

Agrupar con un diccionario

Con Hashable, un valor puede ser clave. Dictionary(grouping: lista, by: { $0 }) crea un diccionario cuya clave es lo que devuelve la closure (aquí, el propio elemento) y cuyo valor es la lista de los elementos que comparten esa clave. .mapValues(\.count) cambia cada lista por su tamaño; \.count es una ruta de propiedad (key path), un atajo de { $0.count } que ya usaste en Closures con listas y verás con calma en Colecciones y key paths. Y buscar en un diccionario devuelve un opcional (la clave puede no estar), así que ?? 0 da el valor de respaldo.

ejemplo mínimo: contar visitas con Dictionary(grouping:) (con comentarios)
let entradas = ["1234ABC", "9876XYZ", "1234ABC"]
 
// La clave es la propia matrícula; el valor, la lista de entradas con esa matrícula.
let agrupadas = Dictionary(grouping: entradas, by: { $0 })
print(agrupadas["1234ABC"] as Any)   // `as Any` solo evita el aviso de Swift al imprimir un opcional
 
// Cada lista, por su tamaño: cuántas veces entró cada matrícula.
let visitas = agrupadas.mapValues(\.count)
print(visitas["1234ABC"] ?? 0)
print(visitas["0000AAA"] ?? 0)       // no está: ?? da el respaldo
Optional(["1234ABC", "1234ABC"])
2
0

Reto · Ejercicio: el pedido de fruta

En tu Playground, haz un struct para una fruta, con un nombre y un color, que se pueda comparar y servir de clave de un diccionario. Con una extensión, dale una etiqueta, «fresa (roja)». Con otra extensión sobre los números enteros, añade un texto en kilos: «3 kg». Con un pedido de tres frutas (fresa roja, kiwi verde y otra fresa roja), imprime: si una fresa roja es igual a otra fresa roja, la etiqueta de la fresa, cuántas fresas hay en el pedido y cuántas peras verdes (agrupando el pedido por fruta), y el texto en kilos del 3. Lo que debe salir:

true
fresa (roja)
2
0
3 kg
Ver solución
solución: el pedido de fruta
struct Fruta: Hashable {
    let nombre: String
    let color: String
}
 
extension Fruta {
    func etiqueta() -> String { "\(nombre) (\(color))" }
}
 
extension Int {
    var enKilos: String { "\(self) kg" }
}
 
let fresa = Fruta(nombre: "fresa", color: "roja")
let pedido = [fresa, Fruta(nombre: "kiwi", color: "verde"), Fruta(nombre: "fresa", color: "roja")]
 
print(fresa == Fruta(nombre: "fresa", color: "roja"))
print(fresa.etiqueta())
let cuantas = Dictionary(grouping: pedido, by: { $0 }).mapValues { $0.count }
print(cuantas[fresa] ?? 0)
print(cuantas[Fruta(nombre: "pera", color: "verde")] ?? 0)
print(3.enKilos)

Salida: true, fresa (roja), 2, 0 y 3 kg. Escribir : Hashable basta: el compilador compara el nombre y el color, y como Hashable incluye a Equatable, == también funciona.

Paso · Formato.swift y los goles de Ana

Objetivo. Añadir dos extensiones de formato (a Jugador y a Equipo), contar con un diccionario los goles de Ana y copiar los nueve tests del reglamento. Al ejecutar torneo-cli verás, además de las dos líneas de la lección anterior, la etiqueta de Ana y sus goles.

Torneo
Sources
Torneo
Formato.swiftse crea
torneo-cli
main.swiftse modifica
Tests
TorneoTests
ReglamentoTests.swiftse crea y se rellena con «Copiar fichero»
Lo que tocas, en el Project navigator de Xcode con el paquete Torneo abierto. Dos ficheros nuevos y uno que cambia.

El título de cada bloque de abajo es la ruta del fichero dentro de ~/Marcador-Swift, la misma que ves en el árbol de arriba.

Cómo crear el fichero. En el Project navigator (el panel de la izquierda de Xcode; se muestra u oculta con Cmd+1), despliega Sources, clic derecho sobre la carpeta Torneo (la de dentro de Sources) → New File from Template… → Swift File → Next, escribe Formato y pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal, en ~/Marcador-Swift/Torneo: touch Sources/Torneo/Formato.swift.

Jugador ya trae lo que necesita: su dorsalOGuion() (de Opcionales) y su Equatable, Hashable, que escribiste en esa misma lección. Equipo es Hashable desde struct frente a class.

Lo que exigen los tests que copiarás. Formato.swift: la etiqueta de un jugador devuelve «Ana (#9)» (nombre y dorsal o guion), y el capitanONinguno de un equipo devuelve el nombre del capitán o «sin capitán».

Intenta escribirlo tú. Añade a Jugador una función que dé el texto con su nombre y su dorsal, y a Equipo una propiedad calculada con el nombre del capitán o «sin capitán». En main.swift: imprime la etiqueta de Ana y el capitán de Toros; y cuenta, agrupando los goleadores de alFinal por jugador, los goles de Ana. Lo que debes ver, detrás de las dos líneas de la lección anterior:

Etiqueta: Ana (#9); capitán de Toros: sin capitán
Goles de Ana: 2

El bloque de main.swift va justo antes de let torneo = Torneo(…), detrás de las líneas del reglamento.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Formato.swift
// Una extensión añade cosas a un tipo que ya existe, sin tocar su fichero.
// En Kotlin sería `fun Jugador.etiqueta()`.
// Puede añadir métodos, propiedades CALCULADAS y adopciones de protocolo,
// pero no propiedades guardadas. Y solo ve lo `private` del tipo si está
// en el MISMO fichero que el tipo (estas dos viven en otro, así que no lo ven).
extension Jugador {
    // Texto con el nombre y el dorsal: «Ana (#9)».
    public func etiqueta() -> String { "\(nombre) (\(dorsalOGuion()))" }
}
 
extension Equipo {
    // `capitan` es opcional: `?.nombre` da el nombre o `nil`, y `??` pone el texto de respaldo.
    public var capitanONinguno: String { capitan?.nombre ?? "sin capitán" }
}
Torneo/Sources/torneo-cli/main.swift
print("Etiqueta: \(ana.etiqueta()); capitán de Toros: \(toros.capitanONinguno)")
 
// Hashable: un jugador puede ser clave de un diccionario o entrar en un Set.
let golesPorJugador = Dictionary(grouping: alFinal.goleadores(), by: { $0 }).mapValues(\.count)
print("Goles de Ana: \(golesPorJugador[ana] ?? 0)")

Compila y ejecuta antes de copiar nada. Desde la Terminal, en ~/Marcador-Swift/Torneo:

xcrun swift build
xcrun swift run torneo-cli

Los tests

ReglamentoTests.swift son nueve comprobaciones de lo que escribiste en las dos lecciones, del mismo tipo que las anteriores, así que no los escribes: se rellena con el botón Copiar fichero, que copia el contenido al portapapeles y no escribe nada en tu proyecto (se explica en Clases y objetos). El orden es siempre el mismo: crear el fichero, pulsar el botón, seleccionar todo, pegar.

  1. En el Project navigator, clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next → ReglamentoTests → Create. (Plan B: touch Tests/TorneoTests/ReglamentoTests.swift desde ~/Marcador-Swift/Torneo.)
  2. Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro de ReglamentoTests.swift, selecciona todo con Cmd+A y pega con Cmd+V.
ReglamentoTests.swift

De los nueve, uno enseña algo que el resto no: demuestra el error típico de la lección anterior con un test en vez de con un print.

el test que enseña algo
// Un tipo de prueba que escribe su propio resumen(), que NO es un requisito.
private struct ReglamentoConDerrotaCastigada: Reglamento {
    let puntosPorVictoria = 3
    let puntosPorEmpate = 1
    let puntosPorDerrota = -1
    func resumen() -> String { "el mío" }
}
 
@Test func unMetodoQueNoEsRequisitoSeDespachaEstaticamente() {
    let concreto = ReglamentoConDerrotaCastigada()
    let comoProtocolo: any Reglamento = concreto
    #expect(concreto.resumen() == "el mío")        // por el tipo concreto
    #expect(comoProtocolo.resumen() == "3/1/-1")   // por el protocolo: sale de la extensión
}

@Test marca la prueba y #expect(condición) comprueba que se cumple, como viste en La regla de los puntos y tu primer test. La prueba pasa precisamente porque las dos líneas dicen cosas distintas: es la trampa de la lección anterior, comprobada. private struct hace el tipo visible solo dentro de ese fichero.

Punto de control

  • xcrun swift build (desde ~/Marcador-Swift/Torneo) compila sin errores.
  • xcrun swift run torneo-cli imprime Reglamento Liga (3/1/0), Con reglas antiguas, una victoria vale 2, Etiqueta: Ana (#9); capitán de Toros: sin capitán y Goles de Ana: 2.
  • xcrun swift test termina con Test run with 51 tests in 7 suites passed: los 42 de la lección anterior más los 9 nuevos de ReglamentoTests.
  • Sabes decir por qué un struct necesita : Equatable, Hashable y una clase como Equipo necesita que escribas la igualdad y el hash.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Jugador es un struct y Equipo una clase. ¿Qué hace falta para que los dos sean Hashable?

  2. 2.¿Qué NO puede añadir una extensión a un tipo que ya existe?

  3. 3.Equipo define su igualdad con === y su hash con ObjectIdentifier(self). ¿Por qué?

  4. 4.Con let visitas = Dictionary(grouping: entradas, by: { $0 }).mapValues(\.count), ¿qué devuelve visitas["0000AAA"] ?? 0 si esa matrícula no aparece en entradas?

  5. 5.¿Qué hace Dictionary(grouping: pedido, by: { $0 })?

Faltan 5 de 5