Swifty Journey

Swift de Cero a Experto #10: Herencia e Inicialización

Subclassing, overriding y super. Designated vs convenience initializers, two-phase initialization, failable y required init, y deinit — el ciclo de vida completo de una class, explicado en memoria.

En el artículo anterior diseccionamos propiedades, métodos y subscripts — el tejido conectivo de todo tipo. Hoy entramos al ciclo de vida de una class de nacimiento a muerte: cómo hereda de otra class (herencia), cómo se le da vida (inicialización), y cómo se destruye (deinicialización).

Esta es una historia exclusiva de classes. En el #8 aprendimos que solo las classes tienen herencia, identidad y deinit. Todo lo que veremos hoy nace de ese hecho: una instancia de class vive en el heap, puede construirse encima de otra class, y Swift tiene que garantizar que cada stored property — incluidas las que hereda — tenga un valor válido antes de que la toques.

Inicializar no es “correr algo de código de setup”. Es un contrato que el compilador hace cumplir: cuando init retorna, cada stored property tiene un valor real — sin excepciones, sin nil por accidente.

Herencia: construir encima de otra class

Una class que no hereda de nada es una base class. A diferencia de otros lenguajes, Swift no tiene una class raíz universal — tus base classes empiezan limpias:

class Vehicle {
var currentSpeed = 0.0
var description: String {
"traveling at \(currentSpeed) miles per hour"
}
func makeNoise() {
// do nothing — an arbitrary vehicle doesn't necessarily make a noise
}
}
let someVehicle = Vehicle()
print(someVehicle.description)
// traveling at 0.0 miles per hour

Para hacer subclassing, escribe el nombre de la subclass, dos puntos, y el nombre de la superclass. La subclass gana automáticamente todo lo que tiene la superclass:

class Bicycle: Vehicle {
var hasBasket = false
}
let bicycle = Bicycle()
bicycle.hasBasket = true
bicycle.currentSpeed = 15.0 // heredado de Vehicle
print(bicycle.description) // también heredado
// traveling at 15.0 miles per hour

Las subclasses también pueden tener subclasses — la cadena es tan profunda como quieras:

class Tandem: Bicycle {
var currentNumberOfPassengers = 0
}

Tandem hereda de Bicycle, que hereda de Vehicle. Una instancia de Tandem carga cada propiedad y método declarado en cualquier punto de esa cadena.

Cadena de herencia: Vehicle (currentSpeed, description, makeNoise) se anida en Bicycle (que agrega hasBasket), que se anida en Tandem (que agrega currentNumberOfPassengers). Cada caja de subclass acumula los miembros heredados más los propios.

Overriding: reemplazar comportamiento heredado

class Train: Vehicle {
override func makeNoise() {
print("Choo Choo")
}
}
let train = Train()
train.makeNoise() // "Choo Choo"

Esa keyword override hace trabajo real. Recuerda del #8: los métodos de class usan dynamic dispatch por defecto — en runtime, Swift consulta la vtable para encontrar qué implementación ejecutar de verdad. El overriding es justo lo que le da sentido a esa indirección: el makeNoise() de Train reemplaza la entrada de Vehicle en la vtable para las instancias de Train.

Llamar hacia arriba con super

A veces quieres extender el comportamiento de la superclass en vez de reemplazarlo por completo. El prefijo super alcanza la versión de la superclass de un método, propiedad o subscript:

class Car: Vehicle {
var gear = 1
override var description: String {
super.description + " in gear \(gear)"
}
}
let car = Car()
car.currentSpeed = 25.0
car.gear = 3
print(car.description)
// traveling at 25.0 miles per hour in gear 3

Car hace override de la computed property read-only description, llama a super.description para obtener el texto base, y luego le agrega lo suyo. Puedes hacer override de cualquier propiedad heredada con un getter personalizado (y setter, si aplica) — una subclass no puede saber si la propiedad heredada era stored o computed en la superclass; solo conoce su nombre y su tipo.

Override de observers en propiedades heredadas

También puedes agregar property observers a una propiedad heredada — aunque tú nunca la hayas declarado:

class AutomaticCar: Car {
override var currentSpeed: Double {
didSet {
gear = Int(currentSpeed / 10.0) + 1
}
}
}
let automatic = AutomaticCar()
automatic.currentSpeed = 35.0
print(automatic.description)
// traveling at 35.0 miles per hour in gear 4

