Swift – Estructuras, Clases y Métodos

Hoy vamos a aprender acerca de las estructuras y las clases, ambas pilares sobre los que construimos nuestras aplicaciones. Esta es un área que comparte Swift con otro lenguajes de programación pero con sus particularidades propias, esas que lo definen como único.

Veremos las características más notables y constataremos como las estructuras y las clases nos brindan mecanismos que nos permitirán modelar todo cuanto queramos representar en nuestro código.

Sintaxis

Las estructuras y las clases cuentan con una definición de sintaxis muy similar. La declaración de las clases la iniciamos con la palabra clave class y las estructuras con struct, ambas establecen su definición dentro de las llaves:

class SomeClass {
    
    // definición aquí
    
} // SomeClass

struct SomeStructure {
    
    // definición aquí
    
} // SomeStructure

Estructuras

Una estructura es un tipo que agrupa en la memoria Stack un set de datos relacionados llamados propiedades y métodos que nos permitirán añadir funcionalidades.

Veamos un ejemplo:

struct User {
    
    var name: String
    
    init(name: String) {
        
        self.name = name.uppercased()
        
    } // init
    
    mutating func changeName(value: String) {
        
        name = value
        
    } // changeName
    
} // User

let nery = User(name: "Nery")

print("El nombre del nuevo usuario es: \(nery.name)")

en el ejemplo anterior he optado por mostrar un escenario donde entran en juego algunas características de las estructuras.

La variable name dentro de una estructura o clase le llamamos propiedad, sobre estas hablaremos con más detalle en el próximo artículo. Luego tenemos nuestro método init (inicializador), lo que en otros lenguajes vendría siendo el constructor.

En el caso de Swift el método init no es obligatorio ya que el compilador genera uno por nosotros, en el cual inicializa todas las propiedades.

Entonces ¿por qué lo hemos declarado en este ejemplo?

Lo he declarado de manera explícita dado que necesito que el valor de la propiedad name se inicialice de la manera que yo quiero, en este caso en mayusculas, es lo que logramos con la línea:

self.name = name.uppercased()

Al final tenemos el método changeName en el cual, haciendo honor a su nombre, cambiamos (o mutamos) el valor almacenado en nuestra propiedad name.

En este punto notamos que nuestro método comienza por la palabra clave mutating. Esta palabra clave solo se usa con las estructuras y se encarga de informar al compilador que dentro de este método se modifican los valores de una o varias propiedades.

La salida en pantalla de nuestro código sería:

El nombre del nuevo usuario es: NERY

Mutating

¿Por qué hay que escribir mutating en todo método que modifique una propiedad?

La respuesta rápida es:

Las estructuras son tipos por valor, y los tipos por valor son inmutables en Swift.


El lenguaje Swift

Veamos un ejemplo que explique mejor esto:

struct Point {
    
    var x = 0.0
    var y = 0.0
    
    mutating func scale(factor: Double) {
        
        self.x *= factor
        self.y *= factor
        
    } // scale

} // Point

Aquí tenemos una estructura de nombre Point en representación de un punto en un plano cartesiano.

No necesitamos un inicializador ya que no tenemos que procesar el valor de inicial de las propiedades X y Y con el inicializador por defecto es más que suficiente.

Por último tenemos un método de nombre scale que escala nuestro punto en base a un determinado factor que pasa el usuario de la estructura.

Bajo este código podemos hacer algo así:

var point = Point(x: 5, y: 25)

point.x = 10

print(point)

point.scale(factor: 2)

print(point)

la salida en pantalla sería:

Point(x: 10.0, y: 25.0)
Point(x: 20.0, y: 50.0)

Hasta aquí todo bien, no hay errores. Muchos de ustedes pudieras estar comentando: ¿Entonces?, ¿A que se refiere con eso de que los tipos por valor son inmutable si claramente lo estamos modificando?, ¡Es bien contradictorio!

La realidad es que no hay contradicción alguna, lo que sucede es que cada vez que mutamos algunas de las propiedades de la estructura, la variable point se sobreescribe con una nueva estructura de tipo Point, obviamente con el nuevo valor actualizado.

Esto lo podemos comprobar con una simple modificación del código:

struct Point {

    var x = 0.0
    var y = 0.0

    mutating func scale(factor: Double) {

        self.x *= factor
        self.y *= factor

    } // scale

} // Point

var point = Point(x: 5, y: 25) {
    
    didSet {
        
        print("\nLa instancia ha cambiado de: \(oldValue) a \(point)\n")
    
    } // didSet

} // property

point.x = 10

print("¡Cambio efectuado!")

point.scale(factor: 2)

print("¡Cambio efectuado!")

La salida en pantalla sería:

La instancia ha cambiado de: Point(x: 5.0, y: 25.0) a Point(x: 10.0, y: 25.0)

¡Cambio efectuado!

La instancia ha cambiado de: Point(x: 10.0, y: 25.0) a Point(x: 20.0, y: 50.0)

¡Cambio efectuado!

Todo esto ocurre tras bambalinas, tomando como referencia el ejemplo anterior: el orden sería el siguiente:

  • Usuario: Creamos una variable de tipo Point con los valores iniciales de x= 5 / y = 25.
  • Usuario: Pasamos el valor numérico 10 a la propiedad x de la variable point.
  • Compilador: Asocia a la variable point un nuevo valor de tipo Point con los valores de x = 10 / y = 25.
  • Usuario: Ejecutamos el método scale y pasamos como factor el valor 2.
  • Compilador: Asocia a la variable point un nuevo valor de tipo Point con los valores de x = 20 / y = 50.

Luego de analizar esto nos percatamos de que en nuestro primer ejemplo tenemos un error. Recordemos que creabamos la siguiente constante:

let nery = User(name: "Nery")

Si aplicamos lo que hemos aprendido sobre las estructuras y la palabra clave mutating:

Los valores de una estructura son inmutables, no cambian. Lo que se modifica es la instancia de dicha estructura, que tiene que ser una variable.

Si hemos entendido bien, no podriamos modificar ninguna de las propiedades de la estructura User. Ya que al llevar acabo la actualización de algunos de sus valores (en este caso la propiedad name) el compilador no podría asociar a nery una nueva instancia de User con dicha modificación.

Comprobemos esto ejecutando el método changeName:

nery.changeName(value: "Sara")

print("El nombre del nuevo usuario es: \(nery.name)")

y en efecto obtenemos un error por parte del compilador, el siguiente mensaje:

error: cannot use mutating member on immutable value: 'nery' is a 'let' constant
note: change 'let' to 'var' to make it mutable

La solución al error, como bien nos informa el mensaje de error, es cambiar el let por un var, nery tiene que ser una variable:

struct User {

    var name: String

    init(name: String) {

        self.name = name.uppercased()

    } // init

    mutating func changeName(value: String) {

        name = value

    } // changeName

} // User

var nery = User(name: "Nery")

print("El nombre del nuevo usuario es: \(nery.name)")

