Hay bloques de código que apenas tienen trabajo: reciben un elemento, leen una propiedad y devuelven su valor. Los escribimos continuamente al transformar colecciones con map, pero Swift permite expresar esa intención de una forma más directa.
Desde Swift 5.2, gracias a SE-0249, podemos utilizar un literal de key path donde se espera una función con la forma (Root) -> Value. Una pequeña comodidad que ayuda a leer operaciones sobre datos sin detenerse en cada $0.
De leer una propiedad a describir su recorrido
Imaginemos una aplicación que supervisa los sensores de un invernadero:
struct Location {
let sector: String
}
struct Sensor {
let name: String
let location: Location
let isOnline: Bool
let lastReading: Double?
}
let sensors = [
Sensor(name: "Temperatura", location: Location(sector: "A"),
isOnline: true, lastReading: 23.5),
Sensor(name: "Humedad", location: Location(sector: "B"),
isOnline: false, lastReading: nil)
]
Para obtener sus nombres, ambas expresiones producen el mismo resultado:
let namesWithBlock = sensors.map { $0.name }
let names = sensors.map(\.name)
El key path \.name describe cómo llegar desde un sensor hasta su nombre. El compilador conoce el tipo de los elementos y puede deducir que la raíz es Sensor. Si necesita más información, podemos escribir \Sensor.name.
También podemos recorrer varias propiedades:
let sectors = sensors.map(\.location.sector)
Como explica la documentación del lenguaje, los key paths representan accesos a propiedades o subíndices. Sus componentes pueden incluir encadenamiento opcional, de modo que también sirven para recorrer modelos con valores opcionales intermedios.
Opcionales y condiciones booleanas
Con map(\.lastReading) obtenemos un array de Double?, conservando los valores ausentes. Si queremos únicamente las mediciones disponibles, usamos compactMap:
let readings = sensors.compactMap(\.lastReading)
// [23.5]
La eliminación de los nil corresponde a compactMap; el key path solo indica qué propiedad debe leer.
Las propiedades booleanas encajan igualmente en los métodos que esperan un predicado (Element) -> Bool:
let onlineSensors = sensors.filter(\.isOnline)
let firstOnline = sensors.first(where: \.isOnline)
let hasOnlineSensors = sensors.contains(where: \.isOnline)
Podemos combinar estas operaciones para obtener las últimas mediciones de los sensores conectados:
let onlineReadings = sensors
.filter(\.isOnline)
.compactMap(\.lastReading)
Cada paso deja visible qué condición se aplica y qué dato se extrae. Esta es una buena razón para adoptar la sintaxis en transformaciones sencillas y frecuentes.
La conversión depende del contexto
Aquí aparece un detalle que puede resultar desconcertante: guardar un key path en una constante cambia el contexto en el que el compilador lo interpreta.
let namePath = \Sensor.name
// namePath es un KeyPath<Sensor, String>
// sensors.map(namePath) // No compila
let storedPathNames = sensors.map { $0[keyPath: namePath] }
La conversión automática se aplica al literal, no a cualquier valor de tipo KeyPath. Para reutilizar una función obtenida a partir de ese literal, podemos proporcionar el tipo al declararla:
let readName: (Sensor) -> String = \.name
let reusedNames = sensors.map(readName)
Ahora readName tiene un tipo de función. Esta posibilidad también demuestra que la conversión puede producirse al declarar una constante, además de al pasar un argumento directamente.
Cuándo mantener el bloque
Si la transformación llama a un método, hace un cálculo o combina propiedades, un bloque sigue expresando claramente la operación:
let uppercaseNames = sensors.map { $0.name.uppercased() }
let offlineSensors = sensors.filter { !$0.isOnline }
La negación necesita una operación adicional. !\.isOnline requeriría definir un operador propio que convierta el key path en un predicado; para un caso tan sencillo, mantener el bloque evita añadir vocabulario al proyecto.
También importa el número de argumentos. sorted(by:) espera una comparación entre dos elementos, mientras que esta conversión de key paths genera funciones que reciben uno:
let orderedSensors = sensors.sorted { $0.name < $1.name }
Para ofrecer una API como sorted(by: \.name) habría que añadir una sobrecarga que aplicase el key path a ambos elementos y comparase los resultados. Esa decisión merece la pena cuando simplifica una necesidad repetida.
El beneficio principal de esta sintaxis está en la legibilidad. Usar \.property cuando toda la operación consiste en acceder a esa propiedad hace que las colecciones se lean con más fluidez. Cuando aparece lógica adicional, el bloque permite mostrarla sin esconder lo que realmente está ocurriendo. Y perdón porque no he encontrado una forma de traducir key path.