final: cerrar la puerta

Marca un método, propiedad o subscript como final para prohibir hacerle override. Marca toda la class como final para prohibir hacerle subclassing por completo:

final class APIClient {
func fetch() { /* ... */ }
}
// class CachedClient: APIClient {} // ERROR — APIClient es final

Como vimos en el #8, final no es solo una herramienta de diseño — es una herramienta de rendimiento. Sin override posible, el compilador puede eliminar el lookup en la vtable y usar static dispatch, saltando directo al código.

Inicialización: el contrato

La regla de hierro: classes y structures deben asignar a todas sus stored properties un valor válido para cuando termine la inicialización. No existe un estado “sin inicializar” que puedas leer por accidente.

struct Fahrenheit {
var temperature: Double
init() {
temperature = 32.0
}
}
var f = Fahrenheit()
print(f.temperature) // 32.0

Si una propiedad tiene un valor por defecto en su declaración, ni siquiera necesitas asignarla en init — y los structs a los que no les escribes un initializer reciben uno memberwise gratis, exactamente como vimos en el #8. Las classes nunca reciben un memberwise init, y ahora vamos a ver por qué: la herencia convierte la inicialización de classes en un problema genuinamente más difícil.

Designated vs convenience initializers

Las classes tienen dos tipos de initializers, y esa distinción es el corazón de todo este capítulo.

class Food {
var name: String
init(name: String) { // designated
self.name = name
}
convenience init() { // convenience
self.init(name: "[Unnamed]")
}
}
let bacon = Food(name: "Bacon") // name es "Bacon"
let mystery = Food() // name es "[Unnamed]"

El init() convenience no toca name directamente — delega de lado al designated init(name:). Esa delegación es obligatoria, y se rige por tres reglas.

Las tres reglas de delegación

Reglas de delegación de initializers
  • Regla 1 — Un designated initializer debe llamar a un designated initializer de su superclass inmediata.
  • Regla 2 — Un convenience initializer debe llamar a otro initializer de la misma class.
  • Regla 3 — Un convenience initializer debe terminar llamando a un designated initializer.

El truco mnemotécnico que da el libro de Swift vale la pena memorizarlo:

Los designated initializers siempre delegan hacia arriba. Los convenience initializers siempre delegan de lado.

Delegación de initializers en una jerarquía Food / RecipeIngredient: los convenience inits delegan de lado ('across') a un designated init dentro de la misma class, mientras que el designated init de la subclass delega 'hacia arriba' al designated init de la superclass.

Two-phase initialization: bajo el capó

Aquí está la parte que explica por qué existen todas esas reglas. La inicialización de classes en Swift corre en dos fases, y el compilador lo hace cumplir.

¿Para qué tanto lío? Por seguridad de memoria. Una instancia de class ocupa una sola alocación en el heap que contiene el almacenamiento de cada class de su cadena. Hasta que cada uno de esos espacios tenga un valor real, el objeto no está formado del todo — y leer self significaría leer memoria sin inicializar.

El compilador hace cumplir cuatro safety checks para que esto sea hermético:

Los cuatro safety checks
  • Check 1 — Un designated init debe inicializar todas las propiedades que su propia class introduce antes de delegar hacia arriba a super.
  • Check 2 — Un designated init debe delegar hacia arriba a super antes de asignar un valor a una propiedad heredada (si no, super la sobrescribiría).
  • Check 3 — Un convenience init debe delegar a otro initializer antes de asignar a cualquier propiedad.
  • Check 4 — Ningún initializer puede leer propiedades de instancia, llamar métodos de instancia, o usar self como valor hasta que la fase 1 termine.

Veamos cómo se despliegan las fases:

class Bicycle: Vehicle {
var hasBasket: Bool
init(hasBasket: Bool) {
// FASE 1: primero pongo mi propia propiedad (safety check 1)
self.hasBasket = hasBasket
// ...luego delego hacia arriba (safety check 2)
super.init()
// FASE 2: ahora self es válido — puedo leerlo/personalizarlo (safety check 4)
currentSpeed = 10.0 // propiedad heredada, ya es seguro tocarla
}
}

Línea de tiempo de la two-phase initialization: la fase 1 sube por la cadena estableciendo el almacenamiento crudo con un banner 'self NO usable aún' y los checks 1-3, luego un límite duro, y después la fase 2 baja con un banner 'self ya es válido' y el check 4 que deja a cada class personalizar la instancia viva.