nery.changeName(value: "Sara")

print("El nombre del nuevo usuario es: \(nery.name)")

Al hacerlo ya todo funciona correctamente y la salida en pantalla es:

El nombre del nuevo usuario es: NERY
El nombre del nuevo usuario es: Sara

Nota: Espero que haya quedado claro, cualquier duda en los comentarios.

Clases

Una clase establece un tipo de dato que agrupa en la memoria Heap un set de datos relacionados, propiedades y métodos que nos permitirán añadir funcionalidades.

Veamos un ejemplo:

final class User {
    
    var name: String
    
    init(name: String) {
        
        self.name = name.uppercased()
        
    } // init
    
    func changeName(value: String) {
        
        name = value
        
    } // changeName
    
} // User

let nery = User(name: "Nery")

print("El nombre del nuevo usuario es: \(nery.name)")

En efecto, es practicamente el mismo ejemplo que hemos expuesto con las estructuras. Solamente hemos cambiado la palabra clave struct por class, hemos agregado al inicio final y eliminamos también la palabra clave mutating en el método changeName.

La palabre clave final no es obligatoria, pero sí se recomienda su uso en todas aquellas clases que no fungirán como clase base de ninguna otra, es decir: nadie heredará de ella.

La función de la palabra clave final es informar al compilador de esto mismo, para que este puede aplicar sobre la clase en cuestión las optimizaciones pertinentes.

Como ya había comentado la palabra clave mutating solo se aplica a la estructuras. En el caso de las clases podemos definir nuestra instancia como una constante, por ende podemos hacer lo siguiente:

nery.changeName(value: "Maria")

print("El nombre del nuevo usuario es: \(nery.name)")

sin obtener ningún error.

Operadores de identidad

Como las clases son tipos de referencia, es posible que varias constantes o variables hagan referencia a la misma instancia de una clase. Por ende en ocaciones es bien útil averiguar si dos constantes o variables se refieren a la misma instancia de una clase.

Para lograr esto Swift nos proporciona dos operadores de identidad:

  • === – Idéntico a
  • !== – No Idéntico a

Los podemos usar de la siguiente manera:

final class User {

    var name: String

    init(name: String) {

        self.name = name.uppercased()

    } // init

    func changeName(value: String) {

        name = value

    } // changeName

} // User

var nery = User(name: "Nery")

print("El nombre asociado a la instancia nery es: \(nery.name)")

var gloria = nery

print("El nombre asociado a la instancia gloria es: \(gloria.name)")

var maria = User(name: "Maria")

print("El nombre asociado a la instancia maria es: \(maria.name)")

if gloria === nery {

    print("\nLas instancias nery, gloria y maria cuentan con direcciónes de memoria propias.")

    withUnsafePointer(to: &gloria) { (address) in

        print("\nGloria address:   \(address)")

    } // withUnsafePointer
    
    withUnsafePointer(to: &nery) { (address) in
        
        print("Nery address:     \(address)")
        
    } // withUnsafePointer
    
    withUnsafePointer(to: &maria) { (address) in
        
        print("Maria address:    \(address)")
        
    } // withUnsafePointer
    
    print("""
        
        Sin direcciones de memoria propias estas instancias no pudieran diferenciarse, usualmente estas referencias
        se almacenan en el Stack, al mismo tiempo que hacen referencia a direcciones de memoria en el Heap,
        que es donde se inicializan los tipos por referencia.

        Ahora, lo que nos importa es verificar que en efecto las referencias nery y gloria apuntan a una dirección de memoria común,
        mientras que la instancia Maria tiene que apuntando a una dirección de memoria distinta.

        Veamos:
        
        """)

    let neryReferencedAddress = Unmanaged.passUnretained(nery).toOpaque()
    let gloriaReferencedAddress = Unmanaged.passUnretained(gloria).toOpaque()
    let mariaReferencedAddress = Unmanaged.passUnretained(maria).toOpaque()
    
    print("La instancia nery esta referenciando la posición de memoria:     \(neryReferencedAddress)")
    print("La instancia gloria esta referenciando la posición de memoria:   \(gloriaReferencedAddress)")
    print("La instancia maria esta referenciando la posición de memoria:    \(mariaReferencedAddress)")

}

Los mensajes asociados a este código explican la lógica del mismo. Lo que hacemos básicamente es comprobar el operador de identidad al mismo tiempo que comprobamos que en efecto ambas instancias apuntan a la misma dirección de memoria.

Inicializadores

Los inicializadores son los constructores de otros lenguajes de programación y están presentes tanto en las clases como en las estructuras.

La tarea de un inicializador es esa, inicializar o preparar una instancia de cierto tipo. Inicializar los valores de sus propiedades y alistar todo para que luego se pueda determinar su tamaño y se cargue en memoria.

Pero Swift es un lenguaje moderno y como en todo existen matizes, veamos a que nos referimos.

Inicializadores por Defecto

Swift proporciona un inicializador por defecto para cualquier estructura o clase, el cual proporciona valores por defecto para todas las propiedades. Este inicializador simplemente crea una nueva instancia y establece todas las propiedades a sus valores predeterminados.

En el siguiente ejemplo definimos una clase llamada ShoppingListItem, que encapsula el nombre, cantidad y estado de compra de un artículo en una lista de la compra:

class ShoppingListItem {
    
    var name: String?
    var quantity = 1
    var purchased = false
    
} // ShoppingListItem

var item = ShoppingListItem()

Debido a que la clase ShoppingListItem es una clase base (no hereda de nadie) y sus propiedades tienen valores por defecto: ShoppingListItem obtiene automáticamente del compilador un inicializador por defecto.

Este inicializador por defecto crea una nueva instancia con todas las propiedades establecidas a sus valores predeterminados.

Nota: A la propiedad name no le hemos especificado un valor por defecto, y es que como esta es opcional recibe automáticamente del compilador el valor predeterminado nil.

Inicializadores de Miembro por Miembro

Las estructura reciben automáticamente un inicializador de miembro por miembro si estas no definen un inicializador. A diferencia del inicializador por defecto de las clases, el compilador otorga a la estructura un inicializador de miembro por miembro.

El inicializador es una manera abreviada de establecer un valor inicial a las propiedades miembros de la estructura. Estos valores se pasan al inicializador de miembro por miembro haciendo uso del nombre de cada propiedad.

Veamos un ejemplo donde esto quede más claro:

struct Size {
    
    var width = 0.0
    var height = 0.0
    
} // Size

let twoByTwo = Size(width: 2.0, height: 2.0)

en el ejemplo se define una estructura llamada Size con dos propiedades variables width y height. El valor inicial que hemos asignado a cada una de estas propiedades es a favor de que el compilador pueda inferir el tipo de dato y de esta manera pueda crear el inicializador de miembro por miembro.

Dicho esto pues otra alternativa también pudo ser:

struct Size {
    
    var width: Double
    var height: Double
    
} // Size

