Inicializadores memberwise en Swift: menos boilerplate y mejores reglas en Swift 6.4
Arturo Rivas Arias
Los inicializadores memberwise son una de esas características de Swift que utilizamos constantemente sin prestarles demasiada atención. Declaramos una estructura, añadimos varias propiedades almacenadas y el compilador genera por nosotros un inicializador con los parámetros necesarios. Es una pequeña comodidad, pero fundamental para que los tipos por valor queden más compactos y sean más expresivos.
Sin embargo, esta síntesis automática siempre ha tenido reglas menos intuitivas de lo que parece. Los valores por defecto, las propiedades let, el control de acceso y la ubicación de otros inicializadores pueden cambiar la firma generada o incluso hacerla inaccesible. Swift 6.4 mejora uno de los casos más molestos mediante la SE-0502: una propiedad privada que ya puede inicializarse por sí misma deja de inutilizar el inicializador que necesitan el resto de propiedades.
Qué es un inicializador memberwise
Swift genera un inicializador memberwise para las estructuras a partir de sus propiedades almacenadas inicializables. Si definimos un tipo sencillo para representar una descarga, no es necesario escribir el init manualmente:
struct DownloadJob {
let url: URL
let destination: URL
var priority: Int
}
let job = DownloadJob(
url: remoteURL,
destination: localURL,
priority: 3
)
Conceptualmente, el compilador ha generado un inicializador equivalente a este:
init(url: URL, destination: URL, priority: Int) {
self.url = url
self.destination = destination
self.priority = priority
}
Esta síntesis pertenece a las estructuras. Las clases cuentan con otras reglas de inicialización y no reciben automáticamente un inicializador equivalente para todas sus propiedades.
Tampoco deben confundirse el inicializador memberwise y el inicializador vacío. Una estructura cuyas propiedades tienen valor inicial puede disponer de init(), mientras que el inicializador memberwise permite proporcionar valores para las propiedades que forman parte de su firma. Dependiendo de cómo estén declaradas, el compilador puede ofrecer uno, ambos o ninguno.
Los valores por defecto no siempre excluyen parámetros
Desde Swift 5.1 y la propuesta SE-0242, una propiedad variable con valor inicial continúa formando parte del inicializador memberwise, pero su parámetro recibe ese mismo valor como argumento por defecto:
struct ExportOptions {
let format: String
var includesMetadata = true
var quality = 0.85
}
let standard = ExportOptions(format: "heic")
let compact = ExportOptions(
format: "jpeg",
includesMetadata: false,
quality: 0.6
)
La firma sintetizada puede entenderse así:
init(
format: String,
includesMetadata: Bool = true,
quality: Double = 0.85
)
Esto permite conservar una API breve para el caso habitual sin impedir que el llamador sustituya los valores predeterminados.
Hay un matiz importante con las constantes. Una propiedad let que ya tiene un valor asignado no se incluye en el inicializador memberwise, porque una constante no puede volver a recibir otro valor durante la inicialización:
struct ThumbnailRequest {
let maximumPixelSize = 1_024
let imageURL: URL
var scale = 1.0
}
// init(imageURL:scale:)
let request = ThumbnailRequest(imageURL: photoURL)
maximumPixelSize no puede personalizarse mediante el inicializador sintetizado. En cambio, scale, al ser var, sí aparece con 1.0 como argumento por defecto.
El problema histórico de las propiedades privadas
Antes de Swift 6.4, una propiedad private o fileprivate podía reducir el nivel de acceso del inicializador completo aunque ya tuviera un valor inicial. El tipo parecía poder construirse sin conocer ese detalle interno, pero el compilador incluía la propiedad en la firma y hacía privado el resultado:
struct SearchConfiguration {
let query: String
var pageSize = 20
private var cachedCursor: String?
}
// Antes de Swift 6.4, conceptualmente:
// private init(
// query: String,
// pageSize: Int = 20,
// cachedCursor: String? = nil
// )
let configuration = SearchConfiguration(query: "Swift")
// Error: el inicializador es inaccesible por su nivel de acceso private
El problema no era que cachedCursor careciera de valor: un opcional se inicializa de forma predeterminada a nil. El problema era que su presencia en la firma arrastraba el nivel de acceso de todo el inicializador. La solución habitual consistía en escribir manualmente un init que solo expusiera las propiedades relevantes:
struct SearchConfiguration {
let query: String
var pageSize = 20
private var cachedCursor: String?
init(query: String, pageSize: Int = 20) {
self.query = query
self.pageSize = pageSize
}
}
El código funciona, pero repite exactamente una tarea que el compilador ya sabe realizar.
La nueva regla de Swift 6.4
SE-0502 cambia la forma en que Swift decide qué propiedades participan en el inicializador implícito. Primero calcula el máximo nivel de acceso que este puede alcanzar, con internal como límite superior. Después excluye una propiedad cuando se cumplen dos condiciones:
- Tiene un nivel de acceso inferior al máximo calculado para el inicializador.
- Ya dispone de un valor inicial, declarado expresamente o proporcionado de forma predeterminada por el tipo.
Con Swift 6.4, el ejemplo anterior genera el inicializador útil que cabría esperar:
struct SearchConfiguration {
let query: String
var pageSize = 20
private var cachedCursor: String?
}
// Swift 6.4:
// internal init(query: String, pageSize: Int = 20)
let configuration = SearchConfiguration(query: "Swift")
cachedCursor permanece encapsulado y comienza siendo nil, pero ya no contamina la API de creación del tipo. La misma regla se aplica a una propiedad con una expresión explícita, como private var retryCount = 0.
No se trata, por tanto, de una excepción específica para la palabra private. La comparación se realiza entre niveles de acceso. Una propiedad private puede excluirse de un inicializador potencialmente internal, y una propiedad fileprivate también puede excluirse si resulta menos accesible que ese máximo.
El nivel de acceso del setter no participa en este cálculo. Una propiedad private(set) sigue teniendo el nivel de lectura de la declaración —normalmente internal—, por lo que no se considera menos accesible y continúa en el inicializador:
struct InboxSummary {
let accountName: String
private(set) var unreadCount = 0
}
// unreadCount sigue siendo un argumento con valor por defecto.
let restored = InboxSummary(
accountName: "Trabajo",
unreadCount: 12
)
La restricción impide modificar unreadCount desde fuera después de crear el valor, pero no impide proporcionar su estado inicial mediante el init sintetizado.
Cuándo una propiedad privada sigue formando parte del inicializador
Swift solo puede excluir una propiedad si existe otra forma de darle un valor. Si una constante privada no tiene valor inicial, continúa siendo necesaria para construir la instancia:
struct SignedPayload {
let data: Data
private let signature: Data
}
// La firma continúa incluyendo signature y sigue siendo private:
// private init(data: Data, signature: Data)
Si la firma debe generarse internamente, es necesario expresar esa decisión mediante un inicializador propio:
struct SignedPayload {
let data: Data
private let signature: Data
init(data: Data, signer: PayloadSigner) throws {
self.data = data
self.signature = try signer.sign(data)
}
}
También existe un caso menos evidente: si todas las propiedades inicializables son privadas, el máximo nivel de acceso calculado también es private. Como ninguna de ellas es menos accesible que el propio inicializador, Swift no tiene motivo para excluirlas.
struct RenderMetrics {
private var frameCount = 0
private var droppedFrames: Int?
}
// El comportamiento no cambia: el inicializador continúa siendo private.
La mejora está diseñada para impedir que un detalle de implementación reduzca la accesibilidad de una API que, por el resto de sus propiedades, podría ser más visible. No pretende convertir automáticamente en pública o interna la construcción de tipos totalmente privados.
Los tipos públicos todavía necesitan un inicializador explícito
El límite superior de un inicializador memberwise sintetizado sigue siendo internal. Declarar una estructura y sus propiedades como public no crea una API pública de inicialización para los clientes de otro módulo:
public struct ImageDescriptor {
public let width: Int
public let height: Int
}
El tipo puede consultarse desde otro módulo, pero allí no puede construirse con ImageDescriptor(width:height:). Si forma parte de la API de un paquete o framework, el inicializador debe declararse expresamente:
public struct ImageDescriptor {
public let width: Int
public let height: Int
public init(width: Int, height: Int) {
self.width = width
self.height = height
}
}
Esta limitación es deliberada. El diseño de una estructura puede cambiar al añadir o reorganizar almacenamiento, mientras que un inicializador public pasa a formar parte del contrato del módulo. Swift obliga así al autor de la libería a decidir qué construcción quiere mantener como API estable.
Cómo añadir otros inicializadores sin perder el sintetizado
Declarar un inicializador personalizado dentro del cuerpo principal de la estructura impide disponer de los inicializadores automáticos que Swift habría proporcionado. Cuando se quiere conservar el memberwise y añadir una forma alternativa de crear el valor, el patrón habitual consiste en colocar el inicializador adicional en una extensión:
struct GridPosition {
let row: Int
let column: Int
}
extension GridPosition {
init(linearIndex: Int, columns: Int) {
self.init(
row: linearIndex / columns,
column: linearIndex % columns
)
}
}
let explicit = GridPosition(row: 2, column: 4)
let converted = GridPosition(linearIndex: 24, columns: 10)
De este modo continúan disponibles tanto init(row:column:) como init(linearIndex:columns:). La técnica resulta especialmente útil para tipos internos y para modelos de aplicación en los que el inicializador sintetizado ya representa una construcción válida.
Compatibilidad con el código existente
El cambio de Swift 6.4 podría romper código que, dentro del mismo archivo, utilizara la antigua firma para proporcionar expresamente una propiedad privada. Para reducir ese impacto, el compilador sintetiza temporalmente una sobrecarga de compatibilidad con la forma anterior:
struct PlaybackState {
let trackID: String
private var elapsedSeconds = 0.0
func restarted() -> PlaybackState {
PlaybackState(trackID: trackID, elapsedSeconds: 0)
}
}
Además del nuevo init(trackID:), Swift puede mantener la variante privada init(trackID:elapsedSeconds:) para que este uso siga compilando. La propuesta deja abierta su eliminación en un futuro modo del lenguaje. Si un tipo depende intencionadamente de esa firma privada, lo más robusto es declararla de manera explícita y no basar su diseño en la sobrecarga transitoria.
SE-0502 no introduce cambios de ABI ni requisitos de despliegue porque estos inicializadores nunca superan el nivel internal. El efecto se produce al compilar el código con la nueva versión de Swift, no al ejecutar la aplicación en una versión concreta de iOS, macOS, watchOS o tvOS.
Por qué el cambio también es importante para las macros
Las macros adjuntas pueden generar almacenamiento auxiliar privado para implementar una determinada característica. Hasta Swift 6.4, ese almacenamiento podía modificar accidentalmente el nivel de acceso del inicializador sintetizado del tipo al que se aplicaba la macro. El desarrollador acababa necesitando generar también un init o exigiendo que lo escribiera quien utilizaba la macro.
Las propiedades envueltas mediante property wrappers ya recibían un tratamiento específico en situaciones similares. La nueva regla acerca el comportamiento del almacenamiento generado por macros al resultado que un desarrollador espera de una propiedad privada con valor inicial: debe ser un detalle de implementación y no alterar una API no relacionada.
Una mejora pequeña con efectos muy prácticos
Los inicializadores memberwise funcionan mejor cuando reflejan la parte configurable del estado y dejan fuera los detalles que el propio tipo ya sabe inicializar. Swift 6.4 corrige un punto en el que el control de acceso y la síntesis automática producían una API que podría resultar poco intituitiva, sin renunciar a la inicialización segura de todas las propiedades.
La regla práctica queda bastante clara: las propiedades variables con valores por defecto suelen aparecer como parámetros con argumentos predeterminados; las constantes que ya tienen valor quedan fuera; las propiedades menos accesibles e inicializadas pueden excluirse desde Swift 6.4; y las que no tienen ningún valor deben seguir participando o inicializarse en un init explícito. En módulos públicos, ese inicializador explícito continúa siendo imprescindible para definir una API estable con un contrato claro.