inout: pasar por referencia sin usar clases
La semántica copy-in copy-out de los parámetros `inout`, el momento exacto de la escritura de vuelta, y la ley de acceso exclusivo que impide distinguirla de un puntero.
A veces una función tiene que modificar lo que el llamador conserva, y convertir el tipo en class solo para conseguirlo sería pagar identidad, montículo y aliasing por un préstamo de tres líneas. Para eso existe inout. Parece paso por referencia, pero el lenguaje lo especifica como algo más modesto y más analizable: copy-in copy-out. Entra una copia, sale una copia, y una ley de exclusividad prohíbe cualquier programa capaz de notar la diferencia con un puntero. Esa combinación es lo que permite al compilador implementar el caso rápido sin exponerte jamás el aliasing.
- Escribir y llamar una función con parámetro
inoutusando el ampersand. - Enunciar copy-in copy-out y situar el momento exacto de la escritura de vuelta.
- Aplicar las reglas de acceso exclusivo y reconocer los errores de solapamiento.
- Delimitar qué puede hacer
inouty en qué casos una clase sigue siendo inevitable.
La forma: inout y el ampersand explícito
El parámetro se declara con inout después de los dos puntos, y el argumento se marca con un ampersand en el sitio de llamada. Esa marca no es decorativa: hace visible en el código del llamador que la variable puede salir alterada.
func duplicar(_ n: inout Int) { n *= 2 }
var x = 21
duplicar(&x) // el ampersand advierte al lector: esto se va a escribir
print(x) // 42
El argumento tiene que ser algo a lo que se pueda escribir: una variable var, una propiedad mutable, un elemento por subíndice. Ni las constantes, ni los literales, ni las propiedades de solo lectura son válidos. Un parámetro inout no admite valor por defecto ni puede ser variádico, porque en ambos casos no existiría un destino real al que devolver el resultado.
let fijo = 3
// duplicar(&fijo) // ERROR: cannot pass immutable value as inout argument
// duplicar(&7) // ERROR: cannot pass immutable value: literals are not mutable
var pareja = (a: 1, b: 2)
duplicar(&pareja.a) // válido: una propiedad mutable es un destino legítimo
Copy-in copy-out: la especificación, no el puntero
El modelo oficial tiene tres tiempos. Al entrar, el argumento se evalúa y su valor se copia en el parámetro. Durante la ejecución, el cuerpo trabaja sobre esa copia local. Al salir, el valor final se escribe de vuelta en el destino original. El compilador puede optimizar los dos extremos pasando directamente la dirección, pero solo cuando puede demostrar que nadie lo notará.
Y sí hay una forma de notarlo: los observadores de propiedad. Como la escritura de vuelta ocurre una sola vez, al retornar, willSet y didSet se disparan una vez aunque el cuerpo haya asignado veinte veces.
var registro: Int = 0 {
willSet { print("pasará a valer \(newValue)") }
didSet { print("valía \(oldValue)") }
}
func tresPasos(_ n: inout Int) { n += 1; n += 1; n += 1 }
tresPasos(®istro)
// pasará a valer 3
// valía 0
Con una propiedad calculada ocurre lo mismo llevado al extremo: el get se invoca una vez al entrar, el cuerpo trabaja sobre el resultado, y el set se invoca una vez al salir. Si el get es caro o tiene efectos, inout los ejecuta exactamente una vez cada uno y en ese orden.
struct Caja {
private var interno = 0
var valor: Int {
get { print("get"); return interno }
set { print("set"); interno = newValue }
}
}
var c = Caja()
tresPasos(&c.valor)
// get ← copy in
// set ← copy out, una sola vez al retornar
Este comportamiento es el que hace posible pasar como inout cosas que ni siquiera existen como almacenamiento: una propiedad calculada, un subíndice, un campo de una tupla anidada. Todo lo que sepa leerse y escribirse sirve de destino, porque el modelo solo necesita un get al principio y un set al final.
flowchart TB ll[Llamada con ampersand] --> ci[Copy in: se evalua el destino y se copia su valor] ci --> cu[El cuerpo muta una copia local] cu --> co[Copy out: se escribe de vuelta al retornar] co --> ob[Los observadores se disparan una sola vez] ll --> ex[Mientras dura la llamada rige el acceso exclusivo]
Acceso exclusivo: la ley que sostiene el modelo
Copy-in copy-out y paso por dirección solo dan resultados distintos si alguien mira o toca el destino mientras la llamada está en curso. Swift resuelve la ambigüedad prohibiéndolo: durante un acceso de escritura a una variable, ningún otro acceso a esa misma variable puede solaparse. Es la ley de acceso exclusivo, y un parámetro inout es precisamente un acceso de escritura de larga duración.
func combinar(_ a: inout Int, _ b: inout Int) { a += b }
var total = 10
// combinar(&total, &total)
// ERROR: inout arguments are not allowed to alias each other
El caso clásico está en las colecciones. Intercambiar dos elementos con la función libre swap requeriría dos accesos exclusivos simultáneos al mismo array, así que la biblioteca estándar ofrece un método que hace el trabajo con un único acceso.
var v = [1, 2, 3]
// swap(&v[0], &v[1]) // ERROR: overlapping accesses to 'v'
v.swapAt(0, 1) // la alternativa correcta
Para variables locales y parámetros el compilador comprueba la regla de forma estática. Para propiedades de clase, variables globales y capturas de closures que escapan, la verificación es dinámica y el programa se detiene en ejecución con el mensaje “Simultaneous accesses”. Este caso es el más instructivo, porque el conflicto se cuela por un closure que vuelve a leer lo que ya está prestado:
var numeros = [1, 2, 3]
func ajustar(_ v: inout [Int], con f: () -> Int) { v[0] = f() }
// ajustar(&numeros) { numeros.count }
// Fallo en ejecución: acceso de lectura solapado con el acceso de escritura inout
La decisión de diseño más fina de inout es haber especificado la semántica débil —copy-in copy-out— y haber prohibido después, por ley, todo programa capaz de distinguirla de la fuerte. El resultado es un espacio donde ambas interpretaciones coinciden, y en ese espacio el compilador puede elegir libremente la implementación eficiente: pasar la dirección cuando le convenga, copiar cuando no, sin cambiar nunca el significado de tu código. Fíjate en lo que eso te devuelve. Un inout no es un puntero porque no tiene vida propia: no puedes guardarlo, ni devolverlo, ni capturarlo en un closure que sobreviva a la llamada; existe solo entre la apertura y el cierre del acceso. No es una referencia porque no confiere identidad: el destino sigue siendo el mismo dato de valor de siempre y nadie puede preguntar si dos inout apuntan a lo mismo, precisamente porque la ley garantiza que no pueden. Y no es aliasing porque el préstamo es exclusivo mientras dura. Es, en rigor, la misma idea que Rust expresa con la referencia exclusiva, alcanzada desde el extremo opuesto: Rust parte del puntero y le impone exclusividad; Swift parte de la copia y le concede la dirección solo cuando la exclusividad ya está garantizada. Dos caminos, un único destino: mutación compartida en el tiempo, nunca en el espacio.
Qué no puede hacer inout
El préstamo termina cuando la función retorna, y esa acotación es exactamente lo que inout no puede superar. No sirve cuando el dato debe seguir siendo alcanzable después.
import Dispatch
func programar(_ n: inout Int, en cola: DispatchQueue) {
// cola.async { n += 1 }
// ERROR: escaping closure captures 'inout' parameter 'n'
n += 1 // solo la mutación síncrona es legal
}
De ahí se sigue el criterio práctico. Usa inout cuando la mutación es local y síncrona: una función auxiliar que actualiza una estructura, un acumulador que se rellena en un bucle, un algoritmo que ordena en el sitio. Necesitas una clase o un actor cuando el estado debe sobrevivir a la llamada, ser observado por varios propietarios, tener ciclo de vida propio con deinit, o cruzar suspensiones de concurrencia.
Es tentador declarar inout para evitar copiar una estructura grande. Innecesario: los parámetros normales de Swift se pasan como préstamos no propietarios y no se copian salvo que el receptor los mute o los conserve. Elige inout cuando la semántica que quieres es “esta función altera mi variable”, nunca como micro-optimización. Si el argumento es un tipo con copy-on-write, inout incluso ayuda a mantener la unicidad del búfer y a evitar duplicados, pero eso es una consecuencia, no el motivo.
inout presta un destino escribible durante la llamada, con semántica copy-in copy-out: copia al entrar, escritura de vuelta al retornar, observadores disparados una sola vez. Exige un destino mutable, y la ley de acceso exclusivo prohíbe cualquier acceso solapado, estáticamente en variables locales y dinámicamente en el resto. No escapa, no da identidad y no sustituye a una clase cuando el estado debe vivir más allá de la llamada.
- Escribe una función que ordene un array recibido como
inouty compruébala sobre una variable. - Añade
willSetydidSeta una variable, pásala a una función que la asigne tres veces y cuenta cuántas veces se disparan. - Provoca el error de aliasing pasando la misma variable a dos parámetros
inouty lee el diagnóstico entero. - Reproduce el fallo de exclusividad dinámica con un closure que lea la variable ya prestada.
- Intenta capturar un parámetro
inouten un closure con@escapingy explica con tus palabras por qué es imposible.