let twoByTwo = Size(width: 2.0, height: 2.0)

Dada ambas declaraciones la estructura recibe automáticamente un inicializador init (width: height: ) que podemos utilizar para inicializar una nueva instancia, tal y como hacemos en la última línea.

Delegación de Inicializadores para Tipos por Valor

Los inicializadores pueden hacer llamadas a otros inicializadores, en pos de que este último complemente la inicialización de una instancia. Este proceso se conoce como delegación de inicializadores, y evita la duplicidad de código a través de múltiples inicializadores.

Las reglas de como esta delegación funciona difieren entre los tipos por valor y las clases.

  • Los tipos por valor (estructuras, enumeraciones, etc) no soportan herencia y por ende la delegación de inicializadores en estos tipos funciona de manera muy simple: solamente pueden delegar hacia otro inicializador que ellos mismos provean.
  • Sin embargo las clases pueden heredar de otras clases, y esto trae consigo la responsabilidad sobre las propiedades miembros que estas heredan. Hay que cerciorarse de que a estas propiedades también se le asigne un valor durante este proceso.

Hay que hacer notar que cuando definimos un inicializador personalizado, automáticamente este toma el lugar del inicializador por defecto o el inicializador de miembro por miembro en el caso de las estructuras.

En el siguiente ejemplo regresamos a la estructura Size, la cual representa un tamaño con valores de ancho y altura. Acto seguido definimos otra llamada Point que viene a representar un punto con sus respectivos valores de X y Y:

struct Size {
    
    var width: Double = 0
    var height: Double = 0
    
} // Size

struct Point {
    
    var x: Double = 0
    var y: Double = 0
    
} // Point

Aquí no termina el ejemplo, continuamos con la estructura Rect que representa un rectángulo y que puede ser inicializada de tres formas:

  • Usando su inicializador por defecto
  • Inicializando las propiedades origin y size
  • Especificando un punto central y un tamaño.

Estas opciones de inicialización están representadas por tres inicializadores personalizados que forman parte de la definición de la estructura:

struct Rect {
    
    var origin = Point()
    var size = Size()
    
    init() {}
    
    init(withOrigin origin: Point, andSize size: Size) {
        
        self.origin = origin
        self.size = size
        
    } // init
    
    init(withCenter center: Point, andSize size: Size) {
        
        let originX = center.x - (size.width / 2)
        let originY = center.y - (size.height / 2)
        
        self.init(withOrigin: Point(x: originX, y: originY), andSize: size)
        
    } // init
    
} // Rect

El primer inicializador init(), se encarga del caso en el cual que no pasamos valores:

let basicRect = Rect()

El segundo, init(withOrigin:andSize:) es funcionalmente similar al inicializador de miembro por miembro que la estructura hubiera recibido si esta no tuviera su propio inicializador personalizado:

let originRect = Rect(withOrigin: Point(x: 2.0, y: 2.0), andSize: Size(width: 5.0, height: 5.0))

El tercer inicializador init(withCenter:andSize:) es un poco más complejo. Este comienza por calcular el punto de origen tomando como referencia el punto central y el valor del tamaño. Luego delegamos hacia el inicializador init(withOrigin:andSize:) el cual almacenará los nuevos valores de origen y tamaño.

let centerRect = Rect(withCenter: Point(x: 4.0, y: 4.0), andSize: Size(width: 3.0, height: 3.0))

Evidentemente el inicializador init(withCenter:andSize:) pudo haber almacenado los valores de origen y tamaño directamente en las propiedades respectivas.

Pero habiendo un inicializador que ya provee de esta función es más conveniente (y claro en intención) delegar esta tarea, al mismo tiempo que nos evitamos dos líneas de código extra que para colmo estarían repetidas.

Inicializadores Falibles

Los inicializadores falibles, son otro ejemplo de cuan flexible es Swift. Veremos como en ocasiones es beneficioso definir inicializadores que puedan fallar.

Este fallo puede ser desencadenado o emitido por una inicialización inválida de alguna propiedad, la ausencia de un recurso externo requerido o alguna otra condición que impida la correcta inicialización de esa clase, estructura o enumeración.

Para hacer frente a condiciones de inicialización que puedan fallar, necesitamos definir uno o más inicializadores falibles. Esto lo logramos mediante la colocación de un signo de interrogación (?) después de la palabra clave init.

Dentro del inicializador también escribimos return nil para indicar que ha fallado la inicialización.

Cabe recalcar los siguientes puntos:

  • El inicializador falible creará un valor opcional del tipo que se está inicializando, es decir la instancia que se crea es del mismo tipo de la clase pero opcional.
  • No se puede definir un inicializador falible con el mismo nombre y parámetros que uno no-falible (estándar).
  • Un inicializador estándar no retorna ningún valor, su función es asegurar que self (o la nueva instancia) sea inicializada completamente. En el caso de un inicializador falible escribimos return nil para notificar que ha ocurrido un fallo.

El ejemplo a continuación define una estructura llamada Animal, con una constante String llamada species. En esta estructura también declaramos un inicializador falible de un solo parámetro. Este inicializador chequea si el valor pasado a species no es una cadena vacía.

En caso de que la cadena vacía sea pasada al inicializador pues desencadena una fallo de inicialización, de lo contrario el valor del parámetro es almacenado y la inicialización terminan satisfactoriamente.

struct Animal {
    
    let species: String
    
    init?(species: String) {
        
        if species.isEmpty {
            
            return nil

        } // if
        
        self.species = species
        
    } // init?
    
} // Animal

let someCreature = Animal(species: "Girafa")

if let giraffe = someCreature {
    
    print("\n¡Un nuevo animal fue inicializado, en este caso una \(giraffe.species)!")
    
} else {
    
    print("\n¡Una criatura anónima no puede ser inicializada!")
    
} // else

en este ejemplo hemos creado una instancia de Animal pasando como parámetro una Girafa luego mediante un if-let verificamos que la instancia no sea nil, es decir que se haya inicializado satisfactoriamente.

Recordemos que cuando llamamos a un inicializador falible la instancia creada es opcional, por esto es que hacemos el chequeo de esta manera.

Enumeraciones

En las enumeraciones también podemos hacer uso de los inicializadores falibles, mediante estos podemos seleccionar el caso apropiado basándonos en uno o más parámetros. Es decir que podemos emitir un fallo si el parámetro no concuerda con un caso en específico.

Veamos un ejemplo:

enum TemperatureUnit {
    
    case Kelvin, Celsius, Fahrenheit
    
    init?(symbol: Character) {
        
        switch symbol {
            
        case "K":
            
            self = .Kelvin
            
        case "C":
            
            self = .Celsius
            
        case "F":
            
            self = .Fahrenheit
            
        default:
            
            return nil
            
        } // switch
        
    } // init?
    
} // TemperatureUnit

let fahrenheitUnit = TemperatureUnit(symbol: "F")

if fahrenheitUnit != nil {
    
    print("\n¡Esta unidad de temperatura está definida, la instancia se ha inicializado satisfactoriamente!")
    
} // if

