Tema 5: Programación Funcional con Swift¶
1. Introducción¶
Te recomendamos que leas el seminario de Swift en el que se introduce el lenguaje y se explica cómo ejecutar programas en este lenguaje:
1.2. Conceptos fundamentales de Programación Funcional¶
Vamos a repasar en este tema cómo se implementan en Swift conceptos principalmente funcionales como:
- Valores inmutables
- Tipos de datos recursivos
- Funciones como objetos de primera clase y clasuras
- Funciones de orden superior
Repasamos rápidamente algunos conceptos básicos de programación funcional, vistos en los primeros temas de la asignatura.
Programación Funcional:
La Programación Funcional es un paradigma de programación que trata la computación como la evaluación de funciones matemáticas y que evita cambios de estado y datos mutables.
Funciones matemáticas o puras:
Las funciones matemáticas tienen la característica de que cuando las invocas con el mismo argumento siempre te devolverán el mismo resultado.
Funciones como objetos de primera clase:
En programación funcional, las funciones son objetos de primera clase del lenguaje, similares a enteros o strings. Podemos pasar funciones como argumentos en las denominadas funciones de orden superior o devolver funciones creadas en tiempo de ejecución (clausuras).
1.3. Características básicas de Swift¶
Swift es un lenguaje principalmente imperativo, pero en su diseño se han introducido conceptos modernos de programación funcional, extraídos de lenguajes como Rust o Haskell. Por ello se puede considerar un lenguaje multi-paradigma, en el que se puede definir código funcional que se puede ejecutar junto con código imperativo.
Como dice su creador Chris Lattner:
El lenguaje Swift es el resultado de un esfuerzo incansable de un equipo de expertos en lenguajes, gurús de documentación, ninjas de optimización de compiladores [..]. Por supuesto, también se benefició enormemente de las experiencias ganadas en muchos otros lenguajes, tomando ideas de Objective-C, Rust, Haskell, Ruby, Python, C#, CLU, y demasiados otros para ser enumerados.
1.3.1. Lenguaje fuertemente tipado¶
A diferencia de Scheme, Swift es un lenguaje fuertemente tipado en el que hay que definir los tipos de variables, parámetros y funciones.
Por ejemplo, en las siguientes declaraciones definimos variables de distintos tipos:
let n: Int = 10
let str: String = "Hola"
let array: [Int] = [1,2,3,4,5]
El compilador de Swift permite identificar los tipos de las variables cuando se realiza una asignación. La técnica se denomina inferencia de tipos y permite declarar variables sin escribir su tipo. Por ejemplo, las variables anteriores se pueden declarar también asi:
let n = 10
let str = "Hola"
let array = [1,2,3,4,5]
Aunque no hayamos declarado explícitamente el tipo de las variables, el compilador les ha asignado el tipo correspondiente. Por ejemplo, no podemos asignarles un valor de distinto tipo:
var x = 5
x = 4 // correcto
x = 6.0 // error
// error: cannot assign value of type 'Double' to type 'Int'
// x = 5.0
// ^~~
// Int( )
El compilador indica el error e incluso sugiere una posible solución
del mismo. En este caso llamar al constructor Int()
pasándole un
Double
como parámetro.
1.3.2. Lenguaje multi-paradigma¶
Swift permite combinar características funcionales con características imperativas y de programación orientada a objetos. Veremos en este tema muchas características funcionales que podremos utilizar en cualquier programa Swift que desarrollemos.
Por ejemplo, cuando declaramos una variable podemos declararla como
mutable, usando la declaración var
, o como inmutable, usando la
declaración let
. Si queremos utilizar un enfoque funcional
preferiremos siempre declarar las variables con let
.
var x = 10
x = 20 // x es mutable
let y = 10
y = 20 // error: y es inmutable
Una ventaja de la inmutabilidad es que permite que el compilador de
Swift optimice el código de forma muy eficiente. De hecho, el propio
compilador nos indica que es preferible definir una variable como
let
si no la vamos a modificar:
func saluda(nombre: String) -> String {
var saludo = "Hola " + nombre + "!"
return saludo
}
//warning: variable 'saludo' was never mutated; consider changing to 'let' constant
// var saludo = "Hola " + nombre
// ~~~ ^
// let
2. Inmutabilidad¶
Una de las características funcionales importantes de Swift es el énfasis en la inmutabilidad para reforzar la seguridad del lenguaje.
Hemos visto que la palabra clave let
permite definir constantes y
que Swift recomienda su uso si el valor que definimos es un valor que
no va a ser modificado.
El valor asignado a una constante let
puede no conocerse en tiempo
de compilación, sino que puede ser obtenido en tiempo de ejecución
como un valor devuelto por una función:
let respuesta: String = respuestaUsuario.respuesta()
Al declarar una variable como let
se bloquea su contenido y no se
permite su modificación. Una de las ventajas del paradigma funcional y
de la inmutabilidad es que garantiza que el código que escribimos no
tiene efectos laterales y puede ser ejecutado sin problemas en
entornos multi-procesador o multi-hilo.
2.1. Creación de nuevas estructuras y mutación¶
En la biblioteca estándar de
Swift
existen una gran cantidad de estructuras (como Int
, Double
,
Bool
, String
, Array
, Dictionary
, etc.) que tienen dos tipos de
métodos: métodos que mutan la estructura y métodos que devuelven una
nueva estructura. Cuando estemos escribiendo código con estilo
funcional deberemos utilizar siempre estos últimos métodos, los que
construyen estructuras nuevas.
Por ejemplo, en el struct Array
se define el método sort
y el
método sorted
. El primero ordena el array con mutación y el segundo
devuelve una copia ordenada, sin modificar el array original. En el
siguiente código no se modifica el array original, sino que se
construye un array nuevo ordenado:
// Código recomendable en programación funcional
// porque utiliza el método sorted que devuelve una
// copia del array original
let miArray = [10, -1, 3, 80]
let arrayOrdenado = miArray.sorted()
print(miArray)
print(arrayOrdenado)
// Imprime:
// [10, -1, 3, 80]
// [-1, 3, 10, 80]
Este código es el recomendable cuando estemos escribiendo código con un estilo de programación funcional.
Sin embargo, el siguiente código es imperativo y utiliza la mutación del array original:
// Código no recomendable en programación funcional
// porque utiliza el método sort que muta el array original
var miArray = [10, -1, 3, 80]
miArray.sort()
print(miArray)
// Imprime:
// [-1, 3, 10, 80]
Otro ejemplo es en la forma de añadir elementos a un array. Podemos
hacerlo con un enfoque funcional, usando el operador +
que construye
un array nuevo:
// Código recomendable en programación funcional
let miArray = [10, -1, 3, 80]
let array2 = miArray + [100]
print(array2)
// Imprime:
// [10, -1, 3, 80, 100]
Y podemos hacerlo usando un enfoque imperativo, con el método
append
:
// Código no recomendable en programación funcional
var miArray = [10, -1, 3, 80]
miArray.append(100)
print(miArray)
// Imprime:
// [10, -1, 3, 80, 100]
Importante
En programación funcional debemos usar siempre los métodos que no modifican las estructuras. Así evitaremos los efectos laterales y nuestro código funcionará correctamente en entornos multi-hilo.
Cuando definimos una variable de tipo let
el valor que se
asigne a esa variable se convierte en inmutable. Si se trata de una
estructura o una clase con métodos mutables el compilador dará un
error. Por ejemplo:
let miArray = [10, -1, 3, 80]
miArray.append(100)
// error: cannot use mutating member on immutable value: 'miArray' is a 'let' constant
Otro ejemplo. El método append(_:)
de un String
es un método
mutable. Si definimos una cadena con let
no podremos modificarla y
daría error el siguiente código:
var cadenaMutable = "Hola"
let cadenaInmutable = "Adios"
cadenaMutable.append(cadenaInmutable) // cadenaMutable es "HolaAdios"
cadenaInmutable.append("Adios")
// error: cannot use mutating member on immutable value: 'cadenaInmutable' is a 'let' constant
3. Funciones¶
3.1. Definición de una función en Swift¶
Para definir una función en Swift se debe usar la palabra func
,
definir el nombre de la función, sus parámetros y el tipo de
vuelto. El valor devuelto por la función se debe devolver usando la
palabra return
.
Código de la función saluda(nombre:)
:
func saluda(nombre: String) -> String {
let saludo = "Hola, " + nombre + "!"
return saludo
}
Una característica de Swift es que para invocar a la función es necesario preceder al argumento con la etiqueta definida por el nombre del parámetro.
Por ejemplo, sería un error llamar a la función anterior de la siguiente forma:
// Error: hay que especificar la etiqueta `nombre:`
print(saluda("Ana"))
La forma correcta de llamar a la función es la siguiente:
print(saluda(nombre:"Ana"))
print(saluda(nombre:"Pedro"))
// Imprime "Hola, Ana!"
// Imprime "Hola, Pedro!"
Esta característica de Swift hace que el código sea más legible y fácil de entender, ya que podemos ver claramente cuál es el propósito de cada argumento al llamar a la función.
Por ejemplo, podemos tener también otra función similar que devuelve un saludo recibiendo el nombre y la edad de una persona:
func crearSaludo(nombre: String, edad: Int) -> String {
return "Hola, \(nombre)! Tienes \(edad) años."
}
let saludo = crearSaludo(nombre: "Carlos", edad: 25)
print(saludo)
Al llamar a la función crearSaludo
queda claro que estamos pasando
el nombre y la edad de la persona a la que queremos saludar.
3.2. Etiquetas de argumentos y nombres de parámetros¶
Es posible hacer distintos la etiqueta del argumento (nombre externo con el que hay que llamar a la función) del nombre del parámetro (nombre interno que se usa en el cuerpo de la función):
func saluda(nombre: String, de ciudad: String) -> String {
return "Hola \(nombre)! Me alegro de que hayas podido visitarnos desde \(ciudad)."
}
print(saluda(nombre: "Bill", de: "Cupertino"))
// Imprime "Hola Bill! Me alegro de que hayas podido visitarnos desde Cupertino."
En este caso el nombre externo del parámetro, el que usamos al invocar
la función, es de
y el nombre interno, el que se usa en el cuerpo de
la función, es ciudad
.
Otro ejemplo, la siguiente función concatena(palabra:con:)
:
func concatena(palabra str1: String, con str2: String) -> String {
return str1+str2
}
print(concatena(palabra:"Hola", con:"adios"))
Si no se quiere una etiqueta del argumento para un parámetro, se puede
escribir un subrayado (_
) en lugar de una etiqueta del argumento
explícita para ese parámetro. Esto nos permite llamar a la función sin
usar un nombre de parámetro. Por ejemplo, la función max(_:_:)
y la
función divide(_:entre:)
:
func max(_ x: Int, _ y: Int) -> Int {
if x > y {
return x
} else {
return y
}
}
print(max(10,3))
func divide(_ x: Double, entre y: Double) -> Double {
return x / y
}
print(divide(30, entre:4))
La firma de la función ("function signature" en inglés, también llamada "perfil" de la función) está formada por el nombre de la función, las etiquetas de los argumentos y sus tipos y el tipo devuelto por la función.
Por ejemplo, en el caso anterior, la firma de la función max
sería:
- Nombre:
max
- Lista de parámetros:
(_ x: Int, _ y: Int)
- Tipo de retorno:
Int
Y la firma de la función divide
sería:
- Nombre:
divide
- Lista de parámetros:
(_ x: Double, entre y: Double)
- Tipo de retorno:
Double
Esta firma permite al compilador y al programador identificar y diferenciar funciones con el mismo nombre pero con diferentes listas de parámetros o tipos de retorno.
En la documentación de las funciones en Swift se suele usar para
nombrarlas su nombre completo: el nombre de la propia función más el
nombre de los parámetros. Por ejemplo, las funciones anteriores se
nombran como max(_:_:)
y divide(_:entre:)
.
Como hemos dicho, los nombres de los parámetros son parte del nombre
completo de la función. Es posible definir funciones distintas con
sólo distintos nombres de parámetros, como las siguientes funciones
mitad(par:)
y mitad(impar:)
:
func mitad(par: Int) -> Int{
return par/2
}
func mitad(impar: Int) -> Int{
return (impar+1)/2
}
print(mitad(par: 8))
// Imprime 4
print(mitad(impar: 9))
// Imprime 5
3.3. Parámetros y valores devueltos¶
Es posible definir funciones sin parámetros:
func diHolaMundo() -> String {
return "hola, mundo"
}
print(diHolaMundo())
// Imprime "hola, mundo"
Podemos definir funciones sin valor devuelto. Por ejemplo, la
siguiente función diAdios(nombre:)
. No hay que escribir flecha con el
tipo devuelto. Cuidado, no sería propiamente programación funcional.
func diAdios(nombre: String) {
print("Adiós, \(nombre)!")
}
diAdios(nombre:"Dave")
// Imprime "Adiós, Dave!"
Es posible devolver múltiples valores, construyendo una tupla. Por
ejemplo, la siguiente función ecuacion(a:b:c:)
calcula las dos
soluciones de una ecuación de segundo grado:
func ecuacion(a: Double, b: Double, c: Double) -> (pos: Double, neg: Double) {
let discriminante = b*b-4*a*c
let raizPositiva = (-b + discriminante.squareRoot()) / 2*a
let raizNegativa = (-b - discriminante.squareRoot()) / 2*a
return (raizPositiva, raizNegativa)
}
Recordemos (consultar el seminario de Swift) que podemos acceder a los valores de la tupla por posición:
let resultado = ecuacion(a: 1, b: -5, c: 6)
print("Las raíces de la ecuación son \(resultado.0) y \(resultado.1)")
//Imprime "Las raíces de la ecuación son 3.0 y 2.0"
En este caso en la definición del tipo devuelto por la función estamos
etiquetando esos valores con las etiquetas pos
y neg
. De esta
forma podemos acceder a los componentes de la tupla usando esas
etiquetas definidas:
let resultado = ecuacion(a: 1, b: -5, c: 6)
print("Las raíces de la ecuación son \(resultado.pos) y \(resultado.neg)")
//Imprime "Las raíces de la ecuación son 3.0 y 2.0"
4. Recursión¶
Veamos algunos ejemplos de funciones recursivas en Swift.
Primero una función suma(hasta:)
que devuelve la suma desde 0 hasta
el número que le pasamos como parámetro.
func suma(hasta x: Int) -> Int {
if x == 0 {
return 0
} else {
return x + suma(hasta: x - 1)
}
}
print(suma(hasta: 5))
// Imprime "15"
También es posible definir recursiones que recorran arrays de una forma similar a cómo trabajábamos en Scheme. Los arrays en Swift no funcionan exactamente como las listas de Scheme (no son listas de parejas), pero podemos obtener el primer elemento y el resto de la siguiente forma.
let a = [10, 20, 30, 40, 50, 60]
let primero = a[0]
let resto = Array(a.dropFirst())
En primero
se guarda el número 10. En resto
se guarda el Array
del 20 al 60. El método dropFirst
devuelve una ArraySlice
, que es
una vista de un rango de elementos del array, en este caso el que va
desde la posición 1 hasta la 5 (la posición inicial de un array es la
0). Es necesario el constructor Array
para convertir ese
ArraySlice
en un Array
.
Usando las instrucciones anteriores podemos definir la función recursiva que suma los valores de un Array de la siguiente forma similar a cómo lo hacíamos en Scheme:
func sumaValores(_ valores: [Int]) -> Int {
if (valores.isEmpty) {
return 0
} else {
let primero = valores[0]
let resto = Array(valores.dropFirst())
return primero + sumaValores(resto)
}
}
print(sumaValores([1,2,3,4,5,6,7,8]))
// Imprime "36"
Un último ejemplo es la siguiente función minMax(array:)
que
devuelve el número más pequeño y más grande de un array de enteros:
func minMax(array: [Int]) -> (min: Int, max: Int) {
if (array.count == 1) {
return (array[0], array[0])
} else {
let primero = array[0]
let resto = Array(array.dropFirst())
// Llamada recursiva que devuelve el mínimo y el máximo del
// resto del array
let minMaxResto = minMax(array: resto)
let minimo = min(primero, minMaxResto.min)
let maximo = max(primero, minMaxResto.max)
return (minimo, maximo)
}
}
let limites = minMax(array: [8, -6, 2, 100, 3, 71])
print("El mínimo es \(limites.min) y el máximo es \(limites.max)")
// Imprime "El mímimo es -6 y el máximo es 100"
En este ejemplo nos apartamos un poco de la solución vista en Scheme
porque permitimos pasos de ejecución que inicializan variables. Pero
no nos salimos del paradigma funcional, porque todas son variables
inmutables definidas con let
.
5. Tipos función¶
En Swift las funciones son objetos de primera clase y podemos asignarlas a variables, pasarlas como parámetro o devolverlas como resultado de otra función.
El siguiente ejemplo muestra todos los posibles usos de una función como objeto de primera clase en Swift. Más adelante veremos con más detalle cada uno de los casos.
// Definimos una función simple que suma dos números
func suma(a: Int, b: Int) -> Int {
return a + b
}
// Asignamos la función suma a una variable
let miSuma = suma
// Llamamos a la función suma usando la variable
let resultado = miSuma(3, 4)
print("La suma de 3 y 4 es: \(resultado)")
// Salida: La suma de 3 y 4 es: 7
// Definimos una función que toma otra función como parámetro y la aplica a dos números
func aplicarOperacion(_ operacion: (Int, Int) -> Int, a: Int, b: Int) -> Int {
return operacion(a, b)
}
let resultadoAplicarOperacion = aplicarOperacion(suma, a: 5, b: 6)
print("La suma de 5 y 6 es: \(resultadoAplicarOperacion)")
// Salida: La suma de 5 y 6 es: 11
// Definimos una función que devuelve otra función como resultado
func obtenerOperacion() -> ((Int, Int) -> Int) {
return suma
}
let funcionObtenida = obtenerOperacion()
let resultadoFuncionObtenida = funcionObtenida(7, 8)
print("La suma de 7 y 8 es: \(resultadoFuncionObtenida)")
// Salida: La suma de 7 y 8 es: 15
Como vemos en el ejemplo anterior, el funcionamiento de los objetos función es similar al que ya hemos visto en Scheme. Pero con una diferencia importante: al ser Swift un lenguaje fuertemente tipado, debemos especificar el tipo de los parámetros o resultados de tipo función.
El tipo específico de la función está definido por el tipo de sus parámetros y el tipo del valor devuelto.
func sumaDosInts(a: Int, b: Int) -> Int {
return a + b
}
func multiplicaDosInts(a: Int, b: Int) -> Int {
return a * b
}
El tipo de estas funciones es (Int, Int) -> Int
, que se puede leer como:
"Un tipo función que tiene dos parámetros, ambos de tipo Int
y que
devuelve un valor de tipo Int
".
Como hemos visto en el primer ejemplo, podemos asignar estas funciones a una variable de tipo función:
var f = sumaDosInts
print(f(2,3))
// Imprime "5"
f = multiplicaDosInts
print(f(2,3))
// Imprime "6"
La variable f
es una variable de tipo (Int, Int) -> Int
, o sea,
una variable que contiene funciones de dos argumentos Int
que
devuelven un Int
.
Nota
Habrás notado que al invocar a f
no se ponen etiquetas en los
argumentos. De hecho, si las pusiéramos el compilador de Swift se
quejaría:
print(f(a:2, b:3))
//error: extraneous argument labels 'a:b:' in call
Esto es debido a que al ser f
una variable se le puede asignar
cualquier función que tenga el tipo (Int, Int) -> Int
sin
tener en cuenta las etiquetas de los argumentos.
5.1. Funciones que reciben otras funciones¶
Tal y como vimos en el ejemplo inicial, podemos usar un tipo función en parámetros de otras funciones:
func printResultado(funcion: (Int, Int) -> Int, _ a: Int, _ b: Int) {
print("Resultado: \(funcion(a, b))")
}
printResultado(funcion: sumaDosInts, 3, 5)
// Prints "Resultado: 8"
La función printResultado(funcion:_:_:)
toma como primer parámetro otra
función que recibe dos Int
y devuelve un Int
, y como segundo y
tercer parámetro dos Int
. Y en el cuerpo llama a la función que se
pasa como parámetro con los argumentos a
y b
.
Veamos otro ejemplo, que ya vimos en Scheme. Supongamos que queremos
calcular el sumatorio desde a
hasta b
en el que aplicamos una
función f
a cada número que sumamos:
sumatorio(a, b, f) = f(a) + f(a+1) + f(a+2) + ... + f(b)
Recordamos que se resuelve con la siguiente recursión:
sumatorio(a, b, f) = f(a) + sumatorio(a+1, b, f)
sumatorio(a, b, f) = 0 si a > b
Veamos cómo se implementa en Swift:
func sumatorio(desde a: Int, hasta b: Int, func f: (Int) -> Int) -> Int {
if a > b {
return 0
} else {
return f(a) + sumatorio(desde: a + 1, hasta: b, func: f)
}
}
func identidad(_ x: Int) -> Int {
return x
}
func doble(_ x: Int) -> Int {
return x + x
}
func cuadrado(_ x: Int) -> Int {
return x * x
}
print(sumatorio(desde: 0, hasta: 10, func: identidad)) // Imprime 55
print(sumatorio(desde: 0, hasta: 10, func: doble)) // Imprime 110
print(sumatorio(desde: 0, hasta: 10, func: cuadrado)) // Imprime 385
5.2. Funciones en estructuras¶
Como cualquier otro tipo Las funciones pueden también incluirse en estructuras de datos compuestas, como arrays:
let funciones = [identidad, doble, cuadrado]
print(funciones[0](10)) // 10
print(funciones[1](10)) // 20
print(funciones[2](10)) // 100
El tipo de la variable funciones
sería [(Int) -> Int]
.
Al ser Swift fuertemente tipado, no podríamos hacer un array con distintos tipos de funciones. Por ejemplo el siguiente código daría un error:
func suma(_ x: Int, _ y: Int) -> Int {
return x + y
}
// La siguiente línea genera un error
let misFunciones = [doble, cuadrado, suma]
// error: heterogenous collection literal could only be inferred to
// '[Any]'; add explicit type annotation if this is intentional
5.3 Funciones que devuelven otras funciones¶
Por último, veamos un ejemplo de funciones que devuelven otras funciones.
Es un ejemplo sencillo, una función que devuelve otra que suma 10:
func construyeSumador10() -> (Int) -> Int {
func suma10(x: Int) -> Int {return x+10}
return suma10
}
let g = construyeSumador10()
print(g(20))
// Imprime 30
La función devuelta por construyeSumador10()
es una función con el tipo
(Int) -> Int
(recibe un parámetro entero y devuelve un entero). En
la llamada a construyeSumador10()
se crea esa función y se asigna a la
variable g
.
Estas funciones devueltas se denominan clausuras. Más adelante hablaremos algo más de ellas. Veremos también más adelante que es posible usar expresiones de clausura que construyen clausuras anónimas.
Podemos modificar el ejemplo anterior, haciendo que la función
construyeSumador
reciba el número a sumar como parámetro:
func construyeSumador(inc: Int) -> (Int) -> Int {
func suma(x: Int) -> Int {return x+inc}
return suma
}
let f2 = construyeSumador(inc: 10)
let f3 = construyeSumador(inc: 100)
print(f2(20))
// Imprime "30"
print(f3(20))
// Imprime "120"
Invocamos dos veces a construyeSumador(inc:)
y guardamos las
clausuras construidas en las variables f2
y f3
. En f2
se guarda una
función que suma 10
a su argumento y en f3
otra que suma 100
.
6. Tipos¶
Entre las ventajas del uso de tipos está la detección de errores en los programas en tiempo de compilación o las ayudas del entorno de desarrollo para autocompletar código. Entre los inconvenientes se encuentra la necesidad de ser más estrictos a la hora de definir los parámetros y los valores devueltos por las funciones, lo que impide la flexibilidad de Scheme.
Se utilizan tipos para definir los posibles valores de:
- variables
- parámetros de funciones
- valores devueltos por funciones
Tal y como hemos visto cuando hemos comentado que Swift es fuertemente
tipado las definiciones de tipos van precedidas de dos puntos en las
variables y parámetros, o de una flecha (->
) en la definición de los
tipos de los valores devueltos por una función:
let valorDouble : Double = 3.0
let unaCadena: String = "Hola"
func calculaEstadisticas(valores: Array<Int>) -> (min: Int, max: Int, media: Int) {
...
}
En Swift existen dos clases de tipos: tipos con nombre y tipos compuestos.
6.1. Tipos con nombre¶
Un tipo con nombre es un tipo al que podemos dar un nombre determinado cuando se define. Por ejemplo, al definir un nombre de una clase o de un enumerado estamos también definiendo un nombre de un tipo.
En Swift es posible definir los siguientes tipos con nombre:
- nombres de clases
- nombres de estructuras
- nombres de enumeraciones
- nombres de protocolos
Por ejemplo, instancias de una clase definida por el usuario llamada
MiClase
tienen el tipo MiClase
.
Además de los tipos definidos por el usuario, la biblioteca estándar
de Swift tiene un gran número de tipos predefinidos. A diferencia de
otros lenguajes, estos tipos no son parte del propio lenguaje sino que
se definen en su mayoría como estructuras implementadas en esta
biblioteca estándar. Por ejemplo, arrays, diccionarios o incluso los
tipos más básicos como String
o Int
están construidos en esa
biblioteca. La implementación de estos elementos está disponible en
abierto en el sitio GitHub de
Swift.
6.2. Tipos compuestos¶
Los tipos compuestos son tipos sin nombre. En Swift se definen dos:
tuplas y tipos función. Un tipo compuesto puede tener tipos con nombre
y otros tipos compuestos. Por ejemplo la tupla (Int, (Int, Int))
contiene dos elementos: el primero es el tipo con nombre Int
y el
segundo el tipo compuesto que define la tupla (Int, Int)
. Los tipos
función los hemos visto previamente.
let tupla: (Int, Int, String) = (2, 3, "Hola")
let otraTupla: (Int, Int, String) = (5, 8, "Adios")
func sumaTupla(tupla t1: (Int, Int), con t2: (Int, Int)) -> (Int, Int) {
return (t1.0 + t2.0, t1.1 + t2.1)
}
print(sumaTupla(tupla: (tupla.0, tupla.1),
con: (otraTupla.0, otraTupla.1)))
// Imprime (7, 11)
6.2.1. Typealias¶
En Swift se define la palabra clave typealias
para darle un nombre
asignado a cualquier otro tipo. Ambos tipos son iguales a todos los
efectos (es únicamente azúcar sintáctico).
Por ejemplo, en el siguiente código definimos un typealias
llamado
Resultado
que corresponde a una tupla con dos Int
correspondientes
al resultado de un partido de futbol. Una vez definido, podemos usarlo
como un tipo. La función quiniela(partido:)
devuelve un String
correspondiente al resultado de la quiniela de un partido:
typealias Resultado = (Int, Int)
func quiniela(partido: Resultado) -> String {
switch partido {
case let (goles1, goles2) where goles1 < goles2:
return "Dos"
case let (goles1, goles2) where goles1 > goles2:
return "Uno"
default:
return "Equis"
}
}
print(quiniela(partido: (1,3)))
// Imprime "Dos"
print(quiniela(partido: (2,2)))
// Imprime "Equis"
En el ejemplo se usa una sentencia switch
que recibe el resultado
del partido. Este resultado es una tupla de dos enteros. En el case
let
se instancia los valores de esa tupla en las variables goles1
y
goles2
y después se define una condición para entrar en el caso. En
el primer caso, que goles1
sea menor que goles2
y en el segundo
que goles1
sea mayor que goles2
.
6.3. Tipos valor y tipos referencia¶
En Swift existen dos tipos de construcciones que forman la base de la programación orientada a objetos: las estructuras (structs) y las clases. En el tema siguiente hablaremos sobre ello.
En la biblioteca estándar de
Swift
la mayor parte de los tipos definidos (como Int
, Double
, Bool
,
String
, Array
, Dictionary
, etc.) son estructuras, no clases.
Una de las diferencias más importantes entre estructuras y clases es su comportamiento en una asignación: las estructuras tienen una semántica de copia (son tipos valor) y las clases tienen una semántica de referencia (son tipos referencia).
Un tipo valor es un tipo que tiene semántica de copia en las asignaciones y cuando se pasan como parámetro en llamadas a funciones.
Los tipos valor son muy útiles porque evitan los efectos laterales en los programas y simplifican el comportamiento del compilador en la gestión de memoria. Al no existir referencias, se simplifica enormemente la gestión de memoria de estas estructuras. No es necesario llevar la cuenta de qué referencias apuntan a un determinado valor, sino que se puede liberar la memoria en cuanto se elimina el ámbito actual.
Frente a un tipo valor, un tipo de referencia es aquel en los que los valores se asignan a variables con una semántica de referencia. Cuando se realizan varias asignaciones de una misma instancia a distintas variables todas ellas guardan una referencia a la misma instancia. Si la instancia se modifica, todas las variables reflejarán el nuevo valor. Cuando veamos las clases en el próximo tema veremos algunos ejemplos.
Veamos ahora algunos ejemplos de copia por valor en estructuras.
Por ejemplo, si asignamos una cadena a otra, se realiza una copia:
var str1 = "Hola"
var str2 = str1
str1.append("Adios")
print(str1) // Imprime "HolaAdios"
print(str2) // Imprime "Hola"
Los arrays también son estructuras y, por tanto, también tienen semántica de copia:
var array1 = [1, 2, 3, 4]
var array2 = array1
array1[0] = 10
print(array1) // [10, 2, 3, 4]
print(array2) // [1, 2, 3, 4]
A diferencia de otros lenguajes como Java, los parámetros de una función siempre son inmutables y se pasan por copia, para reforzar el carácter funcional de las funciones. Por ejemplo, es incorrecto escribir lo siguiente:
func ponCero(array: [Int], pos: Int) {
array[pos] = 0
// error: cannot assign through subscript: 'array' is a 'let' constant
}
Se podría pensar que es muy costoso copiar un array entero. Por ejemplo, si asignamos o pasamos como parámetro un array de 1000 elementos. Pero no es así. El compilador de Swift optimiza estas sentencias y sólo realiza la copia en el momento en que hay una modificación de una de las variables que comparten el array. Es lo que se llama copy on write.
7. Enumeraciones¶
Las enumeraciones definen un tipo con un valor restringido de posibles valores:
enum Direccion {
case norte
case sur
case este
case oeste
}
Cualquier variable del tipo Direccion
solo puede tener uno de los
cuatro valores definidos. Se obtiene el valor escribiendo el nombre de
la enumeración, un punto y el valor definido. Si el tipo de
enumeración se puede inferir no es necesario escribirlo.
let hemosGirado = true
var direccionActual = Direccion.norte
if hemosGirado {
direccionActual = .sur
}
En sentencias switch:
let direccionAIr = Direccion.sur
switch direccionAIr {
case .norte:
print("Nos vamos al norte")
case .sur:
print("Cuidado con los pinguinos")
case .este:
print("Donde nace el sol")
case .oeste:
print("Donde el cielo es azul")
}
// Imprime "Cuidado con los pinguinos"
Otro ejemplo:
enum Planeta {
case mercurio, venus, tierra, marte, jupiter, saturno, urano, neptuno
}
Y, por último, es más correcto definir el resultado de una quiniela con un
enumerado en lugar de con un String
:
enum Quiniela {
case uno, equis, dos
}
7.1. Valores brutos de enumeraciones¶
Es posible asignar a las constantes del enumerado un valor concreto de un tipo subyacente, por ejemplo enteros:
enum Quiniela: Int {
case uno=1, equis=0, dos=2
}
Se puede obtener el valor bruto a partir del propio tipo o de una
variable del tipo, usando rawValue
:
// Obtenemos el valor bruto a partir del tipo
let valorEquis: Int = Quiniela.equis.rawValue
// Obtenemos el valor bruto a partir de una variable
let res = Quiniela.equis
let valorEquis = res.rawValue
También se puede asignar los valores de forma implícita, dando un valor a la primera constante. Las siguientes tienen el valor consecutivo:
enum Planeta: Int {
case mercurio=1, venus, tierra, marte, jupiter, saturno, urano, neptuno
}
let posicionTierra = Planeta.tierra.rawValue
// posicionTierra es 3
Podemos escoger cualquier tipo subyacente. Por ejemplo el tipo Character
:
enum CaracterControlASCII: Character {
case tab = "\t"
case lineFeed = "\n"
case carriageReturn = "\r"
}
El carácter nueva línea (lineFeed) se puede obtener de la siguiente forma:
let nuevaLinea = CaracterControlASCII.LineFeed.rawValue
Y por último, se puede definir como tipo subyacente String
y los
valores brutos de las constantes serán sus nombres convertidos a
cadenas:
enum Direccion: String {
case norte, sur, este, oeste
}
let direccionAtardecer = Direccion.oeste.rawValue
// direccionAtardecer es "oeste"
En este caso, también se puede inicializar el valor bruto con una asignación explícita y no usar el propio nombre:
enum Direccion: String {
case norte = "north"
case sur = "south"
case este = "east"
case oeste = "west"
}
let direccionAtardecer = Direccion.oeste.rawValue
// direccionAtardecer es "west"
Cuando se definen valores brutos es posible inicializar el enumerado
de una forma similar a una estructura o una clase pasando el valor
bruto. Devuelve el valor enumerado correspondiente o nil
(un
opcional):
let posiblePlaneta = Planeta(rawValue: 7)
// posiblePlaneta es de tipo Planeta? y es igual a Planeta.urano
8. Enumeraciones instanciables¶
Una característica singular de las enumeraciones en Swift es que permiten definir valores variables asociados a cada caso de la enumeración, creando algo muy parecido a una instancia de la enumeración.
8.1. Valores asociados a instancias de enumeraciones¶
Un enumerado instanciable permite asociar valores a la instancia del enumerado. Para crear una instancia del enumerado debemos proporcionar el valor asociado.
Al igual que un enumerado normal, el enumerado puede especificar distintos casos. Cada caso puede determinar un tipo de valor asociado.
En otros lenguajes de programación se llaman uniones etiquetadas o variantes.
Por ejemplo, podemos definir un enumerado que permita guardar un
Int
o un String
:
enum Multiple {
case num(Int)
case str(String)
}
De esta forma, podemos crear valores de tipo Multiple
que contienen
un Int
(instanciando el caso num
) o un String
(instanciando el
caso str
):
let valor3 = Multiple.num(10)
let valor4 = Multiple.str("Hola")
Para obtener el valor asociado debemos usar una expresión case let
en una sentencia switch
con una variable a la que se asigna el
valor. Por ejemplo, la siguiente función reciba instancias de tipo
Multiple
e imprime el valor asociado al enumerado que se pasa como
parámetro.
func imprime(multiple: Multiple) {
switch multiple {
case let .num(x):
print("Multiple tiene un Int: \(x)")
case let .str(s):
print("Multiple tiene un String: \(s)")
}
}
imprime(multiple: valor3)
// Imprime "Multiple tiene un Int: 10"
imprime(multiple: valor4)
// Imprime "Multiple tiene un String: Hola
Nota
No hay que confundir un valor asociado a un caso y un valor bruto: el valor bruto de un caso de enumeración es el mismo para todas las instancias, mientras que el valor asociado es distinto y se proporciona cuando se define el valor concreto de la enumeración.
El tipo del caso también puede ser un tipo compuesto, como una tupla. Usamos un enum para definir posibles valores de un código de barras, en el que incluimos dos posibles tipos de código de barras: el código de barras lineal (denominado UPC) y el código QR:
enum CodigoBarras {
case upc(Int, Int, Int, Int)
case qrCode(String)
}
Se lee de la siguiente forma: “Definimos un tipo enumerado llamado
CodigoBarras
, que puede tomar como valor un upc
(código de barras
lineal) con un valor asociado de tipo (Int, Int, Int, Int)
(una
tupla de 4 enteros que representan los 4 números que hay en los
códigos de barras lineales) o un valor qrCode
con valor asociado de
tipo String
".
Veamos un ejemplo de uso, en el que creamos un código de barras de producto de tipo UPC, después lo modificamos a otro de tipo código QR y por último lo imprimimos:
var codigoBarrasProducto = CodigoBarras.upc(8, 85909, 51226, 3)
codigoBarrasProducto = .qrCode("ABCDEFGHIJKLMNOP")
switch codigoBarrasProducto {
case let .upc(sistemaNumeracion, fabricante, producto, control):
print("UPC: \(sistemaNumeracion), \(fabricante), \(producto), \(control).")
case let .qrCode(codigoProducto):
print("Código QR: \(codigoProducto).")
}
// Imprime "Código QR : ABCDEFGHIJKLMNOP."
8.2. Enumeraciones recursivas¶
Es posible combinar las características de las enumeraciones con valor
con la recursión para crear enumeraciones recursivas. Hay que preceder
la palabra clave enum
con indirect
:
indirect enum ExpresionAritmetica {
case numero(Int)
case suma(ExpresionAritmetica, ExpresionAritmetica)
case multiplicacion(ExpresionAritmetica, ExpresionAritmetica)
}
let cinco = ExpresionAritmetica.numero(5)
let cuatro = ExpresionAritmetica.numero(4)
let suma = ExpresionAritmetica.suma(cinco, cuatro)
let producto = ExpresionAritmetica.multiplicacion(suma, ExpresionAritmetica.numero(2))
Es muy cómodo manejar enumeraciones recursivas de forma recursiva:
func evalua(expresion: ExpresionAritmetica) -> Int {
switch expresion {
case let .numero(valor):
return valor
case let .suma(izquierda, derecha):
return evalua(expresion: izquierda) + evalua(expresion: derecha)
case let .multiplicacion(izquierda, derecha):
return evalua(expresion: izquierda) * evalua(expresion: derecha)
}
}
print(evalua(expresion: producto))
// Imprime 18
Otro ejemplo de enums recursivos, para definir un tipo de datos
Lista
similar al que vimos en Scheme. La lista puede ser una lista
vacía o puede contener dos elementos: un valor Int
y otra lista:
indirect enum Lista {
case vacia
case nodo(Int, Lista)
}
Para crear una lista de tipo nodo
deberemos dar un valor entero (el
valor de la cabeza de la lista) y otra lista (el resto de la
lista). También podemos crear una lista vacía.
Por ejemplo, podemos crear la lista (10, 20, 30)
de la siguiente
manera:
let lista1 = Lista.nodo(30, Lista.vacia)
let lista2 = Lista.nodo(20, lista1)
let lista3 = Lista.nodo(10, lista2)
Podríamos crear esta misma lista de una forma más abreviada:
let lista: Lista = .nodo(10, .nodo(20, .nodo(30, .vacia)))
Una vez definido el tipo enumerado, podemos definir funciones que trabajen con él. La siguiente función, por ejemplo, es una función recursiva que recibe una lista y devuelve la suma de sus elementos. Funciona de una forma muy similar a la definición que hicimos en Scheme:
func suma(lista: Lista) -> Int {
switch lista {
case .vacia:
return 0
case let .nodo(first, rest):
return first + suma(lista: rest)
}
}
let z: Lista = .nodo(20, .nodo(10, .vacia))
print(suma(lista: z))
// Imprime 30
Podemos también definir una función recursiva construye(lista:[Int])
que devuelve una lista a partir de una array de enteros:
func construye(lista: [Int]) -> Lista {
if (lista.isEmpty) {
return Lista.vacia
} else {
let primero = lista[0]
let resto = Array(lista.dropFirst())
return Lista.nodo(primero, construye(lista: resto))
}
}
let lista2 = construye(lista: [1,2,3,4,5])
print(suma(lista: lista2))
// Imprime 15
13. Bibliografía¶
- Swift Language Guide
- Biblioteca estándar de Swift
Lenguajes y Paradigmas de Programación, curso 2024–25
© Departamento Ciencia de la Computación e Inteligencia Artificial, Universidad de Alicante
Domingo Gallardo, Cristina Pomares, Antonio Botía, Francisco Martínez