Este orden es exactamente lo que garantizan las Reglas 1–3. La fase 1 sube hasta lo alto de la cadena estableciendo el almacenamiento crudo; la fase 2 baja, y solo entonces self es un objeto real y usable.

Initializer inheritance y overriding

Aquí va una sorpresa si vienes de Objective-C o Java: las subclasses de Swift no heredan los initializers de su superclass por defecto. Esto evita que una subclass especializada se cree a través de un initializer genérico de la superclass que la deje a medio hacer.

Cuando escribes un initializer de subclass que coincide con un designated initializer de la superclass, le estás haciendo override — así que escribes override:

class Vehicle {
var numberOfWheels = 0
var description: String { "\(numberOfWheels) wheel(s)" }
// recibe un default initializer init() automáticamente
}
class Bicycle: Vehicle {
override init() {
super.init() // delega hacia arriba primero
numberOfWheels = 2 // luego personaliza la propiedad heredada
}
}
print(Bicycle().description) // "2 wheel(s)"

Automatic initializer inheritance

Las subclasses heredan los initializers de la superclass automáticamente — pero solo cuando es seguro. Asumiendo que les das valores por defecto a todas las propiedades nuevas que tu subclass introduce:

Reglas de herencia automática
  • Regla 1 — Si tu subclass no define designated initializers, hereda todos los designated initializers de la superclass.
  • Regla 2 — Si tu subclass implementa todos los designated initializers de la superclass (heredándolos vía Regla 1 o escribiéndolos), también hereda todos los convenience initializers de la superclass.

Por esto rara vez tienes que escribir initializers de boilerplate en la práctica. Aquí está la clásica jerarquía de tres niveles del libro de Swift que lo une todo:

class Food {
var name: String
init(name: String) { self.name = name } // designated
convenience init() { self.init(name: "[Unnamed]") }
}
class RecipeIngredient: Food {
var quantity: Int
init(name: String, quantity: Int) { // designated
self.quantity = quantity
super.init(name: name)
}
override convenience init(name: String) { // override del designated init de Food
self.init(name: name, quantity: 1)
}
}
class ShoppingListItem: RecipeIngredient {
var purchased = false // valor por defecto, no necesita init
var description: String {
"\(quantity) x \(name)" + (purchased ? " ✔" : " ✘")
}
}

RecipeIngredient hace override del designated init(name:) de Food — esta vez como convenience initializer — por eso lleva override. Como ahora implementa todos los designated initializers de Food, hereda automáticamente el convenience init() de Food. Y ShoppingListItem solo introduce una propiedad con valor por defecto y ningún initializer, así que hereda todo:

let a = ShoppingListItem() // convenience init() heredado
let b = ShoppingListItem(name: "Bacon") // heredado, quantity por defecto 1
let c = ShoppingListItem(name: "Eggs", quantity: 6) // designated init heredado

Failable initializers

A veces la inicialización debe poder fallar — parámetros inválidos, un recurso ausente, un estado inválido. Escribe init? y return nil en el punto de falla. El resultado es una instancia opcional.

struct Animal {
let species: String
init?(species: String) {
if species.isEmpty { return nil }
self.species = species
}
}
let giraffe = Animal(species: "Giraffe") // Animal? -> no nil
let nothing = Animal(species: "") // Animal? -> nil

Ya has usado failable initializers sin saberlo. Los enums con raw values reciben init?(rawValue:) gratis, y las conversiones numéricas como Int(exactly:) son failable:

let pi = 3.14159
let n = Int(exactly: pi) // nil — el valor no se puede preservar exacto

La falla se propaga: si un failable init delega a otro failable init que falla, todo el proceso aborta de inmediato:

class Product {
let name: String
init?(name: String) {
if name.isEmpty { return nil }
self.name = name
}
}
class CartItem: Product {
let quantity: Int
init?(name: String, quantity: Int) {
if quantity < 1 { return nil } // falla antes de delegar hacia arriba
self.quantity = quantity
super.init(name: name) // también puede fallar -> aborta todo el init
}
}
CartItem(name: "sock", quantity: 2) // CartItem? -> no nil
CartItem(name: "shirt", quantity: 0) // CartItem? -> nil (quantity inválido)
CartItem(name: "", quantity: 1) // CartItem? -> nil (la superclass falla por name)