let unknownUnit = TemperatureUnit(symbol: "X")

if unknownUnit == nil {
    
    print("\n¡Esta unidad de temperatura no está definida, la instancia no se ha inicializado!")
    
} // if

hemos declarado una enumeración llamada TemperatureUnit con tres posibles estados (Kelvin, Celsius y Fahrenheit). El inicializador falible lo usamos en pos de encontrar el caso apropiado para el valor que representa el símbolo de la temperatura, de no existir este caso devolvemos nil.

Propagación

El inicializador falible de una clase, estructura o enumeración puede delegar hacia otro inicializador falible del mismo tipo. De manera similar el inicializador falible de una subclase puede delegar hacia el inicializador falible de una super-clase.

Ejemplo:

class Product {
    
    let name: String
    
    init?(name: String) {
        
        if name.isEmpty {
            
            return nil
            
        } // if
        
        self.name = name
        
    } // init?
    
} // Product

class CartItem: Product {
    
    let quantity: Int
    
    init?(name: String, quantity: Int) {
        
        if quantity < 1 {
            
            return nil
            
        } // if
        
        self.quantity = quantity
        
        super.init(name: name)
        
    } // init?
    
} // CartItem

En este ejemplo hemos creado una sub-clase de Product llamada CartItem.

La clase CartItem representa un artículo en un carro de compra online. CartItem también introduce una propiedad constante llamada quantity y se encarga de que esta siempre tenga un valor de al menos 1.

En caso de que la cantidad (quantity) sea inválida el proceso de inicialización falla inmediatamente, y ningún otro código restante es ejecutado. Del mismo modo el inicializador falible de Product verifica el valor de name y en caso de que sea una cadena en blanco este falla y se devuelve nil.

Si creamos una instancia de CartItem con un nombre y una cantidad de uno o más, la inicialización termina satisfactoriamente:

if let twoShirt = CartItem(name: "camisa", quantity: 2) {
    
    print("Elemento: \(twoShirt.name), cantidad: \(twoShirt.quantity)")
    
} // if let

la salida en pantalla sería:

Elemento: camisa, cantidad: 2

Ahora, si tratamos de crear una instancia de CartItem con una cantidad de 0, el inicializador fallará:

if let zeroShoes = CartItem(name: "zapatos", quantity: 0) {
    
    print("Elemento: \(zeroShoes.name), cantidad: \(zeroShoes.quantity)")
    
} else {
    
    print("¡No es posible inicializar una instancia de cero zapatos!")
    
} // else

la salida en pantalla:

¡No es posible inicializar una instancia de cero zapatos!

Sobreescritura

El inicializador falible de una clase tambien se puede sobreescribir, tal y como hacemos con cualquier otro inicializador. También podemos sobreescribir el inicializador falible de una super clase con el inicializador no-falible de una clase hija.

Esto nos permite definir una clase hija por la cual el inicializador no puede fallar, incluso si la inicialización de la super clase tiene previsto un posible fallo.

Nota: Podemos sobreescribir un inicializador falible con uno no-falible pero no al revés.

El ejemplo a continuación define una clase llamada Document. Esta clase modela un documento que puede ser inicializado con una propiedad llamada name, la cual puede almacenar una cadena de texto o nil, pero no una cadena en blanco:

class Document {
    
    var name: String?
    
    init() {}
    
    init?(name: String) {
        
        if name.isEmpty { return nil }
        
        self.name = name
        
    } // init?
    
} // Document

Ahora veamos la clase AutomaticallyNamedDocument que adopta a la clase Document como su clase padre o super clase:

class AutomaticallyNamedDocument: Document {
    
    override init() {
        
        super.init()
        
        self.name = "[Untitled]"
        
    } // init
    
    override init(name: String) {
        
        super.init()
        
        if name.isEmpty {
            
            self.name = "[Untitled]"
            
        } else {
            
            self.name = name
            
        } // else
        
    } // init
    
} // AutomaticallyNamedDocument

esta clase sobreescribe ambos de los inicializadores designados introducidos por Document. Esta sobreescritura se asegura de que la propiedad name siempre tendrá como valor inicial «[Untitled]«, en caso de que la instancia de AutomaticallyNamedDocument sea inicializada sin un nombre o se le pase una cadena en blanco.

Como podemos comprobar hemos sobreescrito el inicializador falible init?(name:) de la clase padre con un inicializador no-falible init(name:).

Esto lo hemos hecho ya que AutomaticallyNamedDocument maneja de una manera distinta la posibilidad de que name contenga una cadena de texto vacía. Por esta razón no necesita fallar, no necesita interrumpir el proceso de inicialización en caso de que esto ocurra.

El Inicializador Falible init!

Como hemos visto hasta ahora, cuando declaramos un inicializador falible init? obtenemos una instancia opcional, siempre de que el inicializador termine su ejecución satisfatoriamente.

Alternativamente podemos definir un inicializador falible el cual cree una instancia opcional implícitamente desempaquetada (unwrapped). Esto lo podemos lograr colocando un símbolo de exclamación al final de la palabra clave init, es decir colocaríamos el signo ! en lugar de ?, quedando init!.

Pero eso no es todo, también podemos delegar desde un init? hacia un init! y viceversa, de igual manera podemos sobreescribir init? con un init! y viceversa.

Inicializadores Requeridos

Cuando declaramos un inicializador con la palabra clave required antes de init, estamos indicando que toda clase que herede de esta tiene que implementar dicho inicializador:

class SomeClass {
    
    required init() {
        
    } // Init
    
} // SomeClass

class SomeSubclass: SomeClass {
    
    required init() {
        
    } // init
    
} // SomeSubclass

como vemos también podemos escribir la palabra required en el inicializador de la clase hija para mantener este requerimiento a lo largo de la cadena.

Al mismo tiempo que no es necesario usar la palabra clave override ya que el comportamiento es claramente inferido por el compilador.

Inicializadores Designados y de Conveniencia

Un inicializador designado es aquel que funge como primario de una clase, inicializa todas las propiedades y llama al inicializador de una clase padre o superclass en caso necesario. Esto último en pos de continuar el proceso de inicialización hacia arriba en la cadena de herencia.

Cada clase debe tener aunque sea un inicializador designado algo que en ocaciones se logra heredando uno o más inicializadores designados a partir de nuestra clase padre.

Los inicializadores por conveniencia vienen a dar soporte a una clase, son inicializadores secundarios. Podemos declarar un inicializador de conveniencia que llame a otro designado de la misma clase con los valores por defecto para los parámetros de este.

Así también podemos usar un inicializador designado en pos de crear una instancia de una clase para un caso de uso determinado o algún valor de entrada específico.

Sintaxis

En el caso de los inicializadores por conveniencia la sintaxis difiere levemente:

convenience init(parametros) {
    
    // instrucciones
    
} // convenience init

