Saltar a contenido

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


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