Required initializers

Marca un initializer como required para forzar a que toda subclass lo implemente. Las subclasses también escriben required (no override) para propagar el requerimiento por la cadena:

class SomeClass {
required init() {
// ...
}
}
class SomeSubclass: SomeClass {
required init() {
// la subclass debe proveer esto
}
}

Puedes saltarte escribirlo explícitamente si un initializer heredado ya satisface el requerimiento. required importa más para la conformidad a protocolos (los protocolos vienen más adelante en la serie) y para APIs estilo factory (código que crea instancias por ti a partir de un tipo base) — donde el sistema de tipos necesita la garantía de que el initializer existe en cada subclass concreta.

Deinitialization: el último acto

Este es el cierre de todo lo que cubrimos. Recuerda del #8: las classes viven en el heap y las gestiona ARC (Automatic Reference Counting). Cuando la última referencia al objeto desaparece — su reference count llega a 0 — ARC lo desaloca. Justo antes de que eso pase, tu deinit recibe una última oportunidad para limpiar:

class FileHandle {
let path: String
init(path: String) {
self.path = path
print("Opened \(path)")
}
deinit {
print("Closing \(path)") // libera el recurso antes de la desalocación
}
}
var handle: FileHandle? = FileHandle(path: "/tmp/data")
// "Opened /tmp/data"
handle = nil
// "Closing /tmp/data" — refcount llegó a 0, corrió deinit, luego se liberó la memoria

El ejemplo Bank/Player del libro de Swift muestra por qué esto importa — deinit devuelve las monedas de un jugador al banco en el instante en que abandona el juego:

class Bank {
static var coinsInBank = 10_000
static func distribute(coins requested: Int) -> Int {
let toVend = min(requested, coinsInBank)
coinsInBank -= toVend
return toVend
}
static func receive(coins: Int) { coinsInBank += coins }
}
class Player {
var coinsInPurse: Int
init(coins: Int) { coinsInPurse = Bank.distribute(coins: coins) }
deinit { Bank.receive(coins: coinsInPurse) } // devuelve monedas al salir
}
var playerOne: Player? = Player(coins: 100)
print(Bank.coinsInBank) // 9900
playerOne = nil // dispara deinit -> las monedas regresan
print(Bank.coinsInBank) // 10000

init es el apretón de manos que garantiza un objeto válido. deinit es la despedida que devuelve lo que pidió prestado. ARC decide cuándo ocurre cada uno — tú decides qué hacen.

La memoria: el ciclo de vida en una imagen

Ciclo de vida de una instancia de class: init corre en dos fases, luego la instancia está VIVA en el heap con refcount = 1. Cada nueva referencia suma +1, cada referencia soltada resta -1. Cuando el refcount llega a 0, corre deinit (subclass primero, luego super), y finalmente se libera la memoria.

Recapitulación

  • Herencia — solo las classes la tienen; una subclass gana todo de su superclass y puede agregar más
  • Overriding — reemplaza comportamiento heredado con override; el compilador verifica que exista una coincidencia real
  • super — alcanza la versión de la superclass para extender en vez de reemplazar
  • final — prohíbe override/subclassing; permite al compilador usar static dispatch
  • Designated init — inicializa por completo su class, delega hacia arriba
  • Convenience init — un atajo, delega de lado a un designated init
  • Two-phase init — la fase 1 pone el almacenamiento subiendo la cadena, la fase 2 personaliza bajándola; cuatro safety checks lo hacen cumplir
  • Sin herencia automática de inits — salvo bajo las dos reglas de herencia automática
  • Failable initinit? retorna un opcional; la falla se propaga por la delegación
  • Required init — toda subclass debe implementarlo
  • deinit — corre justo antes de que ARC desaloque; el deinit de la superclass siempre corre también

Lo que viene

En el próximo artículo exploramos Optionals y Optional Chaining — el antídoto de Swift al error de mil millones de dólares. Veremos cómo Optional es solo un enum bajo el capó, en qué se compilan realmente ?, !, if let, guard let y ??, y por qué el sistema de tipos te obliga a enfrentar la ausencia de un valor de frente.

Nos vemos la próxima semana.

Una class no es solo datos y métodos — es un ciclo de vida. Domina init y deinit y dejas de pelear con ARC; empiezas a diseñar con él.

Referencias

Relacionados