pero es igual de sencilla como las anteriores, hacemos uso de la palabra clave convenience para diferenciarlo.

Delegación de Inicializadores para Tipos de Clase

En pos de simplificar la relación entre los inicializadores designados y de conveniencia, Swift aplica las siguientes tres reglas para la delegación de llamadas entre inicializadores:

  • Regla 1: Un inicializador designado debe llamar a su homólogo de la clase padre o superclass.
  • Regla 2: Un inicializador de conveniencia debe llamar a otro inicializador de la misma clase.
  • Regla 3: Un inicializador de conveniencia debe, en última instancia, llamar a un inicializador designado.

Tal y como sugieren en la documentación oficial una manera de recordar esto sería:

Los inicializadores designados deben siempre delegar hacia arriba en la cadena de herencia, mientras que los inicializadores de conveniencia deben siempre delegar a través de los inicializadores de la misma clase.

El lenguaje Swift

La siguiente imagen ilustra esta reglas:

Delegacion de Inicializadores
Delegación de inicializadores

En este diagrama Superclass (la clase padre) tiene un solo inicializador designado y dos de conveniencia. Un inicializador de conveniencia llama al otro inicializador de conveniencia, este último por su parte hace una llamada al inicializador designado. Satisfaciendo así las reglas 2 y 3 que listamos arriba.

Por su parte la clase hija Subclass cuenta con dos inicializadores designados y uno de conveniencia, este último debe llamar a uno de los dos designados ya que solamente puede llamar a otro inicializador de la misma clase. Acorde con las reglas 2 y 3.

A su vez los dos inicializadores designados la clase hija Subclass deben llamar al inicializador designado de la clase padre en pos de satisfacer la regla 1.

Continuamos con una imagen:

Delegación de Inicializadores - Embudo

donde podemos observar una jerarquía de clases más compleja, donde los inicializadores designados actúan como un embudo hacia la inicialización, simplificando así la interrelación entre la cadena de clases.

Inicialización en Dos Fases

La inicialización de clases en Swift es un proceso en dos etapas. En la primera de ellas, a cada propiedad almacenada se le asigna un valor inicial.

Una vez que el estado inicial de cada propiedad ha sido determinado, la segunda etapa comienza, y es el momento donde tenemos la oportunidad de personalizar las propiedades almacenadas antes de que la instancia sea considerada lista para su uso.

Durante estas fases el compilador de Swift ejecuta cuatro chequeos de seguridad en pos de que la inicialización termine de manera satisfactoria:

  • Chequeo 1: Un inicializador designado debe asegurarse de que todas las propiedades introducidas por la clase han sido inicializadas antes que se delegue hacia la clase padre o superclass.
  • Chequeo 2: Un inicializador designado debe delegar hacia su clase padre antes de asignar un valor a una propiedad heredada. Si esto no se ejecuta en este orden el valor asignado por nuestro inicializador designado será sobreescrito por el inicializador de la clase padre.
  • Chequeo 3: Un inicializador de conveniencia debe delegar a otro inicializador antes de asignar un valor a una propiedad.
  • Chequeo 4: Un inicializador no puede llamar a ningún método de instancia, leer los valores de ninguna propiedad de instancia o hacer referencia a self como un valor hasta después que la primera fase de inicialización se haya completado.

Una instancia de clase no es válida hasta que la primera fase termina. Las propiedades puedes ser consultadas y los métodos pueden ser llamados una vez que la instancia de la clase es establecida por el compilador como válida, al final de esta primera fase.

Veamos en detalles los pasos que se desarrollan dentro de estas dos fases:

Fase 1

  • Un inicializador designado o de conveniencia es llamado.
  • La memoria para la nueva instancia es asignada aunque aún no se ha inicializado.
  • Un inicializador designado de esta clase confirma que todas las propiedades almacenadas tienen un valor. La memoria de estas propiedades ahora se encuentra inicializada.
  • El inicializador designado delega en el inicializador de su clase padre para que este ejecute la misma tarea para con sus propiedades almacenadas.
  • Esto continua hacia arriba en la cadena de herencia hasta que se llega a la cima.
  • Una vez que la cima de la cadena de herencia es alcanzada y la clase final de esta cadena de herencia se ha asegurado de que todas sus propiedades almacenadas tienen un valor, la memoria de la instancia que estamos creando se considera completamente inicializada y la fase 1 se ha completado.

Fase 2

  • Ahora en reversa, desde la cima de la cadena de herencia hacia abajo, cada inicializador tiene la opción de personalizar la instancia un poco más. Los inicializadores ahora sí pueden hacer referencia a self y pueden consultar y/o modificar los valores de las propiedades, llamar a los métodos de la instancia.
  • Por último, cualquier inicializador de conveniencia en la cadena tiene también la posibilidad de personalizar la instancia y trabajar con self.

En la imagen a continuación podemos observar como luciría la fase 1:

Inicializacion en Dos Fases - Fase Uno

En el ejemplo de esta imagen el proceso de inicialización comienza con la llamada al inicializador de conveniencia de la subclase. Este inicializador aún no pude modificar ninguna propiedad, delega hacia el inicializador designado de la misma clase.

El inicializador designado se asegura de que todas las propiedades de la subclase tengan un valor, para luego llamar al inicializador designado de la clase padre para continuar así la inicialización hacia arriba en la cadena de herencia.

En este punto y tan pronto como no hayan más propiedades por inicializar, la memoria de la clase padre ya se considera completamente inicializada y la fase 1 queda completada.

Aquí vemos como luce la fase 2 de este mismo ejemplo:

Inicializacion en Dos Fases - Fase Dos

Como inicio de esta fase nos encontramos ante la posibilidad de personalizar la instancia. Una vez que el inicializador designado ha finalizado pues su homólogo de la subclase puede también ejecutar tareas de personalización, y al terminar éste también lo podrá hacer el inicializador de conveniencia.

Como podemos constatar en la medida que los inicializadores van terminando sus tareas el flujo de inicialización retorna nuevamente a la llamada inicial.

Inicializadores Designados y de Conveniencia en acción

El siguiente ejemplo muestra a los inicializadores designados, los inicializadores de conveniencia y a la herencia de inicializadores automática en acción.

Este ejemplo define una jerarquía de tres clases llamadas Food, RecipeIngredient y ShoppingListItem con las cuales vamos a demostrar todo cuanto hemos comentado hasta el momento.

La clase base en esta jerarquía es Food, la cual es una clase bastante sencilla donde encapsulamos el nombre de productos alimenticios. Esta clase introduce o declara una sola propiedad de tipo String llamada name y también provee dos inicializadores para la creación de instancias de esta clase:

class Food {
    
    var name: String
    
    init(name: String) {
        
        self.name = name
    
    } // init
    
    convenience init() {
        
        self.init(name: "[Unnamed]")
    
    } // convenience init

} // Food

La siguiente imagen muestra la cadena de inicialización para la clase Food:

Inicializadores Designados y de Conveniencia - Clase Food

