En este artículo, exploraré algunos de los detalles arenosos de los inicializadores Swift. ¿Por qué abordar un tema tan emocionante? Principalmente porque ocasionalmente me he sentido confundido por errores de compilación al intentar crear una subclase de UIView, o al intentar crear una versión Rápida de una antigua clase Objective-C, por ejemplo. Así que decidí profundizar más para comprender mejor los matices de los inicializadores Swift. Dado que mi experiencia/experiencia ha consistido principalmente en trabajar con Groovy, Java y Javascript, compararé algunos de los mecanismos de inicialización de Swift con los de Java & Groovy. Te animo a que enciendas un patio de juegos en XCode y juegues con los ejemplos de código tú mismo. También le animo a que lea la sección de inicialización de la Guía de idiomas de Swift 2.1, que es la principal fuente de información para este artículo. Finalmente, me centraré en los tipos de referencia (clases) en lugar de los tipos de valor (estructuras).
¿Qué Es Un Inicializador?
Un inicializador es un tipo especial de método en una estructura, clase o enumeración Swift que es responsable de asegurarse de que una instancia recién creada de la estructura, clase o enumeración esté completamente inicializada antes de que esté lista para ser utilizada. Desempeñan el mismo papel que un «constructor» en Java y Groovy. Si está familiarizado con Objective-C, debe tener en cuenta que los inicializadores Swift difieren de los inicializadores Objective-C en que no devuelven un valor.
Las subclases Generalmente no heredan Inicializadores
Una de las primeras cosas a tener en cuenta es que «las subclases Swift no heredan sus inicializadores de superclase por defecto», de acuerdo con la guía de idiomas. (La guía explica que hay escenarios en los que los inicializadores de superclase se heredan automáticamente. Cubriremos esos escenarios excepcionales más adelante). Esto es consistente con el funcionamiento de Java (y, por extensión, Groovy). Considere lo siguiente:
Al igual que con Java & Groovy, tiene sentido que esto no esté permitido (aunque, como con la mayoría de las cosas, puede haber algún argumento sobre este punto. Ver este post de StackOverflow). Si se permitiera, se omitiría la inicialización de la propiedad «instrument» de Musician, lo que podría dejar la instancia de Musician en un estado inválido. Sin embargo, con Groovy normalmente no me molestaría en escribir inicializadores (es decir, constructores). Más bien, usaría el constructor de mapas que Groovy proporciona implícitamente, que le permite elegir libremente las propiedades que desea establecer en la construcción. Por ejemplo, el siguiente código Groovy es perfectamente válido:
Tenga en cuenta que puede incluir cualquier propiedad, incluidas las proporcionadas por superclases, pero no tiene que especificar todas (o ninguna) de ellas, y no tiene que especificarse en ningún orden en particular. Swift no proporciona este tipo de inicializador ultraflexible. Lo más cercano en Swift son los inicializadores memberwise proporcionados automáticamente para estructuras. Pero el orden de los argumentos en un inicializador memberwise es significativo, aunque estén nombrados, y depende del orden en el que se definan:
De todos modos, back to classes – La filosofía de Groovy con respecto a la validez de objetos post-construcción es claramente muy diferente de la de Swift. Esta es solo una de las muchas maneras en que Groovy difiere de Swift.
Inicializadores designados vs Convenientes
Antes de llegar demasiado lejos en la madriguera del conejo, debemos aclarar un concepto importante: En Swift, un inicializador se clasifica como un inicializador designado o conveniente. Intentaré explicar la diferencia tanto conceptual como sintácticamente. Cada clase debe tener al menos un inicializador designado, pero puede tener varios («designado» no implica «soltero»). Un inicializador designado se considera un inicializador primario. Son los jefes. En última instancia, son los responsables de asegurarse de que todas las propiedades se inicialicen. Debido a esa responsabilidad, a veces puede convertirse en un dolor usar un inicializador designado todo el tiempo, ya que pueden requerir varios argumentos. Imagine trabajar con una clase que tiene varias propiedades, y necesita crear varias instancias de esa clase que sean casi idénticas, excepto una o dos propiedades. (En aras de la argumentación, supongamos también que no hay valores predeterminados sensibles que se podrían haber asignado a las propiedades cuando se declaran). Por ejemplo, digamos que nuestra clase de persona también tenía comida para comer y disfrutaba de propiedades musicales. Por supuesto, esas dos cosas se establecerían como verdaderas la mayor parte del tiempo, pero nunca se sabe. Eche un vistazo:
Ahora nuestra clase Person tiene cuatro propiedades que deben configurarse, y tenemos un inicializador designado que puede hacer el trabajo. El inicializador designado es el primero, el que toma 4 argumentos. La mayoría de las veces, esos dos últimos argumentos van a tener el valor «verdadero». Sería un dolor tener que seguir especificándolos cada vez que queremos crear una Persona típica. Ahí es donde entran los dos últimos inicializadores, los marcados con el modificador de conveniencia. Este patrón debería ser familiar para un desarrollador Java. Si tiene un constructor que toma más argumentos de los que realmente necesita tratar todo el tiempo, puede escribir constructores simplificados que tomen un subconjunto de esos argumentos y proporcionen valores predeterminados razonables para los demás. Los inicializadores de conveniencia deben delegarse a otro inicializador de conveniencia, quizás menos conveniente, o a un inicializador designado. En última instancia, un inicializador designado debe participar. Además, si se trata de una subclase, el inicializador designado debe llamar a un inicializador designado desde su superclase inmediata.
Un ejemplo del mundo real para el uso del modificador de conveniencia proviene de la clase UIBezierPath de UIKit. Estoy seguro de que puedes imaginar que hay varias formas de especificar un camino. Como tal, UIBezierPath proporciona varios inicializadores de conveniencia:
inicio de conveniencia pública(rect: CGRect)
inicio de conveniencia pública(ovalInRect rect: CGRect)
inicio de conveniencia pública(roundedRect rect: CGRect, cornerRadius: CGFloat)
inicio de conveniencia pública(roundedRect rect: CGRect, Cornerridi: CGSize)
inicio de conveniencia pública(centro arcCenter: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, en el sentido de las agujas del reloj: Bool)
public convenience init(CGPath: CGPath)
Anteriormente, dije que una clase puede tener varios inicializadores designados. Entonces, ¿cómo se ve eso? Una diferencia importante, impuesta por el compilador, entre inicializadores designados e inicializadores de conveniencia es que los inicializadores designados no pueden delegar a otro inicializador de la misma clase (pero deben delegar a un inicializador designado en su superclase inmediata). Mira el segundo inicializador de Persona, el que toma un solo argumento llamado «No muerto». Dado que este inicializador no está marcado con el modificador de conveniencia, Swift lo trata como un inicializador designado. Como tal, no puede delegar en otro inicializador en persona. Intenta comentar las primeras cuatro líneas y descomentar la última línea. El compilador se quejará, y XCode debería intentar ayudarlo sugiriendo que lo arregle agregando el modificador de conveniencia.
Ahora considere la subclase Músico de Persona. Tiene un inicializador único, por lo que debe ser un inicializador designado. Como tal, debe llamar a un inicializador designado de la superclase inmediata, Person. Recuerde: si bien los inicializadores designados no pueden delegar a otro inicializador de la misma clase, los inicializadores de conveniencia deben hacerlo. Además, un inicializador designado debe llamar a un inicializador designado de su superclase inmediata. Consulte la guía de idiomas para obtener más detalles (y bonitos gráficos).
Fases de inicialización
Como explica la guía de idiomas de Swift, hay dos fases de inicialización. Las fases están demarcadas por la llamada al inicializador designado de superclase. La fase 1 es anterior a la llamada al inicializador designado de superclase, la fase 2 es posterior. Una subclase debe inicializar todas sus propiedades PROPIAS en la fase 1, y NO puede establecer ninguna de las propiedades definidas por la superclase hasta la fase 2.
Aquí hay un ejemplo de código, adaptado de un ejemplo proporcionado en la guía de idiomas, que muestra que debe inicializar las propiedades PROPIAS de una subclase antes de invocar el inicializador designado de superclase. No puede acceder a las propiedades proporcionadas por la superclase hasta después de invocar el inicializador designado para superclase. Por último, no puede modificar las propiedades almacenadas constantes después de que se haya invocado el inicializador designado para superclase.
Invalidar un inicializador
Ahora que estamos convencidos de que las subclases generalmente no heredan inicializadores, y tenemos claro el significado y la distinción entre inicializadores designados y de conveniencia, consideremos lo que sucede cuando desea que una subclase invalide un inicializador de su superclase inmediata. Hay cuatro escenarios que me gustaría cubrir, dado que hay dos tipos de inicializador. Así que vamos a tomarlos uno por uno, con ejemplos de código simples para cada caso:
Un inicializador designado que coincide con un inicializador designado de superclase
Este es un escenario típico. Al hacer esto, debe aplicar el modificador de anulación. Tenga en cuenta que este escenario está en efecto incluso cuando está «anulando» un inicializador predeterminado proporcionado automáticamente (es decir, cuando la superclase no define ningún inicializador explícito. En este caso, Swift proporciona uno implícitamente. Los desarrolladores de Java deberían estar familiarizados con este comportamiento). Este inicializador predeterminado proporcionado automáticamente es siempre un inicializador designado.
Un inicializador designado que coincide con un inicializador de conveniencia de superclase
Ahora supongamos que desea agregar un inicializador designado a su subclase que coincide con un inicializador de conveniencia en el padre. Según las reglas de delegación de inicializador establecidas en la guía de idiomas, el inicializador designado de subclase debe delegar hasta un inicializador designado de la superclase inmediata. Es decir, no puede delegar hasta el inicializador de conveniencia coincidente del padre. Por el bien del argumento, también suponga que la subclase no califica para heredar inicializadores de superclase. Entonces, dado que nunca podría crear una instancia de su subclase invocando directamente el inicializador de conveniencia de superclase, ese inicializador de conveniencia coincidente no está, y nunca podría estar, involucrado en el proceso de inicialización de todos modos. Por lo tanto, en realidad no lo está sobreescribiendo, y el modificador de sobreescritura no se aplica.
Un inicializador conveniente que coincide con un inicializador designado de superclase
En este escenario, imagine que tiene una subclase que agrega sus propias propiedades cuyo valor predeterminado puede ser (pero no tiene que ser) calculado a partir de los valores asignados a una o más propiedades de clase principal. Supongamos que también solo desea tener un inicializador designado para su subclase. Puede agregar un inicializador conveniente a su subclase cuya firma coincida con la de un inicializador designado de la clase principal. En este caso, su nuevo inicializador necesitaría tanto los modificadores de conveniencia como de anulación. Aquí hay un ejemplo de código válido para ilustrar este caso:
Un inicializador de conveniencia que coincide con un inicializador de conveniencia de superclase
Si desea agregar un inicializador de conveniencia a su subclase que coincide con la firma de un inicializador de conveniencia de su superclase, siga adelante. Como expliqué anteriormente, de todos modos, no se pueden anular los inicializadores de conveniencia. Por lo tanto, incluiría el modificador de conveniencia, pero omitiría el modificador de anulación y lo trataría como cualquier otro inicializador de conveniencia.
Una conclusión clave de esta sección es que el modificador de anulación solo se usa, y debe usarse, si está sobrescribiendo un inicializador designado de superclase. (Aclaración menor a hacer aquí: si está sobreescribiendo un inicializador requerido, entonces usaría el modificador requerido en lugar del modificador de sobreescritura. El modificador requerido implica el modificador de anulación. Consulte la sección Inicializadores requeridos a continuación).
Cuando se heredan los inicializadores
Ahora para los escenarios antes mencionados donde se heredan los inicializadores de superclase. Como explica la Guía de idiomas de Swift, si su subclase proporciona valores predeterminados para todas sus propiedades en la declaración y no define ninguno de sus propios inicializadores designados, heredará automáticamente todos sus inicializadores designados para superclase. O bien, si su subclase proporciona una implementación de todos los inicializadores designados para superclase, hereda automáticamente todos los inicializadores de conveniencia para superclase. Esto es consistente con la regla de Swift de que la inicialización de clases (y estructuras) no debe dejar las propiedades almacenadas en un estado indeterminado.
Me topé con un comportamiento «interesante» mientras experimentaba con inicializadores de conveniencia, inicializadores designados y las reglas de herencia. Descubrí que es posible crear un círculo vicioso inadvertidamente. Considere el siguiente ejemplo:
La clase RecipeIngredient anula todos los inicializadores designados para clase de alimento y, por lo tanto, hereda automáticamente todos los inicializadores de conveniencia de superclase. Pero el inicializador de conveniencia alimentaria delega razonablemente en su propio inicializador designado, que ha sido anulado por la subclase RecipeIngredient. Por lo tanto, no es la versión Alimentaria de ese inicializador init(nombre: Cadena) la que se invoca, sino la versión anulada en RecipeIngredient. La versión anulada aprovecha el hecho de que la subclase ha heredado el inicializador de conveniencia de Food, y ahí está: tiene un ciclo. No se si esto se consideraría un error del programador o un error del compilador (lo reporté como un error: https://bugs.swift.org/browse/SR-512 ). Imagine que Food es una clase de un tercero, y no tiene acceso al código fuente, por lo que no sabe cómo se implementa en realidad. No sabrías (hasta el tiempo de ejecución) que usarlo de la manera que se muestra en este ejemplo te atraparía en un ciclo. Así que creo que sería mejor que el compilador nos ayudara.
Inicializadores fallables
Imagine que ha diseñado una clase que tiene ciertos invariantes, y le gustaría hacer cumplir esos invariantes desde el momento en que se crea una instancia de la clase. Por ejemplo, tal vez esté modelando una factura y desee asegurarse de que la cantidad siempre no sea negativa. Si agregaste un inicializador que toma un argumento de cantidad de tipo Double, ¿cómo podrías asegurarte de que no violas tu invariante? Una estrategia es simplemente comprobar si el argumento no es negativo. Si lo es, úsalo. De lo contrario, el valor predeterminado es 0. Por ejemplo:
Esto funcionaría, y puede ser aceptable si documenta lo que está haciendo su inicializador (especialmente si planea hacer que su clase esté disponible para otros desarrolladores). Pero es posible que te cueste defender esa estrategia, ya que hace que el tema se oculte bajo la alfombra. Otro enfoque que es compatible con Swift sería dejar que la inicialización falle. Es decir, haría que su inicializador fallara.
Como se describe en este artículo, se agregaron inicializadores fallables a Swift como una forma de eliminar o reducir la necesidad de métodos de fábrica, «que anteriormente eran la única forma de informar de errores» durante la construcción de objetos. Para hacer que un inicializador sea fallable, simplemente agregue el ? o ! carácter después de la palabra clave init (es decir, init? o init! ). Luego, una vez que se hayan establecido todas las propiedades y se hayan cumplido todas las demás reglas relativas a la delegación, agregaría cierta lógica para verificar que los argumentos son válidos. Si no son válidos, se desencadena un error de inicialización con return nil. Tenga en cuenta que esto no implica que el inicializador esté devolviendo algo. Así es como se vería nuestra clase de factura con un inicializador fallable:
¿Nota algo diferente sobre cómo estamos usando el resultado de la creación del objeto? Es como si lo tratáramos como opcional, ¿verdad? Bueno, eso es exactamente lo que estamos haciendo! Cuando usamos un inicializador fallable, obtendremos nil (si se activó un error de inicialización) o un Opcional(Factura). Es decir, si la inicialización fue exitosa, terminaremos con una Opción que envuelve la instancia de factura que nos interesa, por lo que tenemos que desenvolverla. (Como un aparte, tenga en cuenta que Java también tiene opcionales a partir de Java 8).
Los inicializadores fallables son como los otros tipos de inicializadores que hemos discutido con respecto a sobreescritura y delegación, conveniencia vs designada, etc. De hecho, incluso puede sobreescribir un inicializador fallable con un inicializador no disponible. Sin embargo, no puede anular un inicializador no disponible con uno que se pueda fallar.
Es posible que haya notado que los inicializadores fallables tratan con UIView o UIViewController, que ambos proporcionan un inicializador fallable?(codificador aDecoder: NSCoder). Este inicializador se llama cuando su View o ViewController se carga desde un plumín. Es importante entender cómo funcionan los inicializadores fallables. Le recomiendo encarecidamente que lea la sección Inicializadores fallables de la guía de idiomas de Swift para obtener una explicación completa.
Inicializadores requeridos
El modificador requerido se usa para indicar que todas las subclases deben implementar el inicializador afectado. A primera vista, eso suena bastante simple y directo. A veces puede ser un poco confuso si no entiende cómo entran en juego las reglas sobre la herencia del inicializador discutidas anteriormente. Si una subclase cumple los criterios por los que se heredan los inicializadores de superclase, el conjunto de inicializadores heredados incluye los marcados como necesarios. Por lo tanto, la subclase cumple implícitamente el contrato impuesto por el modificador requerido. Implementa el inicializador(es) requerido (s), simplemente no lo ves en el código fuente.
Si una subclase proporciona una implementación explícita (es decir, no heredada) de un inicializador requerido, también está sobrescribiendo la implementación de la superclase. El modificador requerido implica anulación, por lo que no se utiliza el modificador de anulación. Puede incluirlo si lo desea, pero hacerlo sería redundante y XCode lo molestará.
La guía de idiomas de Swift no dice mucho sobre el modificador requerido, así que preparé un ejemplo de código (ver más abajo) con comentarios para explicar su propósito y describir cómo funciona. Para obtener más información, consulte este artículo de Anthony Levings.
Caso especial: Extender UIView
Una de las cosas que me llevó a profundizar en los inicializadores Swift fue mi intento de encontrar una manera de crear un conjunto de inicializadores designados sin duplicar la lógica de inicialización. Por ejemplo, estaba trabajando en este tutorial de UIView de Ray Wenderlich, convirtiendo su código Objective-C en Swift a medida que avanzaba (puede echar un vistazo a mi versión de Swift aquí). Si miras ese tutorial, verás que su subclase RateView de UIView tiene un método baseInit que ambos inicializadores designados usan para realizar tareas de inicialización comunes. Eso me parece un buen enfoque – no querrás duplicar esas cosas en cada uno de esos inicializadores. Quería recrear esa técnica en mi versión Rápida de RateView. Pero me resultó difícil porque un inicializador designado no puede delegar a otro inicializador en la misma clase y no puede llamar a métodos de su propia clase hasta después de delegar al inicializador de superclase. En ese punto, es demasiado tarde para establecer propiedades constantes. Por supuesto, podría evitar esta limitación al no usar constantes, pero esa no es una buena solución. Así que pensé que era mejor proporcionar valores predeterminados para las propiedades almacenadas donde se declaran. Esa sigue siendo la mejor solución que conozco actualmente. Sin embargo, descubrí una técnica alternativa que usa inicializadores.
Eche un vistazo al siguiente ejemplo. Este es un fragmento de RateViewWithConvenienceInits.swift, que es una versión alternativa de mi puerto Swift de RateView. Al ser una subclase de UIView que no proporciona valores predeterminados para todas sus propiedades almacenadas en la declaración, esta versión alternativa de RateView debe al menos proporcionar una implementación explícita del inicio requerido de UIView?inicializador (codificador aDecoder: NSCoder). También desearemos proporcionar una implementación explícita del init(frame) de UIView: CGRect) para asegurarse de que el proceso de inicialización sea consistente. Queremos que nuestras propiedades almacenadas se configuren de la misma manera, independientemente del inicializador que se use.
Observe que agregué el modificador de conveniencia a las versiones anuladas de los inicializadores de UIView. También agregué un inicializador designado que puede fallar a la subclase, al que delegan los dos inicializadores reemplazados (de conveniencia). Este inicializador único designado se encarga de configurar todas las propiedades almacenadas (incluidas las constantes, no tuve que recurrir al uso de var para todo). Funciona, pero creo que es bastante complicado. Preferiría simplemente proporcionar valores predeterminados para mis propiedades almacenadas donde se declaran, pero es bueno saber que esta opción existe si es necesario.