Swift de Cero a Experto #6: Closures — capturas, memoria y poder funcional
Closure expressions, captura de valores, capture lists, @escaping vs non-escaping y por qué los closures son reference types que viven en el heap.
En el artículo anterior descubrimos que las funciones en Swift son ciudadanos de primera clase — valores que puedes almacenar, pasar y retornar. Hoy damos el paso que cambia todo: los closures.
Si una función es un bloque de código con nombre, un closure es un bloque de código que recuerda. Recuerda las variables que lo rodeaban cuando fue creado, las lleva consigo a donde vaya, y puede modificarlas incluso cuando el scope original ya no existe. Esa capacidad de “recordar” tiene un nombre técnico: captura. Y tiene consecuencias directas en memoria que todo desarrollador Swift necesita entender.
Este es, probablemente, el artículo más importante de la serie hasta ahora.
Un closure no es solo una función anónima — es una función con memoria. Y esa memoria tiene un costo real que vive en el heap.

¿Qué es un closure?
Antes de ver sintaxis, necesitamos una definición clara:
En Swift, hay tres formas de closures — y ya conoces dos:
- Funciones globales — tienen nombre, no capturan nada
- Nested functions — tienen nombre, pueden capturar del scope padre (artículo #5)
- Closure expressions — no tienen nombre, pueden capturar del contexto
Las funciones globales y nested functions son casos especiales de closures. Cuando la gente dice “closure” en Swift, generalmente se refiere a la tercera forma: las closure expressions.
Closure Expressions: la sintaxis
Veamos cómo Swift simplifica la sintaxis paso a paso, usando sorted(by:) como ejemplo — directamente desde la documentación oficial:
let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]Queremos ordenar en orden inverso. La forma más explícita es con una función:
// 1. Función completafunc backward(_ s1: String, _ s2: String) -> Bool { return s1 > s2}var reversed = names.sorted(by: backward)Ahora como closure expression, paso a paso:
// 2. Closure con tipos explícitosreversed = names.sorted(by: { (s1: String, s2: String) -> Bool in return s1 > s2})
// 3. Tipos inferidos del contextoreversed = names.sorted(by: { s1, s2 in return s1 > s2 })
// 4. Implicit return (una sola expresión)reversed = names.sorted(by: { s1, s2 in s1 > s2 })
// 5. Shorthand argument namesreversed = names.sorted(by: { $0 > $1 })
// 6. Operator method — la forma más corta posiblereversed = names.sorted(by: >)
Seis formas de escribir exactamente lo mismo. El compilador genera el mismo código para todas — la diferencia es puramente de legibilidad.
Trailing Closures
Cuando el último parámetro de una función es un closure, puedes sacarlo fuera de los paréntesis:
// Sin trailing closurenames.sorted(by: { $0 > $1 })
// Con trailing closurenames.sorted { $0 > $1 }Si el closure es el único argumento, puedes omitir los paréntesis por completo. Esto es lo que hace posible la sintaxis de SwiftUI:
// SwiftUI usa trailing closures en todoVStack { Text("Hello") Text("World")}// VStack.init(content:) recibe un closure como último parámetroMultiple trailing closures
Cuando una función recibe más de un closure, puedes usar esta sintaxis:
func loadPicture( from server: Server, completion: (Picture) -> Void, onFailure: () -> Void) { /* ... */ }
// Multiple trailing closuresloadPicture(from: someServer) { picture in someView.currentPicture = picture} onFailure: { print("Download failed")}El primer closure va sin label (trailing), los siguientes llevan su label.
Capturing Values: donde la magia (y el costo) ocurren
Aquí es donde los closures se separan de las funciones simples. Un closure puede capturar constantes y variables del contexto donde fue definido — y luego usarlas y modificarlas incluso después de que ese contexto ya no exista.
Veamos el ejemplo clásico de la documentación oficial:
func makeIncrementer(forIncrement amount: Int) -> () -> Int { var runningTotal = 0 func incrementer() -> Int { runningTotal += amount // captura runningTotal y amount return runningTotal } return incrementer}makeIncrementer retorna una función (incrementer) que usa dos variables de su scope padre: runningTotal y amount. Cuando makeIncrementer termina, su stack frame se destruye — pero esas variables necesitan sobrevivir porque incrementer las usa.
Swift resuelve esto moviendo runningTotal y amount a una capture box en el heap. El closure apunta a esa box, y cada vez que lo llamas, accede a las variables ahí.
Navega paso a paso para ver cómo funciona:
¿Cómo captura un closure?
Observa cómo las variables se mueven del stack al heap cuando un closure las captura.
let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen() // 10incrementByTen() // 20incrementByTen() // 30 — ¡runningTotal persiste entre llamadas!
// Un segundo closure tiene su PROPIA capture boxlet incrementBySeven = makeIncrementer(forIncrement: 7)incrementBySeven() // 7 — independiente de incrementByTenUn closure no copia las variables que captura — las comparte. Cada llamada al closure accede a las mismas variables en el heap, y los cambios persisten.
Closures son Reference Types
Si los closures capturan variables y viven en el heap, ¿qué pasa cuando asignas un closure a otra variable?
let alsoIncrementByTen = incrementByTenalsoIncrementByTen() // 40incrementByTen() // 50 — ¡el mismo runningTotal!Asignar un closure a otra variable no lo copia. Ambas variables apuntan al mismo closure con la misma capture box. Es como tener dos controles remoto para la misma TV.
Esto es fundamentalmente diferente de los value types como Int o Array:
var a = 42var b = a // b es una COPIA — cambiar b no afecta ab += 1 // a sigue siendo 42
// Pero con closures:let c1 = incrementByTenlet c2 = c1 // c2 apunta al MISMO closure — no hay copia@escaping vs non-escaping: la decisión del heap
Este es uno de los conceptos que más confusión genera — y el que más impacta en memoria. Necesitamos definirlo con claridad.
// Non-escaping (default) — se ejecuta dentro de la funciónfunc doWork(work: () -> Void) { work() // Se ejecuta aquí y muere}
// @escaping — sobrevive después de la funciónvar savedClosures: [() -> Void] = []func saveForLater(closure: @escaping () -> Void) { savedClosures.append(closure) // El closure sobrevive}¿Por qué importa? Por la memoria:

- Non-escaping: el compilador puede alocar el closure y su capture context en el stack. Cuando la función termina, todo se limpia automáticamente. Zero heap allocation.
- @escaping: el closure debe vivir en el heap porque necesita sobrevivir. El capture context se aloca con
malloc, tiene refcount, y se libera con ARC cuando ya no hay referencias.
Capture Lists: controlando la captura
Hasta ahora, las capturas han sido automáticas — Swift decide qué capturar y cómo. Pero puedes tomar control explícito con una capture list.
Captura por valor vs por referencia
var counter = 0
// Sin capture list — captura por REFERENCIAlet byReference = { print(counter) }counter = 10byReference() // 10 — ve el valor actual
// Con capture list — captura por VALOR (copia)counter = 0let byValue = { [counter] in print(counter) }counter = 10byValue() // 0 — tiene su propia copia del momento de la captura![El capibara con un escudo protector marcado [weak self]](/_astro/capture-list.B48w34Ol_Z14HkgW.webp)
[weak self] y [unowned self]
Estas son las capture lists que más vas a usar en código real:
class ViewController { var label = "Hello"
func setupTimer() { // [weak self] — self puede ser nil Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self else { return } print(self.label) }
// [unowned self] — asumo que self SIEMPRE existe // Crashea si self fue deallocado someOperation { [unowned self] in print(self.label) } }}[weak self]→ Cuando el closure puede sobrevivir al objeto. Ejemplo: timers, network requests, animaciones. Es la opción segura por defecto.[unowned self]→ Cuando estás seguro de que self vivirá más que el closure. Más rápido que weak (no necesita side table), pero crashea si te equivocas.[value]→ Cuando quieres congelar el valor actual en lugar de capturar la referencia.
Autoclosures: evaluación diferida
Un autoclosure es un closure que el compilador crea automáticamente para envolver una expresión:
var customers = ["Chris", "Alex", "Ewa", "Barry"]
// Sin autoclosure — necesitas las llaves { }func serve(customer provider: () -> String) { print("Serving \(provider())!")}serve(customer: { customers.remove(at: 0) })
// Con @autoclosure — se ve como una llamada normalfunc serve(customer provider: @autoclosure () -> String) { print("Serving \(provider())!")}serve(customer: customers.remove(at: 0))// Se ve como si pasaras un String, pero es un closure// La evaluación se difiere hasta que provider() se llamaEl operador ?? (nil-coalescing) que vimos en el artículo #1 usa @autoclosure para el valor por defecto — así solo se evalúa si realmente se necesita.
Higher-Order Functions: closures en acción
Aquí es donde los closures brillan en código real — transformando colecciones:
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// map — transforma cada elementolet doubled = numbers.map { $0 * 2 }// [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// filter — selecciona elementos que cumplen una condiciónlet evens = numbers.filter { $0 % 2 == 0 }// [2, 4, 6, 8, 10]
// reduce — combina todos los elementos en un valorlet sum = numbers.reduce(0) { $0 + $1 }// 55
// Encadenados — funcional y expresivolet result = numbers .filter { $0 % 2 == 0 } // Solo pares .map { $0 * $0 } // Elevar al cuadrado .reduce(0, +) // Sumar todo// 220Volveremos a profundizar en estas funciones en el artículo #19 sobre patrones funcionales. Por ahora, lo importante es que cada una recibe un closure como parámetro.
La memoria detrás de los closures
Llegamos a la sección que conecta todo con nuestro hilo de memoria.
Anatomía de un closure en memoria
Un closure en Swift no es solo un puntero a código. Es una estructura de dos partes:
┌─────────────────────────┐│ Closure ││ ┌───────────────────┐ ││ │ function pointer │──── → código en __TEXT│ ├───────────────────┤ ││ │ capture context │──── → capture box en el heap│ └───────────────────┘ │└─────────────────────────┘- Function pointer — apunta al código ejecutable en
__TEXT(igual que una función normal) - Capture context — apunta a la capture box en el heap donde viven las variables capturadas
Si el closure no captura nada (como una función global), el capture context es vacío y no hay heap allocation.
Non-escaping: la optimización del stack
Cuando el compilador sabe que un closure no escapa, puede hacer algo muy inteligente: alocar el closure y su capture context en el stack en lugar del heap.
// Non-escaping — el compilador aloca TODO en el stackfunc process(_ items: [Int], using transform: (Int) -> Int) -> [Int] { return items.map(transform)}
let result = process([1, 2, 3]) { $0 * 2 }// El closure { $0 * 2 } NUNCA toca el heapEsto es enorme para rendimiento. No hay malloc, no hay refcount, no hay free. Todo se limpia automáticamente cuando el stack frame se destruye.
@escaping: el costo del heap
Un escaping closure debe vivir en el heap:
var completionHandlers: [() -> Void] = []
func addHandler(handler: @escaping () -> Void) { completionHandlers.append(handler) // handler sobrevive — va al heap con su capture context}Cada escaping closure que captura variables implica:
mallocpara la capture box- refcount management (retain/release)
freecuando el último reference se libera
El compilador optimiza agresivamente
El compilador de Swift no se queda ahí:
- Inlining de closures: para closures pasados a
map,filter,reduce— el compilador puede copiar el código del closure directamente en el loop, eliminando la indirección por completo - Stack promotion: si el compilador prueba que un
@escapingclosure realmente no escapa en un caso específico, puede promoverlo al stack - Capture optimization: si una variable capturada no se muta, el compilador puede capturar una copia en lugar de una referencia (evitando la capture box)
- Non-escaping closure sin capturas → Zero cost, puede vivir en registros
- Non-escaping closure con capturas → Stack allocation, zero heap cost
- @escaping closure → Heap allocation obligatorio (capture box + refcount)
- Closures en map/filter/reduce → El compilador puede inlinear, eliminando overhead
- Cada capture box → malloc + refcount + eventual free
- Closures son reference types → Asignar = compartir, no copiar
Non-escaping por defecto no es una restricción — es un regalo del compilador. Le estás diciendo “puedes poner esto en el stack”, y el compilador te lo agradece con zero allocations.
Recapitulación
Hoy cubrimos uno de los conceptos más profundos de Swift:
- Closures = funciones + contexto capturado — tres formas: globales, nested, expressions
- Sintaxis evolutiva — de función completa a
>en 6 pasos, todas generan el mismo código - Trailing closures — la sintaxis que hace posible SwiftUI
- Capturing values — variables del scope exterior se mueven a una capture box en el heap
- Reference types — asignar un closure = compartir, no copiar
- @escaping vs non-escaping — non-escaping vive en el stack (free), escaping en el heap (costo)
- Capture lists —
[weak self],[unowned self], captura por valor - Autoclosures — evaluación diferida automática
- Higher-order functions —
map,filter,reducecomo closures en acción - Memoria — function pointer + capture context, inlining, stack promotion
Lo que viene
En el próximo artículo exploramos las Enumeraciones — que en Swift son mucho más que una lista de casos. Veremos raw values, associated values, enums recursivos con indirect, y cómo el compilador elige la representación más eficiente en memoria. Si vienes de otros lenguajes, los enums de Swift te van a sorprender.
Nos vemos la próxima semana.
Entender closures es entender cómo Swift piensa. Cada vez que escribes , estás creando un bloque de código que puede recordar su entorno, viajar a otro scope, y ejecutarse cuando sea necesario. Ese poder tiene un costo en memoria — y ahora sabes exactamente cuál es.
Relacionados
-
- swift
- ios
- performance
Dominando Instruments (Parte 4): Flame Graphs, Swift Concurrency bajo el microscopio y Processor Trace en acción
Aprende a leer Flame Graphs, auditar tareas asíncronas con Swift Tasks y exprimir el Processor Trace con un proyecto CLI real que usa Swift Concurrency de forma intensiva.
-
- swift
- swift-cero-experto
- swift-fundamentals
Swift de Cero a Experto #5: Funciones — ciudadanos de primera clase
Parámetros, labels, inout, function types y funciones como valores. La puerta de entrada a los closures y la programación funcional.
-
- swift
- ios
- performance
Dominando Instruments (Parte 3): método científico, Time Profiler avanzado y profiling a escala
Aprende a diagnosticar problemas de rendimiento como un proceso científico. Domina Weight vs Self-Weight, Charge/Prune/Flatten, y escala tu profiling con xctrace.