Las clases no cuentan con un inicializador de miembro por miembro, razón por la que hemos incluido un inicializador designado que toma un solo argumento llamado name y es nuestra vía para crear una instancia de Food con un nombre asociado a esta:

let namedMeat = Food(name: "Bacon")

Decimos que init(name: String) es un inicializador designado ya que en este nos aseguramos de que todas las propiedades de la clase sean inicializadas. Como podemos observar la clase Food no cuenta con una clase padre por lo que no es necesario delegar en esta, y por ende no tendría sentido ejecutar super.init().

La clase Food también provee un inicializador por conveniencia init() sin argumentos, el cual establece un nombre por defecto delegando hacia el inicializador designado y que evidentemente viene a ser usado en ocaciones donde no especificamos un nombre:

let mysteryMeat = Food()

La segunda clase en la jerarquía es una subclase de Food llamada RecipieIngredient y que viene a modelar un ingrediente dentro de una receta de cocina.

Esta clase está conformada por una propiedad de tipo Int llamada quantity junto a otra de nombre name que es heredada de Food, también se definen dos inicializadores:

class RecipeIngredient: Food {
    
    var quantity: Int
    
    init(name: String, quantity: Int) {
        
        self.quantity = quantity
        
        super.init(name: name)
        
    } // init
    
    override convenience init(name: String) {
        
        self.init(name: name, quantity: 1)
    
    } // override convenience init
    
} // RecipeIngredient

En la siguiente imagen se muestra la cadena de inicialización para la clase RecipieIngredient:

Inicializadores Designados y de Conveniencia - Clase RecipeIngredient

La clase RecipeIngredient contiene un inicializador designado init (name: String, quantity: Int) que contiene todos los argumentos para una instancia completamente funcional. Este inicializador comienza por asignar el argumento quantity a la propiedad del mismo nombre, luego de esto delega hacia el inicializador designado de la clase padre Food.

Este último se encargará de inicializar la propiedad name, al mismo tiempo que satisfacemos el chequeo de seguridad 1 del cual hablamos en la inicialización en dos fases.

En el caso del inicializador por conveniencia init(name: String) sobreescribe el inicializador designado con la misma firma de la clase padre, y como tal debe ser declarado con la palabra clave override. Nuestro inicializador de conveniencia cuenta solo con un argumento y nos crea una instancia a partir de los datos introducidos, asumiendo 1 para la propiedad quantity, datos que luego delega al inicializador designado de la misma clase.

A pesar de que RecipeIngredient proporciona el inicializador de conveniencia init(name: String), esta clase ha declarado todos los inicializadores designados de la clase padre. Por lo tanto RecipieIngredient automáticamente hereda todos los inicializadores de conveniencia de su clase padre Food.

En este ejemplo, la clase padre de RecipeIngredient es Food, la cual tiene un solo inicializador de conveniencia init(),  por lo tanto, este inicializador es heredado por RecipeIngredient. La versión heredada de init() es exactamente la misma versión de Food, con la excepción de que esta delega hacia la versión de init(name: String) en la clase RecipieIngredient en lugar de Food.

La tercera clase y final dentro de la jerarquía es una subclase de RecipieIngredient llamada ShoppingListItem y que viene a modelar un ingrediente de una receta pero en una lista de compras.

Cada elemento en la lista de compras comienza como «unpurchased». Para representar este hecho ShoppingListItem introduce una propiedad bo0leana llamada purchased, con un valor por defecto de false.  Esta clase también declara una propiedad computada llamada description y que provee de un texto descriptivo relativo a la instancia:

class ShoppingListItem: RecipeIngredient {
    
    var purchased = false
    
    var description: String {
        
        var output = "\(quantity) x \(name)"
        
        output += purchased ? " ✔" : " ✘"
        
        return output
        
    } // description
    
} // ShoppingListItem

Como esta clase provee valores por defecto para todas sus propiedades y no define ningún inicializador, pues automáticamente hereda todos los inicializadores designados y de conveniencia de su clase padre.

En la siguiente imagen podemos observar la cadena de inicialización para estas tres clases:

Inicializadores Designados y de Conveniencia - Clase ShoppingListItem

Veamos un ejemplo donde hacemos uso de los tres inicializadores heredados:

var breakfastList = [
    ShoppingListItem(),
    ShoppingListItem(name: "Bacon"),
    ShoppingListItem(name: "Eggs", quantity: 6),
]

breakfastList[0].name = "Orange juice"
breakfastList[0].purchased = true

for item in breakfastList {
    
    print(item.description)
    
} // for

la salida en pantalla sería:

1 x Orange juice ✔
1 x Bacon ✘
6 x Eggs ✘

Aquí tenemos un arreglo llamado breakfastList el cual almacena tres instancias de ShoppingListItem, arreglo que es inferido por el compilador como de tipo [ShoppingListItem].

Una vez creado este arreglo, el nombre del artículo en la lista de compra cambia de «[Unnamed]» a «Orange juice» y se marca como comprado. Al final podemos comprobar que los cambios se han aplicado correctamente, imprimiendo la descripción de cada elemento de nuestro arreglo.

Herencia de Inicializadores

En Swift a diferencia de Objective-C, las subclases no heredan automáticamente los inicializadores de su clase padre.

El enfoque de Swift es evitar la situación donde un inicializador bien básico es heredado por una subclase más especializada para luego ser usado en la creación de una instancia de la subclase, instancia que puede no haberse inicializado completamente o no de la manera correcta.

Sobreescritura de inicializadores

Cuando definimos el inicializador de una subclase y este coincide con un inicializador designado de la clase padre, nos encontramos sobreescribiendo este inicializador designado. Por lo tanto tenemos que escribir la palabra clave override antes de la definición del inicializador.

Por el contrario cuando estamos declarando un inicializador en nuestra subclase y este coincide con el inicializador de conveniencia de la clase padre. El inicializador de la clase padre jamás podrá ser llamado directamente por la subclase, por lo que técnicamente hablando no estaríamos sobreescribiendo el inicializador de conveniencia de la clase padre.

Como es habitual vamos a un ejemplo para entender de mejor manera lo antes comentado. En este ejemplo definimos la clase base Vehicle. Dicha clase declara una propiedad almacenada llamada numberOfWheels, con un valor por defecto de 0.

Esta propiedad es usada por otra propiedad computada de nombre description para crear un String con las características del vehículo:

class Vehicle {
    
    var numberOfWheels = 0
    
    var description: String {
        
        return "\(numberOfWheels) ruedas"
        
    } // description
    
} // Vehicle

Esta clase establece un valor por defecto a su única propiedad almacenada y no provee de ningún inicializador. Como resultado de esto, recibe automáticamente un inicializador por defecto. Inicializador que cuando está disponible siempre es de tipo designado y puede ser usado para crear una instancia de Vehicle con un numberOfWheels de 0:

let vehicle = Vehicle()

print("Vehículo: \(vehicle.description)")

