Protocolos como contrato: requisitos y conformidad
Un protocolo declara qué debe ofrecer un tipo sin decidir qué es. Anatomía de los requisitos, verificación de la conformidad en compilación, conformidad retroactiva y por qué un contrato horizontal no es una jerarquía vertical.
Una clase base dice qué es un tipo; un protocolo dice qué puede hacer. La distinción parece retórica hasta que la observas desde el compilador: heredar inyecta almacenamiento, inicializadores y una tabla de métodos en la identidad misma del subtipo, mientras que conformar a un protocolo solo obliga a demostrar una lista de capacidades. Un struct, un enum y una class pueden firmar el mismo contrato sin compartir un antepasado. Esta lección disecciona ese contrato: qué puede exigir, cómo se cumple y en qué se separa, técnicamente y no solo por gusto de diseño, de la herencia.
- Enumerar los tipos de requisito que un
protocolpuede declarar. - Explicar cómo verifica el compilador la conformidad y qué sintetiza por ti.
- Añadir conformidad retroactiva a un tipo ajeno mediante
extension. - Contrastar contrato y herencia en identidad, almacenamiento e inicialización.
Qué puede exigir un protocolo
Un protocolo es una lista de requisitos sin cuerpo. La superficie es más amplia de lo que sugiere el ejemplo habitual de un solo método:
protocol Sensor {
var identificador: String { get } // propiedad de solo lectura
var lectura: Double { get set } // propiedad legible y escribible
static var unidad: String { get } // requisito de tipo, no de instancia
init(identificador: String) // requisito de inicializador
func calibrar() throws // metodo que puede lanzar
mutating func reiniciar() // permite mutar un value type
}
Cada línea merece lectura atenta. { get } no significa “propiedad calculada”: significa que el conformante debe ofrecer lectura, y puede satisfacerlo con una let, con una var o con una propiedad calculada. { get set }, en cambio, prohíbe la constante. El requisito static obliga al tipo, no a cada instancia. Y mutating es la concesión que Swift hace a los tipos por valor: si el protocolo no lo marca, un struct no podrá modificar sus propios campos al implementarlo, porque el contrato prometía no mutar.
El requisito de inicializador es el más sutil. Prometer un init significa que cualquier código genérico podrá construir un valor del tipo conformante partiendo solo del protocolo, sin conocer el tipo concreto. Esa capacidad es lo que separa un protocolo que consume valores de uno que también los fabrica.
Conformar es una promesa verificada
La conformidad no es documentación ni convención: es una comprobación del compilador. Si falta un requisito, el programa no compila.
struct Termometro: Sensor {
let identificador: String
var lectura: Double = 0
static let unidad = "C"
init(identificador: String) {
self.identificador = identificador
}
func calibrar() throws { /* ajuste del offset */ }
mutating func reiniciar() { lectura = 0 }
}
Nótese que identificador se cumple con un let, que unidad se cumple con un static let aunque el protocolo pidiera var, y que reiniciar puede mutar porque el contrato lo autorizaba. La regla general es que el conformante puede ser más estricto en lo que ofrece, nunca menos.
Un enum puede firmar el mismo contrato sin parecerse en nada al struct anterior:
enum SensorSimulado: Sensor {
case constante(String)
var identificador: String {
if case .constante(let id) = self { return id }
return "desconocido"
}
var lectura: Double {
get { 21.5 }
set { }
}
static let unidad = "C"
init(identificador: String) { self = .constante(identificador) }
func calibrar() throws { }
mutating func reiniciar() { }
}
Las clases arrastran una complicación propia. Un requisito de inicializador debe implementarse con required init, porque la promesa se hereda: toda subclase tiene que poder construirse igual. Si la clase es final, la palabra sobra, ya que no habrá subclases que la incumplan.
Conviene señalar el límite de la verificación. El compilador comprueba firmas, no significado. Si tu protocolo espera que calibrar deje la lectura en cero, o que dos valores iguales devuelvan el mismo hashValue, nada impide conformar con una implementación que incumpla esa promesa. Los requisitos semánticos viven en la documentación y en los tests; el contrato mecanizado cubre solo la superficie.
Cuando una clase conforma a un protocolo, sus subclases heredan la conformidad y no pueden renunciar a ella. Peor: una subclase puede sobreescribir un método requerido y romper la semántica que el protocolo prometía, sin que el compilador diga nada. En un struct este riesgo no existe, porque no hay subtipos que reinterpreten el contrato. Es una de las razones por las que la biblioteca estándar marca final con generosidad.
Conformidad retroactiva y síntesis
Un protocolo puede aplicarse a un tipo que ya existe, incluso a uno que no escribiste tú. Se llama conformidad retroactiva y es la forma de integrar código ajeno en tus abstracciones.
extension String: Sensor {
var identificador: String { self }
var lectura: Double {
get { Double(count) }
set { }
}
static var unidad: String { "caracteres" }
init(identificador: String) { self = identificador }
func calibrar() throws { }
mutating func reiniciar() { self = "" }
}
El ejemplo es deliberadamente absurdo para dejar clara la mecánica: nada te impide declarar la conformidad desde fuera del tipo. Lo que sí conviene recordar es la regla de higiene: conformar un tipo ajeno a un protocolo ajeno desde tu módulo genera un conflicto potencial si la biblioteca original añade esa conformidad más tarde.
En sentido inverso, el compilador colabora. Para Equatable, Hashable, Codable y CaseIterable, basta declarar la conformidad y Swift sintetiza la implementación a partir de la estructura del tipo, siempre que todos sus miembros ya conformen.
struct Medicion: Equatable, Hashable, Codable {
let sensor: String
let valor: Double
}
flowchart TB p[protocolo Sensor lista de requisitos] --> s[struct conforma] p --> e[enum conforma] p --> c[class conforma] s --> v[el compilador verifica requisito por requisito] e --> v c --> v v --> ok[el valor sirve donde se exija Sensor] v --> err[si falta uno el programa no compila]
Contrato frente a herencia
Identidad
Heredar cambia lo que un tipo es y le asigna un lugar en un árbol. Conformar solo añade una capacidad; un tipo puede firmar veinte contratos sin cambiar de naturaleza.
Almacenamiento
Una superclase aporta propiedades almacenadas que el subtipo hereda. Un protocolo no puede declarar almacenamiento: exige acceso, no memoria.
Inicialización
La herencia impone reglas de cadena de inicializadores designados y de conveniencia. El protocolo solo pide una firma y deja la construcción al conformante.
Alcance
La herencia de clases es exclusiva de las clases. Un protocolo alcanza a struct, enum, class y actor por igual.
Lo verdaderamente profundo de un protocolo no es que evite jerarquías, sino que traza una frontera de conocimiento entre quien escribe el algoritmo y quien aporta el tipo. La herencia comparte implementación hacia abajo: la subclase conoce, usa y depende de los detalles internos de su padre, de modo que cada cambio en la clase base se propaga por un árbol que el autor original quizá ni pueda enumerar. Es el acoplamiento más caro que ofrece un lenguaje, y tiene nombre desde hace décadas en la literatura: el problema de la clase base frágil. Un protocolo invierte la dirección de la dependencia. El algoritmo genérico no conoce ningún tipo concreto: conoce una lista de capacidades y nada más, así que no puede depender de nada que no esté escrito en el contrato. El tipo conformante, a su vez, no hereda ni un byte de estado ajeno. Ambos lados dependen de la abstracción y ninguno del otro, que es exactamente la formulación del principio de inversión de dependencias, solo que aquí el compilador la verifica en lugar de confiar en la disciplina del equipo. La consecuencia práctica es que la superficie de acoplamiento de tu sistema deja de ser difusa y se vuelve enumerable: cabe en la declaración del protocol, y todo lo que no aparezca ahí es, por construcción, información que ninguna de las dos partes puede filtrar a la otra.
Un protocolo declara requisitos —propiedades, métodos, inicializadores, miembros static y permisos mutating— sin aportar almacenamiento. El compilador verifica cada uno al conformar y sintetiza los de Equatable, Hashable y Codable. La conformidad puede añadirse retroactivamente. Frente a la herencia, el contrato no toca la identidad del tipo, no impone cadenas de inicialización y funciona igual en tipos por valor y por referencia.
- Escribe un protocolo
Almacencon una propiedad{ get }, una{ get set }, un miembrostatic, uninity un métodomutating. - Conforma un
structy unenumal mismo protocolo; comprueba qué cambia en cada implementación. - Intenta cumplir el requisito
{ get set }con unlety lee con atención el error del compilador. - Conforma
Intretroactivamente a un protocolo tuyo con unaextension. - Declara un
structcomoHashablesin escribir ningún método y verifica que puedes meterlo en unSet.