Proseguimos por crear otra clase, una subclase de Vehicle llamada Bicycle:

class Bicycle: Vehicle {
    
    override init() {
        
        super.init()
        
        numberOfWheels = 2
        
    } // override init()
    
} // Bicycle

La subclase Bicycle declara un inicializador designado, init(). El cual coincide con su homólogo de la clase padre Vehicle. Por esto en el caso de Bicycle el inicializador está marcado con el modificado override.

El inicializador designado de Bicycle comienza por la llamada super.init(), es decir inicia por delegar hacia su clase padre. Esto nos asegura que la propiedad heredada numberOfWheels sea inicializada por Vehicle antes de que Bicycle tenga la oportunidad de modificarla.

En caso de que se pregunten que sucedería si intentemos hacer uso de numberOfWheels antes de la ejecución del inicializador designado de la clase padre, pues el compilador nos arroja el siguiente mensaje de error:

Use of 'self' in property access 'numberOfWheels' before super.init initializes self

Luego de esta primera instrucción establecemos el valor de la propiedad numberOfWheels a 2. Así que cuando creamos una instancia de Bicycle y llamamos a la propiedad heredada description podemos ver a que valor ha sido actualizado el número de ruedas:

let bicycle = Bicycle()

print("Bicicleta: \(bicycle.description)")

la salida en pantalla sería:

Bicicleta: 2 ruedas

Herencia automática de inicializadores

Como hemos mencionado arriba, las subclases no heredan los inicializadores por defecto. No obstante, los inicializadores de las clases padres son automáticamente heredados bajo ciertas condiciones.

En la practica, en la mayoría de los casos más comunes no necesitamos sobreescribir los inicializadores. Podremos heredar los inicializadores de las clases padres, siempre y cuando sea seguro hacerlo.

Asumiendo que establezcamos valores por defecto por cada nueva propiedad que añadimos a una subclase, las siguientes dos reglas se aplican:

  • Regla 1: Si la subclase no define ningún inicializador designado, este automáticamente hereda todos los inicializadores designados de su clase padre.
  • Regla 2: Si la subclase provee una implementación de todos los inicializadores designados de su clase padre – ya sea heredándolas por la regla 1 o declarando una implementación personalizada como parte de su definición – entonces automáticamente hereda todos los inicializadores de conveniencia de su clase padre.

Estas reglas se aplican incluso si la subclase añade inicializadores de conveniencia.

Destructores

Un destructor o de-inicializador es un bloque especial que solo podemos declarar dentro de las clases. El uso del método deinit no es obligatorio, su función es la de brindar un espacio para acciones que necesitemos ejecutar antes de que cierta instancia sea liberada de la memoria.

Estas acciones pueden variar con respecto a nuestro caso, desde tareas de limpieza, sincronización o liberar de la memoria a un puntero. Sí en Swift también podemos trabajar con punteros.

El ejemplo más básico de un destructor sería:

class Example {

    deinit {

        print("Liberando la memoria asociada a esta clase.")
        
    } // deinit

} // Example

var example: Example? = Example()

example = nil

Creamos una clase y dentro de esta definimos el código asociado al bloque deinit. Luego creamos una instancia de tipo Example pero opcional, y acto seguido la igualamos a nil.

Con esto lo que hacemos es invalidar la instancia, ya que es opcional soporta dos estados: o está inicializada o es igual a nil (no está inicializada).

Al pasar nil comenzamos el proceso de eliminar dicha instancia de memoria, y esto activa el método deinit. El bloque deinit solo se ejecutará si dicha instancia se puede liberar de memoria, si no hay otras instancias referenciándolas.

Pero veamos un ejemplo práctico que ilustre mejor todo esto:

class Warehouse {
    
    private (set) static var spaces = 10
    
    private static var leases = [Space]()
    
    func lease(spaces amount: Int, toTenant person: Person?) -> Bool {
        
        guard let person = person else {
            
            return false
            
        } // guard
        
        if amount > Warehouse.spaces {
            
            return false
            
        } // if
        
        Warehouse.spaces = Warehouse.spaces - amount
        
        for _ in 1...amount {
            
            let newLocation = Space(tenant: person)
            
            Warehouse.leases.append(newLocation)
            
        } // for

        return true

    } // lease
    
    func statistics() {

        print("- Hay \(10 - Warehouse.spaces) locales rentados de 10.\n")
        
        if Warehouse.spaces == 10 {
            
            print("* Todos los locales están disponibles.")

            return
            
        } // if
        
        for space in Warehouse.leases {
            
            if let tenant = space.tenant?.name {
                
                print("* Local rentado a \(tenant)")
                
            } // if let

        } // for

    } // statistics
    
    static func terminateLease() {
        
        if Warehouse.leases.isEmpty {
            
            return
            
        } // if

        Warehouse.leases.removeAll { space in
            
            if space.tenant == nil {
                
                Warehouse.spaces = Warehouse.spaces + 1
                
                return true
                
            } // if
            
            return false
            
        } // removeAll

    } // terminateLease

} // Warehouse

struct Space {
    
    weak var tenant: Person?
    
    init(tenant: Person) {
        
        self.tenant = tenant
        
    } // init
    
} // Space

class Person {
    
    let name: String

    init(name: String) {
        
        self.name = name
        
    } // init
    
    deinit {

        Warehouse.terminateLease()
        
    } // deinit
    
} // Person

var warehouse = Warehouse()

print("\nAlmacen con \(Warehouse.spaces) locales disponibles.")

var paco: Person? = Person(name: "Paco")

if warehouse.lease(spaces: 2, toTenant: paco) {
    
    print("\nPaco ha rentado 2 locales.")
    
    print("\nAlmacen con \(Warehouse.spaces) locales disponibles.\n")
    
    warehouse.statistics()

} else {
    
    print("\nError: No hay locales disponibles o bien no se ha especificado correctamente el arrendador.")
    
} // else

var laura: Person? = Person(name: "Laura")

if warehouse.lease(spaces: 4, toTenant: laura) {
    
    print("\nLaura ha rentado 4 locales.")
    
    print("\nAlmacen con \(Warehouse.spaces) locales disponibles.\n")
    
    warehouse.statistics()
    
} else {
    
    print("\nError: No hay locales disponibles o bien no se ha especificado correctamente el arrendador.")
    
} // else

print("\nPaco deja de existir!\n")

paco = nil

warehouse.statistics()

print("\nLaura deja de existir!\n")

laura = nil

warehouse.statistics()

En este ejemplo tenemos dos clases y una estructura. Una clase Warehouse, una estructura Space, y otra clase Person. La clase Warehouse representa un almacen donde podemos rentar locales o cubículos, la estructura Space representa a cada uno de estos locales, y la clase Person es la persona que llega a rentar.

¿Te atreves a analizarlo por ti mismo? Las dudas las puedes dejar en los comentarios.

La salida en pantalla del código anterior sería:

Almacen con 10 locales disponibles.

Paco ha rentado 2 locales.

Almacen con 8 locales disponibles.

- Hay 2 locales rentados de 10.

* Local rentado a Paco
* Local rentado a Paco

Laura ha rentado 4 locales.

Almacen con 4 locales disponibles.

- Hay 6 locales rentados de 10.

* Local rentado a Paco
* Local rentado a Paco
* Local rentado a Laura
* Local rentado a Laura
* Local rentado a Laura
* Local rentado a Laura

Paco deja de existir!

- Hay 4 locales rentados de 10.

* Local rentado a Laura
* Local rentado a Laura
* Local rentado a Laura
* Local rentado a Laura

Laura deja de existir!

- Hay 0 locales rentados de 10.

* Todos los locales están disponibles.

Métodos

Los métodos ya los hemos visto a lo largo de este artículo, son esas funciones que asociamos a un tipo de dato en particular.

Las clases (class), estructuras (struct) y enumeraciones (enum), todas pueden definir métodos de instancia y métodos de tipo, que encapsulan tareas y funciones específicas.

Métodos de instancia

Los métodos de instancia son funciones que pertenecen a instancias de una clase, estructura o enumeración. Aportan una API a las instancias, ya sea proporcionando formas de acceder y modificar las propiedades, o proporcionando funcionalidades relacionadas con el propósito de la instancia.

Ejemplo:

final class Counter {
    
    var count = 0
    
    func increment() {
        
        count = count + 1
        
    } // increment
    
    func incrementBy(amount: Int) {
        
        count += amount
        
    } // incrementBy
    
    func reset() {
        
        count = 0
        
    } // reset
    
} // Counter

en este código hemos creado la clase Counter, tres métodos de instancia y una propiedad.

Más allá de que se llaman métodos de instancia en lugar de funciones, nada de esto es del todo nuevo, ya lo hemos visto en el artículo que dedicamos a la funciones.

La propiedad self

Cada instancia cuenta con una propiedad implícita llamada self, que es exactamente equivalente a la propia instancia.

Utilizamos la propiedad self para hacer referencia a la instancia actual dentro de los propios métodos de instancia. Self vendría a ser como el this de C++ o Java.

El método incrementBy del ejemplo anterior se podría haber escrito así:

func incrementBy(amount: Int) {
        
    self.count += amount
        
} // incrementBy

escribir self en este caso no es necesario ya que el compilador asume que se está haciendo referencia a una propiedad de la propia clase. Es solo para que vean como se utiliza.

Métodos de tipo

Los métodos de tipo son aquellos que en otros lenguajes llamamos como métodos estáticos. En Swift se declaran de manera similar, escribiendo la palabra clave static antes de la palabra clave func.

Un ejemplo podría ser:

final class SomeClass {
    
    static func someTypeMethod() {
        
        print("Método de tipo o método estático!!!")
        
    } // someTypeMethod

} // SomeClass

SomeClass.someTypeMethod()

esto es válido para las clases, pero también para las estructuras y las enumeraciones.

¿Diferencias?

En este punto quizás nos surja algunas preguntas, entre ellas posiblemente las siguientes:

¿Dónde residen las grandes diferencias entre ambos tipos?

Las estructuras y las clases tienen mucho en común como hemos visto hasta este punto. Pudiéramos resumir que ambos pueden:

  • Definir propiedades para almacenar valores
  • Definir método para proporcionar funcionalidades
  • Definir subscripts en pos de permitir el acceso a sus valores mediante la sintaxis subscripts
  • Definir inicializadores para establecer su estado inicial
  • Pueden ser extendidas sus funcionalidades más allá de la implementación por defecto
  • Pueden ajustarse a protocolos en pos de proveer funcionalidades estándar de algún tipo

pero en el caso de las clases estas cuentan con características únicas, la siguientes:

  • Herencia, permitiendo a una clase heredar las características de otra
  • Casting de tipo, permitiendo verificar e interpretar el tipo de una instancia en tiempo de ejecución
  • De-Inicializadores, similar a los destructores en C++ y que nos permite liberar recursos asignados
  • Conteo de referencias, permite más de una referencia apuntando a una misma instancia

Luego de ver las similitudes y diferencias podemos concluir que la diferencia principal radica en que las estructuras son tipos por valor y las clases por referencia.

Esto básicamente significa que cuando copiamos una estructura, el destino obtiene una copia nueva de la misma mientras al tratarse de una clase lo que enviamos es una referencia a la instancia.

Esto en otros lenguajes muchas veces tiene grandes inconvenientes, ya que en ocaciones no es deseable una copia nueva de los mismos datos ni tampoco una referencia que modifique otras áreas del código.

Todo esto en paralelo con el consumo de recursos que puede significar tener datos dobles en memoria o el tiempo de asignación en el Heap que usualmente es más lento en el Stack.

Evidentemente son cuestiones muy ligadas al diseño, aunque en Swift cuando copiamos una estructura no siempre se duplican los datos. Esto es gracias a la característica Copy on Write de la cual hablaremos en un próximo artículo.

¿Las características antes comentadas son determinantes?

La respuesta es: sí.

Hay características de las clases y estructuras que nos obligan a usar una u otra. ¿Quieres ver un ejemplo de esto? Pues ya lo hemos visto, en la sección Destructores, especificamente en el segundo ejemplo hemos utilizado dos clases y una estructura, una elección para nada al azar.

Resulta que en la clase Warehouse necesitaba utilizar dos propiedades estáticas, algo que solo es posible en las clases. Luego que en la clase Person necesitaba un bloque deinit, algo que solo es posible en las clases.

Mientras que la estructura Space no requería ninguna de las características antes mencionadas de las clases. Dado el diseño el arreglo estático leases sería el encargado de almacenar los cubículos representados por Space.

Este es un ejemplo bien clásico de como el diseño del código te va indicando que usar en dependencia de la propia funcionalidad que se desea implementar.

Falta aún mucho por aprender en nuestro camino a convertirnos en iOS Developer. Suscríbete a nuestra lista de correo y síguenos en nuestras redes sociales. Mantente al tanto de todas nuestras publicaciones.

Espero que todo cuanto se ha dicho aquí, de una forma u otra le haya servido de aprendizaje, de referencia, que haya valido su preciado tiempo.

Este artículo, al igual que el resto, será revisado con cierta frecuencia en pos de mantener un contenido de calidad y actualizado.

¡Cualquier duda o sugerencia, ya sea errores a corregir o ejemplos a añadir, será más que bienvenida, necesaria!

Deja una respuesta

Su dirección de correo electrónico no será publicada.

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Este sitio web utiliza cookies para mejorar su experiencia. Asumiremos que está de acuerdo con esto, pero puede optar por no participar si lo desea. Aceptar Leer Más

RECIBE CONTENIDO SIMILAR EN TU CORREO

RECIBE CONTENIDO SIMILAR EN TU CORREO

Suscríbete a nuestra lista de correo y mantente actualizado con las nuevas publicaciones.

Se ha suscrito correctamente!